mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-19 07:44:34 +03:00
feat(telemirror): add optional backup feed for browsing Telegram channels
This commit is contained in:
@@ -930,6 +930,227 @@
|
||||
background: rgba(255, 255, 255, .3)
|
||||
}
|
||||
|
||||
/* BEGIN telemirror */
|
||||
.tm-no-scroll { overflow: hidden }
|
||||
.tm-modal {
|
||||
position: fixed; inset: 0; z-index: 9000;
|
||||
background: var(--bg);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
color: var(--text)
|
||||
}
|
||||
.tm-modal.active { display: flex }
|
||||
|
||||
.tm-topbar {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 16px; min-height: 56px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, var(--bg-elevated, var(--bg)), var(--bg));
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.06);
|
||||
flex-shrink: 0
|
||||
}
|
||||
.tm-back, .tm-menu {
|
||||
background: none; border: 1px solid transparent; color: var(--text);
|
||||
font-size: 16px; cursor: pointer; padding: 6px 10px; border-radius: 8px;
|
||||
transition: background .12s, border-color .12s
|
||||
}
|
||||
.tm-back:hover, .tm-menu:hover { background: var(--border); border-color: var(--border) }
|
||||
.tm-menu { display: none }
|
||||
@media (max-width: 768px) { .tm-menu { display: inline-flex } }
|
||||
|
||||
.tm-topbar-info { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0 }
|
||||
.tm-topbar-meta { min-width: 0; flex: 1 }
|
||||
.tm-topbar-name { font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis }
|
||||
.tm-topbar-sub { font-size: 11px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px }
|
||||
|
||||
.tm-avatar {
|
||||
border-radius: 50%;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-weight: 600; font-size: 16px;
|
||||
overflow: hidden; flex-shrink: 0;
|
||||
background: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,.08), 0 1px 2px rgba(0,0,0,.15);
|
||||
position: relative
|
||||
}
|
||||
.tm-avatar img {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%; object-fit: cover; display: block
|
||||
}
|
||||
.tm-avatar-initial {
|
||||
pointer-events: none; user-select: none;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 100%; height: 100%
|
||||
}
|
||||
|
||||
.tm-body { flex: 1; min-height: 0; display: flex; position: relative }
|
||||
.tm-sidebar {
|
||||
width: 320px; flex-shrink: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-elevated, var(--bg));
|
||||
display: flex; flex-direction: column; min-height: 0
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.tm-sidebar {
|
||||
position: absolute; top: 0; bottom: 0; left: 0; width: 86%; max-width: 340px;
|
||||
z-index: 2; transform: translateX(-100%); transition: transform .22s ease;
|
||||
box-shadow: 4px 0 24px rgba(0,0,0,.0)
|
||||
}
|
||||
.tm-sidebar.open { transform: translateX(0); box-shadow: 4px 0 24px rgba(0,0,0,.32) }
|
||||
}
|
||||
.tm-disclaimer {
|
||||
padding: 12px 14px; margin: 10px;
|
||||
font-size: 11px; color: var(--text-dim); line-height: 1.6;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
border-inline-start: 3px solid var(--accent)
|
||||
}
|
||||
.tm-add-row {
|
||||
display: flex; gap: 6px; padding: 0 10px 10px;
|
||||
border-bottom: 1px solid var(--border)
|
||||
}
|
||||
.tm-add-row input {
|
||||
flex: 1; padding: 8px 12px; font-size: 13px;
|
||||
background: var(--bg); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
font-family: inherit;
|
||||
transition: border-color .12s
|
||||
}
|
||||
.tm-add-row input:focus { outline: none; border-color: var(--accent) }
|
||||
.tm-add-row .btn { padding: 8px 14px; font-size: 13px; border-radius: 8px }
|
||||
|
||||
.tm-channels-list { flex: 1; overflow-y: auto; padding: 6px }
|
||||
.tm-channel-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 9px 10px; cursor: pointer;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
transition: background .12s, border-color .12s;
|
||||
margin-bottom: 2px
|
||||
}
|
||||
.tm-channel-item:hover { background: var(--bg); border-color: var(--border) }
|
||||
.tm-channel-item.active {
|
||||
background: color-mix(in oklab, var(--accent) 18%, transparent);
|
||||
border-color: var(--accent)
|
||||
}
|
||||
.tm-channel-item.active .tm-channel-item-name { color: var(--accent) }
|
||||
.tm-channel-item-meta { flex: 1; min-width: 0 }
|
||||
.tm-channel-item-name {
|
||||
font-size: 13px; font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis
|
||||
}
|
||||
.tm-pin { font-size: 9px; opacity: .55; margin-inline-start: 4px }
|
||||
.tm-x {
|
||||
background: none; border: 1px solid transparent; color: inherit;
|
||||
font-size: 16px; cursor: pointer; opacity: .45;
|
||||
padding: 2px 7px; border-radius: 6px;
|
||||
transition: background .12s, opacity .12s
|
||||
}
|
||||
.tm-x:hover { opacity: 1; background: var(--border) }
|
||||
|
||||
.tm-content {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: 18px 16px 40px;
|
||||
background:
|
||||
radial-gradient(circle at 25% 0%, color-mix(in oklab, var(--accent) 6%, transparent), transparent 40%),
|
||||
var(--bg);
|
||||
display: flex; flex-direction: column; gap: 12px
|
||||
}
|
||||
.tm-empty {
|
||||
margin: auto; padding: 32px 24px;
|
||||
text-align: center; color: var(--text-dim); font-size: 13px
|
||||
}
|
||||
|
||||
.tm-channel-desc {
|
||||
max-width: 720px; width: 100%; margin: 0 auto 4px;
|
||||
padding: 12px 16px; font-size: 13px; color: var(--text-dim);
|
||||
line-height: 1.7;
|
||||
background: var(--bg-elevated, var(--bg));
|
||||
border: 1px solid var(--border); border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.04)
|
||||
}
|
||||
.tm-channel-desc a { color: var(--accent) }
|
||||
|
||||
.tm-post {
|
||||
max-width: 720px; width: 100%; margin: 0 auto;
|
||||
background: var(--bg-elevated, var(--bg));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 12px 16px 10px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.05);
|
||||
transition: border-color .15s, transform .15s
|
||||
}
|
||||
.tm-post:hover { border-color: color-mix(in oklab, var(--accent) 35%, var(--border)) }
|
||||
.tm-post-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 11px; color: var(--text-dim);
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px dashed var(--border)
|
||||
}
|
||||
.tm-post-author { font-weight: 700; color: var(--accent) }
|
||||
.tm-post-time { opacity: .85 }
|
||||
.tm-post-edited {
|
||||
font-size: 10px; padding: 1px 6px; border-radius: 4px;
|
||||
background: var(--border); color: var(--text-dim)
|
||||
}
|
||||
.tm-post-text { font-size: 14px; line-height: 1.7; word-break: break-word }
|
||||
.tm-post-text a { color: var(--accent); text-decoration: underline; text-decoration-color: color-mix(in oklab, var(--accent) 50%, transparent) }
|
||||
.tm-post-text a:hover { text-decoration-color: currentColor }
|
||||
.tm-post-media {
|
||||
margin-top: 10px;
|
||||
display: grid; gap: 4px
|
||||
}
|
||||
/* Single photo: fill width, natural aspect, capped height. */
|
||||
.tm-album-1 { grid-template-columns: 1fr }
|
||||
.tm-album-1 .tm-photo img { max-height: 600px; object-fit: contain; background: #000 }
|
||||
/* 2 photos side by side. */
|
||||
.tm-album-2 { grid-template-columns: 1fr 1fr }
|
||||
.tm-album-2 .tm-photo img { aspect-ratio: 1 / 1; object-fit: cover }
|
||||
/* 3+ photos: 3-column album. */
|
||||
.tm-album-3 { grid-template-columns: 1fr 1fr 1fr }
|
||||
.tm-album-3 .tm-photo img { aspect-ratio: 1 / 1; object-fit: cover }
|
||||
|
||||
.tm-photo {
|
||||
display: block; line-height: 0;
|
||||
border-radius: 10px; overflow: hidden;
|
||||
background: #000;
|
||||
border: 1px solid var(--border)
|
||||
}
|
||||
.tm-photo img {
|
||||
width: 100%; height: auto; display: block
|
||||
}
|
||||
.tm-vid {
|
||||
position: relative; display: flex;
|
||||
align-items: center; justify-content: center;
|
||||
aspect-ratio: 16 / 9;
|
||||
background-size: cover; background-position: center;
|
||||
border-radius: 10px; background-color: var(--border);
|
||||
color: #fff;
|
||||
text-shadow: 0 0 6px rgba(0,0,0,.6);
|
||||
border: 1px solid var(--border)
|
||||
}
|
||||
.tm-vid-play {
|
||||
width: 54px; height: 54px;
|
||||
border-radius: 50%; background: rgba(0,0,0,.5);
|
||||
backdrop-filter: blur(4px);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 22px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.4)
|
||||
}
|
||||
.tm-vid-dur {
|
||||
position: absolute; bottom: 8px; right: 10px;
|
||||
background: rgba(0,0,0,.65);
|
||||
padding: 2px 7px; border-radius: 5px;
|
||||
font-size: 11px
|
||||
}
|
||||
.tm-post-foot {
|
||||
display: flex; gap: 14px;
|
||||
margin-top: 8px;
|
||||
font-size: 11px; color: var(--text-dim)
|
||||
}
|
||||
.tm-views { display: inline-flex; align-items: center; gap: 4px }
|
||||
/* END telemirror */
|
||||
|
||||
.media-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2465,6 +2686,9 @@
|
||||
<div class="sidebar-toolbar">
|
||||
<button class="stb" id="scannerIconBtn" onclick="openScanner()" data-i18n="scanner_find_resolvers">Find Resolvers</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>
|
||||
<!-- BEGIN telemirror -->
|
||||
<button class="stb" id="telemirrorSidebarBtn" onclick="openTelemirror()" data-i18n-title="telemirror_btn_title"><span data-i18n="telemirror_btn">Browse channels</span></button>
|
||||
<!-- END telemirror -->
|
||||
</div>
|
||||
<input class="sidebar-search" id="channelSearch" type="text" data-i18n-ph="search" placeholder="Search..."
|
||||
oninput="filterChannels()">
|
||||
@@ -2539,6 +2763,35 @@
|
||||
<!-- TOAST -->
|
||||
<div id="toast"></div>
|
||||
|
||||
<!-- BEGIN telemirror -->
|
||||
<div id="telemirrorModal" class="tm-modal">
|
||||
<header class="tm-topbar">
|
||||
<button class="tm-back" onclick="closeTelemirror()" aria-label="Close">❮</button>
|
||||
<button class="tm-menu" onclick="toggleTmSidebar()" aria-label="Channels">☰</button>
|
||||
<div class="tm-topbar-info">
|
||||
<div id="tmTopbarAvatar"></div>
|
||||
<div class="tm-topbar-meta">
|
||||
<div class="tm-topbar-name" id="tmTopbarName" data-i18n="telemirror_title">Browse channels</div>
|
||||
<div class="tm-topbar-sub" id="tmTopbarSub"></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="tm-body">
|
||||
<aside id="tmSidebar" class="tm-sidebar">
|
||||
<p class="tm-disclaimer" data-i18n="telemirror_disclaimer">Experimental backup feature for browsing public Telegram channels. Depends on third-party services and may stop working or be rate-limited. For reliable access, use the core thefeed feature.</p>
|
||||
<div class="tm-add-row">
|
||||
<input id="tmAddInput" type="text" data-i18n-ph="telemirror_add_ph" placeholder="@username" maxlength="40">
|
||||
<button class="btn btn-primary" onclick="telemirrorAdd()" data-i18n="add">Add</button>
|
||||
</div>
|
||||
<div id="tmChannelsList" class="tm-channels-list"></div>
|
||||
</aside>
|
||||
<main id="tmContent" class="tm-content">
|
||||
<div class="tm-empty" data-i18n="telemirror_pick_channel">Pick a channel</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END telemirror -->
|
||||
|
||||
<!-- ===== SAVED RESOLVERS POPUP ===== -->
|
||||
<div class="modal-overlay" id="savedResolversModal">
|
||||
<div class="modal" style="max-width:380px">
|
||||
@@ -3023,6 +3276,21 @@
|
||||
cancel_media_msg: 'دانلود این رسانه لغو شود؟', dismiss: 'بستن',
|
||||
write_message: 'پیام بنویسید...', configure_server: 'برای شروع یک سرور راهاندازی کنید',
|
||||
set_up: 'راهاندازی', switching: 'در حال تغییر پروفایل...', select_channel_hint: 'یک کانال را برای دیدن پیامها انتخاب کنید',
|
||||
// BEGIN telemirror
|
||||
telemirror_btn: 'دیدن کانالهای دلخواه',
|
||||
telemirror_btn_title: 'دیدن کانالهای دلخواه',
|
||||
telemirror_title: 'دیدن کانالهای دلخواه',
|
||||
telemirror_disclaimer: 'روش باز کردن کانالهای دلخواه در این بخش فقط تا زمانی که سرویسهای گوگل باز باشن کار میکنه. اگر براتون کار نکرد، از قابلیتهای اصلی thefeed استفاده کنید چون ضدفیلتر هستن.',
|
||||
telemirror_add_ph: 'مثلا @VahidOnline',
|
||||
telemirror_loading: 'در حال بارگذاری...',
|
||||
telemirror_load_failed: 'دریافت کانال ناموفق بود.',
|
||||
telemirror_no_posts: 'هیچ پستی پیدا نشد.',
|
||||
telemirror_pick_channel: 'یک کانال را انتخاب کن',
|
||||
telemirror_invalid_user: 'نام کاربری نامعتبر',
|
||||
telemirror_remove_pinned: 'این کانال پیشفرض قابل حذف نیست',
|
||||
telemirror_views: 'بازدید',
|
||||
telemirror_edited: 'ویرایش شده',
|
||||
// END telemirror
|
||||
font_size: 'اندازه قلم', debug_mode: 'حالت دیباگ', language: 'زبان',
|
||||
next_fetch_info: 'زمان باقیمانده تا دریافت بعدی محتوا توسط سرور',
|
||||
no_profiles: 'هنوز پروفایلی وجود ندارد', add_profile: '+ پروفایل جدید',
|
||||
@@ -3233,6 +3501,21 @@
|
||||
cancel_media_msg: 'Cancel this download?', dismiss: 'Dismiss',
|
||||
write_message: 'Write a message...', configure_server: 'Configure a server to start reading',
|
||||
set_up: 'Set Up', switching: 'Switching profile...', select_channel_hint: 'Pick a channel to view its messages',
|
||||
// BEGIN telemirror
|
||||
telemirror_btn: 'Browse channels',
|
||||
telemirror_btn_title: 'Browse channels',
|
||||
telemirror_title: 'Browse channels',
|
||||
telemirror_disclaimer: 'Browsing custom channels here only works while Google services are reachable. If it stops working for you, use thefeed\'s main features — those are filter-resistant.',
|
||||
telemirror_add_ph: 'e.g. @VahidOnline',
|
||||
telemirror_loading: 'Loading...',
|
||||
telemirror_load_failed: 'Failed to load channel.',
|
||||
telemirror_no_posts: 'No posts found.',
|
||||
telemirror_pick_channel: 'Pick a channel',
|
||||
telemirror_invalid_user: 'Invalid username',
|
||||
telemirror_remove_pinned: 'Pinned channels cannot be removed',
|
||||
telemirror_views: 'views',
|
||||
telemirror_edited: 'edited',
|
||||
// END telemirror
|
||||
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',
|
||||
@@ -7901,7 +8184,7 @@
|
||||
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() }
|
||||
if (e.key === 'Escape') { closeSettings(); closeProfiles(); closeProfileEditor(); closeScanner(); closeMsgSearch(); closeExportModal(); closeResolversModal(); closeTelemirror() }
|
||||
});
|
||||
mobileQuery.addEventListener('change', function () {
|
||||
var app = document.getElementById('app');
|
||||
@@ -7979,6 +8262,9 @@
|
||||
|
||||
init();
|
||||
</script>
|
||||
<!-- BEGIN telemirror -->
|
||||
<script src="/static/telemirror.js"></script>
|
||||
<!-- END telemirror -->
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,268 @@
|
||||
// Optional, removable backup feed UI. All globals here are namespaced
|
||||
// with `tm` / `telemirror` so removing this file (plus the markup block
|
||||
// in index.html) drops the feature without touching anything else.
|
||||
(function () {
|
||||
var tmChannels = [];
|
||||
var tmActive = '';
|
||||
var tmAvatarCache = {}; // username (lower) -> photo URL once we've fetched it
|
||||
|
||||
function tmI18n(key, fallback) {
|
||||
try {
|
||||
var v = (typeof t === 'function') ? t(key) : '';
|
||||
return v && v !== key ? v : (fallback || '');
|
||||
} catch (e) { return fallback || ''; }
|
||||
}
|
||||
|
||||
function tmEsc(s) {
|
||||
return (typeof esc === 'function') ? esc(s) : String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
function tmEscAttr(s) {
|
||||
return (typeof escAttr === 'function') ? escAttr(s) :
|
||||
tmEsc(s).replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
function tmToast(msg) {
|
||||
if (typeof showToast === 'function') showToast(msg);
|
||||
}
|
||||
|
||||
function tmInitial(name) {
|
||||
if (!name) return '?';
|
||||
var ch = name.replace(/^@/, '').charAt(0);
|
||||
return ch ? ch.toUpperCase() : '?';
|
||||
}
|
||||
|
||||
// Deterministic colour-from-name so the placeholder avatars don't all
|
||||
// look identical. Mirrors what Telegram's web client does.
|
||||
function tmAvatarColor(name) {
|
||||
var palette = ['#e57373', '#f06292', '#ba68c8', '#9575cd', '#7986cb',
|
||||
'#64b5f6', '#4fc3f7', '#4dd0e1', '#4db6ac', '#81c784',
|
||||
'#aed581', '#dce775', '#ffd54f', '#ffb74d', '#ff8a65'];
|
||||
var h = 0;
|
||||
for (var i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return palette[h % palette.length];
|
||||
}
|
||||
|
||||
function tmAvatarHTML(username, name, size) {
|
||||
size = size || 40;
|
||||
var disp = name || username || '';
|
||||
var initial = '<span class="tm-avatar-initial">' + tmEsc(tmInitial(disp)) + '</span>';
|
||||
var bg = tmAvatarColor(disp || '?');
|
||||
var photo = tmAvatarCache[(username || '').toLowerCase()];
|
||||
var inner = initial;
|
||||
if (photo) {
|
||||
// Img falls back to the initial-letter span on load failure.
|
||||
inner = '<img src="' + tmEscAttr(photo) + '" loading="lazy" alt=""'
|
||||
+ ' onerror="this.parentNode.classList.add(\'tm-avatar-fallback\');this.remove()">'
|
||||
+ initial;
|
||||
}
|
||||
return '<div class="tm-avatar" style="width:' + size + 'px;height:' + size + 'px;background:' + bg + '">'
|
||||
+ inner + '</div>';
|
||||
}
|
||||
|
||||
// ===== open / close =====
|
||||
window.openTelemirror = function () {
|
||||
document.getElementById('telemirrorModal').classList.add('active');
|
||||
document.body.classList.add('tm-no-scroll');
|
||||
tmLoadChannels();
|
||||
};
|
||||
window.closeTelemirror = function () {
|
||||
document.getElementById('telemirrorModal').classList.remove('active');
|
||||
document.body.classList.remove('tm-no-scroll');
|
||||
};
|
||||
window.toggleTmSidebar = function () {
|
||||
var sb = document.getElementById('tmSidebar');
|
||||
if (sb) sb.classList.toggle('open');
|
||||
};
|
||||
|
||||
// ===== channel list =====
|
||||
async function tmLoadChannels() {
|
||||
try {
|
||||
var r = await fetch('/api/telemirror/channels');
|
||||
var d = await r.json();
|
||||
tmChannels = (d.channels || []).slice();
|
||||
} catch (e) { tmChannels = []; }
|
||||
tmRenderChannels();
|
||||
if (!tmActive && tmChannels.length > 0) {
|
||||
tmSelect(tmChannels[0].username);
|
||||
} else if (tmActive) {
|
||||
tmSelect(tmActive);
|
||||
} else {
|
||||
document.getElementById('tmContent').innerHTML =
|
||||
'<div class="tm-empty">' + tmEsc(tmI18n('telemirror_pick_channel', 'Pick a channel')) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function tmRenderChannels() {
|
||||
var box = document.getElementById('tmChannelsList');
|
||||
var html = '';
|
||||
for (var i = 0; i < tmChannels.length; i++) {
|
||||
var c = tmChannels[i];
|
||||
var active = (c.username.toLowerCase() === tmActive.toLowerCase()) ? ' active' : '';
|
||||
html += '<div class="tm-channel-item' + active + '" data-u="' + tmEscAttr(c.username) + '" onclick="tmSelectFromClick(this.dataset.u)">'
|
||||
+ tmAvatarHTML(c.username, c.username, 40)
|
||||
+ '<div class="tm-channel-item-meta">'
|
||||
+ '<div class="tm-channel-item-name">' + tmEsc(c.username) + (c.pinned ? ' <span class="tm-pin">📌</span>' : '') + '</div>'
|
||||
+ '</div>';
|
||||
if (!c.pinned) {
|
||||
html += '<button class="tm-x" data-u="' + tmEscAttr(c.username) + '" onclick="event.stopPropagation();tmRemove(this.dataset.u)">×</button>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
box.innerHTML = html || '<div class="tm-empty">' + tmEsc(tmI18n('telemirror_pick_channel', 'Pick a channel')) + '</div>';
|
||||
}
|
||||
|
||||
window.tmSelectFromClick = function (username) {
|
||||
tmSelect(username);
|
||||
// Mobile: collapse the sidebar drawer after picking.
|
||||
var sb = document.getElementById('tmSidebar');
|
||||
if (sb) sb.classList.remove('open');
|
||||
};
|
||||
|
||||
function tmShowError(msg) {
|
||||
var content = document.getElementById('tmContent');
|
||||
content.innerHTML =
|
||||
'<div class="tm-empty"><p>' + tmEsc(tmI18n('telemirror_load_failed', 'Failed to load')) + '</p>'
|
||||
+ '<pre style="white-space:pre-wrap;margin-top:10px;padding:10px;background:var(--bg-elevated,var(--bg));border:1px solid var(--border);border-radius:6px;color:var(--text-dim);font-size:11px;text-align:start;max-width:600px;direction:ltr">'
|
||||
+ tmEsc(String(msg).slice(0, 2000))
|
||||
+ '</pre></div>';
|
||||
}
|
||||
|
||||
async function tmSelect(username) {
|
||||
tmActive = username;
|
||||
tmRenderChannels();
|
||||
tmRenderTopbar(null, username);
|
||||
var content = document.getElementById('tmContent');
|
||||
content.innerHTML = '<div class="tm-empty">' + tmEsc(tmI18n('telemirror_loading', 'Loading...')) + '</div>';
|
||||
try {
|
||||
var r = await fetch('/api/telemirror/channel/' + encodeURIComponent(username));
|
||||
if (!r.ok) {
|
||||
var errBody = '';
|
||||
try { errBody = await r.text(); } catch (e2) { }
|
||||
tmShowError(errBody || ('HTTP ' + r.status));
|
||||
return;
|
||||
}
|
||||
var d = await r.json();
|
||||
if (d && d.channel && d.channel.photo) {
|
||||
tmAvatarCache[username.toLowerCase()] = d.channel.photo;
|
||||
tmRenderChannels();
|
||||
}
|
||||
tmRenderTopbar(d && d.channel, username);
|
||||
tmRenderPosts(d);
|
||||
} catch (e) {
|
||||
tmShowError((e && e.message) || String(e));
|
||||
}
|
||||
}
|
||||
window.tmSelect = tmSelect;
|
||||
|
||||
function tmRenderTopbar(channel, username) {
|
||||
var name = (channel && channel.title) || username;
|
||||
var sub = '';
|
||||
if (channel) {
|
||||
if (channel.subscribers) sub = channel.subscribers;
|
||||
else if (username) sub = '@' + username;
|
||||
} else if (username) {
|
||||
sub = '@' + username;
|
||||
}
|
||||
document.getElementById('tmTopbarAvatar').innerHTML = tmAvatarHTML(username, name, 38);
|
||||
document.getElementById('tmTopbarName').textContent = name || '';
|
||||
document.getElementById('tmTopbarSub').textContent = sub || '';
|
||||
}
|
||||
|
||||
function tmRenderPosts(data) {
|
||||
var content = document.getElementById('tmContent');
|
||||
var posts = (data && data.posts) || [];
|
||||
var ch = (data && data.channel) || {};
|
||||
if (!posts.length) {
|
||||
content.innerHTML = '<div class="tm-empty">' + tmEsc(tmI18n('telemirror_no_posts', 'No posts')) + '</div>';
|
||||
return;
|
||||
}
|
||||
// Telegram order: oldest first, newest at the bottom (chat-like).
|
||||
posts.sort(function (a, b) { return (a.time || '').localeCompare(b.time || ''); });
|
||||
|
||||
var html = '';
|
||||
if (ch.description) {
|
||||
html += '<div class="tm-channel-desc">' + ch.description + '</div>';
|
||||
}
|
||||
for (var i = 0; i < posts.length; i++) {
|
||||
var p = posts[i];
|
||||
var when = p.time ? new Date(p.time).toLocaleString() : '';
|
||||
html += '<div class="tm-post">';
|
||||
html += '<div class="tm-post-head">';
|
||||
if (ch.title) html += '<span class="tm-post-author">' + tmEsc(ch.title) + '</span>';
|
||||
html += '<span class="tm-post-time">' + tmEsc(when) + '</span>';
|
||||
if (p.edited) html += '<span class="tm-post-edited">' + tmEsc(tmI18n('telemirror_edited', 'edited')) + '</span>';
|
||||
html += '</div>';
|
||||
|
||||
if (p.text) html += '<div class="tm-post-text">' + p.text + '</div>';
|
||||
|
||||
if (p.media && p.media.length) {
|
||||
// Album-aware grid: 1 photo → fullwidth, 2 → 2 cols, 3+ → 3 cols.
|
||||
var gridClass = 'tm-post-media tm-album-' + Math.min(p.media.length, 3);
|
||||
html += '<div class="' + gridClass + '">';
|
||||
for (var j = 0; j < p.media.length; j++) {
|
||||
var m = p.media[j];
|
||||
if (m.type === 'photo' && m.thumb) {
|
||||
// No link wrapping — clicking a Translate-proxied permalink
|
||||
// just returns useless bytes via /api/telemirror/img.
|
||||
html += '<div class="tm-photo">'
|
||||
+ '<img src="' + tmEscAttr(m.thumb) + '" loading="lazy" alt=""></div>';
|
||||
} else if (m.type === 'video') {
|
||||
var bg = m.thumb ? 'background-image:url(\'' + tmEscAttr(m.thumb) + '\')' : '';
|
||||
var dur = m.duration ? '<span class="tm-vid-dur">' + tmEsc(m.duration) + '</span>' : '';
|
||||
html += '<div class="tm-vid" style="' + bg + '">'
|
||||
+ '<span class="tm-vid-play">▶</span>' + dur + '</div>';
|
||||
}
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '<div class="tm-post-foot">';
|
||||
if (p.views) html += '<span class="tm-views">👁 ' + tmEsc(p.views) + '</span>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
content.innerHTML = html;
|
||||
// Jump to the bottom (newest message), like Telegram does on load.
|
||||
requestAnimationFrame(function () {
|
||||
content.scrollTop = content.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
// ===== add / remove =====
|
||||
window.telemirrorAdd = async function () {
|
||||
var input = document.getElementById('tmAddInput');
|
||||
var u = (input.value || '').trim();
|
||||
if (!u) return;
|
||||
try {
|
||||
var r = await fetch('/api/telemirror/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'add', username: u })
|
||||
});
|
||||
if (!r.ok) { tmToast(tmI18n('telemirror_invalid_user', 'Invalid username')); return; }
|
||||
input.value = '';
|
||||
await tmLoadChannels();
|
||||
} catch (e) { tmToast((e && e.message) || 'failed'); }
|
||||
};
|
||||
|
||||
window.tmRemove = async function (username) {
|
||||
try {
|
||||
var r = await fetch('/api/telemirror/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'remove', username: username })
|
||||
});
|
||||
if (!r.ok) { tmToast(tmI18n('telemirror_remove_pinned', 'Cannot remove pinned')); return; }
|
||||
if (tmActive.toLowerCase() === username.toLowerCase()) tmActive = '';
|
||||
await tmLoadChannels();
|
||||
} catch (e) { tmToast((e && e.message) || 'failed'); }
|
||||
};
|
||||
|
||||
// Allow Enter in the add input.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var inp = document.getElementById('tmAddInput');
|
||||
if (inp) inp.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); window.telemirrorAdd(); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user