spideyhead commited on
Commit
d03db4e
Β·
verified Β·
1 Parent(s): 0d99bcc

Upload 9 files

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ backend/cloudflared.exe filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Copy the entire directory
6
+ COPY . /app
7
+
8
+ # Install dependencies
9
+ RUN pip install --no-cache-dir -r backend/requirements.txt
10
+
11
+ # Hugging Face Spaces strictly requires port 7860
12
+ ENV PORT=7860
13
+ EXPOSE 7860
14
+
15
+ # Change to backend directory so paths resolve correctly
16
+ WORKDIR /app/backend
17
+
18
+ # Start the application
19
+ CMD ["python", "main.py"]
backend/.env ADDED
@@ -0,0 +1 @@
 
 
1
+ NGROK_AUTH_TOKEN=3EP4bgLY76Kdvsr2pq283IA4xb5_hteEFzQq3fMJVb7JJa8X
backend/__pycache__/main.cpython-38.pyc ADDED
Binary file (6.99 kB). View file
 
backend/cloudflared.exe ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f141cded099c239171ad2cea6fb5da0fdaa2bd36104c3074d883f9546519eba7
3
+ size 53886304
backend/cloudflared.log ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"level":"info","time":"2026-05-29T09:09:56Z","message":"Thank you for trying Cloudflare Tunnel. Doing so, without a Cloudflare account, is a quick way to experiment and try it out. However, be aware that these account-less Tunnels have no uptime guarantee, are subject to the Cloudflare Online Services Terms of Use (https://www.cloudflare.com/website-terms/), and Cloudflare reserves the right to investigate your use of Tunnels for violations of such terms. If you intend to use Tunnels in production you should use a pre-created named tunnel by following: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps"}
2
+ {"level":"info","time":"2026-05-29T09:09:56Z","message":"Requesting new quick Tunnel on trycloudflare.com..."}
3
+ {"level":"info","time":"2026-05-29T09:10:04Z","message":"+--------------------------------------------------------------------------------------------+"}
4
+ {"level":"info","time":"2026-05-29T09:10:04Z","message":"| Your quick Tunnel has been created! Visit it at (it may take some time to be reachable): |"}
5
+ {"level":"info","time":"2026-05-29T09:10:04Z","message":"| https://save-entered-advance-tuning.trycloudflare.com |"}
6
+ {"level":"info","time":"2026-05-29T09:10:04Z","message":"+--------------------------------------------------------------------------------------------+"}
7
+ {"level":"info","time":"2026-05-29T09:10:04Z","message":"Cannot determine default configuration path. No file [config.yml config.yaml] in [~/.cloudflared ~/.cloudflare-warp ~/cloudflare-warp]"}
8
+ {"level":"info","time":"2026-05-29T09:10:04Z","message":"Version 2026.5.0 (Checksum f141cded099c239171ad2cea6fb5da0fdaa2bd36104c3074d883f9546519eba7)"}
9
+ {"level":"info","time":"2026-05-29T09:10:04Z","message":"GOOS: windows, GOVersion: go1.26.2, GoArch: amd64"}
10
+ {"level":"info","time":"2026-05-29T09:10:04Z","message":"Settings: map[ha-connections:1 logfile:C:\\Users\\atul\\.gemini\\antigravity\\scratch\\RadarDrop\\backend\\cloudflared.log protocol:quic url:http://localhost:8000]"}
11
+ {"level":"info","time":"2026-05-29T09:10:04Z","message":"cloudflared will not automatically update on Windows systems."}
12
+ {"level":"info","time":"2026-05-29T09:10:04Z","message":"Generated Connector ID: c005171b-ad77-4597-8fc4-5861aef9257e"}
13
+ {"level":"info","time":"2026-05-29T09:10:04Z","message":"Initial protocol quic"}
14
+ {"level":"info","time":"2026-05-29T09:10:04Z","message":"ICMP proxy will use 10.19.14.16 as source for IPv4"}
15
+ {"level":"info","time":"2026-05-29T09:10:05Z","message":"ICMP proxy will use 2409:4041:2e30:9448:e013:af9f:c011:83e0 in zone Wi-Fi as source for IPv6"}
16
+ {"level":"info","time":"2026-05-29T09:10:05Z","message":"cloudflared does not support loading the system root certificate pool on Windows. Please use --origin-ca-pool <PATH> to specify the path to the certificate pool"}
17
+ {"level":"info","time":"2026-05-29T09:10:05Z","message":"ICMP proxy will use 10.19.14.16 as source for IPv4"}
18
+ {"level":"info","event":0,"ip":"2606:4700:a0::6","connIndex":0,"time":"2026-05-29T09:10:05Z","message":"Tunnel connection curve preferences: [X25519MLKEM768 CurveID(65074) CurveP256]"}
19
+ {"level":"info","time":"2026-05-29T09:10:05Z","message":"ICMP proxy will use 2409:4041:2e30:9448:e013:af9f:c011:83e0 in zone Wi-Fi as source for IPv6"}
20
+ {"level":"info","time":"2026-05-29T09:10:05Z","message":"Starting metrics server on 127.0.0.1:20241/metrics"}
21
+ {"level":"info","event":0,"connection":"3f747645-5bec-4e17-8e1b-52ddb64c9461","connIndex":0,"location":"amd01","ip":"2606:4700:a0::6","protocol":"quic","time":"2026-05-29T09:10:06Z","message":"Registered tunnel connection"}
22
+ {"level":"error","event":0,"ip":"2606:4700:a0::6","connIndex":0,"error":"failed to accept QUIC stream: timeout: no recent network activity","time":"2026-05-29T09:13:52Z","message":"failed to accept incoming stream requests"}
23
+ {"level":"error","event":0,"ip":"2606:4700:a0::6","connIndex":0,"error":"timeout: no recent network activity","time":"2026-05-29T09:13:52Z","message":"failed to run the datagram handler"}
24
+ {"level":"error","event":0,"ip":"2606:4700:a0::6","connIndex":0,"error":"accept stream listener encountered a failure while serving","time":"2026-05-29T09:13:53Z","message":"failed to serve tunnel connection"}
25
+ {"level":"error","event":0,"ip":"2606:4700:a0::6","connIndex":0,"error":"accept stream listener encountered a failure while serving","time":"2026-05-29T09:14:03Z","message":"Serve tunnel error"}
26
+ {"level":"info","event":0,"ip":"2606:4700:a0::6","connIndex":0,"time":"2026-05-29T09:14:03Z","message":"Retrying connection in up to 1s"}
27
+ {"level":"info","event":0,"ip":"2606:4700:a0::6","connIndex":0,"time":"2026-05-29T09:14:04Z","message":"Tunnel connection curve preferences: [X25519MLKEM768 CurveID(65074) CurveP256]"}
28
+ {"level":"info","event":0,"connection":"03935aa8-4a2e-4f9a-be3b-3ce4eb540ea8","connIndex":0,"location":"amd01","ip":"2606:4700:a0::6","protocol":"quic","time":"2026-05-29T09:14:04Z","message":"Registered tunnel connection"}
29
+ {"level":"info","time":"2026-05-29T10:33:16Z","message":"Initiating graceful shutdown due to signal terminated ..."}
30
+ {"level":"error","event":0,"ip":"2606:4700:a0::6","connIndex":0,"error":"context canceled","time":"2026-05-29T10:33:16Z","message":"failed to run the datagram handler"}
31
+ {"level":"error","event":0,"ip":"2606:4700:a0::6","connIndex":0,"error":"accept stream listener encountered a failure while serving","time":"2026-05-29T10:33:16Z","message":"failed to serve tunnel connection"}
32
+ {"level":"error","event":0,"ip":"2606:4700:a0::6","connIndex":0,"error":"accept stream listener encountered a failure while serving","time":"2026-05-29T10:33:16Z","message":"Serve tunnel error"}
33
+ {"level":"info","event":0,"ip":"2606:4700:a0::6","connIndex":0,"time":"2026-05-29T10:33:16Z","message":"Retrying connection in up to 1s"}
34
+ {"level":"error","connIndex":0,"time":"2026-05-29T10:33:16Z","message":"Connection terminated"}
35
+ {"level":"error","time":"2026-05-29T10:33:16Z","message":"no more connections active and exiting"}
36
+ {"level":"info","time":"2026-05-29T10:33:16Z","message":"Tunnel server stopped"}
37
+ {"level":"info","time":"2026-05-29T10:33:16Z","message":"Metrics server stopped"}
backend/main.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import io
4
+ import socket
5
+ import subprocess
6
+ import threading
7
+ import mimetypes
8
+ import json
9
+ from pathlib import Path
10
+ from datetime import datetime
11
+ from typing import Optional
12
+
13
+ sys.stdout = open(sys.stdout.fileno(), mode='w', encoding='utf-8', buffering=1)
14
+
15
+ import qrcode
16
+ import uvicorn
17
+ from fastapi import FastAPI, UploadFile, File, HTTPException, WebSocket, WebSocketDisconnect
18
+ from fastapi.responses import FileResponse, JSONResponse, Response, HTMLResponse
19
+ from fastapi.middleware.cors import CORSMiddleware
20
+ from dotenv import load_dotenv
21
+ from pyngrok import ngrok, conf
22
+
23
+ load_dotenv()
24
+
25
+ # ─── Config ───────────────────────────────────────────────────────────────────
26
+ PORT = int(os.environ.get("PORT", 8000))
27
+ BASE_DIR = Path(__file__).resolve().parent
28
+ FRONTEND_DIR = BASE_DIR.parent / "frontend"
29
+ UPLOAD_DIR = BASE_DIR / "uploads"
30
+ URL_FILE = BASE_DIR / "public_url.txt"
31
+ UPLOAD_DIR.mkdir(exist_ok=True)
32
+
33
+ # ─── Rooms State ─────────────────────────────────────────────────────────────
34
+ # { "room_code": { "clients": [WebSocket], "messages": [dict] } }
35
+ rooms = {}
36
+
37
+ # ─── Kill any process already on PORT ────────────────────────────────────────
38
+ def free_port(port: int):
39
+ try:
40
+ result = subprocess.run(["netstat", "-ano"], capture_output=True, text=True)
41
+ for line in result.stdout.splitlines():
42
+ if f":{port}" in line and "LISTENING" in line:
43
+ pid = line.strip().split()[-1]
44
+ subprocess.run(["taskkill", "/F", "/PID", pid], capture_output=True)
45
+ print(f" [~] Freed port {port} (killed PID {pid})")
46
+ except Exception:
47
+ pass
48
+
49
+ # ─── App ─────────────────────────────────────────────────────────────────────
50
+ app = FastAPI(title="RadarDrop v2")
51
+ app.add_middleware(CORSMiddleware, allow_origins=["*"],
52
+ allow_methods=["*"], allow_headers=["*"])
53
+
54
+ # ─── Helpers ─────────────────────────────────────────────────────────────────
55
+ def get_local_ip() -> str:
56
+ try:
57
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
58
+ s.connect(("8.8.8.8", 80))
59
+ ip = s.getsockname()[0]
60
+ s.close()
61
+ return ip
62
+ except Exception:
63
+ return "127.0.0.1"
64
+
65
+ def human_size(n: int) -> str:
66
+ for unit in ("B", "KB", "MB", "GB"):
67
+ if n < 1024:
68
+ return f"{n:.1f} {unit}"
69
+ n /= 1024
70
+ return f"{n:.1f} TB"
71
+
72
+ def make_qr_png(url: str) -> bytes:
73
+ img = qrcode.make(url)
74
+ buf = io.BytesIO()
75
+ img.save(buf, format="PNG")
76
+ buf.seek(0)
77
+ return buf.read()
78
+
79
+ def get_public_url() -> Optional[str]:
80
+ try:
81
+ txt = URL_FILE.read_text().strip()
82
+ return txt if txt else None
83
+ except Exception:
84
+ return None
85
+
86
+ # ─── Ngrok Tunnel ────────────────────────────────────────────────────────────
87
+ def start_ngrok_tunnel(port: int):
88
+ URL_FILE.write_text("", encoding="utf-8")
89
+
90
+ auth_token = os.environ.get("NGROK_AUTH_TOKEN")
91
+ if not auth_token:
92
+ print("\n [!] NGROK_AUTH_TOKEN is missing in .env file.")
93
+ print(" [!] Running strictly on Local Network.")
94
+ URL_FILE.write_text("FAILED", encoding="utf-8")
95
+ return
96
+
97
+ try:
98
+ ngrok.set_auth_token(auth_token)
99
+ # Suppress verbose ngrok logs
100
+ conf.get_default().log_event_callback = lambda log: None
101
+
102
+ domain = os.environ.get("NGROK_DOMAIN")
103
+ if domain:
104
+ tunnel = ngrok.connect(port, domain=domain)
105
+ else:
106
+ tunnel = ngrok.connect(port)
107
+
108
+ public_url = tunnel.public_url
109
+ URL_FILE.write_text(public_url, encoding="utf-8")
110
+ print(f"\n [PUBLIC] Tunnel live: {public_url}")
111
+ print(f" [PUBLIC] Share with anyone!\n")
112
+ except Exception as e:
113
+ print(f"\n [WARN] Ngrok failed: {e}")
114
+ URL_FILE.write_text("FAILED", encoding="utf-8")
115
+
116
+ async def broadcast_to_room(room_code: str, message: dict):
117
+ if room_code in rooms:
118
+ for client in rooms[room_code]["clients"]:
119
+ try:
120
+ await client.send_json(message)
121
+ except Exception:
122
+ pass
123
+
124
+ # ─── WebSockets (Chat & Live Sync) ─────────────────────────────���─────────────
125
+ @app.websocket("/ws/{room_code}")
126
+ async def websocket_endpoint(websocket: WebSocket, room_code: str):
127
+ await websocket.accept()
128
+ if room_code not in rooms:
129
+ rooms[room_code] = {"clients": [], "messages": []}
130
+ (UPLOAD_DIR / room_code).mkdir(exist_ok=True)
131
+
132
+ rooms[room_code]["clients"].append(websocket)
133
+
134
+ # Send chat history to new connection
135
+ for msg in rooms[room_code]["messages"]:
136
+ await websocket.send_json(msg)
137
+
138
+ try:
139
+ while True:
140
+ data = await websocket.receive_text()
141
+ payload = json.loads(data)
142
+ if payload.get("type") == "chat":
143
+ msg = {
144
+ "type": "chat",
145
+ "sender": payload.get("sender", "Anonymous"),
146
+ "text": payload.get("text"),
147
+ "timestamp": datetime.now().strftime("%H:%M")
148
+ }
149
+ rooms[room_code]["messages"].append(msg)
150
+ await broadcast_to_room(room_code, msg)
151
+ except WebSocketDisconnect:
152
+ rooms[room_code]["clients"].remove(websocket)
153
+ except Exception as e:
154
+ if websocket in rooms[room_code]["clients"]:
155
+ rooms[room_code]["clients"].remove(websocket)
156
+
157
+ # ─── Routes ──────────────────────────────────────────────────────────────────
158
+
159
+ @app.api_route("/", methods=["GET", "HEAD"], response_class=HTMLResponse)
160
+ async def serve_index():
161
+ return HTMLResponse((FRONTEND_DIR / "index.html").read_text(encoding="utf-8"))
162
+
163
+ @app.get("/ip")
164
+ async def get_ip():
165
+ ip = get_local_ip()
166
+ public_url = get_public_url()
167
+ failed = public_url == "FAILED"
168
+ return {
169
+ "local_url": f"http://{ip}:{PORT}",
170
+ "public_url": None if (not public_url or failed) else public_url,
171
+ "tunnel_status": "failed" if failed else ("live" if public_url else "connecting"),
172
+ }
173
+
174
+ @app.get("/qr")
175
+ async def local_qr(room: str = ""):
176
+ url = f"http://{get_local_ip()}:{PORT}"
177
+ if room: url += f"?room={room}"
178
+ return Response(content=make_qr_png(url), media_type="image/png")
179
+
180
+ @app.get("/qr/public")
181
+ async def public_qr(room: str = ""):
182
+ url = get_public_url()
183
+ if not url or url == "FAILED":
184
+ raise HTTPException(503, "Tunnel not ready yet")
185
+ if room: url += f"?room={room}"
186
+ return Response(content=make_qr_png(url), media_type="image/png")
187
+
188
+ # ─── Room File Routes ────────────────────────────────────────────────────────
189
+
190
+ @app.get("/api/rooms/{room_code}/files")
191
+ async def list_files(room_code: str):
192
+ room_dir = UPLOAD_DIR / room_code
193
+ room_dir.mkdir(exist_ok=True)
194
+
195
+ files = []
196
+ for f in sorted(room_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
197
+ if f.is_file():
198
+ stat = f.stat()
199
+ mime, _ = mimetypes.guess_type(f.name)
200
+ files.append({
201
+ "name": f.name,
202
+ "size": stat.st_size,
203
+ "size_hr": human_size(stat.st_size),
204
+ "mime": mime or "application/octet-stream",
205
+ "modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M"),
206
+ })
207
+ return JSONResponse(files)
208
+
209
+ @app.post("/api/rooms/{room_code}/upload")
210
+ async def upload(room_code: str, file: UploadFile = File(...)):
211
+ room_dir = UPLOAD_DIR / room_code
212
+ room_dir.mkdir(exist_ok=True)
213
+
214
+ dest = room_dir / file.filename
215
+ stem = Path(file.filename).stem
216
+ suffix = Path(file.filename).suffix
217
+ n = 1
218
+ while dest.exists():
219
+ dest = room_dir / f"{stem} ({n}){suffix}"; n += 1
220
+
221
+ dest.write_bytes(await file.read())
222
+
223
+ # Broadcast to room that files updated
224
+ await broadcast_to_room(room_code, {"type": "files_updated"})
225
+
226
+ return {"filename": dest.name}
227
+
228
+ @app.get("/api/rooms/{room_code}/download/{filename}")
229
+ async def download(room_code: str, filename: str):
230
+ p = UPLOAD_DIR / room_code / filename
231
+ if not p.exists():
232
+ raise HTTPException(404, "Not found")
233
+ return FileResponse(p, filename=filename)
234
+
235
+ @app.delete("/api/rooms/{room_code}/files/{filename}")
236
+ async def delete(room_code: str, filename: str):
237
+ p = UPLOAD_DIR / room_code / filename
238
+ if not p.exists():
239
+ raise HTTPException(404, "Not found")
240
+ p.unlink()
241
+
242
+ await broadcast_to_room(room_code, {"type": "files_updated"})
243
+ return {"ok": True}
244
+
245
+ # ─── Entry Point ─────────────────────────────────────────────────────────────
246
+ if __name__ == "__main__":
247
+ ip = get_local_ip()
248
+
249
+ # If we are NOT running on Hugging Face Spaces, free port and prompt for Ngrok
250
+ is_huggingface = os.environ.get("SPACE_ID") is not None
251
+
252
+ if not is_huggingface:
253
+ free_port(PORT)
254
+ if not os.environ.get("NGROK_AUTH_TOKEN"):
255
+ print("\n" + "="*55)
256
+ print(" [!] FIXED URL SETUP REQUIRED")
257
+ print(" To get a fixed URL, you need a free Ngrok account.")
258
+ print(" 1. Go to: https://dashboard.ngrok.com/get-started/your-authtoken")
259
+ print(" 2. Copy your Authtoken.")
260
+ print(" 3. (Optional) Claim your free static domain at: https://dashboard.ngrok.com/cloud-edge/domains")
261
+ print(" (Or leave blank and press Enter to use Local Network only)")
262
+ print("="*55)
263
+ token = input(" Enter Ngrok Authtoken: ").strip()
264
+ if token:
265
+ domain = input(" (Optional) Enter your Ngrok Static Domain (e.g., xxx.ngrok-free.app) or leave blank: ").strip()
266
+ with open(BASE_DIR / ".env", "w") as f:
267
+ f.write(f"NGROK_AUTH_TOKEN={token}\n")
268
+ if domain:
269
+ f.write(f"NGROK_DOMAIN={domain}\n")
270
+ os.environ["NGROK_AUTH_TOKEN"] = token
271
+ if domain:
272
+ os.environ["NGROK_DOMAIN"] = domain
273
+ print(" [+] Saved to .env!")
274
+ else:
275
+ print(" [-] No token provided. Running in Local Mode only.")
276
+
277
+ threading.Thread(target=start_ngrok_tunnel, args=(PORT,), daemon=True).start()
278
+
279
+ print("\n" + "=" * 55)
280
+ print(" RadarDrop v2 is LIVE")
281
+ print(f" Local : http://localhost:{PORT}")
282
+ if is_huggingface:
283
+ print(" Running in Hugging Face Space")
284
+ else:
285
+ print(f" Network: http://{ip}:{PORT}")
286
+ print("=" * 55 + "\n")
287
+
288
+ uvicorn.run(app, host="0.0.0.0", port=PORT)
backend/public_url.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ https://generic-gorged-trial.ngrok-free.dev
backend/requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.111.0
2
+ uvicorn==0.30.1
3
+ python-multipart==0.0.9
4
+ qrcode[pil]==7.4.2
5
+ Pillow==10.3.0
6
+ aiofiles==23.2.1
7
+ pyngrok==7.2.0
8
+
9
+ websockets
10
+ python-dotenv
11
+ pyngrok
frontend/index.html ADDED
@@ -0,0 +1,470 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"/>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
6
+ <title>RadarDrop v2</title>
7
+ <style>
8
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
9
+
10
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
11
+
12
+ :root{
13
+ --bg:#000;
14
+ --surface:rgba(255,255,255,0.04);
15
+ --surface-hover:rgba(255,255,255,0.08);
16
+ --border:rgba(255,255,255,0.1);
17
+ --text:#fff;
18
+ --muted:rgba(255,255,255,0.45);
19
+ --glow:rgba(255,255,255,0.12);
20
+ --danger:rgba(255,80,80,0.85);
21
+ --green:rgba(80,255,140,0.85);
22
+ }
23
+
24
+ html,body{height:100%;background:var(--bg);color:var(--text);font-family:'Inter',system-ui,sans-serif;overflow:hidden}
25
+
26
+ /* ── Layout ── */
27
+ .app-container{display:none;grid-template-columns:300px 1fr 340px;height:100vh}
28
+ .app-container.active{display:grid}
29
+
30
+ /* ── Landing Page ── */
31
+ .landing{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;text-align:center}
32
+ .landing.hidden{display:none}
33
+ .brand-title{font-size:3rem;font-weight:700;letter-spacing:1px;margin-bottom:8px;display:flex;align-items:center;gap:16px}
34
+ .brand-dot{width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 0 16px #fff,0 0 32px #fff}
35
+ .landing-sub{color:var(--muted);font-size:1.1rem;margin-bottom:40px}
36
+
37
+ .room-box{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:32px;width:100%;max-width:400px}
38
+ .input-code{width:100%;background:rgba(0,0,0,0.3);border:1px solid var(--border);color:#fff;font-size:1.5rem;text-align:center;padding:16px;border-radius:12px;letter-spacing:8px;outline:none;font-family:monospace;transition:border .2s}
39
+ .input-code:focus{border-color:rgba(255,255,255,0.5)}
40
+ .input-code::placeholder{color:var(--muted);letter-spacing:2px;font-family:'Inter',sans-serif;font-size:1rem}
41
+
42
+ .btn{
43
+ display:inline-flex;align-items:center;justify-content:center;gap:8px;
44
+ padding:12px 24px;border-radius:10px;font-size:.9rem;font-weight:600;
45
+ cursor:pointer;transition:all .2s;border:1px solid var(--border);
46
+ background:rgba(255,255,255,0.08);color:#fff;font-family:inherit;width:100%;
47
+ }
48
+ .btn:hover{background:rgba(255,255,255,0.15);border-color:rgba(255,255,255,0.3);transform:translateY(-1px)}
49
+ .btn-primary{background:#fff;color:#000;border:none}
50
+ .btn-primary:hover{background:rgba(255,255,255,0.85);box-shadow:0 0 16px var(--glow)}
51
+ .btn-sm{padding:6px 14px;font-size:.75rem;border-radius:8px;width:auto}
52
+ .btn-danger{background:rgba(255,50,50,0.1);border-color:rgba(255,80,80,0.3);color:rgba(255,130,130,1)}
53
+
54
+ .divider{display:flex;align-items:center;text-align:center;color:var(--muted);font-size:.8rem;margin:24px 0}
55
+ .divider::before,.divider::after{content:'';flex:1;border-bottom:1px solid var(--border)}
56
+ .divider:not(:empty)::before{margin-right:.5em}
57
+ .divider:not(:empty)::after{margin-left:.5em}
58
+
59
+ /* ── Sidebar (Left) ── */
60
+ .sidebar{
61
+ background:rgba(255,255,255,0.02);border-right:1px solid var(--border);
62
+ display:flex;flex-direction:column;align-items:center;padding:24px 16px;gap:24px;overflow-y:auto;
63
+ }
64
+ .room-badge{background:#fff;color:#000;padding:6px 16px;border-radius:20px;font-weight:700;letter-spacing:2px;font-size:1.1rem;font-family:monospace}
65
+
66
+ /* ── Radar SVG ── */
67
+ .radar-wrap{position:relative;width:180px;height:180px}
68
+ .radar-wrap svg{width:100%;height:100%}
69
+ .radar-ring{fill:none;stroke:rgba(255,255,255,0.15);stroke-width:1}
70
+ .radar-sweep{transform-origin:90px 90px;animation:sweep 3s linear infinite}
71
+ @keyframes sweep{to{transform:rotate(360deg)}}
72
+ .radar-dot{fill:#fff;animation:blink 2s ease-in-out infinite}
73
+ @keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}
74
+ .blip{fill:#fff;opacity:0;animation:blipAnim 4s ease-in-out infinite}
75
+ @keyframes blipAnim{0%,100%{opacity:0}40%{opacity:.9}60%{opacity:.9}80%{opacity:0}}
76
+
77
+ /* ── QR Toggle ── */
78
+ .qr-wrap{width:100%;background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px;display:flex;flex-direction:column;align-items:center;gap:10px}
79
+ .qr-wrap img{width:140px;height:140px;border-radius:8px;border:2px solid rgba(255,255,255,0.1)}
80
+ .qr-toggle{display:flex;width:100%;border:1px solid var(--border);border-radius:8px;overflow:hidden}
81
+ .qr-tab{flex:1;padding:6px;font-size:.7rem;font-weight:600;text-align:center;cursor:pointer;border:none;background:transparent;color:var(--muted);transition:all .2s}
82
+ .qr-tab.active{background:rgba(255,255,255,0.15);color:#fff}
83
+ .public-url-text{font-size:.65rem;color:var(--muted);word-break:break-all;text-align:center}
84
+
85
+ /* ── Main area (Center) ── */
86
+ .main{display:flex;flex-direction:column;padding:32px;overflow-y:auto}
87
+ .section-title{font-size:.75rem;text-transform:uppercase;letter-spacing:2px;color:var(--muted);margin-bottom:16px}
88
+
89
+ .drop-zone{
90
+ border:2px dashed rgba(255,255,255,0.2);border-radius:16px;
91
+ padding:40px 20px;text-align:center;cursor:pointer;
92
+ background:var(--surface);transition:all .2s;margin-bottom:32px;
93
+ }
94
+ .drop-zone:hover,.drop-zone.drag-over{border-color:rgba(255,255,255,0.5);background:var(--surface-hover)}
95
+ .drop-icon{font-size:2.5rem;margin-bottom:8px;display:block}
96
+ #fileInput{display:none}
97
+
98
+ /* ── File list ── */
99
+ .files-grid{display:flex;flex-direction:column;gap:8px}
100
+ .file-card{
101
+ display:flex;align-items:center;gap:16px;
102
+ background:var(--surface);border:1px solid var(--border);
103
+ border-radius:12px;padding:12px 16px;transition:transform .2s;
104
+ }
105
+ .file-card:hover{background:var(--surface-hover);transform:translateX(4px)}
106
+ .file-icon{font-size:1.5rem;width:40px;text-align:center}
107
+ .file-info{flex:1;min-width:0}
108
+ .file-name{font-size:.85rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
109
+ .file-meta{font-size:.65rem;color:var(--muted);margin-top:4px}
110
+
111
+ /* ── Chat area (Right) ── */
112
+ .chat-panel{
113
+ background:rgba(255,255,255,0.015);border-left:1px solid var(--border);
114
+ display:flex;flex-direction:column;height:100vh;
115
+ }
116
+ .chat-header{padding:20px;border-bottom:1px solid var(--border);font-weight:600;font-size:.9rem;display:flex;align-items:center;gap:10px}
117
+ .chat-messages{flex:1;padding:20px;overflow-y:auto;display:flex;flex-direction:column;gap:16px}
118
+
119
+ .chat-msg{display:flex;flex-direction:column;align-items:flex-start;max-width:85%}
120
+ .chat-msg.self{align-items:flex-end;align-self:flex-end}
121
+ .msg-sender{font-size:.65rem;color:var(--muted);margin-bottom:4px;margin-left:4px}
122
+ .chat-msg.self .msg-sender{margin-right:4px}
123
+ .msg-bubble{background:var(--surface);border:1px solid var(--border);padding:10px 14px;border-radius:14px;font-size:.85rem;line-height:1.4;word-wrap:break-word}
124
+ .chat-msg.self .msg-bubble{background:rgba(255,255,255,0.1);border-color:rgba(255,255,255,0.2)}
125
+ .msg-time{font-size:.6rem;color:var(--muted);margin-top:4px;align-self:flex-end}
126
+
127
+ .chat-input-wrap{padding:16px;border-top:1px solid var(--border);display:flex;gap:10px;background:rgba(0,0,0,0.5)}
128
+ .chat-input{flex:1;background:var(--surface);border:1px solid var(--border);color:#fff;padding:12px 16px;border-radius:24px;outline:none;font-size:.85rem;font-family:inherit;transition:border .2s}
129
+ .chat-input:focus{border-color:rgba(255,255,255,0.4)}
130
+ .chat-send{background:#fff;color:#000;border:none;width:40px;height:40px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:transform .2s}
131
+ .chat-send:hover{transform:scale(1.05)}
132
+
133
+ /* ── Toast ── */
134
+ .toast-wrap{position:fixed;top:24px;right:24px;display:flex;flex-direction:column;gap:10px;z-index:9999}
135
+ .toast{
136
+ background:rgba(255,255,255,0.1);backdrop-filter:blur(16px);
137
+ border:1px solid var(--border);border-radius:10px;
138
+ padding:12px 18px;font-size:.8rem;animation:toastIn .3s ease;
139
+ }
140
+ .toast.success{border-color:rgba(80,255,140,0.4);color:rgba(180,255,200,1)}
141
+ @keyframes toastIn{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:none}}
142
+
143
+ /* ── Name Modal ── */
144
+ .modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(4px);display:none;align-items:center;justify-content:center;z-index:100}
145
+ .modal-overlay.active{display:flex}
146
+ </style>
147
+ </head>
148
+ <body>
149
+
150
+ <div class="toast-wrap" id="toastWrap"></div>
151
+
152
+ <!-- Name Modal -->
153
+ <div class="modal-overlay" id="nameModal">
154
+ <div class="room-box">
155
+ <h2 style="margin-bottom:8px">What's your name?</h2>
156
+ <p class="landing-sub" style="margin-bottom:20px;font-size:.9rem">This will be shown in the room chat.</p>
157
+ <input type="text" id="usernameInput" class="input-code" placeholder="Enter name" style="letter-spacing:normal;text-align:left;font-family:inherit;margin-bottom:16px">
158
+ <button class="btn btn-primary" onclick="setName()">Continue</button>
159
+ </div>
160
+ </div>
161
+
162
+ <!-- Landing -->
163
+ <div class="landing" id="landingPage">
164
+ <div class="brand-title"><span class="brand-dot"></span> RadarDrop</div>
165
+ <div class="landing-sub">Private, secure, real-time file sharing rooms.</div>
166
+
167
+ <div class="room-box">
168
+ <button class="btn btn-primary" style="padding:16px;font-size:1.05rem" onclick="createRoom()">οΌ‹ Create New Room</button>
169
+ <div class="divider">OR</div>
170
+ <input type="text" id="joinCode" class="input-code" placeholder="PIN" maxlength="4" style="margin-bottom:12px">
171
+ <button class="btn" onclick="joinRoom()">Join Room</button>
172
+ </div>
173
+ </div>
174
+
175
+ <!-- Main App -->
176
+ <div class="app-container" id="app">
177
+
178
+ <!-- Sidebar -->
179
+ <aside class="sidebar">
180
+ <div style="font-weight:700;font-size:1.1rem;display:flex;align-items:center;gap:8px">
181
+ <span class="brand-dot" style="width:10px;height:10px"></span> RadarDrop
182
+ </div>
183
+
184
+ <div style="text-align:center;margin-top:10px">
185
+ <div style="font-size:.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:6px">Room Code</div>
186
+ <div class="room-badge" id="roomBadge">----</div>
187
+ </div>
188
+
189
+ <!-- Radar -->
190
+ <div class="radar-wrap">
191
+ <svg viewBox="0 0 180 180" xmlns="http://www.w3.org/2000/svg">
192
+ <circle cx="90" cy="90" r="88" fill="rgba(255,255,255,0.02)"/>
193
+ <circle class="radar-ring" cx="90" cy="90" r="70"/>
194
+ <circle class="radar-ring" cx="90" cy="90" r="45"/>
195
+ <circle class="radar-ring" cx="90" cy="90" r="20"/>
196
+ <line x1="90" y1="10" x2="90" y2="170" stroke="rgba(255,255,255,0.1)"/>
197
+ <line x1="10" y1="90" x2="170" y2="90" stroke="rgba(255,255,255,0.1)"/>
198
+ <g class="radar-sweep">
199
+ <defs>
200
+ <radialGradient id="sweepGrad" cx="90" cy="90" r="70" gradientUnits="userSpaceOnUse">
201
+ <stop offset="0%" stop-color="rgba(255,255,255,0)"/>
202
+ <stop offset="100%" stop-color="rgba(255,255,255,0.2)"/>
203
+ </radialGradient>
204
+ </defs>
205
+ <path d="M90,90 L90,20 A70,70 0 0,1 160,90 Z" fill="url(#sweepGrad)"/>
206
+ <line x1="90" y1="90" x2="90" y2="20" stroke="rgba(255,255,255,0.8)" stroke-width="1.5"/>
207
+ </g>
208
+ <circle class="blip" cx="120" cy="50" r="3"/>
209
+ <circle class="blip" cx="60" cy="120" r="2" style="animation-delay:1.5s"/>
210
+ <circle class="radar-dot" cx="90" cy="90" r="3"/>
211
+ </svg>
212
+ </div>
213
+
214
+ <!-- QR -->
215
+ <div class="qr-wrap">
216
+ <div class="qr-toggle">
217
+ <button class="qr-tab active" id="tabLocal" onclick="switchQR('local')">Local</button>
218
+ <button class="qr-tab" id="tabPublic" onclick="switchQR('public')">Internet</button>
219
+ </div>
220
+ <img id="qrImg" src="" alt="QR Code"/>
221
+ <div class="public-url-text" id="publicUrlText" style="margin-top:4px">Loading tunnel...</div>
222
+ </div>
223
+
224
+ <button class="btn btn-danger btn-sm" onclick="leaveRoom()" style="margin-top:auto">← Leave Room</button>
225
+ </aside>
226
+
227
+ <!-- Center: Files -->
228
+ <main class="main">
229
+ <div class="section-title">Share Files</div>
230
+ <div class="drop-zone" id="dropZone" onclick="document.getElementById('fileInput').click()">
231
+ <span class="drop-icon">πŸ“‘</span>
232
+ <div style="font-weight:600;font-size:1.1rem;margin-bottom:6px">Drop files here</div>
233
+ <div style="font-size:.8rem;color:var(--muted)">or click to browse</div>
234
+ <input type="file" id="fileInput" multiple/>
235
+ </div>
236
+
237
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
238
+ <div class="section-title" style="margin:0">Room Files</div>
239
+ </div>
240
+ <div class="files-grid" id="filesList"></div>
241
+ </main>
242
+
243
+ <!-- Right: Chat -->
244
+ <aside class="chat-panel">
245
+ <div class="chat-header">
246
+ πŸ’¬ Room Chat
247
+ </div>
248
+ <div class="chat-messages" id="chatMsgs">
249
+ <div style="text-align:center;color:var(--muted);font-size:.75rem;margin:auto">Chat history is synced automatically.</div>
250
+ </div>
251
+ <div class="chat-input-wrap">
252
+ <input type="text" id="chatInput" class="chat-input" placeholder="Type a message..." autocomplete="off">
253
+ <button class="chat-send" onclick="sendChat()">
254
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
255
+ </button>
256
+ </div>
257
+ </aside>
258
+ </div>
259
+
260
+ <script>
261
+ // ── State ──────────────────────────────────────────────────────────────────
262
+ let roomCode = null;
263
+ let username = localStorage.getItem('radardrop_name') || '';
264
+ let ws = null;
265
+ let publicUrl = null;
266
+ let localUrl = null;
267
+
268
+ // ── Init ───────────────────────────────────────────────────────────────────
269
+ window.onload = async () => {
270
+ if(!username) {
271
+ document.getElementById('nameModal').classList.add('active');
272
+ }
273
+
274
+ // Check URL params for room invite
275
+ const params = new URLSearchParams(window.location.search);
276
+ const invite = params.get('room');
277
+ if(invite && invite.length === 4){
278
+ document.getElementById('joinCode').value = invite;
279
+ }
280
+
281
+ // Load IP for QR codes
282
+ try{
283
+ const r = await fetch('/ip');
284
+ const d = await r.json();
285
+ localUrl = d.local_url;
286
+ publicUrl = d.public_url;
287
+ }catch(e){}
288
+ };
289
+
290
+ function setName(){
291
+ const val = document.getElementById('usernameInput').value.trim();
292
+ if(!val) return;
293
+ username = val;
294
+ localStorage.setItem('radardrop_name', username);
295
+ document.getElementById('nameModal').classList.remove('active');
296
+ }
297
+
298
+ function toast(msg, type='success'){
299
+ const el = document.createElement('div');
300
+ el.className = `toast ${type}`;
301
+ el.textContent = msg;
302
+ document.getElementById('toastWrap').appendChild(el);
303
+ setTimeout(()=>{ el.style.opacity='0'; el.style.transition='opacity .4s'; setTimeout(()=>el.remove(),400); }, 3000);
304
+ }
305
+
306
+ // ── Rooms ──────────────────────────────────────────────────────────────────
307
+ function generateCode(){ return Math.floor(1000 + Math.random() * 9000).toString(); }
308
+
309
+ function createRoom(){
310
+ const code = generateCode();
311
+ enterRoom(code);
312
+ }
313
+
314
+ function joinRoom(){
315
+ const code = document.getElementById('joinCode').value.trim();
316
+ if(code.length !== 4) return toast('Enter 4 digit PIN', 'error');
317
+ enterRoom(code);
318
+ }
319
+
320
+ function enterRoom(code){
321
+ roomCode = code;
322
+ document.getElementById('landingPage').classList.add('hidden');
323
+ document.getElementById('app').classList.add('active');
324
+ document.getElementById('roomBadge').textContent = code;
325
+
326
+ // Update URL without reload
327
+ const newUrl = window.location.protocol + "//" + window.location.host + window.location.pathname + "?room=" + code;
328
+ window.history.pushState({path:newUrl},'',newUrl);
329
+
330
+ switchQR('local');
331
+ loadFiles();
332
+ connectWS();
333
+ }
334
+
335
+ function leaveRoom(){
336
+ if(ws) ws.close();
337
+ roomCode = null;
338
+ document.getElementById('app').classList.remove('active');
339
+ document.getElementById('landingPage').classList.remove('hidden');
340
+ window.history.pushState({},'',window.location.pathname);
341
+ }
342
+
343
+ function switchQR(mode){
344
+ const img = document.getElementById('qrImg');
345
+ const txt = document.getElementById('publicUrlText');
346
+ document.getElementById('tabLocal').classList.toggle('active', mode==='local');
347
+ document.getElementById('tabPublic').classList.toggle('active', mode==='public');
348
+
349
+ if(mode === 'local'){
350
+ img.src = `/qr?room=${roomCode}&t=${Date.now()}`;
351
+ txt.textContent = 'Works on Local Wi-Fi';
352
+ } else {
353
+ if(publicUrl && publicUrl !== 'FAILED'){
354
+ img.src = `/qr/public?room=${roomCode}&t=${Date.now()}`;
355
+ txt.textContent = publicUrl;
356
+ } else {
357
+ img.src = '';
358
+ txt.textContent = 'Tunnel not active';
359
+ }
360
+ }
361
+ }
362
+
363
+ // ── WebSockets ─────────────────────────────────────────────────────────────
364
+ function connectWS(){
365
+ if(ws) ws.close();
366
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
367
+ const wsUrl = `${protocol}//${window.location.host}/ws/${roomCode}`;
368
+
369
+ ws = new WebSocket(wsUrl);
370
+ ws.onmessage = (e) => {
371
+ const data = JSON.parse(e.data);
372
+ if(data.type === 'chat'){
373
+ renderMessage(data);
374
+ } else if(data.type === 'files_updated'){
375
+ loadFiles();
376
+ }
377
+ };
378
+ ws.onclose = () => {
379
+ if(roomCode) setTimeout(connectWS, 2000); // Reconnect if still in room
380
+ };
381
+ }
382
+
383
+ function renderMessage(msg){
384
+ const box = document.getElementById('chatMsgs');
385
+ const isSelf = msg.sender === username;
386
+ const el = document.createElement('div');
387
+ el.className = `chat-msg ${isSelf ? 'self' : ''}`;
388
+ el.innerHTML = `
389
+ <div class="msg-sender">${isSelf ? 'You' : msg.sender}</div>
390
+ <div class="msg-bubble">${msg.text}</div>
391
+ <div class="msg-time">${msg.timestamp || ''}</div>
392
+ `;
393
+ box.appendChild(el);
394
+ box.scrollTop = box.scrollHeight;
395
+ }
396
+
397
+ function sendChat(){
398
+ const input = document.getElementById('chatInput');
399
+ const text = input.value.trim();
400
+ if(!text || !ws) return;
401
+ ws.send(JSON.stringify({type: 'chat', sender: username, text: text}));
402
+ input.value = '';
403
+ }
404
+
405
+ document.getElementById('chatInput').addEventListener('keypress', e => {
406
+ if(e.key === 'Enter') sendChat();
407
+ });
408
+
409
+ // ── Files ──────────────────────────────────────────────────────────────────
410
+ function fileIcon(mime=''){
411
+ if(mime.startsWith('image/')) return 'πŸ–ΌοΈ';
412
+ if(mime.startsWith('video/')) return '🎬';
413
+ if(mime.startsWith('audio/')) return '🎡';
414
+ if(mime.includes('pdf')) return 'πŸ“„';
415
+ if(mime.includes('zip')) return 'πŸ—œοΈ';
416
+ if(mime.includes('text')||mime.includes('json')||mime.includes('javascript')) return 'πŸ“';
417
+ return 'πŸ“„';
418
+ }
419
+
420
+ async function loadFiles(){
421
+ if(!roomCode) return;
422
+ try{
423
+ const r = await fetch(`/api/rooms/${roomCode}/files`);
424
+ const files = await r.json();
425
+ const el = document.getElementById('filesList');
426
+ if(!files.length){
427
+ el.innerHTML = `<div style="text-align:center;padding:40px;color:var(--muted)">No files in this room yet.</div>`;
428
+ return;
429
+ }
430
+ el.innerHTML = files.map(f=>`
431
+ <div class="file-card">
432
+ <div class="file-icon">${fileIcon(f.mime)}</div>
433
+ <div class="file-info">
434
+ <div class="file-name" title="${f.name}">${f.name}</div>
435
+ <div class="file-meta">${f.size_hr} Β· ${f.modified}</div>
436
+ </div>
437
+ <div style="display:flex;gap:6px">
438
+ <a href="/api/rooms/${roomCode}/download/${encodeURIComponent(f.name)}" download class="btn btn-sm">⬇</a>
439
+ <button class="btn btn-sm btn-danger" onclick="deleteFile('${f.name.replace(/'/g,"\\'")}')">βœ•</button>
440
+ </div>
441
+ </div>
442
+ `).join('');
443
+ }catch(e){
444
+ console.error(e);
445
+ }
446
+ }
447
+
448
+ async function deleteFile(name){
449
+ if(!confirm(`Delete "${name}"?`)) return;
450
+ const r = await fetch(`/api/rooms/${roomCode}/files/${encodeURIComponent(name)}`, {method:'DELETE'});
451
+ if(r.ok) toast('Deleted', 'success');
452
+ }
453
+
454
+ const dz = document.getElementById('dropZone');
455
+ dz.ondragover = e => { e.preventDefault(); dz.classList.add('drag-over'); };
456
+ dz.ondragleave = () => dz.classList.remove('drag-over');
457
+ dz.ondrop = e => { e.preventDefault(); dz.classList.remove('drag-over'); uploadFiles([...e.dataTransfer.files]); };
458
+ document.getElementById('fileInput').onchange = e => uploadFiles([...e.target.files]);
459
+
460
+ async function uploadFiles(files){
461
+ if(!files.length || !roomCode) return;
462
+ for(let f of files){
463
+ const fd = new FormData(); fd.append('file', f);
464
+ await fetch(`/api/rooms/${roomCode}/upload`, { method:'POST', body:fd });
465
+ }
466
+ toast(`Uploaded ${files.length} file(s)`, 'success');
467
+ }
468
+ </script>
469
+ </body>
470
+ </html>