mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-19 08:54:36 +03:00
3712 lines
150 KiB
HTML
3712 lines
150 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="fa" dir="rtl">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
|
<title>thefeed</title>
|
|
<style>
|
|
@font-face {
|
|
font-family: 'Vazirmatn';
|
|
src: url('/static/Vazirmatn-Regular.woff2') format('woff2');
|
|
font-weight: normal;
|
|
font-style: normal;
|
|
font-display: swap
|
|
}
|
|
|
|
:root {
|
|
--bg: #17212b;
|
|
--bg2: #0e1621;
|
|
--sidebar-bg: #0e1621;
|
|
--surface: #182533;
|
|
--surface2: #1d2b3a;
|
|
--border: #1c2938;
|
|
--accent: #3390ec;
|
|
--accent-hover: #2b7dd6;
|
|
--text: #f5f5f5;
|
|
--text-dim: #8b9eb0;
|
|
--success: #4fae4e;
|
|
--error: #e53935;
|
|
--send-color: #3390ec;
|
|
--msg-in: #182533;
|
|
--hover: #1e2c3a;
|
|
--font-size: 14px;
|
|
}
|
|
|
|
:root[data-theme="light"] {
|
|
--bg: #ffffff;
|
|
--bg2: #f0f2f5;
|
|
--sidebar-bg: #f0f2f5;
|
|
--surface: #ffffff;
|
|
--surface2: #e8eaed;
|
|
--border: #d3d6da;
|
|
--accent: #3390ec;
|
|
--accent-hover: #2b7dd6;
|
|
--text: #1a1a1a;
|
|
--text-dim: #707579;
|
|
--success: #4fae4e;
|
|
--error: #e53935;
|
|
--send-color: #3390ec;
|
|
--msg-in: #ffffff;
|
|
--hover: #e8eaed;
|
|
}
|
|
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box
|
|
}
|
|
|
|
body {
|
|
font-family: 'Vazirmatn', system-ui, -apple-system, sans-serif;
|
|
background: var(--bg2);
|
|
color: var(--text);
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
font-size: var(--font-size)
|
|
}
|
|
|
|
button {
|
|
cursor: pointer;
|
|
font-family: inherit
|
|
}
|
|
|
|
input,
|
|
textarea,
|
|
select {
|
|
font-family: inherit
|
|
}
|
|
|
|
/* ===== LAYOUT ===== */
|
|
.app {
|
|
display: flex;
|
|
height: 100vh;
|
|
direction: ltr
|
|
}
|
|
|
|
.sidebar {
|
|
width: 280px;
|
|
min-width: 280px;
|
|
background: var(--sidebar-bg);
|
|
display: flex;
|
|
flex-direction: column;
|
|
border-right: 1px solid var(--border);
|
|
overflow: hidden
|
|
}
|
|
|
|
.chat-area {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background-color: var(--bg);
|
|
overflow: hidden;
|
|
position: relative
|
|
}
|
|
|
|
/* ===== SIDEBAR HEADER ===== */
|
|
.sidebar-header {
|
|
padding: 10px 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
border-bottom: 1px solid var(--border)
|
|
}
|
|
|
|
.sidebar-header-top {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
min-height: 40px
|
|
}
|
|
|
|
.profile-btn {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 7px 12px;
|
|
border: none;
|
|
border-radius: 20px;
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
text-align: left;
|
|
transition: background .15s;
|
|
overflow: hidden
|
|
}
|
|
|
|
.profile-btn:hover {
|
|
background: var(--hover)
|
|
}
|
|
|
|
.profile-btn-avatar {
|
|
width: 24px;
|
|
height: 24px;
|
|
border-radius: 50%;
|
|
background: var(--accent);
|
|
color: #fff;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0
|
|
}
|
|
|
|
.profile-btn-name {
|
|
flex: 1;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis
|
|
}
|
|
|
|
.profile-btn-arrow {
|
|
font-size: 10px;
|
|
color: var(--text-dim);
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2px
|
|
}
|
|
|
|
.profile-btn-arrow .plus {
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
color: var(--accent)
|
|
}
|
|
|
|
.sidebar-search {
|
|
padding: 7px 12px;
|
|
border: none;
|
|
border-radius: 18px;
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
outline: none;
|
|
width: 100%
|
|
}
|
|
|
|
.sidebar-search::placeholder {
|
|
color: var(--text-dim)
|
|
}
|
|
|
|
.icon-btn {
|
|
width: 36px;
|
|
height: 36px;
|
|
border: none;
|
|
background: none;
|
|
color: var(--text-dim);
|
|
font-size: 17px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0
|
|
}
|
|
|
|
.icon-btn:hover {
|
|
background: var(--hover);
|
|
color: var(--text)
|
|
}
|
|
|
|
.icon-btn.scanning {
|
|
animation: spin 1.2s linear infinite;
|
|
color: var(--accent)
|
|
}
|
|
|
|
/* Sidebar toolbar: compact text buttons */
|
|
.sidebar-toolbar {
|
|
display: flex;
|
|
gap: 4px;
|
|
padding: 4px 12px 6px;
|
|
flex-wrap: wrap
|
|
}
|
|
.sidebar-toolbar .stb {
|
|
flex: 1 1 auto;
|
|
padding: 5px 8px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background: var(--surface);
|
|
color: var(--text-dim);
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
text-align: center;
|
|
white-space: nowrap;
|
|
transition: background .15s, color .15s
|
|
}
|
|
.sidebar-toolbar .stb:hover {
|
|
background: var(--hover);
|
|
color: var(--text)
|
|
}
|
|
.sidebar-toolbar .stb.scanning {
|
|
color: var(--accent);
|
|
border-color: var(--accent)
|
|
}
|
|
.sidebar-toolbar .stb .stb-badge {
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
margin-left: 3px;
|
|
vertical-align: middle
|
|
}
|
|
|
|
/* ===== CHANNEL LIST ===== */
|
|
.channel-list {
|
|
flex: 1;
|
|
overflow-y: auto
|
|
}
|
|
|
|
.channel-section-title {
|
|
padding: 8px 14px 4px;
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
text-transform: uppercase;
|
|
letter-spacing: .5px
|
|
}
|
|
|
|
.ch-item {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 10px 14px;
|
|
cursor: pointer;
|
|
gap: 10px;
|
|
contain: content
|
|
}
|
|
|
|
.ch-item:hover {
|
|
background: var(--hover)
|
|
}
|
|
|
|
.ch-item.active {
|
|
background: var(--accent)
|
|
}
|
|
|
|
.ch-item.active .ch-name,
|
|
.ch-item.active .ch-preview {
|
|
color: #fff
|
|
}
|
|
|
|
.ch-avatar {
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: 50%;
|
|
background: var(--surface2);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 17px;
|
|
color: var(--text-dim);
|
|
flex-shrink: 0;
|
|
font-weight: 600
|
|
}
|
|
|
|
.ch-item.active .ch-avatar {
|
|
background: rgba(255, 255, 255, .2)
|
|
}
|
|
|
|
.ch-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 3px
|
|
}
|
|
|
|
.ch-name {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis
|
|
}
|
|
|
|
.ch-preview {
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis
|
|
}
|
|
|
|
.ch-badge {
|
|
background: #fff;
|
|
color: var(--accent);
|
|
font-size: 10px;
|
|
padding: 1px 6px;
|
|
border-radius: 10px;
|
|
min-width: 18px;
|
|
text-align: center;
|
|
flex-shrink: 0;
|
|
font-weight: 700
|
|
}
|
|
|
|
.ch-item.active .ch-badge {
|
|
background: rgba(255, 255, 255, .3);
|
|
color: #fff
|
|
}
|
|
|
|
.ch-type-tag {
|
|
font-size: 9px;
|
|
padding: 1px 5px;
|
|
border-radius: 3px;
|
|
background: rgba(192, 132, 252, .15);
|
|
color: #c084fc;
|
|
margin-left: 4px
|
|
}
|
|
|
|
.ch-type-tag.x-tag {
|
|
background: rgba(29, 161, 242, .2);
|
|
color: #72c9ff
|
|
}
|
|
|
|
/* ===== RTL MESSAGES ===== */
|
|
.msg.rtl-msg {
|
|
direction: rtl
|
|
}
|
|
|
|
/* ===== SIDEBAR FOOTER ===== */
|
|
.sidebar-footer {
|
|
padding: 8px 14px;
|
|
border-top: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 10px;
|
|
font-size: 10px;
|
|
color: var(--text-dim)
|
|
}
|
|
|
|
.sidebar-footer a {
|
|
color: var(--accent);
|
|
text-decoration: none
|
|
}
|
|
|
|
.sidebar-footer a:hover {
|
|
text-decoration: underline
|
|
}
|
|
|
|
.free-iran {
|
|
color: var(--success);
|
|
font-weight: 700
|
|
}
|
|
|
|
/* ===== CHAT HEADER ===== */
|
|
.chat-header {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 8px 14px;
|
|
background: var(--surface);
|
|
border-bottom: 1px solid var(--border);
|
|
min-height: 54px;
|
|
gap: 10px
|
|
}
|
|
|
|
.back-btn {
|
|
display: none;
|
|
width: 36px;
|
|
height: 36px;
|
|
border: none;
|
|
background: none;
|
|
color: var(--text);
|
|
font-size: 20px;
|
|
border-radius: 50%;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0
|
|
}
|
|
|
|
.back-btn:hover {
|
|
background: var(--hover)
|
|
}
|
|
|
|
.chat-header-info {
|
|
flex: 1;
|
|
min-width: 0
|
|
}
|
|
|
|
.chat-header-name {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis
|
|
}
|
|
|
|
.chat-header-sub {
|
|
font-size: 11px;
|
|
color: var(--text-dim)
|
|
}
|
|
|
|
.chat-header-actions {
|
|
display: flex;
|
|
gap: 4px;
|
|
align-items: center
|
|
}
|
|
|
|
.next-fetch-label {
|
|
font-size: 10px;
|
|
color: var(--text-dim);
|
|
padding: 0 2px
|
|
}
|
|
|
|
.next-fetch-info {
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
cursor: help;
|
|
line-height: 1
|
|
}
|
|
|
|
/* ===== SEARCH BAR ===== */
|
|
.msg-search-bar {
|
|
display: none;
|
|
padding: 6px 14px;
|
|
background: var(--surface);
|
|
border-bottom: 1px solid var(--border);
|
|
gap: 8px;
|
|
align-items: center
|
|
}
|
|
.msg-search-bar.active { display: flex }
|
|
.msg-search-bar input {
|
|
flex: 1;
|
|
padding: 6px 10px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
outline: none
|
|
}
|
|
.msg-search-bar input:focus { border-color: var(--accent) }
|
|
.msg-search-bar .search-nav { display: flex; gap: 4px; align-items: center; font-size: 12px; color: var(--text-dim) }
|
|
.msg-search-bar button { background: none; border: none; color: var(--text-dim); font-size: 16px; cursor: pointer; padding: 4px }
|
|
.msg-search-bar button:hover { color: var(--text) }
|
|
.msg .search-highlight { background: rgba(255,200,0,.35); border-radius: 2px; padding: 0 1px }
|
|
.msg .search-highlight.current { background: rgba(255,200,0,.7) }
|
|
|
|
/* ===== EXPORT MODAL ===== */
|
|
.export-row { display: flex; gap: 8px; align-items: center; margin-bottom: 12px }
|
|
.export-row input[type=number] { width: 80px; padding: 6px 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--text); font-size: 13px }
|
|
|
|
/* ===== MESSAGES ===== */
|
|
.messages {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 10px 14px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
direction: ltr
|
|
}
|
|
|
|
.msg-date-sep {
|
|
text-align: center;
|
|
padding: 8px 0;
|
|
font-size: 12px;
|
|
color: var(--text-dim)
|
|
}
|
|
|
|
.msg-date-sep span {
|
|
background: rgba(0, 0, 0, .3);
|
|
padding: 3px 10px;
|
|
border-radius: 10px
|
|
}
|
|
|
|
.msg-gap-sep {
|
|
text-align: center;
|
|
padding: 6px 0;
|
|
font-size: 11px;
|
|
color: var(--error)
|
|
}
|
|
|
|
.msg-gap-sep span {
|
|
background: rgba(229, 57, 53, .12);
|
|
padding: 3px 10px;
|
|
border-radius: 10px;
|
|
border: 1px dashed rgba(229, 57, 53, .3)
|
|
}
|
|
|
|
.msg {
|
|
max-width: min(82%, 580px);
|
|
padding: 7px 10px 4px;
|
|
border-radius: 12px;
|
|
line-height: 1.7;
|
|
word-break: break-word;
|
|
white-space: pre-wrap;
|
|
font-size: inherit;
|
|
background: var(--msg-in);
|
|
border: 1px solid rgba(255, 255, 255, .07);
|
|
align-self: flex-start;
|
|
border-bottom-left-radius: 4px
|
|
}
|
|
|
|
:root[data-theme="light"] .msg {
|
|
border-color: var(--border)
|
|
}
|
|
|
|
.msg-meta {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 6px;
|
|
font-size: 10px;
|
|
color: var(--text-dim);
|
|
margin-top: 2px;
|
|
direction: ltr
|
|
}
|
|
|
|
.media-tag {
|
|
display: block;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
background: rgba(51, 144, 236, .15);
|
|
color: var(--accent);
|
|
font-size: 11px;
|
|
margin-bottom: 6px
|
|
}
|
|
|
|
.media-tag.reply-tag {
|
|
background: rgba(192, 132, 252, .15);
|
|
color: #c084fc
|
|
}
|
|
|
|
/* ===== SEND PANEL ===== */
|
|
.send-panel {
|
|
display: none;
|
|
padding: 8px 14px;
|
|
background: var(--surface);
|
|
border-top: 1px solid var(--border);
|
|
gap: 8px;
|
|
align-items: flex-end
|
|
}
|
|
|
|
.send-panel.visible {
|
|
display: flex
|
|
}
|
|
|
|
.send-input {
|
|
flex: 1;
|
|
padding: 8px 14px;
|
|
border: none;
|
|
border-radius: 18px;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-size: 14px;
|
|
outline: none;
|
|
resize: none;
|
|
max-height: 120px;
|
|
min-height: 36px
|
|
}
|
|
|
|
.send-btn {
|
|
width: 40px;
|
|
height: 40px;
|
|
border: none;
|
|
border-radius: 50%;
|
|
background: var(--send-color);
|
|
color: #fff;
|
|
font-size: 18px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0
|
|
}
|
|
|
|
.send-btn:hover {
|
|
background: var(--accent-hover)
|
|
}
|
|
|
|
/* ===== PROGRESS ===== */
|
|
.progress-panel {
|
|
background: var(--surface);
|
|
border-top: 1px solid var(--border);
|
|
overflow: hidden;
|
|
font-size: 12px;
|
|
padding: 0 14px;
|
|
direction: ltr;
|
|
text-align: left;
|
|
color: var(--text-dim)
|
|
}
|
|
|
|
.progress-panel:empty {
|
|
display: none
|
|
}
|
|
|
|
.progress-item {
|
|
padding: 5px 0;
|
|
position: relative;
|
|
padding-right: 22px
|
|
}
|
|
|
|
.progress-close {
|
|
position: absolute;
|
|
right: 0;
|
|
top: 4px;
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-dim);
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
line-height: 1;
|
|
padding: 0 2px
|
|
}
|
|
|
|
.progress-close:hover {
|
|
color: var(--text)
|
|
}
|
|
|
|
.progress-label {
|
|
font-size: 11px;
|
|
margin-bottom: 2px;
|
|
color: var(--text-dim)
|
|
}
|
|
|
|
.progress-bar {
|
|
width: 100%;
|
|
height: 3px;
|
|
background: var(--border);
|
|
border-radius: 2px;
|
|
overflow: hidden
|
|
}
|
|
|
|
.progress-fill {
|
|
height: 100%;
|
|
background: var(--accent);
|
|
transition: width .2s
|
|
}
|
|
|
|
@keyframes prog-pulse {
|
|
|
|
0%,
|
|
100% {
|
|
opacity: 1
|
|
}
|
|
|
|
50% {
|
|
opacity: .4
|
|
}
|
|
}
|
|
|
|
@keyframes spin {
|
|
from {
|
|
transform: rotate(0deg)
|
|
}
|
|
|
|
to {
|
|
transform: rotate(360deg)
|
|
}
|
|
}
|
|
|
|
@keyframes badge-pulse {
|
|
|
|
0%,
|
|
100% {
|
|
box-shadow: 0 0 0 0 rgba(51, 144, 236, .4)
|
|
}
|
|
|
|
50% {
|
|
box-shadow: 0 0 0 4px rgba(51, 144, 236, 0)
|
|
}
|
|
}
|
|
|
|
.refresh-has-new {
|
|
animation: badge-pulse 2s ease-in-out infinite;
|
|
color: var(--accent) !important
|
|
}
|
|
|
|
.msg-copy-btn {
|
|
background: none;
|
|
border: 1px solid var(--text-dim);
|
|
color: var(--text-dim);
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
padding: 1px 8px;
|
|
line-height: 1.4;
|
|
flex-shrink: 0;
|
|
opacity: .6;
|
|
transition: opacity .15s, border-color .15s;
|
|
font-family: inherit;
|
|
border-radius: 4px
|
|
}
|
|
|
|
.msg-copy-btn:hover {
|
|
opacity: 1;
|
|
border-color: var(--accent)
|
|
}
|
|
|
|
.msg-new-sep {
|
|
text-align: center;
|
|
padding: 6px 0;
|
|
font-size: 12px;
|
|
color: var(--accent)
|
|
}
|
|
|
|
.msg-new-sep span {
|
|
background: rgba(51, 144, 236, .12);
|
|
padding: 3px 14px;
|
|
border-radius: 10px;
|
|
border: 1px solid rgba(51, 144, 236, .25)
|
|
}
|
|
|
|
.theme-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 4px
|
|
}
|
|
|
|
.theme-btn {
|
|
flex: 1;
|
|
padding: 8px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background: var(--bg);
|
|
color: var(--text-dim);
|
|
font-size: 13px;
|
|
transition: all .15s
|
|
}
|
|
|
|
.theme-btn:hover {
|
|
background: var(--hover)
|
|
}
|
|
|
|
.theme-btn.active-theme {
|
|
border-color: var(--accent);
|
|
background: rgba(51, 144, 236, .12);
|
|
color: var(--accent);
|
|
font-weight: 600
|
|
}
|
|
|
|
/* ===== SCROLL-TO-BOTTOM ===== */
|
|
.scroll-down-btn {
|
|
position: absolute;
|
|
bottom: 70px;
|
|
right: 16px;
|
|
width: 36px;
|
|
height: 36px;
|
|
border: none;
|
|
border-radius: 50%;
|
|
background: var(--surface2);
|
|
color: var(--text);
|
|
font-size: 18px;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-shadow: 0 2px 10px rgba(0, 0, 0, .45);
|
|
z-index: 10;
|
|
cursor: pointer;
|
|
border: 1px solid var(--border)
|
|
}
|
|
|
|
.scroll-down-btn.visible {
|
|
display: flex
|
|
}
|
|
|
|
.scroll-down-btn:hover {
|
|
background: var(--hover)
|
|
}
|
|
|
|
.scroll-down-badge {
|
|
position: absolute;
|
|
top: -4px;
|
|
right: -4px;
|
|
background: var(--accent);
|
|
color: #fff;
|
|
font-size: 9px;
|
|
font-weight: 700;
|
|
min-width: 16px;
|
|
height: 16px;
|
|
border-radius: 8px;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0 3px
|
|
}
|
|
|
|
.scroll-down-badge.visible {
|
|
display: flex
|
|
}
|
|
|
|
/* ===== LOG ===== */
|
|
.log-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 3px 14px;
|
|
background: var(--bg2);
|
|
cursor: pointer;
|
|
user-select: none;
|
|
font-size: 10px;
|
|
color: var(--text-dim);
|
|
letter-spacing: .5px;
|
|
border-top: 1px solid var(--border)
|
|
}
|
|
|
|
.log-toggle:hover {
|
|
color: var(--text)
|
|
}
|
|
|
|
.log-panel {
|
|
height: 110px;
|
|
background: var(--bg2);
|
|
overflow-y: auto;
|
|
font-size: 11px;
|
|
font-family: monospace;
|
|
padding: 4px 14px;
|
|
direction: ltr;
|
|
text-align: left;
|
|
color: var(--text-dim)
|
|
}
|
|
|
|
.log-panel.hidden {
|
|
display: none;
|
|
height: 0
|
|
}
|
|
|
|
.log-line {
|
|
padding: 1px 0;
|
|
line-height: 1.3
|
|
}
|
|
|
|
.log-line.err {
|
|
color: #e53935
|
|
}
|
|
|
|
.log-line.ok {
|
|
color: #4fae4e
|
|
}
|
|
|
|
.log-line.warn {
|
|
color: #f97316
|
|
}
|
|
|
|
.log-line.prog {
|
|
color: #fbbf24
|
|
}
|
|
|
|
.log-line.inf {
|
|
color: #60a5fa
|
|
}
|
|
|
|
/* ===== EMPTY STATE ===== */
|
|
.empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
color: var(--text-dim);
|
|
gap: 14px;
|
|
text-align: center;
|
|
padding: 20px
|
|
}
|
|
|
|
.empty-state .big-icon {
|
|
font-size: 52px;
|
|
opacity: .25
|
|
}
|
|
|
|
.empty-state p {
|
|
font-size: 14px;
|
|
max-width: 240px
|
|
}
|
|
|
|
/* ===== MODALS ===== */
|
|
.modal-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, .65);
|
|
z-index: 100;
|
|
justify-content: center;
|
|
align-items: center
|
|
}
|
|
|
|
.modal-overlay.active {
|
|
display: flex
|
|
}
|
|
|
|
.modal {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
width: 440px;
|
|
max-width: 95vw;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
direction: rtl
|
|
}
|
|
|
|
html[dir=ltr] .modal {
|
|
direction: ltr
|
|
}
|
|
|
|
.modal h2 {
|
|
margin-bottom: 16px;
|
|
font-size: 17px;
|
|
font-weight: 600
|
|
}
|
|
|
|
.modal-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
justify-content: flex-end;
|
|
margin-top: 16px;
|
|
flex-wrap: wrap
|
|
}
|
|
|
|
/* ===== FORMS ===== */
|
|
.form-group {
|
|
margin-bottom: 12px
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
margin-bottom: 4px;
|
|
font-size: 12px;
|
|
color: var(--text-dim)
|
|
}
|
|
|
|
.form-group input,
|
|
.form-group textarea,
|
|
.form-group select {
|
|
width: 100%;
|
|
padding: 8px 12px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
direction: ltr;
|
|
text-align: left;
|
|
outline: none
|
|
}
|
|
|
|
.form-group input:focus,
|
|
.form-group textarea:focus,
|
|
.form-group select:focus {
|
|
border-color: var(--accent)
|
|
}
|
|
|
|
.form-group textarea {
|
|
min-height: 65px;
|
|
resize: vertical
|
|
}
|
|
|
|
.form-group .row {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center
|
|
}
|
|
|
|
.form-group .row input[type=checkbox] {
|
|
width: auto;
|
|
accent-color: var(--accent)
|
|
}
|
|
|
|
.form-group .row label {
|
|
margin: 0;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
color: var(--text)
|
|
}
|
|
|
|
/* ===== BUTTONS ===== */
|
|
.btn {
|
|
padding: 7px 16px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
transition: background .15s;
|
|
font-family: inherit
|
|
}
|
|
|
|
.btn-flat {
|
|
background: transparent;
|
|
color: var(--accent)
|
|
}
|
|
|
|
.btn-flat:hover {
|
|
background: var(--hover)
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(--accent);
|
|
color: #fff
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background: var(--accent-hover)
|
|
}
|
|
|
|
.btn-danger {
|
|
background: var(--error);
|
|
color: #fff
|
|
}
|
|
|
|
.btn-danger:hover {
|
|
background: #c62828
|
|
}
|
|
|
|
.btn-outline {
|
|
background: transparent;
|
|
border: 1px solid var(--border);
|
|
color: var(--text)
|
|
}
|
|
|
|
.btn-outline:hover {
|
|
background: var(--hover)
|
|
}
|
|
|
|
.btn-sm {
|
|
padding: 4px 10px;
|
|
font-size: 11px
|
|
}
|
|
|
|
/* ===== SETTINGS MODAL ===== */
|
|
.font-size-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-bottom: 14px
|
|
}
|
|
|
|
.font-size-row label {
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
white-space: nowrap
|
|
}
|
|
|
|
.font-size-row input[type=range] {
|
|
flex: 1;
|
|
accent-color: var(--accent)
|
|
}
|
|
|
|
.font-size-val {
|
|
font-size: 12px;
|
|
color: var(--text);
|
|
min-width: 28px;
|
|
text-align: center;
|
|
background: var(--bg);
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
border: 1px solid var(--border)
|
|
}
|
|
|
|
.lang-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 4px
|
|
}
|
|
|
|
.lang-btn {
|
|
flex: 1;
|
|
padding: 8px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background: var(--bg);
|
|
color: var(--text-dim);
|
|
font-size: 13px;
|
|
transition: all .15s
|
|
}
|
|
|
|
.lang-btn:hover {
|
|
background: var(--hover)
|
|
}
|
|
|
|
.lang-btn.active-lang {
|
|
border-color: var(--accent);
|
|
background: rgba(51, 144, 236, .12);
|
|
color: var(--accent);
|
|
font-weight: 600
|
|
}
|
|
|
|
/* ===== PROFILES MODAL ===== */
|
|
.profile-row {
|
|
display: flex;
|
|
flex-direction: column;
|
|
border-bottom: 1px solid var(--border)
|
|
}
|
|
|
|
.profile-row-main {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 10px;
|
|
cursor: pointer;
|
|
transition: background .1s
|
|
}
|
|
|
|
.profile-row-main:hover {
|
|
background: var(--hover)
|
|
}
|
|
|
|
.profile-row.active-profile .profile-row-main {
|
|
background: rgba(51, 144, 236, .08)
|
|
}
|
|
|
|
.profile-row-avatar {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background: var(--accent);
|
|
color: #fff;
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0
|
|
}
|
|
|
|
.profile-row.active-profile .profile-row-avatar {
|
|
background: var(--success)
|
|
}
|
|
|
|
.profile-row-info {
|
|
flex: 1;
|
|
min-width: 0
|
|
}
|
|
|
|
.profile-row-name {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis
|
|
}
|
|
|
|
.profile-row-domain {
|
|
font-size: 11px;
|
|
color: var(--text-dim)
|
|
}
|
|
|
|
.profile-row-btns {
|
|
display: flex;
|
|
gap: 4px;
|
|
flex-shrink: 0
|
|
}
|
|
|
|
.share-panel {
|
|
padding: 8px 10px;
|
|
background: var(--bg);
|
|
border-top: 1px solid var(--border)
|
|
}
|
|
|
|
.share-panel-inner {
|
|
display: flex;
|
|
gap: 6px;
|
|
align-items: center
|
|
}
|
|
|
|
.share-uri-input {
|
|
flex: 1;
|
|
padding: 6px 8px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
background: var(--surface);
|
|
color: var(--accent);
|
|
font-family: monospace;
|
|
font-size: 11px;
|
|
direction: ltr;
|
|
text-align: left;
|
|
outline: none
|
|
}
|
|
|
|
.active-badge {
|
|
display: inline-block;
|
|
font-size: 9px;
|
|
padding: 1px 6px;
|
|
border-radius: 8px;
|
|
background: var(--success);
|
|
color: #fff;
|
|
margin-left: 6px;
|
|
vertical-align: middle
|
|
}
|
|
|
|
html[dir=ltr] .active-badge {
|
|
margin-left: 0;
|
|
margin-right: 6px
|
|
}
|
|
|
|
.section-divider {
|
|
border: none;
|
|
border-top: 1px solid var(--border);
|
|
margin: 14px 0
|
|
}
|
|
|
|
.info-note {
|
|
background: rgba(51, 144, 236, .08);
|
|
border: 1px solid rgba(51, 144, 236, .2);
|
|
border-radius: 6px;
|
|
padding: 8px 12px;
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
margin-bottom: 10px;
|
|
line-height: 1.5
|
|
}
|
|
|
|
.tg-warning {
|
|
background: rgba(229, 57, 53, .1);
|
|
border: 1px solid rgba(229, 57, 53, .3);
|
|
border-radius: 6px;
|
|
padding: 8px 12px;
|
|
font-size: 11px;
|
|
color: #ef9a9a;
|
|
margin-bottom: 12px;
|
|
display: none
|
|
}
|
|
|
|
.tg-warning.visible {
|
|
display: block
|
|
}
|
|
|
|
.import-section {
|
|
margin-top: 14px
|
|
}
|
|
|
|
.import-row {
|
|
display: flex;
|
|
gap: 8px
|
|
}
|
|
|
|
.import-row input {
|
|
flex: 1;
|
|
padding: 7px 10px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-size: 12px;
|
|
direction: ltr;
|
|
text-align: left;
|
|
outline: none
|
|
}
|
|
|
|
.import-row input:focus {
|
|
border-color: var(--accent)
|
|
}
|
|
|
|
/* ===== CHANNEL EDITOR IN PROFILE ===== */
|
|
.channel-editor-row {
|
|
display: flex;
|
|
gap: 6px;
|
|
align-items: center;
|
|
margin-bottom: 8px
|
|
}
|
|
|
|
.channel-editor-row input {
|
|
flex: 1;
|
|
padding: 7px 10px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
direction: ltr;
|
|
text-align: left;
|
|
outline: none
|
|
}
|
|
|
|
.channel-editor-row input:focus {
|
|
border-color: var(--accent)
|
|
}
|
|
|
|
.channel-list-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 6px 0;
|
|
border-bottom: 1px solid var(--border)
|
|
}
|
|
|
|
.channel-list-item:last-child {
|
|
border-bottom: none
|
|
}
|
|
|
|
.channel-list-item span {
|
|
font-size: 13px;
|
|
direction: ltr
|
|
}
|
|
|
|
/* ===== TOAST ===== */
|
|
#toast {
|
|
position: fixed;
|
|
bottom: 24px;
|
|
left: 50%;
|
|
transform: translateX(-50%) translateY(20px);
|
|
background: #333;
|
|
color: #fff;
|
|
padding: 8px 18px;
|
|
border-radius: 20px;
|
|
font-size: 13px;
|
|
opacity: 0;
|
|
transition: all .25s;
|
|
z-index: 999;
|
|
pointer-events: none;
|
|
white-space: nowrap
|
|
}
|
|
|
|
#toast.show {
|
|
opacity: 1;
|
|
transform: translateX(-50%) translateY(0)
|
|
}
|
|
|
|
/* ===== MOBILE ===== */
|
|
@media(max-width:768px) {
|
|
.app {
|
|
position: relative;
|
|
overflow: hidden
|
|
}
|
|
|
|
.sidebar {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
width: 100%;
|
|
z-index: 2;
|
|
transform: translateX(0);
|
|
transition: transform .25s ease
|
|
}
|
|
|
|
.chat-area {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
z-index: 1;
|
|
transform: translateX(100%);
|
|
transition: transform .25s ease
|
|
}
|
|
|
|
.app.chat-open .sidebar {
|
|
transform: translateX(-100%)
|
|
}
|
|
|
|
.app.chat-open .chat-area {
|
|
transform: translateX(0)
|
|
}
|
|
|
|
.back-btn {
|
|
display: flex
|
|
}
|
|
|
|
.msg {
|
|
max-width: 90%
|
|
}
|
|
|
|
.modal {
|
|
width: 100%;
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
border-radius: 0;
|
|
border-left: none;
|
|
border-right: none;
|
|
border-bottom: none;
|
|
margin-top: auto;
|
|
align-self: flex-end
|
|
}
|
|
|
|
.modal-overlay.active {
|
|
align-items: flex-end
|
|
}
|
|
}
|
|
|
|
::-webkit-scrollbar {
|
|
width: 4px
|
|
}
|
|
|
|
::-webkit-scrollbar-track {
|
|
background: transparent
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb {
|
|
background: var(--border);
|
|
border-radius: 2px
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div class="app" id="app">
|
|
|
|
<!-- SIDEBAR -->
|
|
<div class="sidebar" id="sidebar">
|
|
<div class="sidebar-header">
|
|
<div class="sidebar-header-top">
|
|
<button class="profile-btn" id="profileBtn" onclick="openProfiles()">
|
|
<div class="profile-btn-avatar" id="profileBtnAvatar">?</div>
|
|
<span class="profile-btn-name" id="profileBtnName" data-i18n="set_up">Set Up</span>
|
|
<span class="profile-btn-arrow"><span class="plus">+</span>▼</span>
|
|
</button>
|
|
<button class="icon-btn" onclick="openSettings()" title="Settings" data-i18n-title="settings">⚙</button>
|
|
</div>
|
|
<div class="sidebar-toolbar">
|
|
<button class="stb" id="scannerIconBtn" onclick="openScanner()" data-i18n="sidebar_scanner">Scanner</button>
|
|
<button class="stb" id="resolversSidebarBtn" onclick="openResolversModal()"><span data-i18n="sidebar_resolvers">Resolvers</span> <span class="stb-badge" id="resolversBadge" style="color:var(--error)">0</span></button>
|
|
<button class="stb" onclick="jumpToLog()" data-i18n="sidebar_log">Log</button>
|
|
</div>
|
|
<input class="sidebar-search" id="channelSearch" type="text" data-i18n-ph="search" placeholder="Search..."
|
|
oninput="filterChannels()">
|
|
</div>
|
|
<div class="channel-list" id="channelList">
|
|
<div style="padding:20px;text-align:center;color:var(--text-dim);font-size:13px" data-i18n="no_channels">No
|
|
channels yet</div>
|
|
</div>
|
|
<div class="sidebar-footer">
|
|
TELEGRAM: <a href="https://t.me/networkti" target="_blank">@networkti</a>
|
|
·
|
|
<a href="https://github.com/sartoopjj/thefeed" target="_blank">GitHub</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CHAT AREA -->
|
|
<div class="chat-area">
|
|
<div class="chat-header">
|
|
<button class="back-btn" onclick="openSidebar()">←</button>
|
|
<div class="chat-header-info">
|
|
<div class="chat-header-name" id="chatName">thefeed</div>
|
|
</div>
|
|
<div class="chat-header-actions">
|
|
<span class="next-fetch-label" id="nextFetchTimer"></span><span class="next-fetch-info" id="nextFetchInfoBtn"
|
|
style="display:none" data-i18n-title="next_fetch_info" title="" onclick="showToast(t('next_fetch_info'))"
|
|
tabindex="0">ⓘ</span>
|
|
<button class="icon-btn" onclick="toggleMsgSearch()" title="Search" data-i18n-title="search_messages"
|
|
style="width:auto;height:30px;font-size:12px;padding:0 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface)" data-i18n="search_messages">Search</button>
|
|
<button class="icon-btn" onclick="openExportModal()" title="Export" data-i18n-title="export_messages"
|
|
style="width:auto;height:30px;font-size:12px;padding:0 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface)" data-i18n="export_messages">Export</button>
|
|
<button class="icon-btn" id="refreshBtn" onclick="doRefreshUI()" title="Refresh"
|
|
style="width:40px;height:40px;font-size:20px">↻</button>
|
|
</div>
|
|
</div>
|
|
<div class="msg-search-bar" id="msgSearchBar">
|
|
<input type="text" id="msgSearchInput" data-i18n-ph="search_messages" placeholder="Search messages..." oninput="doMsgSearch()">
|
|
<div class="search-nav">
|
|
<span id="msgSearchCount"></span>
|
|
<button onclick="msgSearchPrev()" title="Previous">▲</button>
|
|
<button onclick="msgSearchNext()" title="Next">▼</button>
|
|
<button onclick="closeMsgSearch()" title="Close">×</button>
|
|
</div>
|
|
</div>
|
|
<div class="messages" id="messages">
|
|
<div class="empty-state">
|
|
<div class="big-icon">📡</div>
|
|
<p data-i18n="configure_server">Configure a server to start reading</p>
|
|
<button class="btn btn-primary" onclick="openProfiles()" data-i18n="set_up">Set Up</button>
|
|
</div>
|
|
</div>
|
|
<div class="send-panel" id="sendPanel">
|
|
<input class="send-input" id="sendInput" data-i18n-ph="write_message" placeholder="Write a message..."
|
|
maxlength="4000">
|
|
<button class="send-btn" onclick="sendMessage()">➤</button>
|
|
</div>
|
|
<button class="scroll-down-btn" id="scrollDownBtn" onclick="scrollToBottom()" title="Jump to latest">↓<span
|
|
class="scroll-down-badge" id="scrollDownBadge"></span></button>
|
|
<div class="progress-panel" id="progressPanel"></div>
|
|
<div class="log-toggle" onclick="toggleLog()"><span>LOG</span><span id="logToggleIcon">▶</span></div>
|
|
<div class="log-panel hidden" id="logPanel"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TOAST -->
|
|
<div id="toast"></div>
|
|
|
|
<!-- ===== SAVED RESOLVERS POPUP ===== -->
|
|
<div class="modal-overlay" id="savedResolversModal">
|
|
<div class="modal" style="max-width:380px">
|
|
<h2 data-i18n="saved_resolvers_title">Quick Start</h2>
|
|
<p id="savedResolversMsg" style="font-size:13px;color:var(--text-dim);margin-bottom:16px;line-height:1.6"></p>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-flat" onclick="savedResolversSkip()" data-i18n="saved_resolvers_skip">Skip</button>
|
|
<button class="btn btn-outline" onclick="savedResolversRescan()" data-i18n="saved_resolvers_rescan">Scan
|
|
Again</button>
|
|
<button class="btn btn-primary" onclick="savedResolversUseNow()" data-i18n="saved_resolvers_use">Use
|
|
Now</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ===== SETTINGS MODAL ===== -->
|
|
<div class="modal-overlay" id="settingsModal">
|
|
<div class="modal">
|
|
<h2 data-i18n="settings">⚙ Settings</h2>
|
|
<div class="font-size-row">
|
|
<label data-i18n="font_size">Font Size</label>
|
|
<input type="range" id="fontSizeSlider" min="11" max="22" value="14" oninput="previewFontSize(this.value)">
|
|
<span class="font-size-val" id="fontSizeVal">14</span>
|
|
</div>
|
|
<div class="form-group">
|
|
<div class="row">
|
|
<input type="checkbox" id="cfgDebug">
|
|
<label for="cfgDebug" data-i18n="debug_mode">Debug mode</label>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label data-i18n="language">Language</label>
|
|
<div class="lang-row">
|
|
<button class="lang-btn" id="langFa" onclick="setLang('fa')">فارسی</button>
|
|
<button class="lang-btn" id="langEn" onclick="setLang('en')">English</button>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label data-i18n="theme">Theme</label>
|
|
<div class="theme-row">
|
|
<button class="theme-btn" id="themeDark" onclick="setTheme('dark')" data-i18n="theme_dark">Dark</button>
|
|
<button class="theme-btn" id="themeLight" onclick="setTheme('light')" data-i18n="theme_light">Light</button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border);font-size:11px;color:var(--text-dim);display:flex;align-items:center;justify-content:space-between">
|
|
<span data-i18n="version">Version</span>
|
|
<span id="appVersionEl" style="font-family:monospace;color:var(--text)">-</span>
|
|
</div>
|
|
<div
|
|
style="margin-top:10px;font-size:11px;color:var(--text-dim);display:flex;align-items:center;justify-content:space-between">
|
|
<span data-i18n="latest_version">Latest Version</span>
|
|
<span id="latestVersionEl" style="font-family:monospace;color:var(--text)">-</span>
|
|
</div>
|
|
<div style="margin-top:10px;display:flex;align-items:center;justify-content:space-between">
|
|
<span style="font-size:12px;color:var(--text-dim)" data-i18n="check_latest_version">Check for Updates</span>
|
|
<button class="btn btn-outline" id="checkVersionBtn" onclick="checkLatestVersion()"
|
|
style="font-size:11px;padding:4px 12px" data-i18n="check_now">Check Now</button>
|
|
</div>
|
|
<div style="margin-top:10px;display:flex;align-items:center;justify-content:space-between">
|
|
<span style="font-size:12px;color:var(--text-dim)" data-i18n="clear_cache">Clear Cache</span>
|
|
<button class="btn btn-flat" onclick="clearCache()"
|
|
style="font-size:11px;padding:4px 12px;color:var(--danger,#e74c3c)" data-i18n="clear_cache">Clear
|
|
Cache</button>
|
|
</div>
|
|
<div class="form-group" style="margin-top:12px">
|
|
<label data-i18n="bg_image">Background Image</label>
|
|
<div style="display:flex;gap:6px;align-items:center">
|
|
<input type="file" id="bgImageInput" accept="image/*" style="flex:1;font-size:12px;color:var(--text)" onchange="applyBgImage()">
|
|
<button class="btn btn-flat" onclick="clearBgImage()" style="font-size:11px;padding:4px 10px;color:var(--error)" data-i18n="clear_bg">Clear</button>
|
|
</div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-flat" onclick="closeSettings()" data-i18n="cancel">Cancel</button>
|
|
<button class="btn btn-primary" onclick="saveSettings()" data-i18n="save">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ===== PROFILES MODAL ===== -->
|
|
<div class="modal-overlay" id="profilesModal">
|
|
<div class="modal">
|
|
<h2 data-i18n="profiles">Profiles</h2>
|
|
<!-- Import URI at top -->
|
|
<div class="import-section"
|
|
style="margin-top:0;margin-bottom:14px;padding:10px 12px;background:var(--bg);border-radius:8px;border:1px solid var(--border)">
|
|
<div style="font-size:12px;color:var(--text-dim);margin-bottom:6px" data-i18n="import_uri_label">Import URI
|
|
</div>
|
|
<div class="import-row">
|
|
<input id="importUriInput" placeholder="thefeed://..." data-i18n-ph="import_uri_ph">
|
|
<button class="btn btn-primary btn-sm" onclick="doImportUri()" data-i18n="import">Import</button>
|
|
</div>
|
|
<div id="importError" style="color:var(--error);font-size:12px;display:none;margin-top:6px"></div>
|
|
<div id="importSuccess" style="color:var(--success);font-size:12px;display:none;margin-top:6px"></div>
|
|
</div>
|
|
<div id="profilesListEl"></div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-flat" onclick="closeProfiles()" data-i18n="close">Close</button>
|
|
<button class="btn btn-outline" onclick="openProfileEditor(null)" data-i18n="add_manual">✎ Create
|
|
Manually</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ===== PROFILE EDITOR MODAL ===== -->
|
|
<div class="modal-overlay" id="profileEditorModal">
|
|
<div class="modal">
|
|
<h2 id="profileEditorTitle" data-i18n="new_profile">New Profile</h2>
|
|
<div id="peWarning" class="tg-warning"></div>
|
|
<div class="form-group"><label data-i18n="nickname">Nickname</label><input id="peNick" placeholder="My Server">
|
|
</div>
|
|
<div class="form-group"><label data-i18n="domain">Domain</label><input id="peDomain" placeholder="t.example.com">
|
|
</div>
|
|
<div class="form-group"><label data-i18n="passphrase">Passphrase</label><input type="password" id="peKey"
|
|
placeholder="..."></div>
|
|
<div class="form-group"><label data-i18n="resolvers">Resolvers</label><textarea id="peResolvers"
|
|
placeholder="8.8.8.8 1.1.1.1"></textarea></div>
|
|
<div class="form-group"><label data-i18n="query_mode">Query Mode</label>
|
|
<select id="peQueryMode">
|
|
<option value="single">Single label (base32)</option>
|
|
<option value="double">Multi-label (hex)</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group"><label data-i18n="rate_limit">Concurrent block fetches</label><input type="number"
|
|
id="peRateLimit" value="6" min="0" step="0.1"></div>
|
|
<div class="form-group"><label data-i18n="scatter">Parallel resolvers per block</label><input type="number" id="peScatter"
|
|
value="4" min="1" max="5" title="How many resolvers are queried at the same time for one block"></div>
|
|
<div class="form-group"><label data-i18n="dns_timeout">DNS Query Timeout (seconds)</label><input type="number" id="peTimeout"
|
|
value="15" min="1" max="60" step="1"></div>
|
|
<!-- Channel Management (editing only) -->
|
|
<div id="peChannelSection" style="display:none">
|
|
<hr class="section-divider">
|
|
<div style="font-size:14px;font-weight:600;margin-bottom:8px" data-i18n="channels">Channels</div>
|
|
<div class="info-note" id="peChannelNote" data-i18n="channel_mgmt_note">Channel management requires server-side
|
|
support.</div>
|
|
<div class="channel-editor-row" id="peAddChannelRow" style="display:none">
|
|
<input id="peAddChannelInput" data-i18n-ph="channel_placeholder" placeholder="channel_username">
|
|
<button class="btn btn-primary btn-sm" onclick="addChannelEditor()" data-i18n="add">Add</button>
|
|
</div>
|
|
<div id="peChannelList" style="max-height:150px;overflow-y:auto"></div>
|
|
</div>
|
|
<div id="peError" style="color:var(--error);font-size:12px;display:none;margin-top:8px"></div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-flat" onclick="closeProfileEditor()" data-i18n="cancel">Cancel</button>
|
|
<button class="btn btn-danger" id="peDeleteBtn" style="display:none" onclick="deleteEditingProfile()"
|
|
data-i18n="delete">Delete</button>
|
|
<button class="btn btn-outline" id="peScannerBtn" style="display:none" onclick="openScannerFromProfile()"
|
|
data-i18n="scanner_find_resolvers">🔍 Find Resolvers</button>
|
|
<button class="btn btn-primary" onclick="saveProfile()" data-i18n="save">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ===== EXPORT MODAL ===== -->
|
|
<div class="modal-overlay" id="exportModal">
|
|
<div class="modal" style="max-width:380px">
|
|
<h2 data-i18n="export_title">Copy Messages</h2>
|
|
<div class="export-row">
|
|
<label data-i18n="export_count">Number of messages</label>
|
|
<div style="display:flex;align-items:center;gap:8px">
|
|
<button class="btn btn-outline" onclick="var e=document.getElementById('exportCount');e.value=Math.max(1,parseInt(e.value||1)-1)" style="width:32px;height:32px;padding:0;font-size:18px;border-radius:8px">−</button>
|
|
<input type="number" id="exportCount" value="10" min="1" max="500" style="width:70px;text-align:center">
|
|
<button class="btn btn-outline" onclick="var e=document.getElementById('exportCount');e.value=Math.min(parseInt(e.max)||500,parseInt(e.value||0)+1)" style="width:32px;height:32px;padding:0;font-size:18px;border-radius:8px">+</button>
|
|
</div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-flat" onclick="closeExportModal()" data-i18n="cancel">Cancel</button>
|
|
<button class="btn btn-primary" onclick="doExport()" data-i18n="export_copy">Copy to Clipboard</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ===== RESOLVERS MODAL ===== -->
|
|
<div class="modal-overlay" id="resolversModal">
|
|
<div class="modal" style="max-width:560px">
|
|
<h2 data-i18n="resolvers_title">Working Resolvers</h2>
|
|
<div id="resolversListEl" style="max-height:400px;overflow-y:auto;font-size:13px;margin-bottom:14px"></div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-flat" onclick="closeResolversModal()" data-i18n="close">Close</button>
|
|
<button class="btn btn-outline" onclick="resetScoreboard()" data-i18n="reset_scoreboard">Reset Scores</button>
|
|
<button class="btn btn-outline" onclick="copyResolversList()" data-i18n="copy">Copy</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ===== SCANNER MODAL ===== -->
|
|
<div class="modal-overlay" id="scannerModal">
|
|
<div class="modal" style="max-width:520px">
|
|
<h2 data-i18n="scanner_title">🔍 Resolver Scanner</h2>
|
|
|
|
<!-- Collapsible help -->
|
|
<div style="margin-bottom:14px">
|
|
<p id="scannerAboutShort" style="font-size:13px;color:var(--text-dim);line-height:1.5;margin:0">
|
|
<span data-i18n="scanner_about_short">Scan IP ranges to find DNS resolvers that work with your server.</span>
|
|
<a href="#" id="scannerReadMoreLink" onclick="event.preventDefault();document.getElementById('scannerAboutFull').style.display='';this.style.display='none'" style="color:var(--accent);text-decoration:none" data-i18n="scanner_read_more">Read more...</a>
|
|
</p>
|
|
<p id="scannerAboutFull" style="display:none;font-size:12px;color:var(--text-dim);line-height:1.6;margin:0"><span data-i18n="scanner_about">This tool scans IP ranges to find DNS servers that can reach your thefeed server. Enter CIDRs (like 192.168.1.0/24) or individual IPs, pick a profile, and hit Scan. The app sends a small test query to each IP. If the IP answers correctly, it is a working resolver. You can also turn on "Expand /24" — when a working IP is found, the app will automatically check nearby IPs in the same network. Results show response time so you can pick the fastest ones. You can pause, resume, or stop the scan at any time.</span> <a href="#" onclick="event.preventDefault();document.getElementById('scannerAboutFull').style.display='none';document.getElementById('scannerReadMoreLink').style.display=''" style="color:var(--accent);text-decoration:none" data-i18n="scanner_read_less">Show less</a></p>
|
|
</div>
|
|
|
|
<!-- Config section -->
|
|
<div id="scannerConfig">
|
|
<div class="form-group">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
|
|
<label data-i18n="scanner_targets">IPs or CIDRs (one per line)</label>
|
|
<div style="display:flex;gap:4px">
|
|
<button class="btn btn-flat" onclick="document.getElementById('scanTargets').value=''" data-i18n="scanner_clear_targets" style="font-size:12px;padding:4px 10px">🗑 Clear</button>
|
|
<button class="btn btn-flat" onclick="loadScannerPresets()" data-i18n="scanner_load_presets" style="font-size:12px;padding:4px 10px">🇮🇷 IR</button>
|
|
</div>
|
|
</div>
|
|
<textarea id="scanTargets" rows="3" placeholder="5.1.0.0/16 8.8.8.8 1.1.1.1" style="width:100%;font-family:monospace;font-size:13px"></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label data-i18n="scanner_profile">Profile</label>
|
|
<select id="scanProfile" style="width:100%"></select>
|
|
</div>
|
|
<details style="margin-bottom:12px">
|
|
<summary style="cursor:pointer;font-size:13px;color:var(--text-dim);user-select:none" data-i18n="scanner_advanced">Advanced options</summary>
|
|
<div style="margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
|
<div class="form-group">
|
|
<label data-i18n="query_mode">Query Mode</label>
|
|
<select id="scanQueryMode" style="width:100%">
|
|
<option value="single">Single label</option>
|
|
<option value="double">Multi-label</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label data-i18n="scanner_rate_limit">Concurrency</label>
|
|
<input type="number" id="scanRateLimit" value="50" min="1" max="500">
|
|
</div>
|
|
<div class="form-group">
|
|
<label data-i18n="scanner_timeout">Timeout (s)</label>
|
|
<input type="number" id="scanTimeout" value="15" min="1" max="60">
|
|
</div>
|
|
<div class="form-group">
|
|
<label data-i18n="scanner_max_ips">Max IPs (0=all)</label>
|
|
<input type="number" id="scanMaxIPs" value="0" min="0">
|
|
</div>
|
|
</div>
|
|
<div class="form-group" style="margin-top:4px">
|
|
<div class="row" style="gap:6px;align-items:center">
|
|
<input type="checkbox" id="scanExpand">
|
|
<label for="scanExpand" style="font-size:13px" data-i18n="scanner_expand_subnet">Expand /24 — scan nearby IPs when a resolver is found</label>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
|
|
<!-- Progress section -->
|
|
<div id="scannerProgressSection" style="display:none">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
|
<div style="font-size:14px;color:var(--text)">
|
|
<strong id="scanStatusLabel">-</strong>
|
|
</div>
|
|
<div style="font-size:13px;color:var(--text-dim)">
|
|
<span id="scanProgressText">0 / 0</span> — <span id="scanFoundText">0</span> <span data-i18n="scanner_found">found</span>
|
|
</div>
|
|
</div>
|
|
<div style="height:6px;border-radius:3px;margin-bottom:14px;background:var(--border);overflow:hidden">
|
|
<div id="scanProgressFill" style="width:0%;height:100%;border-radius:3px;background:var(--accent);transition:width .3s"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Results section -->
|
|
<div id="scannerResults" style="display:none;max-height:220px;overflow-y:auto;margin-bottom:14px;border:1px solid var(--border);border-radius:8px">
|
|
<table style="width:100%;font-size:13px;border-collapse:collapse">
|
|
<thead><tr style="background:var(--bg);position:sticky;top:0;z-index:1">
|
|
<th style="padding:8px;text-align:left;width:32px"><input type="checkbox" id="scanSelectAll" checked onchange="toggleScanSelectAll(this.checked)"></th>
|
|
<th style="padding:8px;text-align:left">IP</th>
|
|
<th style="padding:8px;text-align:right" data-i18n="scanner_latency">ms</th>
|
|
<th style="padding:8px;width:36px"></th>
|
|
</tr></thead>
|
|
<tbody id="scanResultsBody"></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Action bar -->
|
|
<div id="scannerApplySection" style="display:none;margin-bottom:14px">
|
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
|
<button class="btn btn-primary" style="flex:1;min-width:0;padding:10px 0;font-size:14px" onclick="applyScanResults('append')">
|
|
<span data-i18n="scanner_append">Append</span> <span id="scanAppendCount" style="opacity:.7"></span>
|
|
</button>
|
|
<button class="btn btn-outline" style="flex:1;min-width:0;padding:10px 0;font-size:14px" onclick="applyScanResults('overwrite')">
|
|
<span data-i18n="scanner_overwrite">Overwrite</span> <span id="scanOverwriteCount" style="opacity:.7"></span>
|
|
</button>
|
|
</div>
|
|
<div style="display:flex;gap:8px;margin-top:8px">
|
|
<button class="btn btn-flat" style="flex:1;padding:8px 0;font-size:13px" onclick="copySelectedScanResults()">
|
|
<span data-i18n="copy">Copy</span> <span id="scanCopyCount" style="opacity:.7"></span>
|
|
</button>
|
|
<button class="btn btn-flat" style="flex:1;padding:8px 0;font-size:13px" onclick="copyAllScanResults()" data-i18n="scanner_copy_all">Copy All</button>
|
|
<button class="btn btn-outline" style="flex:1;padding:8px 0;font-size:13px;font-weight:600" onclick="resetScannerUI()" data-i18n="scanner_new_scan">↺ New Scan</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-actions" style="gap:8px;flex-wrap:wrap">
|
|
<button class="btn btn-flat" onclick="closeScanner()" data-i18n="close" style="padding:10px 20px;font-size:14px">Close</button>
|
|
<div style="flex:1"></div>
|
|
<button class="btn btn-outline" id="scanPauseBtn" style="display:none;padding:10px 20px;font-size:14px" onclick="toggleScanPause()" data-i18n="scanner_pause">Pause</button>
|
|
<button class="btn btn-danger" id="scanStopBtn" style="display:none;padding:10px 20px;font-size:14px" onclick="stopScan()" data-i18n="scanner_stop">Stop</button>
|
|
<button class="btn btn-primary" id="scanStartBtn" onclick="startScan()" data-i18n="scanner_start" style="padding:10px 24px;font-size:14px">Start Scan</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// ===== i18n =====
|
|
var I18N = {
|
|
fa: {
|
|
search: 'جستجو...', settings: 'تنظیمات', profiles: 'پروفایلها',
|
|
no_channels: 'هنوز کانالی دریافت نشده', loading: 'در حال بارگذاری...', no_messages: 'هنوز پیامی در این کانال وجود ندارد',
|
|
no_channels_hint: 'در پسزمینه داریم کار میکنیم — برای دیدن جزئیات روی',
|
|
no_channels_hint2: 'کلیک کنید',
|
|
scanning_resolvers: 'در حال بررسی ریزالورها',
|
|
server_fetch_wait: 'سرور در حال دریافت اطلاعات جدید از تلگرام',
|
|
no_messages_hint: 'برنامه در حال دریافت پیامها است. لطفاً چند لحظه صبر کنید...',
|
|
write_message: 'پیام بنویسید...', configure_server: 'برای شروع یک سرور راهاندازی کنید',
|
|
set_up: 'راهاندازی', switching: 'در حال تغییر پروفایل...',
|
|
font_size: 'اندازه قلم', debug_mode: 'حالت دیباگ', language: 'زبان',
|
|
next_fetch_info: 'زمان باقیمانده تا دریافت بعدی محتوا توسط سرور',
|
|
no_profiles: 'هنوز پروفایلی وجود ندارد', add_profile: '+ پروفایل جدید',
|
|
import_uri_label: 'وارد کردن پیوند (URI)', import_uri_ph: 'thefeed://...', import: 'وارد کردن', close: 'بستن',
|
|
invalid_uri: 'پیوند باید با thefeed:// شروع شود', uri_missing: 'پیوند فاقد دامنه یا رمز است',
|
|
import_success: 'وارد شد! پروفایل "{d}" ساخته شد.', import_error: 'خطا در وارد کردن',
|
|
new_profile: 'پروفایل جدید', edit_profile: 'ویرایش پروفایل',
|
|
nickname: 'نام مستعار', domain: 'دامنه', passphrase: 'رمز رمزنگاری',
|
|
resolvers: 'Resolvers (یک در هر خط)', query_mode: 'حالت کوئری', rate_limit: 'تعداد همزمانی دریافت بلاکها',
|
|
channels: 'کانال\u200cها', add: 'افزودن', remove: 'حذف',
|
|
scatter: 'تعداد ریزالور همزمان برای هر بلاک',
|
|
update_available: 'نسخه جدید موجود است: {v}',
|
|
latest_version: 'آخرین نسخه قابل دانلود',
|
|
check_latest_version: 'بررسی نسخه جدید',
|
|
check_now: 'بررسی',
|
|
checking_version: 'در حال بررسی...',
|
|
version_up_to_date: 'نسخه شما بهروز است: {v}',
|
|
version_check_failed: 'بررسی نسخه ناموفق بود',
|
|
channel_mgmt_note: 'قابلیت مدیریت کانال نیاز به فعال سازی سمت سرور دارد. اگر توسط ادمین غیرفعال شده باشد، افزودن/حذف کار نمی\u200cکند.',
|
|
channel_mgmt_inactive: 'برای مدیریت کانال\u200cها، ابتدا این پروفایل را فعال کنید.',
|
|
channel_placeholder: 'نام کاربری کانال',
|
|
version: 'نسخه',
|
|
edit: 'ویرایش', share: 'اشتراک\u200cگذاری', delete: 'حذف', save: 'ذخیره', cancel: 'لغو',
|
|
copied: 'کپی شد!', copy: 'کپی', active: 'فعال',
|
|
private: 'خصوصی', x_posts: 'پستهای X', x_label: 'X', no_config: 'ابتدا پروفایل را ذخیره کنید',
|
|
refreshing: 'در حال بروزرسانی...', fetching_channel: 'در حال دریافت کانال...',
|
|
msg_copied: 'پیام کپی شد!', rescan_started: 'بررسی مجدد شروع شد',
|
|
add_manual: '✎ ساخت دستی', rescan: 'بررسی مجدد',
|
|
new_messages: 'پیام جدید', missed_messages: '{n} پیام از دست رفته یا حذف شده',
|
|
clear_cache: 'پاک کردن کش', cache_cleared: 'کش پاک شد!',
|
|
saved_resolvers_title: 'شروع سریع',
|
|
saved_resolvers_msg: 'آخرین اسکن ({t}) نتیجه داد: {n} سرور DNS سالم پیدا شد. همینها را استفاده کنیم یا دوباره اسکن کنیم؟',
|
|
saved_resolvers_use: 'استفاده کن (بدون اسکن)',
|
|
saved_resolvers_rescan: 'اسکن مجدد',
|
|
saved_resolvers_skip: 'بعداً',
|
|
saved_resolvers_applied: 'سرورهای DNS ذخیرهشده اعمال شدند',
|
|
minutes_ago: 'دقیقه پیش',
|
|
hours_ago: 'ساعت پیش',
|
|
scanner_title: '\uD83D\uDD0D اسکنر ریزالور',
|
|
scanner_about: 'این ابزار بازههای IP را برای پیدا کردن سرورهای DNS که به سرور thefeed شما دسترسی دارند اسکن میکند. CIDR (مثل 192.168.1.0/24) یا IP وارد کنید، پروفایل را انتخاب کنید و اسکن را شروع کنید. برنامه یک کوئری آزمایشی کوچک به هر IP میفرستد. اگر جواب درست بدهد، یک ریزالور کارآمد است. میتوانید «گسترش /24» را فعال کنید — وقتی یک IP کارآمد پیدا شد، آیپیهای نزدیک هم بررسی میشوند. نتایج زمان پاسخدهی را نشان میدهند تا بتوانید سریعترینها را انتخاب کنید. میتوانید هر زمان اسکن را متوقف، مکث یا ادامه دهید.',
|
|
scanner_targets: 'آیپی یا CIDR (هر خط یکی)',
|
|
scanner_profile: 'پروفایل',
|
|
scanner_rate_limit: 'همزمانی',
|
|
scanner_timeout: 'تایماوت (ثانیه)',
|
|
scanner_max_ips: 'حداکثر آیپی (0=همه)',
|
|
scanner_expand_subnet: 'گسترش /24 — وقتی ریزالور کارآمد پیدا شد آیپیهای نزدیک هم بررسی شوند',
|
|
scanner_status: 'وضعیت',
|
|
scanner_found: 'پیدا شده',
|
|
scanner_latency: 'زمان پاسخ',
|
|
scanner_append: 'افزودن به تنظیمات',
|
|
scanner_overwrite: 'جایگزینی تنظیمات',
|
|
scanner_start: 'شروع اسکن',
|
|
scanner_stop: 'توقف',
|
|
scanner_pause: 'مکث',
|
|
scanner_resume: 'ادامه',
|
|
scanner_find_resolvers: '\uD83D\uDD0D پیدا کردن ریزالور',
|
|
scanner_running: 'در حال اسکن',
|
|
scanner_paused: 'مکث شده',
|
|
scanner_done: 'تمام شد',
|
|
scanner_idle: 'آماده',
|
|
scanner_applied: 'ریزالورها اعمال شدند',
|
|
scanner_no_results: 'هیچ ریزالور کارآمدی پیدا نشد',
|
|
scanner_already_running: 'اسکنر در حال اجراست',
|
|
scanner_about_short: 'بازههای IP را اسکن کنید تا ریزالورهای DNS سازگار با سرور شما پیدا شوند.',
|
|
scanner_read_more: 'بیشتر بخوانید...',
|
|
scanner_read_less: 'بستن',
|
|
scanner_load_presets: '\uD83C\uDDEE\uD83C\uDDF7 بارگذاری لیست ایران',
|
|
scanner_new_scan: 'اسکن جدید',
|
|
scanner_advanced: 'تنظیمات پیشرفته',
|
|
scanner_copy_all: 'کپی همه',
|
|
theme: 'پوسته',
|
|
theme_dark: 'تاریک',
|
|
theme_light: 'روشن',
|
|
rescan_prompt_title: 'بررسی ریزالورها',
|
|
rescan_prompt_msg: '{n} ریزالور سالم از اسکن قبلی موجود است. بدون بررسی مجدد ادامه دهیم؟',
|
|
rescan_prompt_skip: 'ادامه بدون اسکن',
|
|
rescan_prompt_yes: 'بررسی مجدد',
|
|
search_messages: 'جستجو',
|
|
search_no_results: 'نتیجهای یافت نشد',
|
|
sidebar_scanner: 'اسکنر',
|
|
sidebar_resolvers: 'ریزالورها',
|
|
sidebar_log: 'لاگ',
|
|
export_title: 'کپی پیامها',
|
|
export_messages: 'کپی پیام',
|
|
export_count: 'تعداد پیام',
|
|
export_copy: 'کپی در کلیپبورد',
|
|
export_copied: 'پیامها کپی شدند!',
|
|
export_no_messages: 'پیامی برای خروجی وجود ندارد',
|
|
show_resolvers: 'ریزالورهای فعال',
|
|
show_resolvers_btn: 'نمایش',
|
|
resolvers_title: 'ریزالورهای فعال',
|
|
no_active_resolvers: 'ریزالور فعالی وجود ندارد',
|
|
resolver_speed: 'سرعت',
|
|
resolver_score: 'امتیاز',
|
|
reset_scoreboard: 'ریست امتیازها',
|
|
bg_image: 'تصویر پسزمینه',
|
|
apply: 'اعمال',
|
|
clear_bg: 'پاک کردن',
|
|
dns_timeout: 'تایماوت DNS (ثانیه)',
|
|
scanner_clear_targets: '\uD83D\uDDD1 پاک کردن',
|
|
},
|
|
en: {
|
|
search: 'Search...', settings: 'Settings', profiles: 'Profiles',
|
|
no_channels: 'No channels yet', loading: 'Loading...', no_messages: 'No messages in this channel',
|
|
no_channels_hint: 'Working in the background — tap',
|
|
no_channels_hint2: 'to see what\'s happening',
|
|
scanning_resolvers: 'Scanning resolvers',
|
|
server_fetch_wait: 'Server fetching fresh data from Telegram',
|
|
no_messages_hint: 'The app is trying to fetch messages. Please wait a moment...',
|
|
write_message: 'Write a message...', configure_server: 'Configure a server to start reading',
|
|
set_up: 'Set Up', switching: 'Switching profile...',
|
|
font_size: 'Font Size', debug_mode: 'Debug mode', language: 'Language',
|
|
next_fetch_info: 'Time until the server next fetches fresh channel content',
|
|
no_profiles: 'No profiles yet', add_profile: '+ Add Profile',
|
|
import_uri_label: 'Import URI', import_uri_ph: 'thefeed://...', import: 'Import', close: 'Close',
|
|
invalid_uri: 'URI must start with thefeed://', uri_missing: 'URI missing domain or passphrase',
|
|
import_success: 'Imported! Profile "{d}" created.', import_error: 'Import error',
|
|
new_profile: 'New Profile', edit_profile: 'Edit Profile',
|
|
nickname: 'Nickname', domain: 'Domain', passphrase: 'Passphrase',
|
|
resolvers: 'Resolvers (one per line)', query_mode: 'Query Mode', rate_limit: 'Concurrent block fetches',
|
|
channels: 'Channels', add: 'Add', remove: 'Remove',
|
|
scatter: 'Parallel resolvers per block',
|
|
update_available: 'New version available: {v}',
|
|
latest_version: 'Latest Version',
|
|
check_latest_version: 'Check for Updates',
|
|
check_now: 'Check Now',
|
|
checking_version: 'Checking...',
|
|
version_up_to_date: 'You are up to date: {v}',
|
|
version_check_failed: 'Version check failed',
|
|
channel_mgmt_note: 'Channel management requires server-side support. If disabled by the server admin, adding/removing channels will not work.',
|
|
channel_mgmt_inactive: 'Switch to this profile first to manage its channels.',
|
|
channel_placeholder: 'channel_username',
|
|
version: 'Version',
|
|
edit: 'Edit', share: 'Share', delete: 'Delete', save: 'Save', cancel: 'Cancel',
|
|
copied: 'URI copied!', copy: 'Copy', active: 'Active',
|
|
private: 'Private', x_posts: 'X Posts', x_label: 'X', no_config: 'Save a profile first',
|
|
refreshing: 'Refreshing...', fetching_channel: 'Fetching channel...',
|
|
msg_copied: 'Message copied!', rescan_started: 'Rescan started',
|
|
add_manual: '✎ Create Manually', rescan: 'Rescan',
|
|
new_messages: 'New messages', missed_messages: '{n} messages missed or deleted',
|
|
clear_cache: 'Clear Cache', cache_cleared: 'Cache cleared!',
|
|
saved_resolvers_title: 'Quick Start',
|
|
saved_resolvers_msg: 'Last scan ({t}) found {n} healthy DNS servers. Use them now (no scan needed), or scan again to re-verify.',
|
|
saved_resolvers_use: 'Use Now (skip scan)',
|
|
saved_resolvers_rescan: 'Scan Again',
|
|
saved_resolvers_skip: 'Later',
|
|
saved_resolvers_applied: 'Saved DNS servers applied!',
|
|
minutes_ago: 'min ago',
|
|
hours_ago: 'hr ago',
|
|
scanner_title: '\uD83D\uDD0D Resolver Scanner',
|
|
scanner_about: 'This tool scans IP ranges to find DNS servers that can reach your thefeed server. Enter CIDRs (like 192.168.1.0/24) or individual IPs, pick a profile, and hit Scan. The app sends a small test query to each IP. If the IP answers correctly, it is a working resolver. You can also turn on "Expand /24" — when a working IP is found, the app will automatically check nearby IPs in the same network. Results show response time so you can pick the fastest ones. You can pause, resume, or stop the scan at any time.',
|
|
scanner_targets: 'IPs or CIDRs (one per line)',
|
|
scanner_profile: 'Profile',
|
|
scanner_rate_limit: 'Concurrency',
|
|
scanner_timeout: 'Timeout (s)',
|
|
scanner_max_ips: 'Max IPs (0=all)',
|
|
scanner_expand_subnet: 'Expand /24 — scan nearby IPs when a working resolver is found',
|
|
scanner_status: 'Status',
|
|
scanner_found: 'found',
|
|
scanner_latency: 'Latency',
|
|
scanner_append: 'Append to Config',
|
|
scanner_overwrite: 'Overwrite Config',
|
|
scanner_start: 'Start Scan',
|
|
scanner_stop: 'Stop',
|
|
scanner_pause: 'Pause',
|
|
scanner_resume: 'Resume',
|
|
scanner_find_resolvers: '\uD83D\uDD0D Find Resolvers',
|
|
scanner_running: 'Running',
|
|
scanner_paused: 'Paused',
|
|
scanner_done: 'Done',
|
|
scanner_idle: 'Ready',
|
|
scanner_applied: 'Resolvers applied',
|
|
scanner_no_results: 'No working resolvers found',
|
|
scanner_already_running: 'Scanner is already running',
|
|
scanner_about_short: 'Scan IP ranges to find DNS resolvers that work with your server.',
|
|
scanner_read_more: 'Read more...',
|
|
scanner_read_less: 'Show less',
|
|
scanner_load_presets: '\uD83C\uDDEE\uD83C\uDDF7 Load IR Presets',
|
|
scanner_new_scan: 'New Scan',
|
|
scanner_advanced: 'Advanced options',
|
|
scanner_copy_all: 'Copy All',
|
|
theme: 'Theme',
|
|
theme_dark: 'Dark',
|
|
theme_light: 'Light',
|
|
rescan_prompt_title: 'Resolver Check',
|
|
rescan_prompt_msg: '{n} healthy resolvers from previous scan. Continue without rescanning?',
|
|
rescan_prompt_skip: 'Skip Rescan',
|
|
rescan_prompt_yes: 'Rescan',
|
|
search_messages: 'Search',
|
|
search_no_results: 'No results',
|
|
sidebar_scanner: 'Scanner',
|
|
sidebar_resolvers: 'Resolvers',
|
|
sidebar_log: 'Log',
|
|
export_title: 'Copy Messages',
|
|
export_messages: 'Copy',
|
|
export_count: 'Number of messages',
|
|
export_copy: 'Copy to Clipboard',
|
|
export_copied: 'Messages copied!',
|
|
export_no_messages: 'No messages to export',
|
|
show_resolvers: 'Working Resolvers',
|
|
show_resolvers_btn: 'Show',
|
|
resolvers_title: 'Working Resolvers',
|
|
no_active_resolvers: 'No active resolvers',
|
|
resolver_speed: 'Speed',
|
|
resolver_score: 'Score',
|
|
reset_scoreboard: 'Reset Scores',
|
|
bg_image: 'Background Image',
|
|
apply: 'Apply',
|
|
clear_bg: 'Clear',
|
|
dns_timeout: 'DNS Query Timeout (s)',
|
|
scanner_clear_targets: '\uD83D\uDDD1 Clear',
|
|
}
|
|
};
|
|
var lang = localStorage.getItem('thefeed_lang') || 'fa';
|
|
function t(k) { return (I18N[lang] && I18N[lang][k]) || I18N.en[k] || k }
|
|
function applyLang() {
|
|
var isRtl = lang === 'fa';
|
|
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
|
|
document.documentElement.lang = lang;
|
|
document.querySelectorAll('[data-i18n]').forEach(function (el) { el.textContent = t(el.dataset.i18n) });
|
|
document.querySelectorAll('[data-i18n-ph]').forEach(function (el) { el.placeholder = t(el.dataset.i18nPh) });
|
|
document.querySelectorAll('[data-i18n-title]').forEach(function (el) { el.title = t(el.dataset.i18nTitle) });
|
|
document.getElementById('langFa').classList.toggle('active-lang', lang === 'fa');
|
|
document.getElementById('langEn').classList.toggle('active-lang', lang === 'en');
|
|
document.getElementById('sendInput').style.direction = isRtl ? 'rtl' : 'ltr';
|
|
applyThemeButtons();
|
|
// Re-render dynamic content
|
|
if (channels.length > 0) renderChannels();
|
|
}
|
|
function setLang(l) { lang = l; localStorage.setItem('thefeed_lang', l); applyLang() }
|
|
|
|
// ===== STATE =====
|
|
var selectedChannel = 0, channels = [], eventSource = null, autoRefreshTimer = null, telegramLoggedIn = false, logVisible = false;
|
|
var serverNextFetch = 0, nextFetchInterval = null, previousMsgIDs = {}, currentMsgTexts = [];
|
|
var appVersion = '', latestVersion = '';
|
|
var profiles = null, activeProfileId = '', editingProfileId = null, resolverScanHint = '', resolverScanHealthy = 0, resolverScanDone = 0, resolverScanTotal = 0;
|
|
var currentMaxMsgID = 0;
|
|
var currentMaxTimestamp = 0;
|
|
var newMsgScrollDone = false;
|
|
|
|
// ===== MOBILE NAV =====
|
|
function openChat() {
|
|
if (window.innerWidth <= 768) {
|
|
document.getElementById('app').classList.add('chat-open');
|
|
history.pushState({ view: 'chat' }, '');
|
|
}
|
|
}
|
|
function openSidebar() { document.getElementById('app').classList.remove('chat-open') }
|
|
window.addEventListener('popstate', function () {
|
|
if (window.innerWidth <= 768 && document.getElementById('app').classList.contains('chat-open')) {
|
|
openSidebar();
|
|
}
|
|
});
|
|
function filterChannels() {
|
|
var q = document.getElementById('channelSearch').value.toLowerCase();
|
|
document.querySelectorAll('.ch-item').forEach(function (el) { el.style.display = el.dataset.name.toLowerCase().includes(q) ? 'flex' : 'none' });
|
|
}
|
|
|
|
// ===== INIT =====
|
|
async function init() {
|
|
loadTheme();
|
|
applyLang();
|
|
await loadFontSize();
|
|
loadBgImage();
|
|
connectSSE();
|
|
refreshResolversBadge();
|
|
try {
|
|
var r = await fetch('/api/status'); var st = await r.json();
|
|
await loadProfiles();
|
|
if (!st.configured) { openProfiles(); return }
|
|
checkAndShowSavedResolversPrompt(st);
|
|
telegramLoggedIn = !!st.telegramLoggedIn;
|
|
serverNextFetch = st.nextFetch || 0;
|
|
latestVersion = st.latestVersion || '';
|
|
renderLatestVersion();
|
|
updateNextFetchDisplay();
|
|
await loadChannels();
|
|
if (channels && channels.length > 0) await selectChannel(1);
|
|
else { showInitProgress(); await doRefresh(); openChat() }
|
|
startAutoRefresh();
|
|
} catch (e) { }
|
|
}
|
|
|
|
// ===== FONT SIZE =====
|
|
async function loadFontSize() {
|
|
try {
|
|
var r = await fetch('/api/settings'); var s = await r.json();
|
|
if (s.fontSize >= 11 && s.fontSize <= 22) {
|
|
document.documentElement.style.setProperty('--font-size', s.fontSize + 'px');
|
|
document.getElementById('fontSizeSlider').value = s.fontSize;
|
|
document.getElementById('fontSizeVal').textContent = s.fontSize;
|
|
}
|
|
if (s.debug) document.getElementById('cfgDebug').checked = true;
|
|
if (s.version) { appVersion = s.version; renderAppVersion(s.version, s.commit); }
|
|
renderLatestVersion();
|
|
} catch (e) { }
|
|
}
|
|
|
|
function renderAppVersion(v, commit) {
|
|
var vEl = document.getElementById('appVersionEl');
|
|
if (!vEl) return;
|
|
if (!v) { vEl.textContent = '-'; return; }
|
|
vEl.textContent = v + (commit && commit !== 'unknown' ? ' (' + commit.slice(0, 7) + ')' : '');
|
|
}
|
|
|
|
function renderLatestVersion() {
|
|
var vEl = document.getElementById('latestVersionEl');
|
|
if (vEl) vEl.textContent = latestVersion || '-';
|
|
}
|
|
|
|
function normalizeVersion(v) {
|
|
if (!v) return '';
|
|
v = String(v).trim().replace(/^v/i, '');
|
|
return v;
|
|
}
|
|
|
|
function compareSemver(a, b) {
|
|
a = normalizeVersion(a); b = normalizeVersion(b);
|
|
if (!a || !b || a === 'dev' || b === 'dev') return 0;
|
|
var as = a.split('.'); var bs = b.split('.');
|
|
var n = Math.max(as.length, bs.length);
|
|
for (var i = 0; i < n; i++) {
|
|
var ai = parseInt(as[i] || '0', 10); if (isNaN(ai)) ai = 0;
|
|
var bi = parseInt(bs[i] || '0', 10); if (isNaN(bi)) bi = 0;
|
|
if (ai > bi) return 1;
|
|
if (ai < bi) return -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function maybeWarnNewVersion() {
|
|
if (!latestVersion || !appVersion) return;
|
|
if (compareSemver(latestVersion, appVersion) <= 0) return;
|
|
var seenKey = 'thefeed_seen_update_' + normalizeVersion(latestVersion);
|
|
if (localStorage.getItem(seenKey) === '1') return;
|
|
localStorage.setItem(seenKey, '1');
|
|
showToast(t('update_available').replace('{v}', latestVersion));
|
|
addLogLine('Warning: ' + t('update_available').replace('{v}', latestVersion));
|
|
}
|
|
function previewFontSize(v) { document.documentElement.style.setProperty('--font-size', v + 'px'); document.getElementById('fontSizeVal').textContent = v }
|
|
|
|
// ===== THEME =====
|
|
function loadTheme() {
|
|
var t = localStorage.getItem('thefeed_theme') || 'dark';
|
|
document.documentElement.setAttribute('data-theme', t);
|
|
}
|
|
function setTheme(t) {
|
|
localStorage.setItem('thefeed_theme', t);
|
|
document.documentElement.setAttribute('data-theme', t);
|
|
applyThemeButtons();
|
|
}
|
|
function applyThemeButtons() {
|
|
var cur = localStorage.getItem('thefeed_theme') || 'dark';
|
|
var d = document.getElementById('themeDark');
|
|
var l = document.getElementById('themeLight');
|
|
if (d) d.classList.toggle('active-theme', cur === 'dark');
|
|
if (l) l.classList.toggle('active-theme', cur === 'light');
|
|
}
|
|
|
|
// ===== LAST SEEN MESSAGES =====
|
|
function channelName(num) {
|
|
var ch = channels[num - 1];
|
|
return (ch && (ch.Name || ch.name)) || '';
|
|
}
|
|
function getLastSeenTimestamp(name) {
|
|
if (!name) return 0;
|
|
try { return parseInt(localStorage.getItem('thefeed_seen_ts_' + name)) || 0 } catch (e) { return 0 }
|
|
}
|
|
function setLastSeenTimestamp(name, ts) {
|
|
if (!name) return;
|
|
try { localStorage.setItem('thefeed_seen_ts_' + name, ts) } catch (e) { }
|
|
}
|
|
|
|
// ===== RESCAN PROMPT =====
|
|
function showRescanPrompt(count) {
|
|
return new Promise(function (resolve) {
|
|
var msg = t('rescan_prompt_msg').replace('{n}', count);
|
|
var overlay = document.createElement('div');
|
|
overlay.className = 'modal-overlay active';
|
|
overlay.innerHTML = '<div class="modal" style="max-width:380px"><h2>' + t('rescan_prompt_title') + '</h2><p style="font-size:13px;color:var(--text-dim);margin-bottom:16px;line-height:1.6">' + esc(msg) + '</p><div class="modal-actions"><button class="btn btn-primary" id="rescanPromptSkip">' + t('rescan_prompt_skip') + '</button><button class="btn btn-outline" id="rescanPromptYes">' + t('rescan_prompt_yes') + '</button></div></div>';
|
|
document.body.appendChild(overlay);
|
|
document.getElementById('rescanPromptSkip').onclick = function () { document.body.removeChild(overlay); resolve(true) };
|
|
document.getElementById('rescanPromptYes').onclick = function () { document.body.removeChild(overlay); resolve(false) };
|
|
});
|
|
}
|
|
|
|
// ===== SETTINGS =====
|
|
function openSettings() {
|
|
renderLatestVersion();
|
|
applyThemeButtons();
|
|
document.getElementById('settingsModal').classList.add('active');
|
|
}
|
|
function closeSettings() { document.getElementById('settingsModal').classList.remove('active') }
|
|
async function saveSettings() {
|
|
var fs = parseInt(document.getElementById('fontSizeSlider').value) || 14;
|
|
var dbg = document.getElementById('cfgDebug').checked;
|
|
try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fontSize: fs, debug: dbg }) }) } catch (e) { }
|
|
closeSettings();
|
|
}
|
|
async function checkLatestVersion() {
|
|
var btn = document.getElementById('checkVersionBtn');
|
|
var prevText = btn ? btn.textContent : '';
|
|
if (btn) {
|
|
btn.disabled = true;
|
|
btn.textContent = t('checking_version');
|
|
}
|
|
try {
|
|
var r = await fetch('/api/version-check', { method: 'POST' });
|
|
var text = await r.text();
|
|
var data = {};
|
|
try { data = JSON.parse(text) } catch (e) { }
|
|
if (!r.ok) {
|
|
showToast(text || t('version_check_failed'));
|
|
return;
|
|
}
|
|
latestVersion = data.latestVersion || '';
|
|
renderLatestVersion();
|
|
if (!latestVersion) {
|
|
showToast(t('version_check_failed'));
|
|
return;
|
|
}
|
|
if (compareSemver(latestVersion, appVersion) > 0) maybeWarnNewVersion();
|
|
else showToast(t('version_up_to_date').replace('{v}', latestVersion));
|
|
} catch (e) {
|
|
showToast(e.message || t('version_check_failed'));
|
|
} finally {
|
|
if (btn) {
|
|
btn.disabled = false;
|
|
btn.textContent = prevText || t('check_now');
|
|
}
|
|
}
|
|
}
|
|
async function clearCache() {
|
|
try { var r = await fetch('/api/cache/clear', { method: 'POST' }); var j = await r.json(); if (j.ok) { alert(t('cache_cleared')) } } catch (e) { }
|
|
}
|
|
|
|
// ===== SAVED RESOLVERS PROMPT =====
|
|
function checkAndShowSavedResolversPrompt(status) {
|
|
if (sessionStorage.getItem('thefeed_scan_prompt_shown')) return;
|
|
if (!status.lastScan || !status.lastScan.count) return;
|
|
var ls = status.lastScan;
|
|
var ageSec = Math.floor(Date.now() / 1000) - ls.scannedAt;
|
|
var ageStr;
|
|
if (ageSec < 3600) ageStr = Math.max(1, Math.round(ageSec / 60)) + ' ' + t('minutes_ago');
|
|
else ageStr = Math.round(ageSec / 3600) + ' ' + t('hours_ago');
|
|
var msg = t('saved_resolvers_msg').replace('{n}', ls.count).replace('{t}', ageStr);
|
|
document.getElementById('savedResolversMsg').textContent = msg;
|
|
document.getElementById('savedResolversModal').classList.add('active');
|
|
}
|
|
function savedResolversSkip() {
|
|
// "Later" — just close, server already applied saved resolvers and refresh is underway
|
|
document.getElementById('savedResolversModal').classList.remove('active');
|
|
sessionStorage.setItem('thefeed_scan_prompt_shown', '1');
|
|
}
|
|
function savedResolversUseNow() {
|
|
// Server already applied saved resolvers at startup; just close the popup
|
|
savedResolversSkip();
|
|
showToast(t('saved_resolvers_applied'));
|
|
}
|
|
async function savedResolversRescan() {
|
|
savedResolversSkip();
|
|
try { await fetch('/api/rescan', { method: 'POST' }) } catch (e) { }
|
|
showToast(t('rescan_started'));
|
|
}
|
|
|
|
// ===== SSE =====
|
|
function connectSSE() {
|
|
if (eventSource) eventSource.close();
|
|
// Clear stale progress items from a previous connection
|
|
document.getElementById('progressPanel').innerHTML = '';
|
|
resolverScanHint = '';
|
|
eventSource = new EventSource('/api/events');
|
|
eventSource.addEventListener('log', function (e) { addLogLine(JSON.parse(e.data)) });
|
|
eventSource.addEventListener('update', async function (e) {
|
|
var data; try { data = JSON.parse(e.data) } catch (x) { data = e.data }
|
|
var wasEmpty = channels.length === 0;
|
|
var snapChannel = selectedChannel;
|
|
await loadChannels();
|
|
if (wasEmpty && channels.length > 0 && selectedChannel === 0) {
|
|
closeProfiles();
|
|
await selectChannel(1); return
|
|
}
|
|
if (data && typeof data === 'object' && data.channel) {
|
|
if (data.channel === snapChannel) await loadMessages(data.channel)
|
|
} else if (snapChannel > 0) { await loadMessages(snapChannel) }
|
|
updateSendPanel();
|
|
});
|
|
eventSource.onerror = function () {
|
|
if (eventSource.readyState === EventSource.CLOSED) { eventSource.close(); setTimeout(connectSSE, 3000) }
|
|
};
|
|
}
|
|
|
|
// ===== PROFILES =====
|
|
async function loadProfiles() {
|
|
try {
|
|
var r = await fetch('/api/profiles'); profiles = await r.json();
|
|
activeProfileId = profiles.active || '';
|
|
renderProfileBtn();
|
|
} catch (e) { profiles = null }
|
|
}
|
|
|
|
function renderProfileBtn() {
|
|
var nameEl = document.getElementById('profileBtnName');
|
|
var avatarEl = document.getElementById('profileBtnAvatar');
|
|
if (!profiles || !profiles.profiles || !profiles.profiles.length) {
|
|
nameEl.textContent = t('set_up'); avatarEl.textContent = '?'; return
|
|
}
|
|
var active = profiles.profiles.find(function (p) { return p.id === activeProfileId });
|
|
var display = (active && (active.nickname || active.config.domain)) || t('profiles');
|
|
nameEl.textContent = display;
|
|
avatarEl.textContent = (active && (active.nickname || active.config.domain) || '?').charAt(0).toUpperCase();
|
|
}
|
|
|
|
function openProfiles() {
|
|
document.getElementById('profilesModal').classList.add('active');
|
|
document.getElementById('importError').style.display = 'none';
|
|
document.getElementById('importSuccess').style.display = 'none';
|
|
document.getElementById('importUriInput').value = '';
|
|
renderProfilesModal();
|
|
}
|
|
function closeProfiles() { document.getElementById('profilesModal').classList.remove('active') }
|
|
|
|
function buildProfileUri(id) {
|
|
if (!profiles || !profiles.profiles) return '';
|
|
var p = profiles.profiles.find(function (x) { return x.id === id });
|
|
if (!p || !p.config.domain) return '';
|
|
return 'thefeed://' + encodeURIComponent(p.config.domain) + '/' + encodeURIComponent(p.config.key) + '?r=' + encodeURIComponent((p.config.resolvers || []).join(','));
|
|
}
|
|
|
|
function renderProfilesModal() {
|
|
var el = document.getElementById('profilesListEl');
|
|
if (!profiles || !profiles.profiles || !profiles.profiles.length) {
|
|
el.innerHTML = '<div style="color:var(--text-dim);padding:14px 0;font-size:13px">' + t('no_profiles') + '</div>'; return
|
|
}
|
|
var h = '';
|
|
for (var i = 0; i < profiles.profiles.length; i++) {
|
|
var p = profiles.profiles[i];
|
|
var isActive = p.id === activeProfileId;
|
|
var initial = (p.nickname || p.config.domain || '?').charAt(0).toUpperCase();
|
|
var shareId = 'share-' + p.id;
|
|
h += '<div class="profile-row' + (isActive ? ' active-profile' : '') + '" id="prow-' + p.id + '">';
|
|
h += '<div class="profile-row-main" onclick="activateProfile(\'' + p.id + '\')">';
|
|
h += '<div class="profile-row-avatar">' + esc(initial) + '</div>';
|
|
h += '<div class="profile-row-info"><div class="profile-row-name">' + esc(p.nickname || p.config.domain);
|
|
if (isActive) h += '<span class="active-badge">' + t('active') + '</span>';
|
|
h += '</div><div class="profile-row-domain">' + esc(p.config.domain) + '</div></div>';
|
|
h += '<div class="profile-row-btns">';
|
|
if (isActive) h += '<button class="btn btn-flat btn-sm" onclick="event.stopPropagation();doRescanFromProfiles()" title="' + t('rescan') + '" style="color:var(--success);font-size:11px">' + t('rescan') + '</button>';
|
|
h += '<button class="btn btn-flat btn-sm" onclick="event.stopPropagation();toggleSharePanel(\'' + p.id + '\')" title="' + t('share') + '" style="font-size:11px">' + t('share') + '</button>';
|
|
h += '<button class="btn btn-flat btn-sm" onclick="event.stopPropagation();openProfileEditor(\'' + p.id + '\')" title="' + t('edit') + '" style="font-size:11px">' + t('edit') + '</button>';
|
|
h += '</div></div>';
|
|
// Share panel (hidden by default)
|
|
h += '<div class="share-panel" id="' + shareId + '" style="display:none">';
|
|
h += '<div class="share-panel-inner">';
|
|
h += '<input class="share-uri-input" type="text" readonly id="suri-' + p.id + '" value="">';
|
|
h += '<button class="btn btn-primary btn-sm" onclick="copyShareUri(\'' + p.id + '\')">' + t('copy') + '</button>';
|
|
h += '<button class="btn btn-flat btn-sm" onclick="toggleSharePanel(\'' + p.id + '\')">✕</button>';
|
|
h += '</div></div>';
|
|
h += '</div>';
|
|
}
|
|
el.innerHTML = h;
|
|
}
|
|
|
|
function toggleSharePanel(id) {
|
|
// Close all first
|
|
document.querySelectorAll('.share-panel').forEach(function (sp) { sp.style.display = 'none' });
|
|
var panel = document.getElementById('share-' + id);
|
|
if (!panel) return;
|
|
var uri = buildProfileUri(id);
|
|
var input = document.getElementById('suri-' + id);
|
|
if (input) input.value = uri || t('no_config');
|
|
panel.style.display = 'block';
|
|
}
|
|
|
|
function copyShareUri(id) {
|
|
var uri = buildProfileUri(id);
|
|
if (!uri) { showToast(t('no_config')); return }
|
|
navigator.clipboard.writeText(uri).then(function () { showToast(t('copied')) }).catch(function () {
|
|
var input = document.getElementById('suri-' + id); if (input) { input.select(); input.setSelectionRange(0, 9999); }
|
|
showToast(t('copied'));
|
|
});
|
|
}
|
|
|
|
async function activateProfile(id) {
|
|
if (id === activeProfileId) { closeProfiles(); return }
|
|
// Check if we should skip resolver check
|
|
var skipCheck = false;
|
|
try {
|
|
var stRes = await fetch('/api/status');
|
|
var st = await stRes.json();
|
|
if (st.lastScan && st.lastScan.count > 0) {
|
|
skipCheck = await showRescanPrompt(st.lastScan.count);
|
|
}
|
|
} catch (e) { }
|
|
try {
|
|
var r = await fetch('/api/profiles/switch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: id, skipCheck: skipCheck }) });
|
|
if (!r.ok) return;
|
|
activeProfileId = id; selectedChannel = 0; channels = []; document.getElementById('progressPanel').innerHTML = ''; document.getElementById('messages').innerHTML = '<div class="empty-state"><p>' + t('switching') + '</p></div>';
|
|
await loadProfiles(); closeProfiles();
|
|
await loadChannels();
|
|
if (channels.length > 0) await selectChannel(1);
|
|
else { showInitProgress(); await doRefresh() }
|
|
} catch (e) { }
|
|
}
|
|
|
|
// ===== IMPORT URI =====
|
|
async function doImportUri() {
|
|
var errEl = document.getElementById('importError'); var okEl = document.getElementById('importSuccess');
|
|
errEl.style.display = 'none'; okEl.style.display = 'none';
|
|
var uri = document.getElementById('importUriInput').value.trim();
|
|
if (!uri.startsWith('thefeed://')) { errEl.textContent = t('invalid_uri'); errEl.style.display = 'block'; return }
|
|
try {
|
|
var body = uri.substring('thefeed://'.length);
|
|
var qIdx = body.indexOf('?'); var path = qIdx >= 0 ? body.substring(0, qIdx) : body;
|
|
var params = qIdx >= 0 ? body.substring(qIdx + 1) : '';
|
|
var parts = path.split('/');
|
|
var domain = decodeURIComponent(parts[0] || ''); var key = decodeURIComponent(parts[1] || '');
|
|
var resolvers = [];
|
|
params.split('&').forEach(function (kv) { var p = kv.split('='); if (p[0] === 'r' && p[1]) resolvers = decodeURIComponent(p[1]).split(',').filter(Boolean) });
|
|
if (!domain || !key) { errEl.textContent = t('uri_missing'); errEl.style.display = 'block'; return }
|
|
if (!resolvers.length) resolvers = ['8.8.8.8', '1.1.1.1'];
|
|
var profile = { id: '', nickname: domain, config: { domain: domain, key: key, resolvers: resolvers, queryMode: 'single', rateLimit: 6 } };
|
|
var r = await fetch('/api/profiles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'create', profile: profile }) });
|
|
if (!r.ok) throw new Error('save failed');
|
|
okEl.textContent = t('import_success').replace('{d}', domain); okEl.style.display = 'block';
|
|
document.getElementById('importUriInput').value = '';
|
|
await loadProfiles(); renderProfilesModal();
|
|
} catch (e) { errEl.textContent = t('import_error') + ': ' + e.message; errEl.style.display = 'block' }
|
|
}
|
|
|
|
// ===== PROFILE EDITOR =====
|
|
function openProfileEditor(id) {
|
|
editingProfileId = id;
|
|
document.getElementById('profileEditorModal').classList.add('active');
|
|
document.getElementById('peError').style.display = 'none';
|
|
document.getElementById('peWarning').style.display = 'none';
|
|
if (id) {
|
|
document.getElementById('profileEditorTitle').textContent = t('edit_profile');
|
|
document.getElementById('peDeleteBtn').style.display = '';
|
|
document.getElementById('peScannerBtn').style.display = '';
|
|
var p = profiles && profiles.profiles && profiles.profiles.find(function (x) { return x.id === id });
|
|
if (p) {
|
|
document.getElementById('peNick').value = p.nickname || '';
|
|
document.getElementById('peDomain').value = p.config.domain || '';
|
|
document.getElementById('peKey').value = p.config.key || '';
|
|
document.getElementById('peResolvers').value = (p.config.resolvers || []).join('\n');
|
|
document.getElementById('peQueryMode').value = p.config.queryMode || 'single';
|
|
document.getElementById('peRateLimit').value = p.config.rateLimit || 6;
|
|
document.getElementById('peScatter').value = p.config.scatter || 4;
|
|
document.getElementById('peTimeout').value = p.config.timeout || 15;
|
|
}
|
|
document.getElementById('peChannelSection').style.display = '';
|
|
var isActive = id === activeProfileId;
|
|
if (isActive) {
|
|
document.getElementById('peChannelNote').textContent = t('channel_mgmt_note');
|
|
document.getElementById('peAddChannelRow').style.display = 'flex';
|
|
loadEditorChannels();
|
|
} else {
|
|
document.getElementById('peChannelNote').textContent = t('channel_mgmt_inactive');
|
|
document.getElementById('peAddChannelRow').style.display = 'none';
|
|
document.getElementById('peChannelList').innerHTML = '';
|
|
}
|
|
} else {
|
|
document.getElementById('profileEditorTitle').textContent = t('new_profile');
|
|
document.getElementById('peDeleteBtn').style.display = 'none';
|
|
document.getElementById('peScannerBtn').style.display = 'none';
|
|
document.getElementById('peNick').value = '';
|
|
document.getElementById('peDomain').value = '';
|
|
document.getElementById('peKey').value = '';
|
|
document.getElementById('peResolvers').value = '';
|
|
document.getElementById('peQueryMode').value = 'single';
|
|
document.getElementById('peRateLimit').value = '6';
|
|
document.getElementById('peScatter').value = '4';
|
|
document.getElementById('peTimeout').value = '15';
|
|
document.getElementById('peChannelSection').style.display = 'none';
|
|
}
|
|
}
|
|
function closeProfileEditor() { document.getElementById('profileEditorModal').classList.remove('active'); editingProfileId = null }
|
|
|
|
async function loadEditorChannels() {
|
|
var el = document.getElementById('peChannelList');
|
|
el.innerHTML = '<div style="color:var(--text-dim);font-size:12px">' + t('loading') + '</div>';
|
|
try {
|
|
var r = await fetch('/api/admin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'list_channels', arg: '' }) });
|
|
if (!r.ok) throw new Error(await r.text() || 'failed');
|
|
var data = await r.json();
|
|
var chans = (data.result || '').split('\n').filter(function (s) { return s.trim() });
|
|
if (!chans.length) { el.innerHTML = '<div style="color:var(--text-dim);font-size:12px">' + t('no_channels') + '</div>'; return }
|
|
var h = '';
|
|
for (var i = 0; i < chans.length; i++) {
|
|
h += '<div class="channel-list-item"><span>' + esc(chans[i]) + '</span>';
|
|
h += '<button class="btn btn-flat btn-sm" style="color:var(--error)" data-ch="' + esc(chans[i]) + '" onclick="removeChannelEditor(this.dataset.ch)">' + t('remove') + '</button></div>';
|
|
}
|
|
el.innerHTML = h;
|
|
} catch (e) { el.innerHTML = '<div style="color:var(--error);font-size:12px">' + esc(e.message) + '</div>' }
|
|
}
|
|
|
|
async function addChannelEditor() {
|
|
var input = document.getElementById('peAddChannelInput');
|
|
var u = input.value.trim().replace(/^@/, ''); if (!u) return;
|
|
try {
|
|
var r = await fetch('/api/admin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'add_channel', arg: u }) });
|
|
if (!r.ok) { showToast(await r.text() || 'Failed'); return }
|
|
input.value = ''; addLogLine('Channel added: ' + u); loadEditorChannels();
|
|
} catch (e) { showToast(e.message) }
|
|
}
|
|
|
|
async function removeChannelEditor(u) {
|
|
try {
|
|
var r = await fetch('/api/admin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'remove_channel', arg: u }) });
|
|
if (!r.ok) { showToast(await r.text() || 'Failed'); return }
|
|
addLogLine('Channel removed: ' + u); loadEditorChannels();
|
|
} catch (e) { showToast(e.message) }
|
|
}
|
|
|
|
async function saveProfile() {
|
|
var errEl = document.getElementById('peError'); errEl.style.display = 'none';
|
|
var nick = document.getElementById('peNick').value.trim();
|
|
var domain = document.getElementById('peDomain').value.trim();
|
|
var key = document.getElementById('peKey').value;
|
|
var resolvers = document.getElementById('peResolvers').value.trim().split(/[\n,]+/).map(function (s) { return s.trim() }).filter(Boolean);
|
|
if (!domain || !key || !resolvers.length) { errEl.textContent = t('resolvers') + ' / ' + t('domain') + ' / ' + t('passphrase'); errEl.style.display = 'block'; return }
|
|
var profile = { id: editingProfileId || '', nickname: nick || domain, config: { domain: domain, key: key, resolvers: resolvers, queryMode: document.getElementById('peQueryMode').value, rateLimit: parseFloat(document.getElementById('peRateLimit').value) || 6, scatter: parseInt(document.getElementById('peScatter').value) || 4, timeout: parseInt(document.getElementById('peTimeout').value) || 15 } };
|
|
var action = editingProfileId ? 'update' : 'create';
|
|
var wasFirst = !profiles || !profiles.profiles || profiles.profiles.length === 0;
|
|
// Check if we should skip resolver check (existing healthy resolvers)
|
|
var skipCheck = false;
|
|
if (editingProfileId && editingProfileId === activeProfileId) {
|
|
try {
|
|
var stRes = await fetch('/api/status');
|
|
var st = await stRes.json();
|
|
if (st.lastScan && st.lastScan.count > 0) {
|
|
skipCheck = await showRescanPrompt(st.lastScan.count);
|
|
}
|
|
} catch (e) { }
|
|
}
|
|
try {
|
|
var r = await fetch('/api/profiles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: action, profile: profile, skipCheck: skipCheck }) });
|
|
if (!r.ok) { errEl.textContent = await r.text(); errEl.style.display = 'block'; return }
|
|
await loadProfiles();
|
|
var savedEditId = editingProfileId;
|
|
closeProfileEditor();
|
|
if (wasFirst) {
|
|
closeProfiles();
|
|
document.getElementById('progressPanel').innerHTML = '';
|
|
document.getElementById('messages').innerHTML = '<div class="empty-state"><p>' + t('loading') + '</p></div>';
|
|
showInitProgress();
|
|
} else {
|
|
renderProfilesModal();
|
|
// Only rescan if we updated the currently active profile
|
|
if (savedEditId && savedEditId === activeProfileId) {
|
|
showToast(t('rescan_started'));
|
|
document.getElementById('progressPanel').innerHTML = '';
|
|
showInitProgress();
|
|
await loadChannels();
|
|
if (selectedChannel > 0) await loadMessages(selectedChannel);
|
|
}
|
|
}
|
|
} catch (e) { errEl.textContent = e.message; errEl.style.display = 'block' }
|
|
}
|
|
|
|
// ===== RESCAN =====
|
|
async function doRescanFromProfiles() {
|
|
closeProfiles();
|
|
showToast(t('rescan_started'));
|
|
document.getElementById('progressPanel').innerHTML = '';
|
|
showInitProgress();
|
|
try { await fetch('/api/rescan', { method: 'POST' }) } catch (e) { }
|
|
setTimeout(function () { loadChannels().then(function () { if (selectedChannel > 0) loadMessages(selectedChannel) }) }, 3000);
|
|
}
|
|
|
|
async function deleteEditingProfile() {
|
|
if (!editingProfileId) return;
|
|
if (!confirm(t('delete') + '?')) return;
|
|
try {
|
|
await fetch('/api/profiles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'delete', profile: { id: editingProfileId } }) });
|
|
await loadProfiles(); closeProfileEditor();
|
|
if (document.getElementById('profilesModal').classList.contains('active')) renderProfilesModal();
|
|
} catch (e) { }
|
|
}
|
|
|
|
// ===== CHANNELS =====
|
|
async function loadChannels() {
|
|
try {
|
|
var r = await fetch('/api/channels'); channels = await r.json(); if (!channels) channels = [];
|
|
renderChannels(); updateSendPanel();
|
|
var initBar = document.getElementById('prog-init'); if (initBar) initBar.remove();
|
|
var sr = await fetch('/api/status'); var st = await sr.json();
|
|
telegramLoggedIn = !!st.telegramLoggedIn;
|
|
if (st.nextFetch) { serverNextFetch = st.nextFetch; updateNextFetchDisplay() }
|
|
if (st.latestVersion) { latestVersion = st.latestVersion; renderLatestVersion() }
|
|
} catch (e) { }
|
|
}
|
|
|
|
var _renderChannelsTimer = null;
|
|
function renderChannels() {
|
|
// Debounce: avoid rapid sequential DOM rebuilds that cause hover flicker
|
|
if (_renderChannelsTimer) clearTimeout(_renderChannelsTimer);
|
|
_renderChannelsTimer = setTimeout(_renderChannelsNow, 50);
|
|
}
|
|
function _renderChannelsNow() {
|
|
_renderChannelsTimer = null;
|
|
var el = document.getElementById('channelList');
|
|
if (!channels || !channels.length) {
|
|
var _hint = resolverScanHint || (t('no_channels_hint') + ' <button onclick="jumpToLog()" style="background:none;border:none;cursor:pointer;font-size:13px;vertical-align:middle;padding:0 2px">📜</button> ' + t('no_channels_hint2'));
|
|
el.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-dim);font-size:13px">' + t('no_channels') + '<br><span id="no-ch-hint" style="font-size:11px;opacity:.7;line-height:1.8">' + _hint + '</span></div>'; return
|
|
}
|
|
// Fast-path: if channel count matches existing items, just update classes/badges
|
|
var existingItems = el.querySelectorAll('.ch-item');
|
|
if (existingItems.length === channels.length) {
|
|
var needsFullRebuild = false;
|
|
for (var ci = 0; ci < channels.length; ci++) {
|
|
var ch = channels[ci], nm = ch.Name || ch.name || 'Channel ' + (ci + 1);
|
|
if (existingItems[ci].dataset.name !== nm) { needsFullRebuild = true; break }
|
|
}
|
|
if (!needsFullRebuild) {
|
|
for (var ui = 0; ui < channels.length; ui++) {
|
|
var num = ui + 1;
|
|
existingItems[ui].classList.toggle('active', num === selectedChannel);
|
|
var lastID = channels[ui].LastMsgID || channels[ui].lastMsgID || 0;
|
|
var chNm = channels[ui].Name || channels[ui].name || '';
|
|
var showBadge = previousMsgIDs[chNm] > 0 && lastID > previousMsgIDs[chNm] && num !== selectedChannel;
|
|
var previewEl = existingItems[ui].querySelector('.ch-preview');
|
|
if (previewEl) previewEl.innerHTML = showBadge ? '<span class="ch-badge">NEW</span>' : '';
|
|
}
|
|
_updateRefreshBadge(); return;
|
|
}
|
|
}
|
|
var pubs = [], privs = [], xposts = [];
|
|
for (var i = 0; i < channels.length; i++) {
|
|
var c = channels[i];
|
|
var ct = c.ChatType || c.chatType || 0;
|
|
if (ct === 2) xposts.push({ ch: c, idx: i });
|
|
else if (ct === 1) privs.push({ ch: c, idx: i });
|
|
else pubs.push({ ch: c, idx: i });
|
|
}
|
|
function section(title, items) {
|
|
if (!items.length) return ''; var h = '';
|
|
if (title) h += '<div class="channel-section-title">' + esc(title) + '</div>';
|
|
for (var j = 0; j < items.length; j++) {
|
|
var e = items[j], num2 = e.idx + 1;
|
|
var name = e.ch.Name || e.ch.name || 'Channel ' + num2;
|
|
var ct2 = e.ch.ChatType || e.ch.chatType || 0;
|
|
var isPriv = e.ch.ChatType === 1 || e.ch.chatType === 1;
|
|
var isX = ct2 === 2;
|
|
var avatarName = name;
|
|
if (isX && avatarName.toLowerCase().indexOf('x/') === 0) avatarName = avatarName.substring(2);
|
|
if (avatarName.charAt(0) === '@') avatarName = avatarName.substring(1);
|
|
var avatarText = (avatarName || '?').charAt(0).toUpperCase();
|
|
var active = num2 === selectedChannel ? ' active' : '';
|
|
var lastID = e.ch.LastMsgID || e.ch.lastMsgID || 0;
|
|
var chNm2 = e.ch.Name || e.ch.name || '';
|
|
var badge = (previousMsgIDs[chNm2] > 0 && lastID > previousMsgIDs[chNm2] && num2 !== selectedChannel) ? '<span class="ch-badge">NEW</span>' : '';
|
|
h += '<div class="ch-item' + active + '" data-name="' + esc(name) + '" onclick="selectChannel(' + num2 + ')">';
|
|
h += '<div class="ch-avatar">' + esc(avatarText) + '</div>';
|
|
h += '<div class="ch-info"><div class="ch-name">' + esc(name) + (isPriv ? '<span class="ch-type-tag">' + t('private') + '</span>' : (isX ? '<span class="ch-type-tag x-tag">' + t('x_label') + '</span>' : '')) + '</div>';
|
|
h += '<div class="ch-preview">' + badge + '</div></div></div>';
|
|
}
|
|
return h;
|
|
}
|
|
el.innerHTML = section('', pubs) + section(t('x_posts'), xposts) + section(t('private'), privs);
|
|
_updateRefreshBadge();
|
|
}
|
|
function _updateRefreshBadge() {
|
|
var hasNew = false;
|
|
for (var k = 0; k < channels.length; k++) {
|
|
var num3 = k + 1, chNm3 = channels[k].Name || channels[k].name || '', lid = channels[k].LastMsgID || channels[k].lastMsgID || 0;
|
|
if (previousMsgIDs[chNm3] > 0 && lid > previousMsgIDs[chNm3] && num3 !== selectedChannel) { hasNew = true; break }
|
|
}
|
|
document.getElementById('refreshBtn').classList.toggle('refresh-has-new', hasNew);
|
|
}
|
|
|
|
async function selectChannel(num) {
|
|
// Save lastSeen for previous channel
|
|
if (selectedChannel > 0 && currentMaxTimestamp > 0) {
|
|
setLastSeenTimestamp(channelName(selectedChannel), currentMaxTimestamp);
|
|
}
|
|
selectedChannel = num;
|
|
currentMaxMsgID = 0;
|
|
currentMaxTimestamp = 0;
|
|
newMsgScrollDone = false;
|
|
openChat();
|
|
var ch = channels[num - 1]; var name = (ch && (ch.Name || ch.name)) || 'Channel ' + num;
|
|
document.getElementById('chatName').textContent = name;
|
|
renderChannels(); updateSendPanel();
|
|
document.getElementById('messages').innerHTML = '<div class="empty-state"><p>' + t('loading') + '</p></div>';
|
|
document.getElementById('scrollDownBtn').classList.remove('visible');
|
|
// Show immediate feedback progress bar
|
|
showChannelFetchProgress(num, name);
|
|
await loadMessages(num);
|
|
await doRefresh(true);
|
|
}
|
|
|
|
function showChannelFetchProgress(num, name) {
|
|
var panel = document.getElementById('progressPanel');
|
|
var id = 'prog-fetch-ch-' + num;
|
|
var existing = document.getElementById(id);
|
|
if (existing) return;
|
|
var item = document.createElement('div'); item.id = id; item.className = 'progress-item';
|
|
item.dataset.lastUpdate = Date.now();
|
|
item.innerHTML = '<button class="progress-close" onclick="this.parentNode.remove()" title="Dismiss">×</button><div class="progress-label">' + t('fetching_channel') + ' ' + (name || num) + '</div><div class="progress-bar"><div class="progress-fill" style="width:40%;animation:prog-pulse 1.5s ease-in-out infinite"></div></div>';
|
|
panel.insertBefore(item, panel.firstChild);
|
|
// Auto-remove after messages load or after timeout
|
|
setTimeout(function () { var el = document.getElementById(id); if (el) el.remove() }, 15000);
|
|
}
|
|
|
|
function updateSendPanel() {
|
|
var panel = document.getElementById('sendPanel');
|
|
var ch = channels[selectedChannel - 1];
|
|
var canSend = !!(ch && (ch.CanSend || ch.canSend));
|
|
if (selectedChannel > 0 && telegramLoggedIn && canSend) panel.classList.add('visible');
|
|
else panel.classList.remove('visible');
|
|
}
|
|
|
|
// ===== MESSAGES =====
|
|
async function loadMessages(chNum) {
|
|
try {
|
|
var r = await fetch('/api/messages/' + chNum); if (chNum !== selectedChannel) return;
|
|
var data = await r.json(); if (chNum !== selectedChannel) return;
|
|
renderMessages(data.messages || [], data.gaps || []);
|
|
// Remove fetch progress bar for this channel
|
|
var fetchBar = document.getElementById('prog-fetch-ch-' + chNum); if (fetchBar) fetchBar.remove();
|
|
if (channels[chNum - 1]) { var cn = channels[chNum - 1].Name || channels[chNum - 1].name || ''; previousMsgIDs[cn] = channels[chNum - 1].LastMsgID || channels[chNum - 1].lastMsgID || 0; renderChannels() }
|
|
} catch (e) { }
|
|
}
|
|
|
|
function renderMessages(msgs, gaps) {
|
|
var el = document.getElementById('messages');
|
|
if (!msgs || !msgs.length) { el.innerHTML = '<div class="empty-state"><p>' + t('no_messages') + '</p><p style="font-size:12px;opacity:.6;margin-top:6px">' + t('no_messages_hint') + '</p></div>'; return }
|
|
// Check if user is near the bottom before re-render (within 150px)
|
|
var wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150;
|
|
var isFirstRender = el.querySelector('.empty-state') !== null || el.querySelector('.msg') === null;
|
|
msgs.sort(function (a, b) { return (a.Timestamp || a.timestamp || 0) - (b.Timestamp || b.timestamp || 0) });
|
|
var html = '', lastDate = '';
|
|
// Build a lookup: message ID → gap count to insert BEFORE that message.
|
|
var gapBefore = {};
|
|
if (gaps) { for (var g = 0; g < gaps.length; g++) { gapBefore[gaps[g].before_id] = gaps[g].count } }
|
|
currentMsgTexts = [];
|
|
var dateLocale = lang === 'fa' ? 'fa-IR' : 'en-US';
|
|
var dateOpts = lang === 'fa' ? { year: 'numeric', month: 'long', day: 'numeric', calendar: 'persian' } : { year: 'numeric', month: 'long', day: 'numeric' };
|
|
var lastSeenTs = getLastSeenTimestamp(channelName(selectedChannel));
|
|
var isFirstVisit = lastSeenTs === 0;
|
|
var newMsgSepInserted = false;
|
|
var maxMsgID = 0;
|
|
var maxTimestamp = 0;
|
|
for (var i = 0; i < msgs.length; i++) {
|
|
var msg = msgs[i];
|
|
var id = msg.ID || msg.id;
|
|
var msgTs = msg.Timestamp || msg.timestamp || 0;
|
|
if (id > maxMsgID) maxMsgID = id;
|
|
if (msgTs > maxTimestamp) maxTimestamp = msgTs;
|
|
if (gapBefore[id]) {
|
|
html += '<div class="msg-gap-sep"><span>' + t('missed_messages').replace('{n}', gapBefore[id]) + '</span></div>';
|
|
}
|
|
// New messages separator (timestamp-based for X/Telegram compatibility)
|
|
if (!isFirstVisit && lastSeenTs > 0 && msgTs > lastSeenTs && !newMsgSepInserted) {
|
|
html += '<div class="msg-new-sep" id="newMsgSep"><span>' + t('new_messages') + '</span></div>';
|
|
newMsgSepInserted = true;
|
|
}
|
|
var ts = new Date((msg.Timestamp || msg.timestamp) * 1000);
|
|
var dateStr = ts.toLocaleDateString(dateLocale, dateOpts);
|
|
if (dateStr !== lastDate) { html += '<div class="msg-date-sep"><span dir="auto">' + dateStr + '</span></div>'; lastDate = dateStr }
|
|
var timeStr = ts.toLocaleTimeString(dateLocale, { hour: '2-digit', minute: '2-digit' });
|
|
var text = msg.Text || msg.text || '';
|
|
currentMsgTexts.push(text);
|
|
var mediaHtml = '', textHtml = esc(text);
|
|
var mediaTypes = ['[IMAGE]', '[VIDEO]', '[FILE]', '[AUDIO]', '[STICKER]', '[GIF]', '[POLL]', '[CONTACT]', '[LOCATION]', '[REPLY]'];
|
|
for (var m = 0; m < mediaTypes.length; m++) {
|
|
if (text.indexOf(mediaTypes[m]) === 0) {
|
|
var tagCls = mediaTypes[m] === '[REPLY]' ? 'media-tag reply-tag' : 'media-tag';
|
|
mediaHtml = '<div class="' + tagCls + '">' + mediaTypes[m] + '</div>';
|
|
textHtml = esc(text.substring(mediaTypes[m].length).replace(/^\n/, '')); break
|
|
}
|
|
}
|
|
html += '<div class="msg' + (isPersian(text) ? ' rtl-msg' : '') + '" dir="auto">' + mediaHtml + textHtml + '<div class="msg-meta"><button class="msg-copy-btn" onclick="copyMsg(' + i + ')">' + t('copy') + '</button><span>#' + id + '</span><span>' + timeStr + '</span></div></div>';
|
|
}
|
|
el.innerHTML = html;
|
|
currentMaxMsgID = maxMsgID;
|
|
currentMaxTimestamp = maxTimestamp;
|
|
// On first visit, store max timestamp (no separator shown)
|
|
var chName = channelName(selectedChannel);
|
|
if (isFirstVisit && maxTimestamp > 0) {
|
|
setLastSeenTimestamp(chName, maxTimestamp);
|
|
}
|
|
// Update lastSeen when user has scrolled to the bottom (messages are "seen")
|
|
if (wasAtBottom && maxTimestamp > 0 && !isFirstVisit) {
|
|
setLastSeenTimestamp(chName, maxTimestamp);
|
|
}
|
|
// Scroll to new messages separator only on first render for this channel
|
|
if (newMsgSepInserted && !newMsgScrollDone) {
|
|
newMsgScrollDone = true;
|
|
setTimeout(function () {
|
|
var sep = document.getElementById('newMsgSep');
|
|
if (sep) sep.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}, 100);
|
|
} else if (isFirstRender || wasAtBottom) {
|
|
el.scrollTop = el.scrollHeight;
|
|
document.getElementById('scrollDownBtn').classList.remove('visible');
|
|
}
|
|
}
|
|
|
|
// ===== LOG =====
|
|
function addLogLine(line) {
|
|
var el = document.getElementById('logPanel');
|
|
var div = document.createElement('div');
|
|
var cls = 'inf';
|
|
if (typeof line === 'string') {
|
|
// Handle structured resolver scan events — show progress bar, suppress from log
|
|
if (line.includes('RESOLVER_SCAN ')) { updateResolverScanDisplay(line); return }
|
|
if (line.includes('SERVER_FETCH_WAIT ')) { updateServerFetchDisplay(line); return }
|
|
if (line.includes('Error:') || line.includes('error') || line.includes('Invalid passphrase')) cls = 'err';
|
|
else if (line.includes('Warning:')) cls = 'warn';
|
|
else if (line.includes('OK') || line.includes('success') || line.includes('done')) cls = 'ok';
|
|
else if (line.match(/\d+%/)) { cls = 'prog'; updateProgressDisplay(line); return }
|
|
}
|
|
div.className = 'log-line ' + cls; div.textContent = line;
|
|
el.appendChild(div); el.scrollTop = el.scrollHeight;
|
|
while (el.children.length > 200) el.removeChild(el.firstChild);
|
|
}
|
|
|
|
function updateServerFetchDisplay(line) {
|
|
var panel = document.getElementById('progressPanel');
|
|
var item = document.getElementById('prog-server-fetch');
|
|
// SERVER_FETCH_WAIT start <totalSec>
|
|
var startMatch = line.match(/SERVER_FETCH_WAIT start (\d+)/);
|
|
if (startMatch) {
|
|
var total = parseInt(startMatch[1]);
|
|
if (!item) {
|
|
item = document.createElement('div'); item.id = 'prog-server-fetch'; item.className = 'progress-item';
|
|
item.innerHTML = '<button class="progress-close" onclick="this.parentNode.remove()" title="Dismiss">×</button><div class="progress-label"></div><div class="progress-bar"><div class="progress-fill" style="width:0%;transition:width 1s linear"></div></div>';
|
|
panel.insertBefore(item, panel.firstChild);
|
|
}
|
|
item.dataset.total = total;
|
|
item.querySelector('.progress-label').textContent = t('server_fetch_wait') + ' — ' + total + 's';
|
|
item.querySelector('.progress-fill').style.width = '0%';
|
|
item.dataset.lastUpdate = Date.now();
|
|
return;
|
|
}
|
|
if (!item) return;
|
|
// SERVER_FETCH_WAIT tick <remaining>/<total>
|
|
var tickMatch = line.match(/SERVER_FETCH_WAIT tick (\d+)\/(\d+)/);
|
|
if (tickMatch) {
|
|
var remaining = parseInt(tickMatch[1]), total2 = parseInt(tickMatch[2]);
|
|
var pct = Math.round(((total2 - remaining) / total2) * 100);
|
|
item.querySelector('.progress-label').textContent = t('server_fetch_wait') + ' — ' + remaining + 's';
|
|
item.querySelector('.progress-fill').style.width = pct + '%';
|
|
item.dataset.lastUpdate = Date.now();
|
|
return;
|
|
}
|
|
// SERVER_FETCH_WAIT done
|
|
if (line.includes('SERVER_FETCH_WAIT done')) {
|
|
item.querySelector('.progress-label').textContent = t('loading');
|
|
item.querySelector('.progress-fill').style.width = '100%';
|
|
setTimeout(function () { if (item.parentNode) item.parentNode.removeChild(item) }, 1200);
|
|
}
|
|
}
|
|
|
|
function ensureResolverScanItem() {
|
|
var item = document.getElementById('prog-resolvers');
|
|
if (!item) {
|
|
var panel = document.getElementById('progressPanel');
|
|
// Remove the generic init loading bar when resolver scan takes over
|
|
var initItem = document.getElementById('prog-init'); if (initItem) initItem.remove();
|
|
item = document.createElement('div'); item.id = 'prog-resolvers'; item.className = 'progress-item';
|
|
item.innerHTML = '<button class="progress-close" onclick="this.parentNode.remove()" title="Dismiss">×</button><div class="progress-label"></div><div class="progress-bar"><div class="progress-fill" style="width:0%"></div></div>';
|
|
panel.insertBefore(item, panel.firstChild);
|
|
}
|
|
return item;
|
|
}
|
|
|
|
function updateResolverScanDisplay(line) {
|
|
var panel = document.getElementById('progressPanel');
|
|
var item = document.getElementById('prog-resolvers');
|
|
// RESOLVER_SCAN start N
|
|
var startMatch = line.match(/RESOLVER_SCAN start (\d+)/);
|
|
if (startMatch) {
|
|
var total = parseInt(startMatch[1]);
|
|
resolverScanDone = 0; resolverScanHealthy = 0; resolverScanTotal = total;
|
|
item = ensureResolverScanItem();
|
|
item.dataset.total = total;
|
|
item.querySelector('.progress-label').textContent = t('scanning_resolvers') + ' 0/' + total;
|
|
item.querySelector('.progress-fill').style.width = '0%';
|
|
item.dataset.lastUpdate = Date.now();
|
|
resolverScanHint = t('scanning_resolvers') + '... <button onclick="jumpToLog()" style="background:none;border:none;cursor:pointer;font-size:13px;vertical-align:middle;padding:0 2px">📜</button>';
|
|
var hintEl = document.getElementById('no-ch-hint'); if (hintEl) hintEl.innerHTML = resolverScanHint;
|
|
return;
|
|
}
|
|
// If the item was removed (e.g. SSE reconnect cleared the panel) but scan
|
|
// is still in progress, re-create it so progress updates keep showing.
|
|
if (!item) item = ensureResolverScanItem();
|
|
// RESOLVER_SCAN progress D/T healthy=H
|
|
var progMatch = line.match(/RESOLVER_SCAN progress (\d+)\/(\d+)(?: healthy=(\d+))?/);
|
|
if (progMatch) {
|
|
var done = parseInt(progMatch[1]), tot = parseInt(progMatch[2]), hlthy = progMatch[3] !== undefined ? parseInt(progMatch[3]) : null;
|
|
// Use authoritative values from the structured message.
|
|
resolverScanDone = done; resolverScanTotal = tot;
|
|
if (hlthy !== null) resolverScanHealthy = hlthy;
|
|
var pct = Math.round((done / tot) * 100);
|
|
var label = t('scanning_resolvers') + ' ' + done + '/' + tot + ' \u2713' + resolverScanHealthy;
|
|
item.querySelector('.progress-label').textContent = label;
|
|
item.querySelector('.progress-fill').style.width = pct + '%';
|
|
item.dataset.lastUpdate = Date.now();
|
|
resolverScanHint = t('scanning_resolvers') + ' (' + done + '/' + tot + ', \u2713' + resolverScanHealthy + ')' + ' <button onclick="jumpToLog()" style="background:none;border:none;cursor:pointer;font-size:13px;vertical-align:middle;padding:0 2px">📜</button>';
|
|
var hintEl = document.getElementById('no-ch-hint'); if (hintEl) hintEl.innerHTML = resolverScanHint;
|
|
return;
|
|
}
|
|
// RESOLVER_SCAN done K/T
|
|
var doneMatch = line.match(/RESOLVER_SCAN done (\d+)\/(\d+)/);
|
|
if (doneMatch) {
|
|
var healthy = parseInt(doneMatch[1]), total2 = parseInt(doneMatch[2]);
|
|
resolverScanDone = total2; resolverScanHealthy = healthy; resolverScanTotal = total2;
|
|
item.querySelector('.progress-label').textContent = t('scanning_resolvers') + ': ' + healthy + '/' + total2 + ' active';
|
|
item.querySelector('.progress-fill').style.width = '100%';
|
|
resolverScanHint = '';
|
|
// Remove init loading bar if it's still around
|
|
var initItem = document.getElementById('prog-init'); if (initItem) initItem.remove();
|
|
var hintEl = document.getElementById('no-ch-hint'); if (hintEl) hintEl.innerHTML = t('no_channels_hint') + ' <button onclick="jumpToLog()" style="background:none;border:none;cursor:pointer;font-size:13px;vertical-align:middle;padding:0 2px">📜</button> ' + t('no_channels_hint2');
|
|
setTimeout(function () { if (item.parentNode) item.parentNode.removeChild(item) }, 2000);
|
|
// Scan is done — load channels in case the SSE 'update' event was dropped.
|
|
setTimeout(function () { loadChannels().then(function () { if (channels.length > 0 && selectedChannel === 0) selectChannel(1) }) }, 3000);
|
|
}
|
|
}
|
|
|
|
function updateProgressDisplay(line) {
|
|
var match = line.match(/Channel\s+(\d+)/); if (!match) return;
|
|
var channelNum = parseInt(match[1]);
|
|
var fracMatch = line.match(/\((\d+)\/(\d+)\)/);
|
|
var completed = fracMatch ? parseInt(fracMatch[1]) : 0;
|
|
var total = fracMatch ? parseInt(fracMatch[2]) : 0;
|
|
var pct = line.match(/(\d+)%/); var percent = pct ? parseInt(pct[1]) : 0;
|
|
var ch = channels[channelNum - 1];
|
|
var chName = ch && (ch.Name || ch.name) || '';
|
|
var label = (fracMatch ? (completed + '/' + total) : ('Channel ' + channelNum)) + (chName ? ' (' + chName + ')' : '');
|
|
var panel = document.getElementById('progressPanel');
|
|
var item = document.getElementById('prog-ch-' + channelNum);
|
|
if (!item) {
|
|
item = document.createElement('div'); item.id = 'prog-ch-' + channelNum; item.className = 'progress-item';
|
|
item.innerHTML = '<button class="progress-close" onclick="this.parentNode.remove()" title="Dismiss">×</button><div class="progress-label"></div><div class="progress-bar"><div class="progress-fill" style="width:0%"></div></div>';
|
|
panel.appendChild(item);
|
|
}
|
|
item.querySelector('.progress-label').textContent = label;
|
|
item.querySelector('.progress-fill').style.width = percent + '%';
|
|
// Mark last-update time so stale items can be cleaned
|
|
item.dataset.lastUpdate = Date.now();
|
|
if (percent >= 100) setTimeout(function () { if (item.parentNode) item.parentNode.removeChild(item) }, 800);
|
|
}
|
|
|
|
// Periodically remove stale progress items that stopped updating (missed 'done' event)
|
|
setInterval(function () {
|
|
var now = Date.now();
|
|
var items = document.getElementById('progressPanel').querySelectorAll('.progress-item');
|
|
for (var i = 0; i < items.length; i++) {
|
|
var lu = parseInt(items[i].dataset.lastUpdate || '0');
|
|
if (lu > 0 && now - lu > 30000) items[i].remove();
|
|
}
|
|
}, 10000);
|
|
|
|
function toggleLog() {
|
|
logVisible = !logVisible;
|
|
var p = document.getElementById('logPanel'); var ic = document.getElementById('logToggleIcon');
|
|
p.classList.toggle('hidden', !logVisible);
|
|
ic.innerHTML = logVisible ? '▼' : '▶';
|
|
}
|
|
function openLog() {
|
|
if (logVisible) return;
|
|
logVisible = true;
|
|
document.getElementById('logPanel').classList.remove('hidden');
|
|
document.getElementById('logToggleIcon').innerHTML = '▼';
|
|
}
|
|
function jumpToLog() {
|
|
openChat();
|
|
openLog();
|
|
setTimeout(function () { document.getElementById('logPanel').scrollIntoView({ behavior: 'smooth', block: 'end' }) }, 300);
|
|
}
|
|
|
|
// ===== REFRESH =====
|
|
function showInitProgress() {
|
|
document.getElementById('progressPanel').innerHTML = '';
|
|
var p = document.getElementById('progressPanel');
|
|
p.innerHTML = '<div class="progress-item" id="prog-init" data-last-update="' + Date.now() + '"><button class="progress-close" onclick="this.parentNode.remove()" title="Dismiss">×</button><div class="progress-label">' + t('loading') + '</div><div class="progress-bar"><div class="progress-fill" style="width:30%;animation:prog-pulse 1.5s ease-in-out infinite"></div></div></div>';
|
|
if (window.innerWidth <= 768) { openChat(); openLog(); }
|
|
}
|
|
function startAutoRefresh() { if (autoRefreshTimer) return; autoRefreshTimer = setInterval(function () { if (selectedChannel > 0) doRefresh(true) }, 600000) }
|
|
function updateNextFetchDisplay() {
|
|
if (nextFetchInterval) clearInterval(nextFetchInterval);
|
|
var el = document.getElementById('nextFetchTimer');
|
|
var info = document.getElementById('nextFetchInfoBtn');
|
|
if (!serverNextFetch) { el.textContent = ''; if (info) info.style.display = 'none'; return }
|
|
if (info) { info.style.display = ''; info.title = t('next_fetch_info') }
|
|
var autoRefreshed = false;
|
|
function tick() {
|
|
var now = Math.floor(Date.now() / 1000), d = serverNextFetch - now;
|
|
if (d <= 0) {
|
|
el.textContent = '';
|
|
if (!autoRefreshed) { autoRefreshed = true; setTimeout(function () { doAutoRefreshAfterCountdown() }, 3000) }
|
|
return
|
|
}
|
|
var m = Math.floor(d / 60), s = d % 60; el.textContent = m + ':' + (s < 10 ? '0' : '') + s
|
|
}
|
|
tick(); nextFetchInterval = setInterval(tick, 1000);
|
|
}
|
|
async function doRefreshUI() {
|
|
var btn = document.getElementById('refreshBtn');
|
|
btn.style.animation = 'spin .8s linear';
|
|
showToast(t('refreshing'));
|
|
await doRefresh(false);
|
|
setTimeout(function () { btn.style.animation = '' }, 800);
|
|
}
|
|
async function doRefresh(quiet) {
|
|
try {
|
|
var url = '/api/refresh';
|
|
if (selectedChannel > 0) url += '?channel=' + selectedChannel;
|
|
if (quiet) url += (url.includes('?') ? '&' : '?') + 'quiet=1';
|
|
await fetch(url, { method: 'POST' });
|
|
if (!quiet && selectedChannel > 0) setTimeout(function () { loadChannels(); loadMessages(selectedChannel) }, 3000);
|
|
} catch (e) { }
|
|
}
|
|
|
|
async function doAutoRefreshAfterCountdown() {
|
|
// Auto-triggered when server fetch countdown reaches 0
|
|
// Clear metadata cache so we get fresh data
|
|
try { localStorage.removeItem(cacheKey()) } catch (e) { }
|
|
try {
|
|
// Reload channels (this also fetches /api/status and updates the timer)
|
|
await loadChannels();
|
|
// If we have a selected channel, reload its messages too
|
|
if (selectedChannel > 0) await loadMessages(selectedChannel);
|
|
// If the timer still didn't show (server may not have refreshed yet), retry after a delay
|
|
if (!serverNextFetch || serverNextFetch <= Math.floor(Date.now() / 1000)) {
|
|
setTimeout(async function () {
|
|
try {
|
|
var sr = await fetch('/api/status'); var st = await sr.json();
|
|
if (st.nextFetch && st.nextFetch > Math.floor(Date.now() / 1000)) { serverNextFetch = st.nextFetch; updateNextFetchDisplay() }
|
|
} catch (e) { }
|
|
}, 15000);
|
|
}
|
|
} catch (e) { }
|
|
}
|
|
|
|
// ===== SEND =====
|
|
async function sendMessage() {
|
|
var input = document.getElementById('sendInput'); var text = input.value.trim();
|
|
if (!text || !selectedChannel) return;
|
|
try {
|
|
var r = await fetch('/api/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channel: selectedChannel, text: text }) });
|
|
if (r.ok) { input.value = ''; addLogLine('Message sent') }
|
|
else addLogLine('Error: ' + (await r.text()));
|
|
} catch (e) { addLogLine('Error: ' + e.message) }
|
|
}
|
|
|
|
// ===== COPY MSG =====
|
|
function copyMsg(idx) {
|
|
var text = currentMsgTexts[idx]; if (text === undefined) return;
|
|
navigator.clipboard.writeText(text).then(function () { showToast(t('msg_copied')) }).catch(function () { });
|
|
}
|
|
|
|
// ===== SCROLL TO BOTTOM =====
|
|
(function () {
|
|
var messagesEl = null;
|
|
function initScrollBtn() {
|
|
messagesEl = document.getElementById('messages');
|
|
if (!messagesEl) return;
|
|
messagesEl.addEventListener('scroll', function () {
|
|
var atBottom = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 150;
|
|
document.getElementById('scrollDownBtn').classList.toggle('visible', !atBottom);
|
|
});
|
|
}
|
|
// Init after DOM is ready
|
|
document.addEventListener('DOMContentLoaded', initScrollBtn, { once: true });
|
|
})();
|
|
function scrollToBottom() {
|
|
var el = document.getElementById('messages');
|
|
if (el) el.scrollTop = el.scrollHeight;
|
|
}
|
|
|
|
// ===== TOAST =====
|
|
function showToast(msg) {
|
|
var el = document.getElementById('toast'); el.textContent = msg; el.classList.add('show');
|
|
setTimeout(function () { el.classList.remove('show') }, 2200);
|
|
}
|
|
|
|
// ===== UTILITIES =====
|
|
function esc(s) { var d = document.createElement('div'); d.appendChild(document.createTextNode(s)); return d.innerHTML }
|
|
function isPersian(text) { return text && (text.match(/[\u0600-\u06FF]/g) || []).length > text.length * 0.25 }
|
|
|
|
// ===== SCANNER =====
|
|
var scanPollTimer = null;
|
|
var scanLastResults = []; // cache for selection
|
|
|
|
function openScanner() {
|
|
document.getElementById('scannerModal').classList.add('active');
|
|
populateScanProfileSelect();
|
|
pollScannerOnce();
|
|
}
|
|
function closeScanner() {
|
|
document.getElementById('scannerModal').classList.remove('active');
|
|
if (scanPollTimer) { clearInterval(scanPollTimer); scanPollTimer = null }
|
|
}
|
|
function openScannerFromProfile() {
|
|
var profileId = editingProfileId;
|
|
closeProfileEditor();
|
|
openScanner();
|
|
if (profileId) {
|
|
var sel = document.getElementById('scanProfile');
|
|
for (var i = 0; i < sel.options.length; i++) {
|
|
if (sel.options[i].value === profileId) { sel.selectedIndex = i; break }
|
|
}
|
|
}
|
|
}
|
|
|
|
function populateScanProfileSelect() {
|
|
var sel = document.getElementById('scanProfile');
|
|
sel.innerHTML = '';
|
|
if (profiles && profiles.profiles) {
|
|
for (var i = 0; i < profiles.profiles.length; i++) {
|
|
var p = profiles.profiles[i];
|
|
var opt = document.createElement('option');
|
|
opt.value = p.id;
|
|
opt.textContent = p.nickname || p.id;
|
|
if (p.id === activeProfileId) opt.selected = true;
|
|
sel.appendChild(opt);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadScannerPresets() {
|
|
try {
|
|
var r = await fetch('/api/scanner/presets');
|
|
if (!r.ok) return;
|
|
var lines = await r.json();
|
|
if (lines && lines.length) {
|
|
var el = document.getElementById('scanTargets');
|
|
var existing = el.value.trim();
|
|
el.value = existing ? existing + '\n' + lines.join('\n') : lines.join('\n');
|
|
}
|
|
} catch (e) { showToast(e.message) }
|
|
}
|
|
|
|
async function startScan() {
|
|
var targets = document.getElementById('scanTargets').value.trim().split('\n').filter(function (s) { return s.trim() });
|
|
if (!targets.length) { showToast(t('scanner_targets')); return }
|
|
// Clear stale results from previous scan.
|
|
scanLastResults = [];
|
|
document.getElementById('scanResultsBody').innerHTML = '';
|
|
document.getElementById('scannerApplySection').style.display = 'none';
|
|
var body = {
|
|
targets: targets,
|
|
profileId: document.getElementById('scanProfile').value,
|
|
rateLimit: parseInt(document.getElementById('scanRateLimit').value) || 50,
|
|
timeout: parseInt(document.getElementById('scanTimeout').value) || 15,
|
|
maxIPs: parseInt(document.getElementById('scanMaxIPs').value) || 0,
|
|
expandSubnet: document.getElementById('scanExpand').checked,
|
|
queryMode: document.getElementById('scanQueryMode').value
|
|
};
|
|
try {
|
|
var r = await fetch('/api/scanner/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
if (!r.ok) { showToast(await r.text() || 'Failed to start'); return }
|
|
showScanRunning();
|
|
startScanPolling();
|
|
} catch (e) { showToast(e.message) }
|
|
}
|
|
|
|
async function stopScan() {
|
|
try { await fetch('/api/scanner/stop', { method: 'POST' }) } catch (e) {}
|
|
if (scanPollTimer) { clearInterval(scanPollTimer); scanPollTimer = null }
|
|
setTimeout(pollScannerOnce, 300);
|
|
}
|
|
|
|
async function toggleScanPause() {
|
|
var btn = document.getElementById('scanPauseBtn');
|
|
var isPaused = btn.dataset.paused === '1';
|
|
try {
|
|
await fetch('/api/scanner/' + (isPaused ? 'resume' : 'pause'), { method: 'POST' });
|
|
pollScannerOnce();
|
|
} catch (e) {}
|
|
}
|
|
|
|
function showScanRunning() {
|
|
document.getElementById('scannerConfig').style.display = 'none';
|
|
document.getElementById('scannerProgressSection').style.display = '';
|
|
document.getElementById('scannerResults').style.display = '';
|
|
document.getElementById('scanStartBtn').style.display = 'none';
|
|
document.getElementById('scanStopBtn').style.display = '';
|
|
document.getElementById('scanPauseBtn').style.display = '';
|
|
document.getElementById('scannerApplySection').style.display = 'none';
|
|
document.getElementById('scannerIconBtn').classList.add('scanning');
|
|
}
|
|
|
|
function showScanIdle() {
|
|
document.getElementById('scannerConfig').style.display = '';
|
|
document.getElementById('scannerProgressSection').style.display = 'none';
|
|
document.getElementById('scannerResults').style.display = 'none';
|
|
document.getElementById('scannerApplySection').style.display = 'none';
|
|
document.getElementById('scanStartBtn').style.display = '';
|
|
document.getElementById('scanStartBtn').textContent = t('scanner_start');
|
|
document.getElementById('scanStopBtn').style.display = 'none';
|
|
document.getElementById('scanPauseBtn').style.display = 'none';
|
|
document.getElementById('scannerIconBtn').classList.remove('scanning');
|
|
}
|
|
|
|
function resetScannerUI() {
|
|
showScanIdle();
|
|
document.getElementById('scannerAboutFull').style.display = 'none';
|
|
document.getElementById('scannerAboutShort').querySelector('a').style.display = '';
|
|
}
|
|
|
|
function showScanDone(progress) {
|
|
document.getElementById('scannerConfig').style.display = 'none';
|
|
document.getElementById('scannerProgressSection').style.display = '';
|
|
document.getElementById('scannerResults').style.display = '';
|
|
document.getElementById('scanStartBtn').style.display = 'none';
|
|
document.getElementById('scanStopBtn').style.display = 'none';
|
|
document.getElementById('scanPauseBtn').style.display = 'none';
|
|
document.getElementById('scannerIconBtn').classList.remove('scanning');
|
|
// Always show the apply section (it has the New Scan button).
|
|
document.getElementById('scannerApplySection').style.display = '';
|
|
if (progress && progress.results && progress.results.length > 0) {
|
|
updateScanSelectedCount();
|
|
}
|
|
if (scanPollTimer) { clearInterval(scanPollTimer); scanPollTimer = null }
|
|
}
|
|
|
|
function getSelectedScanIPs() {
|
|
var cbs = document.querySelectorAll('.scan-select-cb:checked');
|
|
var ips = [];
|
|
for (var i = 0; i < cbs.length; i++) ips.push(cbs[i].dataset.ip);
|
|
return ips;
|
|
}
|
|
|
|
function updateScanSelectedCount() {
|
|
var n = getSelectedScanIPs().length;
|
|
var label = '(' + n + ')';
|
|
var el1 = document.getElementById('scanAppendCount');
|
|
var el2 = document.getElementById('scanOverwriteCount');
|
|
var el3 = document.getElementById('scanCopyCount');
|
|
if (el1) el1.textContent = label;
|
|
if (el2) el2.textContent = label;
|
|
if (el3) el3.textContent = label;
|
|
}
|
|
|
|
function toggleScanSelectAll(checked) {
|
|
var cbs = document.querySelectorAll('.scan-select-cb');
|
|
for (var i = 0; i < cbs.length; i++) cbs[i].checked = checked;
|
|
updateScanSelectedCount();
|
|
}
|
|
|
|
function renderScanProgress(p) {
|
|
var pct = p.total > 0 ? Math.round(p.scanned / p.total * 100) : 0;
|
|
document.getElementById('scanProgressFill').style.width = pct + '%';
|
|
document.getElementById('scanProgressText').textContent = p.scanned + ' / ' + p.total;
|
|
document.getElementById('scanFoundText').textContent = p.found || 0;
|
|
|
|
var stateKey = 'scanner_' + p.state;
|
|
document.getElementById('scanStatusLabel').textContent = t(stateKey);
|
|
|
|
var pauseBtn = document.getElementById('scanPauseBtn');
|
|
if (p.state === 'paused') {
|
|
pauseBtn.textContent = t('scanner_resume');
|
|
pauseBtn.dataset.paused = '1';
|
|
} else {
|
|
pauseBtn.textContent = t('scanner_pause');
|
|
pauseBtn.dataset.paused = '0';
|
|
}
|
|
|
|
// Render results table
|
|
var results = p.results || [];
|
|
scanLastResults = results;
|
|
var body = document.getElementById('scanResultsBody');
|
|
body.innerHTML = '';
|
|
for (var i = 0; i < results.length; i++) {
|
|
var r = results[i];
|
|
var tr = document.createElement('tr');
|
|
tr.style.borderTop = '1px solid var(--border)';
|
|
tr.innerHTML = '<td style="padding:8px"><input type="checkbox" class="scan-select-cb" data-ip="' + esc(r.ip) + '" checked onchange="updateScanSelectedCount()"></td>' +
|
|
'<td style="padding:8px;font-family:monospace;font-size:13px">' + esc(r.ip) + '</td>' +
|
|
'<td style="padding:8px;text-align:right;font-size:13px;color:var(--text-dim)">' + (r.latencyMs != null ? Math.round(r.latencyMs) + 'ms' : '-') + '</td>' +
|
|
'<td style="padding:4px 8px"><button class="btn btn-flat" style="font-size:12px;padding:4px 8px;min-width:0" onclick="navigator.clipboard.writeText(\'' + esc(r.ip) + '\');showToast(t(\'copied\'))">☰</button></td>';
|
|
body.appendChild(tr);
|
|
}
|
|
|
|
if (p.state === 'done') {
|
|
showScanDone(p);
|
|
} else if (p.state === 'running' || p.state === 'paused') {
|
|
showScanRunning();
|
|
if (p.state === 'paused') {
|
|
document.getElementById('scanPauseBtn').textContent = t('scanner_resume');
|
|
document.getElementById('scanPauseBtn').dataset.paused = '1';
|
|
}
|
|
}
|
|
}
|
|
|
|
function startScanPolling() {
|
|
if (scanPollTimer) clearInterval(scanPollTimer);
|
|
scanPollTimer = setInterval(pollScannerOnce, 1500);
|
|
}
|
|
|
|
async function pollScannerOnce() {
|
|
try {
|
|
var r = await fetch('/api/scanner/progress');
|
|
if (!r.ok) return;
|
|
var p = await r.json();
|
|
if (p.state === 'running' || p.state === 'paused') {
|
|
renderScanProgress(p);
|
|
if (!scanPollTimer) startScanPolling();
|
|
} else if (p.state === 'done') {
|
|
renderScanProgress(p);
|
|
} else {
|
|
// idle
|
|
if (p.results && p.results.length > 0) {
|
|
renderScanProgress(p);
|
|
showScanDone(p);
|
|
} else {
|
|
showScanIdle();
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
async function applyScanResults(mode) {
|
|
var ips = getSelectedScanIPs();
|
|
if (!ips.length) { showToast(t('scanner_no_results')); return }
|
|
try {
|
|
var r = await fetch('/api/scanner/apply', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ resolvers: ips, mode: mode, profileId: document.getElementById('scanProfile').value })
|
|
});
|
|
if (!r.ok) { showToast(await r.text() || 'Failed'); return }
|
|
showToast(t('scanner_applied'));
|
|
await loadProfiles();
|
|
} catch (e) { showToast(e.message) }
|
|
}
|
|
|
|
function copySelectedScanResults() {
|
|
var ips = getSelectedScanIPs();
|
|
if (!ips.length) { showToast(t('scanner_no_results')); return }
|
|
navigator.clipboard.writeText(ips.join('\n')).then(function () { showToast(t('copied')) });
|
|
}
|
|
|
|
function copyAllScanResults() {
|
|
var ips = scanLastResults.map(function (r) { return r.ip });
|
|
if (!ips.length) { showToast(t('scanner_no_results')); return }
|
|
navigator.clipboard.writeText(ips.join('\n')).then(function () { showToast(t('copied')) });
|
|
}
|
|
|
|
// ===== MESSAGE SEARCH =====
|
|
var msgSearchMatches = [], msgSearchIdx = -1;
|
|
// Normalize Arabic/Persian: map ي→ی, ك→ک, ة→ه, etc.
|
|
function normalizeArabicPersian(s) {
|
|
return s
|
|
.replace(/\u064A/g, '\u06CC') // Arabic Ya -> Persian Ya
|
|
.replace(/\u0643/g, '\u06A9') // Arabic Kaf -> Persian Kaf
|
|
.replace(/\u0629/g, '\u0647') // Arabic Ta Marbuta -> He
|
|
.replace(/\u0649/g, '\u06CC') // Arabic Alef Maksura -> Persian Ya
|
|
.replace(/\u06C0/g, '\u0647') // He with Hamza above -> He
|
|
.replace(/[\u0623\u0625\u0622]/g, '\u0627') // Alef variants -> plain Alef
|
|
.replace(/\u0624/g, '\u0648') // Waw with Hamza -> plain Waw
|
|
.replace(/\u0626/g, '\u06CC') // Ya with Hamza -> Ya
|
|
.replace(/\u0621/g, '') // standalone Hamza -> remove
|
|
.replace(/[\u064B-\u065F\u0610-\u061A\u0670]/g, '') // strip tashkil/diacritics
|
|
.replace(/[\u200C\u200D\u200E\u200F]/g, '') // strip ZWNJ, ZWJ, directional marks
|
|
}
|
|
function toggleMsgSearch() {
|
|
var bar = document.getElementById('msgSearchBar');
|
|
if (bar.classList.contains('active')) { closeMsgSearch(); return }
|
|
bar.classList.add('active');
|
|
var inp = document.getElementById('msgSearchInput');
|
|
inp.value = '';
|
|
inp.focus();
|
|
msgSearchMatches = []; msgSearchIdx = -1;
|
|
document.getElementById('msgSearchCount').textContent = '';
|
|
}
|
|
function closeMsgSearch() {
|
|
document.getElementById('msgSearchBar').classList.remove('active');
|
|
document.getElementById('msgSearchInput').value = '';
|
|
// Remove highlights
|
|
document.querySelectorAll('.msg .search-highlight').forEach(function (el) {
|
|
el.outerHTML = el.textContent;
|
|
});
|
|
msgSearchMatches = []; msgSearchIdx = -1;
|
|
document.getElementById('msgSearchCount').textContent = '';
|
|
}
|
|
function doMsgSearch() {
|
|
var q = normalizeArabicPersian(document.getElementById('msgSearchInput').value.trim().toLowerCase());
|
|
// Remove old highlights
|
|
document.querySelectorAll('.msg .search-highlight').forEach(function (el) {
|
|
el.outerHTML = el.textContent;
|
|
});
|
|
msgSearchMatches = []; msgSearchIdx = -1;
|
|
if (!q) { document.getElementById('msgSearchCount').textContent = ''; return }
|
|
var msgs = document.querySelectorAll('.msg');
|
|
msgs.forEach(function (msgEl) {
|
|
highlightTextNodes(msgEl, q);
|
|
});
|
|
msgSearchMatches = Array.from(document.querySelectorAll('.msg .search-highlight'));
|
|
if (msgSearchMatches.length > 0) {
|
|
// Start from the last match (bottom of chat, most recent)
|
|
msgSearchIdx = msgSearchMatches.length - 1;
|
|
scrollToSearchMatch();
|
|
document.getElementById('msgSearchCount').textContent = (msgSearchIdx + 1) + '/' + msgSearchMatches.length;
|
|
} else {
|
|
document.getElementById('msgSearchCount').textContent = t('search_no_results');
|
|
}
|
|
}
|
|
function highlightTextNodes(el, q) {
|
|
// Skip metadata and buttons
|
|
if (el.classList && (el.classList.contains('msg-meta') || el.classList.contains('media-tag'))) return;
|
|
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false);
|
|
var nodes = [];
|
|
while (walker.nextNode()) {
|
|
// Skip nodes inside msg-meta
|
|
var p = walker.currentNode.parentNode;
|
|
var skip = false;
|
|
while (p && p !== el) { if (p.classList && (p.classList.contains('msg-meta') || p.classList.contains('media-tag'))) { skip = true; break } p = p.parentNode; }
|
|
if (!skip) nodes.push(walker.currentNode);
|
|
}
|
|
for (var i = nodes.length - 1; i >= 0; i--) {
|
|
var node = nodes[i];
|
|
var src = node.textContent;
|
|
var normalized = normalizeArabicPersian(src.toLowerCase());
|
|
var idx = normalized.indexOf(q);
|
|
if (idx === -1) continue;
|
|
// Map normalized indices back to original string positions
|
|
var frag = document.createDocumentFragment();
|
|
var pos = 0;
|
|
while (idx !== -1) {
|
|
// Find the original char positions that correspond to normalized idx..idx+q.length
|
|
var origStart = mapNormIdx(src, idx);
|
|
var origEnd = mapNormIdx(src, idx + q.length);
|
|
if (origStart > pos) frag.appendChild(document.createTextNode(src.substring(pos, origStart)));
|
|
var span = document.createElement('span');
|
|
span.className = 'search-highlight';
|
|
span.textContent = src.substring(origStart, origEnd);
|
|
frag.appendChild(span);
|
|
pos = origEnd;
|
|
idx = normalized.indexOf(q, idx + q.length);
|
|
}
|
|
if (pos < src.length) frag.appendChild(document.createTextNode(src.substring(pos)));
|
|
node.parentNode.replaceChild(frag, node);
|
|
}
|
|
}
|
|
// Map a position in normalized text to position in original text
|
|
function mapNormIdx(original, normPos) {
|
|
var ni = 0;
|
|
for (var oi = 0; oi <= original.length; oi++) {
|
|
if (ni >= normPos) return oi;
|
|
if (oi < original.length) {
|
|
var ch = original[oi];
|
|
var norm = normalizeArabicPersian(ch.toLowerCase());
|
|
ni += norm.length;
|
|
}
|
|
}
|
|
return original.length;
|
|
}
|
|
function scrollToSearchMatch() {
|
|
if (msgSearchMatches.length === 0) return;
|
|
msgSearchMatches.forEach(function (el) { el.classList.remove('current') });
|
|
var cur = msgSearchMatches[msgSearchIdx];
|
|
if (cur) { cur.classList.add('current'); cur.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
|
|
document.getElementById('msgSearchCount').textContent = (msgSearchIdx + 1) + '/' + msgSearchMatches.length;
|
|
}
|
|
function msgSearchNext() {
|
|
if (msgSearchMatches.length === 0) return;
|
|
msgSearchIdx = (msgSearchIdx + 1) % msgSearchMatches.length;
|
|
scrollToSearchMatch();
|
|
}
|
|
function msgSearchPrev() {
|
|
if (msgSearchMatches.length === 0) return;
|
|
msgSearchIdx = (msgSearchIdx - 1 + msgSearchMatches.length) % msgSearchMatches.length;
|
|
scrollToSearchMatch();
|
|
}
|
|
|
|
// ===== EXPORT MESSAGES =====
|
|
function openExportModal() {
|
|
if (!currentMsgTexts.length) { showToast(t('export_no_messages')); return }
|
|
document.getElementById('exportCount').value = Math.min(10, currentMsgTexts.length);
|
|
document.getElementById('exportCount').max = currentMsgTexts.length;
|
|
document.getElementById('exportModal').classList.add('active');
|
|
}
|
|
function closeExportModal() { document.getElementById('exportModal').classList.remove('active') }
|
|
function doExport() {
|
|
var n = parseInt(document.getElementById('exportCount').value) || 10;
|
|
if (n < 1) n = 1;
|
|
if (n > currentMsgTexts.length) n = currentMsgTexts.length;
|
|
var chName = selectedChannel > 0 && channels[selectedChannel - 1] ? (channels[selectedChannel - 1].Name || channels[selectedChannel - 1].name || 'Channel') : 'Channel';
|
|
// Take last N messages (most recent)
|
|
var start = currentMsgTexts.length - n;
|
|
var lines = [];
|
|
lines.push('=== ' + chName + ' ===');
|
|
for (var i = start; i < currentMsgTexts.length; i++) {
|
|
lines.push('');
|
|
lines.push(currentMsgTexts[i]);
|
|
}
|
|
navigator.clipboard.writeText(lines.join('\n')).then(function () {
|
|
showToast(t('export_copied'));
|
|
closeExportModal();
|
|
}).catch(function () { showToast('Copy failed') });
|
|
}
|
|
|
|
// ===== WORKING RESOLVERS =====
|
|
function updateResolversBadge(count) {
|
|
var badge = document.getElementById('resolversBadge');
|
|
if (!badge) return;
|
|
badge.textContent = count;
|
|
badge.style.color = count > 0 ? 'var(--success, #27ae60)' : 'var(--error, #e74c3c)';
|
|
}
|
|
async function refreshResolversBadge() {
|
|
try {
|
|
var r = await fetch('/api/resolvers/active');
|
|
if (!r.ok) return;
|
|
var data = await r.json();
|
|
updateResolversBadge((data.resolvers || []).length);
|
|
} catch (e) { }
|
|
}
|
|
var resolversRefreshTimer = null;
|
|
async function _fetchResolversBoard(el) {
|
|
try {
|
|
var r = await fetch('/api/resolvers/active');
|
|
if (!r.ok) throw new Error(await r.text());
|
|
var data = await r.json();
|
|
var board = data.scoreboard || [];
|
|
if (!board.length) { el.innerHTML = '<div style="color:var(--text-dim)">' + t('no_active_resolvers') + '</div>'; updateResolversBadge(0); return }
|
|
updateResolversBadge(board.length);
|
|
var h = '<table style="width:100%;border-collapse:collapse;font-size:12px">';
|
|
h += '<thead><tr style="border-bottom:2px solid var(--border);text-align:left">';
|
|
h += '<th style="padding:6px 8px">Resolver</th>';
|
|
h += '<th style="padding:6px 8px;text-align:right">' + t('resolver_speed') + '</th>';
|
|
h += '<th style="padding:6px 8px;text-align:right">' + t('resolver_score') + '</th>';
|
|
h += '<th style="padding:6px 8px;text-align:center">\u2705</th>';
|
|
h += '<th style="padding:6px 8px;text-align:center">\u274C</th>';
|
|
h += '<th style="padding:6px 8px"></th>';
|
|
h += '</tr></thead><tbody>';
|
|
for (var i = 0; i < board.length; i++) {
|
|
var b = board[i];
|
|
var scoreColor = b.score >= 0.5 ? 'var(--success)' : b.score >= 0.15 ? 'var(--text)' : 'var(--error)';
|
|
h += '<tr style="border-bottom:1px solid var(--border)">';
|
|
h += '<td style="padding:5px 8px;font-family:monospace">' + esc(b.addr) + '</td>';
|
|
h += '<td style="padding:5px 8px;text-align:right">' + (b.avgMs > 0 ? Math.round(b.avgMs) + 'ms' : '-') + '</td>';
|
|
h += '<td style="padding:5px 8px;text-align:right;color:' + scoreColor + ';font-weight:600">' + b.score.toFixed(2) + '</td>';
|
|
h += '<td style="padding:5px 8px;text-align:center;color:var(--success)">' + b.success + '</td>';
|
|
h += '<td style="padding:5px 8px;text-align:center;color:var(--error)">' + b.failure + '</td>';
|
|
h += '<td style="padding:5px 8px;text-align:center"><button onclick="removeResolver(\'' + esc(b.addr) + '\')" style="background:none;border:none;color:var(--error);cursor:pointer;font-size:14px;padding:2px 4px" title="Remove">×</button></td>';
|
|
h += '</tr>';
|
|
}
|
|
h += '</tbody></table>';
|
|
el.innerHTML = h;
|
|
} catch (e) { el.innerHTML = '<div style="color:var(--error)">' + esc(e.message) + '</div>' }
|
|
}
|
|
async function openResolversModal() {
|
|
var el = document.getElementById('resolversListEl');
|
|
el.innerHTML = '<div style="color:var(--text-dim)">' + t('loading') + '</div>';
|
|
document.getElementById('resolversModal').classList.add('active');
|
|
await _fetchResolversBoard(el);
|
|
if (resolversRefreshTimer) clearInterval(resolversRefreshTimer);
|
|
resolversRefreshTimer = setInterval(function () {
|
|
if (!document.getElementById('resolversModal').classList.contains('active')) {
|
|
clearInterval(resolversRefreshTimer); resolversRefreshTimer = null; return;
|
|
}
|
|
_fetchResolversBoard(document.getElementById('resolversListEl'));
|
|
}, 3000);
|
|
}
|
|
function closeResolversModal() {
|
|
document.getElementById('resolversModal').classList.remove('active');
|
|
if (resolversRefreshTimer) { clearInterval(resolversRefreshTimer); resolversRefreshTimer = null; }
|
|
}
|
|
async function removeResolver(addr) {
|
|
try {
|
|
await fetch('/api/resolvers/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ addr: addr }) });
|
|
_fetchResolversBoard(document.getElementById('resolversListEl'));
|
|
} catch (e) { }
|
|
}
|
|
async function resetScoreboard() {
|
|
try {
|
|
await fetch('/api/resolvers/reset-stats', { method: 'POST' });
|
|
_fetchResolversBoard(document.getElementById('resolversListEl'));
|
|
} catch (e) { }
|
|
}
|
|
function copyResolversList() {
|
|
var rows = document.querySelectorAll('#resolversListEl tbody tr');
|
|
var lines = [];
|
|
rows.forEach(function (tr) {
|
|
var cells = tr.querySelectorAll('td');
|
|
if (cells.length > 0) lines.push(cells[0].textContent.trim());
|
|
});
|
|
if (!lines.length) { showToast(t('no_active_resolvers')); return }
|
|
navigator.clipboard.writeText(lines.join('\n')).then(function () { showToast(t('copied')) });
|
|
}
|
|
|
|
// ===== BACKGROUND IMAGE =====
|
|
function _setBg(data) {
|
|
var ca = document.querySelector('.chat-area');
|
|
ca.style.backgroundImage = data ? 'url("' + data + '")' : '';
|
|
ca.style.backgroundSize = data ? 'cover' : '';
|
|
ca.style.backgroundPosition = data ? 'center' : '';
|
|
ca.style.backgroundRepeat = data ? 'no-repeat' : '';
|
|
document.getElementById('messages').style.background = data ? 'transparent' : '';
|
|
}
|
|
function loadBgImage() {
|
|
var data = localStorage.getItem('thefeed_bg_image') || '';
|
|
if (data) _setBg(data);
|
|
}
|
|
function applyBgImage() {
|
|
var inp = document.getElementById('bgImageInput');
|
|
if (!inp.files || !inp.files[0]) return;
|
|
var file = inp.files[0];
|
|
if (file.size > 5 * 1024 * 1024) { showToast('File too large (max 5MB)'); return }
|
|
var reader = new FileReader();
|
|
reader.onload = function (e) {
|
|
var data = e.target.result;
|
|
try { localStorage.setItem('thefeed_bg_image', data) } catch (ex) { showToast('File too large for storage'); return }
|
|
_setBg(data);
|
|
showToast(t('apply'));
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
function clearBgImage() {
|
|
localStorage.removeItem('thefeed_bg_image');
|
|
_setBg('');
|
|
document.getElementById('bgImageInput').value = '';
|
|
showToast(t('clear_bg'));
|
|
}
|
|
|
|
// ===== EVENTS =====
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Enter' && document.activeElement === document.getElementById('sendInput')) { e.preventDefault(); sendMessage() }
|
|
if (e.key === 'Enter' && document.activeElement === document.getElementById('peAddChannelInput')) { e.preventDefault(); addChannelEditor() }
|
|
if (e.key === 'Enter' && document.activeElement === document.getElementById('msgSearchInput')) { e.preventDefault(); msgSearchNext() }
|
|
if (e.key === 'Escape') { closeSettings(); closeProfiles(); closeProfileEditor(); closeScanner(); closeMsgSearch(); closeExportModal(); closeResolversModal() }
|
|
});
|
|
window.addEventListener('resize', function () { if (window.innerWidth > 768) document.getElementById('app').classList.remove('chat-open') });
|
|
|
|
// Handle thefeed:// URI hash import
|
|
(function () { var h = location.hash; if (h && h.startsWith('#thefeed://')) { document.getElementById('importUriInput').value = decodeURIComponent(h.substring(1)); openProfiles(); doImportUri() } })();
|
|
|
|
// ===== AUTO-SCROLL DURING TEXT SELECTION =====
|
|
(function () {
|
|
var scrollSpeed = 0, scrollFrame = null, messagesEl = null;
|
|
function startAutoScroll() {
|
|
if (scrollFrame) return;
|
|
function step() {
|
|
if (scrollSpeed === 0 || !messagesEl) { scrollFrame = null; return }
|
|
messagesEl.scrollTop += scrollSpeed;
|
|
scrollFrame = requestAnimationFrame(step);
|
|
}
|
|
scrollFrame = requestAnimationFrame(step);
|
|
}
|
|
function stopAutoScroll() {
|
|
scrollSpeed = 0;
|
|
if (scrollFrame) { cancelAnimationFrame(scrollFrame); scrollFrame = null }
|
|
}
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
messagesEl = document.getElementById('messages');
|
|
if (!messagesEl) return;
|
|
var edgeZone = 40;
|
|
function handleMove(clientY) {
|
|
var sel = window.getSelection();
|
|
if (!sel || sel.isCollapsed) return;
|
|
var rect = messagesEl.getBoundingClientRect();
|
|
if (clientY < rect.top + edgeZone) {
|
|
scrollSpeed = -Math.max(2, (edgeZone - (clientY - rect.top)) / 3);
|
|
startAutoScroll();
|
|
} else if (clientY > rect.bottom - edgeZone) {
|
|
scrollSpeed = Math.max(2, (edgeZone - (rect.bottom - clientY)) / 3);
|
|
startAutoScroll();
|
|
} else { stopAutoScroll() }
|
|
}
|
|
messagesEl.addEventListener('touchmove', function (e) { if (e.touches[0]) handleMove(e.touches[0].clientY) });
|
|
messagesEl.addEventListener('touchend', stopAutoScroll);
|
|
messagesEl.addEventListener('touchcancel', stopAutoScroll);
|
|
messagesEl.addEventListener('mousemove', function (e) { if (e.buttons === 1) handleMove(e.clientY) });
|
|
document.addEventListener('mouseup', stopAutoScroll);
|
|
});
|
|
})();
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
|
|
</html> |