my-airdrop 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # my-airdrop
2
+
3
+ Share files over your local network — like AirDrop, but from your terminal.
4
+
5
+ ```
6
+ npx my-airdrop
7
+ ```
8
+
9
+ Opens a web interface on your local network. Anyone on the same WiFi can upload and download files by scanning a QR code — no app, no account, no cable needed.
10
+
11
+ ## Preview
12
+
13
+ ```
14
+ ◆ my-airdrop
15
+
16
+ Serving ~/Desktop/projects
17
+
18
+ Local http://localhost:3000
19
+ Network http://192.168.1.5:3000
20
+
21
+ [QR CODE]
22
+
23
+ Scan QR or open the Network URL on any device
24
+ Ctrl+C to stop
25
+
26
+ ────────────────────────────────────────────────
27
+
28
+ 17:22:12 ↓ 192.168.1.10 README.md (2.1 KB)
29
+ 17:22:45 ↑ 192.168.1.10 photo.jpg (3.4 MB)
30
+ ```
31
+
32
+ ## Features
33
+
34
+ - **Download** — browse and download files from any device on the network
35
+ - **Upload** — send files from your phone to your computer (tap or drag & drop)
36
+ - **Folder download** — zip and download entire folders in one tap
37
+ - **Multi-select** — select multiple files and download as a zip
38
+ - **QR code** — instantly connect with your phone camera
39
+ - **Mobile-optimized** — large touch targets, responsive layout, dark UI
40
+ - **Safety limits** — warns on large directories, hard limit at 5000 files / 5 GB
41
+
42
+ ## Usage
43
+
44
+ ```bash
45
+ # Serve current directory
46
+ npx my-airdrop
47
+
48
+ # Serve a specific folder
49
+ npx my-airdrop ./photos
50
+
51
+ # Custom port
52
+ npx my-airdrop --port 8080
53
+
54
+ # Read-only (disable uploads)
55
+ npx my-airdrop --no-upload
56
+ ```
57
+
58
+ ## Install globally
59
+
60
+ ```bash
61
+ npm install -g my-airdrop
62
+ my-airdrop
63
+ ```
64
+
65
+ ## Requirements
66
+
67
+ - Node.js >= 14
68
+ - Both devices on the same WiFi network
69
+
70
+ ## License
71
+
72
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const fs = require('fs');
7
+ const chalk = require('chalk');
8
+ const qrcode = require('qrcode-terminal');
9
+ const { createServer } = require('../src/server');
10
+
11
+ // ── Parse args ───────────────────────────────────────
12
+ const args = process.argv.slice(2);
13
+ let servePath = process.cwd();
14
+ let port = 3000;
15
+ let allowUpload = true;
16
+
17
+ for (let i = 0; i < args.length; i++) {
18
+ if (args[i] === '--port' || args[i] === '-p') port = parseInt(args[++i]) || 3000;
19
+ else if (args[i] === '--no-upload') allowUpload = false;
20
+ else if (!args[i].startsWith('-')) servePath = path.resolve(args[i]);
21
+ }
22
+
23
+ if (!fs.existsSync(servePath) || !fs.statSync(servePath).isDirectory()) {
24
+ console.error(chalk.red(' Not a directory: ' + servePath));
25
+ process.exit(1);
26
+ }
27
+
28
+ // ── Safety scan ──────────────────────────────────────
29
+ function quickScan(dir, depth = 0) {
30
+ let count = 0, size = 0;
31
+ try {
32
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
33
+ if (e.name.startsWith('.')) continue;
34
+ const full = path.join(dir, e.name);
35
+ if (e.isDirectory() && depth < 2) {
36
+ const s = quickScan(full, depth + 1);
37
+ count += s.count; size += s.size;
38
+ } else if (e.isFile()) {
39
+ count++;
40
+ try { size += fs.statSync(full).size; } catch {}
41
+ }
42
+ }
43
+ } catch {}
44
+ return { count, size };
45
+ }
46
+
47
+ function fmtBytes(b) {
48
+ if (b < 1e6) return (b / 1e3).toFixed(1) + ' KB';
49
+ if (b < 1e9) return (b / 1e6).toFixed(1) + ' MB';
50
+ return (b / 1e9).toFixed(1) + ' GB';
51
+ }
52
+
53
+ function fmtBytes2(b) {
54
+ if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
55
+ if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB';
56
+ return (b / 1073741824).toFixed(1) + ' GB';
57
+ }
58
+
59
+ function shortenPath(p) {
60
+ const home = os.homedir();
61
+ return p.startsWith(home) ? '~' + p.slice(home.length) : p;
62
+ }
63
+
64
+ function getLocalIP() {
65
+ for (const ifaces of Object.values(os.networkInterfaces())) {
66
+ for (const net of ifaces) {
67
+ if (net.family === 'IPv4' && !net.internal &&
68
+ (net.address.startsWith('192.168.') || net.address.startsWith('10.'))) {
69
+ return net.address;
70
+ }
71
+ }
72
+ }
73
+ // fallback: any non-internal IPv4
74
+ for (const ifaces of Object.values(os.networkInterfaces())) {
75
+ for (const net of ifaces) {
76
+ if (net.family === 'IPv4' && !net.internal) return net.address;
77
+ }
78
+ }
79
+ return 'localhost';
80
+ }
81
+
82
+ // ── Main ─────────────────────────────────────────────
83
+ async function main() {
84
+ const { count, size } = quickScan(servePath);
85
+
86
+ // Hard limit
87
+ if (count > 5000 || size > 5e9) {
88
+ console.log('');
89
+ console.log(' ' + chalk.red('✖ Directory is too large to serve safely'));
90
+ console.log(chalk.gray(` ${count.toLocaleString()} files · ${fmtBytes(size)}`));
91
+ console.log(chalk.gray(' Try specifying a subdirectory: ') + chalk.white('my-airdrop ./subfolder'));
92
+ console.log('');
93
+ process.exit(1);
94
+ }
95
+
96
+ // Soft warning
97
+ if (count > 300 || size > 200e6) {
98
+ console.log('');
99
+ console.log(' ' + chalk.yellow('⚠ Large directory'));
100
+ console.log(chalk.gray(` ${count.toLocaleString()} files · ${fmtBytes(size)}`));
101
+ console.log('');
102
+
103
+ if (process.stdin.isTTY) {
104
+ process.stdout.write(chalk.gray(' Press Enter to continue, Ctrl+C to cancel: '));
105
+ await new Promise(resolve => {
106
+ process.stdin.setRawMode(true);
107
+ process.stdin.resume();
108
+ process.stdin.once('data', key => {
109
+ process.stdin.setRawMode(false);
110
+ process.stdin.pause();
111
+ if (key[0] === 3) { console.log(); process.exit(0); } // Ctrl+C
112
+ console.log('');
113
+ resolve();
114
+ });
115
+ });
116
+ }
117
+ }
118
+
119
+ const localIP = getLocalIP();
120
+ const networkURL = `http://${localIP}:${port}`;
121
+ const localURL = `http://localhost:${port}`;
122
+
123
+ const { server, events } = createServer(servePath, { allowUpload });
124
+
125
+ events.on('log', ({ method, filePath, ip, size: sz }) => {
126
+ const time = new Date().toLocaleTimeString('en', { hour12: false });
127
+ let arrow, label;
128
+ if (method === 'UPLOAD') {
129
+ arrow = chalk.hex('#818cf8')('↑');
130
+ label = chalk.hex('#818cf8')(filePath);
131
+ } else if (method === 'BROWSE') {
132
+ arrow = chalk.gray('→');
133
+ label = chalk.gray(filePath);
134
+ } else {
135
+ arrow = chalk.green('↓');
136
+ label = chalk.white(filePath);
137
+ }
138
+ const sizeStr = sz ? chalk.gray(` (${fmtBytes2(sz)})`) : '';
139
+ console.log(` ${chalk.gray(time)} ${arrow} ${chalk.gray(ip.padEnd(15))} ${label}${sizeStr}`);
140
+ });
141
+
142
+ server.listen(port, '0.0.0.0', () => {
143
+ console.clear();
144
+ console.log('');
145
+ console.log(' ' + chalk.bold.hex('#818cf8')('◆ ') + chalk.bold.white('my-airdrop'));
146
+ console.log('');
147
+ console.log(' ' + chalk.gray('Serving ') + chalk.cyan(shortenPath(servePath)));
148
+ console.log('');
149
+ console.log(' ' + chalk.gray('Local ') + chalk.white(localURL));
150
+ console.log(' ' + chalk.gray('Network ') + chalk.bold.white(networkURL));
151
+ console.log('');
152
+
153
+ qrcode.generate(networkURL, { small: true }, qr => {
154
+ qr.split('\n').forEach(line => console.log(' ' + line));
155
+ });
156
+
157
+ console.log('');
158
+ console.log(chalk.gray(' Scan QR or open the Network URL on any device'));
159
+ if (!allowUpload) console.log(chalk.gray(' ⊘ Upload disabled (read-only)'));
160
+ console.log(chalk.gray(' Ctrl+C to stop'));
161
+ console.log('');
162
+ console.log(chalk.gray(' ' + '─'.repeat(48)));
163
+ console.log('');
164
+ });
165
+
166
+ process.on('SIGINT', () => {
167
+ console.log('\n' + chalk.gray(' Stopped.'));
168
+ process.exit(0);
169
+ });
170
+ }
171
+
172
+ main().catch(e => {
173
+ console.error(chalk.red(' Error: ' + e.message));
174
+ process.exit(1);
175
+ });
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "my-airdrop",
3
+ "version": "1.0.0",
4
+ "description": "Share files over local network — like AirDrop but from your terminal",
5
+ "keywords": ["file-sharing", "airdrop", "local", "network", "cli", "upload", "download"],
6
+ "author": "mingjaam",
7
+ "license": "MIT",
8
+ "bin": { "my-airdrop": "bin/cli.js" },
9
+ "files": ["bin", "src"],
10
+ "engines": { "node": ">=14.0.0" },
11
+ "dependencies": {
12
+ "archiver": "^6.0.0",
13
+ "busboy": "^1.6.0",
14
+ "chalk": "^4.1.2",
15
+ "qrcode-terminal": "^0.12.0"
16
+ }
17
+ }
package/src/server.js ADDED
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { EventEmitter } = require('events');
7
+ const archiver = require('archiver');
8
+ const busboy = require('busboy');
9
+
10
+ const HTML = fs.readFileSync(path.join(__dirname, 'ui.html'), 'utf8');
11
+
12
+ const MIME = {
13
+ html:'text/html;charset=utf-8', txt:'text/plain', md:'text/plain',
14
+ json:'application/json', js:'application/javascript', css:'text/css',
15
+ png:'image/png', jpg:'image/jpeg', jpeg:'image/jpeg',
16
+ gif:'image/gif', webp:'image/webp', svg:'image/svg+xml',
17
+ ico:'image/x-icon', mp4:'video/mp4', mov:'video/quicktime',
18
+ mp3:'audio/mpeg', wav:'audio/wav', pdf:'application/pdf',
19
+ zip:'application/zip',
20
+ };
21
+
22
+ function mime(filename) {
23
+ const ext = filename.split('.').pop().toLowerCase();
24
+ return MIME[ext] || 'application/octet-stream';
25
+ }
26
+
27
+ // Resolve and validate that a path stays within root
28
+ function safePath(root, reqPath) {
29
+ const rel = decodeURIComponent(reqPath || '/').replace(/\0/g, '');
30
+ const full = path.resolve(path.join(root, rel));
31
+ if (full !== root && !full.startsWith(root + path.sep)) throw Object.assign(new Error('forbidden'), { status: 403 });
32
+ return full;
33
+ }
34
+
35
+ function qs(url) {
36
+ const i = url.indexOf('?');
37
+ return i === -1 ? {} : Object.fromEntries(new URLSearchParams(url.slice(i + 1)));
38
+ }
39
+
40
+ function json(res, status, body) {
41
+ res.writeHead(status, { 'Content-Type': 'application/json' });
42
+ res.end(JSON.stringify(body));
43
+ }
44
+
45
+ function scanDir(dir) {
46
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
47
+ const items = [];
48
+ for (const e of entries) {
49
+ if (e.name.startsWith('.')) continue;
50
+ try {
51
+ const full = path.join(dir, e.name);
52
+ const stat = fs.statSync(full);
53
+ items.push({
54
+ name: e.name,
55
+ type: e.isDirectory() ? 'dir' : 'file',
56
+ size: e.isDirectory() ? null : stat.size,
57
+ mtime: stat.mtime.toISOString(),
58
+ });
59
+ } catch {}
60
+ }
61
+ return items.sort((a, b) => {
62
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
63
+ return a.name.localeCompare(b.name);
64
+ });
65
+ }
66
+
67
+ function createServer(root, opts = {}) {
68
+ const { allowUpload = true, maxUploadMB = 500 } = opts;
69
+ const events = new EventEmitter();
70
+
71
+ const server = http.createServer((req, res) => {
72
+ const url = req.url || '/';
73
+ const route = url.split('?')[0];
74
+ const q = qs(url);
75
+ const ip = (req.socket.remoteAddress || '').replace('::ffff:', '');
76
+
77
+ res.setHeader('Access-Control-Allow-Origin', '*');
78
+
79
+ try {
80
+ // ── HTML shell ───────────────────────────────────
81
+ if (route === '/' || route === '/index.html') {
82
+ res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' });
83
+ return res.end(HTML);
84
+ }
85
+
86
+ // ── List directory ───────────────────────────────
87
+ if (route === '/api/ls') {
88
+ const dir = safePath(root, q.p);
89
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory())
90
+ return json(res, 404, { error: 'not found' });
91
+ events.emit('log', { method: 'BROWSE', filePath: q.p || '/', ip });
92
+ return json(res, 200, { items: scanDir(dir) });
93
+ }
94
+
95
+ // ── Download file ────────────────────────────────
96
+ if (route === '/api/dl') {
97
+ const fp = safePath(root, q.p);
98
+ if (!fs.existsSync(fp) || !fs.statSync(fp).isFile())
99
+ return json(res, 404, { error: 'not found' });
100
+ const stat = fs.statSync(fp);
101
+ const name = path.basename(fp);
102
+ events.emit('log', { method: 'DOWNLOAD', filePath: q.p, ip, size: stat.size });
103
+ res.writeHead(200, {
104
+ 'Content-Type': mime(name),
105
+ 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(name)}`,
106
+ 'Content-Length': stat.size,
107
+ });
108
+ return fs.createReadStream(fp).pipe(res);
109
+ }
110
+
111
+ // ── Zip a directory or file ───────────────────────
112
+ if (route === '/api/zip') {
113
+ const fp = safePath(root, q.p);
114
+ if (!fs.existsSync(fp)) return json(res, 404, { error: 'not found' });
115
+ const name = path.basename(fp) || 'files';
116
+ events.emit('log', { method: 'DOWNLOAD', filePath: `${q.p} (zip)`, ip });
117
+ res.writeHead(200, {
118
+ 'Content-Type': 'application/zip',
119
+ 'Content-Disposition': `attachment; filename="${name}.zip"`,
120
+ });
121
+ const arc = archiver('zip', { zlib: { level: 6 } });
122
+ arc.on('error', () => {});
123
+ arc.pipe(res);
124
+ fs.statSync(fp).isDirectory() ? arc.directory(fp, false) : arc.file(fp, { name });
125
+ arc.finalize();
126
+ return;
127
+ }
128
+
129
+ // ── Zip selected paths ───────────────────────────
130
+ if (route === '/api/zip-selected' && req.method === 'POST') {
131
+ let raw = '';
132
+ req.on('data', c => { raw += c; });
133
+ req.on('end', () => {
134
+ try {
135
+ const { paths } = JSON.parse(raw);
136
+ if (!Array.isArray(paths) || !paths.length) return json(res, 400, { error: 'no paths' });
137
+ events.emit('log', { method: 'DOWNLOAD', filePath: `${paths.length} selected (zip)`, ip });
138
+ res.writeHead(200, {
139
+ 'Content-Type': 'application/zip',
140
+ 'Content-Disposition': 'attachment; filename="selected.zip"',
141
+ });
142
+ const arc = archiver('zip', { zlib: { level: 6 } });
143
+ arc.on('error', () => {});
144
+ arc.pipe(res);
145
+ for (const p of paths) {
146
+ try {
147
+ const fp = safePath(root, p);
148
+ const stat = fs.statSync(fp);
149
+ stat.isDirectory()
150
+ ? arc.directory(fp, path.basename(fp))
151
+ : arc.file(fp, { name: path.basename(fp) });
152
+ } catch {}
153
+ }
154
+ arc.finalize();
155
+ } catch { json(res, 400, { error: 'bad request' }); }
156
+ });
157
+ return;
158
+ }
159
+
160
+ // ── Upload ───────────────────────────────────────
161
+ if (route === '/api/ul' && req.method === 'POST') {
162
+ if (!allowUpload) return json(res, 403, { error: 'upload disabled' });
163
+ const uploadDir = safePath(root, q.p || '/');
164
+ if (!fs.existsSync(uploadDir) || !fs.statSync(uploadDir).isDirectory())
165
+ return json(res, 404, { error: 'directory not found' });
166
+
167
+ const bb = busboy({ headers: req.headers, limits: { fileSize: maxUploadMB * 1024 * 1024 } });
168
+ const saves = [];
169
+
170
+ bb.on('file', (_name, stream, info) => {
171
+ const filename = path.basename(info.filename || 'upload');
172
+ const dest = path.join(uploadDir, filename);
173
+ const ws = fs.createWriteStream(dest);
174
+ stream.pipe(ws);
175
+ saves.push(new Promise((resolve, reject) => {
176
+ ws.on('finish', () => {
177
+ const size = fs.statSync(dest).size;
178
+ events.emit('log', { method: 'UPLOAD', filePath: filename, ip, size });
179
+ resolve();
180
+ });
181
+ ws.on('error', reject);
182
+ }));
183
+ });
184
+
185
+ bb.on('finish', async () => {
186
+ try { await Promise.all(saves); json(res, 200, { ok: true }); }
187
+ catch { json(res, 500, { error: 'save failed' }); }
188
+ });
189
+
190
+ bb.on('error', () => json(res, 500, { error: 'upload failed' }));
191
+ req.pipe(bb);
192
+ return;
193
+ }
194
+
195
+ json(res, 404, { error: 'not found' });
196
+
197
+ } catch (e) {
198
+ json(res, e.status || 500, { error: e.message });
199
+ }
200
+ });
201
+
202
+ return { server, events };
203
+ }
204
+
205
+ module.exports = { createServer };
package/src/ui.html ADDED
@@ -0,0 +1,514 @@
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, maximum-scale=1.0">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
8
+ <title>my-airdrop</title>
9
+ <style>
10
+ *,*::before,*::after{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
11
+ :root{
12
+ --bg:#0d0d0d;--surface:#161616;--surface2:#1e1e1e;
13
+ --border:#252525;--text:#f0f0f0;--muted:#555;
14
+ --accent:#818cf8;--accent-bg:rgba(129,140,248,.12);
15
+ --green:#4ade80;--red:#ef4444;--yellow:#facc15;
16
+ }
17
+ html,body{height:100%;overflow:hidden}
18
+ body{
19
+ background:var(--bg);color:var(--text);
20
+ font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,sans-serif;
21
+ font-size:15px;display:flex;flex-direction:column;
22
+ }
23
+
24
+ /* ── Header ── */
25
+ #header{
26
+ flex-shrink:0;
27
+ background:rgba(13,13,13,.95);
28
+ backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
29
+ border-bottom:1px solid var(--border);
30
+ padding:10px 14px 8px;
31
+ }
32
+ .brand{font-size:12px;font-weight:700;color:var(--accent);letter-spacing:.05em;margin-bottom:5px}
33
+ #breadcrumb{
34
+ display:flex;align-items:center;gap:2px;
35
+ font-size:13px;color:var(--muted);
36
+ overflow-x:auto;white-space:nowrap;scrollbar-width:none;
37
+ }
38
+ #breadcrumb::-webkit-scrollbar{display:none}
39
+ .bc{cursor:pointer;padding:2px 5px;border-radius:4px;transition:color .1s}
40
+ .bc:hover,.bc.cur{color:var(--text)}
41
+ .bc-sep{color:var(--border);font-size:11px;padding:0 1px}
42
+
43
+ /* ── Toolbar ── */
44
+ #toolbar{
45
+ flex-shrink:0;display:flex;align-items:center;gap:6px;
46
+ padding:7px 10px;border-bottom:1px solid var(--border);
47
+ overflow-x:auto;scrollbar-width:none;background:var(--bg);
48
+ }
49
+ #toolbar::-webkit-scrollbar{display:none}
50
+ .btn{
51
+ display:inline-flex;align-items:center;gap:5px;
52
+ padding:6px 12px;font-size:13px;font-weight:500;
53
+ border:1px solid var(--border);background:var(--surface2);
54
+ color:var(--text);border-radius:6px;cursor:pointer;
55
+ white-space:nowrap;transition:border-color .15s,color .15s,background .15s;
56
+ flex-shrink:0;line-height:1;
57
+ }
58
+ .btn:hover:not(:disabled){border-color:var(--accent);color:var(--accent)}
59
+ .btn:disabled{opacity:.3;cursor:default}
60
+ .btn.accent{border-color:var(--accent);color:var(--accent);background:var(--accent-bg)}
61
+ .btn.accent:hover:not(:disabled){background:rgba(129,140,248,.2)}
62
+
63
+ /* ── File list ── */
64
+ #scroll{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:6px}
65
+ .file-row{
66
+ display:flex;align-items:center;gap:10px;
67
+ padding:9px 10px;border-radius:8px;
68
+ cursor:pointer;transition:background .1s;
69
+ -webkit-user-select:none;user-select:none;
70
+ }
71
+ .file-row:active,.file-row:hover{background:var(--surface)}
72
+ .file-row.sel{background:var(--accent-bg)}
73
+ .file-cb{
74
+ width:17px;height:17px;flex-shrink:0;
75
+ accent-color:var(--accent);cursor:pointer;
76
+ display:none;border-radius:4px;
77
+ }
78
+ .sel-mode .file-cb{display:block}
79
+ .file-icon{font-size:22px;flex-shrink:0;width:30px;text-align:center}
80
+ .file-body{flex:1;min-width:0}
81
+ .file-name{
82
+ font-size:14px;font-weight:500;
83
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
84
+ }
85
+ .file-meta{font-size:12px;color:var(--muted);margin-top:2px}
86
+ .file-btns{display:flex;gap:3px;flex-shrink:0}
87
+ .icon-btn{
88
+ width:30px;height:30px;display:flex;
89
+ align-items:center;justify-content:center;
90
+ border-radius:6px;border:1px solid transparent;
91
+ background:transparent;color:var(--muted);
92
+ cursor:pointer;font-size:15px;transition:all .15s;
93
+ }
94
+ .icon-btn:hover{border-color:var(--border);background:var(--surface2);color:var(--text)}
95
+
96
+ /* ── FAB ── */
97
+ #fab{
98
+ position:fixed;bottom:22px;right:18px;
99
+ width:52px;height:52px;border-radius:50%;
100
+ background:var(--accent);color:#fff;font-size:22px;
101
+ display:flex;align-items:center;justify-content:center;
102
+ cursor:pointer;border:none;z-index:100;
103
+ box-shadow:0 4px 18px rgba(129,140,248,.45);
104
+ transition:transform .15s,box-shadow .15s;
105
+ }
106
+ #fab:active{transform:scale(.95)}
107
+ @media(min-width:640px){#fab{display:none}}
108
+ .no-mobile{display:none}
109
+ @media(min-width:640px){.no-mobile{display:inline-flex}}
110
+
111
+ /* ── Upload overlay ── */
112
+ #upOverlay{
113
+ position:fixed;bottom:18px;right:18px;
114
+ width:290px;max-width:calc(100vw - 24px);
115
+ background:var(--surface);border:1px solid var(--border);
116
+ border-radius:10px;padding:14px;z-index:200;
117
+ box-shadow:0 8px 32px rgba(0,0,0,.6);
118
+ }
119
+ #upOverlay.hidden{display:none}
120
+ .up-head{font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px}
121
+ .up-item{margin-bottom:8px}
122
+ .up-item:last-child{margin-bottom:0}
123
+ .up-name{font-size:13px;margin-bottom:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
124
+ .pbar{height:3px;background:var(--surface2);border-radius:2px;overflow:hidden}
125
+ .pfill{height:100%;background:var(--accent);border-radius:2px;transition:width .15s}
126
+ .up-item.done .pfill{background:var(--green)}
127
+ .up-item.err .pfill{background:var(--red)}
128
+
129
+ /* ── Drop overlay ── */
130
+ #dropOverlay{
131
+ position:fixed;inset:0;z-index:300;
132
+ background:rgba(129,140,248,.07);
133
+ border:2px dashed var(--accent);
134
+ display:flex;flex-direction:column;
135
+ align-items:center;justify-content:center;gap:10px;
136
+ pointer-events:none;opacity:0;transition:opacity .15s;
137
+ }
138
+ #dropOverlay.show{opacity:1}
139
+ .drop-icon{font-size:52px}
140
+ .drop-label{font-size:17px;font-weight:700;color:var(--accent)}
141
+
142
+ /* ── State messages ── */
143
+ .state{
144
+ text-align:center;padding:64px 20px;
145
+ color:var(--muted);font-size:14px;
146
+ }
147
+ .spin-wrap{
148
+ display:flex;align-items:center;justify-content:center;
149
+ gap:10px;padding:48px;color:var(--muted);font-size:14px;
150
+ }
151
+ @keyframes spin{to{transform:rotate(360deg)}}
152
+ .spin{
153
+ width:18px;height:18px;border:2px solid var(--border);
154
+ border-top-color:var(--accent);border-radius:50%;
155
+ animation:spin .6s linear infinite;
156
+ }
157
+
158
+ /* ── Toasts ── */
159
+ #toasts{
160
+ position:fixed;bottom:22px;left:50%;
161
+ transform:translateX(-50%);
162
+ z-index:400;display:flex;flex-direction:column-reverse;
163
+ align-items:center;gap:6px;pointer-events:none;
164
+ }
165
+ .toast{
166
+ background:var(--surface);border:1px solid var(--border);
167
+ border-radius:6px;padding:8px 14px;
168
+ font-size:13px;white-space:nowrap;
169
+ animation:fadeUp .2s ease;
170
+ }
171
+ .toast.ok{border-color:var(--green);color:var(--green)}
172
+ .toast.err{border-color:var(--red);color:var(--red)}
173
+ @keyframes fadeUp{
174
+ from{opacity:0;transform:translateY(8px)}
175
+ to{opacity:1;transform:translateY(0)}
176
+ }
177
+
178
+ @media(max-width:639px){
179
+ .file-row{min-height:52px}
180
+ .file-name{font-size:15px}
181
+ }
182
+ </style>
183
+ </head>
184
+ <body>
185
+
186
+ <div id="header">
187
+ <div class="brand">◆ my-airdrop</div>
188
+ <div id="breadcrumb"></div>
189
+ </div>
190
+
191
+ <div id="toolbar">
192
+ <label class="btn accent no-mobile" style="cursor:pointer">
193
+ ↑ Upload
194
+ <input id="fileInput" type="file" multiple hidden>
195
+ </label>
196
+ <button class="btn" id="selAllBtn">Select All</button>
197
+ <button class="btn accent" id="dlSelBtn" disabled>↓ <span id="selLabel">Download</span></button>
198
+ <button class="btn no-mobile" id="zipBtn">⊡ Zip Folder</button>
199
+ </div>
200
+
201
+ <div id="scroll"><div id="fileList"></div></div>
202
+
203
+ <!-- Mobile FAB -->
204
+ <label id="fab" title="Upload">
205
+
206
+ <input id="fabInput" type="file" multiple hidden>
207
+ </label>
208
+
209
+ <!-- Upload progress -->
210
+ <div id="upOverlay" class="hidden">
211
+ <div class="up-head">Uploading</div>
212
+ <div id="upItems"></div>
213
+ </div>
214
+
215
+ <!-- Drop overlay -->
216
+ <div id="dropOverlay">
217
+ <div class="drop-icon">📤</div>
218
+ <div class="drop-label">Drop to upload</div>
219
+ </div>
220
+
221
+ <!-- Toasts -->
222
+ <div id="toasts"></div>
223
+
224
+ <script>
225
+ 'use strict';
226
+
227
+ let curPath = '/';
228
+ const sel = new Set();
229
+
230
+ // ── API ──────────────────────────────────────────────
231
+
232
+ async function apiList(p) {
233
+ const r = await fetch('/api/ls?p=' + enc(p));
234
+ if (!r.ok) throw new Error(await r.text());
235
+ return r.json();
236
+ }
237
+
238
+ // ── Navigation ───────────────────────────────────────
239
+
240
+ async function go(p) {
241
+ curPath = p;
242
+ sel.clear();
243
+ syncSel();
244
+ setLoading();
245
+ renderBC(p);
246
+ try {
247
+ const { items } = await apiList(p);
248
+ renderFiles(items);
249
+ } catch (e) {
250
+ document.getElementById('fileList').innerHTML =
251
+ '<div class="state">Could not load — ' + esc(e.message) + '</div>';
252
+ }
253
+ }
254
+
255
+ function setLoading() {
256
+ document.getElementById('fileList').innerHTML =
257
+ '<div class="spin-wrap"><div class="spin"></div>Loading…</div>';
258
+ }
259
+
260
+ function renderBC(p) {
261
+ const parts = p.split('/').filter(Boolean);
262
+ const bc = document.getElementById('breadcrumb');
263
+ let h = `<span class="bc${!parts.length?' cur':''}" data-path="/">~</span>`;
264
+ parts.forEach((seg, i) => {
265
+ const pp = '/' + parts.slice(0, i + 1).join('/');
266
+ h += '<span class="bc-sep">/</span>';
267
+ h += `<span class="bc${i===parts.length-1?' cur':''}" data-path="${attr(pp)}">${esc(seg)}</span>`;
268
+ });
269
+ bc.innerHTML = h;
270
+ bc.scrollLeft = bc.scrollWidth;
271
+ }
272
+
273
+ function renderFiles(items) {
274
+ const list = document.getElementById('fileList');
275
+ if (!items.length) {
276
+ list.innerHTML = '<div class="state">Empty folder</div>';
277
+ return;
278
+ }
279
+ list.innerHTML = items.map(item => {
280
+ const p = (curPath === '/' ? '' : curPath) + '/' + item.name;
281
+ const isSel = sel.has(p);
282
+ return `<div class="file-row${isSel?' sel':''}" data-path="${attr(p)}" data-type="${item.type}">
283
+ <input class="file-cb" type="checkbox"${isSel?' checked':''} data-path="${attr(p)}">
284
+ <div class="file-icon">${icon(item.name, item.type)}</div>
285
+ <div class="file-body">
286
+ <div class="file-name">${esc(item.name)}${item.type==='dir'?'/':''}</div>
287
+ ${item.size!=null?`<div class="file-meta">${fmtSize(item.size)}</div>`:''}
288
+ </div>
289
+ <div class="file-btns">
290
+ ${item.type==='dir'
291
+ ? `<button class="icon-btn" data-act="zip" data-path="${attr(p)}" title="Download ZIP">⊡</button>`
292
+ : `<button class="icon-btn" data-act="dl" data-path="${attr(p)}" title="Download">↓</button>`}
293
+ </div>
294
+ </div>`;
295
+ }).join('');
296
+ }
297
+
298
+ // ── File list events ─────────────────────────────────
299
+
300
+ document.getElementById('fileList').addEventListener('click', e => {
301
+ const btn = e.target.closest('.icon-btn');
302
+ if (btn) {
303
+ e.stopPropagation();
304
+ if (btn.dataset.act === 'dl') dlFile(btn.dataset.path);
305
+ if (btn.dataset.act === 'zip') dlZip(btn.dataset.path);
306
+ return;
307
+ }
308
+ const cb = e.target.closest('.file-cb');
309
+ if (cb) { toggleSel(cb.dataset.path); return; }
310
+
311
+ const row = e.target.closest('.file-row');
312
+ if (!row) return;
313
+ if (sel.size > 0) { toggleSel(row.dataset.path); return; }
314
+ if (row.dataset.type === 'dir') go(row.dataset.path);
315
+ else dlFile(row.dataset.path);
316
+ });
317
+
318
+ document.getElementById('breadcrumb').addEventListener('click', e => {
319
+ const bc = e.target.closest('.bc');
320
+ if (bc) go(bc.dataset.path);
321
+ });
322
+
323
+ // ── Selection ────────────────────────────────────────
324
+
325
+ function toggleSel(p) {
326
+ sel.has(p) ? sel.delete(p) : sel.add(p);
327
+ const row = findRow(p);
328
+ if (row) {
329
+ row.classList.toggle('sel', sel.has(p));
330
+ const cb = row.querySelector('.file-cb');
331
+ if (cb) cb.checked = sel.has(p);
332
+ }
333
+ syncSel();
334
+ }
335
+
336
+ function syncSel() {
337
+ const n = sel.size;
338
+ document.getElementById('dlSelBtn').disabled = n === 0;
339
+ document.getElementById('selLabel').textContent = n > 0 ? `Download (${n})` : 'Download';
340
+ document.getElementById('fileList').classList.toggle('sel-mode', n > 0);
341
+ }
342
+
343
+ document.getElementById('selAllBtn').addEventListener('click', () => {
344
+ const rows = [...document.querySelectorAll('.file-row')];
345
+ if (sel.size === rows.length) {
346
+ sel.clear();
347
+ rows.forEach(r => {
348
+ r.classList.remove('sel');
349
+ const cb = r.querySelector('.file-cb'); if (cb) cb.checked = false;
350
+ });
351
+ } else {
352
+ rows.forEach(r => {
353
+ sel.add(r.dataset.path);
354
+ r.classList.add('sel');
355
+ const cb = r.querySelector('.file-cb'); if (cb) cb.checked = true;
356
+ });
357
+ }
358
+ syncSel();
359
+ });
360
+
361
+ // ── Download ─────────────────────────────────────────
362
+
363
+ function dlFile(p) { window.location.href = '/api/dl?p=' + enc(p); }
364
+ function dlZip(p) { window.location.href = '/api/zip?p=' + enc(p); }
365
+
366
+ document.getElementById('dlSelBtn').addEventListener('click', async () => {
367
+ if (!sel.size) return;
368
+ if (sel.size === 1) {
369
+ const [p] = sel;
370
+ const row = findRow(p);
371
+ if (row?.dataset.type === 'file') { dlFile(p); return; }
372
+ if (row?.dataset.type === 'dir') { dlZip(p); return; }
373
+ }
374
+ try {
375
+ const r = await fetch('/api/zip-selected', {
376
+ method: 'POST',
377
+ headers: { 'Content-Type': 'application/json' },
378
+ body: JSON.stringify({ paths: [...sel] }),
379
+ });
380
+ if (!r.ok) throw new Error();
381
+ const blob = await r.blob();
382
+ const a = Object.assign(document.createElement('a'), {
383
+ href: URL.createObjectURL(blob), download: 'selected.zip',
384
+ });
385
+ a.click();
386
+ URL.revokeObjectURL(a.href);
387
+ } catch { toast('Download failed', 'err'); }
388
+ });
389
+
390
+ document.getElementById('zipBtn')?.addEventListener('click', () => dlZip(curPath));
391
+
392
+ // ── Upload ───────────────────────────────────────────
393
+
394
+ function setupUpload() {
395
+ const bind = id => {
396
+ const el = document.getElementById(id);
397
+ el.addEventListener('change', () => { handleFiles(el.files); el.value = ''; });
398
+ };
399
+ bind('fileInput');
400
+ bind('fabInput');
401
+
402
+ // Drag & drop
403
+ let depth = 0;
404
+ document.addEventListener('dragenter', e => {
405
+ if (!e.dataTransfer?.types.includes('Files')) return;
406
+ depth++;
407
+ document.getElementById('dropOverlay').classList.add('show');
408
+ });
409
+ document.addEventListener('dragleave', () => {
410
+ if (--depth <= 0) { depth = 0; document.getElementById('dropOverlay').classList.remove('show'); }
411
+ });
412
+ document.addEventListener('dragover', e => e.preventDefault());
413
+ document.addEventListener('drop', e => {
414
+ e.preventDefault(); depth = 0;
415
+ document.getElementById('dropOverlay').classList.remove('show');
416
+ handleFiles(e.dataTransfer?.files);
417
+ });
418
+ }
419
+
420
+ function handleFiles(files) {
421
+ if (!files?.length) return;
422
+ const arr = [...files];
423
+ showUpOverlay(arr);
424
+ Promise.all(arr.map((f, i) => uploadOne(f, i))).then(() => {
425
+ setTimeout(() => {
426
+ document.getElementById('upOverlay').classList.add('hidden');
427
+ go(curPath);
428
+ toast(`Uploaded ${arr.length} file${arr.length > 1 ? 's' : ''}`, 'ok');
429
+ }, 700);
430
+ });
431
+ }
432
+
433
+ function showUpOverlay(files) {
434
+ document.getElementById('upOverlay').classList.remove('hidden');
435
+ document.getElementById('upItems').innerHTML = files.map((f, i) =>
436
+ `<div class="up-item" id="ui${i}">
437
+ <div class="up-name">${esc(f.name)}</div>
438
+ <div class="pbar"><div class="pfill" id="uf${i}" style="width:0"></div></div>
439
+ </div>`
440
+ ).join('');
441
+ }
442
+
443
+ function uploadOne(file, idx) {
444
+ return new Promise(resolve => {
445
+ const fd = new FormData();
446
+ fd.append('file', file);
447
+ const xhr = new XMLHttpRequest();
448
+ xhr.open('POST', '/api/ul?p=' + enc(curPath));
449
+ xhr.upload.onprogress = e => {
450
+ if (e.lengthComputable) {
451
+ const fill = document.getElementById('uf' + idx);
452
+ if (fill) fill.style.width = Math.round(e.loaded / e.total * 100) + '%';
453
+ }
454
+ };
455
+ xhr.onloadend = () => {
456
+ const fill = document.getElementById('uf' + idx);
457
+ if (fill) fill.style.width = '100%';
458
+ const item = document.getElementById('ui' + idx);
459
+ if (item) item.classList.add(xhr.status === 200 ? 'done' : 'err');
460
+ resolve();
461
+ };
462
+ xhr.send(fd);
463
+ });
464
+ }
465
+
466
+ // ── Toast ─────────────────────────────────────────────
467
+
468
+ function toast(msg, type = '') {
469
+ const el = Object.assign(document.createElement('div'), {
470
+ className: 'toast' + (type ? ' ' + type : ''),
471
+ textContent: msg,
472
+ });
473
+ document.getElementById('toasts').appendChild(el);
474
+ setTimeout(() => el.remove(), 2500);
475
+ }
476
+
477
+ // ── Helpers ──────────────────────────────────────────
478
+
479
+ function findRow(p) {
480
+ return [...document.querySelectorAll('.file-row')].find(r => r.dataset.path === p) || null;
481
+ }
482
+ function enc(p) { return encodeURIComponent(p); }
483
+ function esc(s) {
484
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
485
+ }
486
+ function attr(s) { return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
487
+
488
+ function icon(name, type) {
489
+ if (type === 'dir') return '📁';
490
+ const ext = (name.split('.').pop() || '').toLowerCase();
491
+ return {
492
+ jpg:'🖼️',jpeg:'🖼️',png:'🖼️',gif:'🖼️',webp:'🖼️',heic:'🖼️',bmp:'🖼️',svg:'🖼️',
493
+ mp4:'🎬',mov:'🎬',avi:'🎬',mkv:'🎬',webm:'🎬',
494
+ mp3:'🎵',wav:'🎵',m4a:'🎵',flac:'🎵',aac:'🎵',
495
+ pdf:'📕',doc:'📝',docx:'📝',txt:'📝',md:'📝',
496
+ js:'💻',ts:'💻',jsx:'💻',tsx:'💻',py:'💻',go:'💻',rs:'💻',
497
+ html:'💻',css:'💻',json:'💻',sh:'💻',yml:'💻',yaml:'💻',
498
+ zip:'📦',tar:'📦',gz:'📦',rar:'📦',dmg:'📦',pkg:'📦',
499
+ }[ext] || '📄';
500
+ }
501
+
502
+ function fmtSize(b) {
503
+ if (b < 1024) return b + ' B';
504
+ if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
505
+ if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB';
506
+ return (b / 1073741824).toFixed(1) + ' GB';
507
+ }
508
+
509
+ // ── Init ─────────────────────────────────────────────
510
+ setupUpload();
511
+ go('/');
512
+ </script>
513
+ </body>
514
+ </html>