You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

822 lines
31 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>ChatHub</title>
<style>
/* ── Reset & Base ─────────────────────────────── */
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
font-size: 15px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── Theme Variables ──────────────────────────── */
:root {
/* Dark (Telegram Dark) — default */
--bg-app: #0e1621;
--sidebar-bg: #17212b;
--sidebar-hover: #202b36;
--sidebar-header-bg: #17212b;
--sidebar-header-border: rgba(255,255,255,0.04);
--chat-bg: #0e1621;
--header-bg: #17212b;
--header-border: rgba(255,255,255,0.04);
--bubble-self: #2b5278;
--bubble-self-text: #e8e8e8;
--bubble-other: #182533;
--bubble-other-text: #e8e8e8;
--input-bg: #17212b;
--input-border: rgba(255,255,255,0.04);
--input-focus-border: #5f9cea;
--input-inner: #242f3d;
--text-primary: #e8e8e8;
--text-secondary: #858e99;
--text-muted: #5f6773;
--accent: #5f9cea;
--accent-hover: #7bafed;
--online-green: #4fae4f;
--red: #e9575f;
--shadow: none;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--sidebar-width: 300px;
--header-height: 48px;
--footer-height: 52px;
--transition: 0.15s ease;
}
/* Light theme */
[data-theme="light"] {
--bg-app: #e6ebee;
--sidebar-bg: #fff;
--sidebar-hover: #f2f6fa;
--sidebar-header-bg: #5682a3;
--sidebar-header-border: #d9dee1;
--chat-bg: #e6ebee;
--header-bg: #fff;
--header-border: #d9dee1;
--bubble-self: #effdde;
--bubble-self-text: #000;
--bubble-other: #fff;
--bubble-other-text: #000;
--input-bg: #fff;
--input-border: #d9dee1;
--input-focus-border: #5b9bd5;
--input-inner: #f2f6fa;
--text-primary: #222;
--text-secondary: #888;
--text-muted: #aaa;
--accent: #5b9bd5;
--accent-hover: #4a8bc5;
--online-green: #3dd68c;
--red: #f0566a;
--shadow: 0 1px 2px rgba(0,0,0,0.04);
}
body {
background: var(--bg-app);
color: var(--text-primary);
}
/* ── App Layout ───────────────────────────────── */
#app {
display: flex;
height: 100vh;
width: 100%;
overflow: hidden;
background: var(--bg-app);
}
/* ═══════════════ Sidebar ═══════════════ */
.sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
background: var(--sidebar-bg);
display: flex;
flex-direction: column;
border-right: 1px solid var(--sidebar-header-border);
}
/* Sidebar header — height aligned with chat header */
.sidebar-header {
height: var(--header-height);
min-height: var(--header-height);
background: var(--sidebar-header-bg);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid var(--sidebar-header-border);
flex-shrink: 0;
}
.sidebar-title {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 0.2px;
display: flex;
align-items: center;
gap: 6px;
}
.sidebar-title-icon {
font-size: 18px;
}
.sidebar-badge {
font-size: 11px;
color: var(--text-secondary);
background: rgba(255,255,255,0.06);
padding: 2px 10px;
border-radius: 10px;
}
.sidebar-search {
padding: 8px 12px;
flex-shrink: 0;
border-bottom: 1px solid var(--sidebar-header-border);
}
.search-input {
width: 100%;
background: var(--input-inner);
border: none;
border-radius: var(--radius-sm);
padding: 7px 12px;
font-size: 13px;
color: var(--text-primary);
outline: none;
pointer-events: none;
}
.search-input::placeholder { color: var(--text-muted); }
.sidebar-section {
font-size: 11px;
text-transform: uppercase;
color: var(--text-secondary);
padding: 10px 16px 4px;
letter-spacing: 0.6px;
font-weight: 600;
flex-shrink: 0;
}
.member-list {
flex: 1;
overflow-y: auto;
}
.member-list::-webkit-scrollbar { width: 4px; }
.member-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 4px; }
[data-theme="light"] .member-list::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.08); }
.member-item {
display: flex;
align-items: center;
gap: 11px;
padding: 8px 16px;
cursor: pointer;
transition: background var(--transition);
}
.member-item:hover { background: var(--sidebar-hover); }
.member-avatar {
width: 38px;
height: 38px;
border-radius: 50%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 600;
color: #fff;
position: relative;
}
.member-avatar-dot {
position: absolute;
bottom: 1px;
right: -1px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--online-green);
border: 2px solid var(--sidebar-bg);
}
.member-info { flex: 1; min-width: 0; }
.member-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-sub {
font-size: 11px;
color: var(--text-secondary);
margin-top: 1px;
}
.member-tag {
font-size: 11px;
color: var(--accent);
background: rgba(95,156,234,0.12);
padding: 1px 8px;
border-radius: 8px;
font-weight: 500;
}
[data-theme="light"] .member-tag { background: #e8f0f7; }
.sidebar-empty {
padding: 30px 16px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
/* Sidebar footer — height aligned with input area */
.sidebar-footer {
height: var(--footer-height);
min-height: var(--footer-height);
display: flex;
align-items: center;
gap: 6px;
padding: 0 16px;
border-top: 1px solid var(--sidebar-header-border);
font-size: 12px;
color: var(--text-secondary);
flex-shrink: 0;
}
.footer-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.footer-dot.online { background: var(--online-green); }
.footer-dot.offline { background: var(--red); }
.footer-dot.connecting { background: #f59e0b; animation: pulse 1s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
/* ═══════════════ Main (Chat) ═══════════════ */
.main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: var(--chat-bg);
position: relative;
}
/* Chat header — height aligned with sidebar header */
.chat-header {
height: var(--header-height);
min-height: var(--header-height);
background: var(--header-bg);
border-bottom: 1px solid var(--header-border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
flex-shrink: 0;
}
.chat-header-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.chat-header-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.chat-header-info { line-height: 1.2; min-width: 0; }
.chat-header-name {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-header-status {
font-size: 11px;
color: var(--text-secondary);
}
.chat-header-right {
display: flex;
gap: 6px;
align-items: center;
flex-shrink: 0;
}
.header-btn {
width: 34px;
height: 34px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text-secondary);
font-size: 17px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background var(--transition);
}
.header-btn:hover {
background: var(--sidebar-hover);
color: var(--text-primary);
}
.header-btn:active { transform: scale(0.9); }
/* ── Messages ── */
#chatLog {
flex: 1;
overflow-y: auto;
padding: 12px 16px 4px;
display: flex;
flex-direction: column;
gap: 3px;
}
#chatLog::-webkit-scrollbar { width: 5px; }
#chatLog::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.05); border-radius: 5px; }
[data-theme="light"] #chatLog::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.06); }
#emptyTip {
text-align: center;
color: var(--text-muted);
padding: 80px 0;
user-select: none;
}
#emptyTip .empty-icon { font-size: 34px; margin-bottom: 8px; opacity: 0.25; }
#emptyTip .empty-t1 { font-size: 14px; }
#emptyTip .empty-t2 { font-size: 12px; margin-top: 4px; opacity: 0.6; }
/* ── Bubbles ── */
.msg-item {
display: flex;
flex-direction: column;
max-width: 80%;
animation: inMsg 0.15s ease;
}
@keyframes inMsg { from{opacity:0;transform:translateY(4px)} to{opacity:1;transform:translateY(0)} }
.msg-item.self { align-self: flex-start; align-items: flex-start; }
.msg-item.other { align-self: flex-start; align-items: flex-start; }
.msg-item.system { align-self: center; align-items: center; max-width: 94%; }
.msg-row {
display: flex;
align-items: flex-end;
gap: 6px;
max-width: 100%;
}
.msg-item.self .msg-row,
.msg-item.other .msg-row { flex-direction: row; }
.msg-avatar {
width: 26px;
height: 26px;
border-radius: 50%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
color: #fff;
opacity: 0.9;
}
.msg-body { min-width: 0; }
.msg-sender {
font-size: 11px;
font-weight: 600;
color: var(--accent);
margin-bottom: 1px;
padding-left: 2px;
}
.msg-item.self .msg-sender { display: block; }
.msg-bubble {
padding: 6px 12px;
border-radius: var(--radius-lg);
font-size: 14px;
line-height: 1.5;
word-wrap: break-word;
white-space: pre-wrap;
box-shadow: var(--shadow);
}
.msg-item.self .msg-bubble {
background: var(--bubble-other);
color: var(--bubble-other-text);
border-bottom-left-radius: 3px;
border-left: 3px solid var(--accent);
}
.msg-item.other .msg-bubble {
background: var(--bubble-other);
color: var(--bubble-other-text);
border-bottom-left-radius: 3px;
}
.msg-item.system .msg-bubble {
background: transparent;
color: var(--text-muted);
font-size: 12px;
text-align: center;
padding: 1px 6px;
box-shadow: none;
}
.msg-time {
font-size: 10px;
color: var(--text-muted);
margin-top: 1px;
padding: 0 4px;
}
.msg-item.self .msg-time,
.msg-item.other .msg-time { text-align: left; }
/* Media */
.msg-media {
max-width: 200px;
max-height: 230px;
border-radius: var(--radius-md);
cursor: pointer;
display: block;
transition: opacity 0.15s;
margin: -1px 0;
}
.msg-media:hover { opacity: 0.92; }
video.msg-media { cursor: default; }
video.msg-media:hover { opacity: 1; }
.preview-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.9);
z-index: 9999;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
animation: ovIn 0.15s ease;
}
@keyframes ovIn { from{opacity:0} to{opacity:1} }
.preview-overlay img, .preview-overlay video {
max-width: 92vw; max-height: 92vh;
object-fit: contain;
border-radius: var(--radius-md);
}
/* ── Input Area — height aligned with sidebar footer ── */
.input-area {
height: var(--footer-height);
min-height: var(--footer-height);
background: var(--input-bg);
border-top: 1px solid var(--header-border);
display: flex;
align-items: center;
gap: 6px;
padding: 0 12px;
flex-shrink: 0;
}
.input-wrap {
flex: 1;
display: flex;
align-items: center;
background: var(--input-inner);
border: 1px solid transparent;
border-radius: var(--radius-lg);
padding: 0 2px 0 0;
transition: border-color var(--transition), background var(--transition);
height: 36px;
}
.input-wrap:focus-within {
border-color: var(--input-focus-border);
background: var(--input-bg);
}
#messageInput {
flex: 1;
background: transparent;
border: none;
color: var(--text-primary);
font-size: 14px;
padding: 0 12px;
outline: none;
font-family: inherit;
line-height: 36px;
height: 36px;
overflow: hidden;
resize: none;
}
#messageInput::placeholder { color: var(--text-muted); }
.btn {
width: 34px;
height: 34px;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 17px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
color: var(--text-secondary);
transition: all var(--transition);
flex-shrink: 0;
}
.btn:hover { background: var(--sidebar-hover); color: var(--text-primary); }
.btn:active { transform: scale(0.88); }
.btn-send {
background: var(--accent);
color: #fff;
font-size: 16px;
}
.btn-send:hover { background: var(--accent-hover); color: #fff; }
.btn-send:active { transform: scale(0.88); }
#fileInput { display: none; }
.upload-progress {
height: 2px;
background: transparent;
display: none;
flex-shrink: 0;
}
.upload-progress.active { display: block; }
.upload-progress-bar {
height: 100%;
width: 0%;
background: var(--accent);
transition: width 0.3s;
}
/* ── Responsive ── */
@media (max-width: 700px) {
.sidebar { display: none; }
.msg-item { max-width: 92%; }
.msg-media { max-width: 140px; max-height: 170px; }
#chatLog { padding: 8px 8px 2px; }
}
</style>
</head>
<body>
<div id="app">
<!-- ═══ Sidebar ═══ -->
<div class="sidebar">
<div class="sidebar-header">
<div class="sidebar-title">
<span class="sidebar-title-icon">💬</span>ChatHub
</div>
<span class="sidebar-badge" id="sidebarBadge">0</span>
</div>
<div class="sidebar-search">
<input class="search-input" placeholder="搜索在线成员..." readonly>
</div>
<div class="sidebar-section">在线成员</div>
<div class="member-list" id="memberList">
<div class="sidebar-empty" id="memberEmpty">🫧 暂无在线成员</div>
</div>
<div class="sidebar-footer">
<span class="footer-dot offline" id="footerDot"></span>
<span id="footerText">连接中...</span>
</div>
</div>
<!-- ═══ Main ═══ -->
<div class="main">
<!-- Chat Header -->
<div class="chat-header">
<div class="chat-header-left">
<div class="chat-header-avatar">💬</div>
<div class="chat-header-info">
<div class="chat-header-name">无痕聊天室</div>
<div class="chat-header-status" id="headerStatus">🟡 连接中...</div>
</div>
</div>
<div class="chat-header-right">
<span class="header-btn" id="themeToggle" title="切换主题">🌙</span>
</div>
</div>
<!-- Messages -->
<div id="chatLog">
<div id="emptyTip">
<div class="empty-icon">🫧</div>
<div class="empty-t1">聊天记录为空</div>
<div class="empty-t2">发出一条消息开始聊天吧</div>
</div>
</div>
<!-- Upload Progress -->
<div class="upload-progress" id="uploadProgress">
<div class="upload-progress-bar" id="uploadProgressBar"></div>
</div>
<!-- Input -->
<div class="input-area">
<input type="file" id="fileInput" accept="image/*,video/*">
<div class="input-wrap">
<textarea id="messageInput" placeholder="输入消息..." rows="1" maxlength="2000"></textarea>
</div>
<button class="btn" id="uploadBtn" title="发送图片/视频">📎</button>
<button class="btn btn-send" id="sendBtn"></button>
</div>
</div>
</div>
<script>
(function() {
'use strict';
// ── Username ────────────────────────────────────────
var username = localStorage.getItem('chat_username');
if (!username) {
username = prompt('请输入你的昵称:') || ('匿名' + Math.floor(Math.random() * 9999));
username = username.trim().slice(0, 20);
localStorage.setItem('chat_username', username);
}
// ── Theme ───────────────────────────────────────────
var themeToggle = document.getElementById('themeToggle');
var currentTheme = localStorage.getItem('chat_theme') || 'dark';
document.documentElement.setAttribute('data-theme', currentTheme);
themeToggle.textContent = currentTheme === 'dark' ? '🌙' : '☀️';
themeToggle.onclick = function() {
var next = currentTheme === 'dark' ? 'light' : 'dark';
currentTheme = next;
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('chat_theme', next);
themeToggle.textContent = next === 'dark' ? '🌙' : '☀️';
};
// ── DOM ─────────────────────────────────────────────
var chatLog = document.getElementById('chatLog');
var emptyTip = document.getElementById('emptyTip');
var memberList = document.getElementById('memberList');
var emptyMsg = document.getElementById('memberEmpty');
var sidebarBadge = document.getElementById('sidebarBadge');
var footerTxt = document.getElementById('footerText');
var footerDot = document.getElementById('footerDot');
var headerSt = document.getElementById('headerStatus');
var msgInput = document.getElementById('messageInput');
var sendBtn = document.getElementById('sendBtn');
var upBtn = document.getElementById('uploadBtn');
var fileInp = document.getElementById('fileInput');
var progEl = document.getElementById('uploadProgress');
var progBar = document.getElementById('uploadProgressBar');
// ── Helpers ─────────────────────────────────────────
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function setStatus(state) {
if (state === 'connected') {
headerSt.textContent = '🟢 已连接';
footerDot.className = 'footer-dot online';
footerTxt.textContent = '已连接';
} else if (state === 'connecting') {
headerSt.textContent = '🟡 连接中...';
footerDot.className = 'footer-dot connecting';
footerTxt.textContent = '连接中...';
} else {
headerSt.textContent = '🔴 已断开';
footerDot.className = 'footer-dot offline';
footerTxt.textContent = '已断开,重连中...';
}
}
// ── Append message ─────────────────────────────────
function appendMsg(cls, sender, html, ts) {
if (emptyTip && emptyTip.parentNode) emptyTip.style.display = 'none';
var d = document.createElement('div');
d.className = 'msg-item ' + cls;
var t = ts ? '<div class="msg-time">' + esc(ts) + '</div>' : '';
if (cls === 'system') {
d.innerHTML = '<div class="msg-bubble">' + html + '</div>';
} else {
var nameTag = cls === 'self' ? '<span class="member-tag" style="font-size:10px;margin-left:4px;vertical-align:middle;">我</span>' : '';
d.innerHTML =
'<div class="msg-row">' +
'<div class="msg-avatar" style="background:' + avColor(sender) + '">' + avL(sender) + '</div>' +
'<div class="msg-body">' +
'<div class="msg-sender">' + esc(sender) + nameTag + '</div>' +
'<div class="msg-bubble">' + html + '</div>' + t +
'</div>' +
'</div>';
}
chatLog.appendChild(d);
chatLog.scrollTop = chatLog.scrollHeight;
}
function avL(n) { return (n&&n[0])?n[0].toUpperCase():'?'; }
var _colors = ['#5f9cea','#e17076','#7bc862','#e5c441','#65aadd','#a695e7','#ee7a6f','#6bc9b6','#f0906f','#b0a0e0'];
function avColor(n) {
var h=0; for(var i=0;i<n.length;i++) h=((h<<5)-h)+n.charCodeAt(i)|0;
return _colors[Math.abs(h)%_colors.length];
}
// ── WebSocket ───────────────────────────────────────
var ws = null, rt = null, wsc = false;
function connect() {
if(ws&&(ws.readyState===1||ws.readyState===0)) return;
setStatus('connecting');
var p = location.protocol==='https:'?'wss':'ws';
ws = new WebSocket(p+'://'+location.host+'/wschat');
ws.onopen = function(){ ws.send(username); setStatus('connected'); wsc=true; refresh(); };
ws.onmessage = function(e){ handle(e.data); };
ws.onclose = function(){ wsc=false; setStatus('offline'); if(rt)clearTimeout(rt); rt=setTimeout(connect,2000); };
}
function handle(msg) {
if(msg.indexOf('COUNT::')===0) {
var c = parseInt(msg.substring(6),10)||0;
sidebarBadge.textContent = c;
return;
}
if(msg.indexOf('MEMBERS::')===0) {
try { var p=JSON.parse(msg.substring(9)); if(p.type==='members') render(p.data||[]); } catch(e){}
return;
}
var c1=msg.indexOf('::'); if(c1<0)return;
var tp=msg.substring(0,c1), bd=msg.substring(c1+2);
if(tp==='SYSTEM') { appendMsg('system','',esc(bd),''); refresh(); }
else if(tp==='TEXT') { var c2=bd.indexOf('::'); if(c2<0)return; appendMsg(bd.substring(0,c2)===username?'self':'other',bd.substring(0,c2),esc(bd.substring(c2+2)),''); }
else if(tp==='IMG') { var c2=bd.indexOf('::'); if(c2<0)return; appendMsg(bd.substring(0,c2)===username?'self':'other',bd.substring(0,c2),'<img class="msg-media" src="'+bd.substring(c2+2)+'" onclick="previewMedia(this.src)" loading="lazy">',''); }
else if(tp==='VIDEO') { var c2=bd.indexOf('::'); if(c2<0)return; appendMsg(bd.substring(0,c2)===username?'self':'other',bd.substring(0,c2),'<video class="msg-media" src="'+bd.substring(c2+2)+'" controls preload="metadata"></video>',''); }
}
// ── Member list ─────────────────────────────────────
function render(members) {
memberList.innerHTML = '';
if(!members||!members.length) {
memberList.innerHTML = '<div class="sidebar-empty">🫧 暂无在线成员</div>';
sidebarBadge.textContent = '0';
return;
}
sidebarBadge.textContent = members.length;
var s = members.slice().sort(function(a,b){ if(a===username)return-1;if(b===username)return1;return a.localeCompare(b); });
for(var i=0;i<s.length;i++) {
var n=s[i], me=n===username;
var d=document.createElement('div'); d.className='member-item';
d.innerHTML =
'<div class="member-avatar" style="background:'+avColor(n)+'">'+
avL(n)+'<span class="member-avatar-dot"></span></div>'+
'<div class="member-info">'+
'<div class="member-name">'+esc(n)+'</div>'+
'<div class="member-sub">'+(me?'在线 (我)':'在线')+'</div></div>'+
(me?'<span class="member-tag">我</span>':'');
memberList.appendChild(d);
}
}
var f = false;
function refresh(){ if(f)return; f=true; fetch('/members').then(function(r){return r.ok?r.json():Promise.reject();}).then(function(d){render(d.members||[]);}).catch(function(){}).finally(function(){f=false;}); }
setInterval(function(){if(wsc)refresh();},5000);
// ── Send ────────────────────────────────────────────
function send(t){ if(!t||!t.trim())return false; if(!ws||ws.readyState!==1){appendMsg('system','','⚠️ 连接已断开','');return false;} ws.send(t.trim()); return true; }
sendBtn.onclick = function(){ var v=msgInput.value; if(send(v)){msgInput.value='';msgInput.style.height='36px';} };
msgInput.addEventListener('keydown',function(e){
if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); var v=msgInput.value; if(send(v)){msgInput.value='';msgInput.style.height='36px';} }
});
// ── Upload ──────────────────────────────────────────
upBtn.onclick = function(){ fileInp.click(); };
fileInp.addEventListener('change',function(){
var f=this.files[0]; if(!f)return;
if(f.size>10*1024*1024){ appendMsg('system','','⚠️ 文件过大('+(f.size/1024/1024).toFixed(1)+'MB最大 10MB',''); fileInp.value=''; return; }
var fd=new FormData(); fd.append('file',f); fd.append('sender',username);
progEl.classList.add('active'); progBar.style.width='25%';
fetch('/upload',{method:'POST',body:fd})
.then(function(r){ if(!r.ok)return r.json().then(function(d){throw new Error(d.error||'上传失败');}); return r.json(); })
.then(function(){ progBar.style.width='100%'; })
.catch(function(e){ progBar.style.width='100%'; appendMsg('system','','⚠️ '+esc(e.message),''); })
.finally(function(){ setTimeout(function(){progEl.classList.remove('active');progBar.style.width='0%';},600); fileInp.value=''; });
});
// ── Preview ─────────────────────────────────────────
window.previewMedia = function(s){
var o=document.createElement('div'); o.className='preview-overlay';
var e=s.match(/\.(png|jpg|jpeg|gif|webp|bmp)(\?|$)/i)?(function(){var i=new Image();i.src=s;return i;})():(function(){var v=document.createElement('video');v.src=s;v.controls=true;v.autoplay=true;return v;})();
o.appendChild(e); o.onclick=function(){document.body.removeChild(o);}; document.body.appendChild(o);
};
// ── Init ────────────────────────────────────────────
connect();
refresh();
})();
</script>
</body>
</html>