Files
omni-parse/web/index.html
Vlad Gothbreach 2e3bf4cb24
Some checks failed
ci / test (3.10) (push) Has been cancelled
ci / test (3.11) (push) Has been cancelled
ci / test (3.12) (push) Has been cancelled
abuse: схлопывание перепечаток (sig в индексе, uniq в API), кэш выдачи 60с; scan 13с -> 1.5с
2026-10-27 03:43:42 +03:00

632 lines
117 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="ru" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>OMNI — Лента</title>
<link rel="stylesheet" href="/assets/vendor/tabler/tabler.min.css">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ctext x='3' y='25' font-size='28'%3EO%3C/text%3E%3C/svg%3E">
<style>
/* Self-hosted Inter 5.3.0 variable (OFL-1.1), Latin + Cyrillic subsets. */
@font-face { font-family: "Inter Variable"; font-style: normal; font-display: swap; font-weight: 100 900;
src: url("/assets/vendor/inter/files/inter-latin-wght-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: "Inter Variable"; font-style: normal; font-display: swap; font-weight: 100 900;
src: url("/assets/vendor/inter/files/inter-latin-ext-wght-normal.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
@font-face { font-family: "Inter Variable"; font-style: normal; font-display: swap; font-weight: 100 900;
src: url("/assets/vendor/inter/files/inter-cyrillic-wght-normal.woff2") format("woff2");
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; }
@font-face { font-family: "Inter Variable"; font-style: normal; font-display: swap; font-weight: 100 900;
src: url("/assets/vendor/inter/files/inter-cyrillic-ext-wght-normal.woff2") format("woff2");
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; }
/* shadcn/ui neutral dark tokens adapted for vanilla OMNI: https://ui.shadcn.com/docs/theming
Vendored Tabler remains the layout and behavior foundation; no React/Radix runtime. */
:root, html[data-bs-theme="dark"] {
color-scheme: dark;
--omni-background: #09090b;
--omni-card: #0f0f12;
--omni-foreground: #fafafa;
--omni-secondary: #e4e4e7;
--omni-muted: #18181b;
--omni-muted-foreground: #a1a1aa;
--omni-border: #27272a;
--omni-ring: #a1a1aa;
--omni-primary-hover: #d4d4d8;
--omni-success: #a7d9b8;
--omni-success-bg: #102019;
--omni-success-border: #284537;
--omni-danger: #f2a5ad;
--omni-danger-bg: #251316;
--omni-danger-border: #51272e;
--omni-warning: #e6c792;
--omni-warning-bg: #251e12;
--omni-warning-border: #4b3c22;
--omni-info: #b8c7e5;
--omni-info-bg: #151b26;
--omni-info-border: #2c374b;
--omni-space-1: .25rem;
--omni-space-2: .5rem;
--omni-space-3: .75rem;
--omni-space-4: 1rem;
--omni-space-6: 1.5rem;
--omni-space-8: 2rem;
--omni-text-caption: .75rem;
--omni-text-small: .75rem;
--omni-text-base: .875rem;
--omni-text-title: 1rem;
--omni-text-heading: 1.5rem;
--omni-radius: .375rem;
--omni-control-height: 2.25rem;
--omni-sidebar-width: 13.75rem;
--omni-reading-size: 1rem;
--omni-reader-width: 44rem;
--omni-source-width: 20rem;
--omni-transition: 140ms cubic-bezier(.25, 1, .5, 1);
--omni-select-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23a1a1aa' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E");
--omni-check-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%2309090b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4 8 3 3 5-6'/%3E%3C/svg%3E");
--omni-switch-off-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='%23a1a1aa'/%3E%3C/svg%3E");
--omni-switch-on-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='%2309090b'/%3E%3C/svg%3E");
--tblr-font-sans-serif: "Inter Variable", "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
--tblr-body-font-family: var(--tblr-font-sans-serif);
--tblr-body-font-size: var(--omni-text-base);
--tblr-body-line-height: 1.5;
--tblr-body-bg: var(--omni-background);
--tblr-body-color: var(--omni-foreground);
--tblr-heading-color: var(--omni-foreground);
--tblr-secondary: var(--omni-muted-foreground);
--tblr-secondary-rgb: 161, 161, 170;
--tblr-secondary-color: var(--omni-muted-foreground);
--tblr-tertiary-color: var(--omni-muted-foreground);
--tblr-primary: var(--omni-foreground);
--tblr-primary-rgb: 250, 250, 250;
--tblr-primary-fg: var(--omni-background);
--tblr-primary-darken: var(--omni-primary-hover);
--tblr-primary-lt: var(--omni-muted);
--tblr-primary-subtle: var(--omni-muted);
--tblr-primary-text-emphasis: var(--omni-foreground);
--tblr-primary-border-subtle: var(--omni-border);
--tblr-link-color: var(--omni-secondary);
--tblr-link-color-rgb: 228, 228, 231;
--tblr-link-hover-color: var(--omni-foreground);
--tblr-link-hover-color-rgb: 250, 250, 250;
--tblr-bg-surface: var(--omni-background);
--tblr-bg-surface-primary: var(--omni-background);
--tblr-bg-surface-secondary: var(--omni-card);
--tblr-bg-surface-tertiary: var(--omni-muted);
--tblr-bg-surface-dark: var(--omni-background);
--tblr-bg-surface-inverted: var(--omni-muted);
--tblr-bg-forms: var(--omni-background);
--tblr-bg-forms-disabled: var(--omni-muted);
--tblr-border-color: var(--omni-border);
--tblr-border-color-translucent: var(--omni-border);
--tblr-border-active-color: var(--omni-ring);
--tblr-border-radius: var(--omni-radius);
--tblr-border-radius-sm: var(--omni-radius);
--tblr-border-radius-lg: var(--omni-radius);
--tblr-hover-bg: var(--omni-muted);
--tblr-active-bg: var(--omni-muted);
--tblr-disabled-bg: var(--omni-muted);
--tblr-disabled-color: var(--omni-muted-foreground);
--tblr-btn-input-bg: var(--omni-background);
--tblr-btn-input-fg: var(--omni-foreground);
--tblr-btn-input-font-size: var(--omni-text-base);
--tblr-btn-input-border-radius: var(--omni-radius);
--tblr-sidebar-width: var(--omni-sidebar-width);
--tblr-shadow-card: none;
--tblr-shadow-input: none;
--tblr-shadow: none;
--tblr-shadow-sm: none;
--tblr-shadow-lg: none;
--tblr-box-shadow: none;
--tblr-box-shadow-sm: none;
--bs-body-bg: var(--omni-background);
--bs-body-bg-rgb: 9, 9, 11;
--bs-body-color: var(--omni-foreground);
--bs-body-color-rgb: 250, 250, 250;
--bs-secondary-color: var(--omni-muted-foreground);
--bs-secondary-bg: var(--omni-card);
--bs-tertiary-bg: var(--omni-muted);
--bs-primary: var(--omni-foreground);
--bs-primary-rgb: 250, 250, 250;
--bs-border-color: var(--omni-border);
--bs-border-color-translucent: var(--omni-border);
--bs-link-color: var(--omni-secondary);
--bs-link-hover-color: var(--omni-foreground);
--bs-border-radius: var(--omni-radius);
}
html, body { background: var(--omni-background); color: var(--omni-foreground); }
body { font-family: var(--tblr-font-sans-serif); font-size: var(--omni-text-base); overflow-wrap: break-word; }
.page-wrapper, main, #view, .min-w-0 { min-width: 0; }
.page-wrapper { background: var(--omni-background); }
.page-wrapper .container-xl { width: 100%; max-width: none; padding-inline: var(--omni-space-8); }
.page-header { margin: 0; padding-block: var(--omni-space-6); }
.page-pretitle, .subheader { font-size: var(--omni-text-caption); font-weight: 500; letter-spacing: .06em; color: var(--omni-muted-foreground); }
.page-pretitle { margin-bottom: var(--omni-space-2); }
.page-title { font-size: var(--omni-text-heading); line-height: 1.3333; font-weight: 600; letter-spacing: -.025em; }
#page-description { max-width: 64rem; line-height: 1.5; }
.page-body { margin-top: 0; margin-bottom: var(--omni-space-6); }
.footer { padding-block: var(--omni-space-4); border-color: var(--omni-border); background: var(--omni-background); }
.text-secondary, .form-hint, .card-subtitle { color: var(--omni-muted-foreground) !important; }
.small, small, .form-hint { font-size: var(--omni-text-small); }
a { color: var(--omni-secondary); }
a:hover { color: var(--omni-foreground); }
.navbar.navbar-vertical { --tblr-navbar-color: var(--omni-muted-foreground); --tblr-navbar-active-bg: var(--omni-muted); --tblr-navbar-active-color: var(--omni-foreground); --tblr-navbar-border-color: var(--omni-border); --tblr-navbar-toggler-icon-color: var(--omni-foreground); background: var(--omni-background); box-shadow: none; border-inline-end: 1px solid var(--omni-border); }
.navbar.navbar-vertical > .container-fluid { min-width: 0; gap: 0; }
.navbar.navbar-vertical .navbar-brand { position: static; inset: auto; transform: none; margin: 0; min-width: 0; white-space: normal; color: var(--omni-foreground); }
.brand-lockup { display: flex; align-items: center; gap: var(--omni-space-3); text-decoration: none; }
.skip-link { position: absolute; z-index: 1090; }
.brand-mark { display: grid; place-items: center; flex: 0 0 var(--omni-space-8); width: var(--omni-space-8); height: var(--omni-space-8); border: 1px solid var(--omni-border); border-radius: var(--omni-radius); background: var(--omni-card); }
.brand-name { display: block; font-size: var(--omni-text-title); font-weight: 600; line-height: 1.3; letter-spacing: .04em; }
.brand-caption { display: block; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--omni-muted-foreground); font-size: var(--omni-text-caption); font-weight: 500; line-height: 1.45; letter-spacing: .06em; }
#sidebar-menu { min-width: 0; width: 100%; }
.navbar.navbar-vertical .navbar-nav { --tblr-sidebar-nav-padding-y: var(--omni-space-2); --tblr-sidebar-nav-padding-x: var(--omni-space-3); gap: var(--omni-space-1); padding-inline: var(--omni-space-2); }
.navbar.navbar-vertical .navbar-nav .nav-link { gap: var(--omni-space-3); min-height: var(--omni-control-height); padding: var(--omni-space-2) var(--omni-space-3); margin: 0; border-radius: var(--omni-radius); color: var(--omni-muted-foreground); font-size: var(--omni-text-base); line-height: 1.4; transition: background-color var(--omni-transition), color var(--omni-transition); }
.navbar-vertical .navbar-nav .nav-link:hover, .navbar-vertical .navbar-nav .nav-link:focus-visible { background: var(--omni-muted); color: var(--omni-foreground); }
.navbar-vertical .navbar-nav .nav-link.active { background: var(--omni-muted); color: var(--omni-foreground); font-weight: 500; }
.navbar-vertical .navbar-nav .nav-link::after, .navbar-vertical .navbar-nav .nav-item::after { content: none; }
.navbar-vertical .nav-link-icon { display: inline-flex !important; align-items: center; justify-content: center; flex: 0 0 var(--omni-space-4); width: var(--omni-space-4); height: var(--omni-space-4); margin: 0; color: inherit; }
.navbar-vertical .nav-link-icon .icon { width: var(--omni-space-4); height: var(--omni-space-4); }
.navbar details > summary { cursor: pointer; }
.navbar details[open] > summary { margin-bottom: var(--omni-space-2); }
.sidebar-footer { padding: var(--omni-space-4) var(--omni-space-3); }
#health { line-height: 1.6; }
.sidebar-footer .btn { padding-inline: 0; white-space: normal; text-align: start; }
#sidebar-toggle { position: static; inset: auto; transform: none; width: auto; min-width: 5.5rem; height: auto; min-height: var(--omni-control-height); flex: 0 0 auto; gap: var(--omni-space-2); padding: var(--omni-space-2) var(--omni-space-3); border: 1px solid var(--omni-border); border-radius: var(--omni-radius); color: var(--omni-foreground); font-size: var(--omni-text-base); background: var(--omni-background); box-shadow: none; }
#sidebar-toggle .navbar-toggler-icon { width: var(--omni-space-4); height: var(--omni-space-4); }
#sidebar-toggle .ms-2 { margin-inline-start: 0 !important; }
.card { --tblr-card-spacer-x: var(--omni-space-4); --tblr-card-spacer-y: var(--omni-space-4); --tblr-card-cap-bg: var(--omni-background); --tblr-card-bg: var(--omni-background); --tblr-card-border-color: var(--omni-border); --tblr-card-border-radius: var(--omni-radius); --tblr-card-box-shadow: none; min-width: 0; background: var(--omni-background); border-color: var(--omni-border); box-shadow: none; }
.card-header, .card-body, .card-footer { padding: var(--omni-space-4); background-color: transparent; border-color: var(--omni-border); }
.card-header { min-height: auto; }
.card-title { margin-bottom: 0; font-size: var(--omni-text-title); font-weight: 600; line-height: 1.5; }
.card-title + form, .card-title + .text-secondary { margin-top: var(--omni-space-3); }
.card-header:has(.card-header-tabs) { padding-block: 0; }
.card-header-tabs { gap: var(--omni-space-6); margin: 0; }
.nav-tabs { border-color: var(--omni-border); }
.nav-tabs .nav-link { margin: 0; padding: var(--omni-space-3) 0; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent; color: var(--omni-muted-foreground); font-weight: 500; }
.nav-tabs .nav-link:hover { color: var(--omni-foreground); border-color: var(--omni-border); }
.nav-tabs .nav-link.active { color: var(--omni-foreground); background: transparent; border-color: var(--omni-foreground); }
.btn { --tblr-btn-color: var(--omni-foreground); --tblr-btn-bg: var(--omni-background); --tblr-btn-border-color: var(--omni-border); --tblr-btn-hover-color: var(--omni-foreground); --tblr-btn-hover-bg: var(--omni-muted); --tblr-btn-hover-border-color: var(--omni-border); --tblr-btn-active-color: var(--omni-foreground); --tblr-btn-active-bg: var(--omni-muted); --tblr-btn-active-border-color: var(--omni-ring); --tblr-btn-disabled-color: var(--omni-muted-foreground); --tblr-btn-disabled-bg: var(--omni-muted); --tblr-btn-disabled-border-color: var(--omni-border); --tblr-btn-box-shadow: none; min-height: var(--omni-control-height); padding: var(--omni-space-2) var(--omni-space-3); font-size: var(--omni-text-base); font-weight: 500; line-height: 1.4; border-radius: var(--omni-radius); background-image: none; box-shadow: none; transition: background-color var(--omni-transition), color var(--omni-transition), border-color var(--omni-transition); }
.btn:hover, .btn:active, .btn:focus-visible { background-image: none; box-shadow: none; }
.btn-primary { --tblr-btn-color: var(--omni-background); --tblr-btn-bg: var(--omni-foreground); --tblr-btn-border-color: var(--omni-foreground); --tblr-btn-hover-color: var(--omni-background); --tblr-btn-hover-bg: var(--omni-primary-hover); --tblr-btn-hover-border-color: var(--omni-primary-hover); --tblr-btn-active-color: var(--omni-background); --tblr-btn-active-bg: var(--omni-primary-hover); --tblr-btn-active-border-color: var(--omni-primary-hover); }
.btn-ghost-secondary, .btn-link { --tblr-btn-color: var(--omni-muted-foreground); --tblr-btn-bg: transparent; --tblr-btn-border-color: transparent; --tblr-btn-hover-color: var(--omni-foreground); --tblr-btn-hover-bg: var(--omni-muted); --tblr-btn-hover-border-color: transparent; }
.btn-link { min-height: 0; background: transparent; text-decoration: none; }
.btn-link:hover { background: transparent; text-decoration: underline; text-underline-offset: var(--omni-space-1); }
.btn-ghost-danger { --tblr-btn-color: var(--omni-danger); --tblr-btn-bg: transparent; --tblr-btn-border-color: transparent; --tblr-btn-hover-color: var(--omni-danger); --tblr-btn-hover-bg: var(--omni-danger-bg); --tblr-btn-hover-border-color: transparent; }
.btn-sm { min-height: var(--omni-space-8); font-size: var(--omni-text-small); }
.btn:disabled, .btn.disabled { opacity: .5; }
.form-label { margin-bottom: var(--omni-space-2); color: var(--omni-secondary); font-size: var(--omni-text-small); font-weight: 500; }
.form-control, .form-select { min-width: 0; min-height: var(--omni-control-height); padding: var(--omni-space-2) var(--omni-space-3); border: 1px solid var(--omni-border); border-radius: var(--omni-radius); background-color: var(--omni-background); color: var(--omni-foreground); font-family: inherit; font-size: var(--omni-text-base); line-height: 1.25; box-shadow: none; transition: border-color var(--omni-transition); }
.form-select { padding-inline-end: var(--omni-space-8); background-image: var(--omni-select-image); background-size: var(--omni-space-4); background-position: right var(--omni-space-3) center; }
.form-control::placeholder { color: var(--omni-muted-foreground); opacity: 1; }
.form-control:focus, .form-select:focus { color: var(--omni-foreground); background-color: var(--omni-background); border-color: var(--omni-ring); box-shadow: none; }
.form-control:disabled, .form-select:disabled, .form-control[readonly] { color: var(--omni-muted-foreground); background-color: var(--omni-muted); border-color: var(--omni-border); opacity: 1; }
option, optgroup { color: var(--omni-foreground); background: var(--omni-background); }
.input-group { min-width: 0; flex-wrap: nowrap; }
.input-group > .form-control, .input-group > .form-select { min-width: 0; }
.input-group > .btn { flex-shrink: 0; }
.form-check-input { border-color: var(--omni-border); background-color: var(--omni-background); box-shadow: none; }
.form-check-input:focus { border-color: var(--omni-ring); box-shadow: none; }
.form-check-input:checked, .form-check-input:indeterminate { background-color: var(--omni-foreground); border-color: var(--omni-foreground); }
.form-check-input[type="checkbox"]:checked { background-image: var(--omni-check-image); }
.form-check-input[type="checkbox"]:indeterminate { background-color: var(--omni-muted-foreground); }
.form-switch .form-check-input { background-image: var(--omni-switch-off-image); }
.form-switch .form-check-input:checked { background-image: var(--omni-switch-on-image); }
.form-check-input:disabled { opacity: .5; }
.table { --tblr-table-color: var(--omni-secondary); --tblr-table-bg: var(--omni-background); --tblr-table-border-color: var(--omni-border); --tblr-table-striped-bg: var(--omni-card); --tblr-table-hover-bg: var(--omni-muted); --tblr-table-active-bg: var(--omni-muted); --bs-table-bg: var(--omni-background); --bs-table-color: var(--omni-secondary); --bs-table-border-color: var(--omni-border); color: var(--omni-secondary); font-size: var(--omni-text-base); }
.table > :not(caption) > * > * { padding: var(--omni-space-3) var(--omni-space-4); border-color: var(--omni-border); box-shadow: none; }
.table thead th { color: var(--omni-muted-foreground); background: var(--omni-card); font-size: var(--omni-text-small); font-weight: 500; letter-spacing: 0; text-transform: none; white-space: nowrap; }
.table td { vertical-align: middle; }
.table tbody tr:hover > td { background-color: var(--omni-card); }
.table-responsive { max-width: 100%; }
.table .source-name { overflow-wrap: anywhere; color: var(--omni-foreground); }
.source-url { max-width: var(--omni-source-width); }
.list-group { --tblr-list-group-color: var(--omni-foreground); --tblr-list-group-bg: var(--omni-background); --tblr-list-group-border-color: var(--omni-border); --tblr-list-group-action-color: var(--omni-foreground); --tblr-list-group-action-hover-color: var(--omni-foreground); --tblr-list-group-action-hover-bg: var(--omni-muted); --tblr-list-group-action-active-bg: var(--omni-muted); }
.list-group-item { background-color: var(--omni-background); border-color: var(--omni-border); color: var(--omni-foreground); }
.item { padding: 10px var(--omni-space-4); transition: background-color var(--omni-transition); }
.item:hover, .item:focus-within { background: var(--omni-card); }
.reader-open { display: block; width: 100%; border: 0; padding: 0; background: transparent; color: inherit; font: inherit; text-align: start; }
.reader-open:hover .feed-title, .reader-open:focus-visible .feed-title { color: var(--omni-foreground); text-decoration: underline; text-underline-offset: var(--omni-space-1); }
.feed-title { font-size: var(--omni-text-title); line-height: 1.45; letter-spacing: 0; }
.line-clamp-2 { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; }
.feed-content { flex: 1; min-width: 0; }
.feed-preview { font-size: var(--omni-text-base); line-height: 1.55; }
#list .feed-preview { -webkit-line-clamp: 1; min-height: 1.55em; }
.feed-original { color: var(--omni-muted-foreground); }
#feed-form > .row { --tblr-gutter-x: var(--omni-space-3); --tblr-gutter-y: var(--omni-space-3); }
#feed-form summary { cursor: pointer; font-size: var(--omni-text-small); }
.badge { padding: 2px var(--omni-space-2); border: 1px solid var(--omni-border); border-radius: var(--omni-radius); font-size: var(--omni-text-caption); line-height: 1.4; font-weight: 500; letter-spacing: 0; }
.bg-blue-lt, .bg-primary-lt, .bg-secondary-lt { color: var(--omni-secondary) !important; background-color: var(--omni-muted) !important; border-color: var(--omni-border); }
.bg-green-lt { color: var(--omni-success) !important; background-color: var(--omni-success-bg) !important; border-color: var(--omni-success-border); }
.bg-red-lt { color: var(--omni-danger) !important; background-color: var(--omni-danger-bg) !important; border-color: var(--omni-danger-border); }
.alert { --tblr-alert-color: var(--omni-secondary); --tblr-alert-bg: var(--omni-card); --tblr-alert-border-color: var(--omni-border); color: var(--omni-secondary); background: var(--omni-card); border: 1px solid var(--omni-border); border-radius: var(--omni-radius); box-shadow: none; }
.alert-danger { color: var(--omni-danger); background: var(--omni-danger-bg); border-color: var(--omni-danger-border); }
.alert-success { color: var(--omni-success); background: var(--omni-success-bg); border-color: var(--omni-success-border); }
.alert-warning { color: var(--omni-warning); background: var(--omni-warning-bg); border-color: var(--omni-warning-border); }
.alert-info { color: var(--omni-info); background: var(--omni-info-bg); border-color: var(--omni-info-border); }
.alert-title { color: inherit; }
.search-wrap { position: relative; min-width: 0; }
.dropdown-menu { --tblr-dropdown-bg: var(--omni-card); --tblr-dropdown-color: var(--omni-foreground); --tblr-dropdown-border-color: var(--omni-border); --tblr-dropdown-link-color: var(--omni-secondary); --tblr-dropdown-link-hover-color: var(--omni-foreground); --tblr-dropdown-link-hover-bg: var(--omni-muted); --tblr-dropdown-link-active-bg: var(--omni-muted); --tblr-dropdown-link-active-color: var(--omni-foreground); background: var(--omni-card); color: var(--omni-foreground); border: 1px solid var(--omni-border); border-radius: var(--omni-radius); box-shadow: none; }
.search-results { max-height: 24rem; max-width: 100%; margin-top: var(--omni-space-2); padding: var(--omni-space-1); overflow-y: auto; }
.search-results .dropdown-item { padding: var(--omni-space-2) var(--omni-space-3); border-radius: var(--omni-radius); white-space: normal; overflow-wrap: anywhere; }
.dropdown-item:hover, .dropdown-item:focus, .dropdown-item.active { color: var(--omni-foreground); background: var(--omni-muted); }
.dropdown-header { color: var(--omni-muted-foreground); font-size: var(--omni-text-small); }
.placeholder { color: var(--omni-muted-foreground); opacity: .15; }
.spinner-border { color: var(--omni-muted-foreground); }
#drawer { --tblr-offcanvas-width: min(100vw, var(--omni-reader-width)); --tblr-offcanvas-margin: 0; --tblr-offcanvas-bg: var(--omni-card); --tblr-offcanvas-color: var(--omni-foreground); --tblr-offcanvas-border-color: var(--omni-border); --bs-offcanvas-width: min(100vw, var(--omni-reader-width)); border-inline-start: 1px solid var(--omni-border); border-radius: 0; background: var(--omni-card); color: var(--omni-foreground); box-shadow: none; }
#drawer .offcanvas-header { align-items: flex-start; gap: var(--omni-space-3); padding: var(--omni-space-6); border-bottom: 1px solid var(--omni-border); }
#drawer .offcanvas-body { padding: var(--omni-space-6); }
#d-title { font-size: var(--omni-text-heading); font-weight: 600; line-height: 1.4; letter-spacing: -.025em; overflow-wrap: anywhere; }
.reader-text { white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--omni-reading-size); line-height: 1.75; }
.reader-markdown { font-size: var(--omni-reading-size); line-height: 1.7; overflow-wrap: anywhere; }
.reader-markdown pre, .reader-markdown code { white-space: pre-wrap; overflow-wrap: anywhere; }
.reader-markdown h2 { margin-top: var(--omni-space-6); font-size: var(--omni-text-heading); }
.related-link { white-space: normal; text-align: start; }
.modal { --tblr-modal-bg: var(--omni-card); --tblr-modal-color: var(--omni-foreground); --tblr-modal-border-color: var(--omni-border); --tblr-modal-box-shadow: none; }
.modal-content { background: var(--omni-card); color: var(--omni-foreground); border: 1px solid var(--omni-border); border-radius: var(--omni-radius); box-shadow: none; }
.modal-header, .modal-body, .modal-footer { padding: var(--omni-space-6); border-color: var(--omni-border); }
.modal-footer { background: var(--omni-card); }
.modal-backdrop, .offcanvas-backdrop { --tblr-backdrop-opacity: .8; background: var(--omni-background); }
.toast-container { max-width: 100%; }
#toast { background: var(--omni-card) !important; border-color: var(--omni-border); box-shadow: none !important; }
code, kbd, pre { color: var(--omni-secondary); background-color: var(--omni-muted); border-radius: var(--omni-radius); }
kbd { border: 1px solid var(--omni-border); box-shadow: none; }
pre { padding: var(--omni-space-4); }
:focus-visible, .btn:focus-visible, #sidebar-toggle:focus-visible, .form-control:focus-visible, .form-select:focus-visible, .form-check-input:focus-visible, .nav-link:focus-visible { outline: 2px solid var(--omni-ring); outline-offset: var(--omni-space-1); }
@media (min-width: 992px) {
.navbar.navbar-vertical > .container-fluid { height: 100%; padding: var(--omni-space-6) var(--omni-space-2) 0; }
.navbar.navbar-vertical .navbar-brand { padding: 0 var(--omni-space-3) var(--omni-space-6); }
#sidebar-menu { flex: 1; align-items: stretch; }
}
@media (max-width: 991.98px) {
.navbar.navbar-vertical { border-inline-end: 0; border-bottom: 1px solid var(--omni-border); }
.navbar.navbar-vertical > .container-fluid { padding: var(--omni-space-3) var(--omni-space-4); justify-content: space-between; flex-wrap: wrap; }
.navbar.navbar-vertical .navbar-brand { flex: 1; padding: 0; }
#sidebar-menu { flex-basis: 100%; }
.navbar-vertical .navbar-nav { padding-inline: 0; }
.sidebar-footer { margin-top: var(--omni-space-4) !important; padding-inline: 0; }
.page-wrapper .container-xl { padding-inline: var(--omni-space-6); }
}
@media (max-width: 575.98px) {
.page-wrapper .container-xl { padding-inline: var(--omni-space-4); }
.page-header { padding-block: var(--omni-space-6); }
#page-actions { gap: var(--omni-space-2); }
#drawer { --tblr-offcanvas-width: 100vw; --bs-offcanvas-width: 100vw; }
#drawer .offcanvas-header, #drawer .offcanvas-body { padding: var(--omni-space-4); }
.feed-row { gap: var(--omni-space-2) !important; }
.feed-original { min-height: 0; padding: var(--omni-space-1) 0; font-size: var(--omni-text-caption); }
#list .feed-preview { -webkit-line-clamp: 2; }
.source-url { max-width: 12rem; }
.page-header .btn { white-space: normal; }
.modal-header, .modal-body, .modal-footer { padding: var(--omni-space-4); }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
}
</style>
</head>
<body>
<a class="visually-hidden-focusable skip-link btn btn-primary m-3" href="#main-content" data-skip-main>Перейти к содержимому</a>
<div class="page">
<aside class="navbar navbar-vertical navbar-expand-lg bg-surface" aria-label="Главная навигация">
<div class="container-fluid">
<a class="navbar-brand text-start brand-lockup" href="#/feed" aria-label="OMNI — открыть ленту">
<span class="brand-mark" aria-hidden="true"><svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3h10l3 7-3 7H5l-3-7 3-7Z"/><path d="m5 3 5 7 5-7M5 17l5-7 5 7M2 10h16"/></svg></span>
<span class="min-w-0"><span class="brand-name">OMNI</span><span class="brand-caption" title="Source intelligence">INTELLIGENCE</span></span>
</a>
<button class="navbar-toggler" id="sidebar-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#sidebar-menu" aria-controls="sidebar-menu" aria-expanded="false" aria-label="Открыть меню навигации"><span class="navbar-toggler-icon" aria-hidden="true"></span><span class="ms-2">Меню</span></button>
<div class="collapse navbar-collapse" id="sidebar-menu">
<nav id="nav" class="w-100" aria-label="Разделы приложения"></nav>
<div class="mt-auto sidebar-footer border-top w-100">
<div id="health" class="small text-secondary mb-3" role="status">Подключение к серверу…</div>
<button class="btn btn-ghost-secondary btn-sm w-100 justify-content-start" data-act="help" type="button">Помощь и горячие клавиши</button>
</div>
</div>
</div>
</aside>
<div class="page-wrapper">
<main id="main-content" tabindex="-1">
<div class="page-header d-print-none">
<div class="container-xl">
<div class="row g-3 align-items-center">
<div class="col min-w-0">
<div id="page-group" class="page-pretitle">Рабочая область</div>
<h1 id="crumb" class="page-title" tabindex="-1">Лента</h1>
<p id="page-description" class="text-secondary mt-2 mb-0">Читайте свежие записи подключённых источников.</p>
</div>
<div id="page-actions" class="col-12 col-md-auto d-flex flex-wrap gap-2"></div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<div id="global-feedback" aria-live="polite"></div>
<div id="view" aria-busy="true"></div>
</div>
</div>
</main>
<footer class="footer footer-transparent d-print-none">
<div class="container-xl d-flex flex-wrap justify-content-between gap-2 small text-secondary">
<span>OMNI · личная рабочая лента</span><span>Данные и сессии остаются на сервере</span>
</div>
</footer>
</div>
</div>
<aside class="offcanvas offcanvas-end" tabindex="-1" id="drawer" aria-labelledby="d-title">
<div class="offcanvas-header">
<h2 class="offcanvas-title" id="d-title">Чтение записи</h2>
<button class="btn btn-ghost-secondary btn-sm flex-shrink-0" type="button" data-bs-dismiss="offcanvas" aria-label="Закрыть панель чтения">Закрыть</button>
</div>
<div class="offcanvas-body" id="d-body" aria-busy="false"></div>
</aside>
<div class="modal fade" id="modal" tabindex="-1" aria-labelledby="modal-title" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"><div class="modal-content" id="modal-box"></div></div>
</div>
<div class="toast-container position-fixed bottom-0 end-0 p-3"><div id="toast" class="alert bg-surface shadow d-none mb-0" role="status" aria-live="polite" aria-atomic="true"></div></div>
<script src="/assets/vendor/tabler/tabler.min.js"></script>
<script>
"use strict";
// Tabler's vertical navbar and inbox list-group, adapted from the pinned upstream templates.
const $=(s,r=document)=>r.querySelector(s), $$=(s,r=document)=>[...r.querySelectorAll(s)];
const esc=s=>String(s==null?"":s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const mask=s=>String(s??"").replace(/\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{16,}|xox[baprs]-[A-Za-z0-9-]{10,})\b/g,"[ключ скрыт]").replace(/\b(Bearer\s+)[A-Za-z0-9._~-]{16,}/gi,"$1[скрыто]");
const text=s=>esc(mask(s));
const num=n=>Number.isFinite(Number(n))?Number(n).toLocaleString("ru-RU"):"0";
const int=(n,lo=0,hi=Number.MAX_SAFE_INTEGER)=>Math.min(hi,Math.max(lo,Math.floor(Number(n)||0)));
const dt=s=>{if(!s)return"Не указана";const value=typeof s==="number"?new Date(s*1000):new Date(String(s));return Number.isNaN(value.getTime())?String(s).slice(0,19):value.toLocaleDateString("ru-RU")};
const datetime=s=>{if(!s)return"Нет проверки";const value=typeof s==="number"?new Date(s*1000):new Date(String(s).replace(" ","T"));return Number.isNaN(value.getTime())?String(s).slice(0,19):value.toLocaleString("ru-RU",{day:"2-digit",month:"2-digit",year:"numeric",hour:"2-digit",minute:"2-digit"})};
const debounce=(f,ms=250)=>{let timer;return(...a)=>{clearTimeout(timer);timer=setTimeout(()=>f(...a),ms)}};
function safeUrl(value){try{const u=new URL(String(value||"").trim());return ["http:","https:"].includes(u.protocol)&&!u.username&&!u.password?u.href:""}catch{return""}}
function external(value,label,classes="",extra=""){const url=safeUrl(value);return url?`<a class="${classes}" href="${esc(url)}" target="_blank" rel="noopener noreferrer" ${extra}>${esc(label)}</a>`:`<span class="text-secondary small">Ссылка недоступна</span>`}
const typeLabels=()=>Object.fromEntries((S.types||[]).map(t=>[t.key,t.label]));
const typesOf=it=>Array.isArray(it.types)?it.types:String(it.types||"").split(",").filter(Boolean);
function titleOf(it){let title=mask(it.title||"").trim();if(!title||/^https?:\/\/\S+$/i.test(title))title=`Материал из ${mask(it.source||"источника")}`;return title}
const isOkSt=s=>String(s??"").trim().toLowerCase().startsWith("ok");
const isEmpSt=s=>["пусто","empty"].includes(String(s??"").trim().toLowerCase());
const stKey=s=>!String(s??"").trim()?"none":isOkSt(s)?"ok":isEmpSt(s)?"empty":"err";
const RUN_STATUS={ok:["Успешно","green"],empty:["Нет записей","secondary"],err:["Ошибка","red"],none:["Без статуса","secondary"]};
function statusBadge(key,source=false){const [label,color]=(source?SOURCE_STATUS:RUN_STATUS)[key]||RUN_STATUS.none;return `<span class="badge bg-${color}-lt">${label}</span>`}
const SOURCE_STATUS={ok:["Успешно","green"],empty:["Нет записей","secondary"],err:["Ошибка","red"],none:["Не проверен","secondary"]};
const hasCheck=s=>s.status!=null||s.items!=null;
function sourceStatus(s){if(!hasCheck(s))return"none";if(Number(s.items)>0)return"ok";const code=Number(s.status),note=String(s.note||"").trim();return code>=200&&code<400&&(!note||/^(пусто|empty|ok|нет данных)$/i.test(note))?"empty":"err"}
let TOAST_T;
function toast(message,bad=false){const el=$("#toast");el.textContent=mask(message);el.classList.remove("d-none","border-danger");if(bad)el.classList.add("border-danger");clearTimeout(TOAST_T);TOAST_T=setTimeout(()=>el.classList.add("d-none"),4500)}
async function api(path,opt={}){
const {signal,...options}=opt,controller=new AbortController();let timedOut=false;
const abort=()=>controller.abort();if(signal?.aborted)abort();else signal?.addEventListener("abort",abort,{once:true});
const timer=setTimeout(()=>{timedOut=true;controller.abort()},45000);
try{const response=await fetch(path,{...options,signal:controller.signal,headers:{Accept:"application/json",...(options.body?{"Content-Type":"application/json"}:{}),...options.headers}});let data;
try{data=await response.json()}catch{throw new Error("Сервер вернул некорректный ответ")}
if(!response.ok||(data.error&&!(path==="/api/tg/status"&&typeof data.enabled==="boolean"))||data.ok===false)throw new Error(mask(data.error||`Ошибка запроса: HTTP ${response.status}`));return data;
}catch(error){if(timedOut)throw new Error("Сервер не ответил за 45 секунд. Повторите запрос.");throw error}
finally{clearTimeout(timer);signal?.removeEventListener("abort",abort)}
}
const post=(path,body)=>api(path,{method:"POST",body:JSON.stringify(body||{})});
async function copy(value){if(!value){toast("Нет данных для копирования",true);return}try{if(!navigator.clipboard)throw new Error("clipboard");await navigator.clipboard.writeText(value);toast("Скопировано")}catch{toast("Не удалось скопировать. Разрешите доступ к буферу обмена.",true)}}
const retries=new Map();let retrySerial=0;
function failure(error,retry){const id=String(++retrySerial);if(retry){retries.set(id,retry);if(retries.size>40)retries.delete(retries.keys().next().value)}return `<div class="alert alert-danger mb-0" role="alert"><h3 class="alert-title">Не удалось загрузить данные</h3><div class="text-break">${text(error.message||error)}</div>${retry?`<button class="btn btn-sm mt-3" type="button" data-retry="${id}">Повторить</button>`:""}</div>`}
function loading(label="Загрузка данных…",rows=3){return `<div role="status" class="p-4"><div class="d-flex align-items-center gap-2 text-secondary"><span class="spinner-border spinner-border-sm" aria-hidden="true"></span>${esc(label)}</div><div class="placeholder-glow mt-3" aria-hidden="true">${Array(rows).fill('<div class="mb-3"><span class="placeholder col-8"></span><span class="placeholder col-5 d-block mt-2"></span></div>').join("")}</div></div>`}
function empty(title,description,action=""){return `<div class="p-4"><h3 class="mb-2">${esc(title)}</h3><p class="text-secondary mb-0">${esc(description)}</p>${action?`<div class="mt-3">${action}</div>`:""}</div>`}
const BUSY=new Set();
function syncBusy(){ $$("[data-lock]").forEach(el=>{el.disabled=BUSY.has(el.dataset.lock)||el.dataset.unavailable==="1";if(BUSY.has(el.dataset.lock))el.setAttribute("aria-busy","true");else el.removeAttribute("aria-busy")}) }
async function busy(el,key,work){
if(BUSY.has(key))return;BUSY.add(key);
const origin=activeContext,label=el?.innerHTML,wasDisabled=!!el?.disabled;
if(el?.tagName==="BUTTON"){el.innerHTML='<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>Выполняется…';el.disabled=true;el.setAttribute("aria-busy","true")}
syncBusy();
try{return await work()}
catch(error){if(error.name!=="AbortError"&&origin?.valid()){$("#global-feedback").innerHTML=failure(error,()=>render());toast(error.message,true)}}
finally{BUSY.delete(key);if(el?.isConnected&&label!==undefined){el.innerHTML=label;if(!el.dataset.lock){el.disabled=wasDisabled;el.removeAttribute("aria-busy")}}syncBusy()}
}
function md(src){
const inline=value=>text(value).replace(/`([^`]+)`/g,"<code>$1</code>").replace(/\*\*([^*]+)\*\*/g,"<strong>$1</strong>").replace(/\[([^\]]+)\]\(([^)]+)\)/g,(_,label,encoded)=>{const raw=encoded.replace(/&amp;/g,"&").replace(/&quot;/g,'"').replace(/&#39;/g,"'").replace(/&lt;/g,"<").replace(/&gt;/g,">");const url=safeUrl(raw);return url?`<a href="${esc(url)}" target="_blank" rel="noopener noreferrer">${label}</a>`:label});
let out=[],list=false;for(const line of String(src||"").split("\n")){if(/^\s*-\s+/.test(line)){if(!list){out.push("<ul>");list=true}out.push("<li>"+inline(line.replace(/^\s*-\s+/,""))+"</li>");continue}if(list){out.push("</ul>");list=false}if(/^#{1,3}\s/.test(line))out.push("<h2>"+inline(line.replace(/^#{1,3}\s+/,""))+"</h2>");else if(line.trim())out.push("<p>"+inline(line)+"</p>")}if(list)out.push("</ul>");return out.join("\n")
}
const NAV=[
{id:"overview",label:"Обзор",group:"Система",description:"Состояние реестра, импорт и последние сборы — без изменения ваших данных.",icon:"M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z"},
{id:"feed",label:"Лента",group:"Рабочая область",description:"Свежие записи ваших источников. Найдите нужное и откройте для чтения.",icon:"M4 6h16M4 12h16M4 18h10"},
{id:"abuse",label:"Находки",group:"Рабочая область",description:"Промо, пробные доступы и другие находки из источников, а также панели с бесплатными API.",icon:"M12 3l8 4v6c0 5-3.5 7.5-8 8-4.5-.5-8-3-8-8V7z"},
{id:"sources",label:"Источники",group:"Рабочая область",description:"Управляйте реестром, проверяйте ответы и читайте собранные записи.",icon:"M4 6h16M4 12h16M4 18h16"},
{id:"runs",label:"История сборов",group:"Система",description:"Когда и откуда пришли записи: результат запроса, полученное и новое.",icon:"M12 8v5l3 2M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z"},
{id:"tg",label:"Telegram",group:"Рабочая область",description:"Состояние вашей сессии, поиск каналов и импорт выбранных публикаций.",icon:"M21 4L3 11l5 2 2 6 3-4 5 3z"},
{id:"keys",label:"API-ключи",group:"Система",description:"Готовность ключей и остывание по сервисам. Секретные значения не запрашиваются.",icon:"M14 7a4 4 0 1 1-3 6.9L4 21H2v-3l8.1-6.9A4 4 0 0 1 14 7z"},
{id:"digest",label:"Дайджест",group:"Инструменты",description:"Краткая Markdown-сводка за выбранный период — для чтения и экспорта.",icon:"M6 3h9l4 4v14H6zM15 3v4h4"},
{id:"watch",label:"Отслеживание",group:"Инструменты",description:"Сохраните слова и типы записей. Новые совпадения появятся рядом с правилом.",icon:"M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12zM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z"}
];
let auto=false;try{auto=localStorage.getItem("omni.auto")==="1"}catch{}
const S={view:"feed",q:"",cat:"",type:"all",sort:"fresh",days:0,priced:false,src:"",page:1,items:[],total:0,types:[],cats:[],auto,srcQ:"",srcCat:"",srcState:"",srcEnabled:"",runQ:"",runF:"all",tag:"",tab:"signals",digestDays:3};
let renderVersion=0,activeContext=null,readerVersion=0,readerController=null,readerReturnFocus=null,modalReturnFocus=null;
const serials={};
function requestScope(key,ctx=activeContext){const serial=(serials[key]||0)+1;serials[key]=serial;return {signal:ctx?.signal,valid:()=>!!ctx?.valid()&&serials[key]===serial}}
function route(){
const [raw,query=""]=location.hash.replace(/^#\/?/,"").split("?"),p=new URLSearchParams(query),before=S.view;
S.view=NAV.some(n=>n.id===raw)?raw:"feed";S.q=p.get("q")||"";S.cat=p.get("cat")||"";S.type=p.get("type")||"all";
S.sort=["fresh","old","score","price"].includes(p.get("sort"))?p.get("sort"):"fresh";S.days=int(p.get("days"),0,3650);S.priced=p.get("priced")==="1";S.src=p.get("src")||"";S.page=int(p.get("page")||1,1,25001);S.tag=p.get("tag")||"";S.tab=p.get("tab")==="panels"?"panels":"signals";
closeDrawer();if($("#modal").classList.contains("show"))tabler.Modal.getOrCreateInstance($("#modal")).hide();
if(before!==S.view)$("#global-feedback").innerHTML="";render(before!==S.view);
}
function go(view,params={},replace=false){
const state={...S,...params},p=new URLSearchParams();for(const key of ["q","cat","src"])if(state[key])p.set(key,state[key]);if(state.type!=="all")p.set("type",state.type);if(state.sort!=="fresh")p.set("sort",state.sort);if(state.days)p.set("days",state.days);if(state.priced)p.set("priced","1");
const changedFilter=["q","cat","type","sort","days","priced","src"].some(key=>key in params);const page=params.page??(view!==S.view||changedFilter?1:S.page);if(page>1&&view==="feed")p.set("page",page);if(view==="abuse"){if(state.tag)p.set("tag",state.tag);if(state.tab==="panels")p.set("tab","panels")}
const hash="#/"+view+(p.size?"?"+p.toString():"");if(replace||location.hash===hash){history.replaceState(null,"",hash);route()}else location.hash=hash;
}
function paintNav(){
const systemOpen=$("#system-nav")?.open||["overview","runs","keys"].includes(S.view);
const link=id=>{const n=NAV.find(v=>v.id===id);return `<li class="nav-item"><a class="nav-link ${S.view===id?"active":""}" href="#/${id}" data-nav="${id}" ${S.view===id?'aria-current="page"':""}><span class="nav-link-icon d-md-none d-lg-inline-block"><svg class="icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${n.icon}"/></svg></span><span class="nav-link-title">${n.label}</span></a></li>`};
$("#nav").innerHTML=`<div class="subheader px-3 mt-3 mb-2">Рабочая область</div><ul class="navbar-nav">${["feed","abuse","sources","tg"].map(link).join("")}</ul><div class="subheader px-3 mt-4 mb-2">Инструменты</div><ul class="navbar-nav">${["watch","digest"].map(link).join("")}</ul><details id="system-nav" class="mt-4 mb-3" ${systemOpen?"open":""}><summary class="subheader px-3">Система</summary><ul class="navbar-nav mt-2">${["overview","runs","keys"].map(link).join("")}</ul></details>`;
}
function pageHeader(){const n=NAV.find(n=>n.id===S.view);document.title="OMNI — "+n.label;$("#crumb").textContent=n.label;$("#page-group").textContent=n.group;$("#page-description").textContent=n.description;
const primary={feed:'<button class="btn btn-primary" data-act="run" data-lock="feed" type="button">Собрать записи</button>',sources:'<button class="btn btn-primary" data-act="add-src" type="button">Добавить источник</button>',abuse:S.tab==="signals"?'<button class="btn btn-primary" data-act="summary" data-lock="summary" type="button">Сводка по находкам</button>':"",digest:'<button class="btn btn-primary" data-act="digest" data-lock="digest" type="button">Собрать дайджест</button>',overview:'<a class="btn btn-primary" href="#/feed" data-nav="feed">Открыть ленту</a>'};
$("#page-actions").innerHTML=`<button class="btn" id="page-refresh" data-act="refresh" type="button">Обновить</button>${primary[S.view]||""}`;syncBusy();
}
function categoryOptions(value=""){const names=[...new Set([...(S.cats||[]),...(value?[value]:[])])];return '<option value="">Все категории</option>'+names.map(name=>`<option value="${esc(name)}" ${name===value?"selected":""}>${text(name)}</option>`).join("")}
function typeOptions(value="all"){const rows=[...S.types];if(value!=="all"&&!rows.some(t=>t.key===value))rows.push({key:value,label:value});return '<option value="all">Все типы</option>'+rows.map(t=>`<option value="${esc(t.key)}" ${t.key===value?"selected":""}>${text(t.label)}</option>`).join("")}
function refreshOptions(){if($("#cat"))$("#cat").innerHTML=categoryOptions(S.cat);if($("#f-type"))$("#f-type").innerHTML=typeOptions(S.type);if($("#w-type"))$("#w-type").innerHTML=typeOptions($("#w-type").value);if($("#s-cat"))$("#s-cat").innerHTML=categoryOptions(S.srcCat)}
async function loadMeta(){const values=await Promise.allSettled([api("/api/stats"),api("/api/types")]);if(values[0].status==="fulfilled"){S.stats=values[0].value;S.cats=(S.stats.categories||[]).map(c=>c.name);paintHealth(S.stats)}else $("#health").innerHTML='API недоступен. <button class="btn btn-link btn-sm p-0" data-act="refresh-health" type="button">Повторить</button>';if(values[1].status==="fulfilled"){S.types=values[1].value.types||[];if($("#meta-error"))$("#meta-error").innerHTML=""}else if($("#meta-error"))$("#meta-error").innerHTML=failure(values[1].reason,loadMeta);refreshOptions()}
function paintHealth(h){$("#health").textContent=`${num(h.items)} записей · ${num(h.alive)} из ${num(h.sources)} источников отдавали записи`}
async function refreshHealth(){try{paintHealth(await api("/api/health"))}catch{$("#health").innerHTML='Сервер недоступен. <button class="btn btn-link btn-sm p-0" data-act="refresh-health" type="button">Повторить</button>'}}
const V={};
async function render(focusHeading=false){
const focused=document.activeElement,restore=focused?.id&&$("#view").contains(focused)?{id:focused.id,start:focused.selectionStart,end:focused.selectionEnd}:null;
activeContext?.controller.abort();const controller=new AbortController(),version=++renderVersion;let restored=false;
const ctx={controller,signal:controller.signal,valid:()=>renderVersion===version&&!controller.signal.aborted,mount(html){if(!this.valid())return;$("#view").innerHTML=html;syncBusy();if(!restored&&restore){const input=document.getElementById(restore.id);if(input){input.focus({preventScroll:true});if(typeof restore.start==="number"&&typeof input.setSelectionRange==="function")try{input.setSelectionRange(restore.start,restore.end)}catch{}restored=true}}}};activeContext=ctx;
paintNav();pageHeader();$("#view").setAttribute("aria-busy","true");$("#page-refresh").disabled=true;ctx.mount(`<div class="card">${loading()}</div>`);
try{await V[S.view](ctx)}catch(error){if(ctx.valid()&&error.name!=="AbortError")ctx.mount(`<div class="card card-body">${failure(error,()=>render())}</div>`)}
finally{if(ctx.valid()){$("#view").setAttribute("aria-busy","false");$("#page-refresh").disabled=false;syncBusy();if(focusHeading)$("#crumb").focus({preventScroll:true});if(S.focusSearch){$("#q")?.focus();S.focusSearch=false}}}
}
function findChips(f){if(!f)return"";const parts=[];if(f.codes?.length)parts.push("Промокод: "+f.codes.slice(0,2).map(c=>`<code>${text(c)}</code>`).join(", "));if(f.amount)parts.push("Объём: "+text(f.amount));if(f.term)parts.push("Срок: "+text(f.term));return parts.length?`<div class="small text-secondary mt-1">${parts.join(" · ")}</div>`:""}
function itemRow(it,compact=false){const labels=typeLabels(),types=typesOf(it).slice(0,2),url=safeUrl(it.url);return `<article class="list-group-item item"><div class="d-flex align-items-start gap-3 feed-row"><div class="feed-content"><button type="button" class="reader-open" data-id="${int(it.id)}" aria-label="Читать: ${esc(titleOf(it))}"><span class="feed-title fw-semibold line-clamp-2">${esc(titleOf(it))}</span>${!compact&&it.text?`<span class="text-secondary feed-preview line-clamp-2 mt-1">${text(it.text)}</span>`:""}</button><div class="d-flex flex-wrap align-items-center gap-2 mt-2 small"><span class="text-secondary">${text(it.source||"Источник не указан")}</span><span class="text-secondary">${esc(dt(it.published))}</span>${types.map(t=>`<span class="badge bg-blue-lt">${text(labels[t]||t)}</span>`).join("")}${it.price?`<span class="text-secondary">${text(it.price)} ${text(it.currency||"")}</span>`:""}</div>${compact?"":findChips(it.find)}</div>${url?`<a class="btn btn-ghost-secondary btn-sm flex-shrink-0 feed-original" href="${esc(url)}" target="_blank" rel="noopener noreferrer" aria-label="Открыть оригинал: ${esc(titleOf(it))}">Оригинал <span class="ms-1" aria-hidden="true">↗</span></a>`:""}</div></article>`}
const exQS=()=>new URLSearchParams({q:S.q,category:S.cat,type:S.type,sort:S.sort,...(S.days?{days:String(S.days)}:{}),...(S.priced?{priced:"1"}:{}),...(S.src?{src:S.src}:{}),limit:"1000"}).toString();
function readFeedFilters(){return {q:$("#q")?.value.trim()||"",cat:$("#cat")?.value||"",sort:$("#f-sort")?.value||"fresh",days:+($("#f-days")?.value||0),type:$("#f-type")?.value||"all",src:$("#f-src")?.value.trim()||"",priced:!!$("#f-priced")?.checked,page:1}}
V.feed=async ctx=>{
ctx.mount(`<div class="card"><form id="feed-form" class="card-body border-bottom"><div class="row g-3 align-items-end"><div class="col-12 col-xl-5"><label class="form-label" for="q">Поиск по заголовкам и тексту</label><div class="search-wrap"><div class="input-group"><input class="form-control" id="q" type="search" placeholder="Поиск по заголовкам и тексту" value="${esc(S.q)}" autocomplete="off" aria-controls="sug" aria-expanded="false"><button class="btn" type="button" data-act="clear-search" aria-label="Очистить поисковый запрос">Очистить</button><button class="btn" type="submit">Найти</button></div><div class="dropdown-menu w-100 search-results d-none" id="sug" aria-label="Подсказки поиска"></div></div></div><div class="col-6 col-md-4 col-xl-2"><label class="form-label" for="f-days">Период публикации</label><select class="form-select" id="f-days">${[[0,"За всё время"],[1,"За сутки"],[7,"За неделю"],[30,"За месяц"],[90,"За квартал"],...([0,1,7,30,90].includes(S.days)?[]:[[S.days,`За ${S.days} дн.`]])].map(([v,l])=>`<option value="${v}" ${S.days===v?"selected":""}>${l}</option>`).join("")}</select></div><div class="col-6 col-md-4 col-xl-3"><label class="form-label" for="cat">Категория</label><select class="form-select" id="cat">${categoryOptions(S.cat)}</select></div><div class="col-12 col-md-4 col-xl-2"><label class="form-label" for="f-sort">Сортировка</label><select class="form-select" id="f-sort">${[["fresh","Сначала новые"],["score","По релевантности"],["old","Сначала старые"],["price","По цене"]].map(([v,l])=>`<option value="${v}" ${S.sort===v?"selected":""}>${l}</option>`).join("")}</select></div></div><details class="mt-3" ${S.type!=="all"||S.src||S.priced?"open":""}><summary class="text-secondary">Дополнительные фильтры</summary><div class="row g-3 align-items-end mt-1"><div class="col-sm-4"><label class="form-label" for="f-type">Тип записи</label><select class="form-select" id="f-type">${typeOptions(S.type)}</select></div><div class="col-sm-5"><label class="form-label" for="f-src">Название источника</label><input class="form-control" id="f-src" value="${esc(S.src)}" placeholder="Точное название из реестра"></div><div class="col-sm-3"><label class="form-check mb-2"><input class="form-check-input" type="checkbox" id="f-priced" ${S.priced?"checked":""}><span class="form-check-label">Только с ценой</span></label></div></div></details><div id="meta-error"></div></form><div class="card-body py-3 d-flex flex-wrap gap-3 align-items-center border-bottom"><div id="feed-count" class="text-secondary" aria-live="polite">Загрузка записей…</div><button class="btn btn-link btn-sm p-0" type="button" data-act="reset-filters">Сбросить фильтры</button><div class="d-flex flex-wrap gap-2 ms-sm-auto"><a class="btn btn-sm" id="export-csv" href="/api/export.csv?${exQS()}" download title="Те же фильтры, до 1000 записей">Экспорт CSV</a><a class="btn btn-sm" id="export-json" href="/api/export.json?${exQS()}" download title="Те же фильтры, до 1000 записей">JSON</a></div></div><div id="list" class="list-group list-group-flush">${loading("Загрузка ленты…",6)}</div><div id="more" class="card-footer"></div></div>`);
await loadItems(ctx);
};
async function loadItems(ctx=activeContext){const scope=requestScope("feed",ctx),offset=(S.page-1)*40,p=new URLSearchParams({limit:"40",offset:String(offset),q:S.q,sort:S.sort,type:S.type,...(S.cat?{category:S.cat}:{}),...(S.days?{days:String(S.days)}:{}),...(S.priced?{priced:"1"}:{}),...(S.src?{src:S.src}:{})});
try{const d=await api("/api/items?"+p,{signal:scope.signal});if(!scope.valid())return;S.items=d.items||[];S.total=Number(d.total)||0;$("#feed-count").textContent=`Найдено ${num(S.total)} записей`;$("#list").innerHTML=S.items.map(it=>itemRow(it)).join("")||empty("По этим условиям записей нет","Попробуйте другой период или сбросьте фильтры. Если реестр пуст, добавьте источник и запустите сбор.",'<button class="btn" data-act="reset-filters" type="button">Сбросить фильтры</button> <a class="btn" href="#/sources" data-nav="sources">Источники</a>');$("#more").innerHTML=`<div class="d-flex flex-wrap gap-3 align-items-center justify-content-between"><span class="text-secondary" id="feed-range">${S.items.length?num(offset+1):"0"}–${num(offset+S.items.length)} из ${num(S.total)}</span><nav class="btn-group" aria-label="Страницы ленты"><button class="btn" type="button" data-act="page-prev" ${S.page<=1?"disabled":""}>Назад</button><button class="btn" type="button" data-act="page-next" ${offset+S.items.length>=S.total?"disabled":""}>Вперёд</button></nav></div>`}
catch(error){if(scope.valid()&&error.name!=="AbortError"){$("#feed-count").textContent="Лента не загружена";$("#list").innerHTML=`<div class="p-3">${failure(error,()=>loadItems(ctx))}</div>`;$("#more").innerHTML=""}}
}
function readerContext(title){const el=$("#drawer");readerController?.abort();readerController=new AbortController();const version=++readerVersion;if(!el.classList.contains("show"))readerReturnFocus=document.activeElement;$("#d-title").textContent=mask(title);$("#d-body").innerHTML=loading("Загрузка содержимого…");$("#d-body").setAttribute("aria-busy","true");tabler.Offcanvas.getOrCreateInstance(el).show();return {signal:readerController.signal,valid:()=>readerVersion===version&&!readerController.signal.aborted}}
function closeDrawer(){readerController?.abort();readerVersion++;const instance=tabler.Offcanvas.getInstance($("#drawer"));if(instance)instance.hide()}
$("#drawer").addEventListener("hidden.bs.offcanvas",()=>{readerController?.abort();readerVersion++;if(readerReturnFocus?.isConnected)readerReturnFocus.focus({preventScroll:true});else $("#crumb").focus({preventScroll:true});readerReturnFocus=null});
function related(items){return `<div class="list-group list-group-flush">${(items||[]).map(it=>`<button class="list-group-item list-group-item-action related-link" type="button" data-id="${int(it.id)}">${esc(titleOf(it))}<span class="d-block text-secondary small mt-1">${text(it.source||"")} · ${esc(dt(it.published))}</span></button>`).join("")}</div>`}
async function openItem(id){if(!id)return;const ctx=readerContext("Чтение записи");try{const d=await api("/api/item/"+int(id),{signal:ctx.signal});if(!ctx.valid())return;const it=d.item;if(!it)throw new Error("Запись не найдена");const labels=typeLabels(),url=safeUrl(it.url);$("#d-title").textContent=titleOf(it);$("#d-body").innerHTML=`<div class="d-flex flex-wrap align-items-center gap-2 mb-3"><span class="fw-medium">${text(it.source||"Источник не указан")}</span><span class="text-secondary">Опубликовано: ${esc(dt(it.published))}</span>${typesOf(it).slice(0,2).map(t=>`<span class="badge bg-blue-lt">${text(labels[t]||t)}</span>`).join("")}</div><div class="d-flex flex-wrap gap-2 mb-4">${external(url,"Открыть оригинал","btn btn-primary")}${url?`<button class="btn" data-copy="${esc(url)}" type="button">Копировать ссылку</button>`:""}<button class="btn" type="button" data-watch="${esc(titleOf(it).slice(0,80))}" data-lock="watch-write">Отслеживать тему</button></div>${it.text?`<div class="reader-text">${text(it.text)}</div>`:empty("Полный текст не передан","Откройте оригинал: источник передал только заголовок.")}<details class="mt-4 border-top pt-3"><summary class="text-secondary">Сведения о записи</summary><dl class="row mt-3 mb-0"><dt class="col-4">Автор</dt><dd class="col-8">${text(it.author||"Не указан")}</dd><dt class="col-4">Провайдер / вид</dt><dd class="col-8">${text(it.provider||"—")} / ${text(it.kind||"—")}</dd><dt class="col-4">Релевантность</dt><dd class="col-8">${text(it.score??"Не рассчитана")}</dd>${it.price?`<dt class="col-4">Цена</dt><dd class="col-8">${text(it.price)} ${text(it.currency||"")}</dd>`:""}</dl></details>${it.related_same_source?.length?`<section class="mt-4"><h3 class="h3">Ещё из этого источника</h3>${related(it.related_same_source)}</section>`:""}${it.related_same_host?.length?`<section class="mt-4"><h3 class="h3">С того же сайта</h3>${related(it.related_same_host)}</section>`:""}`;syncBusy()}
catch(error){if(ctx.valid()&&error.name!=="AbortError")$("#d-body").innerHTML=failure(error,()=>openItem(id))}finally{if(ctx.valid())$("#d-body").setAttribute("aria-busy","false")}}
V.abuse=async ctx=>{
ctx.mount(`<div class="card"><div class="card-header"><nav class="nav nav-tabs card-header-tabs" aria-label="Виды находок"><a class="nav-link ${S.tab==="signals"?"active":""}" href="#/abuse" data-ab-tab="signals" ${S.tab==="signals"?'aria-current="page"':""}>Находки в публикациях</a><a class="nav-link ${S.tab==="panels"?"active":""}" href="#/abuse?tab=panels" data-ab-tab="panels" ${S.tab==="panels"?'aria-current="page"':""}>Бесплатные API-панели</a></nav></div><div id="ab-content">${loading("Загрузка находок…",5)}</div><div id="pn-box" ${S.tab!=="panels"?"hidden":""}></div></div><div id="sum-box" class="mt-4" aria-live="polite"></div>`);
if(S.tab==="panels"){$("#ab-content").hidden=true;await loadPanels(ctx);return}
const d=await api("/api/abuse?limit=120"+(S.tag?"&tag="+encodeURIComponent(S.tag):""),{signal:ctx.signal});if(!ctx.valid())return;S.abuse=d.items||[];const tags=Object.entries(d.labels||{});
$("#ab-content").innerHTML=`<div class="card-body border-bottom"><div class="row g-3 align-items-end"><div class="col-sm-5"><label class="form-label" for="ab-tag">Что искать в публикациях</label><select class="form-select" id="ab-tag"><option value="">Все находки</option>${tags.map(([tag,label])=>`<option value="${esc(tag)}" ${S.tag===tag?"selected":""}>${text(label)}</option>`).join("")}</select></div><div class="col text-secondary">Показано ${num(S.abuse.length)} из ${num(d.total)} находок${d.uniq&&d.uniq!==d.total?` · ${num(d.uniq)} уникальных`:"\u00a0"}</div></div><p class="text-secondary small mt-3 mb-0">Это признаки в публикациях, а не подтверждённые предложения. Проверяйте условия в оригинале. Значения API-ключей не отображаются.</p></div><div id="ab-list" class="list-group list-group-flush">${S.abuse.map(it=>itemRow(it)).join("")||empty("Находок пока нет","Выберите другой признак или соберите свежие записи источников.",'<button class="btn" data-tag="" type="button">Все находки</button>')}</div>`;
};
async function loadPanels(ctx=activeContext){const scope=requestScope("panels",ctx),box=$("#pn-box");if(!box)return;box.innerHTML=loading("Загрузка API-панелей…");try{const d=await api("/api/panels",{signal:scope.signal});if(!scope.valid())return;const all=(d.panels||[]).filter(p=>Number(p.free_count||p.free||0)>0),rows=all.slice(0,40);box.innerHTML=`<div class="card-body border-bottom"><h2 class="card-title">Панели с бесплатными моделями</h2><p class="text-secondary mb-0">${num(all.length)} с бесплатными моделями из ${num(d.total)} в реестре · показано ${num(rows.length)}. Доступность и условия проверяйте на сайте панели. Учётные данные здесь не запрашиваются.</p></div>${rows.length?`<div class="table-responsive"><table class="table table-vcenter mb-0" id="panels-table"><thead><tr><th scope="col">Панель</th><th scope="col">Бесплатных моделей</th><th scope="col">Всего моделей</th><th scope="col">Действие</th></tr></thead><tbody>${rows.map(p=>{const raw=String(p.host||p.url||""),url=safeUrl(/^https?:\/\//i.test(raw)?raw:"https://"+raw),host=url?new URL(url).hostname:"Адрес недоступен",models=Array.isArray(p.models)?p.models.length:(p.models||p.model_count||0);return `<tr><td class="fw-medium text-break">${esc(host)}</td><td>${num(p.free_count||p.free||0)}</td><td>${num(models)}</td><td>${external(url,"Открыть панель","btn btn-sm")}</td></tr>`}).join("")}</tbody></table></div>`:empty("Бесплатных панелей пока нет","В текущем реестре нет панелей с бесплатными моделями. Обновите список позже.",'<button class="btn" data-act="panels-retry" type="button">Обновить список</button>')}`}
catch(error){if(scope.valid()&&error.name!=="AbortError")box.innerHTML=`<div class="p-3">${failure(error,()=>loadPanels(ctx))}</div>`}}
async function summary(){const ctx=requestScope("summary"),box=$("#sum-box");if(!box)return;box.innerHTML=`<div class="card">${loading("Подготовка сводки…")}</div>`;try{const d=await api("/api/summary?limit=14"+(S.tag?"&tag="+encodeURIComponent(S.tag):""),{signal:ctx.signal});if(!ctx.valid())return;box.innerHTML=`<section class="card card-body"><h2 class="card-title">Сводка по ${num(d.n)} находкам</h2><div class="reader-markdown">${md(d.summary||"Сводка не сформирована. Проверьте настройку ИИ на сервере.")}</div></section>`}catch(error){if(ctx.valid()&&error.name!=="AbortError"){const reason=String(error.message||"");const hint=reason.includes("ai_not_configured")?"ИИ не настроен. Добавьте ключ в разделе «Ключи» и повторите сводку.":"";box.innerHTML=`<div class="card card-body">${hint?`<p class="mb-2">${esc(hint)}</p>`:""}${failure(error,summary)}</div>`}}}
V.sources=async ctx=>{
ctx.mount(`<div class="card"><div class="card-body border-bottom"><div class="row g-3 align-items-end"><div class="col-sm-6 col-xl-4"><label class="form-label" for="s-q">Поиск источника</label><input class="form-control" id="s-q" type="search" value="${esc(S.srcQ)}" placeholder="Название или адрес сайта"></div><div class="col-sm-6 col-xl-3"><label class="form-label" for="s-cat">Категория</label><select class="form-select" id="s-cat">${categoryOptions(S.srcCat)}</select></div><div class="col-6 col-xl-3"><label class="form-label" for="s-state">Результат проверки</label><select class="form-select" id="s-state">${[["","Любой результат"],["ok","Успешно"],["empty","Нет записей"],["err","Ошибка"],["none","Не проверен"]].map(([v,l])=>`<option value="${v}" ${S.srcState===v?"selected":""}>${l}</option>`).join("")}</select></div><div class="col-6 col-xl-2"><label class="form-label" for="s-enabled">Участие в сборе</label><select class="form-select" id="s-enabled">${[["","Все"],["on","Включены"],["off","Выключены"]].map(([v,l])=>`<option value="${v}" ${S.srcEnabled===v?"selected":""}>${l}</option>`).join("")}</select></div></div><div class="d-flex flex-wrap align-items-center gap-3 mt-3"><button class="btn btn-sm" type="button" data-act="check-all" data-lock="source-check">Проверить категорию</button><button class="btn btn-link btn-sm p-0" data-act="source-reset" type="button">Сбросить фильтры</button><details class="ms-sm-auto"><summary class="text-secondary small">Массовое включение</summary><div class="d-flex flex-wrap gap-2 mt-2"><button class="btn btn-sm" data-act="toggle-all" data-on="1" data-lock="source-write" type="button">Включить категорию</button><button class="btn btn-sm" data-act="toggle-all" data-on="0" data-lock="source-write" type="button">Выключить категорию</button></div></details></div><p class="text-secondary small mt-3 mb-0">«Включён» — участвует в сборе. «Проверен» — есть результат запроса; включение само по себе не считается проверкой. «В базе» — уже сохранённые записи.</p></div><div class="card-body py-3 text-secondary" id="src-summary" aria-live="polite">Загрузка реестра…</div><div id="src-bulk" class="card-body bg-primary-lt border-top border-bottom" hidden><div class="d-flex flex-wrap gap-2 align-items-center"><span id="src-sel" class="me-auto">Выбрано: 0</span><button class="btn btn-sm" data-act="bulk-on" data-lock="source-write" type="button">Включить</button><button class="btn btn-sm" data-act="bulk-off" data-lock="source-write" type="button">Выключить</button><button class="btn btn-sm" data-act="bulk-check" data-lock="source-check" type="button">Проверить выбранные</button></div></div><div class="table-responsive"><table class="table table-vcenter mb-0" id="src-tbl"><thead><tr><th scope="col"><input class="form-check-input m-0" type="checkbox" id="src-all" aria-label="Выбрать все показанные источники"></th><th scope="col">Источник</th><th scope="col">Категория</th><th scope="col">Результат</th><th scope="col" class="text-end">В базе</th><th scope="col">Последняя проверка</th><th scope="col">Действия</th></tr></thead><tbody><tr><td colspan="7">${loading("Загрузка источников…",5)}</td></tr></tbody></table></div></div>`);await loadSources(ctx);
};
async function loadSources(ctx=activeContext){const scope=requestScope("sources",ctx),p=new URLSearchParams({category:S.srcCat,q:S.srcQ,state:""}),filters={state:S.srcState,enabled:S.srcEnabled};const tbody=$("#src-tbl tbody");if(!tbody)return;tbody.setAttribute("aria-busy","true");try{const d=await api("/api/sources?"+p,{signal:scope.signal});if(!scope.valid())return;if(!S.cats.length){S.cats=(d.categories||[]).map(c=>c.name);refreshOptions()}const filtered=(d.items||[]).filter(s=>(!filters.state||sourceStatus(s)===filters.state)&&(!filters.enabled||(filters.enabled==="on"?s.enabled:!s.enabled))),rows=filtered.slice(0,900),checked=filtered.filter(hasCheck).length;
$("#src-summary").textContent=`В реестре: ${num(d.total)} · По фильтрам: ${num(filtered.length)} · Включено: ${num(filtered.filter(s=>s.enabled).length)} · С результатом проверки: ${num(checked)}${filtered.length>900?" · Показаны первые 900 — уточните поиск":""}`;
tbody.innerHTML=rows.map(s=>`<tr data-src="${esc(s.name)}"><td><input type="checkbox" class="form-check-input m-0 src-ck" data-name="${esc(s.name)}" aria-label="Выбрать источник ${text(s.name)}"></td><td><button class="btn btn-link p-0 text-start source-name fw-semibold" type="button" data-source="${esc(s.name)}">${text(s.name)}</button><span class="d-block text-secondary small text-truncate source-url" title="${text(s.url)}">${text(s.url)}</span></td><td>${text(s.category)}</td><td>${statusBadge(sourceStatus(s),true)}${s.note&&sourceStatus(s)==="err"?`<span class="d-block text-secondary small mt-1">${text(s.note)}</span>`:""}</td><td class="text-end">${num(s.stored)}</td><td class="text-secondary small text-nowrap">${hasCheck(s)?esc(datetime(s.checked)):"Нет проверки"}</td><td><div class="d-flex flex-wrap align-items-center gap-3"><label class="form-check form-switch mb-0"><input class="form-check-input" type="checkbox" data-source-toggle="${esc(s.name)}" data-lock="source-write" ${s.enabled?"checked":""}><span class="form-check-label">Включён</span></label><button class="btn btn-sm" data-check-src="${esc(s.name)}" data-lock="source-check" type="button">Проверить</button></div></td></tr>`).join("")||`<tr><td colspan="7">${empty("Источники не найдены","Измените поиск, статус или категорию. Новый источник можно добавить кнопкой вверху.",'<button class="btn" data-act="source-reset" type="button">Сбросить фильтры</button>')}</td></tr>`;$("#src-all").checked=false;srcBulkBar();syncBusy()
}catch(error){if(scope.valid()&&error.name!=="AbortError"){tbody.innerHTML=`<tr><td colspan="7">${failure(error,()=>loadSources(ctx))}</td></tr>`;$("#src-summary").textContent="Реестр не загружен"}}finally{if(scope.valid())tbody.setAttribute("aria-busy","false")}}
const selectedSrc=()=> $$(".src-ck:checked").map(el=>el.dataset.name);
function srcBulkBar(){const n=selectedSrc().length,bar=$("#src-bulk");if(!bar)return;bar.hidden=!n;$("#src-sel").textContent=`Выбрано: ${n}`;const checkboxes=$$(".src-ck"),all=$("#src-all");if(all){all.indeterminate=n>0&&n<checkboxes.length;all.checked=checkboxes.length>0&&n===checkboxes.length}}
async function srcDetail(name){const ctx=readerContext(name);try{const d=await api("/api/source?name="+encodeURIComponent(name),{signal:ctx.signal});if(!ctx.valid())return;const s=d.source,state=d.state||{};$("#d-title").textContent=mask(s.name);$("#d-body").innerHTML=`<dl class="row"><dt class="col-4">Категория</dt><dd class="col-8">${text(s.category)}</dd><dt class="col-4">Адрес</dt><dd class="col-8 text-break">${external(s.url,"Открыть сайт источника")}</dd><dt class="col-4">Результат</dt><dd class="col-8">${statusBadge(sourceStatus(state),true)} ${state.note?`<span class="d-block small text-secondary mt-1">${text(state.note)}</span>`:""}</dd><dt class="col-4">Последняя проверка</dt><dd class="col-8">${hasCheck(state)?esc(datetime(state.checked)):"Нет проверки"}</dd><dt class="col-4">В базе</dt><dd class="col-8">${num(d.stored)}</dd><dt class="col-4">Адаптер</dt><dd class="col-8">${text(s.adapter)}</dd><dt class="col-4">Метки</dt><dd class="col-8">${(s.tags||[]).map(text).join(", ")||"Нет"}</dd></dl><div class="d-flex flex-wrap gap-2 mb-4"><button class="btn" type="button" data-toggle-src="${esc(name)}" data-on="${state.enabled===false?1:0}" data-lock="source-write">${state.enabled===false?"Включить источник":"Выключить источник"}</button><button class="btn" type="button" data-check-src="${esc(name)}" data-lock="source-check">Проверить</button><button class="btn" type="button" data-act="feed-src" data-category="${esc(s.category)}" data-lock="feed">Собрать категорию</button><button class="btn btn-primary" type="button" data-source-feed="${esc(name)}">Читать записи</button></div><section><h3 class="h3">Последние записи</h3>${d.latest?.length?related(d.latest):empty("Записей пока нет","Запустите сбор категории источника.")}</section><section class="mt-4"><h3 class="h3">Последние сборы</h3>${d.runs?.length?`<div class="table-responsive"><table class="table table-sm"><thead><tr><th scope="col">Когда</th><th scope="col">Получено / новых</th><th scope="col">Результат</th></tr></thead><tbody>${d.runs.map(r=>`<tr><td class="small">${esc(datetime(r.when))}</td><td>${num(r.got)} / ${num(r.kept)}</td><td>${statusBadge(stKey(r.status))}<span class="d-block small text-secondary mt-1">${text(r.status||"")}</span></td></tr>`).join("")}</tbody></table></div>`:empty("Сборов ещё не было","После сбора здесь появится его результат.")}</section>`;$("#drawer").dataset.source=name;syncBusy()}
catch(error){if(ctx.valid()&&error.name!=="AbortError")$("#d-body").innerHTML=failure(error,()=>srcDetail(name))}finally{if(ctx.valid())$("#d-body").setAttribute("aria-busy","false")}}
V.runs=async ctx=>{const d=await api("/api/runs?limit=200",{signal:ctx.signal});if(!ctx.valid())return;S.runs=d.items||[];S.runTotal=d.total;const counts=d.by_status||{};
ctx.mount(`<div class="card"><div class="card-body border-bottom"><label class="form-label" for="r-q">Поиск по источнику или запросу</label><input class="form-control" type="search" id="r-q" placeholder="Название источника или текст запроса" value="${esc(S.runQ)}"><div class="d-flex flex-wrap gap-2 mt-3" aria-label="Фильтр по результату">${[["all","Все",d.total],["ok","Успешно",counts.ok],["empty","Нет записей",counts.empty],["err","Ошибки",counts.err],["none","Без статуса",counts.none]].map(([v,l,n])=>`<button class="btn btn-sm ${S.runF===v?"btn-primary":""}" data-rf="${v}" aria-pressed="${S.runF===v}" type="button">${l} · ${num(n)}</button>`).join("")}</div><p class="text-secondary small mt-3 mb-0">Счётчики — по всей истории. Ниже фильтруются последние 200 сборов; пустой ответ не считается ошибкой.</p><div id="runs-count" class="text-secondary mt-2" aria-live="polite"></div></div><div class="table-responsive"><table class="table table-vcenter mb-0" id="r-tbl"><thead><tr><th scope="col">Дата и время</th><th scope="col">Источник</th><th scope="col">Запрос</th><th scope="col" class="text-end">Получено</th><th scope="col" class="text-end">Новых</th><th scope="col" class="text-end">Байт</th><th scope="col">Результат</th></tr></thead><tbody>${S.runs.map(r=>`<tr data-st="${stKey(r.status)}"><td class="text-secondary small text-nowrap">${esc(datetime(r.when))}</td><td>${text(r.source||"Не указан")}</td><td><span class="line-clamp-2 small" title="${text(r.query||"")}">${text(r.query||"—")}</span></td><td class="text-end">${num(r.got)}</td><td class="text-end">${num(r.kept)}</td><td class="text-end text-secondary">${num(r.bytes)}</td><td>${statusBadge(stKey(r.status))}<span class="d-block small text-secondary mt-1">${text(r.status||"Статус не записан")}</span></td></tr>`).join("")}<tr id="runs-empty" hidden><td colspan="7">${empty("Подходящих сборов нет","Измените поисковый запрос или выберите другой результат.")}</td></tr></tbody></table></div></div>`);applyRuns()};
function applyRuns(){const q=S.runQ.toLowerCase();let visible=0;$$("#r-tbl tr[data-st]").forEach(tr=>{tr.hidden=!!((q&&!tr.textContent.toLowerCase().includes(q))||(S.runF!=="all"&&tr.dataset.st!==S.runF));if(!tr.hidden)visible++});if($("#runs-empty"))$("#runs-empty").hidden=visible>0;if($("#runs-count"))$("#runs-count").textContent=`Показано ${num(visible)} из ${num(S.runs?.length)} загруженных · в истории ${num(S.runTotal)}`;$$("[data-rf]").forEach(el=>{el.classList.toggle("btn-primary",el.dataset.rf===S.runF);el.setAttribute("aria-pressed",String(el.dataset.rf===S.runF))})}
V.tg=async ctx=>{const [st,recent]=await Promise.all([api("/api/tg/status",{signal:ctx.signal}),api("/api/items?limit=100&sort=fresh",{signal:ctx.signal})]);if(!ctx.valid())return;S.tgReady=!!st.enabled&&!st.error;const unavailable=S.tgReady?"":'data-unavailable="1" disabled',tgItems=(recent.items||[]).filter(it=>/telegram|telethon|tg[_-]?/i.test(it.provider||"")||/^https?:\/\/(?:www\.)?t\.me\//i.test(it.url||"")).slice(0,8);
ctx.mount(`<div class="row g-4"><div class="col-lg-8"><section class="card"><div class="card-body"><h2 class="card-title">Найти каналы</h2><form id="tg-search-form"><label class="form-label" for="tg-q">Название или тема канала</label><div class="input-group"><input class="form-control" id="tg-q" placeholder="Например, технологии или название канала" minlength="2" required ${S.tgReady?"":"disabled"}><button class="btn btn-primary" type="submit" data-lock="tg-search" ${unavailable}>Найти каналы</button></div><p class="form-hint mb-0">Глобальный поиск через вашу серверную сессию Telegram. Найденные каналы можно выбрать и импортировать.</p></form></div><div id="tg-res" aria-live="polite">${empty(S.tgReady?"Введите тему канала":"Поиск пока недоступен",S.tgReady?"Начните с двух символов, затем выберите каналы из результатов.":"Для поиска и импорта нужна подключённая серверная сессия.")}</div></section></div><div class="col-lg-4"><section class="card"><div class="card-body"><h2 class="card-title">Подключение Telegram</h2><p class="mb-3"><span class="badge bg-${S.tgReady?"green":st.error?"red":"secondary"}-lt">${S.tgReady?"Сессия подключена":st.error?"Ошибка подключения":"Сессия не настроена"}</span></p><dl class="mb-0"><dt>Аккаунт</dt><dd>${text(st.me?.first_name||"Не определён")}${st.me?.username?" · @"+text(st.me.username):""}</dd><dt>Глубина истории</dt><dd>${num(st.limit)} постов на канал</dd><dt>Сессия</dt><dd class="text-break">${text(st.session||"Не указана")}</dd></dl>${st.error?`<div class="alert alert-danger mt-3 mb-0" role="alert">${text(st.error)}<button class="btn btn-sm mt-2" type="button" data-act="refresh">Обновить статус</button></div>`:""}<p class="text-secondary small mt-3 mb-0">Конфигурация хранится на сервере в <code>data/tg.json</code>. Панель не изменяет ключи Telegram и файлы сессии. Публичные каналы также доступны через реестр источников.</p></div></section></div><div class="col-12"><section class="card card-body"><h2 class="card-title">Импорт по списку каналов</h2><form id="tg-pull-form" class="row g-3 align-items-end"><div class="col-lg-7"><label class="form-label" for="tg-hands">Каналы через запятую или с новой строки</label><textarea class="form-control" id="tg-hands" rows="2" placeholder="@channel, https://t.me/channel" required ${S.tgReady?"":"disabled"}></textarea></div><div class="col-sm-6 col-lg-2"><label class="form-label" for="tg-lim">Постов на канал</label><input class="form-control" type="number" min="5" max="500" id="tg-lim" value="100" required ${S.tgReady?"":"disabled"}></div><div class="col-sm-6 col-lg-3"><button class="btn w-100" type="submit" data-lock="tg-pull" ${unavailable}>Импортировать каналы</button></div></form><div id="tg-job" class="mt-3" role="status"></div></section></div><div class="col-12"><section class="card"><div class="card-header"><div><h2 class="card-title">Свежие публикации Telegram</h2><p class="card-subtitle">Из последних 100 записей общей ленты</p></div></div><div id="tg-recent" class="list-group list-group-flush">${tgItems.map(it=>itemRow(it,true)).join("")||empty("В свежей ленте нет публикаций Telegram","Найдите канал или укажите его адрес для импорта.")}</div></section></div></div>`);
};
async function tgSearch(){const q=$("#tg-q")?.value.trim();if(!q||q.length<2){toast("Введите не менее двух символов",true);return}const scope=requestScope("tg-search"),box=$("#tg-res");box.innerHTML=loading("Поиск каналов…");try{const d=await api("/api/tg/search?q="+encodeURIComponent(q)+"&limit=30",{signal:scope.signal});if(!scope.valid())return;const channels=d.channels||[];box.innerHTML=channels.length?`<div class="card-body py-3 border-top d-flex flex-wrap align-items-center gap-3"><span id="tg-selected" class="text-secondary me-auto">Выберите каналы для импорта</span><button class="btn btn-sm" data-act="tg-import-selected" data-lock="tg-pull" data-unavailable="1" type="button" disabled>Импортировать выбранные</button></div><div class="table-responsive"><table class="table table-vcenter mb-0"><thead><tr><th scope="col"><input class="form-check-input m-0" id="tg-all" type="checkbox" aria-label="Выбрать все доступные каналы"></th><th scope="col">Канал</th><th scope="col">Подписчиков</th><th scope="col">Действие</th></tr></thead><tbody>${channels.map(c=>{const handle=String(c.username||"").replace(/^@/,""),valid=/^[A-Za-z0-9_]{3,}$/.test(handle);return `<tr><td><input class="form-check-input m-0 tg-ck" type="checkbox" data-handle="${esc(valid?handle:"")}" aria-label="Выбрать ${text(c.title)}" ${valid?"":"disabled"}></td><td><span class="fw-medium">${text(c.title)}</span><span class="d-block text-secondary small">${valid?"@"+esc(handle):"Нет публичного адреса"}</span></td><td>${num(c.participants)}</td><td>${valid?`<button class="btn btn-sm" type="button" data-pull-one="${esc(handle)}" data-lock="tg-pull">Импортировать</button>`:'<span class="text-secondary small">Недоступно</span>'}</td></tr>`}).join("")}</tbody></table></div>`:empty("Каналы не найдены","Попробуйте другую тему или точное название канала.");syncBusy()}catch(error){if(scope.valid()&&error.name!=="AbortError")box.innerHTML=`<div class="p-3">${failure(error,tgSearch)}</div>`}}
function tgSelection(){const selected=$$(".tg-ck:checked"),all=$$(".tg-ck:not(:disabled)"),button=$('[data-act="tg-import-selected"]');if(button){button.dataset.unavailable=selected.length?"0":"1";syncBusy()}if($("#tg-selected"))$("#tg-selected").textContent=`Выбрано каналов: ${selected.length}`;if($("#tg-all")){$("#tg-all").checked=all.length>0&&selected.length===all.length;$("#tg-all").indeterminate=selected.length>0&&selected.length<all.length}}
async function tgPull(handles){if(!handles.length){toast("Выберите хотя бы один канал",true);return}if(handles.length>20){toast("За один импорт можно выбрать до 20 каналов",true);return}const ctx=activeContext,box=$("#tg-job"),limit=int($("#tg-lim")?.value||100,5,500);if(box)box.textContent="Импорт запущен. Публикации сохраняются в общей ленте…";try{const {job}=await post("/api/tg/pull",{handles,limit});const result=await monitorJob(job,ctx,st=>{if(box?.isConnected)box.textContent=`Импорт выполняется${st.done?": "+num(st.done):""}…`});if(!ctx.valid())return;if(box?.isConnected)box.innerHTML=`<div class="alert alert-${result.errors?.length?"warning":"success"} mb-0">Импорт завершён: каналов ${num(result.channels)}, постов ${num(result.fetched)}, новых ${num(result.new)}, повторов ${num(result.dup)}.${result.errors?.length?`<div class="mt-2">Не импортировано: ${result.errors.map(e=>text(e.handle+": "+e.error)).join("; ")}</div>`:""}<a class="d-block mt-2" href="#/feed" data-nav="feed">Читать в ленте</a></div>`;toast(`Telegram: ${num(result.new)} новых записей`)}catch(error){if(ctx.valid()&&error.name!=="AbortError"&&box?.isConnected)box.innerHTML=failure(error,()=>busy(null,"tg-pull",()=>tgPull(handles)))}}
V.keys=async ctx=>{const d=await api("/api/keys",{signal:ctx.signal});if(!ctx.valid())return;const rows=[...(d.services||[])].sort((a,b)=>b.keys-a.keys);ctx.mount(`<div class="card"><div class="card-body border-bottom"><div class="d-flex flex-wrap gap-3 align-items-end"><div class="flex-fill"><label class="form-label" for="k-q">Поиск сервиса</label><input class="form-control" type="search" id="k-q" placeholder="Название API-сервиса"></div><div class="text-secondary">Готово: <strong class="text-body">${num(d.total_ready)}</strong> из ${num(d.total_keys)} ключей</div></div><p class="text-secondary small mt-3 mb-0">Только состояние пула — без значений и фрагментов ключей. При 401/402/403 ключ остывает час, при 429 — 10 минут. Ротация выполняется сервером.</p></div><div class="table-responsive"><table class="table table-vcenter mb-0" id="k-tbl"><thead><tr><th scope="col">Сервис</th><th scope="col" class="text-end">Всего ключей</th><th scope="col" class="text-end">Готовы</th><th scope="col" class="text-end">Остывают</th><th scope="col">Хранение</th></tr></thead><tbody>${rows.map(r=>`<tr data-key-service><td class="fw-medium">${text(r.service)}</td><td class="text-end">${num(r.keys)}</td><td class="text-end">${num(r.ready)}</td><td class="text-end">${num(r.cooling)}</td><td class="text-secondary">${r.env?"Переменные окружения (приоритет)":"Серверный vault"}</td></tr>`).join("")}<tr id="keys-empty" ${rows.length?"hidden":""}><td colspan="5">${empty(rows.length?"Сервис не найден":"В пуле пока нет ключей",rows.length?"Измените поисковый запрос.":"Добавьте ключи существующим серверным способом, затем обновите состояние.")}</td></tr></tbody></table></div></div><p class="text-secondary small mt-3">Проверка ключа — реальный вызов API из парсера. Управление хранилищем остаётся в существующем CLI: <code>omni keys</code>.</p>`)};
V.digest=async ctx=>{ctx.mount(`<div class="card"><div class="card-body border-bottom"><div class="d-flex flex-wrap gap-3 align-items-end"><div><label class="form-label" for="d-days">Период сводки</label><select class="form-select" id="d-days" data-lock="digest">${[[1,"За сутки"],[3,"За 3 дня"],[7,"За неделю"]].map(([v,l])=>`<option value="${v}" ${S.digestDays===v?"selected":""}>${l}</option>`).join("")}</select></div><a class="btn" id="d-dl" href="/api/digest?days=${S.digestDays}&download=1" download>Скачать Markdown</a></div><p class="text-secondary small mt-3 mb-0">Главное по релевантности, уязвимости, утечки и находки. Сервер также сохраняет результат в <code>data/digest_latest.md</code>.</p></div><div id="d-out" class="card-body reader-markdown" aria-live="polite">${empty("Выберите период и соберите дайджест","Нажмите «Собрать дайджест» вверху. Готовую сводку можно прочитать здесь, скопировать или скачать.")}</div></div>`)};
async function runDigest(){const scope=requestScope("digest"),days=$("#d-days")?.value||S.digestDays,out=$("#d-out");if(!out)return;out.innerHTML=loading("Подготовка дайджеста…");out.setAttribute("aria-busy","true");try{const d=await api("/api/digest?days="+encodeURIComponent(days),{signal:scope.signal});if(!scope.valid())return;out.innerHTML=`<div class="d-flex flex-wrap align-items-center gap-3 mb-4"><button class="btn btn-sm" type="button" data-copy-md>Копировать Markdown</button><span class="text-secondary small">${num(d.n)} записей за период</span></div>${d.md?md(d.md):empty("За этот период записей нет","Выберите более длинный период или запустите сбор.")}`;out.dataset.md=mask(d.md||"")}catch(error){if(scope.valid()&&error.name!=="AbortError")out.innerHTML=failure(error,()=>busy(null,"digest",runDigest))}finally{if(scope.valid())out.setAttribute("aria-busy","false")}}
V.watch=async ctx=>{const d=await api("/api/watch",{signal:ctx.signal});if(!ctx.valid())return;const labels=typeLabels();ctx.mount(`<div class="card"><div class="card-body border-bottom"><h2 class="card-title">Новое правило</h2><form id="watch-form" class="row g-3 align-items-end"><div class="col-sm-6"><label class="form-label" for="w-q">Слово или фраза</label><input class="form-control" id="w-q" placeholder="Например, CVE-2026 или название сервиса" minlength="2" required></div><div class="col-sm-3"><label class="form-label" for="w-type">Тип записи</label><select class="form-select" id="w-type">${typeOptions()}</select></div><div class="col-sm-3"><button class="btn btn-primary w-100" type="submit" data-lock="watch-write">Добавить правило</button></div></form><p class="text-secondary small mt-3 mb-0">Поиск по подстроке, без учёта регистра. Совпадения считаются только с момента добавления правила (watchlist).</p><div class="d-flex flex-wrap align-items-center gap-2 mt-3"><span class="text-secondary small">Быстро добавить:</span>${["CVE-2026","firecrawl","промо"].map(q=>`<button class="btn btn-sm" type="button" data-watch-q="${esc(q)}" data-lock="watch-write">${esc(q)}</button>`).join("")}</div></div><div class="card-header"><h2 class="card-title">Мои правила · ${num((d.rules||[]).length)}</h2></div><div class="list-group list-group-flush" id="watch-rules">${(d.rules||[]).map(r=>`<section class="list-group-item"><div class="d-flex flex-wrap align-items-center gap-2"><h3 class="h3 mb-0 me-2 text-break">${text(r.q)}</h3><span class="badge bg-secondary-lt">${text(r.type==="all"?"Все типы":labels[r.type]||r.type)}</span><span class="text-secondary small">Совпадений: ${num(r.count)}</span><button class="btn btn-ghost-danger btn-sm ms-auto" type="button" data-watch-del="${esc(r.id)}" data-lock="watch-write" aria-label="Удалить правило ${text(r.q)}">Удалить</button></div>${r.latest?.length?related(r.latest):'<p class="text-secondary small mt-3 mb-0">Новых совпадений пока нет. Правило уже работает.</p>'}</section>`).join("")||empty("Добавьте первую тему для отслеживания","Введите слово или фразу в форме выше. Свежие совпадения появятся в этом списке.")}</div></div>`)};
V.overview=async ctx=>{const [st,tl,runs,watch,latest]=await Promise.all([api("/api/stats",{signal:ctx.signal}),api("/api/timeline?days=30&mode=seen",{signal:ctx.signal}),api("/api/runs?limit=6",{signal:ctx.signal}),api("/api/watch",{signal:ctx.signal}),api("/api/items?limit=6&sort=fresh",{signal:ctx.signal})]);if(!ctx.valid())return;S.stats=st;
ctx.mount(`<div class="row g-4"><div class="col-lg-5"><section class="card"><div class="card-header"><h2 class="card-title">Состояние рабочей области</h2></div><div class="card-body"><dl class="row mb-0"><dt class="col-8">Сохранено записей</dt><dd class="col-4 text-end">${num(st.items)}</dd><dt class="col-8">Импортировано за 24 часа</dt><dd class="col-4 text-end">${num(tl.last24)}</dd><dt class="col-8">Источников в реестре</dt><dd class="col-4 text-end">${num(st.sources)}</dd><dt class="col-8">Включено для сбора</dt><dd class="col-4 text-end">${num(st.sources_enabled)}</dd><dt class="col-8">Ранее отдавали записи</dt><dd class="col-4 text-end">${num(st.alive)}</dd><dt class="col-8">Готово API-ключей</dt><dd class="col-4 text-end">${num(st.keys?.ready)} / ${num(st.keys?.keys)}</dd><dt class="col-8">Правил отслеживания</dt><dd class="col-4 text-end">${num(watch.rules?.length)}</dd></dl><label class="form-check mt-3 mb-0"><input class="form-check-input" type="checkbox" id="auto-refresh" ${S.auto?"checked":""}><span class="form-check-label">Обновлять данные раз в минуту</span></label></div></section><section class="card mt-4"><div class="card-header"><h2 class="card-title">Импорт за 30 дней</h2></div><div class="card-body"><p class="text-secondary mb-3">${num(tl.total)} записей за период. Это дата импорта; фильтр периода в ленте использует дату публикации.</p><div class="table-responsive"><table class="table table-sm mb-0"><thead><tr><th scope="col">День (последняя неделя)</th><th scope="col" class="text-end">Импортировано</th></tr></thead><tbody>${(tl.points||[]).slice(-7).map(p=>`<tr><td>${esc(dt(p.day))}</td><td class="text-end">${num(p.n)}</td></tr>`).join("")}</tbody></table></div></div></section></div><div class="col-lg-7"><section class="card"><div class="card-header d-flex justify-content-between gap-2"><h2 class="card-title">Свежие записи</h2><a href="#/feed" data-nav="feed">Вся лента</a></div><div class="list-group list-group-flush">${(latest.items||[]).map(it=>itemRow(it,true)).join("")||empty("Пока нет записей","Добавьте источник и запустите сбор.")}</div></section></div><div class="col-lg-6"><section class="card"><div class="card-header"><h2 class="card-title">Источники по категориям</h2></div><div class="table-responsive"><table class="table mb-0"><thead><tr><th scope="col">Категория</th><th scope="col" class="text-end">В реестре</th><th scope="col" class="text-end">Отдавали записи</th><th scope="col" class="text-end">В базе</th></tr></thead><tbody>${(st.categories||[]).map(c=>`<tr><td>${text(c.name)}</td><td class="text-end">${num(c.total)}</td><td class="text-end">${num(c.alive)}</td><td class="text-end">${num(c.stored)}</td></tr>`).join("")||'<tr><td colspan="4">Категорий пока нет</td></tr>'}</tbody></table></div></section></div><div class="col-lg-6"><section class="card"><div class="card-header d-flex justify-content-between gap-2"><h2 class="card-title">Последние сборы</h2><a href="#/runs" data-nav="runs">Вся история</a></div><div class="table-responsive"><table class="table mb-0"><thead><tr><th scope="col">Источник</th><th scope="col">Когда</th><th scope="col">Результат</th></tr></thead><tbody>${(runs.items||[]).map(r=>`<tr><td>${text(r.source)}</td><td class="text-secondary small">${esc(datetime(r.when))}</td><td>${statusBadge(stKey(r.status))}</td></tr>`).join("")||'<tr><td colspan="3">Сборов пока нет</td></tr>'}</tbody></table></div></section></div></div>`)};
function sleep(ms,signal){return new Promise((resolve,reject)=>{if(signal?.aborted){reject(new DOMException("Aborted","AbortError"));return}const done=()=>{signal?.removeEventListener("abort",abort);resolve()},timer=setTimeout(done,ms),abort=()=>{clearTimeout(timer);reject(new DOMException("Aborted","AbortError"))};signal?.addEventListener("abort",abort,{once:true})})}
async function monitorJob(job,ctx,onProgress){if(!job)throw new Error("Сервер не вернул номер задачи");while(ctx.valid()){const state=await api("/api/job/"+encodeURIComponent(job),{signal:ctx.signal});if(!ctx.valid())throw new DOMException("Aborted","AbortError");if(state.state==="error")throw new Error(state.error||"Задача завершилась с ошибкой");if(state.state==="done"||state.ok===true)return state;if(state.state==="unknown")throw new Error("Сервер больше не знает эту задачу. Обновите данные раздела.");onProgress?.(state);await sleep(1400,ctx.signal)}throw new DOMException("Aborted","AbortError")}
async function startJob(path,body,key,el,label,after){return busy(el,key,async()=>{const ctx=activeContext;$("#global-feedback").innerHTML=`<div class="alert alert-info" role="status">${esc(label)}. При переходе в другой раздел задача продолжит работу на сервере.</div>`;const {job}=await post(path,body);const result=await monitorJob(job,ctx,st=>{if(ctx.valid())$("#global-feedback").innerHTML=`<div class="alert alert-info" role="status">${esc(label)}${st.total?`: ${num(st.done)} из ${num(st.total)}`:""}…</div>`});if(!ctx.valid())return;$("#global-feedback").innerHTML=`<div class="alert alert-success" role="status">${key==="source-check"?`Проверено ${num(result.done)} источников; отдали записи ${num(result.alive)}. Остальные могли ответить без записей или с ошибкой.`:`Сбор завершён: получено ${num(result.fetched)}, новых ${num(result.new)}, повторов ${num(result.dup)}.`}</div>`;toast("Задача завершена");if(after)await after(ctx);else if(ctx.valid())await render();refreshHealth()})}
function showModal(title,body,footer){modalReturnFocus=document.activeElement;$("#modal-box").innerHTML=`<div class="modal-header"><h2 class="modal-title" id="modal-title">${esc(title)}</h2><button class="btn btn-ghost-secondary btn-sm" type="button" data-bs-dismiss="modal" aria-label="Закрыть диалог">Закрыть</button></div><div class="modal-body">${body}</div><div class="modal-footer">${footer}</div>`;tabler.Modal.getOrCreateInstance($("#modal")).show()}
$("#modal").addEventListener("hidden.bs.modal",()=>{if(modalReturnFocus?.isConnected)modalReturnFocus.focus({preventScroll:true});modalReturnFocus=null});
function addSource(){showModal("Новый источник",`<form id="add-source-form" class="row g-3"><div class="col-sm-6"><label class="form-label" for="a-name">Название</label><input class="form-control" id="a-name" placeholder="example-forum" required></div><div class="col-sm-6"><label class="form-label" for="a-cat">Категория</label><input class="form-control" id="a-cat" value="community" list="cats-list" required><datalist id="cats-list">${S.cats.map(c=>`<option value="${esc(c)}">`).join("")}</datalist></div><div class="col-12"><label class="form-label" for="a-url">Адрес источника</label><input class="form-control" id="a-url" type="url" placeholder="https://example.com/rss" required></div><div class="col-sm-8"><label class="form-label" for="a-adapter">Адаптер</label><select class="form-select" id="a-adapter">${["rss","html_listing","json","tg_web","xenforo_latest","discourse","sitemap_recent"].map(a=>`<option>${a}</option>`).join("")}</select></div><div class="col-sm-4"><label class="form-label" for="a-lim">Лимит записей</label><input class="form-control" type="number" id="a-lim" min="1" max="500" value="20" required></div><div class="col-12"><label class="form-label" for="a-tags">Метки через запятую</label><input class="form-control" id="a-tags" placeholder="Тема, язык, проект"></div><div class="col-12" id="add-source-error" aria-live="polite"></div></form>`,'<button class="btn" type="button" data-bs-dismiss="modal">Отмена</button><button class="btn btn-primary" type="submit" form="add-source-form" data-lock="source-write">Добавить источник</button>')}
async function saveSource(){const origin=activeContext,body={name:$("#a-name").value.trim(),category:$("#a-cat").value.trim()||"community",url:$("#a-url").value.trim(),adapter:$("#a-adapter").value,limit:int($("#a-lim").value,1,500),tags:$("#a-tags").value.split(",").map(t=>t.trim()).filter(Boolean)};if(!safeUrl(body.url)){$("#add-source-error").innerHTML=failure(new Error("Нужен адрес с протоколом https:// или http://"));return}try{await post("/api/sources/add",body);tabler.Modal.getOrCreateInstance($("#modal")).hide();toast("Источник добавлен");if(origin.valid())await render()}catch(error){if($("#add-source-error"))$("#add-source-error").innerHTML=failure(error)}}
async function bulkToggle(on,el){const names=selectedSrc();if(!names.length){toast("Выберите источники",true);return}await busy(el,"source-write",async()=>{const ctx=activeContext;for(const name of names)await post("/api/sources/toggle",{name,enabled:on});toast(`${on?"Включено":"Выключено"}: ${names.length}`);if(ctx.valid())await loadSources(ctx)})}
async function toggleSource(name,on,el,detail=false){await busy(el,"source-write",async()=>{const ctx=activeContext,reader=readerVersion;try{await post("/api/sources/toggle",{name,enabled:on});toast(on?"Источник включён":"Источник выключен");if(ctx.valid()&&S.view==="sources")await loadSources(ctx);if(detail&&reader===readerVersion&&$("#drawer").classList.contains("show"))await srcDetail(name)}catch(error){if(el?.type==="checkbox")el.checked=!on;throw error}})}
async function addWatch(q,type="all",el){if(q.trim().length<2){toast("Введите не менее двух символов",true);return}await busy(el,"watch-write",async()=>{const ctx=activeContext;await post("/api/watch/add",{q:q.trim(),type});toast("Правило отслеживания добавлено");if(ctx.valid()&&S.view==="watch")await render()})}
const ACT={
refresh:()=>render(),"refresh-health":()=>loadMeta(),
help:()=>showModal("Помощь и горячие клавиши",`<p>Лента — основное место для чтения. Находки выделяют полезные признаки, а реестр источников управляет сбором.</p><dl class="row mb-0"><dt class="col-3"><kbd>/</kbd></dt><dd class="col-9">Открыть поиск в ленте</dd><dt class="col-3"><kbd>r</kbd></dt><dd class="col-9">Обновить текущий раздел</dd><dt class="col-3"><kbd>Esc</kbd></dt><dd class="col-9">Закрыть чтение или диалог</dd><dt class="col-3"><kbd>1–9</kbd></dt><dd class="col-9">Обзор, Лента, Находки, Источники, История сборов, Telegram, API-ключи, Дайджест, Отслеживание</dd><dt class="col-3"><kbd>?</kbd></dt><dd class="col-9">Эта справка</dd></dl><p class="text-secondary small mt-3 mb-0">Фильтры ленты сохраняются в адресе. Ссылку на отфильтрованную ленту можно открыть в новой вкладке.</p>`,'<button class="btn" type="button" data-bs-dismiss="modal">Понятно</button>'),
run:el=>startJob("/api/feed",{category:S.cat||null,limit:12},"feed",el,"Сбор записей"),
"page-prev":()=>go("feed",{page:Math.max(1,S.page-1)},true),"page-next":()=>go("feed",{page:S.page+1},true),more:()=>go("feed",{page:S.page+1},true),
"clear-search":()=>{S.focusSearch=true;go("feed",{q:""},true)},
"reset-filters":()=>go("feed",{q:"",cat:"",type:"all",sort:"fresh",days:0,priced:false,src:"",page:1},true),
"source-reset":()=>{S.srcQ="";S.srcCat="";S.srcState="";S.srcEnabled="";return render()},
"add-src":addSource,"add-src-save":saveSource,
"toggle-all":async el=>{const on=el.dataset.on==="1",category=S.srcCat||null;if(!confirm(`${on?"Включить":"Выключить"} все источники ${category?"категории «"+category+"»":"всего реестра"}? Поиск и фильтр статуса не ограничивают эту операцию.`))return;await busy(el,"source-write",async()=>{const ctx=activeContext,d=await post("/api/sources/toggle_all",{enabled:on,category});toast(`${on?"Включено":"Выключено"}: ${num(d.changed)}`);if(ctx.valid())await loadSources(ctx)})},
"bulk-on":el=>bulkToggle(true,el),"bulk-off":el=>bulkToggle(false,el),
"bulk-check":el=>{const names=selectedSrc();if(!names.length){toast("Выберите источники",true);return}return startJob("/api/sources/check",{names},"source-check",el,"Проверка выбранных источников")},
"check-all":el=>startJob("/api/sources/check",{category:S.srcCat||null},"source-check",el,"Проверка источников категории"),
"feed-src":el=>{const category=el.dataset.category,source=$("#drawer").dataset.source,version=readerVersion;return startJob("/api/feed",{category,limit:12},"feed",el,"Сбор категории «"+category+"»",async ctx=>{if(ctx.valid()&&version===readerVersion&&$("#drawer").classList.contains("show"))await srcDetail(source)})},
summary:el=>busy(el,"summary",summary),"panels-retry":()=>loadPanels(),digest:el=>busy(el,"digest",runDigest),
"digest-quick":()=>{S.digestDays=1;go("digest",{},true)},
"tg-search":el=>busy(el,"tg-search",tgSearch),
"tg-pull":el=>busy(el,"tg-pull",()=>tgPull(($("#tg-hands")?.value||"").split(/[,\s]+/).map(s=>s.trim()).filter(Boolean))),
"tg-import-selected":el=>busy(el,"tg-pull",()=>tgPull($$(".tg-ck:checked").map(c=>c.dataset.handle).filter(Boolean))),
"watch-add":el=>addWatch($("#w-q")?.value||"",$("#w-type")?.value||"all",el)
};
// Delegated events keep controls live after a view is replaced; inputs do not trigger whole-view renders.
document.addEventListener("submit",async event=>{const form=event.target;if(!["feed-form","tg-search-form","tg-pull-form","watch-form","add-source-form"].includes(form.id))return;event.preventDefault();if(!form.reportValidity())return;const submit=event.submitter||$('button[type="submit"]',form);if(form.id==="feed-form"){hideSuggestions();go("feed",readFeedFilters(),true)}if(form.id==="tg-search-form")await busy(submit,"tg-search",tgSearch);if(form.id==="tg-pull-form")await ACT["tg-pull"](submit);if(form.id==="watch-form")await ACT["watch-add"](submit);if(form.id==="add-source-form")await busy(submit,"source-write",saveSource)});
document.addEventListener("click",async event=>{const target=event.target;if(!(target instanceof Element))return;if(target.closest("[data-skip-main]")){event.preventDefault();$("#main-content").focus();$("#main-content").scrollIntoView();return}const nav=target.closest("[data-nav]");if(nav){if(event.ctrlKey||event.metaKey||event.shiftKey||event.altKey)return;event.preventDefault();const collapse=$("#sidebar-menu");if(window.innerWidth<992&&collapse.classList.contains("show"))tabler.Collapse.getOrCreateInstance(collapse,{toggle:false}).hide();go(nav.dataset.nav);return}
const retry=target.closest("[data-retry]");if(retry){event.preventDefault();await busy(retry,"retry-"+retry.dataset.retry,()=>retries.get(retry.dataset.retry)?.());return}
const act=target.closest("[data-act]");if(act){event.preventDefault();if(act.disabled)return;const fn=ACT[act.dataset.act];if(fn)await fn(act);return}
const tab=target.closest("[data-ab-tab]");if(tab){event.preventDefault();go("abuse",{tab:tab.dataset.abTab},true);return}
const tag=target.closest("[data-tag]");if(tag){event.preventDefault();go("abuse",{tag:tag.dataset.tag},true);return}
const item=target.closest("[data-id]");if(item){event.preventDefault();await openItem(+item.dataset.id);return}
const source=target.closest("[data-source]");if(source){await srcDetail(source.dataset.source);return}
const sourceFeed=target.closest("[data-source-feed]");if(sourceFeed){go("feed",{src:sourceFeed.dataset.sourceFeed,q:"",cat:""});return}
const cp=target.closest("[data-copy]");if(cp){await copy(safeUrl(cp.dataset.copy));return}if(target.closest("[data-copy-md]")){await copy($("#d-out")?.dataset.md||"");return}
const watch=target.closest("[data-watch]");if(watch){await addWatch(watch.dataset.watch,"all",watch);return}
const quick=target.closest("[data-watch-q]");if(quick){await addWatch(quick.dataset.watchQ,"all",quick);return}
const del=target.closest("[data-watch-del]");if(del){await busy(del,"watch-write",async()=>{const ctx=activeContext;await post("/api/watch/remove",{id:del.dataset.watchDel});toast("Правило удалено");if(ctx.valid())await render()});return}
const toggle=target.closest("[data-toggle-src]");if(toggle){await toggleSource(toggle.dataset.toggleSrc,toggle.dataset.on==="1",toggle,true);return}
const check=target.closest("[data-check-src]");if(check){const name=check.dataset.checkSrc,version=readerVersion;await startJob("/api/sources/check",{names:[name]},"source-check",check,"Проверка источника",async ctx=>{if(!ctx.valid())return;if(version===readerVersion&&$("#drawer").classList.contains("show"))await srcDetail(name);if(S.view==="sources"&&ctx.valid())await loadSources(ctx)});return}
const pull=target.closest("[data-pull-one]");if(pull){await busy(pull,"tg-pull",()=>tgPull([pull.dataset.pullOne]));return}
const rf=target.closest("[data-rf]");if(rf){S.runF=rf.dataset.rf;applyRuns();return}
const suggestion=target.closest("[data-sug-id],[data-sug-src],[data-sug-q]");if(suggestion){hideSuggestions();if(suggestion.dataset.sugId)await openItem(+suggestion.dataset.sugId);else if(suggestion.dataset.sugSrc){S.srcQ=suggestion.dataset.sugSrc;go("sources")}else go("feed",{q:suggestion.dataset.sugQ},true);return}
if(!target.closest(".search-wrap"))hideSuggestions();
});
document.addEventListener("change",async event=>{const el=event.target;if(el.matches("#cat,#f-sort,#f-days,#f-priced,#f-type,#f-src")){go("feed",readFeedFilters(),true);return}if(el.id==="ab-tag"){go("abuse",{tag:el.value},true);return}
if(el.id==="s-cat"||el.id==="s-state"||el.id==="s-enabled"){S[{"s-cat":"srcCat","s-state":"srcState","s-enabled":"srcEnabled"}[el.id]]=el.value;await loadSources();return}
if(el.id==="src-all"){$$(".src-ck").forEach(cb=>cb.checked=el.checked);srcBulkBar();return}if(el.classList.contains("src-ck")){srcBulkBar();return}
if(el.dataset.sourceToggle){await toggleSource(el.dataset.sourceToggle,el.checked,el);return}
if(el.id==="tg-all"){$$(".tg-ck:not(:disabled)").forEach(cb=>cb.checked=el.checked);tgSelection();return}if(el.classList.contains("tg-ck")){tgSelection();return}
if(el.id==="d-days"){S.digestDays=+el.value;$("#d-dl").href=`/api/digest?days=${S.digestDays}&download=1`;$("#d-out").innerHTML=empty("Период изменён","Нажмите «Собрать дайджест», чтобы обновить сводку.");delete $("#d-out").dataset.md;return}
if(el.id==="auto-refresh"){S.auto=el.checked;try{localStorage.setItem("omni.auto",S.auto?"1":"0")}catch{}toast(S.auto?"Автообновление раз в минуту включено":"Автообновление выключено")}
});
const sourceSearch=debounce((value,ctx)=>{if(ctx?.valid()&&S.view==="sources"){S.srcQ=value;loadSources(ctx)}},250);
const suggestSearch=debounce(async(value,ctx)=>{if(!ctx?.valid()||S.view!=="feed"||$("#q")?.value.trim()!==value)return;if(value.length<2){hideSuggestions();return}const scope=requestScope("suggest",ctx);try{const d=await api("/api/suggest?q="+encodeURIComponent(value),{signal:scope.signal});if(!scope.valid()||$("#q")?.value.trim()!==value)return;const box=$("#sug");box.innerHTML=(d.titles?.length?'<div class="dropdown-header">Записи</div>'+d.titles.slice(0,5).map(it=>`<button type="button" class="dropdown-item" data-sug-id="${int(it.id)}">${esc(titleOf(it))}<span class="d-block small text-secondary">${text(it.source)}</span></button>`).join(""):"")+(d.sources?.length?'<div class="dropdown-header">Источники</div>'+d.sources.slice(0,4).map(s=>`<button type="button" class="dropdown-item" data-sug-src="${esc(s.name)}">${text(s.name)}<span class="d-block small text-secondary">${text(s.category)}</span></button>`).join(""):"")+`<div class="dropdown-divider"></div><button type="button" class="dropdown-item" data-sug-q="${esc(value)}">Искать «${text(value)}» в ленте</button>`;box.classList.remove("d-none");box.classList.add("show");$("#q").setAttribute("aria-expanded","true")}
catch(error){if(scope.valid()&&error.name!=="AbortError"){const box=$("#sug");box.innerHTML='<div class="px-3 py-2 small text-secondary">Подсказки недоступны. Нажмите «Найти» для обычного поиска.</div>';box.classList.remove("d-none");box.classList.add("show");$("#q").setAttribute("aria-expanded","true")}}},220);
function hideSuggestions(){const box=$("#sug");if(box){box.classList.add("d-none");box.classList.remove("show")}$("#q")?.setAttribute("aria-expanded","false");serials.suggest=(serials.suggest||0)+1}
document.addEventListener("input",event=>{const el=event.target;if(el.id==="s-q")sourceSearch(el.value.trim(),activeContext);if(el.id==="q"){if(el.value.trim().length<2)hideSuggestions();suggestSearch(el.value.trim(),activeContext)}if(el.id==="r-q"){S.runQ=el.value;applyRuns()}if(el.id==="k-q"){let shown=0;const q=el.value.toLowerCase();$$("#k-tbl [data-key-service]").forEach(tr=>{tr.hidden=!tr.textContent.toLowerCase().includes(q);if(!tr.hidden)shown++});$("#keys-empty").hidden=shown>0}});
document.addEventListener("keydown",event=>{const field=/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName||"")||document.activeElement?.isContentEditable;if(event.key==="Escape"){hideSuggestions();return}
const dialog=$("#modal.show")||$("#drawer.show");
if(dialog){
if(event.key==="Tab"){
const focusable=$$('a[href],button,input,select,textarea,[tabindex]',dialog).filter(el=>!el.disabled&&el.tabIndex>=0&&el.getClientRects().length&&getComputedStyle(el).visibility!=="hidden");
const first=focusable[0],last=focusable.at(-1),active=document.activeElement;
if(!first){event.preventDefault();dialog.focus()}
else if(event.shiftKey&&(active===first||active===dialog)){event.preventDefault();last.focus()}
else if(!event.shiftKey&&(active===last||active===dialog)){event.preventDefault();first.focus()}
}
return;
}
if((event.key==="ArrowDown"||event.key==="ArrowUp")&&!$("#sug")?.classList.contains("d-none")&&(document.activeElement?.id==="q"||document.activeElement?.closest("#sug"))){const buttons=$$("#sug button"),index=buttons.indexOf(document.activeElement),next=event.key==="ArrowDown"?(index+1)%buttons.length:(index<=0?buttons.length-1:index-1);if(buttons[next]){event.preventDefault();buttons[next].focus()}return}
if(event.key==="/"&&!field){event.preventDefault();if(S.view==="feed")$("#q")?.focus();else{S.focusSearch=true;go("feed")}return}if(event.key==="?"&&!field){event.preventDefault();ACT.help();return}if(event.key==="r"&&!field&&!event.ctrlKey&&!event.metaKey){event.preventDefault();render();return}if(/^[1-9]$/.test(event.key)&&!field&&!event.ctrlKey&&!event.metaKey){event.preventDefault();go(NAV[+event.key-1].id)}
});
window.addEventListener("hashchange",route);
setInterval(()=>{refreshHealth();if(S.auto&&document.visibilityState==="visible"&&!BUSY.size&&!$("#drawer").classList.contains("show")&&!$("#modal").classList.contains("show")&&!$("#view").contains(document.activeElement))render()},60000);
route();loadMeta();
</script>
</body>
</html>