victor HF Staff commited on
Commit
a10f004
·
0 Parent(s):

Bucket Chat: Slack-like app with persistent storage

Browse files
Files changed (3) hide show
  1. Dockerfile +6 -0
  2. README.md +8 -0
  3. app.py +637 -0
Dockerfile ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+ WORKDIR /app
3
+ COPY app.py .
4
+ RUN pip install flask
5
+ EXPOSE 7860
6
+ CMD ["python", "app.py"]
README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Bucket Chat
3
+ emoji: 💬
4
+ colorFrom: gray
5
+ colorTo: gray
6
+ sdk: docker
7
+ pinned: false
8
+ ---
app.py ADDED
@@ -0,0 +1,637 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import uuid
4
+ import sqlite3
5
+ from datetime import datetime
6
+ from functools import wraps
7
+ from flask import Flask, request, jsonify, make_response, send_from_directory
8
+
9
+ app = Flask(__name__)
10
+
11
+ DATA_DIR = "/data"
12
+ DB_PATH = os.path.join(DATA_DIR, "chat.db")
13
+
14
+
15
+ def get_db():
16
+ os.makedirs(DATA_DIR, exist_ok=True)
17
+ conn = sqlite3.connect(DB_PATH)
18
+ conn.row_factory = sqlite3.Row
19
+ conn.execute("PRAGMA journal_mode=WAL")
20
+ conn.execute("PRAGMA foreign_keys=ON")
21
+ return conn
22
+
23
+
24
+ def init_db():
25
+ db = get_db()
26
+ db.executescript("""
27
+ CREATE TABLE IF NOT EXISTS users (
28
+ id TEXT PRIMARY KEY,
29
+ name TEXT NOT NULL,
30
+ color TEXT NOT NULL,
31
+ created_at TEXT NOT NULL
32
+ );
33
+ CREATE TABLE IF NOT EXISTS channels (
34
+ id TEXT PRIMARY KEY,
35
+ name TEXT NOT NULL UNIQUE,
36
+ created_by TEXT NOT NULL,
37
+ created_at TEXT NOT NULL
38
+ );
39
+ CREATE TABLE IF NOT EXISTS messages (
40
+ id TEXT PRIMARY KEY,
41
+ channel_id TEXT NOT NULL,
42
+ user_id TEXT NOT NULL,
43
+ text TEXT NOT NULL,
44
+ created_at TEXT NOT NULL,
45
+ FOREIGN KEY (channel_id) REFERENCES channels(id),
46
+ FOREIGN KEY (user_id) REFERENCES users(id)
47
+ );
48
+ CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at);
49
+ """)
50
+ # Seed default channel
51
+ existing = db.execute("SELECT id FROM channels WHERE name = 'general'").fetchone()
52
+ if not existing:
53
+ db.execute(
54
+ "INSERT INTO channels (id, name, created_by, created_at) VALUES (?, ?, ?, ?)",
55
+ (str(uuid.uuid4()), "general", "system", datetime.utcnow().isoformat()),
56
+ )
57
+ db.commit()
58
+ db.close()
59
+
60
+
61
+ COLORS = [
62
+ "#007AFF", "#FF3B30", "#FF9500", "#AF52DE", "#5856D6",
63
+ "#34C759", "#FF2D55", "#00C7BE", "#5AC8FA", "#BF5AF2",
64
+ ]
65
+
66
+
67
+ def get_user():
68
+ sid = request.cookies.get("sid")
69
+ if not sid:
70
+ return None
71
+ db = get_db()
72
+ user = db.execute("SELECT * FROM users WHERE id = ?", (sid,)).fetchone()
73
+ db.close()
74
+ return dict(user) if user else None
75
+
76
+
77
+ def require_user(f):
78
+ @wraps(f)
79
+ def wrapper(*args, **kwargs):
80
+ user = get_user()
81
+ if not user:
82
+ return jsonify({"error": "not authenticated"}), 401
83
+ request.user = user
84
+ return f(*args, **kwargs)
85
+ return wrapper
86
+
87
+
88
+ # --- API ---
89
+
90
+ @app.route("/api/me")
91
+ def api_me():
92
+ user = get_user()
93
+ if user:
94
+ return jsonify(user)
95
+ return jsonify(None)
96
+
97
+
98
+ @app.route("/api/login", methods=["POST"])
99
+ def api_login():
100
+ data = request.json
101
+ name = (data.get("name") or "").strip()
102
+ if not name or len(name) > 32:
103
+ return jsonify({"error": "Name must be 1-32 characters"}), 400
104
+
105
+ db = get_db()
106
+ # Check if name taken
107
+ existing = db.execute("SELECT * FROM users WHERE name = ?", (name,)).fetchone()
108
+ if existing:
109
+ # Log back in
110
+ resp = make_response(jsonify(dict(existing)))
111
+ resp.set_cookie("sid", existing["id"], max_age=60 * 60 * 24 * 365, httponly=True, samesite="Lax")
112
+ db.close()
113
+ return resp
114
+
115
+ user_id = str(uuid.uuid4())
116
+ color = COLORS[hash(name) % len(COLORS)]
117
+ now = datetime.utcnow().isoformat()
118
+ db.execute(
119
+ "INSERT INTO users (id, name, color, created_at) VALUES (?, ?, ?, ?)",
120
+ (user_id, name, color, now),
121
+ )
122
+ db.commit()
123
+ user = {"id": user_id, "name": name, "color": color, "created_at": now}
124
+ db.close()
125
+
126
+ resp = make_response(jsonify(user))
127
+ resp.set_cookie("sid", user_id, max_age=60 * 60 * 24 * 365, httponly=True, samesite="Lax")
128
+ return resp
129
+
130
+
131
+ @app.route("/api/logout", methods=["POST"])
132
+ def api_logout():
133
+ resp = make_response(jsonify({"ok": True}))
134
+ resp.delete_cookie("sid")
135
+ return resp
136
+
137
+
138
+ @app.route("/api/channels")
139
+ @require_user
140
+ def api_channels():
141
+ db = get_db()
142
+ rows = db.execute("SELECT * FROM channels ORDER BY created_at").fetchall()
143
+ db.close()
144
+ return jsonify([dict(r) for r in rows])
145
+
146
+
147
+ @app.route("/api/channels", methods=["POST"])
148
+ @require_user
149
+ def api_create_channel():
150
+ data = request.json
151
+ name = (data.get("name") or "").strip().lower().replace(" ", "-")
152
+ if not name or len(name) > 40:
153
+ return jsonify({"error": "Channel name must be 1-40 characters"}), 400
154
+
155
+ db = get_db()
156
+ existing = db.execute("SELECT id FROM channels WHERE name = ?", (name,)).fetchone()
157
+ if existing:
158
+ db.close()
159
+ return jsonify({"error": "Channel already exists"}), 409
160
+
161
+ ch_id = str(uuid.uuid4())
162
+ now = datetime.utcnow().isoformat()
163
+ db.execute(
164
+ "INSERT INTO channels (id, name, created_by, created_at) VALUES (?, ?, ?, ?)",
165
+ (ch_id, name, request.user["id"], now),
166
+ )
167
+ db.commit()
168
+ channel = {"id": ch_id, "name": name, "created_by": request.user["id"], "created_at": now}
169
+ db.close()
170
+ return jsonify(channel), 201
171
+
172
+
173
+ @app.route("/api/channels/<channel_id>/messages")
174
+ @require_user
175
+ def api_messages(channel_id):
176
+ db = get_db()
177
+ rows = db.execute("""
178
+ SELECT m.id, m.text, m.created_at, m.channel_id,
179
+ u.id as user_id, u.name as user_name, u.color as user_color
180
+ FROM messages m JOIN users u ON m.user_id = u.id
181
+ WHERE m.channel_id = ?
182
+ ORDER BY m.created_at
183
+ LIMIT 200
184
+ """, (channel_id,)).fetchall()
185
+ db.close()
186
+ return jsonify([dict(r) for r in rows])
187
+
188
+
189
+ @app.route("/api/channels/<channel_id>/messages", methods=["POST"])
190
+ @require_user
191
+ def api_send_message(channel_id):
192
+ data = request.json
193
+ text = (data.get("text") or "").strip()
194
+ if not text or len(text) > 4000:
195
+ return jsonify({"error": "Message must be 1-4000 characters"}), 400
196
+
197
+ db = get_db()
198
+ ch = db.execute("SELECT id FROM channels WHERE id = ?", (channel_id,)).fetchone()
199
+ if not ch:
200
+ db.close()
201
+ return jsonify({"error": "Channel not found"}), 404
202
+
203
+ msg_id = str(uuid.uuid4())
204
+ now = datetime.utcnow().isoformat()
205
+ db.execute(
206
+ "INSERT INTO messages (id, channel_id, user_id, text, created_at) VALUES (?, ?, ?, ?, ?)",
207
+ (msg_id, channel_id, request.user["id"], text, now),
208
+ )
209
+ db.commit()
210
+ db.close()
211
+
212
+ return jsonify({
213
+ "id": msg_id, "text": text, "created_at": now, "channel_id": channel_id,
214
+ "user_id": request.user["id"], "user_name": request.user["name"], "user_color": request.user["color"],
215
+ }), 201
216
+
217
+
218
+ @app.route("/")
219
+ def index():
220
+ return INDEX_HTML
221
+
222
+
223
+ INDEX_HTML = """<!DOCTYPE html>
224
+ <html lang="en">
225
+ <head>
226
+ <meta charset="utf-8">
227
+ <meta name="viewport" content="width=device-width, initial-scale=1">
228
+ <title>Bucket Chat</title>
229
+ <style>
230
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
231
+
232
+ :root {
233
+ --bg: #ffffff;
234
+ --bg-secondary: #f5f5f7;
235
+ --bg-hover: #e8e8ed;
236
+ --border: #d2d2d7;
237
+ --text: #1d1d1f;
238
+ --text-secondary: #86868b;
239
+ --accent: #007AFF;
240
+ --accent-hover: #0056CC;
241
+ --sidebar-w: 240px;
242
+ --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
243
+ }
244
+
245
+ html, body { height: 100%; font-family: var(--font); color: var(--text); background: var(--bg); }
246
+
247
+ /* --- Login --- */
248
+ #login {
249
+ display: flex; align-items: center; justify-content: center;
250
+ height: 100%; background: var(--bg-secondary);
251
+ }
252
+ #login-box {
253
+ background: var(--bg); border-radius: 16px; padding: 48px 40px;
254
+ width: 360px; text-align: center;
255
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.06);
256
+ }
257
+ #login-box h1 { font-size: 28px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 6px; }
258
+ #login-box p { color: var(--text-secondary); font-size: 15px; margin-bottom: 28px; }
259
+ #login-box input {
260
+ width: 100%; padding: 12px 16px; font-size: 16px; border: 1px solid var(--border);
261
+ border-radius: 10px; outline: none; font-family: var(--font); background: var(--bg);
262
+ transition: border-color 0.15s;
263
+ }
264
+ #login-box input:focus { border-color: var(--accent); }
265
+ #login-box button {
266
+ width: 100%; margin-top: 12px; padding: 12px; font-size: 16px; font-weight: 500;
267
+ border: none; border-radius: 10px; background: var(--accent); color: #fff;
268
+ cursor: pointer; font-family: var(--font); transition: background 0.15s;
269
+ }
270
+ #login-box button:hover { background: var(--accent-hover); }
271
+ #login-error { color: #FF3B30; font-size: 13px; margin-top: 10px; min-height: 18px; }
272
+
273
+ /* --- App shell --- */
274
+ #app { display: none; height: 100%; }
275
+ #app.active { display: flex; }
276
+
277
+ /* Sidebar */
278
+ #sidebar {
279
+ width: var(--sidebar-w); min-width: var(--sidebar-w); height: 100%;
280
+ background: var(--bg-secondary); border-right: 1px solid var(--border);
281
+ display: flex; flex-direction: column; overflow: hidden;
282
+ }
283
+ #sidebar-header {
284
+ padding: 20px 16px 12px; font-size: 13px; font-weight: 600;
285
+ color: var(--text-secondary); letter-spacing: 0.02em; text-transform: uppercase;
286
+ display: flex; align-items: center; justify-content: space-between;
287
+ }
288
+ #sidebar-header button {
289
+ background: none; border: none; font-size: 20px; color: var(--text-secondary);
290
+ cursor: pointer; line-height: 1; padding: 0 2px; border-radius: 4px;
291
+ }
292
+ #sidebar-header button:hover { color: var(--text); background: var(--bg-hover); }
293
+ #channel-list { flex: 1; overflow-y: auto; padding: 0 8px; }
294
+ .ch-item {
295
+ padding: 8px 12px; border-radius: 8px; cursor: pointer;
296
+ font-size: 15px; color: var(--text); display: flex; align-items: center; gap: 6px;
297
+ transition: background 0.1s;
298
+ }
299
+ .ch-item:hover { background: var(--bg-hover); }
300
+ .ch-item.active { background: var(--accent); color: #fff; }
301
+ .ch-item .hash { font-weight: 600; opacity: 0.5; }
302
+ #user-bar {
303
+ padding: 12px 16px; border-top: 1px solid var(--border);
304
+ display: flex; align-items: center; gap: 10px; font-size: 14px;
305
+ }
306
+ #user-bar .avatar {
307
+ width: 28px; height: 28px; border-radius: 50%; color: #fff;
308
+ display: flex; align-items: center; justify-content: center;
309
+ font-size: 13px; font-weight: 600; flex-shrink: 0;
310
+ }
311
+ #user-bar .name { font-weight: 500; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
312
+ #user-bar button {
313
+ background: none; border: none; color: var(--text-secondary); cursor: pointer;
314
+ font-size: 13px; padding: 4px 8px; border-radius: 6px;
315
+ }
316
+ #user-bar button:hover { background: var(--bg-hover); color: var(--text); }
317
+
318
+ /* Main area */
319
+ #main { flex: 1; display: flex; flex-direction: column; height: 100%; overflow: hidden; }
320
+ #channel-header {
321
+ padding: 16px 20px; border-bottom: 1px solid var(--border);
322
+ font-size: 17px; font-weight: 600; flex-shrink: 0;
323
+ }
324
+ #channel-header .hash { color: var(--text-secondary); margin-right: 2px; }
325
+ #messages {
326
+ flex: 1; overflow-y: auto; padding: 16px 20px;
327
+ display: flex; flex-direction: column; gap: 2px;
328
+ }
329
+ .msg { display: flex; gap: 10px; padding: 6px 0; }
330
+ .msg .avatar {
331
+ width: 32px; height: 32px; border-radius: 50%; color: #fff;
332
+ display: flex; align-items: center; justify-content: center;
333
+ font-size: 14px; font-weight: 600; flex-shrink: 0; margin-top: 2px;
334
+ }
335
+ .msg .body { min-width: 0; }
336
+ .msg .meta { display: flex; align-items: baseline; gap: 8px; }
337
+ .msg .author { font-size: 14px; font-weight: 600; }
338
+ .msg .time { font-size: 12px; color: var(--text-secondary); }
339
+ .msg .text { font-size: 15px; line-height: 1.45; margin-top: 2px; word-break: break-word; white-space: pre-wrap; }
340
+ .msg-grouped { padding: 1px 0; }
341
+ .msg-grouped .avatar { visibility: hidden; width: 32px; }
342
+ .msg-grouped .meta { display: none; }
343
+ #empty-state {
344
+ flex: 1; display: flex; align-items: center; justify-content: center;
345
+ color: var(--text-secondary); font-size: 15px;
346
+ }
347
+
348
+ /* Composer */
349
+ #composer {
350
+ padding: 12px 20px 16px; flex-shrink: 0;
351
+ }
352
+ #composer form { display: flex; gap: 8px; }
353
+ #composer input {
354
+ flex: 1; padding: 10px 14px; font-size: 15px; border: 1px solid var(--border);
355
+ border-radius: 10px; outline: none; font-family: var(--font); background: var(--bg);
356
+ transition: border-color 0.15s;
357
+ }
358
+ #composer input:focus { border-color: var(--accent); }
359
+ #composer button {
360
+ padding: 10px 18px; font-size: 15px; font-weight: 500; border: none;
361
+ border-radius: 10px; background: var(--accent); color: #fff; cursor: pointer;
362
+ font-family: var(--font); transition: background 0.15s; white-space: nowrap;
363
+ }
364
+ #composer button:hover { background: var(--accent-hover); }
365
+
366
+ /* New channel modal */
367
+ #modal-bg {
368
+ display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.25);
369
+ z-index: 100; align-items: center; justify-content: center;
370
+ backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
371
+ }
372
+ #modal-bg.active { display: flex; }
373
+ #modal {
374
+ background: var(--bg); border-radius: 14px; padding: 28px 24px 24px;
375
+ width: 340px; box-shadow: 0 8px 32px rgba(0,0,0,0.12);
376
+ }
377
+ #modal h2 { font-size: 18px; font-weight: 600; margin-bottom: 16px; }
378
+ #modal input {
379
+ width: 100%; padding: 10px 14px; font-size: 15px; border: 1px solid var(--border);
380
+ border-radius: 10px; outline: none; font-family: var(--font);
381
+ }
382
+ #modal input:focus { border-color: var(--accent); }
383
+ #modal-actions { display: flex; gap: 8px; margin-top: 14px; justify-content: flex-end; }
384
+ #modal-actions button {
385
+ padding: 8px 18px; font-size: 14px; font-weight: 500; border-radius: 8px;
386
+ cursor: pointer; font-family: var(--font); transition: background 0.15s;
387
+ }
388
+ #modal-cancel { background: var(--bg-secondary); border: 1px solid var(--border); color: var(--text); }
389
+ #modal-cancel:hover { background: var(--bg-hover); }
390
+ #modal-create { background: var(--accent); border: none; color: #fff; }
391
+ #modal-create:hover { background: var(--accent-hover); }
392
+ #modal-error { color: #FF3B30; font-size: 13px; margin-top: 8px; min-height: 16px; }
393
+
394
+ /* Scrollbar */
395
+ ::-webkit-scrollbar { width: 6px; }
396
+ ::-webkit-scrollbar-track { background: transparent; }
397
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
398
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }
399
+
400
+ @media (max-width: 640px) {
401
+ #sidebar { display: none; }
402
+ #channel-header { font-size: 15px; }
403
+ }
404
+ </style>
405
+ </head>
406
+ <body>
407
+
408
+ <div id="login">
409
+ <div id="login-box">
410
+ <h1>Bucket Chat</h1>
411
+ <p>Messages persist in an HF Bucket.</p>
412
+ <form onsubmit="doLogin(event)">
413
+ <input id="login-name" placeholder="Pick a username" maxlength="32" autofocus autocomplete="off">
414
+ <button type="submit">Continue</button>
415
+ </form>
416
+ <div id="login-error"></div>
417
+ </div>
418
+ </div>
419
+
420
+ <div id="app">
421
+ <div id="sidebar">
422
+ <div id="sidebar-header">
423
+ <span>Channels</span>
424
+ <button onclick="openModal()" title="New channel">+</button>
425
+ </div>
426
+ <div id="channel-list"></div>
427
+ <div id="user-bar">
428
+ <div class="avatar" id="my-avatar"></div>
429
+ <span class="name" id="my-name"></span>
430
+ <button onclick="doLogout()">Sign out</button>
431
+ </div>
432
+ </div>
433
+ <div id="main">
434
+ <div id="channel-header"></div>
435
+ <div id="messages"></div>
436
+ <div id="composer">
437
+ <form onsubmit="sendMessage(event)">
438
+ <input id="msg-input" placeholder="Write a message..." autocomplete="off">
439
+ <button type="submit">Send</button>
440
+ </form>
441
+ </div>
442
+ </div>
443
+ </div>
444
+
445
+ <div id="modal-bg" onclick="closeModal()">
446
+ <div id="modal" onclick="event.stopPropagation()">
447
+ <h2>New channel</h2>
448
+ <form onsubmit="createChannel(event)">
449
+ <input id="ch-name-input" placeholder="channel-name" maxlength="40" autocomplete="off">
450
+ <div id="modal-error"></div>
451
+ <div id="modal-actions">
452
+ <button type="button" id="modal-cancel" onclick="closeModal()">Cancel</button>
453
+ <button type="submit" id="modal-create">Create</button>
454
+ </div>
455
+ </form>
456
+ </div>
457
+ </div>
458
+
459
+ <script>
460
+ let me = null;
461
+ let channels = [];
462
+ let currentChannel = null;
463
+ let pollTimer = null;
464
+
465
+ async function boot() {
466
+ const res = await fetch("/api/me");
467
+ me = await res.json();
468
+ if (me) enterApp();
469
+ }
470
+
471
+ async function doLogin(e) {
472
+ e.preventDefault();
473
+ const name = document.getElementById("login-name").value.trim();
474
+ if (!name) return;
475
+ const res = await fetch("/api/login", {
476
+ method: "POST", headers: {"Content-Type": "application/json"},
477
+ body: JSON.stringify({name})
478
+ });
479
+ const data = await res.json();
480
+ if (!res.ok) {
481
+ document.getElementById("login-error").textContent = data.error;
482
+ return;
483
+ }
484
+ me = data;
485
+ enterApp();
486
+ }
487
+
488
+ async function doLogout() {
489
+ await fetch("/api/logout", {method: "POST"});
490
+ me = null;
491
+ clearInterval(pollTimer);
492
+ document.getElementById("app").classList.remove("active");
493
+ document.getElementById("login").style.display = "flex";
494
+ document.getElementById("login-name").value = "";
495
+ }
496
+
497
+ async function enterApp() {
498
+ document.getElementById("login").style.display = "none";
499
+ document.getElementById("app").classList.add("active");
500
+ document.getElementById("my-name").textContent = me.name;
501
+ const av = document.getElementById("my-avatar");
502
+ av.style.background = me.color;
503
+ av.textContent = me.name[0].toUpperCase();
504
+
505
+ await loadChannels();
506
+ if (channels.length > 0) selectChannel(channels[0].id);
507
+ pollTimer = setInterval(pollMessages, 2500);
508
+ }
509
+
510
+ async function loadChannels() {
511
+ const res = await fetch("/api/channels");
512
+ channels = await res.json();
513
+ renderChannels();
514
+ }
515
+
516
+ function renderChannels() {
517
+ const el = document.getElementById("channel-list");
518
+ el.innerHTML = channels.map(c =>
519
+ `<div class="ch-item ${c.id === currentChannel ? 'active' : ''}" onclick="selectChannel('${c.id}')">
520
+ <span class="hash">#</span> ${esc(c.name)}
521
+ </div>`
522
+ ).join("");
523
+ }
524
+
525
+ async function selectChannel(id) {
526
+ currentChannel = id;
527
+ renderChannels();
528
+ const ch = channels.find(c => c.id === id);
529
+ document.getElementById("channel-header").innerHTML =
530
+ `<span class="hash">#</span>${esc(ch ? ch.name : "")}`;
531
+ await loadMessages();
532
+ document.getElementById("msg-input").focus();
533
+ }
534
+
535
+ async function loadMessages() {
536
+ if (!currentChannel) return;
537
+ const res = await fetch(`/api/channels/${currentChannel}/messages`);
538
+ const msgs = await res.json();
539
+ renderMessages(msgs);
540
+ }
541
+
542
+ function renderMessages(msgs) {
543
+ const el = document.getElementById("messages");
544
+ if (msgs.length === 0) {
545
+ el.innerHTML = '<div id="empty-state">No messages yet. Say something!</div>';
546
+ return;
547
+ }
548
+ let html = "";
549
+ let prevUser = null;
550
+ for (const m of msgs) {
551
+ const grouped = prevUser === m.user_id;
552
+ const t = new Date(m.created_at + "Z");
553
+ const time = t.toLocaleTimeString([], {hour: "2-digit", minute: "2-digit"});
554
+ if (grouped) {
555
+ html += `<div class="msg msg-grouped">
556
+ <div class="avatar"></div>
557
+ <div class="body"><div class="text">${esc(m.text)}</div></div>
558
+ </div>`;
559
+ } else {
560
+ html += `<div class="msg">
561
+ <div class="avatar" style="background:${m.user_color}">${esc(m.user_name[0].toUpperCase())}</div>
562
+ <div class="body">
563
+ <div class="meta">
564
+ <span class="author" style="color:${m.user_color}">${esc(m.user_name)}</span>
565
+ <span class="time">${time}</span>
566
+ </div>
567
+ <div class="text">${esc(m.text)}</div>
568
+ </div>
569
+ </div>`;
570
+ }
571
+ prevUser = m.user_id;
572
+ }
573
+ el.innerHTML = html;
574
+ el.scrollTop = el.scrollHeight;
575
+ }
576
+
577
+ async function sendMessage(e) {
578
+ e.preventDefault();
579
+ const input = document.getElementById("msg-input");
580
+ const text = input.value.trim();
581
+ if (!text || !currentChannel) return;
582
+ input.value = "";
583
+ await fetch(`/api/channels/${currentChannel}/messages`, {
584
+ method: "POST", headers: {"Content-Type": "application/json"},
585
+ body: JSON.stringify({text})
586
+ });
587
+ await loadMessages();
588
+ }
589
+
590
+ async function pollMessages() {
591
+ if (!currentChannel) return;
592
+ await loadMessages();
593
+ }
594
+
595
+ function openModal() {
596
+ document.getElementById("modal-bg").classList.add("active");
597
+ document.getElementById("ch-name-input").value = "";
598
+ document.getElementById("modal-error").textContent = "";
599
+ document.getElementById("ch-name-input").focus();
600
+ }
601
+ function closeModal() {
602
+ document.getElementById("modal-bg").classList.remove("active");
603
+ }
604
+
605
+ async function createChannel(e) {
606
+ e.preventDefault();
607
+ const name = document.getElementById("ch-name-input").value.trim();
608
+ if (!name) return;
609
+ const res = await fetch("/api/channels", {
610
+ method: "POST", headers: {"Content-Type": "application/json"},
611
+ body: JSON.stringify({name})
612
+ });
613
+ const data = await res.json();
614
+ if (!res.ok) {
615
+ document.getElementById("modal-error").textContent = data.error;
616
+ return;
617
+ }
618
+ closeModal();
619
+ await loadChannels();
620
+ selectChannel(data.id);
621
+ }
622
+
623
+ function esc(s) {
624
+ const d = document.createElement("div");
625
+ d.textContent = s;
626
+ return d.innerHTML;
627
+ }
628
+
629
+ boot();
630
+ </script>
631
+ </body>
632
+ </html>
633
+ """
634
+
635
+ if __name__ == "__main__":
636
+ init_db()
637
+ app.run(host="0.0.0.0", port=7860)