vsmdvic commited on
Commit
c5893c2
·
verified ·
1 Parent(s): f47d12b

Upload 7 files

Browse files
Files changed (7) hide show
  1. Dockerfile +21 -0
  2. README.md +44 -10
  3. requirements.txt +9 -0
  4. server.py +167 -0
  5. static/css/style.css +536 -0
  6. static/js/app.js +311 -0
  7. templates/index.html +172 -0
Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ gcc \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ COPY requirements.txt .
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ COPY . .
13
+
14
+ RUN mkdir -p /tmp/tds_uploads
15
+
16
+ ENV PORT=7860
17
+ ENV PYTHONUNBUFFERED=1
18
+
19
+ EXPOSE 7860
20
+
21
+ CMD ["python", "server.py"]
README.md CHANGED
@@ -1,10 +1,44 @@
1
- ---
2
- title: Tds
3
- emoji: 📊
4
- colorFrom: gray
5
- colorTo: pink
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TDS Transfer
2
+
3
+ **Secure QR-based file transfer. Files auto-delete in 5 minutes.**
4
+
5
+ ## Deploy on HuggingFace Spaces
6
+
7
+ 1. Create a new Space → Docker SDK
8
+ 2. Upload all files maintaining folder structure:
9
+ ```
10
+ tds-transfer/
11
+ ├── Dockerfile
12
+ ├── server.py
13
+ ├── requirements.txt
14
+ ├── templates/
15
+ │ └── index.html
16
+ └── static/
17
+ ├── css/style.css
18
+ └── js/app.js
19
+ ```
20
+ 3. Add Space secret: `APP_URL` = your HuggingFace Space URL
21
+ Example: `https://yourname-tds-transfer.hf.space`
22
+
23
+ 4. Space auto-builds and deploys.
24
+
25
+ ## How it works
26
+
27
+ - **Sender** generates a QR code (optionally attaches files)
28
+ - **Receiver** scans QR with camera from the same app
29
+ - Session lasts **5 minutes** then all files are permanently deleted
30
+ - Works on any device — no install needed
31
+
32
+ ## Local dev
33
+
34
+ ```bash
35
+ pip install -r requirements.txt
36
+ python server.py
37
+ # → http://localhost:7860
38
+ ```
39
+
40
+ ## Tech Stack
41
+ - Flask + Flask-SocketIO (WebSockets for real-time)
42
+ - jsQR (browser QR scanning)
43
+ - qrcode (server QR generation)
44
+ - Pure HTML/CSS/JS frontend — no framework
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ flask==3.0.3
2
+ flask-socketio==5.3.6
3
+ qrcode[pil]==7.4.2
4
+ Pillow==10.4.0
5
+ python-engineio==4.9.1
6
+ python-socketio==5.11.3
7
+ eventlet==0.36.1
8
+ gunicorn==22.0.0
9
+ Werkzeug==3.0.3
server.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+ import json
4
+ import time
5
+ import threading
6
+ import qrcode
7
+ import io
8
+ import base64
9
+ from datetime import datetime, timedelta
10
+ from flask import Flask, request, jsonify, render_template, send_file
11
+ from flask_socketio import SocketIO, emit, join_room
12
+ from werkzeug.utils import secure_filename
13
+
14
+ app = Flask(__name__)
15
+ app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'tds-secret-2025')
16
+ app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024 # 500MB
17
+ app.config['UPLOAD_FOLDER'] = '/tmp/tds_uploads'
18
+
19
+ os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
20
+
21
+ socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')
22
+
23
+ # In-memory session store
24
+ sessions = {}
25
+ EXPIRY_SECONDS = 300 # 5 minutes
26
+
27
+ def cleanup_expired():
28
+ while True:
29
+ time.sleep(30)
30
+ now = datetime.utcnow()
31
+ expired = [sid for sid, s in list(sessions.items()) if now > s['expires_at']]
32
+ for sid in expired:
33
+ s = sessions.pop(sid, {})
34
+ for f in s.get('files', []):
35
+ try:
36
+ os.remove(f['path'])
37
+ except:
38
+ pass
39
+ socketio.emit('session_expired', {'session_id': sid}, room=sid)
40
+
41
+ threading.Thread(target=cleanup_expired, daemon=True).start()
42
+
43
+ def generate_qr(data: str) -> str:
44
+ qr = qrcode.QRCode(version=1, box_size=8, border=2,
45
+ error_correction=qrcode.constants.ERROR_CORRECT_H)
46
+ qr.add_data(data)
47
+ qr.make(fit=True)
48
+ img = qr.make_image(fill_color="white", back_color="black")
49
+ buf = io.BytesIO()
50
+ img.save(buf, format='PNG')
51
+ return base64.b64encode(buf.getvalue()).decode()
52
+
53
+ @app.route('/')
54
+ def index():
55
+ return render_template('index.html')
56
+
57
+ @app.route('/api/session/create', methods=['POST'])
58
+ def create_session():
59
+ data = request.json or {}
60
+ session_id = str(uuid.uuid4())
61
+ mode = data.get('mode', 'send') # send | receive
62
+
63
+ user_info = {
64
+ 'name': data.get('name', 'Anonymous'),
65
+ 'cp': data.get('cp', ''),
66
+ 'created_at': datetime.utcnow().isoformat()
67
+ }
68
+
69
+ sessions[session_id] = {
70
+ 'id': session_id,
71
+ 'mode': mode,
72
+ 'user': user_info,
73
+ 'files': [],
74
+ 'created_at': datetime.utcnow(),
75
+ 'expires_at': datetime.utcnow() + timedelta(seconds=EXPIRY_SECONDS),
76
+ 'status': 'waiting',
77
+ 'connected_peers': []
78
+ }
79
+
80
+ app_url = os.environ.get('APP_URL', request.host_url.rstrip('/'))
81
+ qr_url = f"{app_url}/session/{session_id}"
82
+ qr_img = generate_qr(qr_url)
83
+
84
+ return jsonify({
85
+ 'session_id': session_id,
86
+ 'qr_image': qr_img,
87
+ 'qr_url': qr_url,
88
+ 'expires_in': EXPIRY_SECONDS,
89
+ 'mode': mode
90
+ })
91
+
92
+ @app.route('/api/session/<session_id>', methods=['GET'])
93
+ def get_session(session_id):
94
+ s = sessions.get(session_id)
95
+ if not s:
96
+ return jsonify({'error': 'Session not found or expired'}), 404
97
+ remaining = max(0, int((s['expires_at'] - datetime.utcnow()).total_seconds()))
98
+ return jsonify({
99
+ 'session_id': session_id,
100
+ 'mode': s['mode'],
101
+ 'user': s['user'],
102
+ 'status': s['status'],
103
+ 'files': [{'name': f['name'], 'size': f['size'], 'type': f['type']} for f in s['files']],
104
+ 'expires_in': remaining
105
+ })
106
+
107
+ @app.route('/api/session/<session_id>/upload', methods=['POST'])
108
+ def upload_file(session_id):
109
+ s = sessions.get(session_id)
110
+ if not s:
111
+ return jsonify({'error': 'Session expired'}), 404
112
+
113
+ uploaded = []
114
+ for file in request.files.getlist('files'):
115
+ filename = secure_filename(file.filename)
116
+ file_id = str(uuid.uuid4())
117
+ path = os.path.join(app.config['UPLOAD_FOLDER'], f"{file_id}_{filename}")
118
+ file.save(path)
119
+ size = os.path.getsize(path)
120
+ entry = {'id': file_id, 'name': filename, 'size': size,
121
+ 'type': file.content_type, 'path': path}
122
+ s['files'].append(entry)
123
+ uploaded.append({'name': filename, 'size': size, 'type': file.content_type})
124
+
125
+ s['status'] = 'ready'
126
+ socketio.emit('files_ready', {
127
+ 'session_id': session_id,
128
+ 'files': uploaded,
129
+ 'user': s['user']
130
+ }, room=session_id)
131
+
132
+ return jsonify({'uploaded': len(uploaded), 'files': uploaded})
133
+
134
+ @app.route('/api/session/<session_id>/download/<file_id>')
135
+ def download_file(session_id, file_id):
136
+ s = sessions.get(session_id)
137
+ if not s:
138
+ return jsonify({'error': 'Session expired'}), 404
139
+ for f in s['files']:
140
+ if f['id'] == file_id:
141
+ return send_file(f['path'], as_attachment=True, download_name=f['name'])
142
+ return jsonify({'error': 'File not found'}), 404
143
+
144
+ @app.route('/session/<session_id>')
145
+ def session_page(session_id):
146
+ return render_template('index.html', prefill_session=session_id)
147
+
148
+ @socketio.on('join_session')
149
+ def on_join(data):
150
+ session_id = data.get('session_id')
151
+ if session_id in sessions:
152
+ join_room(session_id)
153
+ s = sessions[session_id]
154
+ s['connected_peers'].append(request.sid)
155
+ emit('peer_joined', {'session_id': session_id, 'status': s['status']}, room=session_id)
156
+
157
+ @socketio.on('ping_session')
158
+ def on_ping(data):
159
+ session_id = data.get('session_id')
160
+ if session_id in sessions:
161
+ s = sessions[session_id]
162
+ remaining = max(0, int((s['expires_at'] - datetime.utcnow()).total_seconds()))
163
+ emit('session_tick', {'expires_in': remaining, 'status': s['status']})
164
+
165
+ if __name__ == '__main__':
166
+ port = int(os.environ.get('PORT', 7860))
167
+ socketio.run(app, host='0.0.0.0', port=port, debug=False)
static/css/style.css ADDED
@@ -0,0 +1,536 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
2
+
3
+ :root {
4
+ --black: #080808;
5
+ --white: #ffffff;
6
+ --gray1: #111111;
7
+ --gray2: #1a1a1a;
8
+ --gray3: #2a2a2a;
9
+ --gray4: #444444;
10
+ --gray5: #888888;
11
+ --gray6: #bbbbbb;
12
+ --accent: #ffffff;
13
+ --blur: blur(3.5px);
14
+ --glass-bg: rgba(255,255,255,0.04);
15
+ --glass-border: rgba(255,255,255,0.09);
16
+ --radius: 16px;
17
+ --radius-sm: 10px;
18
+ }
19
+
20
+ html, body {
21
+ height: 100%;
22
+ background: var(--black);
23
+ color: var(--white);
24
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
25
+ font-size: 16px;
26
+ line-height: 1.6;
27
+ overflow-x: hidden;
28
+ }
29
+
30
+ /* Noise texture overlay */
31
+ .noise {
32
+ position: fixed;
33
+ inset: 0;
34
+ pointer-events: none;
35
+ z-index: 0;
36
+ opacity: 0.03;
37
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E");
38
+ background-size: 200px 200px;
39
+ }
40
+
41
+ /* HEADER */
42
+ header {
43
+ position: fixed;
44
+ top: 0; left: 0; right: 0;
45
+ z-index: 100;
46
+ display: flex;
47
+ align-items: center;
48
+ justify-content: space-between;
49
+ padding: 18px 32px;
50
+ background: rgba(8,8,8,0.7);
51
+ backdrop-filter: var(--blur);
52
+ -webkit-backdrop-filter: var(--blur);
53
+ border-bottom: 0.5px solid var(--glass-border);
54
+ }
55
+
56
+ .logo {
57
+ display: flex;
58
+ align-items: center;
59
+ gap: 12px;
60
+ font-size: 18px;
61
+ font-weight: 700;
62
+ letter-spacing: 1px;
63
+ }
64
+ .logo em { font-style: normal; color: var(--gray5); font-weight: 400; }
65
+
66
+ .status-bar {
67
+ display: flex;
68
+ align-items: center;
69
+ gap: 8px;
70
+ font-size: 12px;
71
+ color: var(--gray5);
72
+ letter-spacing: 1px;
73
+ }
74
+ .dot {
75
+ width: 7px; height: 7px;
76
+ border-radius: 50%;
77
+ background: var(--gray4);
78
+ display: inline-block;
79
+ }
80
+ .dot.connected { background: #4ade80; box-shadow: 0 0 6px #4ade80aa; }
81
+ .dot.green { background: #4ade80; }
82
+
83
+ /* MAIN */
84
+ main {
85
+ min-height: 100vh;
86
+ padding-top: 80px;
87
+ position: relative;
88
+ z-index: 1;
89
+ }
90
+
91
+ /* VIEWS */
92
+ .view {
93
+ display: none;
94
+ padding: 48px 32px;
95
+ max-width: 520px;
96
+ margin: 0 auto;
97
+ animation: fadeUp 0.4s ease;
98
+ }
99
+ .view.active { display: block; }
100
+
101
+ @keyframes fadeUp {
102
+ from { opacity: 0; transform: translateY(20px); }
103
+ to { opacity: 1; transform: translateY(0); }
104
+ }
105
+
106
+ /* HERO */
107
+ .hero { margin-bottom: 52px; }
108
+ .hero h1 {
109
+ font-size: clamp(42px, 8vw, 64px);
110
+ font-weight: 700;
111
+ line-height: 1.05;
112
+ letter-spacing: -2px;
113
+ margin-bottom: 16px;
114
+ }
115
+ .hero p { font-size: 15px; color: var(--gray5); }
116
+ .hero strong { color: var(--white); }
117
+
118
+ /* ACTION GRID */
119
+ .action-grid {
120
+ display: grid;
121
+ grid-template-columns: 1fr 1fr;
122
+ gap: 16px;
123
+ }
124
+
125
+ .card-btn {
126
+ background: var(--glass-bg);
127
+ border: 0.5px solid var(--glass-border);
128
+ backdrop-filter: var(--blur);
129
+ -webkit-backdrop-filter: var(--blur);
130
+ border-radius: var(--radius);
131
+ padding: 28px 24px;
132
+ cursor: pointer;
133
+ color: var(--white);
134
+ text-align: left;
135
+ transition: background 0.2s, border-color 0.2s, transform 0.15s;
136
+ display: flex;
137
+ flex-direction: column;
138
+ gap: 12px;
139
+ }
140
+ .card-btn:hover {
141
+ background: rgba(255,255,255,0.08);
142
+ border-color: rgba(255,255,255,0.18);
143
+ transform: translateY(-2px);
144
+ }
145
+ .card-btn:active { transform: scale(0.98); }
146
+
147
+ .card-icon {
148
+ width: 44px; height: 44px;
149
+ border: 0.5px solid var(--glass-border);
150
+ border-radius: var(--radius-sm);
151
+ display: flex; align-items: center; justify-content: center;
152
+ }
153
+ .card-icon svg { width: 22px; height: 22px; }
154
+
155
+ .card-title { font-size: 16px; font-weight: 600; }
156
+ .card-sub { font-size: 12px; color: var(--gray5); }
157
+
158
+ /* BACK BAR */
159
+ .back-bar { margin-bottom: 28px; }
160
+ .back-bar button {
161
+ background: none;
162
+ border: none;
163
+ color: var(--gray5);
164
+ cursor: pointer;
165
+ font-size: 14px;
166
+ letter-spacing: 0.5px;
167
+ padding: 0;
168
+ transition: color 0.2s;
169
+ }
170
+ .back-bar button:hover { color: var(--white); }
171
+
172
+ h2 {
173
+ font-size: 28px;
174
+ font-weight: 700;
175
+ letter-spacing: -0.5px;
176
+ margin-bottom: 28px;
177
+ }
178
+
179
+ /* FORM GLASS */
180
+ .form-glass {
181
+ background: var(--glass-bg);
182
+ border: 0.5px solid var(--glass-border);
183
+ backdrop-filter: var(--blur);
184
+ -webkit-backdrop-filter: var(--blur);
185
+ border-radius: var(--radius);
186
+ padding: 32px;
187
+ display: flex;
188
+ flex-direction: column;
189
+ gap: 12px;
190
+ }
191
+
192
+ label { font-size: 12px; color: var(--gray5); letter-spacing: 1px; text-transform: uppercase; }
193
+
194
+ input[type=text] {
195
+ background: rgba(255,255,255,0.05);
196
+ border: 0.5px solid var(--glass-border);
197
+ border-radius: var(--radius-sm);
198
+ color: var(--white);
199
+ font-size: 15px;
200
+ padding: 12px 16px;
201
+ outline: none;
202
+ width: 100%;
203
+ transition: border-color 0.2s;
204
+ font-family: inherit;
205
+ }
206
+ input[type=text]:focus { border-color: rgba(255,255,255,0.35); }
207
+ input[type=text]::placeholder { color: var(--gray4); }
208
+
209
+ /* BUTTONS */
210
+ .btn-primary {
211
+ background: var(--white);
212
+ color: var(--black);
213
+ border: none;
214
+ border-radius: var(--radius-sm);
215
+ padding: 14px 28px;
216
+ font-size: 15px;
217
+ font-weight: 700;
218
+ cursor: pointer;
219
+ width: 100%;
220
+ margin-top: 8px;
221
+ transition: opacity 0.2s, transform 0.15s;
222
+ letter-spacing: 0.3px;
223
+ }
224
+ .btn-primary:hover { opacity: 0.9; }
225
+ .btn-primary:active { transform: scale(0.98); }
226
+
227
+ /* QR */
228
+ .qr-wrap {
229
+ display: flex;
230
+ flex-direction: column;
231
+ align-items: center;
232
+ gap: 20px;
233
+ margin-bottom: 32px;
234
+ }
235
+ #qr-img {
236
+ width: 220px; height: 220px;
237
+ border-radius: var(--radius);
238
+ border: 0.5px solid var(--glass-border);
239
+ background: #fff;
240
+ padding: 8px;
241
+ }
242
+
243
+ .qr-timer {
244
+ display: flex;
245
+ flex-direction: column;
246
+ align-items: center;
247
+ font-size: 12px;
248
+ color: var(--gray5);
249
+ letter-spacing: 1px;
250
+ gap: 2px;
251
+ }
252
+ #qr-countdown {
253
+ font-size: 22px;
254
+ font-weight: 700;
255
+ color: var(--white);
256
+ font-variant-numeric: tabular-nums;
257
+ }
258
+
259
+ /* DROP ZONE */
260
+ .file-drop-zone {
261
+ border: 0.5px dashed var(--gray3);
262
+ border-radius: var(--radius);
263
+ padding: 32px;
264
+ text-align: center;
265
+ cursor: pointer;
266
+ transition: border-color 0.2s, background 0.2s;
267
+ display: flex;
268
+ flex-direction: column;
269
+ align-items: center;
270
+ gap: 10px;
271
+ color: var(--gray5);
272
+ margin-bottom: 20px;
273
+ }
274
+ .file-drop-zone:hover {
275
+ border-color: var(--gray5);
276
+ background: var(--glass-bg);
277
+ }
278
+ .file-drop-zone p { font-size: 14px; }
279
+ .file-drop-zone small { font-size: 12px; color: var(--gray4); }
280
+
281
+ .peer-status {
282
+ display: flex;
283
+ align-items: center;
284
+ gap: 8px;
285
+ font-size: 13px;
286
+ color: #4ade80;
287
+ justify-content: center;
288
+ margin-top: 16px;
289
+ }
290
+ .peer-status.hidden { display: none; }
291
+
292
+ /* CAMERA */
293
+ .camera-wrap {
294
+ position: relative;
295
+ border-radius: var(--radius);
296
+ overflow: hidden;
297
+ background: var(--gray1);
298
+ aspect-ratio: 1;
299
+ max-width: 380px;
300
+ margin: 0 auto;
301
+ }
302
+ #camera-feed {
303
+ width: 100%; height: 100%;
304
+ object-fit: cover;
305
+ display: block;
306
+ }
307
+ .scan-frame {
308
+ position: absolute;
309
+ inset: 30px;
310
+ pointer-events: none;
311
+ }
312
+ .corner {
313
+ position: absolute;
314
+ width: 24px; height: 24px;
315
+ border-color: white;
316
+ border-style: solid;
317
+ opacity: 0.8;
318
+ }
319
+ .corner.tl { top: 0; left: 0; border-width: 2px 0 0 2px; }
320
+ .corner.tr { top: 0; right: 0; border-width: 2px 2px 0 0; }
321
+ .corner.bl { bottom: 0; left: 0; border-width: 0 0 2px 2px; }
322
+ .corner.br { bottom: 0; right: 0; border-width: 0 2px 2px 0; }
323
+
324
+ .scan-line {
325
+ position: absolute;
326
+ left: 0; right: 0;
327
+ height: 1.5px;
328
+ background: white;
329
+ opacity: 0.6;
330
+ animation: scanMove 2.5s ease-in-out infinite;
331
+ }
332
+ @keyframes scanMove {
333
+ 0%,100% { top: 0; }
334
+ 50% { top: 100%; }
335
+ }
336
+
337
+ .cam-overlay {
338
+ position: absolute;
339
+ inset: 0;
340
+ background: rgba(8,8,8,0.7);
341
+ backdrop-filter: var(--blur);
342
+ display: flex;
343
+ flex-direction: column;
344
+ align-items: center;
345
+ justify-content: center;
346
+ gap: 20px;
347
+ color: var(--gray6);
348
+ font-size: 14px;
349
+ letter-spacing: 1px;
350
+ }
351
+ .cam-overlay.hidden { display: none; }
352
+
353
+ /* PROCESSING */
354
+ .process-center {
355
+ display: flex;
356
+ flex-direction: column;
357
+ align-items: center;
358
+ justify-content: center;
359
+ min-height: 60vh;
360
+ gap: 24px;
361
+ text-align: center;
362
+ }
363
+ .process-center h2 { font-size: 24px; margin-bottom: 0; }
364
+ .process-center p { color: var(--gray5); font-size: 14px; }
365
+
366
+ .process-steps {
367
+ display: flex;
368
+ flex-direction: column;
369
+ gap: 12px;
370
+ margin-top: 16px;
371
+ width: 100%;
372
+ max-width: 280px;
373
+ }
374
+ .step {
375
+ display: flex;
376
+ align-items: center;
377
+ gap: 12px;
378
+ font-size: 13px;
379
+ color: var(--gray4);
380
+ transition: color 0.4s;
381
+ letter-spacing: 0.5px;
382
+ }
383
+ .step.done { color: var(--white); }
384
+ .step-dot {
385
+ width: 8px; height: 8px;
386
+ border-radius: 50%;
387
+ background: var(--gray3);
388
+ border: 1px solid var(--gray4);
389
+ flex-shrink: 0;
390
+ transition: background 0.4s, border-color 0.4s;
391
+ }
392
+ .step.done .step-dot { background: var(--white); border-color: var(--white); }
393
+
394
+ /* TRANSFER */
395
+ .transfer-header {
396
+ text-align: center;
397
+ margin-bottom: 32px;
398
+ }
399
+ .transfer-anim {
400
+ display: flex;
401
+ justify-content: center;
402
+ margin-bottom: 24px;
403
+ }
404
+ .transfer-header h2 { font-size: 26px; }
405
+ .transfer-header p { color: var(--gray5); font-size: 14px; }
406
+ .transfer-header strong { color: var(--white); }
407
+
408
+ .files-list {
409
+ display: flex;
410
+ flex-direction: column;
411
+ gap: 10px;
412
+ margin-bottom: 24px;
413
+ }
414
+ .file-item {
415
+ background: var(--glass-bg);
416
+ border: 0.5px solid var(--glass-border);
417
+ backdrop-filter: var(--blur);
418
+ border-radius: var(--radius-sm);
419
+ padding: 16px 20px;
420
+ display: flex;
421
+ align-items: center;
422
+ gap: 14px;
423
+ }
424
+ .file-icon {
425
+ width: 36px; height: 36px;
426
+ border: 0.5px solid var(--glass-border);
427
+ border-radius: 8px;
428
+ display: flex; align-items: center; justify-content: center;
429
+ flex-shrink: 0;
430
+ }
431
+ .file-name { font-size: 14px; font-weight: 500; flex: 1; word-break: break-all; }
432
+ .file-size { font-size: 12px; color: var(--gray5); flex-shrink: 0; }
433
+
434
+ .expire-note {
435
+ text-align: center;
436
+ font-size: 12px;
437
+ color: var(--gray4);
438
+ margin-top: 14px;
439
+ letter-spacing: 0.5px;
440
+ }
441
+ #dl-countdown { color: var(--white); font-weight: 600; }
442
+
443
+ /* SUCCESS */
444
+ .success-center {
445
+ display: flex;
446
+ flex-direction: column;
447
+ align-items: center;
448
+ justify-content: center;
449
+ min-height: 70vh;
450
+ text-align: center;
451
+ gap: 16px;
452
+ }
453
+ .success-icon {
454
+ width: 80px; height: 80px;
455
+ border-radius: 50%;
456
+ border: 0.5px solid var(--glass-border);
457
+ background: var(--glass-bg);
458
+ backdrop-filter: var(--blur);
459
+ display: flex; align-items: center; justify-content: center;
460
+ margin-bottom: 8px;
461
+ }
462
+ .success-center h2 { font-size: 28px; }
463
+ .success-center p { color: var(--gray5); }
464
+
465
+ /* ---- ANIMATION 1: pulsing rings ---- */
466
+ .loader-anim1 {
467
+ width: 52px; height: 52px;
468
+ display: inline-block;
469
+ position: relative;
470
+ flex-shrink: 0;
471
+ }
472
+ .loader-anim1::after, .loader-anim1::before {
473
+ content: '';
474
+ box-sizing: border-box;
475
+ width: 52px; height: 52px;
476
+ border-radius: 50%;
477
+ border: 2px solid #fff;
478
+ position: absolute;
479
+ left: 0; top: 0;
480
+ animation: anim1 2s linear infinite;
481
+ }
482
+ .loader-anim1::after { animation-delay: 1s; }
483
+ @keyframes anim1 {
484
+ 0% { transform: scale(0); opacity: 1; }
485
+ 100% { transform: scale(1); opacity: 0; }
486
+ }
487
+
488
+ /* ---- ANIMATION 2: data stream bars ---- */
489
+ .loader-anim2 {
490
+ display: flex;
491
+ align-items: flex-end;
492
+ gap: 5px;
493
+ height: 52px;
494
+ }
495
+ .loader-anim2::before,
496
+ .loader-anim2::after {
497
+ content: '';
498
+ display: block;
499
+ }
500
+ .bar {
501
+ width: 6px;
502
+ background: white;
503
+ border-radius: 2px;
504
+ animation: barPulse 1.2s ease-in-out infinite;
505
+ }
506
+ .bar:nth-child(1) { animation-delay: 0s; height: 20px; }
507
+ .bar:nth-child(2) { animation-delay: 0.15s; height: 32px; }
508
+ .bar:nth-child(3) { animation-delay: 0.3s; height: 48px; }
509
+ .bar:nth-child(4) { animation-delay: 0.45s; height: 36px; }
510
+ .bar:nth-child(5) { animation-delay: 0.6s; height: 24px; }
511
+ .bar:nth-child(6) { animation-delay: 0.75s; height: 40px; }
512
+ .bar:nth-child(7) { animation-delay: 0.9s; height: 28px; }
513
+
514
+ @keyframes barPulse {
515
+ 0%, 100% { opacity: 0.2; transform: scaleY(0.5); }
516
+ 50% { opacity: 1; transform: scaleY(1); }
517
+ }
518
+
519
+ /* INFO BOX */
520
+ .info-box {
521
+ background: var(--glass-bg);
522
+ border: 0.5px solid var(--glass-border);
523
+ border-radius: var(--radius-sm);
524
+ padding: 16px 20px;
525
+ font-size: 13px;
526
+ color: var(--gray5);
527
+ margin-bottom: 20px;
528
+ line-height: 1.7;
529
+ }
530
+ .info-box strong { color: var(--white); }
531
+
532
+ @media (max-width: 480px) {
533
+ .action-grid { grid-template-columns: 1fr; }
534
+ header { padding: 16px 20px; }
535
+ .view { padding: 32px 20px; }
536
+ }
static/js/app.js ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const socket = io();
2
+ let currentSession = null;
3
+ let currentMode = null;
4
+ let countdownInterval = null;
5
+ let scanInterval = null;
6
+ let cameraStream = null;
7
+ let selectedFiles = [];
8
+
9
+ // --- SOCKET SETUP ---
10
+ socket.on('connect', () => {
11
+ document.getElementById('conn-dot').className = 'dot connected';
12
+ document.getElementById('conn-label').textContent = 'Connected';
13
+ });
14
+ socket.on('disconnect', () => {
15
+ document.getElementById('conn-dot').className = 'dot';
16
+ document.getElementById('conn-label').textContent = 'Disconnected';
17
+ });
18
+ socket.on('peer_joined', (data) => {
19
+ document.getElementById('peer-status').classList.remove('hidden');
20
+ });
21
+ socket.on('files_ready', (data) => {
22
+ if (currentMode === 'receive') showTransfer(data);
23
+ });
24
+ socket.on('session_expired', () => {
25
+ alert('Session expired. Files deleted.');
26
+ goHome();
27
+ });
28
+ socket.on('session_tick', (data) => {
29
+ updateCountdown(data.expires_in);
30
+ });
31
+
32
+ // --- NAVIGATION ---
33
+ function showView(id) {
34
+ document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
35
+ document.getElementById(id).classList.add('active');
36
+ window.scrollTo(0, 0);
37
+ }
38
+
39
+ function goHome() {
40
+ stopCamera();
41
+ clearInterval(countdownInterval);
42
+ currentSession = null;
43
+ currentMode = null;
44
+ selectedFiles = [];
45
+ showView('view-home');
46
+ }
47
+
48
+ // --- HOME ACTIONS ---
49
+ function startFlow(mode) {
50
+ currentMode = mode;
51
+ document.getElementById('setup-title').textContent = mode === 'send' ? 'Who\'s sending?' : 'Who\'s receiving?';
52
+ document.getElementById('setup-btn').textContent = mode === 'send' ? 'Generate QR →' : 'Open Camera →';
53
+ showView('view-setup');
54
+ }
55
+
56
+ function generateCP() {
57
+ return Math.floor(10000000 + Math.random() * 89999999).toString();
58
+ }
59
+
60
+ async function confirmSetup() {
61
+ const name = document.getElementById('input-name').value.trim() || 'Anonymous';
62
+ const cp = document.getElementById('input-cp').value.trim() || generateCP();
63
+
64
+ if (currentMode === 'send') {
65
+ await createSendSession(name, cp);
66
+ } else {
67
+ await openCamera(name, cp);
68
+ }
69
+ }
70
+
71
+ // --- SENDER FLOW ---
72
+ async function createSendSession(name, cp) {
73
+ showView('view-qr');
74
+ try {
75
+ const res = await fetch('/api/session/create', {
76
+ method: 'POST',
77
+ headers: {'Content-Type': 'application/json'},
78
+ body: JSON.stringify({mode: 'send', name, cp})
79
+ });
80
+ const data = await res.json();
81
+ currentSession = data;
82
+
83
+ document.getElementById('qr-img').src = `data:image/png;base64,${data.qr_image}`;
84
+ socket.emit('join_session', {session_id: data.session_id});
85
+ startCountdown(data.expires_in, 'qr-countdown');
86
+ } catch (e) {
87
+ alert('Failed to create session. Check server.');
88
+ goHome();
89
+ }
90
+ }
91
+
92
+ function handleDrop(e) {
93
+ e.preventDefault();
94
+ addFiles([...e.dataTransfer.files]);
95
+ }
96
+
97
+ function handleFileSelect(input) {
98
+ addFiles([...input.files]);
99
+ }
100
+
101
+ function addFiles(files) {
102
+ selectedFiles = [...selectedFiles, ...files];
103
+ const label = document.getElementById('files-attached');
104
+ label.textContent = `${selectedFiles.length} file(s) attached — ${formatBytes(selectedFiles.reduce((a,f) => a+f.size, 0))}`;
105
+ if (currentSession) uploadFiles();
106
+ }
107
+
108
+ async function uploadFiles() {
109
+ if (!currentSession || selectedFiles.length === 0) return;
110
+ const form = new FormData();
111
+ selectedFiles.forEach(f => form.append('files', f));
112
+ try {
113
+ await fetch(`/api/session/${currentSession.session_id}/upload`, {
114
+ method: 'POST', body: form
115
+ });
116
+ } catch (e) { console.error('Upload failed', e); }
117
+ }
118
+
119
+ // --- RECEIVER FLOW ---
120
+ async function openCamera() {
121
+ showView('view-scan');
122
+ try {
123
+ cameraStream = await navigator.mediaDevices.getUserMedia({video: {facingMode: 'environment'}});
124
+ const video = document.getElementById('camera-feed');
125
+ video.srcObject = cameraStream;
126
+ video.play();
127
+ scanInterval = setInterval(() => scanFrame(video), 200);
128
+ } catch (e) {
129
+ alert('Camera access denied. Cannot scan QR.');
130
+ goHome();
131
+ }
132
+ }
133
+
134
+ function scanFrame(video) {
135
+ if (video.readyState !== video.HAVE_ENOUGH_DATA) return;
136
+ const canvas = document.createElement('canvas');
137
+ canvas.width = video.videoWidth;
138
+ canvas.height = video.videoHeight;
139
+ const ctx = canvas.getContext('2d');
140
+ ctx.drawImage(video, 0, 0);
141
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
142
+ const code = jsQR(imageData.data, imageData.width, imageData.height, {inversionAttempts: 'dontInvert'});
143
+ if (code) {
144
+ clearInterval(scanInterval);
145
+ stopCamera();
146
+ processScannedURL(code.data);
147
+ }
148
+ }
149
+
150
+ function stopCamera() {
151
+ clearInterval(scanInterval);
152
+ if (cameraStream) {
153
+ cameraStream.getTracks().forEach(t => t.stop());
154
+ cameraStream = null;
155
+ }
156
+ }
157
+
158
+ async function processScannedURL(url) {
159
+ showView('view-processing');
160
+ let sessionId = null;
161
+ try {
162
+ const u = new URL(url);
163
+ const parts = u.pathname.split('/');
164
+ sessionId = parts[parts.length - 1];
165
+ } catch {
166
+ sessionId = url;
167
+ }
168
+
169
+ await simulateStep('step1', 1000);
170
+ await simulateStep('step2', 1500);
171
+
172
+ try {
173
+ const res = await fetch(`/api/session/${sessionId}`);
174
+ if (!res.ok) throw new Error('Session not found');
175
+ const data = await res.json();
176
+ socket.emit('join_session', {session_id: sessionId});
177
+ currentSession = {session_id: sessionId};
178
+
179
+ await simulateStep('step3', 800);
180
+
181
+ if (data.files && data.files.length > 0) {
182
+ showTransfer({session_id: sessionId, user: data.user, files: data.files, expires_in: data.expires_in});
183
+ } else {
184
+ showReceiveWaiting(sessionId, data.user, data.expires_in);
185
+ }
186
+ } catch (e) {
187
+ alert('Session expired or not found.');
188
+ goHome();
189
+ }
190
+ }
191
+
192
+ async function receiveFromSession(sessionId) {
193
+ currentMode = 'receive';
194
+ currentSession = {session_id: sessionId};
195
+ showView('view-processing');
196
+ await simulateStep('step1', 600);
197
+ await simulateStep('step2', 1000);
198
+ try {
199
+ const res = await fetch(`/api/session/${sessionId}`);
200
+ if (!res.ok) throw new Error();
201
+ const data = await res.json();
202
+ socket.emit('join_session', {session_id: sessionId});
203
+ await simulateStep('step3', 600);
204
+ if (data.files && data.files.length > 0) {
205
+ showTransfer({session_id: sessionId, user: data.user, files: data.files, expires_in: data.expires_in});
206
+ } else {
207
+ showReceiveWaiting(sessionId, data.user, data.expires_in);
208
+ }
209
+ } catch {
210
+ alert('Session not found.');
211
+ goHome();
212
+ }
213
+ }
214
+
215
+ function simulateStep(stepId, delay) {
216
+ return new Promise(resolve => {
217
+ setTimeout(() => {
218
+ const el = document.getElementById(stepId);
219
+ if (el) el.classList.add('done');
220
+ resolve();
221
+ }, delay);
222
+ });
223
+ }
224
+
225
+ function showReceiveWaiting(sessionId, user, expiresIn) {
226
+ document.getElementById('proc-title').textContent = 'Waiting for files...';
227
+ document.getElementById('proc-sub').textContent = `Connected to ${user?.name || 'sender'}`;
228
+ startCountdown(expiresIn || 300, null);
229
+ }
230
+
231
+ function showTransfer(data) {
232
+ showView('view-transfer');
233
+ document.getElementById('sender-name').textContent = data.user?.name || 'Unknown';
234
+
235
+ // Build animation 2
236
+ const animEl = document.getElementById('transfer-anim');
237
+ animEl.innerHTML = '<div class="loader-anim2">' + Array(7).fill('<div class="bar"></div>').join('') + '</div>';
238
+
239
+ const list = document.getElementById('files-list');
240
+ list.innerHTML = '';
241
+
242
+ if (data.files && data.files.length > 0) {
243
+ data.files.forEach(f => {
244
+ const item = document.createElement('div');
245
+ item.className = 'file-item';
246
+ item.innerHTML = `
247
+ <div class="file-icon">
248
+ <svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="1.2" width="18" height="18">
249
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
250
+ <polyline points="14 2 14 8 20 8"/>
251
+ </svg>
252
+ </div>
253
+ <span class="file-name">${f.name}</span>
254
+ <span class="file-size">${formatBytes(f.size)}</span>
255
+ `;
256
+ list.appendChild(item);
257
+ });
258
+ } else {
259
+ list.innerHTML = `<div class="info-box">No files attached — only sender info shared.<br/><strong>From:</strong> ${data.user?.name || '—'}</div>`;
260
+ document.getElementById('download-all-btn').style.display = 'none';
261
+ }
262
+
263
+ startCountdown(data.expires_in || 300, 'dl-countdown');
264
+ }
265
+
266
+ async function downloadAll() {
267
+ if (!currentSession) return;
268
+ const res = await fetch(`/api/session/${currentSession.session_id}`);
269
+ const data = await res.json();
270
+ for (const f of (data.files || [])) {
271
+ const a = document.createElement('a');
272
+ a.href = `/api/session/${currentSession.session_id}/download/${f.id || ''}`;
273
+ a.download = f.name;
274
+ a.click();
275
+ await new Promise(r => setTimeout(r, 400));
276
+ }
277
+ setTimeout(() => showView('view-success'), 1000);
278
+ }
279
+
280
+ // --- UTILS ---
281
+ function startCountdown(seconds, elementId) {
282
+ clearInterval(countdownInterval);
283
+ let remaining = seconds;
284
+ function tick() {
285
+ const m = Math.floor(remaining / 60);
286
+ const s = remaining % 60;
287
+ const str = `${m}:${s.toString().padStart(2, '0')}`;
288
+ if (elementId) {
289
+ const el = document.getElementById(elementId);
290
+ if (el) el.textContent = str;
291
+ }
292
+ if (currentSession) {
293
+ socket.emit('ping_session', {session_id: currentSession.session_id});
294
+ }
295
+ if (remaining <= 0) { clearInterval(countdownInterval); return; }
296
+ remaining--;
297
+ }
298
+ tick();
299
+ countdownInterval = setInterval(tick, 1000);
300
+ }
301
+
302
+ function updateCountdown(secs) {
303
+ // sync from server
304
+ }
305
+
306
+ function formatBytes(bytes) {
307
+ if (!bytes) return '—';
308
+ if (bytes < 1024) return bytes + ' B';
309
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
310
+ return (bytes / 1048576).toFixed(1) + ' MB';
311
+ }
templates/index.html ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>TDS Transfer</title>
7
+ <link rel="stylesheet" href="/static/css/style.css"/>
8
+ <script src="https://cdn.jsdelivr.net/npm/socket.io-client@4.7.2/dist/socket.io.min.js"></script>
9
+ <script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js"></script>
10
+ </head>
11
+ <body>
12
+
13
+ <div class="noise"></div>
14
+
15
+ <header>
16
+ <div class="logo">
17
+ <svg width="32" height="32" viewBox="0 0 32 32" fill="none">
18
+ <line x1="4" y1="8" x2="18" y2="8" stroke="white" stroke-width="2.5" stroke-linecap="square"/>
19
+ <line x1="11" y1="8" x2="11" y2="24" stroke="white" stroke-width="2.5" stroke-linecap="square"/>
20
+ <path d="M20,8 L20,24 M20,8 L26,8 Q30,8 30,14 Q30,20 26,20 L20,20" stroke="white" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="round" fill="none"/>
21
+ </svg>
22
+ <span>TDS <em>Transfer</em></span>
23
+ </div>
24
+ <div class="status-bar">
25
+ <span class="dot" id="conn-dot"></span>
26
+ <span id="conn-label">Connecting...</span>
27
+ </div>
28
+ </header>
29
+
30
+ <main>
31
+
32
+ <!-- HOME -->
33
+ <section id="view-home" class="view active">
34
+ <div class="hero">
35
+ <h1>Secure<br/>File Transfer</h1>
36
+ <p>Instant. Encrypted in transit. Auto-deleted in <strong>5 minutes</strong>.</p>
37
+ </div>
38
+ <div class="action-grid">
39
+ <button class="card-btn" onclick="startFlow('send')">
40
+ <div class="card-icon">
41
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
42
+ <path d="M12 19V5M5 12l7-7 7 7"/>
43
+ </svg>
44
+ </div>
45
+ <span class="card-title">Send Files</span>
46
+ <span class="card-sub">Generate QR → receiver scans</span>
47
+ </button>
48
+ <button class="card-btn" onclick="startFlow('receive')">
49
+ <div class="card-icon">
50
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
51
+ <path d="M12 5v14M5 12l7 7 7-7"/>
52
+ </svg>
53
+ </div>
54
+ <span class="card-title">Receive Files</span>
55
+ <span class="card-sub">Scan QR from sender</span>
56
+ </button>
57
+ </div>
58
+ </section>
59
+
60
+ <!-- SETUP -->
61
+ <section id="view-setup" class="view">
62
+ <div class="back-bar"><button onclick="goHome()">← Back</button></div>
63
+ <h2 id="setup-title">Setup</h2>
64
+ <div class="form-glass">
65
+ <label>Your name</label>
66
+ <input type="text" id="input-name" placeholder="Ex: Ion Popescu" autocomplete="off"/>
67
+ <label>Personal Code (optional)</label>
68
+ <input type="text" id="input-cp" placeholder="Auto-generated if empty" autocomplete="off"/>
69
+ <button class="btn-primary" id="setup-btn" onclick="confirmSetup()">Continue →</button>
70
+ </div>
71
+ </section>
72
+
73
+ <!-- QR SHOW (sender) -->
74
+ <section id="view-qr" class="view">
75
+ <div class="back-bar"><button onclick="goHome()">← Cancel</button></div>
76
+ <h2>Share this QR</h2>
77
+ <div class="qr-wrap">
78
+ <img id="qr-img" src="" alt="QR Code"/>
79
+ <div class="qr-timer">
80
+ <span id="qr-countdown">5:00</span>
81
+ <span>remaining</span>
82
+ </div>
83
+ </div>
84
+ <div class="file-drop-zone" id="drop-zone"
85
+ onclick="document.getElementById('file-input').click()"
86
+ ondragover="event.preventDefault()"
87
+ ondrop="handleDrop(event)">
88
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" width="40" height="40">
89
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
90
+ </svg>
91
+ <p>Drop files here or click to attach</p>
92
+ <small id="files-attached">No files attached — only your info will be shared</small>
93
+ </div>
94
+ <input type="file" id="file-input" multiple style="display:none" onchange="handleFileSelect(this)"/>
95
+ <div id="peer-status" class="peer-status hidden">
96
+ <span class="dot green"></span> Receiver connected
97
+ </div>
98
+ </section>
99
+
100
+ <!-- SCANNING -->
101
+ <section id="view-scan" class="view">
102
+ <div class="back-bar"><button onclick="goHome()">← Cancel</button></div>
103
+ <h2>Scan QR Code</h2>
104
+ <div class="camera-wrap">
105
+ <video id="camera-feed" autoplay playsinline muted></video>
106
+ <div class="scan-frame">
107
+ <div class="corner tl"></div>
108
+ <div class="corner tr"></div>
109
+ <div class="corner bl"></div>
110
+ <div class="corner br"></div>
111
+ <div class="scan-line"></div>
112
+ </div>
113
+ <div class="cam-overlay" id="scan-overlay">
114
+ <div class="loader-anim1"></div>
115
+ <p>Scanning...</p>
116
+ </div>
117
+ </div>
118
+ </section>
119
+
120
+ <!-- PROCESSING -->
121
+ <section id="view-processing" class="view">
122
+ <div class="process-center">
123
+ <div class="loader-anim1" id="proc-loader"></div>
124
+ <h2 id="proc-title">Connecting to session...</h2>
125
+ <p id="proc-sub">Establishing secure channel</p>
126
+ <div class="process-steps">
127
+ <div class="step" id="step1"><span class="step-dot"></span> QR Decoded</div>
128
+ <div class="step" id="step2"><span class="step-dot"></span> Session Verified</div>
129
+ <div class="step" id="step3"><span class="step-dot"></span> Files Ready</div>
130
+ </div>
131
+ </div>
132
+ </section>
133
+
134
+ <!-- TRANSFER READY -->
135
+ <section id="view-transfer" class="view">
136
+ <div class="transfer-header">
137
+ <div class="transfer-anim" id="transfer-anim">
138
+ <div class="loader-anim2"></div>
139
+ </div>
140
+ <h2 id="transfer-title">Transfer Ready</h2>
141
+ <p id="transfer-sub">From: <strong id="sender-name">—</strong></p>
142
+ </div>
143
+ <div id="files-list" class="files-list"></div>
144
+ <button class="btn-primary" id="download-all-btn" onclick="downloadAll()">Download All</button>
145
+ <p class="expire-note">Files auto-delete in <span id="dl-countdown">5:00</span></p>
146
+ </section>
147
+
148
+ <!-- SUCCESS -->
149
+ <section id="view-success" class="view">
150
+ <div class="success-center">
151
+ <div class="success-icon">
152
+ <svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" width="48" height="48">
153
+ <polyline points="20 6 9 17 4 12"/>
154
+ </svg>
155
+ </div>
156
+ <h2>Transfer Complete</h2>
157
+ <p>Files delivered successfully.</p>
158
+ <button class="btn-primary" onclick="goHome()">New Transfer</button>
159
+ </div>
160
+ </section>
161
+
162
+ </main>
163
+
164
+ <script src="/static/js/app.js"></script>
165
+ <script>
166
+ const prefillSession = "{{ prefill_session if prefill_session else '' }}";
167
+ if (prefillSession) {
168
+ window.addEventListener('load', () => receiveFromSession(prefillSession));
169
+ }
170
+ </script>
171
+ </body>
172
+ </html>