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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-airdrop",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Share files over local network — like AirDrop but from your terminal",
5
5
  "keywords": [
6
6
  "file-sharing",
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>