632 lines
117 KiB
HTML
632 lines
117 KiB
HTML
<!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: var(--omni-space-1); 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: var(--omni-space-3) 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; }
|
||
.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">SOURCE 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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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(/&/g,"&").replace(/"/g,'"').replace(/'/g,"'").replace(/</g,"<").replace(/>/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)} находок</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>
|