my-airdrop 1.2.2 → 1.3.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 +14 -1
- package/package.json +1 -1
- package/src/server.js +52 -0
- package/src/ui.html +176 -0
package/README.md
CHANGED
|
@@ -49,8 +49,9 @@ Opens a beautiful web interface anyone can access by scanning a QR code. Upload
|
|
|
49
49
|
- **Folder download** — zip and download entire folders in one tap
|
|
50
50
|
- **Multi-select** — select multiple files and download as a single zip
|
|
51
51
|
- **QR code** — instantly connect any device with a camera
|
|
52
|
-
- **Public tunnel** — share outside your local network via a public URL
|
|
52
|
+
- **Public tunnel** — share outside your local network via a public URL, works on any network including WiFi
|
|
53
53
|
- **Mobile-optimized** — large touch targets, responsive layout, dark UI
|
|
54
|
+
- **Clipboard sync** — share text snippets, URLs, or code between devices in real time
|
|
54
55
|
- **Safety limits** — warns on large directories, hard stops at 5000 files / 5 GB
|
|
55
56
|
|
|
56
57
|
## Usage
|
|
@@ -77,6 +78,14 @@ npx my-airdrop --port 8080
|
|
|
77
78
|
npx my-airdrop --no-upload
|
|
78
79
|
```
|
|
79
80
|
|
|
81
|
+
## Clipboard sync
|
|
82
|
+
|
|
83
|
+
Tap **⎘ Clip** on any device to open the clipboard panel. Type or paste text and hit Send — it instantly appears on all connected devices.
|
|
84
|
+
|
|
85
|
+
- Works both ways: PC → phone and phone → PC
|
|
86
|
+
- Real-time sync via SSE (no refresh needed)
|
|
87
|
+
- Last 10 clips kept in memory (cleared on server restart)
|
|
88
|
+
|
|
80
89
|
## Public mode
|
|
81
90
|
|
|
82
91
|
With `--public`, a Cloudflare Quick Tunnel is created so anyone on the internet can access your files:
|
|
@@ -87,6 +96,10 @@ Public https://random-words.trycloudflare.com
|
|
|
87
96
|
|
|
88
97
|
Share the URL with whoever you want to give access. No password required — just open and go.
|
|
89
98
|
|
|
99
|
+
- Powered by [Cloudflare Quick Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/)
|
|
100
|
+
- Works on any network including WiFi (uses TCP 443, not blocked by routers)
|
|
101
|
+
- Auto-reconnects if the tunnel drops
|
|
102
|
+
|
|
90
103
|
## Install globally
|
|
91
104
|
|
|
92
105
|
```bash
|
package/package.json
CHANGED
package/src/server.js
CHANGED
|
@@ -68,6 +68,18 @@ function createServer(root, opts = {}) {
|
|
|
68
68
|
const { allowUpload = true, maxUploadMB = 500 } = opts;
|
|
69
69
|
const events = new EventEmitter();
|
|
70
70
|
|
|
71
|
+
// ── Clipboard state ──────────────────────────────────
|
|
72
|
+
const clips = [];
|
|
73
|
+
const sseClients = new Set();
|
|
74
|
+
const MAX_CLIPS = 10;
|
|
75
|
+
|
|
76
|
+
function broadcastClip(clip) {
|
|
77
|
+
const msg = `data: ${JSON.stringify(clip)}\n\n`;
|
|
78
|
+
for (const client of sseClients) {
|
|
79
|
+
try { client.write(msg); } catch { sseClients.delete(client); }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
71
83
|
const server = http.createServer((req, res) => {
|
|
72
84
|
const url = req.url || '/';
|
|
73
85
|
const route = url.split('?')[0];
|
|
@@ -157,6 +169,46 @@ function createServer(root, opts = {}) {
|
|
|
157
169
|
return;
|
|
158
170
|
}
|
|
159
171
|
|
|
172
|
+
// ── Clipboard SSE stream ─────────────────────────
|
|
173
|
+
if (route === '/api/clip-stream') {
|
|
174
|
+
res.writeHead(200, {
|
|
175
|
+
'Content-Type': 'text/event-stream',
|
|
176
|
+
'Cache-Control': 'no-cache',
|
|
177
|
+
'Connection': 'keep-alive',
|
|
178
|
+
'X-Accel-Buffering': 'no',
|
|
179
|
+
});
|
|
180
|
+
res.write(': connected\n\n');
|
|
181
|
+
sseClients.add(res);
|
|
182
|
+
const hb = setInterval(() => {
|
|
183
|
+
try { res.write(': ping\n\n'); } catch { clearInterval(hb); sseClients.delete(res); }
|
|
184
|
+
}, 25000);
|
|
185
|
+
req.on('close', () => { clearInterval(hb); sseClients.delete(res); });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Get clips ────────────────────────────────────
|
|
190
|
+
if (route === '/api/clips') {
|
|
191
|
+
return json(res, 200, { clips });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Post clip ────────────────────────────────────
|
|
195
|
+
if (route === '/api/clip' && req.method === 'POST') {
|
|
196
|
+
let raw = '';
|
|
197
|
+
req.on('data', c => { raw += c; if (raw.length > 52000) req.destroy(); });
|
|
198
|
+
req.on('end', () => {
|
|
199
|
+
try {
|
|
200
|
+
const { text } = JSON.parse(raw);
|
|
201
|
+
if (!text || typeof text !== 'string' || !text.trim()) return json(res, 400, { error: 'no text' });
|
|
202
|
+
const clip = { id: Date.now(), text: text.slice(0, 50000), time: new Date().toISOString() };
|
|
203
|
+
clips.unshift(clip);
|
|
204
|
+
if (clips.length > MAX_CLIPS) clips.pop();
|
|
205
|
+
broadcastClip(clip);
|
|
206
|
+
return json(res, 200, { ok: true });
|
|
207
|
+
} catch { json(res, 400, { error: 'bad request' }); }
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
160
212
|
// ── Upload ───────────────────────────────────────
|
|
161
213
|
if (route === '/api/ul' && req.method === 'POST') {
|
|
162
214
|
if (!allowUpload) return json(res, 403, { error: 'upload disabled' });
|
package/src/ui.html
CHANGED
|
@@ -179,6 +179,53 @@ body{
|
|
|
179
179
|
.file-row{min-height:52px}
|
|
180
180
|
.file-name{font-size:15px}
|
|
181
181
|
}
|
|
182
|
+
|
|
183
|
+
/* ── Clip panel ── */
|
|
184
|
+
#clipPanel{
|
|
185
|
+
position:fixed;bottom:18px;right:18px;
|
|
186
|
+
width:320px;max-width:calc(100vw - 24px);
|
|
187
|
+
max-height:70vh;
|
|
188
|
+
background:var(--surface);border:1px solid var(--border);
|
|
189
|
+
border-radius:10px;z-index:200;
|
|
190
|
+
box-shadow:0 8px 32px rgba(0,0,0,.6);
|
|
191
|
+
display:flex;flex-direction:column;
|
|
192
|
+
}
|
|
193
|
+
#clipPanel.hidden{display:none}
|
|
194
|
+
.clip-head{
|
|
195
|
+
display:flex;align-items:center;justify-content:space-between;
|
|
196
|
+
padding:12px 14px 0;
|
|
197
|
+
font-size:11px;font-weight:700;color:var(--muted);
|
|
198
|
+
text-transform:uppercase;letter-spacing:.06em;flex-shrink:0;
|
|
199
|
+
}
|
|
200
|
+
.clip-close{
|
|
201
|
+
background:none;border:none;color:var(--muted);
|
|
202
|
+
cursor:pointer;font-size:16px;padding:0;line-height:1;
|
|
203
|
+
}
|
|
204
|
+
.clip-close:hover{color:var(--text)}
|
|
205
|
+
.clip-compose{padding:10px 14px;flex-shrink:0}
|
|
206
|
+
#clipInput{
|
|
207
|
+
width:100%;background:var(--surface2);
|
|
208
|
+
border:1px solid var(--border);border-radius:6px;
|
|
209
|
+
color:var(--text);font-size:13px;padding:8px 10px;
|
|
210
|
+
resize:none;height:72px;font-family:inherit;display:block;
|
|
211
|
+
}
|
|
212
|
+
#clipInput:focus{outline:none;border-color:var(--accent)}
|
|
213
|
+
.clip-send-row{display:flex;justify-content:space-between;align-items:center;margin-top:6px}
|
|
214
|
+
.clip-hint{font-size:11px;color:var(--muted)}
|
|
215
|
+
.clip-list{flex:1;overflow-y:auto;padding:0 14px 12px;-webkit-overflow-scrolling:touch}
|
|
216
|
+
.clip-item{
|
|
217
|
+
display:flex;align-items:flex-start;gap:8px;
|
|
218
|
+
padding:8px 0;border-top:1px solid var(--border);
|
|
219
|
+
}
|
|
220
|
+
.clip-body{flex:1;min-width:0}
|
|
221
|
+
.clip-text{
|
|
222
|
+
font-size:13px;color:var(--text);
|
|
223
|
+
word-break:break-all;white-space:pre-wrap;
|
|
224
|
+
display:-webkit-box;-webkit-line-clamp:3;
|
|
225
|
+
-webkit-box-orient:vertical;overflow:hidden;
|
|
226
|
+
}
|
|
227
|
+
.clip-time{font-size:11px;color:var(--muted);margin-top:3px}
|
|
228
|
+
.clip-empty{font-size:13px;color:var(--muted);text-align:center;padding:20px 0}
|
|
182
229
|
</style>
|
|
183
230
|
</head>
|
|
184
231
|
<body>
|
|
@@ -196,6 +243,7 @@ body{
|
|
|
196
243
|
<button class="btn" id="selAllBtn">Select All</button>
|
|
197
244
|
<button class="btn accent" id="dlSelBtn" disabled>↓ <span id="selLabel">Download</span></button>
|
|
198
245
|
<button class="btn no-mobile" id="zipBtn">⊡ Zip Folder</button>
|
|
246
|
+
<button class="btn" id="clipBtn">⎘ Clip</button>
|
|
199
247
|
</div>
|
|
200
248
|
|
|
201
249
|
<div id="scroll"><div id="fileList"></div></div>
|
|
@@ -218,6 +266,24 @@ body{
|
|
|
218
266
|
<div class="drop-label">Drop to upload</div>
|
|
219
267
|
</div>
|
|
220
268
|
|
|
269
|
+
<!-- Clip panel -->
|
|
270
|
+
<div id="clipPanel" class="hidden">
|
|
271
|
+
<div class="clip-head">
|
|
272
|
+
Clipboard
|
|
273
|
+
<button class="clip-close" id="clipClose">✕</button>
|
|
274
|
+
</div>
|
|
275
|
+
<div class="clip-compose">
|
|
276
|
+
<textarea id="clipInput" placeholder="Paste or type text to share…"></textarea>
|
|
277
|
+
<div class="clip-send-row">
|
|
278
|
+
<span class="clip-hint">⌘↵ to send</span>
|
|
279
|
+
<button class="btn accent" id="clipSendBtn">Send</button>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
<div class="clip-list" id="clipList">
|
|
283
|
+
<div class="clip-empty">No clips yet</div>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
221
287
|
<!-- Toasts -->
|
|
222
288
|
<div id="toasts"></div>
|
|
223
289
|
|
|
@@ -506,8 +572,118 @@ function fmtSize(b) {
|
|
|
506
572
|
return (b / 1073741824).toFixed(1) + ' GB';
|
|
507
573
|
}
|
|
508
574
|
|
|
575
|
+
// ── Clipboard ─────────────────────────────────────────
|
|
576
|
+
|
|
577
|
+
function setupClip() {
|
|
578
|
+
const panel = document.getElementById('clipPanel');
|
|
579
|
+
|
|
580
|
+
document.getElementById('clipBtn').addEventListener('click', () => {
|
|
581
|
+
const opening = panel.classList.contains('hidden');
|
|
582
|
+
panel.classList.toggle('hidden');
|
|
583
|
+
if (opening) {
|
|
584
|
+
loadClips();
|
|
585
|
+
document.getElementById('clipInput').focus();
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
document.getElementById('clipClose').addEventListener('click', () => {
|
|
590
|
+
panel.classList.add('hidden');
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
document.getElementById('clipSendBtn').addEventListener('click', sendClip);
|
|
594
|
+
|
|
595
|
+
document.getElementById('clipInput').addEventListener('keydown', e => {
|
|
596
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendClip(); }
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
connectClipSSE();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function connectClipSSE() {
|
|
603
|
+
const es = new EventSource('/api/clip-stream');
|
|
604
|
+
es.onmessage = e => {
|
|
605
|
+
try {
|
|
606
|
+
const clip = JSON.parse(e.data);
|
|
607
|
+
if (!document.querySelector(`.clip-item[data-id="${clip.id}"]`)) {
|
|
608
|
+
addClipToList(clip, true);
|
|
609
|
+
if (document.getElementById('clipPanel').classList.contains('hidden')) {
|
|
610
|
+
toast('New clip received', '');
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
} catch {}
|
|
614
|
+
};
|
|
615
|
+
es.onerror = () => { es.close(); setTimeout(connectClipSSE, 3000); };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function loadClips() {
|
|
619
|
+
try {
|
|
620
|
+
const { clips } = await (await fetch('/api/clips')).json();
|
|
621
|
+
const list = document.getElementById('clipList');
|
|
622
|
+
list.innerHTML = clips.length ? '' : '<div class="clip-empty">No clips yet</div>';
|
|
623
|
+
clips.forEach(c => addClipToList(c, false));
|
|
624
|
+
} catch {}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function addClipToList(clip, prepend) {
|
|
628
|
+
const list = document.getElementById('clipList');
|
|
629
|
+
const empty = list.querySelector('.clip-empty');
|
|
630
|
+
if (empty) empty.remove();
|
|
631
|
+
|
|
632
|
+
const div = document.createElement('div');
|
|
633
|
+
div.className = 'clip-item';
|
|
634
|
+
div.dataset.id = clip.id;
|
|
635
|
+
const time = new Date(clip.time).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
|
|
636
|
+
div.innerHTML = `
|
|
637
|
+
<div class="clip-body">
|
|
638
|
+
<div class="clip-text">${esc(clip.text)}</div>
|
|
639
|
+
<div class="clip-time">${time}</div>
|
|
640
|
+
</div>
|
|
641
|
+
<button class="icon-btn" title="Copy">⎘</button>`;
|
|
642
|
+
div.querySelector('.icon-btn').addEventListener('click', () => copyText(clip.text));
|
|
643
|
+
|
|
644
|
+
if (prepend) list.prepend(div); else list.appendChild(div);
|
|
645
|
+
while (list.querySelectorAll('.clip-item').length > 10) list.removeChild(list.lastElementChild);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function copyText(text) {
|
|
649
|
+
if (navigator.clipboard) {
|
|
650
|
+
navigator.clipboard.writeText(text).then(() => toast('Copied', 'ok')).catch(() => fallbackCopy(text));
|
|
651
|
+
} else {
|
|
652
|
+
fallbackCopy(text);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function fallbackCopy(text) {
|
|
657
|
+
const ta = Object.assign(document.createElement('textarea'), {
|
|
658
|
+
value: text, style: 'position:fixed;opacity:0',
|
|
659
|
+
});
|
|
660
|
+
document.body.appendChild(ta);
|
|
661
|
+
ta.focus(); ta.select();
|
|
662
|
+
try { document.execCommand('copy'); toast('Copied', 'ok'); } catch { toast('Copy failed', 'err'); }
|
|
663
|
+
ta.remove();
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function sendClip() {
|
|
667
|
+
const input = document.getElementById('clipInput');
|
|
668
|
+
const text = input.value.trim();
|
|
669
|
+
if (!text) return;
|
|
670
|
+
const btn = document.getElementById('clipSendBtn');
|
|
671
|
+
btn.disabled = true;
|
|
672
|
+
try {
|
|
673
|
+
const r = await fetch('/api/clip', {
|
|
674
|
+
method: 'POST',
|
|
675
|
+
headers: { 'Content-Type': 'application/json' },
|
|
676
|
+
body: JSON.stringify({ text }),
|
|
677
|
+
});
|
|
678
|
+
if (!r.ok) throw new Error();
|
|
679
|
+
input.value = '';
|
|
680
|
+
} catch { toast('Failed to send', 'err'); }
|
|
681
|
+
finally { btn.disabled = false; }
|
|
682
|
+
}
|
|
683
|
+
|
|
509
684
|
// ── Init ─────────────────────────────────────────────
|
|
510
685
|
setupUpload();
|
|
686
|
+
setupClip();
|
|
511
687
|
go('/');
|
|
512
688
|
</script>
|
|
513
689
|
</body>
|