205 lines
5.3 KiB
Python
205 lines
5.3 KiB
Python
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
|
from fastapi.responses import HTMLResponse
|
|
import asyncio, json
|
|
from typing import Set
|
|
|
|
app = FastAPI()
|
|
|
|
class ConnectionManager:
|
|
def __init__(self):
|
|
self.active: Set[WebSocket] = set()
|
|
self._lock = asyncio.Lock()
|
|
|
|
async def connect(self, ws: WebSocket):
|
|
await ws.accept()
|
|
async with self._lock:
|
|
self.active.add(ws)
|
|
|
|
async def disconnect(self, ws: WebSocket):
|
|
async with self._lock:
|
|
self.active.discard(ws)
|
|
|
|
async def broadcast(self, message: str):
|
|
data = json.dumps({"text": message})
|
|
async with self._lock:
|
|
dead = []
|
|
for ws in self.active:
|
|
try:
|
|
await ws.send_text(data)
|
|
except Exception:
|
|
dead.append(ws)
|
|
for ws in dead:
|
|
self.active.discard(ws)
|
|
|
|
manager = ConnectionManager()
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def index():
|
|
return """
|
|
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>Messages</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<link rel="stylesheet" href="https://unpkg.com/hack@0.8.1/dist/hack.css">
|
|
<link rel="stylesheet" href="https://unpkg.com/hack@0.8.1/dist/dark.css">
|
|
<style>
|
|
#log {
|
|
white-space: pre-wrap;
|
|
font-size: 1rem;
|
|
height: 70vh;
|
|
overflow-y: auto;
|
|
border: 1px solid #2a2a35;
|
|
border-radius: 14px;
|
|
padding: 1rem 1.25rem;
|
|
background: rgba(20, 20, 26, 0.7);
|
|
backdrop-filter: blur(6px);
|
|
scroll-behavior: smooth;
|
|
}
|
|
|
|
.line {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
margin: 0.25rem 0;
|
|
line-height: 1.4;
|
|
animation: lineIn 200ms ease-out;
|
|
}
|
|
@keyframes lineIn {
|
|
from { opacity: 0; transform: translateY(4px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.icon {
|
|
flex: 0 0 1.5rem;
|
|
text-align: center;
|
|
opacity: 0.7;
|
|
margin-right: 0.5rem;
|
|
}
|
|
|
|
.text {
|
|
flex: 1;
|
|
}
|
|
|
|
.typing::after {
|
|
content: "▍";
|
|
display: inline-block;
|
|
margin-left: 2px;
|
|
animation: blink 1s steps(1, end) infinite;
|
|
color: rgba(255,255,255,0.8);
|
|
}
|
|
@keyframes blink {
|
|
0%, 49% { opacity: 1; }
|
|
50%, 100% { opacity: 0; }
|
|
}
|
|
|
|
.char {
|
|
opacity: 0;
|
|
transform: translateY(4px);
|
|
transition: opacity 140ms ease-out, transform 140ms ease-out;
|
|
}
|
|
.char.in {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="hack dark">
|
|
<div class="container">
|
|
<h2>Messages</h2>
|
|
<div id="log"></div>
|
|
</div>
|
|
<script>
|
|
const log = document.getElementById('log');
|
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
const ws = new WebSocket(proto + '://' + location.host + '/ws');
|
|
|
|
const nearBottom = () => (log.scrollHeight - log.scrollTop - log.clientHeight) < 40;
|
|
const scrollToBottom = () => { log.scrollTop = log.scrollHeight; };
|
|
|
|
function typeText(text) {
|
|
const line = document.createElement("div");
|
|
line.className = "line";
|
|
|
|
const icon = document.createElement("div");
|
|
icon.className = "icon";
|
|
icon.textContent = "▶"; // 🔑 change this to 💬 ➤ 👉 etc.
|
|
|
|
const textDiv = document.createElement("div");
|
|
textDiv.className = "text typing";
|
|
|
|
line.appendChild(icon);
|
|
line.appendChild(textDiv);
|
|
log.appendChild(line);
|
|
|
|
let i = 0;
|
|
const interval = setInterval(() => {
|
|
if (i >= text.length) {
|
|
clearInterval(interval);
|
|
textDiv.classList.remove("typing");
|
|
if (nearBottom()) scrollToBottom();
|
|
return;
|
|
}
|
|
const span = document.createElement("span");
|
|
span.className = "char";
|
|
span.textContent = text[i++];
|
|
textDiv.appendChild(span);
|
|
requestAnimationFrame(() => span.classList.add("in"));
|
|
if (nearBottom()) scrollToBottom();
|
|
}, 50);
|
|
}
|
|
|
|
ws.onmessage = e => {
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
typeText(data.text || "");
|
|
} catch {
|
|
typeText("");
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
const line = document.createElement("div");
|
|
line.className = "line";
|
|
const icon = document.createElement("div");
|
|
icon.className = "icon";
|
|
icon.textContent = "⚠️";
|
|
const textDiv = document.createElement("div");
|
|
textDiv.className = "text";
|
|
textDiv.textContent = "Disconnected";
|
|
line.appendChild(icon);
|
|
line.appendChild(textDiv);
|
|
log.appendChild(line);
|
|
scrollToBottom();
|
|
};
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
@app.post("/send")
|
|
async def send(request: Request):
|
|
text = ""
|
|
try:
|
|
payload = await request.json()
|
|
if isinstance(payload, dict):
|
|
text = payload.get("text", "")
|
|
except Exception:
|
|
try:
|
|
form = await request.form()
|
|
text = form.get("text", "")
|
|
except Exception:
|
|
pass
|
|
await manager.broadcast(text or "")
|
|
return {"message": text or ""}
|
|
|
|
@app.websocket("/ws")
|
|
async def ws_endpoint(ws: WebSocket):
|
|
await manager.connect(ws)
|
|
try:
|
|
while True:
|
|
await ws.receive_text()
|
|
except WebSocketDisconnect:
|
|
await manager.disconnect(ws)
|
|
|