@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.
- package/dist/ConfirmButton.d.ts +5 -0
- package/dist/CopyButton.d.ts +4 -0
- package/dist/FolderSelect.d.ts +6 -0
- package/dist/SecureFiles.d.ts +3 -0
- package/dist/ServerFiles.d.ts +6 -0
- package/dist/ShareDialog.d.ts +6 -0
- package/dist/UploadPanel.d.ts +6 -0
- package/dist/api.d.ts +46 -0
- package/dist/index.cjs +537 -1004
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +4 -8
- package/dist/index.js +530 -951
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +54 -0
- package/package.json +15 -11
- package/src/ConfirmButton.tsx +35 -0
- package/src/CopyButton.tsx +24 -0
- package/src/FolderSelect.tsx +68 -0
- package/src/SecureFiles.tsx +344 -0
- package/src/ServerFiles.tsx +324 -0
- package/src/ShareDialog.tsx +148 -0
- package/src/UploadPanel.tsx +217 -0
- package/src/api.ts +119 -0
- package/src/index.ts +4 -8
- package/src/styles.css +432 -0
- package/src/types.ts +54 -0
- package/css.d.ts +0 -2
- package/dist/App/SecureFiles.d.ts +0 -4
- package/dist/App/index.d.ts +0 -1
- package/dist/FileHandlers/FileRow/imports.d.ts +0 -2
- package/dist/FileHandlers/FileRow/index.d.ts +0 -1
- package/dist/FileHandlers/FileRow/src/index.d.ts +0 -1
- package/dist/FileHandlers/FileRow/src/row_utils.d.ts +0 -2
- package/dist/FileHandlers/FileTable/imports.d.ts +0 -4
- package/dist/FileHandlers/FileTable/index.d.ts +0 -1
- package/dist/FileHandlers/FileTable/src/index.d.ts +0 -1
- package/dist/FileHandlers/FileTable/src/table_utils.d.ts +0 -2
- package/dist/FileHandlers/ShareModal/imports.d.ts +0 -2
- package/dist/FileHandlers/ShareModal/index.d.ts +0 -1
- package/dist/FileHandlers/ShareModal/src/index.d.ts +0 -1
- package/dist/FileHandlers/ShareModal/src/share_utils.d.ts +0 -2
- package/dist/FileHandlers/Upload/imports.d.ts +0 -2
- package/dist/FileHandlers/Upload/index.d.ts +0 -1
- package/dist/FileHandlers/Upload/src/index.d.ts +0 -2
- package/dist/FileHandlers/Upload/src/upload_utils.d.ts +0 -2
- package/dist/FileHandlers/imports.d.ts +0 -4
- package/dist/FileHandlers/index.d.ts +0 -4
- package/dist/UI/index.d.ts +0 -28
- package/dist/file_utils/imports.d.ts +0 -2
- package/dist/file_utils/index.d.ts +0 -1
- package/dist/file_utils/src/cookieFetch.d.ts +0 -33
- package/dist/file_utils/src/delete_utils/imports.d.ts +0 -1
- package/dist/file_utils/src/delete_utils/index.d.ts +0 -1
- package/dist/file_utils/src/delete_utils/src/delete_row.d.ts +0 -2
- package/dist/file_utils/src/delete_utils/src/delete_select.d.ts +0 -4
- package/dist/file_utils/src/delete_utils/src/delete_selected.d.ts +0 -2
- package/dist/file_utils/src/delete_utils/src/index.d.ts +0 -3
- package/dist/file_utils/src/download_utils/imports.d.ts +0 -2
- package/dist/file_utils/src/download_utils/index.d.ts +0 -1
- package/dist/file_utils/src/download_utils/src/direct_download.d.ts +0 -3
- package/dist/file_utils/src/download_utils/src/download_file.d.ts +0 -6
- package/dist/file_utils/src/download_utils/src/download_select.d.ts +0 -2
- package/dist/file_utils/src/download_utils/src/index.d.ts +0 -5
- package/dist/file_utils/src/download_utils/src/target_downloads.d.ts +0 -4
- package/dist/file_utils/src/download_utils/src/trigger_download.d.ts +0 -2
- package/dist/file_utils/src/imports.d.ts +0 -2
- package/dist/file_utils/src/index.d.ts +0 -6
- package/dist/file_utils/src/list_utils/imports.d.ts +0 -1
- package/dist/file_utils/src/list_utils/index.d.ts +0 -1
- package/dist/file_utils/src/list_utils/src/index.d.ts +0 -1
- package/dist/file_utils/src/list_utils/src/list_utils.d.ts +0 -2
- package/dist/file_utils/src/select_utils/index.d.ts +0 -1
- package/dist/file_utils/src/select_utils/src/index.d.ts +0 -3
- package/dist/file_utils/src/select_utils/src/init_select_all.d.ts +0 -2
- package/dist/file_utils/src/select_utils/src/sync_header_checkbox.d.ts +0 -5
- package/dist/file_utils/src/select_utils/src/toggle_checkboxes.d.ts +0 -2
- package/dist/file_utils/src/share_utils/imports.d.ts +0 -1
- package/dist/file_utils/src/share_utils/index.d.ts +0 -1
- package/dist/file_utils/src/share_utils/src/index.d.ts +0 -1
- package/dist/file_utils/src/share_utils/src/share_utils.d.ts +0 -7
- package/dist/file_utils/src/upload_utils/imports.d.ts +0 -0
- package/dist/file_utils/src/upload_utils/index.d.ts +0 -1
- package/dist/file_utils/src/upload_utils/src/index.d.ts +0 -3
- package/dist/file_utils/src/upload_utils/src/patch_file_settings.d.ts +0 -2
- package/dist/file_utils/src/upload_utils/src/upload_file.d.ts +0 -2
- package/dist/file_utils/src/upload_utils/src/upload_files.d.ts +0 -5
- package/dist/hooks/imports.d.ts +0 -3
- package/dist/hooks/index.d.ts +0 -1
- package/dist/hooks/useFiles/imports.d.ts +0 -2
- package/dist/hooks/useFiles/index.d.ts +0 -1
- package/dist/hooks/useFiles/src/deleteFile.d.ts +0 -3
- package/dist/hooks/useFiles/src/index.d.ts +0 -6
- package/dist/hooks/useFiles/src/load.d.ts +0 -3
- package/dist/hooks/useFiles/src/patchShareSettings.d.ts +0 -3
- package/dist/hooks/useFiles/src/toggleShare.d.ts +0 -3
- package/dist/hooks/useFiles/src/useFiles.d.ts +0 -13
- package/dist/hooks/useFiles/src/useSaveShareSettings.d.ts +0 -7
- package/dist/imports.d.ts +0 -6
- package/dist/index.css +0 -1
- package/dist/styles/index.d.ts +0 -1
- package/dist/types/index.d.ts +0 -1
- package/dist/types/src/index.d.ts +0 -1
- package/dist/types/src/types.d.ts +0 -75
- package/index.ts +0 -1
- package/postcss.config.cjs +0 -8
- package/rollup.config.js +0 -38
- package/rollup.config.mjs +0 -35
- package/src/App/SecureFiles.tsx +0 -45
- package/src/App/index.ts +0 -1
- package/src/FileHandlers/FileRow/imports.ts +0 -2
- package/src/FileHandlers/FileRow/index.ts +0 -1
- package/src/FileHandlers/FileRow/src/index.ts +0 -1
- package/src/FileHandlers/FileRow/src/row_utils.tsx +0 -143
- package/src/FileHandlers/FileTable/imports.ts +0 -15
- package/src/FileHandlers/FileTable/index.ts +0 -1
- package/src/FileHandlers/FileTable/src/index.ts +0 -1
- package/src/FileHandlers/FileTable/src/table_utils.tsx +0 -251
- package/src/FileHandlers/ShareModal/imports.ts +0 -2
- package/src/FileHandlers/ShareModal/index.ts +0 -1
- package/src/FileHandlers/ShareModal/src/index.ts +0 -1
- package/src/FileHandlers/ShareModal/src/share_utils.tsx +0 -155
- package/src/FileHandlers/Upload/imports.ts +0 -3
- package/src/FileHandlers/Upload/index.ts +0 -1
- package/src/FileHandlers/Upload/src/index.ts +0 -2
- package/src/FileHandlers/Upload/src/upload_utils.tsx +0 -369
- package/src/FileHandlers/imports.ts +0 -17
- package/src/FileHandlers/index.ts +0 -4
- package/src/UI/index.tsx +0 -122
- package/src/custom.d.ts +0 -7
- package/src/file_utils/imports.ts +0 -2
- package/src/file_utils/index.ts +0 -1
- package/src/file_utils/src/cookieFetch.ts +0 -109
- package/src/file_utils/src/delete_utils/imports.ts +0 -1
- package/src/file_utils/src/delete_utils/index.ts +0 -1
- package/src/file_utils/src/delete_utils/src/delete_row.ts +0 -11
- package/src/file_utils/src/delete_utils/src/delete_select.ts +0 -10
- package/src/file_utils/src/delete_utils/src/delete_selected.ts +0 -15
- package/src/file_utils/src/delete_utils/src/index.ts +0 -3
- package/src/file_utils/src/download_utils/imports.ts +0 -3
- package/src/file_utils/src/download_utils/index.ts +0 -1
- package/src/file_utils/src/download_utils/src/direct_download.ts +0 -22
- package/src/file_utils/src/download_utils/src/download_file.ts +0 -25
- package/src/file_utils/src/download_utils/src/download_select.ts +0 -25
- package/src/file_utils/src/download_utils/src/index.ts +0 -5
- package/src/file_utils/src/download_utils/src/target_downloads.ts +0 -21
- package/src/file_utils/src/download_utils/src/trigger_download.ts +0 -14
- package/src/file_utils/src/imports.ts +0 -3
- package/src/file_utils/src/index.ts +0 -6
- package/src/file_utils/src/list_utils/imports.ts +0 -2
- package/src/file_utils/src/list_utils/index.ts +0 -1
- package/src/file_utils/src/list_utils/src/index.ts +0 -1
- package/src/file_utils/src/list_utils/src/list_utils.ts +0 -28
- package/src/file_utils/src/select_utils/index.ts +0 -1
- package/src/file_utils/src/select_utils/src/index.ts +0 -3
- package/src/file_utils/src/select_utils/src/init_select_all.ts +0 -37
- package/src/file_utils/src/select_utils/src/sync_header_checkbox.ts +0 -18
- package/src/file_utils/src/select_utils/src/toggle_checkboxes.ts +0 -11
- package/src/file_utils/src/share_utils/imports.ts +0 -1
- package/src/file_utils/src/share_utils/index.ts +0 -1
- package/src/file_utils/src/share_utils/src/index.ts +0 -1
- package/src/file_utils/src/share_utils/src/share_utils.ts +0 -20
- package/src/file_utils/src/upload_utils/imports.ts +0 -1
- package/src/file_utils/src/upload_utils/index.ts +0 -1
- package/src/file_utils/src/upload_utils/src/index.ts +0 -3
- package/src/file_utils/src/upload_utils/src/patch_file_settings.ts +0 -20
- package/src/file_utils/src/upload_utils/src/upload_file.ts +0 -27
- package/src/file_utils/src/upload_utils/src/upload_files.ts +0 -19
- package/src/hooks/imports.ts +0 -3
- package/src/hooks/index.ts +0 -1
- package/src/hooks/useFiles/imports.ts +0 -3
- package/src/hooks/useFiles/index.ts +0 -1
- package/src/hooks/useFiles/src/deleteFile.ts +0 -23
- package/src/hooks/useFiles/src/index.ts +0 -6
- package/src/hooks/useFiles/src/load.ts +0 -39
- package/src/hooks/useFiles/src/logs/clipit_logs.log +0 -0
- package/src/hooks/useFiles/src/logs/routes_bp.log +0 -0
- package/src/hooks/useFiles/src/patchShareSettings.ts +0 -27
- package/src/hooks/useFiles/src/toggleShare.ts +0 -28
- package/src/hooks/useFiles/src/useFiles.ts +0 -35
- package/src/hooks/useFiles/src/useSaveShareSettings.ts +0 -31
- package/src/imports.ts +0 -18
- package/src/styles/SecureFiles/App.css +0 -42
- package/src/styles/SecureFiles/index.css +0 -10
- package/src/styles/SecureFiles/logs/clipit_logs.log +0 -0
- package/src/styles/SecureFiles/logs/routes_bp.log +0 -0
- package/src/styles/SecureFiles/tailwind.css +0 -3
- package/src/styles/SecureFiles/window-manager.css +0 -283
- package/src/styles/SecureFilesStyles.module.css +0 -287
- package/src/styles/index.ts +0 -1
- package/src/types/index.ts +0 -1
- package/src/types/src/index.ts +0 -1
- package/src/types/src/types.ts +0 -88
- package/tailwind.config.cjs +0 -7
- package/testit.mjs +0 -11
- package/testit.ts +0 -3
- package/tsconfig.json +0 -27
- /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 & 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 & 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
|
+
}
|