@putkoff/abstract-securefiles 0.0.48 → 0.0.49

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.
Files changed (197) hide show
  1. package/dist/ConfirmButton.d.ts +5 -0
  2. package/dist/CopyButton.d.ts +4 -0
  3. package/dist/FolderSelect.d.ts +6 -0
  4. package/dist/SecureFiles.d.ts +3 -0
  5. package/dist/ServerFiles.d.ts +6 -0
  6. package/dist/ShareDialog.d.ts +6 -0
  7. package/dist/UploadPanel.d.ts +6 -0
  8. package/dist/api.d.ts +46 -0
  9. package/dist/index.cjs +537 -1004
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.ts +4 -8
  12. package/dist/index.js +530 -951
  13. package/dist/index.js.map +1 -1
  14. package/dist/types.d.ts +54 -0
  15. package/package.json +15 -11
  16. package/src/ConfirmButton.tsx +35 -0
  17. package/src/CopyButton.tsx +24 -0
  18. package/src/FolderSelect.tsx +68 -0
  19. package/src/SecureFiles.tsx +344 -0
  20. package/src/ServerFiles.tsx +324 -0
  21. package/src/ShareDialog.tsx +148 -0
  22. package/src/UploadPanel.tsx +217 -0
  23. package/src/api.ts +119 -0
  24. package/src/index.ts +4 -8
  25. package/src/styles.css +432 -0
  26. package/src/types.ts +54 -0
  27. package/css.d.ts +0 -2
  28. package/dist/App/SecureFiles.d.ts +0 -4
  29. package/dist/App/index.d.ts +0 -1
  30. package/dist/FileHandlers/FileRow/imports.d.ts +0 -2
  31. package/dist/FileHandlers/FileRow/index.d.ts +0 -1
  32. package/dist/FileHandlers/FileRow/src/index.d.ts +0 -1
  33. package/dist/FileHandlers/FileRow/src/row_utils.d.ts +0 -2
  34. package/dist/FileHandlers/FileTable/imports.d.ts +0 -4
  35. package/dist/FileHandlers/FileTable/index.d.ts +0 -1
  36. package/dist/FileHandlers/FileTable/src/index.d.ts +0 -1
  37. package/dist/FileHandlers/FileTable/src/table_utils.d.ts +0 -2
  38. package/dist/FileHandlers/ShareModal/imports.d.ts +0 -2
  39. package/dist/FileHandlers/ShareModal/index.d.ts +0 -1
  40. package/dist/FileHandlers/ShareModal/src/index.d.ts +0 -1
  41. package/dist/FileHandlers/ShareModal/src/share_utils.d.ts +0 -2
  42. package/dist/FileHandlers/Upload/imports.d.ts +0 -2
  43. package/dist/FileHandlers/Upload/index.d.ts +0 -1
  44. package/dist/FileHandlers/Upload/src/index.d.ts +0 -2
  45. package/dist/FileHandlers/Upload/src/upload_utils.d.ts +0 -2
  46. package/dist/FileHandlers/imports.d.ts +0 -4
  47. package/dist/FileHandlers/index.d.ts +0 -4
  48. package/dist/UI/index.d.ts +0 -28
  49. package/dist/file_utils/imports.d.ts +0 -2
  50. package/dist/file_utils/index.d.ts +0 -1
  51. package/dist/file_utils/src/cookieFetch.d.ts +0 -33
  52. package/dist/file_utils/src/delete_utils/imports.d.ts +0 -1
  53. package/dist/file_utils/src/delete_utils/index.d.ts +0 -1
  54. package/dist/file_utils/src/delete_utils/src/delete_row.d.ts +0 -2
  55. package/dist/file_utils/src/delete_utils/src/delete_select.d.ts +0 -4
  56. package/dist/file_utils/src/delete_utils/src/delete_selected.d.ts +0 -2
  57. package/dist/file_utils/src/delete_utils/src/index.d.ts +0 -3
  58. package/dist/file_utils/src/download_utils/imports.d.ts +0 -2
  59. package/dist/file_utils/src/download_utils/index.d.ts +0 -1
  60. package/dist/file_utils/src/download_utils/src/direct_download.d.ts +0 -3
  61. package/dist/file_utils/src/download_utils/src/download_file.d.ts +0 -6
  62. package/dist/file_utils/src/download_utils/src/download_select.d.ts +0 -2
  63. package/dist/file_utils/src/download_utils/src/index.d.ts +0 -5
  64. package/dist/file_utils/src/download_utils/src/target_downloads.d.ts +0 -4
  65. package/dist/file_utils/src/download_utils/src/trigger_download.d.ts +0 -2
  66. package/dist/file_utils/src/imports.d.ts +0 -2
  67. package/dist/file_utils/src/index.d.ts +0 -6
  68. package/dist/file_utils/src/list_utils/imports.d.ts +0 -1
  69. package/dist/file_utils/src/list_utils/index.d.ts +0 -1
  70. package/dist/file_utils/src/list_utils/src/index.d.ts +0 -1
  71. package/dist/file_utils/src/list_utils/src/list_utils.d.ts +0 -2
  72. package/dist/file_utils/src/select_utils/index.d.ts +0 -1
  73. package/dist/file_utils/src/select_utils/src/index.d.ts +0 -3
  74. package/dist/file_utils/src/select_utils/src/init_select_all.d.ts +0 -2
  75. package/dist/file_utils/src/select_utils/src/sync_header_checkbox.d.ts +0 -5
  76. package/dist/file_utils/src/select_utils/src/toggle_checkboxes.d.ts +0 -2
  77. package/dist/file_utils/src/share_utils/imports.d.ts +0 -1
  78. package/dist/file_utils/src/share_utils/index.d.ts +0 -1
  79. package/dist/file_utils/src/share_utils/src/index.d.ts +0 -1
  80. package/dist/file_utils/src/share_utils/src/share_utils.d.ts +0 -7
  81. package/dist/file_utils/src/upload_utils/imports.d.ts +0 -0
  82. package/dist/file_utils/src/upload_utils/index.d.ts +0 -1
  83. package/dist/file_utils/src/upload_utils/src/index.d.ts +0 -3
  84. package/dist/file_utils/src/upload_utils/src/patch_file_settings.d.ts +0 -2
  85. package/dist/file_utils/src/upload_utils/src/upload_file.d.ts +0 -2
  86. package/dist/file_utils/src/upload_utils/src/upload_files.d.ts +0 -5
  87. package/dist/hooks/imports.d.ts +0 -3
  88. package/dist/hooks/index.d.ts +0 -1
  89. package/dist/hooks/useFiles/imports.d.ts +0 -2
  90. package/dist/hooks/useFiles/index.d.ts +0 -1
  91. package/dist/hooks/useFiles/src/deleteFile.d.ts +0 -3
  92. package/dist/hooks/useFiles/src/index.d.ts +0 -6
  93. package/dist/hooks/useFiles/src/load.d.ts +0 -3
  94. package/dist/hooks/useFiles/src/patchShareSettings.d.ts +0 -3
  95. package/dist/hooks/useFiles/src/toggleShare.d.ts +0 -3
  96. package/dist/hooks/useFiles/src/useFiles.d.ts +0 -13
  97. package/dist/hooks/useFiles/src/useSaveShareSettings.d.ts +0 -7
  98. package/dist/imports.d.ts +0 -6
  99. package/dist/index.css +0 -1
  100. package/dist/styles/index.d.ts +0 -1
  101. package/dist/types/index.d.ts +0 -1
  102. package/dist/types/src/index.d.ts +0 -1
  103. package/dist/types/src/types.d.ts +0 -75
  104. package/index.ts +0 -1
  105. package/postcss.config.cjs +0 -8
  106. package/rollup.config.js +0 -38
  107. package/rollup.config.mjs +0 -35
  108. package/src/App/SecureFiles.tsx +0 -45
  109. package/src/App/index.ts +0 -1
  110. package/src/FileHandlers/FileRow/imports.ts +0 -2
  111. package/src/FileHandlers/FileRow/index.ts +0 -1
  112. package/src/FileHandlers/FileRow/src/index.ts +0 -1
  113. package/src/FileHandlers/FileRow/src/row_utils.tsx +0 -143
  114. package/src/FileHandlers/FileTable/imports.ts +0 -15
  115. package/src/FileHandlers/FileTable/index.ts +0 -1
  116. package/src/FileHandlers/FileTable/src/index.ts +0 -1
  117. package/src/FileHandlers/FileTable/src/table_utils.tsx +0 -251
  118. package/src/FileHandlers/ShareModal/imports.ts +0 -2
  119. package/src/FileHandlers/ShareModal/index.ts +0 -1
  120. package/src/FileHandlers/ShareModal/src/index.ts +0 -1
  121. package/src/FileHandlers/ShareModal/src/share_utils.tsx +0 -155
  122. package/src/FileHandlers/Upload/imports.ts +0 -3
  123. package/src/FileHandlers/Upload/index.ts +0 -1
  124. package/src/FileHandlers/Upload/src/index.ts +0 -2
  125. package/src/FileHandlers/Upload/src/upload_utils.tsx +0 -369
  126. package/src/FileHandlers/imports.ts +0 -17
  127. package/src/FileHandlers/index.ts +0 -4
  128. package/src/UI/index.tsx +0 -122
  129. package/src/custom.d.ts +0 -7
  130. package/src/file_utils/imports.ts +0 -2
  131. package/src/file_utils/index.ts +0 -1
  132. package/src/file_utils/src/cookieFetch.ts +0 -109
  133. package/src/file_utils/src/delete_utils/imports.ts +0 -1
  134. package/src/file_utils/src/delete_utils/index.ts +0 -1
  135. package/src/file_utils/src/delete_utils/src/delete_row.ts +0 -11
  136. package/src/file_utils/src/delete_utils/src/delete_select.ts +0 -10
  137. package/src/file_utils/src/delete_utils/src/delete_selected.ts +0 -15
  138. package/src/file_utils/src/delete_utils/src/index.ts +0 -3
  139. package/src/file_utils/src/download_utils/imports.ts +0 -3
  140. package/src/file_utils/src/download_utils/index.ts +0 -1
  141. package/src/file_utils/src/download_utils/src/direct_download.ts +0 -22
  142. package/src/file_utils/src/download_utils/src/download_file.ts +0 -25
  143. package/src/file_utils/src/download_utils/src/download_select.ts +0 -25
  144. package/src/file_utils/src/download_utils/src/index.ts +0 -5
  145. package/src/file_utils/src/download_utils/src/target_downloads.ts +0 -21
  146. package/src/file_utils/src/download_utils/src/trigger_download.ts +0 -14
  147. package/src/file_utils/src/imports.ts +0 -3
  148. package/src/file_utils/src/index.ts +0 -6
  149. package/src/file_utils/src/list_utils/imports.ts +0 -2
  150. package/src/file_utils/src/list_utils/index.ts +0 -1
  151. package/src/file_utils/src/list_utils/src/index.ts +0 -1
  152. package/src/file_utils/src/list_utils/src/list_utils.ts +0 -28
  153. package/src/file_utils/src/select_utils/index.ts +0 -1
  154. package/src/file_utils/src/select_utils/src/index.ts +0 -3
  155. package/src/file_utils/src/select_utils/src/init_select_all.ts +0 -37
  156. package/src/file_utils/src/select_utils/src/sync_header_checkbox.ts +0 -18
  157. package/src/file_utils/src/select_utils/src/toggle_checkboxes.ts +0 -11
  158. package/src/file_utils/src/share_utils/imports.ts +0 -1
  159. package/src/file_utils/src/share_utils/index.ts +0 -1
  160. package/src/file_utils/src/share_utils/src/index.ts +0 -1
  161. package/src/file_utils/src/share_utils/src/share_utils.ts +0 -20
  162. package/src/file_utils/src/upload_utils/imports.ts +0 -1
  163. package/src/file_utils/src/upload_utils/index.ts +0 -1
  164. package/src/file_utils/src/upload_utils/src/index.ts +0 -3
  165. package/src/file_utils/src/upload_utils/src/patch_file_settings.ts +0 -20
  166. package/src/file_utils/src/upload_utils/src/upload_file.ts +0 -27
  167. package/src/file_utils/src/upload_utils/src/upload_files.ts +0 -19
  168. package/src/hooks/imports.ts +0 -3
  169. package/src/hooks/index.ts +0 -1
  170. package/src/hooks/useFiles/imports.ts +0 -3
  171. package/src/hooks/useFiles/index.ts +0 -1
  172. package/src/hooks/useFiles/src/deleteFile.ts +0 -23
  173. package/src/hooks/useFiles/src/index.ts +0 -6
  174. package/src/hooks/useFiles/src/load.ts +0 -39
  175. package/src/hooks/useFiles/src/logs/clipit_logs.log +0 -0
  176. package/src/hooks/useFiles/src/logs/routes_bp.log +0 -0
  177. package/src/hooks/useFiles/src/patchShareSettings.ts +0 -27
  178. package/src/hooks/useFiles/src/toggleShare.ts +0 -28
  179. package/src/hooks/useFiles/src/useFiles.ts +0 -35
  180. package/src/hooks/useFiles/src/useSaveShareSettings.ts +0 -31
  181. package/src/imports.ts +0 -18
  182. package/src/styles/SecureFiles/App.css +0 -42
  183. package/src/styles/SecureFiles/index.css +0 -10
  184. package/src/styles/SecureFiles/logs/clipit_logs.log +0 -0
  185. package/src/styles/SecureFiles/logs/routes_bp.log +0 -0
  186. package/src/styles/SecureFiles/tailwind.css +0 -3
  187. package/src/styles/SecureFiles/window-manager.css +0 -283
  188. package/src/styles/SecureFilesStyles.module.css +0 -287
  189. package/src/styles/index.ts +0 -1
  190. package/src/types/index.ts +0 -1
  191. package/src/types/src/index.ts +0 -1
  192. package/src/types/src/types.ts +0 -88
  193. package/tailwind.config.cjs +0 -7
  194. package/testit.mjs +0 -11
  195. package/testit.ts +0 -3
  196. package/tsconfig.json +0 -27
  197. /package/src/{global.d.ts → css.d.ts} +0 -0
@@ -0,0 +1,324 @@
1
+ import React, { useCallback, useEffect, useState } from "react";
2
+ import type { SecureFilesApi } from "./api";
3
+ import type { BrowseResult, LinkRoot, RootChallenge } from "./types";
4
+ import { ConfirmButton } from "./ConfirmButton";
5
+ import { CopyButton } from "./CopyButton";
6
+ import { FolderSelect } from "./FolderSelect";
7
+
8
+ function formatSize(bytes: number) {
9
+ const units = ["B", "KB", "MB", "GB", "TB"];
10
+ let n = bytes;
11
+ let i = 0;
12
+ while (n >= 1024 && i < units.length - 1) {
13
+ n /= 1024;
14
+ i++;
15
+ }
16
+ return `${n.toFixed(i ? 1 : 0)} ${units[i]}`;
17
+ }
18
+
19
+ const errText = (err: unknown) => (err instanceof Error ? err.message : String(err));
20
+
21
+ function AddRoot({ api, onAdded }: { api: SecureFilesApi; onAdded: (roots: LinkRoot[]) => void }) {
22
+ const [path, setPath] = useState("");
23
+ const [label, setLabel] = useState("");
24
+ const [challenge, setChallenge] = useState<RootChallenge | null>(null);
25
+ const [signature, setSignature] = useState("");
26
+ const [error, setError] = useState<string | null>(null);
27
+ const [busy, setBusy] = useState(false);
28
+
29
+ const request = async () => {
30
+ setBusy(true);
31
+ setError(null);
32
+ try {
33
+ setChallenge((await api.requestRoot(path.trim())).challenge);
34
+ setSignature("");
35
+ } catch (err) {
36
+ setError(errText(err));
37
+ } finally {
38
+ setBusy(false);
39
+ }
40
+ };
41
+
42
+ const confirm = async () => {
43
+ if (!challenge) return;
44
+ setBusy(true);
45
+ setError(null);
46
+ try {
47
+ onAdded((await api.confirmRoot(challenge.nonce, signature, label)).roots);
48
+ setChallenge(null);
49
+ setPath("");
50
+ setLabel("");
51
+ setSignature("");
52
+ } catch (err) {
53
+ setError(errText(err));
54
+ } finally {
55
+ setBusy(false);
56
+ }
57
+ };
58
+
59
+ const minutesLeft = challenge ? Math.max(0, Math.round((challenge.expires * 1000 - Date.now()) / 60000)) : 0;
60
+
61
+ return (
62
+ <div className="sf-card">
63
+ <h4>Allow a directory</h4>
64
+ {!challenge ? (
65
+ <div className="sf-row sf-row--bottom">
66
+ <label className="sf-field">
67
+ <span>Absolute path on the server</span>
68
+ <input
69
+ className="sf-input"
70
+ value={path}
71
+ onChange={(e) => setPath(e.target.value)}
72
+ placeholder="/srv/shared/docs"
73
+ onKeyDown={(e) => e.key === "Enter" && path.trim() && request()}
74
+ />
75
+ </label>
76
+ <label className="sf-field sf-field--narrow">
77
+ <span>Label (optional)</span>
78
+ <input className="sf-input" value={label} onChange={(e) => setLabel(e.target.value)} />
79
+ </label>
80
+ <button type="button" className="sf-btn sf-btn--primary" disabled={busy || !path.trim()} onClick={request}>
81
+ Request
82
+ </button>
83
+ </div>
84
+ ) : (
85
+ <div className="sf-stack">
86
+ <p>
87
+ Allowing <code>{challenge.message.split("|")[3]}</code> requires a signature from a trusted SSH key. Run
88
+ this where that key lives (expires in about {minutesLeft} min):
89
+ </p>
90
+ <div className="sf-row">
91
+ <textarea className="sf-input sf-code" readOnly rows={3} value={challenge.command} />
92
+ <CopyButton text={challenge.command} label="Copy command" />
93
+ </div>
94
+ <label className="sf-field">
95
+ <span>Paste the full output (BEGIN … END SSH SIGNATURE)</span>
96
+ <textarea
97
+ className="sf-input sf-code"
98
+ rows={6}
99
+ value={signature}
100
+ onChange={(e) => setSignature(e.target.value)}
101
+ placeholder="-----BEGIN SSH SIGNATURE-----"
102
+ />
103
+ </label>
104
+ <div className="sf-row">
105
+ <button
106
+ type="button"
107
+ className="sf-btn sf-btn--primary"
108
+ disabled={busy || !signature.includes("END SSH SIGNATURE")}
109
+ onClick={confirm}
110
+ >
111
+ Verify &amp; allow
112
+ </button>
113
+ <button type="button" className="sf-btn" onClick={() => setChallenge(null)}>
114
+ Cancel
115
+ </button>
116
+ </div>
117
+ </div>
118
+ )}
119
+ {error && <p className="sf-error">{error}</p>}
120
+ </div>
121
+ );
122
+ }
123
+
124
+ function Browser({
125
+ api,
126
+ roots,
127
+ folders,
128
+ onLinked,
129
+ }: {
130
+ api: SecureFilesApi;
131
+ roots: LinkRoot[];
132
+ folders: string[];
133
+ onLinked: () => void;
134
+ }) {
135
+ const [listing, setListing] = useState<BrowseResult | null>(null);
136
+ const [picked, setPicked] = useState<Set<string>>(new Set());
137
+ const [target, setTarget] = useState("");
138
+ const [error, setError] = useState<string | null>(null);
139
+ const [result, setResult] = useState<string | null>(null);
140
+ const [busy, setBusy] = useState(false);
141
+
142
+ const open = useCallback(
143
+ async (path: string) => {
144
+ setError(null);
145
+ try {
146
+ setListing(await api.browse(path));
147
+ setPicked(new Set());
148
+ } catch (err) {
149
+ setError(errText(err));
150
+ }
151
+ },
152
+ [api]
153
+ );
154
+
155
+ useEffect(() => {
156
+ if (!listing && roots.length) open(roots[0].path);
157
+ if (listing && !roots.some((r) => listing.path.startsWith(r.path))) setListing(null);
158
+ }, [roots, listing, open]);
159
+
160
+ const link = async () => {
161
+ setBusy(true);
162
+ setResult(null);
163
+ try {
164
+ const res = await api.link([...picked], target);
165
+ const parts = [`Linked ${res.linked.length} file(s)`];
166
+ if (res.errors.length) parts.push(`${res.errors.length} skipped: ${res.errors.map((e) => e.error).join("; ")}`);
167
+ setResult(parts.join(". "));
168
+ onLinked();
169
+ if (listing) await open(listing.path);
170
+ } catch (err) {
171
+ setResult(errText(err));
172
+ } finally {
173
+ setBusy(false);
174
+ }
175
+ };
176
+
177
+ if (!roots.length) return <p className="sf-muted">Allow a directory above to browse and link its files.</p>;
178
+
179
+ const linkable = listing?.files.filter((f) => !f.linked_id) ?? [];
180
+ const relative = listing ? listing.path.slice(listing.root.length) || "/" : "";
181
+
182
+ return (
183
+ <div className="sf-card">
184
+ <h4>Link files</h4>
185
+ <div className="sf-row">
186
+ <select
187
+ className="sf-input sf-root-select"
188
+ value={listing?.root ?? ""}
189
+ onChange={(e) => open(e.target.value)}
190
+ >
191
+ {roots.map((r) => (
192
+ <option key={r.id} value={r.path}>
193
+ {r.label ? `${r.label} (${r.path})` : r.path}
194
+ </option>
195
+ ))}
196
+ </select>
197
+ {listing && <code className="sf-muted">{relative}</code>}
198
+ </div>
199
+ {error && <p className="sf-error">{error}</p>}
200
+ {listing && (
201
+ <div className="sf-browser">
202
+ {listing.parent && (
203
+ <button type="button" className="sf-browser__dir" onClick={() => open(listing.parent!)}>
204
+ ↩ ..
205
+ </button>
206
+ )}
207
+ {listing.dirs.map((d) => (
208
+ <button key={d} type="button" className="sf-browser__dir" onClick={() => open(`${listing.path}/${d}`)}>
209
+ 📁 {d}
210
+ </button>
211
+ ))}
212
+ {listing.files.length > 0 && (
213
+ <label className="sf-check sf-browser__all">
214
+ <input
215
+ type="checkbox"
216
+ checked={linkable.length > 0 && linkable.every((f) => picked.has(f.path))}
217
+ disabled={!linkable.length}
218
+ onChange={(e) => setPicked(e.target.checked ? new Set(linkable.map((f) => f.path)) : new Set())}
219
+ />
220
+ Select all files here
221
+ </label>
222
+ )}
223
+ {listing.files.map((f) => (
224
+ <label key={f.path} className={`sf-check sf-browser__file${f.linked_id ? " sf-muted" : ""}`}>
225
+ <input
226
+ type="checkbox"
227
+ disabled={!!f.linked_id}
228
+ checked={picked.has(f.path)}
229
+ onChange={(e) =>
230
+ setPicked((p) => {
231
+ const next = new Set(p);
232
+ e.target.checked ? next.add(f.path) : next.delete(f.path);
233
+ return next;
234
+ })
235
+ }
236
+ />
237
+ <span className="sf-browser__name">{f.name}</span>
238
+ <span className="sf-muted">{f.linked_id ? "already linked" : formatSize(f.size)}</span>
239
+ </label>
240
+ ))}
241
+ {!listing.dirs.length && !listing.files.length && <p className="sf-muted">Empty directory</p>}
242
+ {listing.truncated && <p className="sf-muted">Showing the first 5000 entries.</p>}
243
+ </div>
244
+ )}
245
+ <div className="sf-row sf-row--bottom">
246
+ <FolderSelect label="Show in Secure Files under" folders={folders} value={target} onChange={setTarget} />
247
+ <button type="button" className="sf-btn sf-btn--primary" disabled={busy || !picked.size} onClick={link}>
248
+ {busy ? "Linking…" : `Link ${picked.size || ""}`.trim()}
249
+ </button>
250
+ </div>
251
+ {result && <p className={result.includes("skipped") ? "sf-error" : "sf-muted"}>{result}</p>}
252
+ </div>
253
+ );
254
+ }
255
+
256
+ export function ServerFiles({
257
+ api,
258
+ folders,
259
+ onLinked,
260
+ }: {
261
+ api: SecureFilesApi;
262
+ folders: string[];
263
+ onLinked: () => void;
264
+ }) {
265
+ const [roots, setRoots] = useState<LinkRoot[] | null>(null);
266
+ const [error, setError] = useState<string | null>(null);
267
+
268
+ const load = useCallback(async () => {
269
+ try {
270
+ setRoots((await api.roots()).roots);
271
+ setError(null);
272
+ } catch (err) {
273
+ setError(errText(err));
274
+ }
275
+ }, [api]);
276
+
277
+ return (
278
+ <details className="sf-admin" onToggle={(e) => (e.currentTarget as HTMLDetailsElement).open && !roots && load()}>
279
+ <summary>Server files (admin)</summary>
280
+ <div className="sf-stack">
281
+ <p className="sf-muted">
282
+ Link files that already live on the server instead of uploading copies. Only files inside allowed
283
+ directories can be linked; removing a directory immediately disables its links. Deleting a linked entry
284
+ never touches the original file. Available from the local network only.
285
+ </p>
286
+ {error && <p className="sf-error">{error}</p>}
287
+ {roots && (
288
+ <>
289
+ <div className="sf-card">
290
+ <h4>Allowed directories</h4>
291
+ {roots.length === 0 ? (
292
+ <p className="sf-muted">None yet.</p>
293
+ ) : (
294
+ <ul className="sf-roots">
295
+ {roots.map((r) => (
296
+ <li key={r.id}>
297
+ <span>
298
+ <code>{r.path}</code> {r.label && <span className="sf-muted">— {r.label}</span>}
299
+ {!r.exists && <span className="sf-error"> (missing)</span>}
300
+ </span>
301
+ <ConfirmButton
302
+ label="Remove"
303
+ onConfirm={async () => {
304
+ try {
305
+ setRoots((await api.removeRoot(r.id)).roots);
306
+ onLinked();
307
+ } catch (err) {
308
+ setError(errText(err));
309
+ }
310
+ }}
311
+ />
312
+ </li>
313
+ ))}
314
+ </ul>
315
+ )}
316
+ </div>
317
+ <AddRoot api={api} onAdded={setRoots} />
318
+ <Browser api={api} roots={roots} folders={folders} onLinked={onLinked} />
319
+ </>
320
+ )}
321
+ </div>
322
+ </details>
323
+ );
324
+ }
@@ -0,0 +1,148 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import type { SecureFile, ShareUpdate } from "./types";
3
+ import { CopyButton } from "./CopyButton";
4
+
5
+ type PasswordMode = "keep" | "set" | "remove";
6
+
7
+ export function ShareDialog({
8
+ file,
9
+ onClose,
10
+ onSave,
11
+ }: {
12
+ file: SecureFile;
13
+ onClose: () => void;
14
+ onSave: (update: ShareUpdate) => Promise<void>;
15
+ }) {
16
+ const [shareable, setShareable] = useState(file.shareable);
17
+ const [limit, setLimit] = useState(file.download_limit ? String(file.download_limit) : "");
18
+ const [pwMode, setPwMode] = useState<PasswordMode>(file.has_password ? "keep" : "remove");
19
+ const [password, setPassword] = useState("");
20
+ const [resetCount, setResetCount] = useState(false);
21
+ const [saving, setSaving] = useState(false);
22
+ const [error, setError] = useState<string | null>(null);
23
+
24
+ useEffect(() => {
25
+ const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
26
+ window.addEventListener("keydown", onKey);
27
+ return () => window.removeEventListener("keydown", onKey);
28
+ }, [onClose]);
29
+
30
+ const limitValid = limit === "" || /^\d+$/.test(limit);
31
+ const passwordValid = pwMode !== "set" || password.length > 0;
32
+
33
+ const save = async () => {
34
+ setSaving(true);
35
+ setError(null);
36
+ try {
37
+ const update: ShareUpdate = { id: file.id, shareable };
38
+ if (shareable) {
39
+ update.download_limit = limit === "" ? null : Number(limit);
40
+ if (pwMode === "set") update.password = password;
41
+ else if (pwMode === "remove" && file.has_password) update.password = "";
42
+ if (resetCount) update.reset_count = true;
43
+ }
44
+ await onSave(update);
45
+ onClose();
46
+ } catch (err) {
47
+ setError(err instanceof Error ? err.message : String(err));
48
+ } finally {
49
+ setSaving(false);
50
+ }
51
+ };
52
+
53
+ return (
54
+ <div className="sf-overlay" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
55
+ <div className="sf-dialog" role="dialog" aria-modal="true" aria-label={`Share settings for ${file.filename}`}>
56
+ <header className="sf-dialog__head">
57
+ <h3>Share settings</h3>
58
+ <button type="button" className="sf-icon-btn" aria-label="Close" onClick={onClose}>
59
+ ✕
60
+ </button>
61
+ </header>
62
+ <p className="sf-muted">{file.filepath}</p>
63
+
64
+ <label className="sf-check">
65
+ <input type="checkbox" checked={shareable} onChange={(e) => setShareable(e.target.checked)} />
66
+ Anyone with the link can download
67
+ </label>
68
+
69
+ <fieldset disabled={!shareable} className="sf-fieldset">
70
+ <label className="sf-field">
71
+ <span>Download limit (blank = unlimited)</span>
72
+ <input
73
+ className="sf-input"
74
+ inputMode="numeric"
75
+ value={limit}
76
+ onChange={(e) => setLimit(e.target.value.trim())}
77
+ placeholder="unlimited"
78
+ />
79
+ {!limitValid && <span className="sf-error">Enter a whole number</span>}
80
+ </label>
81
+ <p className="sf-muted">
82
+ Downloaded {file.download_count} time{file.download_count === 1 ? "" : "s"}
83
+ {file.download_limit ? ` of ${file.download_limit}` : ""}.{" "}
84
+ <label className="sf-check sf-check--inline">
85
+ <input type="checkbox" checked={resetCount} onChange={(e) => setResetCount(e.target.checked)} />
86
+ reset count
87
+ </label>
88
+ </p>
89
+
90
+ <div className="sf-field">
91
+ <span>Password</span>
92
+ <div className="sf-row">
93
+ {file.has_password && (
94
+ <label className="sf-check sf-check--inline">
95
+ <input type="radio" checked={pwMode === "keep"} onChange={() => setPwMode("keep")} />
96
+ keep current
97
+ </label>
98
+ )}
99
+ <label className="sf-check sf-check--inline">
100
+ <input type="radio" checked={pwMode === "set"} onChange={() => setPwMode("set")} />
101
+ {file.has_password ? "change" : "require password"}
102
+ </label>
103
+ <label className="sf-check sf-check--inline">
104
+ <input type="radio" checked={pwMode === "remove"} onChange={() => setPwMode("remove")} />
105
+ {file.has_password ? "remove" : "none"}
106
+ </label>
107
+ </div>
108
+ {pwMode === "set" && (
109
+ <input
110
+ className="sf-input"
111
+ type="password"
112
+ autoComplete="new-password"
113
+ value={password}
114
+ onChange={(e) => setPassword(e.target.value)}
115
+ placeholder="New password"
116
+ />
117
+ )}
118
+ </div>
119
+
120
+ <div className="sf-field">
121
+ <span>Share link</span>
122
+ <div className="sf-row">
123
+ <input className="sf-input" readOnly value={file.share_url} onFocus={(e) => e.target.select()} />
124
+ <CopyButton text={file.share_url} />
125
+ </div>
126
+ {!file.shareable && <span className="sf-muted">The link works once you save with sharing on.</span>}
127
+ </div>
128
+ </fieldset>
129
+
130
+ {error && <p className="sf-error">{error}</p>}
131
+
132
+ <footer className="sf-row sf-dialog__foot">
133
+ <button type="button" className="sf-btn" onClick={onClose}>
134
+ Cancel
135
+ </button>
136
+ <button
137
+ type="button"
138
+ className="sf-btn sf-btn--primary"
139
+ disabled={saving || !limitValid || !passwordValid}
140
+ onClick={save}
141
+ >
142
+ {saving ? "Saving…" : "Save"}
143
+ </button>
144
+ </footer>
145
+ </div>
146
+ </div>
147
+ );
148
+ }
@@ -0,0 +1,217 @@
1
+ import React, { useRef, useState } from "react";
2
+ import type { SecureFilesApi } from "./api";
3
+ import { FolderSelect } from "./FolderSelect";
4
+
5
+ interface Queued {
6
+ file: File;
7
+ relPath: string;
8
+ progress: number;
9
+ error?: string;
10
+ done?: boolean;
11
+ }
12
+
13
+ function readEntries(reader: any): Promise<any[]> {
14
+ return new Promise((resolve, reject) => reader.readEntries(resolve, reject));
15
+ }
16
+
17
+ async function walkEntry(entry: any, prefix: string, out: { file: File; relPath: string }[]) {
18
+ if (entry.isFile) {
19
+ const file: File = await new Promise((resolve, reject) => entry.file(resolve, reject));
20
+ out.push({ file, relPath: prefix + file.name });
21
+ } else if (entry.isDirectory) {
22
+ const reader = entry.createReader();
23
+ let batch: any[];
24
+ do {
25
+ batch = await readEntries(reader);
26
+ for (const child of batch) await walkEntry(child, `${prefix}${entry.name}/`, out);
27
+ } while (batch.length > 0);
28
+ }
29
+ }
30
+
31
+ async function filesFromDrop(dt: DataTransfer) {
32
+ const out: { file: File; relPath: string }[] = [];
33
+ const entries = Array.from(dt.items || [])
34
+ .map((item) => (item as any).webkitGetAsEntry?.())
35
+ .filter(Boolean);
36
+ if (entries.length) {
37
+ for (const entry of entries) await walkEntry(entry, "", out);
38
+ } else {
39
+ for (const file of Array.from(dt.files)) out.push({ file, relPath: file.name });
40
+ }
41
+ return out;
42
+ }
43
+
44
+ function joinTarget(target: string, relPath: string) {
45
+ const clean = target.trim().replace(/^\/+|\/+$/g, "");
46
+ return clean ? `${clean}/${relPath}` : relPath;
47
+ }
48
+
49
+ function CloudIcon() {
50
+ return (
51
+ <svg className="sf-drop__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
52
+ <path
53
+ strokeLinecap="round"
54
+ strokeLinejoin="round"
55
+ d="M12 16.5V9.75m0 0 3 3m-3-3-3 3M6.75 19.5a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z"
56
+ />
57
+ </svg>
58
+ );
59
+ }
60
+
61
+ export function UploadPanel({
62
+ api,
63
+ folders,
64
+ onUploaded,
65
+ }: {
66
+ api: SecureFilesApi;
67
+ folders: string[];
68
+ onUploaded: () => void;
69
+ }) {
70
+ const [queue, setQueue] = useState<Queued[]>([]);
71
+ const [mode, setMode] = useState<"files" | "folder">("files");
72
+ const [target, setTarget] = useState("");
73
+ const [dragging, setDragging] = useState(false);
74
+ const [busy, setBusy] = useState(false);
75
+ const [message, setMessage] = useState<string | null>(null);
76
+ const fileInput = useRef<HTMLInputElement>(null);
77
+ const folderInput = useRef<HTMLInputElement>(null);
78
+
79
+ const enqueue = (items: { file: File; relPath: string }[]) => {
80
+ setMessage(null);
81
+ setQueue((q) => {
82
+ const kept = q.filter((x) => !x.done);
83
+ const seen = new Set(kept.map((x) => x.relPath));
84
+ return [...kept, ...items.filter((i) => !seen.has(i.relPath)).map((i) => ({ ...i, progress: 0 }))];
85
+ });
86
+ };
87
+
88
+ const openPicker = () => (mode === "folder" ? folderInput : fileInput).current?.click();
89
+
90
+ const fromInput = (e: React.ChangeEvent<HTMLInputElement>) => {
91
+ const files = Array.from(e.target.files || []);
92
+ enqueue(files.map((file) => ({ file, relPath: (file as any).webkitRelativePath || file.name })));
93
+ e.target.value = "";
94
+ };
95
+
96
+ const uploadAll = async () => {
97
+ setBusy(true);
98
+ setMessage(null);
99
+ let failed = 0;
100
+ for (let i = 0; i < queue.length; i++) {
101
+ const item = queue[i];
102
+ if (item.done) continue;
103
+ const set = (patch: Partial<Queued>) =>
104
+ setQueue((q) => q.map((x, idx) => (idx === i ? { ...x, ...patch } : x)));
105
+ try {
106
+ await api.upload(item.file, joinTarget(target, item.relPath), (p) => set({ progress: p }));
107
+ set({ progress: 1, done: true, error: undefined });
108
+ } catch (err) {
109
+ failed++;
110
+ set({ error: err instanceof Error ? err.message : String(err) });
111
+ }
112
+ }
113
+ setBusy(false);
114
+ setMessage(failed ? `${failed} upload(s) failed` : "Upload complete");
115
+ if (!failed) setQueue([]);
116
+ onUploaded();
117
+ };
118
+
119
+ const pending = queue.filter((q) => !q.done);
120
+
121
+ return (
122
+ <section className="sf-upload">
123
+ <div className="sf-row sf-mode" role="radiogroup" aria-label="Choose what clicking the upload area picks">
124
+ <label className="sf-check sf-check--inline">
125
+ <input type="radio" checked={mode === "files"} onChange={() => setMode("files")} /> Files
126
+ </label>
127
+ <label className="sf-check sf-check--inline">
128
+ <input type="radio" checked={mode === "folder"} onChange={() => setMode("folder")} /> Folder
129
+ </label>
130
+ <span className="sf-muted">(drag-and-drop accepts either)</span>
131
+ </div>
132
+
133
+ <div
134
+ className={`sf-drop${dragging ? " sf-drop--active" : ""}`}
135
+ role="button"
136
+ tabIndex={0}
137
+ aria-label={`Upload area: click to choose ${mode === "folder" ? "a folder" : "files"}, or drop files or folders`}
138
+ onClick={openPicker}
139
+ onKeyDown={(e) => {
140
+ if (e.key === "Enter" || e.key === " ") {
141
+ e.preventDefault();
142
+ openPicker();
143
+ }
144
+ }}
145
+ onDragEnter={(e) => {
146
+ e.preventDefault();
147
+ setDragging(true);
148
+ }}
149
+ onDragOver={(e) => e.preventDefault()}
150
+ onDragLeave={(e) => {
151
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragging(false);
152
+ }}
153
+ onDrop={async (e) => {
154
+ e.preventDefault();
155
+ setDragging(false);
156
+ enqueue(await filesFromDrop(e.dataTransfer));
157
+ }}
158
+ >
159
+ <CloudIcon />
160
+ <div>
161
+ <p className="sf-drop__title">Drag &amp; drop files or folders here</p>
162
+ <p className="sf-muted">Click anywhere to choose {mode === "folder" ? "a folder" : "files"}</p>
163
+ {pending.length > 0 && (
164
+ <p className="sf-drop__count">
165
+ {pending.length} item{pending.length === 1 ? "" : "s"} queued
166
+ </p>
167
+ )}
168
+ </div>
169
+ </div>
170
+ <input ref={fileInput} type="file" multiple hidden onChange={fromInput} />
171
+ <input
172
+ ref={folderInput}
173
+ type="file"
174
+ multiple
175
+ hidden
176
+ onChange={fromInput}
177
+ {...({ webkitdirectory: "", directory: "" } as any)}
178
+ />
179
+
180
+ <div className="sf-row">
181
+ <FolderSelect label="Upload into" folders={folders} value={target} onChange={setTarget} />
182
+ <button type="button" className="sf-btn sf-btn--primary" disabled={busy || !pending.length} onClick={uploadAll}>
183
+ {busy ? "Uploading…" : `Upload ${pending.length}`}
184
+ </button>
185
+ {queue.length > 0 && !busy && (
186
+ <button type="button" className="sf-btn" onClick={() => setQueue([])}>
187
+ Clear
188
+ </button>
189
+ )}
190
+ </div>
191
+
192
+ {message && (
193
+ <p className={message.includes("failed") ? "sf-error" : "sf-muted"} role="status">
194
+ {message}
195
+ </p>
196
+ )}
197
+
198
+ {queue.length > 0 && (
199
+ <ul className="sf-queue">
200
+ {queue.map((q, i) => (
201
+ <li key={`${q.relPath}-${i}`}>
202
+ <span className="sf-queue__name">{joinTarget(target, q.relPath)}</span>
203
+ {q.error ? (
204
+ <span className="sf-error">{q.error}</span>
205
+ ) : (
206
+ <span className="sf-row">
207
+ <progress max={1} value={q.progress} />
208
+ <span className="sf-muted">{Math.round(q.progress * 100)}%</span>
209
+ </span>
210
+ )}
211
+ </li>
212
+ ))}
213
+ </ul>
214
+ )}
215
+ </section>
216
+ );
217
+ }