tinyrelaycommit

Commit c656e4458f03

browse files at this commit | history

1diff --git a/docs/files-and-private-repositories.md b/docs/files-and-private-repositories.md
2index 0ac5321..7d861ce 100644
3--- a/docs/files-and-private-repositories.md
4+++ b/docs/files-and-private-repositories.md
5@@ -4,7 +4,7 @@ Use **Files** to search your uploads, import a public HTTPS URL or Blossom URI,
6
7 ## Uploading
8
9-The **Upload** panel on the Files page stores files or a folder. Choose the files, check **folder** to select a whole folder, and check **encrypt** to encrypt in the browser before sending. Plain uploads store each file as it is and refresh the listing. **Import from URL** stores a copy of a public HTTPS URL or Blossom URI.
10+The **Upload** panel on the Files page stores files or a folder. Choose files, choose a folder, or drop either onto the panel, and check **encrypt** to encrypt in the browser before sending. Plain uploads store each file as it is and refresh the listing. **Import from URL** stores a copy of a public HTTPS URL or Blossom URI.
11
12 ## Encrypted files
13
14diff --git a/internal/webui/file-workspace.js b/internal/webui/file-workspace.js
15index 2f7723e..d7729bd 100644
16--- a/internal/webui/file-workspace.js
17+++ b/internal/webui/file-workspace.js
18@@ -151,13 +151,23 @@
19 if (this.bound) return;
20 this.bound = true;
21 this.innerHTML =
22- '<form><label>File or folder <input type="file" name="file" multiple required></label><label><input type="checkbox" name="folder"> folder</label><label><input type="checkbox" name="encrypt"> encrypt</label><button>Upload</button></form><p data-controls hidden><button type="button" data-cancel disabled>Cancel</button> <button type="button" data-retry disabled>Retry</button></p><label data-share hidden>Share link <input data-share-url readonly></label><output role="status"></output>';
23+ '<form><label>Files <input type="file" name="file" multiple></label><input type="file" name="folder" webkitdirectory multiple hidden><p data-drop><button type="button" data-pick-folder>Choose folder</button> or drop files or a folder here</p><label><input type="checkbox" name="encrypt"> encrypt</label><button>Upload</button></form><p data-controls hidden><button type="button" data-cancel disabled>Cancel</button> <button type="button" data-retry disabled>Retry</button></p><label data-share hidden>Share link <input data-share-url readonly></label><output role="status"></output>';
24 this.out = this.querySelector("output");
25 const form = this.querySelector("form");
26- const input = form.elements.namedItem("file");
27- form.elements.namedItem("folder").addEventListener("change", event => {
28- if (event.currentTarget.checked) input.setAttribute("webkitdirectory", "");
29- else input.removeAttribute("webkitdirectory");
30+ const files = form.elements.namedItem("file");
31+ const folder = form.elements.namedItem("folder");
32+ files.addEventListener("change", () => this.choose([...files.files]));
33+ folder.addEventListener("change", () => this.choose([...folder.files]));
34+ this.querySelector("[data-pick-folder]").addEventListener("click", () => folder.click());
35+ this.addEventListener("dragover", event => {
36+ event.preventDefault();
37+ this.dataset.over = "";
38+ });
39+ this.addEventListener("dragleave", () => delete this.dataset.over);
40+ this.addEventListener("drop", event => {
41+ event.preventDefault();
42+ delete this.dataset.over;
43+ this.dropped(event.dataTransfer).catch(error => this.say(error.message, true));
44 });
45 form.addEventListener("submit", event => {
46 event.preventDefault();
47@@ -166,6 +176,49 @@
48 this.querySelector("[data-cancel]").addEventListener("click", () => this.controller?.abort());
49 this.querySelector("[data-retry]").addEventListener("click", () => this.run().catch(() => {}));
50 }
51+ // choose records a selection. A folder is recognized from the relative
52+ // paths that folder pickers and folder drops attach to their files.
53+ choose(files) {
54+ const folder = files.some(file => (file.webkitRelativePath || "").includes("/"));
55+ this.chosen = {files, folder};
56+ if (files.length)
57+ this.say(
58+ folder
59+ ? files.length + " files in a folder selected."
60+ : files.length + (files.length === 1 ? " file selected." : " files selected.")
61+ );
62+ }
63+ async dropped(transfer) {
64+ const entries = [...(transfer.items || [])].map(item => item.webkitGetAsEntry?.()).filter(Boolean);
65+ if (!entries.length) return this.choose([...transfer.files]);
66+ const files = [];
67+ const walk = (entry, path) =>
68+ new Promise((resolve, reject) => {
69+ if (files.length > MAX_FILES) return reject(Error("Choose a folder with at most 10,000 files."));
70+ if (entry.isFile) {
71+ entry.file(file => {
72+ if (path) Object.defineProperty(file, "webkitRelativePath", {value: path + file.name});
73+ files.push(file);
74+ resolve();
75+ }, reject);
76+ return;
77+ }
78+ const reader = entry.createReader();
79+ const next = () =>
80+ reader.readEntries(async batch => {
81+ if (!batch.length) return resolve();
82+ try {
83+ for (const child of batch) await walk(child, path + entry.name + "/");
84+ } catch (error) {
85+ return reject(error);
86+ }
87+ next();
88+ }, reject);
89+ next();
90+ });
91+ for (const entry of entries) await walk(entry, "");
92+ this.choose(files);
93+ }
94 disconnectedCallback() {
95 this.controller?.abort();
96 }
97@@ -183,12 +236,8 @@
98 async run(form) {
99 if (this.busy) return;
100 if (form) {
101- const files = [...form.elements.namedItem("file").files];
102- this.selection = {
103- files,
104- folder: Boolean(form.elements.namedItem("folder").checked),
105- encrypt: Boolean(form.elements.namedItem("encrypt").checked)
106- };
107+ if (!this.chosen) this.choose([...form.elements.namedItem("file").files]);
108+ this.selection = {...this.chosen, encrypt: Boolean(form.elements.namedItem("encrypt").checked)};
109 this.pending = null;
110 }
111 if (!this.selection?.files.length) throw Error("Choose a file or folder first.");
112@@ -221,7 +270,7 @@
113 } finally {
114 this.busy = false;
115 this.controls(false, completed);
116- if (completed) this.selection = null;
117+ if (completed) this.selection = this.chosen = null;
118 }
119 }
120 async storePlain(files) {
121diff --git a/internal/webui/style.css b/internal/webui/style.css
122index e5a838b..2fd2589 100644
123--- a/internal/webui/style.css
124+++ b/internal/webui/style.css
125@@ -115,6 +115,7 @@ rpc-form label input,rpc-form label select,signed-form label input,nostr-compose
126 file-upload label[data-share]{display:block}file-upload input[data-share-url]{width:100%}
127 file-upload label:has([type=checkbox]),#panel file-upload label:has([type=checkbox]){display:inline-block;margin:8px 14px 8px 0}file-upload label:has([type=checkbox]) input,#panel file-upload label:has([type=checkbox]) input{display:inline-block;width:auto;min-width:0;margin:0 4px 0 0;vertical-align:middle}
128 #panel file-upload [hidden],file-upload [hidden],file-tools [hidden]{display:none}
129+file-upload p[data-drop]{margin:6px 0;color:var(--muted);font-size:12px}file-upload[data-over]{outline:1px dashed var(--accent);outline-offset:4px}
130 #panel file-upload,#panel file-mirror{border:0;padding:0;max-width:none}#panel details{margin-top:8px}#panel details>summary{cursor:pointer;color:var(--muted);font-size:12px}
131 rpc-form label:has(textarea),publish-list label:has(textarea),nostr-compose label:has(textarea){display:block}
132 rpc-form output,signed-form output,publish-list output,nostr-compose output,file-tools output,file-upload output,file-workspace-root output,private-services output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px;overflow-wrap:anywhere}
133diff --git a/scripts/file-workspace.test.mjs b/scripts/file-workspace.test.mjs
134index a29ef18..8cda48b 100644
135--- a/scripts/file-workspace.test.mjs
136+++ b/scripts/file-workspace.test.mjs
137@@ -108,7 +108,6 @@ const workspaceServer = t => {
138 upload.connectedCallback();
139 const form = upload.querySelector("form");
140 form.elements.namedItem("file").files = files;
141- form.elements.namedItem("folder").checked = true;
142 form.elements.namedItem("encrypt").checked = true;
143 return upload.run(form);
144 };

About

ownerc01503e8035f
visibilitypublic
headc656e4458f03

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs