diff --git a/app/static/Vazirmatn[wght].woff2 b/app/static/Vazirmatn[wght].woff2 new file mode 100644 index 0000000..a501289 Binary files /dev/null and b/app/static/Vazirmatn[wght].woff2 differ diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..edc600d --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,168 @@ +const usernameInput = document.querySelector('#username'); +const roomInput = document.querySelector('#room'); +const connectBtn = document.querySelector('#connectBtn'); +const disconnectBtn = document.querySelector('#disconnectBtn'); +const clearBtn = document.querySelector('#clearBtn'); +const messageForm = document.querySelector('#messageForm'); +const messageInput = document.querySelector('#messageInput'); +const sendBtn = document.querySelector('#sendBtn'); +const messages = document.querySelector('#messages'); +const statusPill = document.querySelector('#connectionStatus'); +const usersList = document.querySelector('#usersList'); +const roomTitle = document.querySelector('#roomTitle'); +const roomSubtitle = document.querySelector('#roomSubtitle'); + +let socket = null; +let currentUser = ''; +let currentRoom = ''; + +usernameInput.value = localStorage.getItem('chat_username') || ''; +roomInput.value = localStorage.getItem('chat_room') || 'عمومی'; + +function setConnectedState(isConnected) { + connectBtn.disabled = isConnected; + disconnectBtn.disabled = !isConnected; + messageInput.disabled = !isConnected; + sendBtn.disabled = !isConnected; + usernameInput.disabled = isConnected; + roomInput.disabled = isConnected; + + statusPill.textContent = isConnected ? 'متصل' : 'قطع'; + statusPill.classList.toggle('connected', isConnected); +} + +function showEmptyState() { + messages.innerHTML = '
هنوز پیامی وجود ندارد.
وارد یک اتاق شوید و پیام بفرستید.
'; +} + +function clearEmptyState() { + const emptyState = messages.querySelector('.empty-state'); + if (emptyState) emptyState.remove(); +} + +function safeName(value, fallback) { + return value.trim() || fallback; +} + +function formatTime(timestamp) { + if (!timestamp) return ''; + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }); +} + +function addMessage(payload) { + clearEmptyState(); + + const card = document.createElement('article'); + card.className = 'message'; + + if (payload.type === 'system') card.classList.add('system'); + if (payload.client_id === currentUser && payload.type === 'message') card.classList.add('mine'); + + const meta = document.createElement('div'); + meta.className = 'message-meta'; + + const sender = document.createElement('span'); + sender.textContent = payload.client_id || 'ناشناس'; + + const time = document.createElement('span'); + time.textContent = formatTime(payload.timestamp); + + meta.append(sender, time); + + const content = document.createElement('div'); + content.className = 'message-content'; + content.textContent = payload.content || ''; + + card.append(meta, content); + messages.appendChild(card); + messages.scrollTop = messages.scrollHeight; +} + +function updateUsers(users = []) { + usersList.innerHTML = ''; + + if (!users.length) { + const li = document.createElement('li'); + li.className = 'muted'; + li.textContent = 'کاربری آنلاین نیست'; + usersList.appendChild(li); + return; + } + + users.forEach((user) => { + const li = document.createElement('li'); + li.textContent = user; + usersList.appendChild(li); + }); +} + +function connect() { + currentUser = safeName(usernameInput.value, 'ناشناس'); + currentRoom = safeName(roomInput.value, 'عمومی'); + + localStorage.setItem('chat_username', currentUser); + localStorage.setItem('chat_room', currentRoom); + + const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const wsUrl = `${wsProtocol}://${window.location.host}/ws/${encodeURIComponent(currentRoom)}/${encodeURIComponent(currentUser)}`; + + socket = new WebSocket(wsUrl); + + socket.addEventListener('open', () => { + setConnectedState(true); + roomTitle.textContent = `اتاق: ${currentRoom}`; + roomSubtitle.textContent = `شما با نام ${currentUser} گفتگو می‌کنید.`; + messageInput.focus(); + }); + + socket.addEventListener('message', (event) => { + const payload = JSON.parse(event.data); + + if (payload.type === 'presence') { + updateUsers(payload.users || []); + return; + } + + addMessage(payload); + }); + + socket.addEventListener('close', () => { + setConnectedState(false); + updateUsers([]); + roomSubtitle.textContent = 'ارتباط بسته شد.'; + socket = null; + }); + + socket.addEventListener('error', () => { + addMessage({ + type: 'system', + client_id: 'سیستم', + content: 'خطا در اتصال. مطمئن شوید سرور برنامه در حال اجرا است.', + timestamp: new Date().toISOString(), + }); + }); +} + +function disconnect() { + if (socket) socket.close(); +} + +connectBtn.addEventListener('click', connect); +disconnectBtn.addEventListener('click', disconnect); +clearBtn.addEventListener('click', showEmptyState); + +messageForm.addEventListener('submit', (event) => { + event.preventDefault(); + + const content = messageInput.value.trim(); + if (!content || !socket || socket.readyState !== WebSocket.OPEN) return; + + socket.send(JSON.stringify({ content })); + messageInput.value = ''; + messageInput.focus(); +}); + +showEmptyState(); +setConnectedState(false); diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..e0899e7 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,63 @@ + + + + + + چت‌روم WebSocket + + + +
+
+
+

پروژه مهندسی اینترنت

+

چت‌روم ساده با WebSocket

+

یک نمونه آموزشی برای ارسال و دریافت پیام هم‌زمان بین چند کاربر.

+
+
قطع
+
+ +
+ + +
+
+
+

اتاقی انتخاب نشده است

+

برای شروع گفتگو متصل شوید.

+
+ +
+ +
+ +
+ + +
+
+
+
+ + + + diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..f4fe510 --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,346 @@ +:root { + --bg: #f5f7fb; + --panel: #ffffff; + --text: #172033; + --muted: #667085; + --line: #d9e1ec; + --accent: #2563eb; + --accent-dark: #1d4ed8; + --danger: #b42318; + --success: #067647; +} + +@font-face { + font-family: "Vazirmatn"; + src: url("/static/Vazirmatn[wght].woff2") format("woff2"); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + height: 100vh; + font-family: "Vazirmatn", Tahoma, sans-serif; + color: var(--text); + background: var(--bg); + direction: rtl; + overflow: hidden; +} + +button, +input { + font: inherit; +} + +.shell { + width: min(1040px, calc(100% - 24px)); + margin: 0 auto; + height: 100vh; + padding: 20px 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-bottom: 18px; +} + +.eyebrow { + margin: 0 0 6px; + color: var(--accent); + font-size: 0.9rem; + font-weight: 800; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 6px; + font-size: clamp(1.8rem, 4vw, 3rem); + line-height: 1.35; +} + +.subtitle { + margin-bottom: 0; + color: var(--muted); +} + +.status-pill { + min-width: 86px; + padding: 9px 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff1f3; + color: var(--danger); + text-align: center; + white-space: nowrap; +} + +.status-pill.connected { + color: var(--success); + background: #ecfdf3; + border-color: #abefc6; +} + +.layout { + min-height: 0; + flex: 1; + display: grid; + grid-template-columns: 300px 1fr; + gap: 16px; +} + +.panel { + min-height: 0; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; +} + +.setup-panel { + padding: 18px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.setup-panel h2, +.chat-header h2 { + margin-bottom: 12px; +} + +label { + display: block; + margin: 14px 0 7px; + color: var(--muted); + font-size: 0.92rem; +} + +input { + width: 100%; + padding: 11px 12px; + border: 1px solid var(--line); + border-radius: 8px; + outline: none; + color: var(--text); + background: #ffffff; +} + +input:focus { + border-color: var(--accent); +} + +input:disabled { + opacity: 0.55; +} + +.button-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 16px; +} + +button { + border: 0; + border-radius: 8px; + padding: 11px 14px; + color: #ffffff; + background: var(--accent); + font-weight: 800; + cursor: pointer; + transition: background 0.15s ease, opacity 0.15s ease; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +button.secondary, +button.ghost { + color: var(--text); + background: #eef2f7; + border: 1px solid var(--line); +} + +button.ghost { + padding: 9px 12px; +} + +.hint { + margin-top: 16px; + padding: 12px; + border-radius: 8px; + color: #344054; + background: #eff6ff; + border: 1px solid #bfdbfe; + line-height: 1.5; +} + +.online-box { + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid var(--line); + min-height: 0; + flex: 1; + display: flex; + flex-direction: column; +} + +.online-box h3 { + margin-bottom: 10px; + font-size: 1rem; +} + +ul { + list-style: none; + padding: 0; + margin: 0; +} + +#usersList { + min-height: 0; + overflow-y: auto; +} + +li { + padding: 8px 0; + border-bottom: 1px solid #eef2f7; +} + +.muted { + color: var(--muted); +} + +.chat-panel { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.chat-header { + padding: 18px; + border-bottom: 1px solid var(--line); + display: flex; + justify-content: space-between; + align-items: center; + gap: 14px; +} + +.chat-header p { + margin-bottom: 0; + color: var(--muted); +} + +.messages { + min-height: 0; + flex: 1; + padding: 18px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 10px; + background: #fbfcfe; +} + +.empty-state { + margin: auto; + color: var(--muted); + text-align: center; + line-height: 1.6; +} + +.message { + max-width: 78%; + padding: 11px 13px; + border-radius: 8px; + background: #ffffff; + border: 1px solid var(--line); +} + +.message.mine { + align-self: flex-start; + background: #eff6ff; + border-color: #bfdbfe; +} + +.message.system { + align-self: center; + max-width: 90%; + color: #344054; + background: #f2f4f7; +} + +.message-meta { + display: flex; + justify-content: space-between; + gap: 14px; + margin-bottom: 5px; + color: var(--muted); + font-size: 0.78rem; + font-weight: 800; +} + +.message-content { + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: 1.45; +} + +.message-form { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + padding: 14px; + border-top: 1px solid var(--line); +} + +@media (max-width: 820px) { + body { + overflow: auto; + } + + .shell { + min-height: 100vh; + height: auto; + overflow: visible; + } + + .header { + flex-direction: column; + align-items: stretch; + } + + .layout { + grid-template-columns: 1fr; + flex: none; + } + + .chat-panel { + height: min(560px, calc(100vh - 24px)); + } + + .setup-panel { + max-height: 42vh; + } + + .message { + max-width: 92%; + } +}