anycoder-a5a3f55d / index.html
samirerty's picture
Upload folder using huggingface_hub
f1bc4d6 verified
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<title>چت روم مینیمال</title>
<meta name="color-scheme" content="light dark">
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;700;900&display=swap" rel="stylesheet" />
<style>
:root{
--bg: #0f172a;
--bg-2: #0b1222;
--text: #e5e7eb;
--muted: #94a3b8;
--primary: #60a5fa;
--primary-2: #3b82f6;
--accent: #22d3ee;
--danger: #ef4444;
--success: #10b981;
--warning: #f59e0b;
--card: rgba(255,255,255,.06);
--card-2: rgba(255,255,255,.08);
--border: rgba(255,255,255,.12);
--shadow: 0 10px 30px rgba(0,0,0,.35);
--radius-lg: 16px;
--radius: 12px;
--radius-sm: 8px;
--pad: 16px;
--pad-sm: 10px;
--pad-xs: 6px;
--trans-fast: 140ms cubic-bezier(.2,.7,.2,1);
--trans: 220ms cubic-bezier(.2,.7,.2,1);
--trans-slow: 340ms cubic-bezier(.2,.7,.2,1);
--glass-blur: 16px;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f3f4f6;
--bg-2: #e5e7eb;
--text: #111827;
--muted: #6b7280;
--card: rgba(255,255,255,.75);
--card-2: rgba(255,255,255,.9);
--border: rgba(17,24,39,.1);
--shadow: 0 8px 28px rgba(0,0,0,.08);
}
}
* { box-sizing: border-box; }
html,body { height: 100%; }
body {
margin: 0;
font-family: "Vazirmatn", ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji","Segoe UI Emoji";
background: radial-gradient(1200px 800px at 20% -20%, rgba(59,130,246,.18), transparent 60%),
radial-gradient(1000px 700px at 120% 10%, rgba(34,211,238,.12), transparent 60%),
var(--bg);
color: var(--text);
overflow: hidden;
}
.app {
height: 100%;
display: grid;
grid-template-rows: auto 1fr;
}
.glass {
background: var(--card);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--border);
box-shadow: var(--shadow);
}
header.app-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 16px;
position: sticky;
top: 0;
z-index: 20;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.logo {
width: 36px; height: 36px;
display: grid; place-items: center;
border-radius: 10px;
background: linear-gradient(135deg, var(--primary), var(--accent));
color: white;
font-weight: 900;
box-shadow: 0 8px 20px rgba(59,130,246,.35);
user-select: none;
}
.brand h1 {
margin: 0; font-size: 16px; font-weight: 700; letter-spacing: -.2px;
}
.brand small { color: var(--muted); display: block; font-size: 12px; font-weight: 400; }
.header-actions {
display: flex; align-items: center; gap: 8px;
}
.icon-btn {
width: 38px; height: 38px; display: grid; place-items: center;
border-radius: 12px; border: 1px solid var(--border);
background: var(--card);
color: var(--text);
cursor: pointer;
transition: transform var(--trans-fast), background var(--trans-fast), border-color var(--trans-fast), opacity var(--trans-fast);
}
.icon-btn:hover { transform: translateY(-1px); background: var(--card-2); }
.icon-btn:active { transform: translateY(0); }
.chip {
border-radius: 12px; padding: 6px 10px; font-size: 12px; background: var(--card-2); border: 1px solid var(--border); color: var(--muted);
}
.layout {
display: grid;
grid-template-columns: 320px 1fr;
gap: 16px;
padding: 16px;
height: calc(100vh - 64px);
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
height: calc(100vh - 64px);
padding: 10px;
gap: 10px;
}
aside.sidebar { order: 2; }
main.chat { order: 1; }
}
aside.sidebar {
border-radius: var(--radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
.sidebar-header {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--border);
}
.rooms {
overflow: auto; padding: 10px; display: grid; gap: 10px;
scrollbar-width: thin;
}
.room-card {
border-radius: 14px;
padding: 12px;
border: 1px solid var(--border);
background: linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.02));
display: grid; gap: 8px;
cursor: pointer;
transition: transform var(--trans-fast), border-color var(--trans-fast), background var(--trans-fast);
}
.room-card:hover { transform: translateY(-2px); border-color: rgba(255,255,255,.18); }
.room-card.active { outline: 2px solid var(--primary); }
.room-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.room-title { font-weight: 700; }
.room-meta { color: var(--muted); font-size: 12px; }
.room-actions { display: flex; gap: 6px; }
.btn {
padding: 8px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--card);
color: var(--text);
cursor: pointer;
font-weight: 600;
transition: background var(--trans-fast), transform var(--trans-fast), border-color var(--trans-fast), color var(--trans-fast);
}
.btn:hover { background: var(--card-2); transform: translateY(-1px); }
.btn:active { transform: translateY(0); }
.btn.primary {
background: linear-gradient(135deg, var(--primary), var(--accent));
color: white; border: none;
box-shadow: 0 10px 24px rgba(59,130,246,.35);
}
.btn.ghost { background: transparent; }
.btn.danger { background: linear-gradient(135deg, #ef4444, #f97316); color: white; border: none; }
.btn.success { background: linear-gradient(135deg, #10b981, #22d3ee); color: white; border: none; }
.btn.small { padding: 6px 10px; font-size: 12px; }
main.chat {
border-radius: var(--radius-lg);
display: grid; grid-template-rows: auto 1fr auto;
overflow: hidden;
min-height: 0;
}
.chat-header {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 10px; justify-content: space-between;
}
.chat-id { color: var(--muted); font-size: 12px; }
.chat-title { font-weight: 800; letter-spacing: -.2px; }
.chat-tools { display: flex; align-items: center; gap: 6px; }
.messages {
overflow: auto;
padding: 14px;
display: grid; gap: 8px;
background: radial-gradient(1200px 700px at 0% 0%, rgba(59,130,246,.08), transparent 50%),
radial-gradient(900px 500px at 100% 100%, rgba(34,211,238,.08), transparent 55%);
}
.day-sep {
text-align: center;
color: var(--muted);
font-size: 12px;
margin: 8px 0;
}
.msg {
display: grid; gap: 6px;
max-width: min(82%, 720px);
position: relative;
}
.msg.me { justify-self: end; }
.msg.other { justify-self: start; }
.bubble {
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--border);
background: var(--card);
backdrop-filter: blur(10px);
box-shadow: 0 6px 14px rgba(0,0,0,.18);
line-height: 1.9;
word-wrap: break-word;
white-space: pre-wrap;
}
.msg.me .bubble {
background: linear-gradient(180deg, rgba(96,165,250,.22), rgba(34,211,238,.18));
border-color: rgba(59,130,246,.35);
}
.meta {
display: flex; align-items: center; gap: 8px;
color: var(--muted); font-size: 12px;
}
.avatar {
width: 28px; height: 28px; border-radius: 50%;
display: grid; place-items: center;
color: white; font-weight: 800; font-size: 12px;
box-shadow: 0 4px 10px rgba(0,0,0,.25);
user-select: none;
}
.reply-preview {
border-right: 3px solid var(--primary);
padding-right: 8px;
margin-bottom: 8px;
color: var(--muted);
font-size: 13px;
}
.reply-preview .who { font-weight: 700; color: var(--text); }
.reactions {
display: flex; gap: 4px; flex-wrap: wrap;
}
.reaction-pill {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 8px; border-radius: 999px;
border: 1px solid var(--border);
background: rgba(255,255,255,.06);
font-size: 12px; cursor: default;
}
.composer {
padding: 10px;
border-top: 1px solid var(--border);
display: grid; gap: 8px;
}
.replying-to {
display: none;
align-items: center; gap: 8px;
padding: 8px 10px;
border-radius: 10px;
background: rgba(96,165,250,.14);
border: 1px dashed rgba(59,130,246,.35);
color: var(--muted);
font-size: 13px;
}
.replying-to.show { display: flex; }
.replying-to .x { margin-inline-start: auto; cursor: pointer; }
.input-row {
display: grid; grid-template-columns: auto 1fr auto; gap: 8px; align-items: end;
}
textarea.input {
width: 100%;
min-height: 44px; max-height: 140px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
outline: none;
color: var(--text);
background: var(--card);
resize: none;
overflow: auto;
transition: border-color var(--trans-fast), background var(--trans-fast), box-shadow var(--trans-fast);
box-shadow: inset 0 1px 0 rgba(255,255,255,.05);
}
textarea.input:focus {
border-color: rgba(59,130,246,.6);
box-shadow: 0 0 0 6px rgba(59,130,246,.08);
background: var(--card-2);
}
.send-btn {
height: 44px; aspect-ratio: 1/1;
border-radius: 12px;
border: none; cursor: pointer;
color: white;
background: linear-gradient(135deg, var(--primary), var(--accent));
display: grid; place-items: center;
box-shadow: 0 10px 24px rgba(59,130,246,.35);
transition: transform var(--trans-fast), filter var(--trans-fast);
}
.send-btn:active { transform: scale(.98); filter: saturate(1.1); }
.muted {
color: var(--muted);
font-size: 13px;
text-align: center;
padding: 12px;
}
.empty {
display: grid; place-items: center; height: 100%;
color: var(--muted);
}
/* Modals */
.modal {
position: fixed; inset: 0; display: none; place-items: center; z-index: 50;
background: rgba(2,6,23,.55);
backdrop-filter: blur(6px);
padding: 20px;
}
.modal.show { display: grid; }
.modal-card {
width: min(520px, 92vw);
border-radius: 16px;
padding: 16px;
border: 1px solid var(--border);
background: var(--card);
box-shadow: var(--shadow);
}
.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.modal-title { font-weight: 800; }
.close-x { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 10px; cursor: pointer; border: 1px solid var(--border); }
.form {
display: grid; gap: 12px;
}
.field { display: grid; gap: 6px; }
label { font-size: 13px; color: var(--muted); }
input[type="text"], input[type="tel"], input[type="number"] {
width: 100%;
padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border);
background: var(--card-2); color: var(--text); outline: none;
transition: border-color var(--trans-fast), box-shadow var(--trans-fast);
}
input:focus {
border-color: rgba(59,130,246,.6);
box-shadow: 0 0 0 6px rgba(59,130,246,.08);
}
.row { display: flex; align-items: center; gap: 10px; }
.grow { flex: 1; }
.otp-inputs { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }
.footer-note { color: var(--muted); font-size: 12px; text-align: center; margin-top: 6px; }
/* Bottom sheet for reactions */
.sheet {
position: fixed; left: 0; right: 0; bottom: 0;
background: var(--card);
border-top-left-radius: 16px; border-top-right-radius: 16px;
border: 1px solid var(--border);
transform: translateY(110%);
transition: transform var(--trans);
z-index: 40;
padding: 10px;
box-shadow: 0 -20px 40px rgba(0,0,0,.25);
}
.sheet.show { transform: translateY(0); }
.sheet-head { display: flex; align-items: center; justify-content: space-between; padding: 4px 6px 10px; }
.emoji-grid {
display: grid; grid-template-columns: repeat(8, 1fr); gap: 10px;
padding: 8px 4px 16px;
max-height: 46vh; overflow: auto;
}
.emoji-btn {
font-size: 26px; padding: 6px; border-radius: 10px; cursor: pointer; border: 1px solid var(--border); background: transparent;
display: grid; place-items: center;
transition: transform var(--trans-fast), background var(--trans-fast);
}
.emoji-btn:hover { transform: translateY(-2px); background: rgba(255,255,255,.06); }
/* Toasts */
.toasts {
position: fixed; bottom: 14px; left: 50%; transform: translateX(-50%);
display: grid; gap: 8px; z-index: 60;
}
.toast {
background: var(--card);
border: 1px solid var(--border);
color: var(--text);
padding: 10px 12px;
border-radius: 12px;
box-shadow: var(--shadow);
animation: toast-in var(--trans) ease;
font-size: 13px;
}
@keyframes toast-in {
from { transform: translate(-50%, 8px); opacity: 0; }
to { transform: translate(-50%, 0); opacity: 1; }
}
/* Utility */
.hidden { display: none !important; }
.sep { height: 1px; background: var(--border); margin: 8px 0; }
.kbd { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; background: rgba(255,255,255,.06); padding: 2px 6px; border-radius: 6px; border: 1px solid var(--border); }
a { color: var(--primary-2); text-decoration: none; }
a:hover { text-decoration: underline; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.center { text-align: center; }
.right { text-align: right; }
.nowrap { white-space: nowrap; }
.pointer { cursor: pointer; }
/* Built with */
.built-with {
position: fixed; top: 8px; left: 50%; transform: translateX(-50%);
z-index: 80; font-size: 12px; color: var(--muted);
background: rgba(0,0,0,.25);
border: 1px solid var(--border);
padding: 4px 8px; border-radius: 999px; backdrop-filter: blur(6px);
}
.built-with a { color: var(--muted); text-decoration: none; }
.built-with a:hover { color: var(--text); text-decoration: underline; }
/* Scrollbar */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,.15); border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.25); }
</style>
</head>
<body>
<div class="built-with">Built with <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" rel="noopener">anycoder</a></div>
<div class="app">
<header class="app-header glass">
<div class="brand">
<div class="logo" aria-hidden="true">چ</div>
<div>
<h1>چت روم مینیمال <small>Glassmorphism + فارسی</small></h1>
</div>
</div>
<div class="header-actions">
<span class="chip" id="onlineStatus">آماده</span>
<button class="icon-btn" id="btnNewRoom" title="اتاق جدید">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
<button class="icon-btn" id="btnAuth" title="پروفایل">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5Zm0 2c-5 0-9 2.5-9 5.5V22h18v-2.5C21 16.5 17 14 12 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
</header>
<div class="layout">
<aside class="sidebar glass">
<div class="sidebar-header">
<div class="row">
<strong>اتاق‌ها</strong>
<span class="chip" id="roomsCount">0/3</span>
</div>
<div class="row">
<button class="btn small" id="btnCreateRoom">ساخت اتاق</button>
</div>
</div>
<div class="rooms" id="roomsList">
<!-- Rooms injected -->
</div>
</aside>
<main class="chat glass">
<div class="chat-header">
<div class="row">
<div class="avatar" id="roomAvatar">R</div>
<div>
<div class="chat-title" id="roomTitle">اتاقی انتخاب نشده</div>
<div class="chat-id mono" id="roomIdLabel"></div>
</div>
</div>
<div class="chat-tools">
<button class="icon-btn" id="btnCopyLink" title="کپی لینک اتاق">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M8 12a4 4 0 0 1 4-4h3a4 4 0 0 1 4 4v3a4 4 0 0 1-4 4h-3a4 4 0 0 1-4-4v-3Z" stroke="currentColor" stroke-width="2"/><path d="M16 8a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v3a4 4 0 0 0 4 4h3" stroke="currentColor" stroke-width="2"/></svg>
</button>
<button class="icon-btn" id="btnLeaveRoom" title="ترک اتاق">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M15 7v-2a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2v-2" stroke="currentColor" stroke-width="2"/><path d="M10 12h10M15 9l3-3-3-3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
<button class="icon-btn" id="btnDeleteRoom" title="حذف اتاق">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M4 7h16M9 7v-.5A1.5 1.5 0 0 1 10.5 5h3A1.5 1.5 0 0 1 15 6.5V7m-9 0v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7" stroke="currentColor" stroke-width="2"/><path d="M10 11v6M14 11v6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
</div>
<div class="messages" id="messages">
<div class="empty" id="emptyChat">
<div>
<div class="center" style="font-size:18px; font-weight:800; margin-bottom:6px;">هنوز پیامی ارسال نشده</div>
<div class="center" style="color:var(--muted)">برای شروع، یک اتاق بسازید یا به اتاقی بپیوندید.</div>
</div>
</div>
</div>
<div class="composer">
<div class="replying-to" id="replyingTo">
<span id="replyingText"></span>
<span class="x" id="cancelReply" title="حذف پاسخ"></span>
</div>
<div class="input-row">
<button class="icon-btn" id="btnEmoji" title="واکنش‌ها">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"/><circle cx="9" cy="10" r="1.2" fill="currentColor"/><circle cx="15" cy="10" r="1.2" fill="currentColor"/><path d="M8 14c.8 1.2 2.2 2 4 2s3.2-.8 4-2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
<textarea id="input" class="input" placeholder="پیام خود را بنویسید..." rows="1"></textarea>
<button class="send-btn" id="btnSend" title="ارسال">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none"><path d="M4 12l15-7-4 7 4 7-15-7Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
</button>
</div>
<div class="muted">با نگه داشت انگشت روی پیام، واکنش یا پاسخ دهید. برای حذف، سوایپ کنید.</div>
</div>
</main>
</div>
</div>
<!-- Auth Modal -->
<div class="modal" id="authModal" aria-hidden="true">
<div class="modal-card">
<div class="modal-head">
<div class="modal-title">ورود با شماره موبایل</div>
<div class="close-x" id="closeAuth"></div>
</div>
<div class="form" id="authStepPhone">
<div class="field">
<label>شماره موبایل (ایران)</label>
<div class="row">
<span class="chip">+98</span>
<input class="grow" id="phoneInput" type="tel" inputmode="tel" placeholder="912 123 4567" />
</div>
</div>
<div class="row">
<button class="btn primary" id="btnSendCode">ارسال کد</button>
<button class="btn ghost" id="btnUseStored">استفاده از شماره ذخیره‌شده</button>
</div>
<div class="footer-note">کد تأیید: 123456 (دمو)</div>
</div>
<div class="form hidden" id="authStepOtp">
<div class="field">
<label>کد ۶ رقمی</label>
<div class="otp-inputs">
<input class="otp" type="text" maxlength="1" />
<input class="otp" type="text" maxlength="1" />
<input class="otp" type="text" maxlength="1" />
<input class="otp" type="text" maxlength="1" />
<input class="otp" type="text" maxlength="1" />
<input class="otp" type="text" maxlength="1" />
</div>
</div>
<div class="row">
<button class="btn primary" id="btnVerify">ورود</button>
<button class="btn ghost" id="btnBackToPhone">بازگشت</button>
</div>
<div class="footer-note">کد تأیید: 123456</div>
</div>
</div>
</div>
<!-- Room Modal -->
<div class="modal" id="roomModal" aria-hidden="true">
<div class="modal-card">
<div class="modal-head">
<div class="modal-title">ساخت اتاق جدید</div>
<div class="close-x" id="closeRoom"></div>
</div>
<div class="form">
<div class="field">
<label>نام اتاق</label>
<input id="roomNameInput" type="text" placeholder="مثال: تیم طراحی" />
</div>
<div class="row">
<button class="btn primary" id="btnCreateRoomConfirm">ساخت اتاق</button>
<button class="btn ghost" id="btnJoinById">پیوستن با شناسه</button>
</div>
<div class="footer-note">حداکثر ۳ اتاق فعال</div>
</div>
</div>
</div>
<!-- Join Modal -->
<div class="modal" id="joinModal" aria-hidden="true">
<div class="modal-card">
<div class="modal-head">
<div class="modal-title">پیوستن به اتاق</div>
<div class="close-x" id="closeJoin"></div>
</div>
<div class="form">
<div class="field">
<label>شناسه اتاق</label>
<input id="joinIdInput" type="text" placeholder="ROOM-XXXXXXXX" />
</div>
<div class="row">
<button class="btn primary" id="btnJoinConfirm">پیوستن</button>
<button class="btn ghost" id="btnCreateInstead">ساخت اتاق جدید</button>
</div>
<div class="footer-note">لینک اتاق را کپی کنید و با دیگران به اشتراک بگذارید.</div>
</div>
</div>
</div>
<!-- Reaction Sheet -->
<div class="sheet" id="reactionSheet" aria-hidden="true">
<div class="sheet-head">
<strong>واکنش‌ها</strong>
<button class="icon-btn" id="closeSheet"></button>
</div>
<div class="emoji-grid" id="emojiGrid"></div>
</div>
<!-- Toasts -->
<div class="toasts" id="toasts"></div>
<script type="module">
// ---------- Utils ----------
const $ = (sel, root=document) => root.querySelector(sel);
const $$ = (sel, root=document) => [...root.querySelectorAll(sel)];
const sleep = (ms) => new Promise(r=>setTimeout(r, ms));
const uid = () => (crypto.randomUUID ? crypto.randomUUID() : (Date.now().toString(36)+Math.random().toString(36).slice(2)));
const now = () => Date.now();
const clamp = (n, min, max) => Math.max(min, Math.min(n, max));
const toFaDigits = (s) => s.toString().replace(/\d/g, d=>"۰۱۲۳۴۵۶۷۸۹"[d]);
const fmtTime = (ts) => {
const d = new Date(ts);
return toFaDigits(d.toLocaleTimeString('fa-IR', {hour:'2-digit', minute:'2-digit'}));
};
const fmtDateShort = (ts) => {
const d = new Date(ts);
return toFaDigits(d.toLocaleDateString('fa-IR', {month:'short', day:'numeric'}));
};
const sameDay = (a,b) => {
const da = new Date(a), db = new Date(b);
return da.getFullYear()===db.getFullYear() && da.getMonth()===db.getMonth() && da.getDate()===db.getDate();
};
const normalizePhone = (p) => (p||"").replace(/[^\d]/g, "").replace(/^0+/, "");
const displayPhone = (p) => {
const n = normalizePhone(p);
return "+98 " + n.replace(/^98/,"").replace(/^9/,"").replace(/(\d{3})(\d{3})(\d{4})/, "$1 $2 $3");
};
const colorFromString = (s) => {
let h=0; for (let i=0;i<s.length;i++) h = (h*31 + s.charCodeAt(i))>>>0;
const hue = h % 360;
return `hsl(${hue} 70% 55%)`;
};
const shareOrCopy = async (text) => {
try {
if (navigator.share) {
await navigator.share({ text });
toast("با موفقیت به اشتراک گذاشته شد");
} else {
await navigator.clipboard.writeText(text);
toast("در کلیپ‌بورد کپی شد");
}
} catch {
await navigator.clipboard.writeText(text);
toast("در کلیپ‌بورد کپی شد");
}
};
const copyToClipboard = async (text) => {
try { await navigator.clipboard.writeText(text); toast("کپی شد"); } catch { toast("خطا در کپی"); }
};
const toast = (msg, timeout=2200) => {
const wrap = $('#toasts');
const el = document.createElement('div');
el.className = 'toast';
el.textContent = msg;
wrap.appendChild(el);
setTimeout(()=>{ el.style.opacity = '0'; el.style.transform = 'translate(-50%, 8px)'; }, timeout);
setTimeout(()=>{ el.remove(); }, timeout+400);
};
// ---------- Store (LocalStorage) ----------
const LS = {
get(key, def=null) { try { const v = localStorage.getItem(key); return v ? JSON.parse(v) : def; } catch { return def; } },
set(key, val) { localStorage.setItem(key, JSON.stringify(val)); },
del(key) { localStorage.removeItem(key); },
};
const KEYS = {
auth: 'mini_chat_auth',
rooms: 'mini_chat_rooms',
selectedRoom: 'mini_chat_selected_room',
};
// ---------- Auth ----------
const Auth = {
get() { return LS.get(KEYS.auth, null); },
set(user) { LS.set(KEYS.auth, user); },
clear() { LS.del(KEYS.auth); },
ensure() { return !!this.get(); },
};
// ---------- Broadcast bus (multi-tab) ----------
const Bus = (() => {
let bc = null;
try { bc = new BroadcastChannel('mini-chat-v1'); } catch {}
const listeners = new Map();
const on = (type, fn) => { listeners.set(type, fn); };
const off = (type) => { listeners.delete(type); };
const emitLocal = (type, payload) => {
const fn = listeners.get(type);
if (fn) fn(payload);
};
const send = (type, payload) => {
payload = {...payload, __bus__: true, t: now()};
if (bc) bc.postMessage({type, payload});
// local fallback
try {
localStorage.setItem('__mini_bus__', JSON.stringify({type, payload, ts: now()}));
// cleanup minimal
setTimeout(()=>localStorage.removeItem('__mini_bus__'), 0);
} catch {}
};
if (bc) bc.onmessage = (e) => {
const {type, payload} = e.data || {};
const fn = listeners.get(type);
if (fn) fn(payload);
};
window.addEventListener('storage', (e)=>{
if (e.key === '__mini_bus__' && e.newValue) {
try {
const {type, payload} = JSON.parse(e.newValue);
const fn = listeners.get(type);
if (fn) fn(payload);
} catch {}
}
});
return { on, off, send };
})();
// ---------- Rooms Manager ----------
const Rooms = {
all() { return LS.get(KEYS.rooms, []); },
save(list) { LS.set(KEYS.rooms, list); },
add(room) {
const list = this.all();
if (list.length >= 3) { toast("حداکثر ۳ اتاق مجاز است"); return false; }
if (list.some(r