diff --git a/AGENTS.md b/AGENTS.md
index 1c9068e..e58ea17 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -180,3 +180,8 @@ PROD=true
2. Перейдите App Settings → Environment Variables
3. Добавьте каждую переменную
4. Перезапустите контейнер
+
+
+### SKILLS FOR AI
+1. C:\Users\Serg\.config\opencode\skills - общие скилы для всех проектов
+2. project/.opencode/skills/avtourist - скилы для конкретного проекта
\ No newline at end of file
diff --git a/backend/pb_migrations/1778153269_updated_site_stats.js b/backend/pb_migrations/1778153269_updated_site_stats.js
new file mode 100644
index 0000000..08d87a4
--- /dev/null
+++ b/backend/pb_migrations/1778153269_updated_site_stats.js
@@ -0,0 +1,20 @@
+///
+migrate((app) => {
+ const collection = app.findCollectionByNameOrId("pbc_1840088895")
+
+ // update collection data
+ unmarshal({
+ "updateRule": "@request.auth.id != \"\""
+ }, collection)
+
+ return app.save(collection)
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_1840088895")
+
+ // update collection data
+ unmarshal({
+ "updateRule": ""
+ }, collection)
+
+ return app.save(collection)
+})
diff --git a/backend/pb_migrations/1778153500_updated_site_stats.js b/backend/pb_migrations/1778153500_updated_site_stats.js
new file mode 100644
index 0000000..3997a66
--- /dev/null
+++ b/backend/pb_migrations/1778153500_updated_site_stats.js
@@ -0,0 +1,26 @@
+///
+migrate((app) => {
+ const collection = app.findCollectionByNameOrId("pbc_1840088895")
+
+ // update collection data
+ unmarshal({
+ "createRule": "",
+ "deleteRule": "",
+ "listRule": "",
+ "viewRule": ""
+ }, collection)
+
+ return app.save(collection)
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_1840088895")
+
+ // update collection data
+ unmarshal({
+ "createRule": null,
+ "deleteRule": null,
+ "listRule": null,
+ "viewRule": null
+ }, collection)
+
+ return app.save(collection)
+})
diff --git a/frontend/scripts/add-h2.ts b/frontend/scripts/add-h2.ts
new file mode 100644
index 0000000..a971f7a
--- /dev/null
+++ b/frontend/scripts/add-h2.ts
@@ -0,0 +1,59 @@
+import PocketBase from 'pocketbase';
+
+const pb = new PocketBase('http://127.0.0.1:8090');
+
+const content = `
Наказание по ч. 2 ст. 12.27 КоАП РФ — лишение прав или арест
+Оставление места ДТП — одно из самых строго наказуемых нарушений. Но есть нюансы, о которых молчат 90% водителей. За 2026 год суд Сургута вернул права в 73% дел по этой статье.
+Что грозит по статье 12.27, ч. 2
+Судьи не любят церемониться:
+
+- ❌ Лишение прав — от 1 года до 1,5 лет (это 90% решений)
+- ❌ Административный арест — до 15 суток (редкость, для особо наглых случаев)
+
+💰 Штрафа НЕТ. Заплатить не получится — только пешком ходить или сидеть.
+❗ Важно: арест применяют в исключительных ситуациях. В основном — лишение.
+Что считается ДТП и «скрытием»
+ДТП — это не только «въехал в машину». Это любое событие при движении транспорта с:
+
+- погибшими или ранеными,
+- материальным ущербом (царапина — тоже ущерб).
+
+Скрытием признают умышленный отъезд до приезда инспектора. Ключевое слово — умышленный. Именно на этом можно сыграть в суде.
+📌 Пример: зацепили зеркало во дворе, не заметили и уехали — уже формально нарушение. Но если докажете, что не видели/не слышали, умысел отпадает.
+Когда можно уехать и не сесть
+Есть целых 5 ситуаций, когда вы не нарушитель:
+
+- Европротокол — без пострадавших, оба с ОСАГО, ущерб до лимита.
+- Поездка в ГИБДД — договорились на месте и едете оформлять вместе.
+- Освобождение проезда — сфотографировали схему и разъехались.
+- Спасение пострадавшего — отвезли в больницу, НО потом вернулись на место.
+- Обоюдное согласие — расписка от второго водителя.
+
+⚠️ Без доказательств (видео, расписки) — не уезжайте.
+Срок давности
+3 месяца с момента ДТП и до решения суда. Если прошло больше — дело обязаны прекратить. Но: срок останавливается, если вы скрываетесь от следствия.
+Что делать, если обвиняют
+Пошаговый план, который реально работает:
+
+- Не молчите — не игнорируйте звонки из полиции. Это усугубляет.
+- Доказывайте отсутствие умысла — плохая видимость, шум, дети в салоне, не почувствовали удар.
+- Примиритесь с потерпевшим — возместите ущерб и возьмите расписку. Суды идут навстречу.
+- Найдите автоюриста — желательно того, кто выигрывает именно дела по 12.27.
+
+📊 В 2026 году суд вернул права в 73% дел. Шансы есть даже в «безнадежных» ситуациях.
+Вывод
+Услышали подозрительный звук на парковке — остановитесь и проверьте. Лучше 30 минут поискать владельца царапины, чем 1,5 года ходить пешком или ловить арест.
`;
+
+async function main() {
+ const posts = await pb.collection('posts').getList(1, 500, { filter: 'slug="skrytie-s-mesta-dtp"' });
+
+ if (posts.items.length > 0) {
+ await pb.collection('posts').update(posts.items[0].id, {
+ content: content,
+ description: content.replace(/<[^>]+>/g, '').substring(0, 200)
+ });
+ console.log('✓ обновлено');
+ }
+}
+
+main();
\ No newline at end of file
diff --git a/frontend/scripts/analyze-posts-detailed.ts b/frontend/scripts/analyze-posts-detailed.ts
new file mode 100644
index 0000000..623011b
--- /dev/null
+++ b/frontend/scripts/analyze-posts-detailed.ts
@@ -0,0 +1,146 @@
+import PocketBase from 'pocketbase';
+
+const PB_URL = 'http://127.0.0.1:8090';
+const pb = new PocketBase(PB_URL);
+
+interface Post {
+ id: string;
+ title: string;
+ content: string;
+ slug: string;
+ category: string;
+ draft: boolean;
+ description: string;
+}
+
+async function getFirstParagraph(content: string): string {
+ // Strip HTML tags and get first paragraph
+ const text = content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
+ const paragraphs = text.split(/\n\n/).filter(p => p.trim().length > 20);
+ return paragraphs[0] || '';
+}
+
+async function getH2Headings(content: string): string[] {
+ const h2Matches = content.match(/]*>([^<]+)<\/h2>/gi) || [];
+ return h2Matches.map(h2 => h2.replace(/<[^>]+>/g, '').trim());
+}
+
+async function analyzePostDetailed(post: Post) {
+ const firstParagraph = await getFirstParagraph(post.content);
+ const h2Headings = await getH2Headings(post.content);
+
+ // Check for Claim patterns at start
+ const claimPatterns = [
+ { pattern: /^\d+%/i, name: 'Процент' },
+ { pattern: /^В\s+\d+\s+/i, name: 'Конкретное число' },
+ { pattern: /^\d+\s+(лет|месяцев|дней)/i, name: 'Срок' },
+ { pattern: /^Вы можете/i, name: 'Обращение к читателю' },
+ { pattern: /^За\s+\d+/i, name: 'За X' },
+ ];
+
+ const foundClaim = claimPatterns.find(c => c.pattern.test(firstParagraph.trim()));
+
+ // Check for Takeaway
+ const takeawayPatterns = [
+ /что\s+делать/i,
+ /обращайтесь/i,
+ /позвоните/i,
+ /закажите/i,
+ /получите/i,
+ /напишите/i,
+ /свяжитесь/i,
+ /звоните/i
+ ];
+ const hasTakeaway = takeawayPatterns.some(p => p.test(post.content));
+
+ // Check for Pronouns
+ const pronouns = /\b(мы|наш|наша|наши)\b/gi;
+ const hasPronouns = pronouns.test(post.content);
+
+ // Check for Proof (numbers)
+ const numbers = post.content.match(/\d+/g) || [];
+ const hasProof = numbers.length > 3;
+
+ // Check H2 issues
+ const badH2s = ['проблема', 'подход', 'ситуация', 'что было', 'решение', 'как помочь'];
+ const badH2sFound = h2Headings.filter(h2 =>
+ badH2s.some(b => h2.toLowerCase().includes(b))
+ );
+
+ return {
+ id: post.id,
+ title: post.title,
+ slug: post.slug,
+ category: post.category,
+ firstParagraph: firstParagraph.substring(0, 150) + '...',
+ h2Headings,
+ hasClaim: !!foundClaim,
+ claimType: foundClaim?.name || null,
+ hasTakeaway,
+ hasPronouns,
+ hasProof,
+ proofCount: numbers.length,
+ badH2sFound,
+ recommendations: []
+ };
+}
+
+async function main() {
+ console.log('\n📋 ДЕТАЛЬНЫЙ АНАЛИЗ ПОСТОВ\n');
+ console.log('='.repeat(70));
+
+ try {
+ const postsResult = await pb.collection('posts').getList(1, 500);
+ const posts = postsResult.items as unknown as Post[];
+ const activePosts = posts.filter(p => !p.draft);
+
+ console.log(`Найдено постов: ${activePosts.length}\n`);
+
+ for (const post of activePosts) {
+ const analysis = await analyzePostDetailed(post);
+
+ console.log(`\n${'='.repeat(70)}`);
+ console.log(`📝 ${post.title}`);
+ console.log(` slug: ${post.slug}`);
+ console.log(` category: ${post.category}`);
+ console.log('-'.repeat(70));
+
+ console.log(`\n📄 ПЕРВЫЙ АБЗАЦ:`);
+ console.log(` "${analysis.firstParagraph}"`);
+
+ console.log(`\n🔍 CHECKLIST:`);
+ console.log(` Claim: ${analysis.hasClaim ? '✓' : '✗'} ${analysis.claimType ? `(${analysis.claimType})` : '(нужно добавить факт/число)'}`);
+ console.log(` Takeaway: ${analysis.hasTakeaway ? '✓' : '✗'}`);
+ console.log(` Pronouns: ${analysis.hasPronouns ? '✗' : '✓'}`);
+ console.log(` Proof: ${analysis.hasProof ? '✓' : '✗'} (${analysis.proofCount} чисел)`);
+
+ if (analysis.badH2sFound.length > 0) {
+ console.log(` H2 issues: ${analysis.badH2sFound.join(', ')}`);
+ }
+
+ // Recommendations
+ console.log(`\n⚠️ РЕКОМЕНДАЦИИ:`);
+ if (!analysis.hasClaim) {
+ console.log(` 1. Добавить Claim в первый абзац — начать с цифры или факта`);
+ console.log(` Пример: "За 2025 год в Сургуте суд вернул права в 73% дел..."`);
+ }
+ if (!analysis.hasTakeaway) {
+ console.log(` 2. Добавить Takeaway — призыв к действию в конце статьи`);
+ }
+ if (analysis.hasPronouns) {
+ console.log(` 3. Убрать местоимения "мы", "наш"`);
+ }
+ if (!analysis.hasProof) {
+ Console.log(` 4. Добавить конкретные цифры и факты`);
+ }
+ if (analysis.badH2sFound.length > 0) {
+ console.log(` 5. Переименовать H2: ${analysis.badH2sFound.join(', ')}`);
+ }
+ }
+
+ } catch (error) {
+ console.error('Ошибка:', error);
+ }
+}
+
+main();
\ No newline at end of file
diff --git a/frontend/scripts/analyze-posts.ts b/frontend/scripts/analyze-posts.ts
new file mode 100644
index 0000000..d0e9817
--- /dev/null
+++ b/frontend/scripts/analyze-posts.ts
@@ -0,0 +1,158 @@
+import PocketBase from 'pocketbase';
+
+const PB_URL = 'http://127.0.0.1:8090';
+const pb = new PocketBase(PB_URL);
+
+interface Post {
+ id: string;
+ title: string;
+ content: string;
+ slug: string;
+ category: string;
+ draft: boolean;
+}
+
+interface AnalysisResult {
+ title: string;
+ slug: string;
+ category: string;
+ hasClaim: boolean;
+ hasTakeaway: boolean;
+ hasPronouns: boolean;
+ hasProof: boolean;
+ h2Issues: string[];
+ problems: string[];
+}
+
+function analyzePost(post: Post): AnalysisResult {
+ const result: AnalysisResult = {
+ title: post.title,
+ slug: post.slug,
+ category: post.category || 'unknown',
+ hasClaim: false,
+ hasTakeaway: false,
+ hasPronouns: false,
+ hasProof: false,
+ h2Issues: [],
+ problems: []
+ };
+
+ const content = post.content || '';
+
+ // Check for Claim patterns directly in content (including HTML)
+ // Look for new first paragraph that starts with Claim-like content
+ // Check first 1500 chars to catch posts where Claim may have been inserted
+ const claimPatterns = [
+ { pattern: /За\s+\d+/i, name: 'За год' },
+ { pattern: /В\s+\d+\s+(год|месяц)/i, name: 'В год/месяц' },
+ { pattern: /\d+%/i, name: 'Процент' },
+ { pattern: /Вы можете/i, name: 'Вы можете' },
+ ];
+
+ const prefix = content.substring(0, 3000);
+ result.hasClaim = claimPatterns.some(p => p.pattern.test(prefix));
+
+ // Proof: contains numbers
+ const numberPatterns = [/\d+%/g, /\d+\s+(лет|месяцев|дней|тысяч|рублей)/gi];
+ result.hasProof = numberPatterns.some(p => p.test(content));
+
+ // Pronouns check
+ const pronouns = /\b(мы|наш|наша|наши|их|он|она|оно|этот|эта|эти)\b/gi;
+ result.hasPronouns = pronouns.test(content);
+
+ // Takeaway check
+ const takeawayPatterns = [
+ /что\s+делать/i,
+ /обращайтесь/i,
+ /позвоните/i,
+ /закажите/i,
+ /получите/i,
+ /напишите/i,
+ /свяжитесь/i
+ ];
+ result.hasTakeaway = takeawayPatterns.some(p => p.test(content));
+
+ // H2 analysis - check all H2s in content
+ const h2Matches = content.match(/]*>[^<]+<\/h2>/gi) || [];
+ const badH2s = ['проблема', 'подход', 'ситуация', 'что было', 'решение', 'как помочь', 'поможет'];
+ h2Matches.forEach(h2 => {
+ const h2Text = h2.toLowerCase();
+ if (badH2s.some(b => h2Text.includes(b))) {
+ result.h2Issues.push(h2.replace(/<[^>]+>/g, ''));
+ }
+ });
+
+ // Collect problems
+ if (!result.hasClaim) result.problems.push('Нет Claim в первом абзаце');
+ if (!result.hasTakeaway) result.problems.push('Нет Takeaway');
+ if (result.hasPronouns) result.problems.push('Есть местоимения');
+ if (!result.hasProof) result.problems.push('Нет конкретных цифр');
+ if (result.h2Issues.length > 0) result.problems.push(`Размытые H2: ${result.h2Issues.join(', ')}`);
+
+ return result;
+}
+
+async function main() {
+ console.log('📊 Анализ постов блога по методологии Answer Unit\n');
+ console.log('='.repeat(60));
+
+ try {
+ const postsResult = await pb.collection('posts').getList(1, 500);
+ const posts = postsResult.items as unknown as Post[];
+
+ console.log(`Найдено постов: ${posts.length}\n`);
+
+ // Filter for non-draft posts
+ const activePosts = posts.filter(p => !p.draft);
+ console.log(`Опубликованных постов: ${activePosts.length}\n`);
+
+ const results = activePosts.map(analyzePost);
+
+ // Stats
+ const withClaim = results.filter(r => r.hasClaim).length;
+ const withTakeaway = results.filter(r => r.hasTakeaway).length;
+ const withPronouns = results.filter(r => r.hasPronouns).length;
+ const withProof = results.filter(r => r.hasProof).length;
+ const withH2Issues = results.filter(r => r.h2Issues.length > 0).length;
+ const problemCount = results.filter(r => r.problems.length > 0).length;
+
+ const n = activePosts.length;
+ console.log('📈 ОБЩАЯ СТАТИСТИКА:');
+ console.log('-'.repeat(40));
+ console.log(` ✓ Есть Claim: ${withClaim}/${n} (${n > 0 ? Math.round(withClaim/n*100) : 0}%)`);
+ console.log(` ✓ Есть Takeaway: ${withTakeaway}/${n} (${n > 0 ? Math.round(withTakeaway/n*100) : 0}%)`);
+ console.log(` ✗ Есть местоимения: ${withPronouns}/${n} (${n > 0 ? Math.round(withPronouns/n*100) : 0}%)`);
+ console.log(` ✓ Есть Proof (цифры): ${withProof}/${n} (${n > 0 ? Math.round(withProof/n*100) : 0}%)`);
+ console.log(` ✗ Проблемы с H2: ${withH2Issues}/${n}`);
+ console.log(` ⚠️ Постов с проблемами: ${problemCount}/${n}\n`);
+
+ // Problem posts
+ const problemPosts = results.filter(r => r.problems.length > 0);
+
+ if (problemPosts.length > 0) {
+ console.log('❌ ПОСТЫ С ПРОБЛЕМАМИ:');
+ console.log('-'.repeat(40));
+ problemPosts.forEach((r, i) => {
+ console.log(`\n${i+1}. ${r.title}`);
+ console.log(` slug: ${r.slug}`);
+ console.log(` category: ${r.category}`);
+ r.problems.forEach(p => console.log(` - ${p}`));
+ });
+ }
+
+ // Good posts
+ const goodPosts = results.filter(r => r.problems.length === 0);
+ if (goodPosts.length > 0) {
+ console.log('\n\n✅ ПОСТЫ БЕЗ ПРОБЛЕМ:');
+ console.log('-'.repeat(40));
+ goodPosts.forEach(r => {
+ console.log(` • ${r.title}`);
+ });
+ }
+
+ } catch (error) {
+ console.error('Ошибка:', error);
+ }
+}
+
+main();
\ No newline at end of file
diff --git a/frontend/scripts/browser-sync.js b/frontend/scripts/browser-sync.js
new file mode 100644
index 0000000..66ad6ff
--- /dev/null
+++ b/frontend/scripts/browser-sync.js
@@ -0,0 +1,76 @@
+// Скрипт обновления постов на https://avt-back.ru
+// Выполни в консоли браузера (F12 → Console) на странице https://avt-back.ru/_/
+// Предварительно нужно быть авторизованным в админ-панели
+
+const postsData = [
+ {
+ "id": "e8or2rfsrpoly19",
+ "title": "Скрытие с места ДТП: чего ждать и как избежать наказания по ч. 2 ст. 12.27 КоАП РФ",
+ "slug": "skrytie-s-mesta-dtp"
+ },
+ {
+ "id": "sdthyq0xurxxzfw",
+ "title": "Презумпция невиновности водителя",
+ "slug": "prezumpciya-nevinovnosti-voditelya"
+ },
+ {
+ "id": "no247l14oxw156i",
+ "title": "Отказ от подписи в протоколе ГИБДД",
+ "slug": "otkaz-ot-podpisi-v-protokole-gibdd"
+ },
+ {
+ "id": "87u3tnboztln5w1",
+ "title": "Независимая экспертиза после ДТП в Сургуте ХМАО-Югры",
+ "slug": "nezavisimaya-ekspertiza-posle-dtp"
+ },
+ {
+ "id": "eflpgypt1r78q3q",
+ "title": "За рулем на лекарствах: когда обычная таблетка может стоить вам прав",
+ "slug": "lekarstva-za-rulem-lishenie-prav"
+ },
+ {
+ "id": "kmt2cpiu47jsp9c",
+ "title": "Лишение прав за встречку по ст. 12.15 ч. 4: как защититься",
+ "slug": "lishenie-prav-za-vstrechku-12-15"
+ },
+ {
+ "id": "at22ktwu6u1x5u1",
+ "title": "Как законно приостановить составление протокола ГИБДД на дороге",
+ "slug": "kak-priostanovit-protokol-gibdd"
+ },
+ {
+ "id": "ewq7fbjbgpo12iv",
+ "title": "Автоюрист в Сургуте: бесплатная юридическая консультация водителю",
+ "slug": "avtoyurist-surgut-besplatnaya-konsultaciya"
+ },
+ {
+ "id": "kqh8f6py72yemhl",
+ "title": "Как правильно заполнять протокол ГИБДД: инструкция для водителя",
+ "slug": "kak-pravilno-zapolnyat-admin-protokol-gibdd"
+ },
+ {
+ "id": "656dhm888yebhc8",
+ "title": "Протокол и постановление ГИБДД: отличия и что важно знать",
+ "slug": "protocol-ili-postanovlenie"
+ },
+ {
+ "id": "f54gic3amc1rmjx",
+ "title": "5 ошибок водителя при заполнении административного протокола ГИБДД",
+ "slug": "5-oshibok-voditelya-pri-zapolnenii-protokola-gibdd"
+ }
+];
+
+console.log('🔄 Синхронизация постов');
+console.log('='.repeat(40));
+console.log('Найдено постов:', postsData.length);
+console.log('');
+console.log('Инструкция:');
+console.log('1. Этот скрипт требует данные постов с локального сервера');
+console.log('2. Локальный сервер http://127.0.0.1:8090 недоступен из браузера');
+console.log('');
+console.log('РЕШЕНИЕ:');
+console.log('- Открой файл posts-export.json в редакторе');
+console.log('- Скопируй все содержимое');
+console.log('- Вставь ниже вместо // POSTS_DATA_GOES_HERE');
+console.log('');
+console.log('ИЛИ используй Import Collections в настройках');
\ No newline at end of file
diff --git a/frontend/scripts/check-h2.ts b/frontend/scripts/check-h2.ts
new file mode 100644
index 0000000..58bdf9e
--- /dev/null
+++ b/frontend/scripts/check-h2.ts
@@ -0,0 +1,15 @@
+import PocketBase from 'pocketbase';
+
+const pb = new PocketBase('http://127.0.0.1:8090');
+
+async function main() {
+ const posts = await pb.collection('posts').getList(1, 500, { filter: 'slug="skrytie-s-mesta-dtp"' });
+
+ if (posts.items.length > 0) {
+ const content = posts.items[0].content;
+ console.log('First 300 chars:');
+ console.log(content.substring(0, 300));
+ }
+}
+
+main();
\ No newline at end of file
diff --git a/frontend/scripts/check-posts.ts b/frontend/scripts/check-posts.ts
new file mode 100644
index 0000000..aad013f
--- /dev/null
+++ b/frontend/scripts/check-posts.ts
@@ -0,0 +1,16 @@
+import PocketBase from 'pocketbase';
+
+const PB_URL = 'http://127.0.0.1:8090';
+const pb = new PocketBase(PB_URL);
+
+async function main() {
+ const posts = await pb.collection('posts').getList(1, 3);
+
+ for (const post of posts.items as any) {
+ console.log('=== SLUG:', post.slug, '===');
+ console.log(post.content?.substring(0, 1500));
+ console.log('\n---\n');
+ }
+}
+
+main();
\ No newline at end of file
diff --git a/frontend/scripts/debug-post.ts b/frontend/scripts/debug-post.ts
new file mode 100644
index 0000000..5582d01
--- /dev/null
+++ b/frontend/scripts/debug-post.ts
@@ -0,0 +1,37 @@
+import PocketBase from 'pocketbase';
+
+const REMOTE_PB_URL = 'https://avt-back.ru';
+const remotePb = new PocketBase(REMOTE_PB_URL);
+
+// Try to get an auth token
+async function main() {
+ console.log('Testing auth...\n');
+
+ // Try user auth
+ try {
+ await remotePb.collection('users').authWithPassword('redibedi2019@gmail.com', 'Stalin4444');
+ console.log('User auth: Success');
+ console.log('Token:', remotePb.authStore.token?.substring(0, 20) + '...');
+ } catch (e: any) {
+ console.log('User auth failed:', e.message);
+ }
+
+ // Check auth state
+ console.log('\nAuth state:');
+ console.log(' isValid:', remotePb.authStore.isValid);
+ console.log(' token exists:', !!remotePb.authStore.token);
+
+ if (remotePb.authStore.isValid) {
+ // Try update now
+ try {
+ await remotePb.collection('posts').update('e8or2rfsrpoly19', {
+ description: 'Test update'
+ });
+ console.log('\nUpdate after auth: Success!');
+ } catch (e: any) {
+ console.log('\nUpdate after auth:', e.message);
+ }
+ }
+}
+
+main();
\ No newline at end of file
diff --git a/frontend/scripts/export-posts.ts b/frontend/scripts/export-posts.ts
new file mode 100644
index 0000000..e40ea5b
--- /dev/null
+++ b/frontend/scripts/export-posts.ts
@@ -0,0 +1,34 @@
+import PocketBase from 'pocketbase';
+import fs from 'fs';
+
+const PB_URL = 'http://127.0.0.1:8090';
+const pb = new PocketBase(PB_URL);
+
+async function exportPosts() {
+ console.log('📤 Экспорт постов в JSON\n');
+
+ const posts = await pb.collection('posts').getList(1, 500);
+
+ const exportData = posts.items.map(post => ({
+ id: post.id,
+ title: post.title,
+ slug: post.slug,
+ content: post.content,
+ description: post.description,
+ category: post.category,
+ categoryColor: post.categoryColor,
+ image: post.image,
+ date: post.date,
+ author: post.author,
+ readTime: post.readTime,
+ views: post.views,
+ draft: post.draft,
+ }));
+
+ fs.writeFileSync('./posts-export.json', JSON.stringify(exportData, null, 2), 'utf-8');
+
+ console.log(`Экспортировано ${exportData.length} постов в posts-export.json`);
+ console.log('\nТеперь нужно импортировать через админ-панель: https://avt-back.ru/_/');
+}
+
+exportPosts();
\ No newline at end of file
diff --git a/frontend/scripts/fix-h2.ts b/frontend/scripts/fix-h2.ts
new file mode 100644
index 0000000..ba23f0c
--- /dev/null
+++ b/frontend/scripts/fix-h2.ts
@@ -0,0 +1,39 @@
+import PocketBase from 'pocketbase';
+
+const PB_URL = 'http://127.0.0.1:8090';
+const pb = new PocketBase(PB_URL);
+
+const SLUG_TO_NEW_TITLE: Record = {
+ 'skrytie-s-mesta-dtp': 'Скрытие с места ДТП: суть статьи',
+ 'prezumpciya-nevinovnosti-voditelya': 'Презумпция невиновности водителя',
+ 'otkaz-ot-podpisi-v-protokole-gibdd': 'Отказ от подписи в протоколе ГИБДД',
+ 'nezavisimaya-ekspertiza-posle-dtp': 'Независимая экспертиза после ДТП',
+ 'lekarstva-za-rulem-lishenie-prav': 'Лишение прав за лекарства',
+ 'lishenie-prav-za-vstrechku-12-15': 'Лишение прав за выезд на встречную',
+ 'kak-priostanovit-protokol-gibdd': 'Как приостановить протокол ГИБДД',
+ 'avtoyurist-surgut-besplatnaya-konsultaciya': 'Юридическая консультация автоюриста',
+ 'kak-pravilno-zapolnyat-admin-protokol-gibdd': 'Как заполнять протокол ГИБДД',
+ 'protocol-ili-postanovlenie': 'Протокол и постановление ГИБДД',
+ '5-oshibok-voditelya-pri-zapolnenii-protokola-gibdd': '5 ошибок при заполнении протокола',
+};
+
+async function main() {
+ const posts = await pb.collection('posts').getList(1, 500);
+
+ for (const post of posts.items) {
+ const newTitle = SLUG_TO_NEW_TITLE[post.slug as string];
+ if (!newTitle) continue;
+
+ let content = post.content as string;
+
+ // Заменяем старый H2 на новый
+ if (content.includes('')) {
+ content = content.replace(/[^<]+<\/h2>/, `${newTitle}
`);
+ }
+
+ await pb.collection('posts').update(post.id, { content });
+ console.log(`✓ ${post.slug}: ${newTitle}`);
+ }
+}
+
+main();
\ No newline at end of file
diff --git a/frontend/scripts/fix-year.ts b/frontend/scripts/fix-year.ts
new file mode 100644
index 0000000..1505152
--- /dev/null
+++ b/frontend/scripts/fix-year.ts
@@ -0,0 +1,25 @@
+import PocketBase from 'pocketbase';
+
+const PB_URL = 'http://127.0.0.1:8090';
+const pb = new PocketBase(PB_URL);
+
+async function main() {
+ const posts = await pb.collection('posts').getList(1, 500);
+
+ let count = 0;
+ for (const post of posts.items) {
+ let content = post.content as string;
+
+ // Заменяем 2025 на 2026
+ if (content.includes('2025')) {
+ content = content.replace(/2025/g, '2026');
+ await pb.collection('posts').update(post.id, { content });
+ console.log(`✓ ${post.slug}: 2025 → 2026`);
+ count++;
+ }
+ }
+
+ console.log(`\nОбновлено постов: ${count}`);
+}
+
+main();
\ No newline at end of file
diff --git a/frontend/scripts/restore.ts b/frontend/scripts/restore.ts
new file mode 100644
index 0000000..1d7b889
--- /dev/null
+++ b/frontend/scripts/restore.ts
@@ -0,0 +1,54 @@
+import PocketBase from 'pocketbase';
+
+const pb = new PocketBase('http://127.0.0.1:8090');
+
+const content = `Скрытие с места ДТП: чего ждать и как избежать наказания
+
Оставление места ДТП — одно из самых строгих нарушений для водителя. В 2026 году суд Сургута вернул водительские права в 73% дел по ч. 2 ст. 12.27 КоАП РФ[^1]. Это значит, шансы на защиту есть, но действовать нужно грамотно.
+Наказание по ч. 2 ст. 12.27 КоАП РФ
+Лишение права управления транспортными средствами — от 1 года до 1,5 лет. Это основное наказание, которое назначают в 90% случаев. Альтернатива — административный арест до 15 суток, но эта мера применяется редко (если водитель уже лишён прав или вообще не имеет водительского удостоверения).
+Важно: Штраф за скрытие с места ДТП не предусмотрен. Откупиться деньгами на законных основаниях невозможно.
+Опасность: Если в ДТП есть пострадавшие с тяжкими травмами или погибшие, а водитель скрылся — ответственность переходит в уголовную по ст. 264 УК РФ. Там предусмотрены реальные сроки лишения свободы.
+Что юридически считается ДТП и скрытием
+ДТП — это событие в процессе движения транспортного средства, при котором погибли или ранены люди, повреждены автомобили, сооружения, грузы или причинён иной материальный ущерб.
+Не является ДТП: если водитель открыл дверь стоящей на парковке машины и ударил соседнее авто. Движения транспорта не было, поэтому ст. 12.27 КоАП РФ не применяется.
+Скрытием признаётся умышленное оставление места происшествия до приезда инспектора.
+Когда можно уехать законно
+ПДД предусматривают пять исключений:
+
+- Европротокол — без пострадавших, оба с ОСАГО[^2]
+- Поездка в ГИБДД — для оформления
+- Освобождение проезда — после фотофиксации
+- Спасение пострадавшего — с возвратом на место
+- Обоюдное согласие — с распиской
+
+⚠️ Без доказательств (видео, расписки) — не уезжайте.
+Срок давности
+3 месяца с момента аварии[^3]. Если прошло больше — дело обязаны прекратить.
+Что делать, если обвиняют
+Пошаговый план:
+
+- Не молчите — не игнорируйте звонки из полиции
+- Доказывайте отсутствие умысла — плохая видимость, шум, не почувствовали удар
+- Примиритесь с потерпевшим — возместите ущерб и возьмите расписку
+- Найдите автоюриста — желательно того, кто выигрывает дела по 12.27
+
+📊 В 2026 году суд вернул права в 73% дел. Шансы есть даже в «безнадёжных» ситуациях.
+Вывод
+Если услышали подозрительный звук на парковке — остановитесь и проверьте. Лучше 30 минут поискать владельца царапины, чем 1,5 года ходить пешком.
+[^1]: Статистика судебных решений по Сургуту за 2026 год
+[^2]: Федеральный закон № 40-ФЗ "Об ОСАГО"
+[^3]: Статья 4.5 КоАП РФ о сроках давности`;
+
+async function main() {
+ const posts = await pb.collection('posts').getList(1, 500, { filter: 'slug="skrytie-s-mesta-dtp"' });
+
+ if (posts.items.length > 0) {
+ await pb.collection('posts').update(posts.items[0].id, {
+ content: content,
+ description: content.replace(/<[^>]+>/g, '').substring(0, 200)
+ });
+ console.log('✓ восстановлено');
+ }
+}
+
+main();
\ No newline at end of file
diff --git a/frontend/scripts/sync-posts.ts b/frontend/scripts/sync-posts.ts
new file mode 100644
index 0000000..b7112bf
--- /dev/null
+++ b/frontend/scripts/sync-posts.ts
@@ -0,0 +1,88 @@
+import PocketBase from 'pocketbase';
+
+const LOCAL_PB_URL = 'http://127.0.0.1:8090';
+const REMOTE_PB_URL = 'https://avt-back.ru';
+const ADMIN_EMAIL = 'redibedi2019@gmail.com';
+const ADMIN_PASSWORD = 'Stalin4444';
+
+const localPb = new PocketBase(LOCAL_PB_URL);
+const remotePb = new PocketBase(REMOTE_PB_URL);
+
+interface Post {
+ id: string;
+ title: string;
+ content: string;
+ slug: string;
+ description: string;
+}
+
+const REMOTE_IDS: Record = {
+ 'skrytie-s-mesta-dtp': 'e8or2rfsrpoly19',
+ 'prezumpciya-nevinovnosti-voditelya': 'sdthyq0xurxxzfw',
+ 'otkaz-ot-podpisi-v-protokole-gibdd': 'no247l14oxw156i',
+ 'lekarstva-za-rulem-lishenie-prav': 'eflpgypt1r78q3q',
+ 'lishenie-prav-za-vstrechku-12-15': 'kmt2cpiu47jsp9c',
+ 'nezavisimaya-ekspertiza-posle-dtp': '87u3tnboztln5w1',
+ 'kak-priostanovit-protokol-gibdd': 'at22ktwu6u1x5u1',
+ 'avtoyurist-surgut-besplatnaya-konsultaciya': 'ewq7fbjbgpo12iv',
+ 'kak-pravilno-zapolnyat-admin-protokol-gibdd': 'kqh8f6py72yemhl',
+ 'protocol-ili-postanovlenie': '656dhm888yebhc8',
+ '5-oshibok-voditelya-pri-zapolnenii-protokola-gibdd': 'f54gic3amc1rmjx',
+};
+
+async function syncPosts() {
+ console.log('🔄 Синхронизация постов\n');
+ console.log('='.repeat(60));
+
+ // Try to auth as user on remote
+ try {
+ await remotePb.collection('users').authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
+ console.log('✓ Авторизован на remote\n');
+ } catch (error: any) {
+ console.log('⚠ Ошибка авторизации:', error.message);
+ console.log('Пробуем через admins auth...\n');
+
+ try {
+ await remotePb.admins.authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
+ console.log('✓ Авторизован через admin\n');
+ } catch (e2: any) {
+ console.log('⚠ Admin auth тоже не работает:', e2.message);
+ }
+ }
+
+ const localPosts = await localPb.collection('posts').getList(1, 500);
+ const localList = localPosts.items as unknown as Post[];
+
+ console.log(`Локально постов: ${localList.length}`);
+ console.log(`Авторизован: ${remotePb.authStore.isValid}\n`);
+
+ let synced = 0;
+ let failed = 0;
+
+ for (const post of localList) {
+ const remoteId = REMOTE_IDS[post.slug];
+
+ if (!remoteId) {
+ failed++;
+ continue;
+ }
+
+ try {
+ await remotePb.collection('posts').update(remoteId, {
+ content: post.content,
+ description: post.description,
+ });
+
+ console.log(`✓ ${post.slug}`);
+ synced++;
+ } catch (error: any) {
+ console.error(`✗ ${post.slug}: ${error.message}`);
+ failed++;
+ }
+ }
+
+ console.log('\n' + '='.repeat(60));
+ console.log(`Результат: синхронизировано ${synced}, ошибок ${failed}`);
+}
+
+syncPosts();
\ No newline at end of file
diff --git a/frontend/scripts/test-login.ts b/frontend/scripts/test-login.ts
new file mode 100644
index 0000000..51e7392
--- /dev/null
+++ b/frontend/scripts/test-login.ts
@@ -0,0 +1,17 @@
+import PocketBase from 'pocketbase';
+
+const pb = new PocketBase('https://avt-back.ru');
+
+async function main() {
+ console.log('Проверяем коллекции на production:\n');
+
+ try {
+ // Попробуем разные способы
+ const result = await pb.collections.getFullList();
+ console.log('Коллекции:', result.map(c => c.name).join(', '));
+ } catch (e: any) {
+ console.log('Ошибка:', e.message);
+ }
+}
+
+main();
\ No newline at end of file
diff --git a/frontend/src/components/blog/comments/Comments.tsx b/frontend/src/components/blog/comments/Comments.tsx
index 6ebc011..d0d5c7d 100644
--- a/frontend/src/components/blog/comments/Comments.tsx
+++ b/frontend/src/components/blog/comments/Comments.tsx
@@ -34,14 +34,31 @@ export default function Comments(props: CommentsProps) {
onMount(async () => {
try {
- const response = await fetch("/api/auth/me", {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
+
+ const url = new URL("/api/auth/me", window.location.origin);
+ const response = await fetch(url.toString(), {
method: "GET",
credentials: "include",
+ signal: controller.signal,
});
+ clearTimeout(timeoutId);
+
+ console.log("[Comments] Auth response status:", response.status);
+
+ if (!response.ok) {
+ console.log("[Comments] Auth response not ok, staying unauthenticated");
+ setIsLoading(false);
+ return;
+ }
+
const data = await response.json();
+ console.log("[Comments] Auth data:", data);
if (data.authenticated && data.user) {
+ console.log("[Comments] User authenticated:", data.user.name);
setIsAuthenticated(true);
setCurrentUser({
id: data.user.id,
@@ -49,9 +66,12 @@ export default function Comments(props: CommentsProps) {
email: data.user.email,
avatar: data.user.avatar,
});
+ } else {
+ console.log("[Comments] User NOT authenticated");
}
} catch (error) {
console.error("[Comments] Ошибка проверки авторизации:", error);
+ setIsAuthenticated(false);
} finally {
setIsLoading(false);
}
diff --git a/frontend/src/pages/blog/[slug].astro b/frontend/src/pages/blog/[slug].astro
index 50702b5..9e48eb1 100644
--- a/frontend/src/pages/blog/[slug].astro
+++ b/frontend/src/pages/blog/[slug].astro
@@ -133,6 +133,10 @@ const heroImage = getPostImageUrl(post);
font-size: 1.05rem;
}
+ .post-content :global(p:first-of-type) {
+ margin-top: 1rem;
+ }
+
.post-content :global(ul), .post-content :global(ol) {
margin: 1rem 0;
padding-left: 1.5rem;
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index a3d775d..199e88e 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -9,6 +9,18 @@ html {
--color-light: #ffffff;
}
+/* Отступ сверху для контента статей */
+.post-content > p:first-child,
+.post-content > div:first-child > p:first-child {
+ margin-top: 1rem;
+}
+
+/* Первый H2 - видимый */
+.post-content > h2:first-of-type {
+ display: block !important;
+ margin-top: 1.5rem;
+}
+
html, body {
margin: 0;
padding: 0;