Spaces:
Running
Running
| <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 ; } | |
| .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 |