14 Commits

Author SHA1 Message Date
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
28 changed files with 1121 additions and 161 deletions

View File

@@ -8,9 +8,13 @@ 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/ {

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,34 @@ http {
# Убираем токены версии nginx для безопасности # Убираем токены версии nginx для безопасности
server_tokens off; server_tokens off;
# --- ЗАЩИТА ОТ БОЛЬШИХ ЗАПРОСОВ ---
# Ограничиваем максимальный размер тела запроса (например, 1MB)
client_max_body_size 1M;
# --- КАСТОМНЫЕ СТРАНИЦЫ ОШИБОК ---
error_page 500 /500.html;
error_page 502 /502.html;
error_page 503 /503.html;
error_page 504 /504.html;
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;
}
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,43 @@ 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 &&
gunicorn --bind 0.0.0.0:8000 --chdir /app/etpgrf_site etpgrf_site.wsgi"
volumes: volumes:
# База данных (папка data должна быть создана на хосте) # База данных (папка data должна быть создана на хосте)
@@ -36,11 +74,6 @@ 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
@@ -72,12 +105,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.1.3"
__author__ = "Sergei Erjemin"
__email__ = "erjemin@gmail.com"
__license__ = "MIT"
__copyright__ = "Copyright 2026 Sergei Erjemin"

View File

@@ -7,11 +7,6 @@ from django.contrib.staticfiles.storage import staticfiles_storage
urlpatterns = [ urlpatterns = [
path(route='adm-in/', view=admin.site.urls), path(route='adm-in/', view=admin.site.urls),
path(
route="favicon.ico",
view=RedirectView.as_view(url=staticfiles_storage.url("favicon.ico")),
name="favicon",
),
path(route='', view=include('typograph.urls')), path(route='', view=include('typograph.urls')),
] ]

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,71 +1,80 @@
{% 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">
{# --- SEO & Meta Tags --- #} {# --- SEO & Meta Tags --- #}
<title>{% block title %}ETPGRF — единая типографика для веба{% endblock %}</title> <title>{% block title %}ETPGRF — единая типографика для веба{% endblock %}</title>
<meta name="description" content="{% block description %}Бесплатный онлайн-типограф для подготовки текстов к публикации в вебе. Расставка неразрывных пробелов, правильных кавычек («ёлочки»), тире, спецсимволы, отбивка, компоновка, висячая пунктуация. Идеально для верстки сайтов, статей и постов.{% endblock %}"> <meta name="description" content="{% block description %}Бесплатный онлайн-типограф для подготовки текстов к публикации в вебе. Расставка неразрывных пробелов, правильных кавычек («ёлочки»), тире, спецсимволы, отбивка, компоновка, висячая пунктуация. Идеально для верстки сайтов, статей и постов.{% endblock %}">
<meta name="keywords" content="типограф, типографика, онлайн типограф, подготовка текста для веба, html типограф, неразрывные пробелы, кавычки елочки, длинное тире, очистка текста от мусора, интернет верстка, муравьев"> <meta name="keywords" content="типограф, типографика, онлайн типограф, подготовка текста для веба, html типограф, неразрывные пробелы, кавычки елочки, длинное тире, очистка текста от мусора, интернет верстка, муравьев">
<meta name="author" content="Sergei Erjemin"> <meta name="author" content="Sergei Erjemin">
{# --- Open Graph (Facebook, VK, LinkedIn, Telegram) --- #} {# --- Open Graph (Facebook, VK, LinkedIn, Telegram) --- #}
<meta property="og:type" content="website"> <meta property="og:type" content="website" />
<meta property="og:site_name" content="ETPGRF"> <meta property="og:site_name" content="ETPGRF" />
<meta property="og:url" content="{{ request.build_absolute_uri }}"> <meta property="og:url" content="{{ request.build_absolute_uri }}" />
<meta property="og:title" content="{% block og_title %}ETPGRF — единая типографика для веба{% endblock %}"> <meta property="og:title" content="{% block og_title %}ETPGRF — единая типографика для веба{% endblock %}" />
<meta property="og:description" content="{% block og_description %}Сделайте ваш текст профессиональным и готовым к публикации в интернете за один клик. Умная типографика для веб-дизайнеров, редакторов и контент-менеджеров.{% endblock %}"> <meta property="og:description" content="{% block og_description %}Сделайте ваш текст профессиональным и готовым к публикации в интернете за один клик. Умная типографика для веб-дизайнеров, редакторов и контент-менеджеров.{% endblock %}" />
{# Картинка должна быть абсолютной ссылкой #} <meta property="og:image" content="{% block og_image %}{{ request.scheme }}://{{ request.get_host }}{% static 'img/etpgrf-logo-for-fb-vk-x.gif' %}{% endblock %}" />
<meta property="og:image" content="{% block og_image %}{{ request.scheme }}://{{ request.get_host }}{% static 'img/etpgrf-logo-for-fb-vk-x.gif' %}{% endblock %}"> <meta property="og:image:width" content="1200" />
<meta property="og:image:width" content="1200"> <meta property="og:image:height" content="630" />
<meta property="og:image:height" content="630">
{# --- Twitter Cards (X) --- #} {# --- Twitter Cards (X) --- #}
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{% block twitter_title %}ETPGRF — единая типографика для веба{% endblock %}"> <meta name="twitter:title" content="{% block twitter_title %}ETPGRF — единая типографика для веба{% endblock %}" />
<meta name="twitter:description" content="{% block twitter_description %}Сделайте ваш текст профессиональным и готовым к публикации в интернете за один клик.{% 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 %}"> <meta name="twitter:image" content="{% block twitter_image %}{{ request.scheme }}://{{ request.get_host }}{% static 'img/etpgrf-logo-for-fb-vk-x.gif' %}{% endblock %}" />
{# --- Favicons --- #} {# --- Favicons --- #}
<link rel="icon" type="image/png" href="{% static 'favicon-96x96.png' %}" sizes="96x96" /> <link rel="icon" href="{{ request.scheme }}://{{ request.get_host }}{% static 'favicon.ico' %}" type="image/x-icon" />
<link rel="icon" href="{% static 'favicon-light.svg' %}" type="image/svg+xml" media="(prefers-color-scheme: light)"> <link rel="icon" type="image/png" href="{% static 'favicon-96x96.png' %}" />
<link rel="icon" href="{% static 'favicon-dark.svg' %}" type="image/svg+xml" media="(prefers-color-scheme: dark)"> <link rel="icon" href="{% static 'favicon-light.svg' %}" type="image/svg+xml" media="(prefers-color-scheme: light)" />
<link rel="icon" type="image/svg+xml" href="{% static 'favicon.svg' %}" /> <link rel="icon" href="{% static 'favicon-dark.svg' %}" type="image/svg+xml" media="(prefers-color-scheme: dark)" />
<link rel="shortcut icon" href="{% static 'favicon.ico' %}" sizes="any" /> <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="apple-touch-icon" sizes="180x180" href="{% static 'apple-touch-icon.png' %}" />
<link rel="manifest" href="{% static 'site.webmanifest' %}" /> <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 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" /> {# Bootstrap Icons #}<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"/>
{# Custom CSS #}<link href="{% static 'css/etpgrf.css' %}" rel="stylesheet" /> <style>
{# HTMX #}<script src="https://unpkg.com/htmx.org@1.9.10"></script> :root {
{# Alpine.js #}<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> --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>
</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>v0.1.3 / v0.1.6
<span class="text-muted small float-end">v0.1.2</span> </nobr>
{# Сводная статистика (HTMX) #}<span class="text-muted small ms-2" hx-get="{% url 'stats_summary' %}" hx-trigger="load">
...
</span>
</div> </div>
</footer> </footer>
@@ -83,13 +92,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

@@ -269,14 +269,17 @@
</form> </form>
</div> </div>
<div class="col-md-12 mt-4"> <div class="col-md-12 my-4">
<div class="d-flex justify-content-between align-items-end mb-2"> <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"> <label class="form-label fw-bold small text-muted ls-1 mb-0">
<i class="bi bi-code-slash me-1"></i> Результат обработки: <i class="bi bi-code-slash me-1"></i> Результат обработки:
</label> </label>
<button id="btn-copy" class="btn btn-sm btn-outline-secondary d-none" title="Копировать в буфер обмена"> <div class="d-flex align-items-center">
<i class="bi bi-clipboard me-1"></i> Копировать в&nbsp;буфер обмена <span id="processing-time" class="small text-muted me-3 nowrap"></span>
</button> <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>
<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>

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,45 @@
from django import template
from django.utils.safestring import mark_safe
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

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)

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

@@ -48,40 +48,52 @@ body {
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 {
padding: 0; /* Убираем отступы у бренда */ background: no-repeat left;
background-size: contain;
} }
/* Стили для скролла */ #main-navbar > .container.logo-big {
.navbar-scrolled { background-image: var(--bg-image-text);
height: 55px; transition: .4s ease;
} }
/* Логотип */ #main-navbar > .container:not(.logo-big) {
.logo-img { background-image: none;
width: 70%; transition: .4s ease;
margin-left: -3%; /* Немного сдвигаем влево, чтобы буквы ETPGRF логотипа выровнять */
height: 151px; /* Ограничиваем высоту */
object-fit: contain; /* Вписываем, сохраняя пропорции */
} }
/* Уменьшаем логотип при скролле */ #main-navbar > #logo > .navbar-brand {
.navbar-scrolled .logo-img { display: block; /* Блок, чтобы работали размеры */
height: 78px; /* Компактная высота */ background: no-repeat left;
margin-left: -5%; background-size: contain;
margin-left: -1.5%;
}
#main-navbar > #logo.logo-big > .navbar-brand {
height: 105px;
width: 500px;
opacity: 1;
transition: .4s ease;
}
#main-navbar > #logo:not(.logo-big) > .navbar-brand {
height: 60px;
width: 285px;
transition: .4s ease;
opacity: 0;
} }
/* Контент растягивается, чтобы прижать футер */ /* Контент растягивается, чтобы прижать футер */

View File

@@ -7,38 +7,33 @@
function updateTheme(e) { function updateTheme(e) {
const theme = e.matches ? 'dark' : 'light'; const theme = e.matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-bs-theme', theme); document.documentElement.setAttribute('data-bs-theme', theme);
// При смене темы обновляем и логотип
updateLogo();
} }
// Установить при загрузке
updateTheme(darkModeMediaQuery);
// Слушать изменения
darkModeMediaQuery.addEventListener('change', updateTheme);
// --- ЛОГОТИП И СКРОЛЛ --- // --- ЛОГОТИП И СКРОЛЛ ---
function updateLogo() { function updateLogo() {
const logoImg = document.getElementById('logo-img'); const navbar = document.getElementById('logo');
const navbar = document.getElementById('main-navbar'); if (!navbar) return;
const scrollY = window.scrollY;
if (!logoImg || !navbar) return; // Гистерезис: включаем после 60px, выключаем до 10px
// Это предотвращает дребезг на границе
const isDark = darkModeMediaQuery.matches; if (scrollY > 60) {
// Используем window.scrollY для определения прокрутки navbar.classList.remove('logo-big');
// Если прокрутили больше 50px, уменьшаем шапку } else if (scrollY < 10) {
const isScrolled = window.scrollY > 50; navbar.classList.add('logo-big');
if (isScrolled) {
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);
darkModeMediaQuery.addEventListener('change', updateTheme);
// Инициализация логотипа при загрузке и скролле // Инициализация логотипа при загрузке и скролле
document.addEventListener('DOMContentLoaded', updateLogo); // document.addEventListener('DOMContentLoaded', updateLogo);
window.addEventListener('scroll', updateLogo); window.addEventListener('scroll', updateLogo, { passive: true });
// --- КУКИ И СЧЕТЧИКИ --- // --- КУКИ И СЧЕТЧИКИ ---

View File

@@ -21,8 +21,7 @@ import {
const resultWrapper = document.getElementById('cm-result-wrapper'); const resultWrapper = document.getElementById('cm-result-wrapper');
const btnCopy = document.getElementById('btn-copy'); const btnCopy = document.getElementById('btn-copy');
const sourceTextarea = document.querySelector('textarea[name="text"]'); const sourceTextarea = document.querySelector('textarea[name="text"]');
const processingTimeSpan = document.getElementById('processing-time');
// console.log("Index.js loaded. btnCopy:", !!btnCopy, "sourceTextarea:", !!sourceTextarea); // DEBUG
const themeCompartment = new Compartment(); const themeCompartment = new Compartment();
function getTheme() { function getTheme() {
@@ -90,7 +89,6 @@ const resultView = new EditorView({
// Обработка ответа от сервера (HTMX) // Обработка ответа от сервера (HTMX)
document.body.addEventListener('htmx:afterSwap', function (evt) { document.body.addEventListener('htmx:afterSwap', function (evt) {
// console.log("HTMX afterSwap event:", evt.detail.target.id); // DEBUG
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;
@@ -101,9 +99,19 @@ document.body.addEventListener('htmx:afterSwap', function (evt) {
// Показываем кнопку копирования // Показываем кнопку копирования
if (btnCopy) { if (btnCopy) {
// console.log("Showing copy button"); // DEBUG
btnCopy.classList.remove('d-none'); 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';
}
}
} }
}); });
@@ -113,6 +121,9 @@ if (sourceTextarea) {
if (btnCopy) { if (btnCopy) {
btnCopy.classList.add('d-none'); btnCopy.classList.add('d-none');
} }
if (processingTimeSpan) {
processingTimeSpan.innerText = '';
}
// Сбрасываем редактор на плейсхолдер // Сбрасываем редактор на плейсхолдер
if (resultView.state.doc.toString() !== PLACEHOLDER_TEXT) { if (resultView.state.doc.toString() !== PLACEHOLDER_TEXT) {
resultView.dispatch({ resultView.dispatch({
@@ -132,13 +143,24 @@ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', ()
if (btnCopy) { if (btnCopy) {
btnCopy.addEventListener('click', async () => { btnCopy.addEventListener('click', async () => {
const text = resultView.state.doc.toString(); const text = resultView.state.doc.toString();
// console.log("Copying text:", text.substring(0, 20) + "..."); // DEBUG
// Отправляем цель в метрику // Отправляем цель в метрику
if (typeof window.sendGoal === 'function') { if (typeof window.sendGoal === 'function') {
window.sendGoal('etpgrf-copy-pressed'); 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 { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);

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