8 Commits

17 changed files with 570 additions and 137 deletions

View File

@@ -59,6 +59,15 @@ http {
# Убираем токены версии nginx для безопасности # Убираем токены версии nginx для безопасности
server_tokens off; server_tokens off;
# Прямая раздача favicon.ico (для поисковиков и браузеров)
# Это быстрее и надежнее, чем редирект через Django
location = /favicon.ico {
alias /app/public/static_collected/favicon.ico;
access_log off;
log_not_found off;
expires 30d;
}
location / { location / {
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;

View File

@@ -12,8 +12,8 @@ services:
labels: labels:
- "com.centurylinklabs.watchtower.scope=etpgrf" - "com.centurylinklabs.watchtower.scope=etpgrf"
# Запускаем collectstatic перед стартом # Запускаем миграции, потом 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" command: sh -c "python etpgrf_site/manage.py migrate --noinput && python etpgrf_site/manage.py collectstatic --noinput && gunicorn --bind 0.0.0.0:8000 --chdir /app/etpgrf_site etpgrf_site.wsgi"
volumes: volumes:
# База данных (папка data должна быть создана на хосте) # База данных (папка data должна быть создана на хосте)
@@ -72,7 +72,6 @@ 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 сам понимает из имени образа)

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

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

@@ -3,20 +3,43 @@
<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 --- #}
<title>{% block title %}ETPGRF — единая типографика для веба{% endblock %}</title> <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 %}">
<meta name="keywords" content="типограф, типографика, онлайн типограф, подготовка текста для веба, html типограф, неразрывные пробелы, кавычки елочки, длинное тире, очистка текста от мусора, интернет верстка, муравьев">
<meta name="author" content="Sergei Erjemin">
{# --- Open Graph (Facebook, VK, LinkedIn, Telegram) --- #}
<meta property="og:type" content="website">
<meta property="og:site_name" content="ETPGRF">
<meta property="og:url" content="{{ request.build_absolute_uri }}">
<meta property="og:title" content="{% block og_title %}ETPGRF — единая типографика для веба{% 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:width" content="1200">
<meta property="og:image:height" content="630">
{# --- Twitter Cards (X) --- #}
<meta name="twitter:card" content="summary_large_image">
<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" type="image/png" href="{% static 'favicon-96x96.png' %}" sizes="96x96" />
<link rel="icon" href="{% static 'favicon-light.svg' %}" type="image/svg+xml" media="(prefers-color-scheme: light)"> <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" 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="icon" type="image/svg+xml" href="{% static 'favicon.svg' %}" />
{# Fallback для старых браузеров #}<link rel="shortcut icon" href="{% static 'favicon.ico' %}" sizes="any" /> <link rel="shortcut icon" href="{% static 'favicon.ico' %}" sizes="any" />
{# iOS Icon #}<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" /> {# Custom CSS #}<link href="{% static 'css/etpgrf.css' %}" rel="stylesheet" />
{# HTMX #}<script src="https://unpkg.com/htmx.org@1.9.10"></script> {# 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> {# Alpine.js #}<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
{# Polyfill for Import Maps #}<script async src="https://ga.jspm.io/npm:es-module-shims@1.10.0/dist/es-module-shims.js"></script>
</head> </head>
<body> <body>
@@ -39,10 +62,18 @@
</div> </div>
{# Футер #} {# Футер #}
<footer class="footer mt-auto py-3"> <footer class="footer mt-auto py-2 mt-4">
<div class="container"> <div class="container d-flex justify-content-between align-items-center">
<span class="text-muted small">&copy; Sergei Erjemin, 2025&ndash;{% now 'Y' %}</span> <span class="text-muted small nowrap">&copy; Sergei Erjemin, 2025&ndash;{% now 'Y' %}.</span>
<span class="text-muted small float-end">v0.1.1</span>
<span class="text-muted small nowrap"><i class="bi bi-tags me-1" title="Версия библиотеки etpgrf / Версия сайта"></i>v0.1.3 / v0.1.3</span>
{# Сводная статистика (HTMX) #}
<span class="text-muted small" hx-get="{% url 'stats_summary' %}" hx-trigger="load">
...
</span>
</div> </div>
</footer> </footer>

View File

@@ -4,14 +4,40 @@
{% 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">
<label class="form-label fw-bold small text-muted ls-1">
<i class="bi bi-file-text me-1"></i> Исходный текст:
</label>
<textarea class="form-control" name="text" rows="10" placeholder="Вставьте текст сюда..."></textarea> <textarea class="form-control" name="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"
@@ -239,12 +265,19 @@
</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>
<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 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 @@
<span class="me-3" title="Просмотров">
<i class="bi bi-eye me-1"></i>{{ views }}
</span>
<span class="me-3 nowrap" title="На вход обработано текстов/символов">
<i class="bi bi-box-arrow-in-right me-1"></i>{{ processed }}/{{ chars_in }}
</span>
<span class="me-3" title="На выход получено символов">
<i class="bi bi-box-arrow-right me-1"></i>{{ chars_out }}
</span>
<span class="nowrap" title="Скопировано в буфер текстов/символов">
<i class="bi bi-clipboard-check me-1"></i>{{ copied }}/{{ chars_copied }}
</span>

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,19 +1,86 @@
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
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...except для отладки
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')
)
# print("Aggregated stats:", stats) # DEBUG
# Функция для форматирования чисел с сокращениями (M, k)
def format_large_number(num):
if num > 1_000_000:
return f"{num / 1_000_000:.3f}M".replace(".", ",")
elif num > 1_000:
return f"{num / 1_000:.2f}k".replace(".", ",")
return str(num)
context = {
'views': f"{(stats['views'] or 0):,}".replace(",", "&thinsp;"),
'processed': f"{(stats['processed'] or 0):,}".replace(",", "&thinsp;"),
'copied': f"{(stats['copied'] or 0):,}".replace(",", "&thinsp;"),
'chars_in': format_large_number(stats['chars_in'] or 0),
'chars_out': format_large_number(stats['chars_out'] or 0),
'chars_copied': format_large_number(stats['chars_copied'] or 0),
}
return render(request, 'typograph/stats_summary.html', context)
@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='')
# 1. Читаем базовые настройки # 1. Читаем базовые настройки
langs = request.POST.get(key='langs', default='ru') langs = request.POST.get(key='langs', default='ru')
# 2. Собираем LayoutProcessor # 2. Собираем LayoutProcessor
layout_enabled = request.POST.get(key='layout') == 'on' layout_enabled = request.POST.get(key='layout') == 'on'
layout_option = False layout_option = False
@@ -23,13 +90,13 @@ def process_text(request):
custom_units = request.POST.get(key='layout_units_custom', default='').strip() custom_units = request.POST.get(key='layout_units_custom', default='').strip()
if custom_units: if custom_units:
process_units = custom_units.split() process_units = custom_units.split()
layout_option = LayoutProcessor( layout_option = LayoutProcessor(
langs=langs, langs=langs,
process_initials_and_acronyms=request.POST.get(key='layout_initials') == 'on', process_initials_and_acronyms=request.POST.get(key='layout_initials') == 'on',
process_units=process_units process_units=process_units
) )
# 3. Собираем Hyphenator # 3. Собираем Hyphenator
hyphenation_enabled = request.POST.get(key='hyphenation') == 'on' hyphenation_enabled = request.POST.get(key='hyphenation') == 'on'
hyphenation_option = False hyphenation_option = False
@@ -39,7 +106,7 @@ def process_text(request):
max_len = int(max_len) max_len = int(max_len)
except (ValueError, TypeError): except (ValueError, TypeError):
max_len = 12 max_len = 12
hyphenation_option = Hyphenator( hyphenation_option = Hyphenator(
langs=langs, langs=langs,
max_unhyphenated_len=max_len max_unhyphenated_len=max_len
@@ -70,23 +137,40 @@ def process_text(request):
'mode': request.POST.get(key='mode', default='mixed'), 'mode': request.POST.get(key='mode', default='mixed'),
'sanitizer': sanitizer_option, 'sanitizer': sanitizer_option,
} }
# --- ДИАГНОСТИКА --- # --- ДИАГНОСТИКА ---
# print("Typographer options:", options) # print("Typographer options:", options)
# ------------------- # -------------------
# Создаем экземпляр типографа # Создаем экземпляр типографа
typo = Typographer(**options) typo = Typographer(**options)
# Обрабатываем текст # Обрабатываем текст
processed = typo.process(text) processed = typo.process(text)
# print("Processed text length:", len(processed))
# print("Processed text:", processed)
# --- СБОР СТАТИСТИКИ ---
try:
today = timezone.now().date()
stat, created = DailyStat.objects.get_or_create(date=today)
# Обновляем атомарные поля
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 мы пока не считаем, чтобы не усложнять
)
# JSON с настройками пока не пишем, чтобы не усложнять (как договаривались)
except Exception as e:
print(f"Stat error: {e}")
# -----------------------
return render( return render(
request, request,
template_name='typograph/result_fragment.html', template_name='typograph/result_fragment.html',
context={'processed_text': processed} context={'processed_text': processed}
) )
return HttpResponse(status=405) return HttpResponse(status=405)

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -1,114 +1,133 @@
(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) { updateLogo();
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); function updateLogo() {
updateLogo(); const logoImg = document.getElementById('logo-img');
document.addEventListener('DOMContentLoaded', updateLogo); const navbar = document.getElementById('main-navbar');
if (!logoImg || !navbar) return;
// Слушаем скролл const isDark = darkModeMediaQuery.matches;
window.addEventListener('scroll', updateLogo); // Используем window.scrollY для определения прокрутки
// Если прокрутили больше 50px, уменьшаем шапку
const isScrolled = window.scrollY > 50;
// Слушаем смену темы if (isScrolled) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateLogo); 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;
}
}
// --- КУКИ --- // Инициализация темы и логотипа
const COOKIE_KEY = 'cookie_consent'; updateTheme(darkModeMediaQuery);
const TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90 дней: 90 * 24 * 60 * 60 * 1000 = 7776000000) darkModeMediaQuery.addEventListener('change', updateTheme);
// Инициализация логотипа при загрузке и скролле
document.addEventListener('DOMContentLoaded', updateLogo);
window.addEventListener('scroll', updateLogo);
const banner = document.getElementById('cookie-banner');
const acceptButton = document.getElementById('cookie-accept');
function loadCounters() { // --- КУКИ И СЧЕТЧИКИ ---
// console.log("Загрузка счетчиков (Яндекс, Google)..."); const COOKIE_KEY = 'cookie_consent';
// Код Яндекс.Метрики const TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90 дней
(function (m, e, t, r, i, k, a) { const MAILRU_ID = "3734603";
m[i] = m[i] || function () { (m[i].a = m[i].a || []).push(arguments) }; const YANDEX_ID = "106310834";
m[i].l = 1 * new Date();
for (var j = 0; j < document.scripts.length; j++) { if (document.scripts[j].src === r) { return; } } function loadCounters() {
k = e.createElement(t), a = e.getElementsByTagName(t)[0], k.async = 1, k.src = r, a.parentNode.insertBefore(k, a) // console.log("Загрузка счетчиков...");
})(window, document, 'script', 'https://mc.yandex.ru/metrika/tag.js?id=106310834', 'ym'); try {
ym(106310834, 'init', { // Mail.ru
ssr: true, webvisor: true, clickmap: true, ecommerce: "dataLayer", var _tmr = window._tmr || (window._tmr = []);
accurateTrackBounce: true, trackLinks: true _tmr.push({id: MAILRU_ID, type: "pageView", start: (new Date()).getTime()});
(function (d, w, id) {
if (d.getElementById(id)) return;
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";
var f = d.getElementsByTagName("script")[0]; f.parentNode.insertBefore(ts, f);
})(document, window, "topmailru-code");
// Яндекс.Метрика
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
window.ym(YANDEX_ID, "init", {
clickmap:true,
trackLinks:true,
accurateTrackBounce:true
});
} catch (e) {
console.error("Ошибка загрузки счетчиков:", e);
}
}
function checkConsent() {
try {
const stored = localStorage.getItem(COOKIE_KEY);
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;
}
}
// Инициализация куки-баннера
document.addEventListener('DOMContentLoaded', function() {
const banner = document.getElementById('cookie-banner');
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();
});
}
}); });
// Код Google Analytics // Глобальная функция для отправки целей
// window.dataLayer = window.dataLayer || []; window.sendGoal = function(goalName) {
// function gtag(){dataLayer.push(arguments);} if (!checkConsent()) return;
// gtag('js', new Date()); // console.log("Sending goal:", goalName);
// gtag('config', 'G-XXXXXXXXXX');
try {
// Код Top.Mail.Ru if (window._tmr) {
var _tmr = window._tmr || (window._tmr = []); window._tmr.push({ id: MAILRU_ID, type: "reachGoal", goal: goalName, value: 1 });
_tmr.push({id: "3734603", type: "pageView", start: (new Date()).getTime()}); }
(function (d, w, id) { if (typeof window.ym === 'function') {
if (d.getElementById(id)) return; window.ym(YANDEX_ID, 'reachGoal', goalName);
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"; } catch (e) {
var f = function () { console.error("Ошибка отправки цели:", e);
var s = d.getElementsByTagName("script")[0]; s.parentNode.insertBefore(ts, s); }
};
if (w.opera == "[object Opera]") {
d.addEventListener("DOMContentLoaded", f, false);
} 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);
if (!stored) return false;
try {
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;
}
}
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();
});
})();

View File

@@ -19,9 +19,10 @@ 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 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 +48,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 +86,86 @@ 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 (sourceTextarea) {
sourceTextarea.addEventListener('input', () => {
if (btnCopy) {
btnCopy.classList.add('d-none');
}
// Сбрасываем редактор на плейсхолдер
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('Не удалось скопировать текст.');
}
});
}