decap-cms-widget-bulk-github-images 0.1.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/LICENSE +21 -0
- package/README.md +95 -0
- package/dist/index.js +939 -0
- package/package.json +30 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# decap-cms-widget-bulk-github-images
|
|
2
|
+
|
|
3
|
+
Custom Decap CMS widget that supports bulk gallery workflows while keeping media in your GitHub repository.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- Bulk upload multiple local images to your repo `media_folder`
|
|
8
|
+
- Multi-select existing images from that same folder
|
|
9
|
+
- Store gallery values as public paths (for example `/uploads/example.jpg`)
|
|
10
|
+
- Works with GitHub backend and local Decap proxy (`decap-server`)
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install decap-cms-widget-bulk-github-images
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Use in `/admin/index.html`
|
|
19
|
+
|
|
20
|
+
For production, prefer self-hosting pinned assets (recommended):
|
|
21
|
+
|
|
22
|
+
```html
|
|
23
|
+
<script src="/admin/vendor/decap-cms-3.0.0.js"></script>
|
|
24
|
+
<script src="/admin/vendor/decap-cms-widget-bulk-github-images-0.1.0.js"></script>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
If you use a CDN, pin exact versions and add SRI:
|
|
28
|
+
|
|
29
|
+
```html
|
|
30
|
+
<script
|
|
31
|
+
src="https://unpkg.com/decap-cms@3.0.0/dist/decap-cms.js"
|
|
32
|
+
integrity="sha384-REPLACE_WITH_REAL_HASH"
|
|
33
|
+
crossorigin="anonymous"
|
|
34
|
+
></script>
|
|
35
|
+
<script
|
|
36
|
+
src="https://unpkg.com/decap-cms-widget-bulk-github-images@0.1.0/dist/index.js"
|
|
37
|
+
integrity="sha384-REPLACE_WITH_REAL_HASH"
|
|
38
|
+
crossorigin="anonymous"
|
|
39
|
+
></script>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## `config.yml` field config
|
|
43
|
+
|
|
44
|
+
```yml
|
|
45
|
+
- label: Gallery Images
|
|
46
|
+
name: gallery
|
|
47
|
+
widget: bulkGithubImages
|
|
48
|
+
required: false
|
|
49
|
+
repo: YOUR_OWNER/YOUR_REPO
|
|
50
|
+
branch: main
|
|
51
|
+
proxy_url: http://localhost:8081/api/v1
|
|
52
|
+
allow_remote_proxy: false
|
|
53
|
+
strict_proxy_security: true
|
|
54
|
+
media_folder: src/uploads
|
|
55
|
+
public_folder: /uploads
|
|
56
|
+
max_file_size_mb: 10
|
|
57
|
+
max_files_per_upload: 25
|
|
58
|
+
max_total_upload_mb: 50
|
|
59
|
+
allowed_extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".bmp"]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Security notes:
|
|
63
|
+
|
|
64
|
+
- `proxy_url` must be same-origin or localhost by default.
|
|
65
|
+
- Non-local `proxy_url` requires `https`.
|
|
66
|
+
- Set `allow_remote_proxy: true` only when you intentionally trust a remote proxy endpoint.
|
|
67
|
+
- `strict_proxy_security: true` (default) only allows localhost `proxy_url`.
|
|
68
|
+
- On non-local hosts, the widget always uses GitHub API mode and does not auto-fallback through proxy calls.
|
|
69
|
+
- `media_folder` and returned media paths are validated to reject unsafe path segments like `..`.
|
|
70
|
+
- Upload validation enforces `allowed_extensions`, `max_file_size_mb`, `max_files_per_upload`, and `max_total_upload_mb`.
|
|
71
|
+
|
|
72
|
+
## Local development
|
|
73
|
+
|
|
74
|
+
This is a source-first repo:
|
|
75
|
+
|
|
76
|
+
- Edit `src/index.js`
|
|
77
|
+
- Run `npm run build` to generate `dist/index.js`
|
|
78
|
+
- Run `npm test` to execute security regression tests
|
|
79
|
+
|
|
80
|
+
When using `local_backend: true`, run both:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npm run build
|
|
84
|
+
npm run dev
|
|
85
|
+
npm run dev:cms
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The widget uses `proxy_url` for local media operations.
|
|
89
|
+
|
|
90
|
+
## Publish
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
cd packages/decap-cms-widget-bulk-github-images
|
|
94
|
+
npm publish
|
|
95
|
+
```
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
if (!window.CMS) return;
|
|
3
|
+
|
|
4
|
+
var h = window.h || window.CMS.h;
|
|
5
|
+
var createClass = window.createClass || window.CMS.createClass;
|
|
6
|
+
var TOKEN_KEYS = ["decap-cms-user", "netlify-cms-user", "nc-user"];
|
|
7
|
+
var DEFAULT_IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".bmp"];
|
|
8
|
+
var DEFAULT_MAX_FILE_SIZE_MB = 10;
|
|
9
|
+
var DEFAULT_MAX_FILES_PER_UPLOAD = 25;
|
|
10
|
+
var DEFAULT_MAX_TOTAL_UPLOAD_MB = 50;
|
|
11
|
+
var DEFAULT_STRICT_PROXY_SECURITY = true;
|
|
12
|
+
|
|
13
|
+
function toArray(value) {
|
|
14
|
+
if (!value) return [];
|
|
15
|
+
if (typeof value.toJS === "function") value = value.toJS();
|
|
16
|
+
if (Array.isArray(value)) return value;
|
|
17
|
+
return [value];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeGalleryValue(value) {
|
|
21
|
+
var normalized = toArray(value)
|
|
22
|
+
.map(function (item) {
|
|
23
|
+
if (!item) return "";
|
|
24
|
+
if (typeof item === "string") return item;
|
|
25
|
+
return item.image || "";
|
|
26
|
+
})
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
|
|
29
|
+
return Array.from(new Set(normalized));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function safeFileName(name) {
|
|
33
|
+
return String(name || "image")
|
|
34
|
+
.trim()
|
|
35
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
36
|
+
.replace(/^-+|-+$/g, "") || "image";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isLocalHostname(hostname) {
|
|
40
|
+
var value = String(hostname || "").toLowerCase();
|
|
41
|
+
return value === "localhost" || value === "127.0.0.1" || value === "0.0.0.0" || value === "::1";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function chooseStorageMode(hostname) {
|
|
45
|
+
return isLocalHostname(hostname) ? "probe_local_proxy" : "github_api";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseBoolean(value, fallback) {
|
|
49
|
+
if (value === undefined || value === null || value === "") return !!fallback;
|
|
50
|
+
if (typeof value === "boolean") return value;
|
|
51
|
+
var normalized = String(value).trim().toLowerCase();
|
|
52
|
+
if (normalized === "true" || normalized === "1" || normalized === "yes") return true;
|
|
53
|
+
if (normalized === "false" || normalized === "0" || normalized === "no") return false;
|
|
54
|
+
return !!fallback;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parsePositiveNumber(value, fallback) {
|
|
58
|
+
var parsed = Number(value);
|
|
59
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
60
|
+
return parsed;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parsePositiveInteger(value, fallback) {
|
|
64
|
+
var parsed = Math.floor(Number(value));
|
|
65
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
66
|
+
return parsed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeExtensions(value) {
|
|
70
|
+
var list = toArray(value);
|
|
71
|
+
var expanded = [];
|
|
72
|
+
for (var i = 0; i < list.length; i += 1) {
|
|
73
|
+
var raw = String(list[i] || "");
|
|
74
|
+
var parts = raw.split(",");
|
|
75
|
+
for (var j = 0; j < parts.length; j += 1) {
|
|
76
|
+
expanded.push(parts[j]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
var normalized = expanded
|
|
81
|
+
.map(function (item) {
|
|
82
|
+
return String(item || "")
|
|
83
|
+
.trim()
|
|
84
|
+
.toLowerCase()
|
|
85
|
+
.replace(/^\.+/, ".");
|
|
86
|
+
})
|
|
87
|
+
.filter(function (item) {
|
|
88
|
+
return /^\.[a-z0-9]+$/.test(item);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return normalized.length ? Array.from(new Set(normalized)) : DEFAULT_IMAGE_EXTENSIONS.slice();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getFileExtension(name) {
|
|
95
|
+
var match = String(name || "")
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.match(/(\.[a-z0-9]+)$/);
|
|
98
|
+
return match ? match[1] : "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function validateUploadBatch(files, config) {
|
|
102
|
+
var list = Array.isArray(files) ? files : [];
|
|
103
|
+
var maxFiles = parsePositiveInteger(config && config.maxFilesPerUpload, DEFAULT_MAX_FILES_PER_UPLOAD);
|
|
104
|
+
var maxTotalMb = parsePositiveNumber(config && config.maxTotalUploadMB, DEFAULT_MAX_TOTAL_UPLOAD_MB);
|
|
105
|
+
var maxTotalBytes = Math.floor(maxTotalMb * 1024 * 1024);
|
|
106
|
+
|
|
107
|
+
if (list.length > maxFiles) {
|
|
108
|
+
throw new Error("Too many files selected (" + list.length + "). Max per upload is " + maxFiles + ".");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
var totalBytes = list.reduce(function (sum, file) {
|
|
112
|
+
var size = file && Number.isFinite(file.size) ? file.size : 0;
|
|
113
|
+
return sum + size;
|
|
114
|
+
}, 0);
|
|
115
|
+
|
|
116
|
+
if (totalBytes > maxTotalBytes) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
"Upload batch too large (" +
|
|
119
|
+
Math.ceil(totalBytes / (1024 * 1024)) +
|
|
120
|
+
" MB). Max total per upload is " +
|
|
121
|
+
maxTotalMb +
|
|
122
|
+
" MB."
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isLikelyGithubToken(token) {
|
|
128
|
+
var value = String(token || "").trim();
|
|
129
|
+
if (!value) return false;
|
|
130
|
+
return /^gh[pousr]_[A-Za-z0-9_]{20,}$/.test(value) || /^github_pat_[A-Za-z0-9_]{20,}$/.test(value);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizeProxyUrl(rawValue, allowRemoteProxy, strictProxySecurity) {
|
|
134
|
+
var value = String(rawValue || "").trim();
|
|
135
|
+
var url;
|
|
136
|
+
try {
|
|
137
|
+
url = new URL(value, window.location.origin);
|
|
138
|
+
} catch (_error) {
|
|
139
|
+
throw new Error("Invalid proxy_url value.");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
var sameOrigin = url.origin === window.location.origin;
|
|
143
|
+
var proxyIsLocal = isLocalHostname(url.hostname);
|
|
144
|
+
|
|
145
|
+
if (strictProxySecurity && !proxyIsLocal) {
|
|
146
|
+
throw new Error("proxy_url must point to localhost when strict_proxy_security is enabled.");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!proxyIsLocal && url.protocol !== "https:") {
|
|
150
|
+
throw new Error("proxy_url must use https outside localhost.");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!allowRemoteProxy && !sameOrigin && !proxyIsLocal) {
|
|
154
|
+
throw new Error("proxy_url must be same-origin or localhost unless allow_remote_proxy is true.");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return url.toString();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function normalizeRepoFolderPath(pathValue) {
|
|
161
|
+
var normalized = String(pathValue || "")
|
|
162
|
+
.trim()
|
|
163
|
+
.replace(/\\/g, "/")
|
|
164
|
+
.replace(/^\/+|\/+$/g, "");
|
|
165
|
+
if (!normalized) {
|
|
166
|
+
throw new Error("media_folder must not be empty.");
|
|
167
|
+
}
|
|
168
|
+
var segments = normalized.split("/");
|
|
169
|
+
for (var i = 0; i < segments.length; i += 1) {
|
|
170
|
+
var segment = segments[i];
|
|
171
|
+
if (!segment || segment === "." || segment === "..") {
|
|
172
|
+
throw new Error("media_folder contains an unsafe path segment.");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return segments.join("/");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isSafeRepoPath(pathValue) {
|
|
179
|
+
var normalized = String(pathValue || "").trim().replace(/\\/g, "/");
|
|
180
|
+
if (!normalized || normalized.indexOf("\u0000") >= 0) return false;
|
|
181
|
+
if (normalized.startsWith("/") || normalized.startsWith("//")) return false;
|
|
182
|
+
if (/^[A-Za-z]:\//.test(normalized)) return false;
|
|
183
|
+
var segments = normalized.split("/");
|
|
184
|
+
for (var i = 0; i < segments.length; i += 1) {
|
|
185
|
+
var segment = segments[i];
|
|
186
|
+
if (!segment || segment === "." || segment === "..") return false;
|
|
187
|
+
}
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function toPublicPath(repoPath, mediaFolder, publicFolder) {
|
|
192
|
+
if (!isSafeRepoPath(repoPath)) {
|
|
193
|
+
throw new Error("Received unsafe repository path.");
|
|
194
|
+
}
|
|
195
|
+
var cleanedMedia = String(mediaFolder || "").replace(/^\/+|\/+$/g, "");
|
|
196
|
+
var cleanedRepoPath = String(repoPath || "")
|
|
197
|
+
.replace(/\\/g, "/")
|
|
198
|
+
.replace(/^\/+|\/+$/g, "");
|
|
199
|
+
var cleanedPublic = String(publicFolder || "/uploads").replace(/\/+$/g, "");
|
|
200
|
+
|
|
201
|
+
if (!cleanedRepoPath.startsWith(cleanedMedia + "/")) {
|
|
202
|
+
return "/" + cleanedRepoPath;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
var relative = cleanedRepoPath.slice(cleanedMedia.length + 1);
|
|
206
|
+
return (cleanedPublic.startsWith("/") ? cleanedPublic : "/" + cleanedPublic) + "/" + relative;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function readStorageToken(storage) {
|
|
210
|
+
if (!storage) return "";
|
|
211
|
+
|
|
212
|
+
for (var i = 0; i < TOKEN_KEYS.length; i += 1) {
|
|
213
|
+
var raw = storage.getItem(TOKEN_KEYS[i]);
|
|
214
|
+
if (!raw) continue;
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
var parsed = JSON.parse(raw);
|
|
218
|
+
var token =
|
|
219
|
+
parsed &&
|
|
220
|
+
(parsed.token ||
|
|
221
|
+
parsed.access_token ||
|
|
222
|
+
parsed.accessToken ||
|
|
223
|
+
(parsed.user && parsed.user.token) ||
|
|
224
|
+
(parsed.auth && parsed.auth.token));
|
|
225
|
+
if (isLikelyGithubToken(token)) return token;
|
|
226
|
+
} catch (_error) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return "";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function getBackendToken() {
|
|
235
|
+
try {
|
|
236
|
+
if (!window.CMS || !window.CMS.getBackend) return "";
|
|
237
|
+
var backend = window.CMS.getBackend();
|
|
238
|
+
if (!backend || typeof backend !== "object") return "";
|
|
239
|
+
|
|
240
|
+
var candidates = [
|
|
241
|
+
backend.token,
|
|
242
|
+
backend.accessToken,
|
|
243
|
+
backend.access_token,
|
|
244
|
+
backend.authToken,
|
|
245
|
+
backend._authToken,
|
|
246
|
+
backend.auth && backend.auth.token,
|
|
247
|
+
backend.user && backend.user.token
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
for (var i = 0; i < candidates.length; i += 1) {
|
|
251
|
+
var token = candidates[i];
|
|
252
|
+
if (isLikelyGithubToken(token)) return String(token);
|
|
253
|
+
}
|
|
254
|
+
return "";
|
|
255
|
+
} catch (_error) {
|
|
256
|
+
return "";
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function getGithubToken() {
|
|
261
|
+
var backendToken = getBackendToken();
|
|
262
|
+
if (backendToken) return backendToken;
|
|
263
|
+
|
|
264
|
+
var storageToken = readStorageToken(window.localStorage) || readStorageToken(window.sessionStorage) || "";
|
|
265
|
+
if (storageToken) return storageToken;
|
|
266
|
+
|
|
267
|
+
return "";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function fileToBase64(file) {
|
|
271
|
+
return new Promise(function (resolve, reject) {
|
|
272
|
+
var reader = new FileReader();
|
|
273
|
+
reader.onload = function () {
|
|
274
|
+
var result = String(reader.result || "");
|
|
275
|
+
var commaIndex = result.indexOf(",");
|
|
276
|
+
resolve(commaIndex >= 0 ? result.slice(commaIndex + 1) : result);
|
|
277
|
+
};
|
|
278
|
+
reader.onerror = function () {
|
|
279
|
+
reject(new Error("Failed to read file " + file.name));
|
|
280
|
+
};
|
|
281
|
+
reader.readAsDataURL(file);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function apiHeaders(token) {
|
|
286
|
+
var headers = {
|
|
287
|
+
Accept: "application/vnd.github+json",
|
|
288
|
+
"Content-Type": "application/json"
|
|
289
|
+
};
|
|
290
|
+
if (token) headers.Authorization = "Bearer " + token;
|
|
291
|
+
return headers;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function uniqueUploadPath(baseFolder, fileName, existingPaths) {
|
|
295
|
+
var folder = String(baseFolder || "").replace(/^\/+|\/+$/g, "");
|
|
296
|
+
var clean = safeFileName(fileName);
|
|
297
|
+
var extMatch = clean.match(/(\.[^.]*)$/);
|
|
298
|
+
var ext = extMatch ? extMatch[1] : "";
|
|
299
|
+
var stem = ext ? clean.slice(0, -ext.length) : clean;
|
|
300
|
+
var index = 0;
|
|
301
|
+
var candidate;
|
|
302
|
+
|
|
303
|
+
do {
|
|
304
|
+
candidate = folder + "/" + (index === 0 ? stem + ext : stem + "-" + index + ext);
|
|
305
|
+
index += 1;
|
|
306
|
+
} while (existingPaths.has(candidate));
|
|
307
|
+
|
|
308
|
+
existingPaths.add(candidate);
|
|
309
|
+
return candidate;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
var BulkGithubImagesControl = createClass({
|
|
313
|
+
getInitialState: function () {
|
|
314
|
+
return {
|
|
315
|
+
items: normalizeGalleryValue(this.props.value),
|
|
316
|
+
existingImages: [],
|
|
317
|
+
selectedExisting: {},
|
|
318
|
+
query: "",
|
|
319
|
+
loadingExisting: false,
|
|
320
|
+
uploading: false,
|
|
321
|
+
error: "",
|
|
322
|
+
status: "",
|
|
323
|
+
pickerOpen: false
|
|
324
|
+
};
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
componentDidUpdate: function (prevProps) {
|
|
328
|
+
if (prevProps.value !== this.props.value) {
|
|
329
|
+
this.setState({ items: normalizeGalleryValue(this.props.value) });
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
getFieldOption: function (name, fallback) {
|
|
334
|
+
var value = this.props.field && this.props.field.get ? this.props.field.get(name) : undefined;
|
|
335
|
+
return value === undefined || value === null || value === "" ? fallback : value;
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
getConfig: function () {
|
|
339
|
+
var allowRemoteProxy = parseBoolean(this.getFieldOption("allow_remote_proxy", false), false);
|
|
340
|
+
var strictProxySecurity = parseBoolean(
|
|
341
|
+
this.getFieldOption("strict_proxy_security", DEFAULT_STRICT_PROXY_SECURITY),
|
|
342
|
+
DEFAULT_STRICT_PROXY_SECURITY
|
|
343
|
+
);
|
|
344
|
+
var mediaFolder = normalizeRepoFolderPath(this.getFieldOption("media_folder", "src/uploads"));
|
|
345
|
+
return {
|
|
346
|
+
repo: this.getFieldOption("repo", ""),
|
|
347
|
+
branch: this.getFieldOption("branch", "main"),
|
|
348
|
+
mediaFolder: mediaFolder,
|
|
349
|
+
publicFolder: this.getFieldOption("public_folder", "/uploads"),
|
|
350
|
+
proxyUrl: normalizeProxyUrl(
|
|
351
|
+
this.getFieldOption("proxy_url", "http://localhost:8081/api/v1"),
|
|
352
|
+
allowRemoteProxy,
|
|
353
|
+
strictProxySecurity
|
|
354
|
+
),
|
|
355
|
+
allowRemoteProxy: allowRemoteProxy,
|
|
356
|
+
strictProxySecurity: strictProxySecurity,
|
|
357
|
+
maxFileSizeMB: parsePositiveNumber(this.getFieldOption("max_file_size_mb", DEFAULT_MAX_FILE_SIZE_MB), DEFAULT_MAX_FILE_SIZE_MB),
|
|
358
|
+
maxFilesPerUpload: parsePositiveInteger(
|
|
359
|
+
this.getFieldOption("max_files_per_upload", DEFAULT_MAX_FILES_PER_UPLOAD),
|
|
360
|
+
DEFAULT_MAX_FILES_PER_UPLOAD
|
|
361
|
+
),
|
|
362
|
+
maxTotalUploadMB: parsePositiveNumber(
|
|
363
|
+
this.getFieldOption("max_total_upload_mb", DEFAULT_MAX_TOTAL_UPLOAD_MB),
|
|
364
|
+
DEFAULT_MAX_TOTAL_UPLOAD_MB
|
|
365
|
+
),
|
|
366
|
+
allowedExtensions: normalizeExtensions(this.getFieldOption("allowed_extensions", DEFAULT_IMAGE_EXTENSIONS))
|
|
367
|
+
};
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
syncItems: function (nextItems) {
|
|
371
|
+
var deduped = Array.from(new Set((nextItems || []).filter(Boolean)));
|
|
372
|
+
this.setState({ items: deduped });
|
|
373
|
+
this.props.onChange(deduped);
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
removeItem: function (index) {
|
|
377
|
+
var nextItems = this.state.items.slice();
|
|
378
|
+
nextItems.splice(index, 1);
|
|
379
|
+
this.syncItems(nextItems);
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
clearError: function () {
|
|
383
|
+
this.setState({ error: "" });
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
ensureConfig: function () {
|
|
387
|
+
var config = this.getConfig();
|
|
388
|
+
if (!config.repo) {
|
|
389
|
+
throw new Error("Missing widget option: repo");
|
|
390
|
+
}
|
|
391
|
+
return config;
|
|
392
|
+
},
|
|
393
|
+
|
|
394
|
+
postProxyAction: async function (action, params) {
|
|
395
|
+
var config = this.ensureConfig();
|
|
396
|
+
var response = await fetch(config.proxyUrl, {
|
|
397
|
+
method: "POST",
|
|
398
|
+
credentials: "same-origin",
|
|
399
|
+
headers: {
|
|
400
|
+
Accept: "application/json",
|
|
401
|
+
"Content-Type": "application/json",
|
|
402
|
+
"X-Requested-With": "decap-cms-widget-bulk-github-images"
|
|
403
|
+
},
|
|
404
|
+
body: JSON.stringify({
|
|
405
|
+
action: action,
|
|
406
|
+
params: params
|
|
407
|
+
})
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
if (!response.ok) {
|
|
411
|
+
var body = await response.json().catch(function () {
|
|
412
|
+
return {};
|
|
413
|
+
});
|
|
414
|
+
var detail = body && body.error ? ": " + body.error : "";
|
|
415
|
+
throw new Error("Local backend request failed (" + response.status + ")" + detail);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return response.json();
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
detectStorageMode: async function () {
|
|
422
|
+
if (this._storageMode) return this._storageMode;
|
|
423
|
+
var config = this.ensureConfig();
|
|
424
|
+
var hostname = String((window.location && window.location.hostname) || "").toLowerCase();
|
|
425
|
+
var preferredMode = chooseStorageMode(hostname);
|
|
426
|
+
|
|
427
|
+
if (preferredMode === "github_api") {
|
|
428
|
+
this._storageMode = "github_api";
|
|
429
|
+
return this._storageMode;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
await this.postProxyAction("info", { branch: config.branch });
|
|
434
|
+
this._storageMode = "local_proxy";
|
|
435
|
+
} catch (error) {
|
|
436
|
+
this._storageMode = "local_missing";
|
|
437
|
+
this._storageModeError =
|
|
438
|
+
"Local CMS backend unavailable at " +
|
|
439
|
+
config.proxyUrl +
|
|
440
|
+
". Start it with `npm run dev:cms` and retry.";
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return this._storageMode;
|
|
444
|
+
},
|
|
445
|
+
|
|
446
|
+
getRepoTreeFromGithub: async function () {
|
|
447
|
+
var config = this.ensureConfig();
|
|
448
|
+
var token = getGithubToken();
|
|
449
|
+
var headers = apiHeaders(token);
|
|
450
|
+
|
|
451
|
+
var branchResponse = await fetch(
|
|
452
|
+
"https://api.github.com/repos/" +
|
|
453
|
+
encodeURIComponent(config.repo).replace("%2F", "/") +
|
|
454
|
+
"/branches/" +
|
|
455
|
+
encodeURIComponent(config.branch),
|
|
456
|
+
{ headers: headers }
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
if (!branchResponse.ok) {
|
|
460
|
+
throw new Error("Unable to load branch metadata (" + branchResponse.status + ")");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
var branchPayload = await branchResponse.json();
|
|
464
|
+
var sha = branchPayload && branchPayload.commit && branchPayload.commit.sha;
|
|
465
|
+
if (!sha) {
|
|
466
|
+
throw new Error("Could not resolve branch head sha");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
var treeResponse = await fetch(
|
|
470
|
+
"https://api.github.com/repos/" +
|
|
471
|
+
encodeURIComponent(config.repo).replace("%2F", "/") +
|
|
472
|
+
"/git/trees/" +
|
|
473
|
+
encodeURIComponent(sha) +
|
|
474
|
+
"?recursive=1",
|
|
475
|
+
{ headers: headers }
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
if (!treeResponse.ok) {
|
|
479
|
+
throw new Error("Unable to load repository tree (" + treeResponse.status + ")");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
var treePayload = await treeResponse.json();
|
|
483
|
+
var entries = Array.isArray(treePayload.tree) ? treePayload.tree : [];
|
|
484
|
+
return entries
|
|
485
|
+
.filter(function (entry) {
|
|
486
|
+
return (
|
|
487
|
+
entry.type === "blob" &&
|
|
488
|
+
typeof entry.path === "string" &&
|
|
489
|
+
isSafeRepoPath(entry.path) &&
|
|
490
|
+
entry.path.startsWith(config.mediaFolder + "/")
|
|
491
|
+
);
|
|
492
|
+
})
|
|
493
|
+
.map(function (entry) {
|
|
494
|
+
return {
|
|
495
|
+
repoPath: entry.path,
|
|
496
|
+
publicPath: toPublicPath(entry.path, config.mediaFolder, config.publicFolder)
|
|
497
|
+
};
|
|
498
|
+
})
|
|
499
|
+
.sort(function (a, b) {
|
|
500
|
+
return a.repoPath.localeCompare(b.repoPath);
|
|
501
|
+
});
|
|
502
|
+
},
|
|
503
|
+
|
|
504
|
+
getRepoTreeFromProxy: async function () {
|
|
505
|
+
var config = this.ensureConfig();
|
|
506
|
+
var mediaFiles = await this.postProxyAction("getMedia", {
|
|
507
|
+
branch: config.branch,
|
|
508
|
+
mediaFolder: config.mediaFolder
|
|
509
|
+
});
|
|
510
|
+
var files = Array.isArray(mediaFiles) ? mediaFiles : [];
|
|
511
|
+
return files
|
|
512
|
+
.map(function (file) {
|
|
513
|
+
var repoPath = file && file.path ? String(file.path).replace(/\\/g, "/") : "";
|
|
514
|
+
if (!repoPath || !isSafeRepoPath(repoPath)) return null;
|
|
515
|
+
if (!repoPath.startsWith(config.mediaFolder + "/")) return null;
|
|
516
|
+
return {
|
|
517
|
+
repoPath: repoPath,
|
|
518
|
+
publicPath: toPublicPath(repoPath, config.mediaFolder, config.publicFolder)
|
|
519
|
+
};
|
|
520
|
+
})
|
|
521
|
+
.filter(Boolean)
|
|
522
|
+
.sort(function (a, b) {
|
|
523
|
+
return a.repoPath.localeCompare(b.repoPath);
|
|
524
|
+
});
|
|
525
|
+
},
|
|
526
|
+
|
|
527
|
+
getRepoTree: async function () {
|
|
528
|
+
var mode = await this.detectStorageMode();
|
|
529
|
+
if (mode === "local_proxy") {
|
|
530
|
+
return this.getRepoTreeFromProxy();
|
|
531
|
+
}
|
|
532
|
+
if (mode === "local_missing") {
|
|
533
|
+
throw new Error(this._storageModeError || "Local CMS backend is unavailable.");
|
|
534
|
+
}
|
|
535
|
+
return this.getRepoTreeFromGithub();
|
|
536
|
+
},
|
|
537
|
+
|
|
538
|
+
loadExisting: async function () {
|
|
539
|
+
this.setState({ loadingExisting: true, error: "", status: "" });
|
|
540
|
+
try {
|
|
541
|
+
var entries = await this.getRepoTree();
|
|
542
|
+
this.setState({ existingImages: entries, loadingExisting: false, pickerOpen: true });
|
|
543
|
+
} catch (error) {
|
|
544
|
+
this.setState({
|
|
545
|
+
loadingExisting: false,
|
|
546
|
+
pickerOpen: false,
|
|
547
|
+
error: error && error.message ? error.message : "Failed to load existing images."
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
|
|
552
|
+
toggleExisting: function (repoPath) {
|
|
553
|
+
var next = Object.assign({}, this.state.selectedExisting);
|
|
554
|
+
if (next[repoPath]) {
|
|
555
|
+
delete next[repoPath];
|
|
556
|
+
} else {
|
|
557
|
+
next[repoPath] = true;
|
|
558
|
+
}
|
|
559
|
+
this.setState({ selectedExisting: next });
|
|
560
|
+
},
|
|
561
|
+
|
|
562
|
+
addSelectedExisting: function () {
|
|
563
|
+
var selectedPaths = this.state.existingImages
|
|
564
|
+
.filter(
|
|
565
|
+
function (entry) {
|
|
566
|
+
return !!this.state.selectedExisting[entry.repoPath];
|
|
567
|
+
}.bind(this)
|
|
568
|
+
)
|
|
569
|
+
.map(function (entry) {
|
|
570
|
+
return entry.publicPath;
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
this.syncItems(this.state.items.concat(selectedPaths));
|
|
574
|
+
this.setState({ selectedExisting: {}, status: "Added " + selectedPaths.length + " existing image(s)." });
|
|
575
|
+
},
|
|
576
|
+
|
|
577
|
+
handleUploadInput: async function (event) {
|
|
578
|
+
var files = Array.prototype.slice.call((event.target && event.target.files) || []);
|
|
579
|
+
event.target.value = "";
|
|
580
|
+
if (!files.length) return;
|
|
581
|
+
var config;
|
|
582
|
+
try {
|
|
583
|
+
config = this.ensureConfig();
|
|
584
|
+
validateUploadBatch(files, config);
|
|
585
|
+
var maxBytes = Math.floor(config.maxFileSizeMB * 1024 * 1024);
|
|
586
|
+
|
|
587
|
+
for (var f = 0; f < files.length; f += 1) {
|
|
588
|
+
var selected = files[f];
|
|
589
|
+
var extension = getFileExtension(selected.name);
|
|
590
|
+
if (config.allowedExtensions.indexOf(extension) === -1) {
|
|
591
|
+
throw new Error(
|
|
592
|
+
"Unsupported file extension for " +
|
|
593
|
+
selected.name +
|
|
594
|
+
". Allowed: " +
|
|
595
|
+
config.allowedExtensions.join(", ")
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
if (selected.type && selected.type.toLowerCase().indexOf("image/") !== 0) {
|
|
599
|
+
throw new Error("Unsupported mime type for " + selected.name + ": " + selected.type);
|
|
600
|
+
}
|
|
601
|
+
if (selected.size > maxBytes) {
|
|
602
|
+
throw new Error(
|
|
603
|
+
"File too large: " +
|
|
604
|
+
selected.name +
|
|
605
|
+
" (" +
|
|
606
|
+
Math.ceil(selected.size / (1024 * 1024)) +
|
|
607
|
+
" MB). Max is " +
|
|
608
|
+
config.maxFileSizeMB +
|
|
609
|
+
" MB."
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
} catch (error) {
|
|
614
|
+
this.setState({
|
|
615
|
+
error: error && error.message ? error.message : "Invalid file selection.",
|
|
616
|
+
status: ""
|
|
617
|
+
});
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
this.setState({ uploading: true, error: "", status: "" });
|
|
622
|
+
|
|
623
|
+
try {
|
|
624
|
+
var mode = await this.detectStorageMode();
|
|
625
|
+
if (mode === "local_missing") {
|
|
626
|
+
throw new Error(this._storageModeError || "Local CMS backend is unavailable.");
|
|
627
|
+
}
|
|
628
|
+
var token = mode === "github_api" ? getGithubToken() : "";
|
|
629
|
+
if (mode === "github_api" && !token) {
|
|
630
|
+
throw new Error("No GitHub token found. Sign out/in to /admin and retry.");
|
|
631
|
+
}
|
|
632
|
+
var headers = apiHeaders(token);
|
|
633
|
+
|
|
634
|
+
var existingSet = new Set(
|
|
635
|
+
this.state.existingImages.map(function (entry) {
|
|
636
|
+
return entry.repoPath;
|
|
637
|
+
})
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
var uploadedPublicPaths = [];
|
|
641
|
+
var uploadedEntries = [];
|
|
642
|
+
|
|
643
|
+
for (var i = 0; i < files.length; i += 1) {
|
|
644
|
+
var file = files[i];
|
|
645
|
+
var repoPath = uniqueUploadPath(config.mediaFolder, file.name, existingSet);
|
|
646
|
+
var content = await fileToBase64(file);
|
|
647
|
+
|
|
648
|
+
if (mode === "local_proxy") {
|
|
649
|
+
await this.postProxyAction("persistMedia", {
|
|
650
|
+
branch: config.branch,
|
|
651
|
+
asset: {
|
|
652
|
+
path: repoPath,
|
|
653
|
+
content: content,
|
|
654
|
+
encoding: "base64"
|
|
655
|
+
},
|
|
656
|
+
options: {
|
|
657
|
+
commitMessage: "Upload media via Decap bulk gallery widget"
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
} else {
|
|
661
|
+
var encodedPath = repoPath
|
|
662
|
+
.split("/")
|
|
663
|
+
.map(function (segment) {
|
|
664
|
+
return encodeURIComponent(segment);
|
|
665
|
+
})
|
|
666
|
+
.join("/");
|
|
667
|
+
|
|
668
|
+
var uploadResponse = await fetch(
|
|
669
|
+
"https://api.github.com/repos/" +
|
|
670
|
+
encodeURIComponent(config.repo).replace("%2F", "/") +
|
|
671
|
+
"/contents/" +
|
|
672
|
+
encodedPath,
|
|
673
|
+
{
|
|
674
|
+
method: "PUT",
|
|
675
|
+
headers: headers,
|
|
676
|
+
body: JSON.stringify({
|
|
677
|
+
message: "Upload media via Decap bulk gallery widget",
|
|
678
|
+
content: content,
|
|
679
|
+
branch: config.branch
|
|
680
|
+
})
|
|
681
|
+
}
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
if (!uploadResponse.ok) {
|
|
685
|
+
var failureBody = await uploadResponse.json().catch(function () {
|
|
686
|
+
return {};
|
|
687
|
+
});
|
|
688
|
+
var reason = failureBody.message ? ": " + failureBody.message : "";
|
|
689
|
+
throw new Error("Upload failed for " + file.name + " (" + uploadResponse.status + ")" + reason);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
var publicPath = toPublicPath(repoPath, config.mediaFolder, config.publicFolder);
|
|
694
|
+
uploadedPublicPaths.push(publicPath);
|
|
695
|
+
uploadedEntries.push({ repoPath: repoPath, publicPath: publicPath });
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
this.syncItems(this.state.items.concat(uploadedPublicPaths));
|
|
699
|
+
this.setState({
|
|
700
|
+
existingImages: this.state.existingImages.concat(uploadedEntries).sort(function (a, b) {
|
|
701
|
+
return a.repoPath.localeCompare(b.repoPath);
|
|
702
|
+
}),
|
|
703
|
+
uploading: false,
|
|
704
|
+
status: "Uploaded " + uploadedPublicPaths.length + " image(s)."
|
|
705
|
+
});
|
|
706
|
+
} catch (error) {
|
|
707
|
+
this.setState({
|
|
708
|
+
uploading: false,
|
|
709
|
+
error: error && error.message ? error.message : "Upload failed."
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
},
|
|
713
|
+
|
|
714
|
+
render: function () {
|
|
715
|
+
var filteredExisting = this.state.existingImages.filter(
|
|
716
|
+
function (entry) {
|
|
717
|
+
if (!this.state.query) return true;
|
|
718
|
+
var q = this.state.query.toLowerCase();
|
|
719
|
+
return (
|
|
720
|
+
entry.repoPath.toLowerCase().indexOf(q) >= 0 ||
|
|
721
|
+
entry.publicPath.toLowerCase().indexOf(q) >= 0
|
|
722
|
+
);
|
|
723
|
+
}.bind(this)
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
return h(
|
|
727
|
+
"div",
|
|
728
|
+
{ className: this.props.classNameWrapper },
|
|
729
|
+
h(
|
|
730
|
+
"div",
|
|
731
|
+
{ style: { marginBottom: "0.75rem" } },
|
|
732
|
+
h(
|
|
733
|
+
"button",
|
|
734
|
+
{
|
|
735
|
+
type: "button",
|
|
736
|
+
onClick:
|
|
737
|
+
this.state.uploading
|
|
738
|
+
? function () {}
|
|
739
|
+
: function () {
|
|
740
|
+
this.clearError();
|
|
741
|
+
if (this.fileInput) this.fileInput.click();
|
|
742
|
+
}.bind(this),
|
|
743
|
+
disabled: this.state.uploading,
|
|
744
|
+
style: { marginRight: "0.5rem" }
|
|
745
|
+
},
|
|
746
|
+
this.state.uploading ? "Uploading..." : "Upload Images"
|
|
747
|
+
),
|
|
748
|
+
h(
|
|
749
|
+
"button",
|
|
750
|
+
{
|
|
751
|
+
type: "button",
|
|
752
|
+
onClick: this.loadExisting,
|
|
753
|
+
disabled: this.state.loadingExisting || this.state.uploading,
|
|
754
|
+
style: { marginRight: "0.5rem" }
|
|
755
|
+
},
|
|
756
|
+
this.state.loadingExisting ? "Loading..." : "Choose Existing"
|
|
757
|
+
),
|
|
758
|
+
h(
|
|
759
|
+
"button",
|
|
760
|
+
{
|
|
761
|
+
type: "button",
|
|
762
|
+
onClick:
|
|
763
|
+
this.state.items.length
|
|
764
|
+
? function () {
|
|
765
|
+
this.syncItems([]);
|
|
766
|
+
this.setState({ status: "Cleared gallery images." });
|
|
767
|
+
}.bind(this)
|
|
768
|
+
: function () {},
|
|
769
|
+
disabled: !this.state.items.length
|
|
770
|
+
},
|
|
771
|
+
"Clear"
|
|
772
|
+
),
|
|
773
|
+
h("input", {
|
|
774
|
+
type: "file",
|
|
775
|
+
multiple: true,
|
|
776
|
+
accept: "image/*",
|
|
777
|
+
ref: function (element) {
|
|
778
|
+
this.fileInput = element;
|
|
779
|
+
}.bind(this),
|
|
780
|
+
onChange: this.handleUploadInput,
|
|
781
|
+
style: { display: "none" }
|
|
782
|
+
})
|
|
783
|
+
),
|
|
784
|
+
this.state.error
|
|
785
|
+
? h("p", { style: { color: "#b00020", margin: "0 0 0.5rem" } }, this.state.error)
|
|
786
|
+
: null,
|
|
787
|
+
this.state.status
|
|
788
|
+
? h("p", { style: { color: "#116149", margin: "0 0 0.5rem" } }, this.state.status)
|
|
789
|
+
: null,
|
|
790
|
+
h(
|
|
791
|
+
"ul",
|
|
792
|
+
{ style: { listStyle: "none", margin: 0, padding: 0 } },
|
|
793
|
+
this.state.items.map(
|
|
794
|
+
function (path, index) {
|
|
795
|
+
return h(
|
|
796
|
+
"li",
|
|
797
|
+
{
|
|
798
|
+
key: path + "-" + index,
|
|
799
|
+
style: {
|
|
800
|
+
display: "flex",
|
|
801
|
+
alignItems: "center",
|
|
802
|
+
justifyContent: "space-between",
|
|
803
|
+
padding: "0.35rem 0.5rem",
|
|
804
|
+
border: "1px solid #d9dde7",
|
|
805
|
+
marginBottom: "0.35rem",
|
|
806
|
+
borderRadius: "0.25rem"
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
h("span", { style: { overflow: "hidden", textOverflow: "ellipsis" } }, path),
|
|
810
|
+
h(
|
|
811
|
+
"button",
|
|
812
|
+
{
|
|
813
|
+
type: "button",
|
|
814
|
+
onClick: function () {
|
|
815
|
+
this.removeItem(index);
|
|
816
|
+
}.bind(this),
|
|
817
|
+
style: { marginLeft: "0.5rem" }
|
|
818
|
+
},
|
|
819
|
+
"Remove"
|
|
820
|
+
)
|
|
821
|
+
);
|
|
822
|
+
}.bind(this)
|
|
823
|
+
)
|
|
824
|
+
),
|
|
825
|
+
this.state.pickerOpen
|
|
826
|
+
? h(
|
|
827
|
+
"div",
|
|
828
|
+
{
|
|
829
|
+
style: {
|
|
830
|
+
marginTop: "0.75rem",
|
|
831
|
+
border: "1px solid #d9dde7",
|
|
832
|
+
borderRadius: "0.25rem",
|
|
833
|
+
padding: "0.6rem"
|
|
834
|
+
}
|
|
835
|
+
},
|
|
836
|
+
h("input", {
|
|
837
|
+
type: "text",
|
|
838
|
+
value: this.state.query,
|
|
839
|
+
placeholder: "Filter existing uploads...",
|
|
840
|
+
onChange: function (event) {
|
|
841
|
+
this.setState({ query: event.target.value });
|
|
842
|
+
}.bind(this),
|
|
843
|
+
style: { width: "100%", marginBottom: "0.5rem" }
|
|
844
|
+
}),
|
|
845
|
+
h(
|
|
846
|
+
"div",
|
|
847
|
+
{
|
|
848
|
+
style: {
|
|
849
|
+
maxHeight: "12rem",
|
|
850
|
+
overflowY: "auto",
|
|
851
|
+
border: "1px solid #e5e8ef",
|
|
852
|
+
padding: "0.35rem"
|
|
853
|
+
}
|
|
854
|
+
},
|
|
855
|
+
filteredExisting.length
|
|
856
|
+
? filteredExisting.map(
|
|
857
|
+
function (entry) {
|
|
858
|
+
var checked = !!this.state.selectedExisting[entry.repoPath];
|
|
859
|
+
return h(
|
|
860
|
+
"label",
|
|
861
|
+
{
|
|
862
|
+
key: entry.repoPath,
|
|
863
|
+
style: {
|
|
864
|
+
display: "flex",
|
|
865
|
+
alignItems: "center",
|
|
866
|
+
gap: "0.4rem",
|
|
867
|
+
padding: "0.2rem 0"
|
|
868
|
+
}
|
|
869
|
+
},
|
|
870
|
+
h("input", {
|
|
871
|
+
type: "checkbox",
|
|
872
|
+
checked: checked,
|
|
873
|
+
onChange: function () {
|
|
874
|
+
this.toggleExisting(entry.repoPath);
|
|
875
|
+
}.bind(this)
|
|
876
|
+
}),
|
|
877
|
+
h("span", {}, entry.publicPath)
|
|
878
|
+
);
|
|
879
|
+
}.bind(this)
|
|
880
|
+
)
|
|
881
|
+
: h("p", { style: { margin: 0 } }, "No files found in uploads folder.")
|
|
882
|
+
),
|
|
883
|
+
h(
|
|
884
|
+
"div",
|
|
885
|
+
{ style: { marginTop: "0.5rem" } },
|
|
886
|
+
h(
|
|
887
|
+
"button",
|
|
888
|
+
{
|
|
889
|
+
type: "button",
|
|
890
|
+
onClick: this.addSelectedExisting,
|
|
891
|
+
disabled: !Object.keys(this.state.selectedExisting).length,
|
|
892
|
+
style: { marginRight: "0.5rem" }
|
|
893
|
+
},
|
|
894
|
+
"Add Selected"
|
|
895
|
+
),
|
|
896
|
+
h(
|
|
897
|
+
"button",
|
|
898
|
+
{
|
|
899
|
+
type: "button",
|
|
900
|
+
onClick: function () {
|
|
901
|
+
this.setState({ pickerOpen: false, selectedExisting: {}, query: "" });
|
|
902
|
+
}.bind(this)
|
|
903
|
+
},
|
|
904
|
+
"Close"
|
|
905
|
+
)
|
|
906
|
+
)
|
|
907
|
+
)
|
|
908
|
+
: null
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
var BulkGithubImagesPreview = createClass({
|
|
914
|
+
render: function () {
|
|
915
|
+
var items = normalizeGalleryValue(this.props.value);
|
|
916
|
+
return h(
|
|
917
|
+
"ul",
|
|
918
|
+
{ style: { margin: 0, paddingLeft: "1rem" } },
|
|
919
|
+
items.map(function (path, index) {
|
|
920
|
+
return h("li", { key: path + "-" + index }, path);
|
|
921
|
+
})
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
if (window.__BULK_GITHUB_IMAGES_ENABLE_TEST_HOOKS__) {
|
|
927
|
+
window.__BULK_GITHUB_IMAGES_TEST_HOOKS__ = {
|
|
928
|
+
chooseStorageMode: chooseStorageMode,
|
|
929
|
+
isLocalHostname: isLocalHostname,
|
|
930
|
+
normalizeProxyUrl: normalizeProxyUrl,
|
|
931
|
+
normalizeRepoFolderPath: normalizeRepoFolderPath,
|
|
932
|
+
validateUploadBatch: validateUploadBatch,
|
|
933
|
+
isSafeRepoPath: isSafeRepoPath,
|
|
934
|
+
toPublicPath: toPublicPath
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
CMS.registerWidget("bulkGithubImages", BulkGithubImagesControl, BulkGithubImagesPreview);
|
|
939
|
+
})();
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "decap-cms-widget-bulk-github-images",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Decap CMS custom widget for bulk upload and multi-select of GitHub-backed gallery images.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"decap-cms",
|
|
8
|
+
"decap",
|
|
9
|
+
"netlify-cms",
|
|
10
|
+
"widget",
|
|
11
|
+
"gallery",
|
|
12
|
+
"github"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"main": "dist/index.js",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "node scripts/build.mjs",
|
|
23
|
+
"test": "node --test",
|
|
24
|
+
"prepare": "npm run build",
|
|
25
|
+
"prepack": "npm run build"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
}
|
|
30
|
+
}
|