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 +72 -0
- package/bin/cli.js +175 -0
- package/package.json +17 -0
- package/src/server.js +205 -0
- package/src/ui.html +514 -0
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
485
|
+
}
|
|
486
|
+
function attr(s) { return String(s).replace(/"/g,'"').replace(/'/g,'''); }
|
|
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>
|