@intranefr/superbackend 1.4.3
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/.commiat +4 -0
- package/.env.example +47 -0
- package/README.md +110 -0
- package/index.js +94 -0
- package/package.json +67 -0
- package/public/css/styles.css +139 -0
- package/public/js/animations.js +41 -0
- package/sdk/error-tracking/browser/package.json +16 -0
- package/sdk/error-tracking/browser/src/core.js +270 -0
- package/sdk/error-tracking/browser/src/embed.js +18 -0
- package/sdk/error-tracking/browser/src/index.js +1 -0
- package/server.js +5 -0
- package/src/admin/endpointRegistry.js +300 -0
- package/src/controllers/admin.controller.js +321 -0
- package/src/controllers/adminAssets.controller.js +530 -0
- package/src/controllers/adminAssetsStorage.controller.js +260 -0
- package/src/controllers/adminEjsVirtual.controller.js +354 -0
- package/src/controllers/adminFeatureFlags.controller.js +155 -0
- package/src/controllers/adminHeadless.controller.js +1071 -0
- package/src/controllers/adminI18n.controller.js +604 -0
- package/src/controllers/adminJsonConfigs.controller.js +97 -0
- package/src/controllers/adminLlm.controller.js +273 -0
- package/src/controllers/adminMigration.controller.js +257 -0
- package/src/controllers/adminSeoConfig.controller.js +515 -0
- package/src/controllers/adminStats.controller.js +121 -0
- package/src/controllers/adminUploadNamespaces.controller.js +208 -0
- package/src/controllers/assets.controller.js +248 -0
- package/src/controllers/auth.controller.js +93 -0
- package/src/controllers/billing.controller.js +223 -0
- package/src/controllers/featureFlags.controller.js +35 -0
- package/src/controllers/forms.controller.js +217 -0
- package/src/controllers/globalSettings.controller.js +252 -0
- package/src/controllers/headlessCrud.controller.js +126 -0
- package/src/controllers/i18n.controller.js +12 -0
- package/src/controllers/invite.controller.js +249 -0
- package/src/controllers/jsonConfigs.controller.js +19 -0
- package/src/controllers/metrics.controller.js +149 -0
- package/src/controllers/notificationAdmin.controller.js +264 -0
- package/src/controllers/notifications.controller.js +131 -0
- package/src/controllers/org.controller.js +357 -0
- package/src/controllers/orgAdmin.controller.js +491 -0
- package/src/controllers/stripeAdmin.controller.js +410 -0
- package/src/controllers/user.controller.js +361 -0
- package/src/controllers/userAdmin.controller.js +277 -0
- package/src/controllers/waitingList.controller.js +167 -0
- package/src/controllers/webhook.controller.js +200 -0
- package/src/middleware/auth.js +66 -0
- package/src/middleware/errorCapture.js +170 -0
- package/src/middleware/headlessApiTokenAuth.js +57 -0
- package/src/middleware/org.js +108 -0
- package/src/middleware.js +901 -0
- package/src/models/ActionEvent.js +31 -0
- package/src/models/ActivityLog.js +41 -0
- package/src/models/Asset.js +84 -0
- package/src/models/AuditEvent.js +93 -0
- package/src/models/EmailLog.js +28 -0
- package/src/models/ErrorAggregate.js +72 -0
- package/src/models/FormSubmission.js +41 -0
- package/src/models/GlobalSetting.js +38 -0
- package/src/models/HeadlessApiToken.js +24 -0
- package/src/models/HeadlessModelDefinition.js +41 -0
- package/src/models/I18nEntry.js +77 -0
- package/src/models/I18nLocale.js +33 -0
- package/src/models/Invite.js +70 -0
- package/src/models/JsonConfig.js +46 -0
- package/src/models/Notification.js +60 -0
- package/src/models/Organization.js +57 -0
- package/src/models/OrganizationMember.js +43 -0
- package/src/models/StripeCatalogItem.js +77 -0
- package/src/models/StripeWebhookEvent.js +57 -0
- package/src/models/User.js +89 -0
- package/src/models/VirtualEjsFile.js +60 -0
- package/src/models/VirtualEjsFileVersion.js +43 -0
- package/src/models/VirtualEjsGroupChange.js +32 -0
- package/src/models/WaitingList.js +41 -0
- package/src/models/Webhook.js +63 -0
- package/src/models/Workflow.js +29 -0
- package/src/models/WorkflowExecution.js +12 -0
- package/src/routes/admin.routes.js +26 -0
- package/src/routes/adminAssets.routes.js +28 -0
- package/src/routes/adminAssetsStorage.routes.js +13 -0
- package/src/routes/adminAudit.routes.js +196 -0
- package/src/routes/adminEjsVirtual.routes.js +17 -0
- package/src/routes/adminErrors.routes.js +164 -0
- package/src/routes/adminFeatureFlags.routes.js +12 -0
- package/src/routes/adminHeadless.routes.js +38 -0
- package/src/routes/adminI18n.routes.js +22 -0
- package/src/routes/adminJsonConfigs.routes.js +15 -0
- package/src/routes/adminLlm.routes.js +12 -0
- package/src/routes/adminMigration.routes.js +81 -0
- package/src/routes/adminSeoConfig.routes.js +20 -0
- package/src/routes/adminUploadNamespaces.routes.js +13 -0
- package/src/routes/assets.routes.js +21 -0
- package/src/routes/auth.routes.js +12 -0
- package/src/routes/billing.routes.js +11 -0
- package/src/routes/errorTracking.routes.js +31 -0
- package/src/routes/featureFlags.routes.js +9 -0
- package/src/routes/forms.routes.js +9 -0
- package/src/routes/formsAdmin.routes.js +13 -0
- package/src/routes/globalSettings.routes.js +18 -0
- package/src/routes/headless.routes.js +15 -0
- package/src/routes/i18n.routes.js +8 -0
- package/src/routes/invite.routes.js +9 -0
- package/src/routes/jsonConfigs.routes.js +8 -0
- package/src/routes/log.routes.js +111 -0
- package/src/routes/metrics.routes.js +9 -0
- package/src/routes/notificationAdmin.routes.js +15 -0
- package/src/routes/notifications.routes.js +12 -0
- package/src/routes/org.routes.js +31 -0
- package/src/routes/orgAdmin.routes.js +20 -0
- package/src/routes/publicAssets.routes.js +7 -0
- package/src/routes/stripeAdmin.routes.js +20 -0
- package/src/routes/user.routes.js +22 -0
- package/src/routes/userAdmin.routes.js +15 -0
- package/src/routes/waitingList.routes.js +13 -0
- package/src/routes/waitingListAdmin.routes.js +9 -0
- package/src/routes/webhook.routes.js +32 -0
- package/src/routes/workflowWebhook.routes.js +54 -0
- package/src/routes/workflows.routes.js +110 -0
- package/src/services/assets.service.js +110 -0
- package/src/services/audit.service.js +62 -0
- package/src/services/auditLogger.js +165 -0
- package/src/services/ejsVirtual.service.js +614 -0
- package/src/services/email.service.js +351 -0
- package/src/services/errorLogger.js +221 -0
- package/src/services/featureFlags.service.js +202 -0
- package/src/services/forms.service.js +214 -0
- package/src/services/globalSettings.service.js +49 -0
- package/src/services/headlessApiTokens.service.js +158 -0
- package/src/services/headlessCrypto.service.js +31 -0
- package/src/services/headlessModels.service.js +356 -0
- package/src/services/i18n.service.js +314 -0
- package/src/services/i18nInferredKeys.service.js +337 -0
- package/src/services/jsonConfigs.service.js +392 -0
- package/src/services/llm.service.js +749 -0
- package/src/services/migration.service.js +581 -0
- package/src/services/migrationAssets/fsLocal.js +58 -0
- package/src/services/migrationAssets/index.js +134 -0
- package/src/services/migrationAssets/s3.js +75 -0
- package/src/services/migrationAssets/sftp.js +92 -0
- package/src/services/notification.service.js +212 -0
- package/src/services/objectStorage.service.js +514 -0
- package/src/services/seoConfig.service.js +402 -0
- package/src/services/storage.js +150 -0
- package/src/services/stripe.service.js +185 -0
- package/src/services/stripeHelper.service.js +264 -0
- package/src/services/uploadNamespaces.service.js +326 -0
- package/src/services/webhook.service.js +157 -0
- package/src/services/workflow.service.js +271 -0
- package/src/utils/asyncHandler.js +5 -0
- package/src/utils/encryption.js +80 -0
- package/src/utils/jwt.js +40 -0
- package/src/utils/orgRoles.js +156 -0
- package/src/utils/validation.js +26 -0
- package/src/utils/webhookRetry.js +93 -0
- package/views/admin-assets.ejs +444 -0
- package/views/admin-audit.ejs +283 -0
- package/views/admin-coolify-deploy.ejs +207 -0
- package/views/admin-dashboard-home.ejs +291 -0
- package/views/admin-dashboard.ejs +397 -0
- package/views/admin-ejs-virtual.ejs +280 -0
- package/views/admin-errors.ejs +368 -0
- package/views/admin-feature-flags.ejs +390 -0
- package/views/admin-forms.ejs +526 -0
- package/views/admin-global-settings.ejs +436 -0
- package/views/admin-headless.ejs +2020 -0
- package/views/admin-i18n-locales.ejs +221 -0
- package/views/admin-i18n.ejs +728 -0
- package/views/admin-json-configs.ejs +410 -0
- package/views/admin-llm.ejs +884 -0
- package/views/admin-metrics.ejs +274 -0
- package/views/admin-migration.ejs +814 -0
- package/views/admin-notifications.ejs +430 -0
- package/views/admin-organizations.ejs +984 -0
- package/views/admin-seo-config.ejs +673 -0
- package/views/admin-stripe-pricing.ejs +558 -0
- package/views/admin-test.ejs +342 -0
- package/views/admin-users.ejs +452 -0
- package/views/admin-waiting-list.ejs +547 -0
- package/views/admin-webhooks.ejs +329 -0
- package/views/admin-workflows.ejs +310 -0
- package/views/partials/admin-assets-script.ejs +2022 -0
- package/views/partials/admin-test-sidebar.ejs +14 -0
- package/views/partials/dashboard/nav-items.ejs +66 -0
- package/views/partials/dashboard/palette.ejs +63 -0
- package/views/partials/dashboard/sidebar.ejs +21 -0
- package/views/partials/dashboard/tab-bar.ejs +26 -0
- package/views/partials/footer.ejs +3 -0
|
@@ -0,0 +1,2022 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
const API_BASE = window.location.origin + "<%= baseUrl %>";
|
|
3
|
+
let allAssets = [];
|
|
4
|
+
let namespaces = [];
|
|
5
|
+
let currentPage = 1;
|
|
6
|
+
let totalPages = 1;
|
|
7
|
+
let viewMode = 'cards';
|
|
8
|
+
|
|
9
|
+
const LS_KEYS = {
|
|
10
|
+
viewMode: 'adminAssets.viewMode',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
let selectedAssetId = null;
|
|
14
|
+
|
|
15
|
+
let selectedAssetIds = new Set();
|
|
16
|
+
|
|
17
|
+
let tagsEditingAssetId = null;
|
|
18
|
+
|
|
19
|
+
let namespaceEditorMode = 'create';
|
|
20
|
+
let namespaceEditorOriginalKey = null;
|
|
21
|
+
|
|
22
|
+
let storageConsole = {
|
|
23
|
+
activeBackend: null,
|
|
24
|
+
s3Configured: false,
|
|
25
|
+
s3CheckOk: false,
|
|
26
|
+
lastS3CheckError: null,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function showToast(message, type = 'success') {
|
|
30
|
+
const toast = document.createElement('div');
|
|
31
|
+
toast.className = `toast px-4 py-3 rounded shadow-lg text-white ${type === 'error' ? 'bg-red-500' : 'bg-green-500'}`;
|
|
32
|
+
toast.textContent = message;
|
|
33
|
+
const container = document.getElementById('toast-container');
|
|
34
|
+
container.appendChild(toast);
|
|
35
|
+
|
|
36
|
+
setTimeout(() => {
|
|
37
|
+
toast.classList.add('fade-out');
|
|
38
|
+
setTimeout(() => toast.remove(), 300);
|
|
39
|
+
}, 3000);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatBytes(bytes) {
|
|
43
|
+
if (bytes === 0) return '0 Bytes';
|
|
44
|
+
const k = 1024;
|
|
45
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
46
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
47
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatDateTime(value) {
|
|
51
|
+
if (!value) return '';
|
|
52
|
+
const d = value instanceof Date ? value : new Date(value);
|
|
53
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
54
|
+
try {
|
|
55
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
56
|
+
year: 'numeric',
|
|
57
|
+
month: '2-digit',
|
|
58
|
+
day: '2-digit',
|
|
59
|
+
hour: '2-digit',
|
|
60
|
+
minute: '2-digit',
|
|
61
|
+
}).format(d);
|
|
62
|
+
} catch {
|
|
63
|
+
return d.toISOString();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function setNamespaceMaxSizePresetMb(mb) {
|
|
68
|
+
const input = document.getElementById('ns-max');
|
|
69
|
+
const parsedMb = Number(mb);
|
|
70
|
+
if (!Number.isFinite(parsedMb) || parsedMb <= 0) return;
|
|
71
|
+
input.value = String(Math.round(parsedMb * 1024 * 1024));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function clearNamespaceMaxSize() {
|
|
75
|
+
document.getElementById('ns-max').value = '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function setNamespaceAllowedTypesPreset(preset) {
|
|
79
|
+
const input = document.getElementById('ns-types');
|
|
80
|
+
|
|
81
|
+
const media = [
|
|
82
|
+
'image/jpeg',
|
|
83
|
+
'image/png',
|
|
84
|
+
'image/webp',
|
|
85
|
+
'image/gif',
|
|
86
|
+
'video/mp4',
|
|
87
|
+
'video/webm',
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
const documents = [
|
|
91
|
+
'application/pdf',
|
|
92
|
+
'text/plain',
|
|
93
|
+
'text/csv',
|
|
94
|
+
'application/json',
|
|
95
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
96
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
if (preset === 'media') {
|
|
100
|
+
input.value = media.join(',');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (preset === 'documents') {
|
|
105
|
+
input.value = documents.join(',');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (preset === 'allDefault') {
|
|
110
|
+
input.value = '';
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseEnvLines(text) {
|
|
116
|
+
const result = {};
|
|
117
|
+
const lines = String(text || '').split(/\r?\n/);
|
|
118
|
+
for (const lineRaw of lines) {
|
|
119
|
+
const line = String(lineRaw || '').trim();
|
|
120
|
+
if (!line) continue;
|
|
121
|
+
if (line.startsWith('#')) continue;
|
|
122
|
+
|
|
123
|
+
const normalized = line.startsWith('export ') ? line.slice('export '.length).trim() : line;
|
|
124
|
+
const idx = normalized.indexOf('=');
|
|
125
|
+
if (idx <= 0) continue;
|
|
126
|
+
|
|
127
|
+
const key = normalized.slice(0, idx).trim();
|
|
128
|
+
let value = normalized.slice(idx + 1).trim();
|
|
129
|
+
if (!key) continue;
|
|
130
|
+
|
|
131
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
132
|
+
value = value.slice(1, -1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
result[key] = value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parseBoolean(value) {
|
|
142
|
+
const v = String(value ?? '').trim().toLowerCase();
|
|
143
|
+
if (['1', 'true', 'yes', 'y', 'on'].includes(v)) return true;
|
|
144
|
+
if (['0', 'false', 'no', 'n', 'off'].includes(v)) return false;
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function applyS3EnvMap(envs) {
|
|
149
|
+
if (!envs || typeof envs !== 'object') return { applied: 0 };
|
|
150
|
+
let applied = 0;
|
|
151
|
+
|
|
152
|
+
const setValue = (id, value) => {
|
|
153
|
+
const el = document.getElementById(id);
|
|
154
|
+
if (!el) return;
|
|
155
|
+
const v = String(value ?? '');
|
|
156
|
+
if (!v) return;
|
|
157
|
+
el.value = v;
|
|
158
|
+
applied += 1;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (envs.S3_ENDPOINT) setValue('s3-endpoint', envs.S3_ENDPOINT);
|
|
162
|
+
if (envs.S3_REGION) setValue('s3-region', envs.S3_REGION);
|
|
163
|
+
if (envs.S3_BUCKET) setValue('s3-bucket', envs.S3_BUCKET);
|
|
164
|
+
|
|
165
|
+
if (envs.S3_ACCESS_KEY_ID) setValue('s3-access-key', envs.S3_ACCESS_KEY_ID);
|
|
166
|
+
if (envs.S3_SECRET_ACCESS_KEY) setValue('s3-secret-key', envs.S3_SECRET_ACCESS_KEY);
|
|
167
|
+
|
|
168
|
+
if (envs.S3_FORCE_PATH_STYLE !== undefined) {
|
|
169
|
+
const b = parseBoolean(envs.S3_FORCE_PATH_STYLE);
|
|
170
|
+
if (b !== null) {
|
|
171
|
+
const checkbox = document.getElementById('s3-force-path-style');
|
|
172
|
+
if (checkbox) {
|
|
173
|
+
checkbox.checked = b;
|
|
174
|
+
applied += 1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { applied };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function pasteS3EnvsFromClipboard() {
|
|
183
|
+
const hint = document.getElementById('storage-console-hint');
|
|
184
|
+
try {
|
|
185
|
+
if (!navigator.clipboard?.readText) {
|
|
186
|
+
throw new Error('Clipboard API not available in this browser context');
|
|
187
|
+
}
|
|
188
|
+
if (hint) hint.textContent = 'Reading clipboard...';
|
|
189
|
+
|
|
190
|
+
const text = await navigator.clipboard.readText();
|
|
191
|
+
const envs = parseEnvLines(text);
|
|
192
|
+
const { applied } = applyS3EnvMap(envs);
|
|
193
|
+
|
|
194
|
+
if (!applied) {
|
|
195
|
+
throw new Error('No recognized S3_* envs found in clipboard');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (hint) hint.textContent = `Pasted ${applied} value(s). Review fields, then click "Save S3 config".`;
|
|
199
|
+
showToast(`Pasted ${applied} value(s)`);
|
|
200
|
+
} catch (e) {
|
|
201
|
+
if (hint) hint.textContent = '';
|
|
202
|
+
showToast(e.message || 'Failed to paste envs', 'error');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function clearNamespaceAllowedTypes() {
|
|
207
|
+
document.getElementById('ns-types').value = '';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function fetchStorageConsoleStatus() {
|
|
211
|
+
const res = await fetch(`${API_BASE}/api/admin/assets/storage`);
|
|
212
|
+
const data = await res.json().catch(() => ({}));
|
|
213
|
+
if (!res.ok) throw new Error(data?.error || 'Failed to load storage status');
|
|
214
|
+
|
|
215
|
+
storageConsole.activeBackend = data?.activeBackend || null;
|
|
216
|
+
storageConsole.s3Configured = Boolean(data?.s3?.configured);
|
|
217
|
+
return data;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function renderStorageConsole({ info, storageStatus }) {
|
|
221
|
+
const data = info;
|
|
222
|
+
const status = storageStatus;
|
|
223
|
+
|
|
224
|
+
const activeBackend = status?.activeBackend || data?.provider || 'fs';
|
|
225
|
+
const s3Configured = Boolean(status?.s3?.configured);
|
|
226
|
+
|
|
227
|
+
const s3CheckStateText = storageConsole.s3CheckOk
|
|
228
|
+
? '<span class="text-green-700 font-semibold">connected</span>'
|
|
229
|
+
: storageConsole.lastS3CheckError
|
|
230
|
+
? `<span class="text-red-700 font-semibold">failed</span> <span class="text-red-700">(${escapeHtml(storageConsole.lastS3CheckError)})</span>`
|
|
231
|
+
: '<span class="text-gray-600">not checked</span>';
|
|
232
|
+
|
|
233
|
+
const s3Config = status?.s3?.config || null;
|
|
234
|
+
|
|
235
|
+
document.getElementById('storage-info-content').innerHTML = `
|
|
236
|
+
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
237
|
+
<div><strong>Active backend:</strong> ${escapeHtml(activeBackend === 's3' ? 'S3' : 'Filesystem')}</div>
|
|
238
|
+
<div><strong>Bucket:</strong> ${escapeHtml(String(data.bucket || ''))}</div>
|
|
239
|
+
${data.multerCeilingMaxFileSizeBytes ? `<div><strong>Multer ceiling:</strong> ${formatBytes(data.multerCeilingMaxFileSizeBytes)}</div>` : ''}
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<div class="mt-2 text-xs text-blue-900">
|
|
243
|
+
<div><strong>Env hard cap (fallback):</strong> ${data.envFallbackHardCapMaxFileSizeBytes ? formatBytes(data.envFallbackHardCapMaxFileSizeBytes) : '(not set)'}</div>
|
|
244
|
+
<div><strong>Setting hard cap:</strong> ${data.configuredHardCapMaxFileSizeBytes ? formatBytes(data.configuredHardCapMaxFileSizeBytes) : '(not set)'}</div>
|
|
245
|
+
<div><strong>Effective hard cap (enforced):</strong> ${data.hardCapMaxFileSizeBytes ? formatBytes(data.hardCapMaxFileSizeBytes) : '(unknown)'}</div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<div class="mt-3">
|
|
249
|
+
<div class="text-xs text-blue-900 font-semibold mb-2">Set hard cap (GlobalSetting: MAX_FILE_SIZE_HARD_CAP)</div>
|
|
250
|
+
<div class="flex flex-wrap gap-2 items-center">
|
|
251
|
+
<button type="button" class="px-2 py-1 text-xs bg-white border rounded hover:bg-blue-100" onclick="setHardCapPreset('10mb')">10mb</button>
|
|
252
|
+
<button type="button" class="px-2 py-1 text-xs bg-white border rounded hover:bg-blue-100" onclick="setHardCapPreset('30mb')">30mb</button>
|
|
253
|
+
<button type="button" class="px-2 py-1 text-xs bg-white border rounded hover:bg-blue-100" onclick="setHardCapPreset('50mb')">50mb</button>
|
|
254
|
+
<button type="button" class="px-2 py-1 text-xs bg-white border rounded hover:bg-blue-100" onclick="setHardCapPreset('100mb')">100mb</button>
|
|
255
|
+
<button type="button" class="px-2 py-1 text-xs bg-white border rounded hover:bg-blue-100" onclick="setHardCapPreset('1gb')">1gb</button>
|
|
256
|
+
<span class="text-xs text-blue-900">or</span>
|
|
257
|
+
<input id="hard-cap-input" class="border rounded px-2 py-1 text-xs" placeholder="e.g. 10mb, 1gb" style="width: 150px;" />
|
|
258
|
+
<button type="button" class="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700" onclick="applyHardCapFromInput()">Set</button>
|
|
259
|
+
</div>
|
|
260
|
+
<div class="mt-1 text-xs text-blue-900" id="hard-cap-hint"></div>
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<div class="mt-4 border-t border-blue-200 pt-4">
|
|
264
|
+
<div class="text-xs text-blue-900 font-semibold mb-2">S3 storage</div>
|
|
265
|
+
|
|
266
|
+
<div class="text-xs text-blue-900 mb-2">
|
|
267
|
+
<div><strong>Configured:</strong> ${s3Configured ? 'yes' : 'no'}</div>
|
|
268
|
+
<div><strong>Connection:</strong> ${s3CheckStateText}</div>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
272
|
+
<input id="s3-endpoint" class="border rounded px-2 py-1 text-xs" placeholder="S3 endpoint" value="${escapeHtml(String(s3Config?.endpoint || ''))}" />
|
|
273
|
+
<input id="s3-region" class="border rounded px-2 py-1 text-xs" placeholder="Region" value="${escapeHtml(String(s3Config?.region || 'us-east-1'))}" />
|
|
274
|
+
<input id="s3-bucket" class="border rounded px-2 py-1 text-xs" placeholder="Bucket" value="${escapeHtml(String(s3Config?.bucket || ''))}" />
|
|
275
|
+
<input id="s3-access-key" class="border rounded px-2 py-1 text-xs" placeholder="Access key" value="" />
|
|
276
|
+
<input id="s3-secret-key" class="border rounded px-2 py-1 text-xs" placeholder="Secret key" value="" />
|
|
277
|
+
<label class="flex items-center gap-2 text-xs text-blue-900">
|
|
278
|
+
<input id="s3-force-path-style" type="checkbox" ${s3Config?.forcePathStyle ? 'checked' : ''} />
|
|
279
|
+
forcePathStyle
|
|
280
|
+
</label>
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
<div class="mt-2 flex flex-wrap gap-2">
|
|
284
|
+
<button type="button" class="px-3 py-1 text-xs bg-white border rounded hover:bg-blue-100" onclick="pasteS3EnvsFromClipboard()">Paste envs</button>
|
|
285
|
+
<button type="button" class="px-3 py-1 text-xs bg-gray-800 text-white rounded hover:bg-gray-900" onclick="saveS3Config()">Save S3 config</button>
|
|
286
|
+
<button type="button" class="px-3 py-1 text-xs bg-white border rounded hover:bg-blue-100" onclick="checkS3Conn()" ${s3Configured ? '' : 'disabled'}>Check S3 conn</button>
|
|
287
|
+
<button type="button" class="px-3 py-1 text-xs bg-white border rounded hover:bg-blue-100" onclick="runSync('fs-to-s3')" ${s3Configured ? '' : 'disabled'}>Sync fs → s3</button>
|
|
288
|
+
<button type="button" class="px-3 py-1 text-xs bg-white border rounded hover:bg-blue-100" onclick="runSync('s3-to-fs')" ${s3Configured ? '' : 'disabled'}>Sync s3 → fs</button>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
<div class="mt-2 flex flex-wrap gap-2 items-center">
|
|
292
|
+
<span class="text-xs text-blue-900"><strong>Switch backend:</strong></span>
|
|
293
|
+
<button type="button" class="px-3 py-1 text-xs rounded border ${activeBackend === 'fs' ? 'bg-blue-600 text-white border-blue-600' : 'bg-white'}" onclick="switchBackend('fs')">Use fs</button>
|
|
294
|
+
<button type="button" class="px-3 py-1 text-xs rounded border ${activeBackend === 's3' ? 'bg-blue-600 text-white border-blue-600' : 'bg-white'}" onclick="switchBackend('s3')" ${s3Configured && storageConsole.s3CheckOk ? '' : 'disabled'}>Use s3</button>
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
<div class="mt-1 text-xs text-blue-900" id="storage-console-hint"></div>
|
|
298
|
+
</div>
|
|
299
|
+
`;
|
|
300
|
+
|
|
301
|
+
const input = document.getElementById('hard-cap-input');
|
|
302
|
+
if (input) {
|
|
303
|
+
input.value = data.configuredHardCapMaxFileSizeBytes
|
|
304
|
+
? bytesToHumanInput(data.configuredHardCapMaxFileSizeBytes)
|
|
305
|
+
: '';
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function loadStorageInfo() {
|
|
310
|
+
try {
|
|
311
|
+
const [infoRes, storageStatus] = await Promise.all([
|
|
312
|
+
fetch(`${API_BASE}/api/admin/assets/info`).then(r => r.json().then(d => ({ ok: r.ok, d }))),
|
|
313
|
+
fetchStorageConsoleStatus(),
|
|
314
|
+
]);
|
|
315
|
+
|
|
316
|
+
if (!infoRes.ok) throw new Error(infoRes.d?.error || 'Failed to load storage info');
|
|
317
|
+
|
|
318
|
+
renderStorageConsole({ info: infoRes.d, storageStatus });
|
|
319
|
+
} catch (error) {
|
|
320
|
+
document.getElementById('storage-info-content').innerHTML = `<p class="text-red-600">Error: ${escapeHtml(error.message)}</p>`;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function saveS3Config() {
|
|
325
|
+
const hint = document.getElementById('storage-console-hint');
|
|
326
|
+
try {
|
|
327
|
+
if (hint) hint.textContent = 'Saving S3 config...';
|
|
328
|
+
|
|
329
|
+
const payload = {
|
|
330
|
+
endpoint: document.getElementById('s3-endpoint')?.value,
|
|
331
|
+
region: document.getElementById('s3-region')?.value,
|
|
332
|
+
bucket: document.getElementById('s3-bucket')?.value,
|
|
333
|
+
accessKeyId: document.getElementById('s3-access-key')?.value,
|
|
334
|
+
secretAccessKey: document.getElementById('s3-secret-key')?.value,
|
|
335
|
+
forcePathStyle: Boolean(document.getElementById('s3-force-path-style')?.checked),
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const res = await fetch(`${API_BASE}/api/admin/assets/storage/s3-config`, {
|
|
339
|
+
method: 'PUT',
|
|
340
|
+
headers: { 'Content-Type': 'application/json' },
|
|
341
|
+
body: JSON.stringify(payload),
|
|
342
|
+
});
|
|
343
|
+
const data = await res.json().catch(() => ({}));
|
|
344
|
+
if (!res.ok) throw new Error(data?.error || 'Failed to save S3 config');
|
|
345
|
+
|
|
346
|
+
storageConsole.s3Configured = true;
|
|
347
|
+
storageConsole.s3CheckOk = false;
|
|
348
|
+
storageConsole.lastS3CheckError = null;
|
|
349
|
+
|
|
350
|
+
if (hint) hint.textContent = 'Saved. Now run "Check S3 conn".';
|
|
351
|
+
showToast('S3 config saved');
|
|
352
|
+
await loadStorageInfo();
|
|
353
|
+
} catch (e) {
|
|
354
|
+
if (hint) hint.textContent = '';
|
|
355
|
+
showToast(e.message || 'Failed to save S3 config', 'error');
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function checkS3Conn() {
|
|
360
|
+
const hint = document.getElementById('storage-console-hint');
|
|
361
|
+
try {
|
|
362
|
+
if (hint) hint.textContent = 'Checking S3 connection...';
|
|
363
|
+
const res = await fetch(`${API_BASE}/api/admin/assets/storage/s3-check`, { method: 'POST' });
|
|
364
|
+
const data = await res.json().catch(() => ({}));
|
|
365
|
+
if (!res.ok || data?.ok === false) throw new Error(data?.error || 'S3 check failed');
|
|
366
|
+
|
|
367
|
+
storageConsole.s3CheckOk = true;
|
|
368
|
+
storageConsole.lastS3CheckError = null;
|
|
369
|
+
if (hint) hint.textContent = 'S3 connected.';
|
|
370
|
+
showToast('S3 connected');
|
|
371
|
+
await loadStorageInfo();
|
|
372
|
+
} catch (e) {
|
|
373
|
+
storageConsole.s3CheckOk = false;
|
|
374
|
+
storageConsole.lastS3CheckError = e.message || 'S3 check failed';
|
|
375
|
+
if (hint) hint.textContent = '';
|
|
376
|
+
showToast(storageConsole.lastS3CheckError, 'error');
|
|
377
|
+
await loadStorageInfo();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function runSync(direction) {
|
|
382
|
+
const hint = document.getElementById('storage-console-hint');
|
|
383
|
+
try {
|
|
384
|
+
if (!confirm(`Run sync ${direction}?\n\nRules:\n- missing in source => skip\n- different bytes => skip\n- provider/bucket mismatch => abort`)) return;
|
|
385
|
+
if (hint) hint.textContent = `Syncing (${direction})...`;
|
|
386
|
+
|
|
387
|
+
const res = await fetch(`${API_BASE}/api/admin/assets/storage/sync`, {
|
|
388
|
+
method: 'POST',
|
|
389
|
+
headers: { 'Content-Type': 'application/json' },
|
|
390
|
+
body: JSON.stringify({ direction, limit: 100 }),
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const data = await res.json().catch(() => ({}));
|
|
394
|
+
if (!res.ok) {
|
|
395
|
+
const details = data?.details?.reason ? `${data.details.reason} (assetId=${data.details.assetId})` : (data?.error || 'Sync failed');
|
|
396
|
+
throw new Error(details);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const stats = data?.stats;
|
|
400
|
+
if (hint) hint.textContent = `Done. scanned=${stats?.scanned || 0} copied=${stats?.copied || 0} skippedMissing=${stats?.skippedMissingSource || 0} skippedSame=${stats?.skippedAlreadySynced || 0} skippedDiff=${stats?.skippedDifferentBytes || 0}`;
|
|
401
|
+
showToast('Sync completed');
|
|
402
|
+
} catch (e) {
|
|
403
|
+
if (hint) hint.textContent = '';
|
|
404
|
+
showToast(e.message || 'Sync failed', 'error');
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function switchBackend(backend) {
|
|
409
|
+
const hint = document.getElementById('storage-console-hint');
|
|
410
|
+
try {
|
|
411
|
+
if (!confirm(`Switch active backend to ${backend}?`)) return;
|
|
412
|
+
if (backend === 's3' && !storageConsole.s3CheckOk) {
|
|
413
|
+
throw new Error('S3 must be configured and connected before switching');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (hint) hint.textContent = `Switching to ${backend}...`;
|
|
417
|
+
const res = await fetch(`${API_BASE}/api/admin/assets/storage/switch`, {
|
|
418
|
+
method: 'POST',
|
|
419
|
+
headers: { 'Content-Type': 'application/json' },
|
|
420
|
+
body: JSON.stringify({ backend }),
|
|
421
|
+
});
|
|
422
|
+
const data = await res.json().catch(() => ({}));
|
|
423
|
+
if (!res.ok) throw new Error(data?.error || 'Switch failed');
|
|
424
|
+
|
|
425
|
+
storageConsole.activeBackend = backend;
|
|
426
|
+
if (hint) hint.textContent = `Active backend: ${backend}`;
|
|
427
|
+
showToast('Backend switched');
|
|
428
|
+
await loadStorageInfo();
|
|
429
|
+
} catch (e) {
|
|
430
|
+
if (hint) hint.textContent = '';
|
|
431
|
+
showToast(e.message || 'Failed to switch backend', 'error');
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function bytesToHumanInput(bytes) {
|
|
436
|
+
const n = Number(bytes);
|
|
437
|
+
if (!Number.isFinite(n) || n <= 0) return '';
|
|
438
|
+
const mb = n / (1024 * 1024);
|
|
439
|
+
if (mb >= 1024) return `${Math.round(mb / 1024)}gb`;
|
|
440
|
+
if (mb >= 1) return `${Math.round(mb)}mb`;
|
|
441
|
+
const kb = n / 1024;
|
|
442
|
+
if (kb >= 1) return `${Math.round(kb)}kb`;
|
|
443
|
+
return `${Math.round(n)}b`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function parseHumanBytes(input) {
|
|
447
|
+
const raw = String(input || '').trim().toLowerCase();
|
|
448
|
+
if (!raw) return null;
|
|
449
|
+
|
|
450
|
+
const match = raw.match(/^([0-9]+(?:\.[0-9]+)?)\s*(b|kb|mb|gb)$/);
|
|
451
|
+
if (!match) return null;
|
|
452
|
+
|
|
453
|
+
const value = Number(match[1]);
|
|
454
|
+
if (!Number.isFinite(value) || value <= 0) return null;
|
|
455
|
+
|
|
456
|
+
const unit = match[2];
|
|
457
|
+
const multipliers = {
|
|
458
|
+
b: 1,
|
|
459
|
+
kb: 1024,
|
|
460
|
+
mb: 1024 * 1024,
|
|
461
|
+
gb: 1024 * 1024 * 1024,
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
return Math.round(value * multipliers[unit]);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function setHardCapPreset(text) {
|
|
468
|
+
const input = document.getElementById('hard-cap-input');
|
|
469
|
+
if (input) input.value = text;
|
|
470
|
+
applyHardCapFromInput();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function persistHardCapSetting(bytes) {
|
|
474
|
+
const hint = document.getElementById('hard-cap-hint');
|
|
475
|
+
try {
|
|
476
|
+
if (hint) hint.textContent = 'Saving...';
|
|
477
|
+
|
|
478
|
+
const putRes = await fetch(`${API_BASE}/api/admin/settings/MAX_FILE_SIZE_HARD_CAP`, {
|
|
479
|
+
method: 'PUT',
|
|
480
|
+
headers: { 'Content-Type': 'application/json' },
|
|
481
|
+
body: JSON.stringify({ value: String(bytes) }),
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
if (putRes.status === 404) {
|
|
485
|
+
const postRes = await fetch(`${API_BASE}/api/admin/settings`, {
|
|
486
|
+
method: 'POST',
|
|
487
|
+
headers: { 'Content-Type': 'application/json' },
|
|
488
|
+
body: JSON.stringify({
|
|
489
|
+
key: 'MAX_FILE_SIZE_HARD_CAP',
|
|
490
|
+
value: String(bytes),
|
|
491
|
+
type: 'number',
|
|
492
|
+
description: 'Global multipart hard cap for uploads (bytes)',
|
|
493
|
+
public: false,
|
|
494
|
+
}),
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
const postData = await postRes.json().catch(() => ({}));
|
|
498
|
+
if (!postRes.ok) throw new Error(postData?.error || 'Failed to create setting');
|
|
499
|
+
} else {
|
|
500
|
+
const putData = await putRes.json().catch(() => ({}));
|
|
501
|
+
if (!putRes.ok) throw new Error(putData?.error || 'Failed to update setting');
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (hint) hint.textContent = `Saved: ${formatBytes(bytes)}.`;
|
|
505
|
+
|
|
506
|
+
await loadStorageInfo();
|
|
507
|
+
await loadNamespaces();
|
|
508
|
+
await loadNamespacesSummary();
|
|
509
|
+
} catch (e) {
|
|
510
|
+
if (hint) hint.textContent = '';
|
|
511
|
+
showToast(e.message || 'Failed to save hard cap', 'error');
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function applyHardCapFromInput() {
|
|
516
|
+
const input = document.getElementById('hard-cap-input');
|
|
517
|
+
const hint = document.getElementById('hard-cap-hint');
|
|
518
|
+
const bytes = parseHumanBytes(input?.value);
|
|
519
|
+
if (!bytes) {
|
|
520
|
+
if (hint) hint.textContent = 'Enter a value like 10mb, 1gb, 500kb.';
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (hint) hint.textContent = `Will set hard cap to ${formatBytes(bytes)}.`;
|
|
524
|
+
persistHardCapSetting(bytes);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function escapeHtml(text) {
|
|
528
|
+
const div = document.createElement('div');
|
|
529
|
+
div.textContent = String(text ?? '');
|
|
530
|
+
return div.innerHTML;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function escapeJs(text) {
|
|
534
|
+
return String(text ?? '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function showUploadModal() {
|
|
538
|
+
document.getElementById('upload-file').value = '';
|
|
539
|
+
setUploadNamespace('default');
|
|
540
|
+
document.getElementById('upload-modal').classList.remove('hidden');
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function closeUploadModal() {
|
|
544
|
+
document.getElementById('upload-modal').classList.add('hidden');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function handleUpload(event) {
|
|
548
|
+
event.preventDefault();
|
|
549
|
+
|
|
550
|
+
const fileInput = document.getElementById('upload-file');
|
|
551
|
+
const visibility = document.getElementById('upload-visibility').value;
|
|
552
|
+
|
|
553
|
+
if (!fileInput.files.length) {
|
|
554
|
+
showToast('Please select a file', 'error');
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const formData = new FormData();
|
|
559
|
+
formData.append('file', fileInput.files[0]);
|
|
560
|
+
formData.append('visibility', visibility);
|
|
561
|
+
formData.append('namespace', document.getElementById('upload-namespace').value || 'default');
|
|
562
|
+
|
|
563
|
+
const btn = document.getElementById('upload-btn');
|
|
564
|
+
btn.disabled = true;
|
|
565
|
+
btn.textContent = 'Uploading...';
|
|
566
|
+
|
|
567
|
+
try {
|
|
568
|
+
const response = await fetch(`${API_BASE}/api/admin/assets/upload`, {
|
|
569
|
+
method: 'POST',
|
|
570
|
+
body: formData
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
const data = await response.json();
|
|
574
|
+
|
|
575
|
+
if (!response.ok) {
|
|
576
|
+
const details = Array.isArray(data?.errors)
|
|
577
|
+
? data.errors.map((e) => {
|
|
578
|
+
if (!e) return '';
|
|
579
|
+
if (e.reason && e.value !== undefined) return `${e.reason} (${e.value})`;
|
|
580
|
+
return e.reason || 'policy error';
|
|
581
|
+
}).join('; ')
|
|
582
|
+
: '';
|
|
583
|
+
const msg = data?.error || 'Upload failed';
|
|
584
|
+
throw new Error(details ? `${msg}: ${details}` : msg);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
showToast('Asset uploaded successfully');
|
|
588
|
+
closeUploadModal();
|
|
589
|
+
currentPage = 1;
|
|
590
|
+
await loadAssets();
|
|
591
|
+
await loadNamespacesSummary();
|
|
592
|
+
} catch (error) {
|
|
593
|
+
showToast(error.message, 'error');
|
|
594
|
+
} finally {
|
|
595
|
+
btn.disabled = false;
|
|
596
|
+
btn.textContent = 'Upload';
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function getSelectedNamespaceKey() {
|
|
601
|
+
const raw = document.getElementById('filter-namespace')?.value || '';
|
|
602
|
+
return String(raw).trim();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function getNamespaceConfig(key) {
|
|
606
|
+
if (!key) return null;
|
|
607
|
+
return namespaces.find(n => String(n.key) === String(key)) || null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function setQuickUploadStatus(text, kind) {
|
|
611
|
+
const el = document.getElementById('quick-upload-status');
|
|
612
|
+
if (!el) return;
|
|
613
|
+
el.className = 'mt-3 text-xs ' + (kind === 'error' ? 'text-red-600' : kind === 'success' ? 'text-green-700' : 'text-gray-600');
|
|
614
|
+
el.textContent = text || '';
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function updateQuickUploadVisibility() {
|
|
618
|
+
const key = getSelectedNamespaceKey();
|
|
619
|
+
const wrap = document.getElementById('quick-upload');
|
|
620
|
+
const label = document.getElementById('quick-upload-namespace-label');
|
|
621
|
+
if (!wrap || !label) return;
|
|
622
|
+
|
|
623
|
+
if (!key) {
|
|
624
|
+
wrap.classList.add('hidden');
|
|
625
|
+
label.textContent = '';
|
|
626
|
+
setQuickUploadStatus('', '');
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
wrap.classList.remove('hidden');
|
|
631
|
+
label.textContent = key;
|
|
632
|
+
setQuickUploadStatus('', '');
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function uploadSingleFileToSelectedNamespace(file, idx, total) {
|
|
636
|
+
const namespaceKey = getSelectedNamespaceKey();
|
|
637
|
+
if (!namespaceKey) {
|
|
638
|
+
throw new Error('Select a namespace first');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const formData = new FormData();
|
|
642
|
+
formData.append('file', file);
|
|
643
|
+
formData.append('namespace', namespaceKey);
|
|
644
|
+
// Intentionally omit visibility => backend uses namespace default visibility
|
|
645
|
+
|
|
646
|
+
setQuickUploadStatus(`Uploading ${idx}/${total}: ${file.name || 'clipboard'}...`, '');
|
|
647
|
+
|
|
648
|
+
const response = await fetch(`${API_BASE}/api/admin/assets/upload`, {
|
|
649
|
+
method: 'POST',
|
|
650
|
+
body: formData,
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
const data = await response.json().catch(() => ({}));
|
|
654
|
+
if (!response.ok) {
|
|
655
|
+
const details = Array.isArray(data?.errors)
|
|
656
|
+
? data.errors.map((e) => {
|
|
657
|
+
if (!e) return '';
|
|
658
|
+
if (e.reason && e.value !== undefined) return `${e.reason} (${e.value})`;
|
|
659
|
+
return e.reason || 'policy error';
|
|
660
|
+
}).join('; ')
|
|
661
|
+
: '';
|
|
662
|
+
const msg = data?.error || 'Upload failed';
|
|
663
|
+
throw new Error(details ? `${msg}: ${details}` : msg);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return data?.asset || null;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function uploadFilesToSelectedNamespace(files) {
|
|
670
|
+
const list = Array.from(files || []).filter(Boolean);
|
|
671
|
+
if (!list.length) return;
|
|
672
|
+
|
|
673
|
+
const btn = document.getElementById('quick-upload-clipboard-btn');
|
|
674
|
+
if (btn) btn.disabled = true;
|
|
675
|
+
|
|
676
|
+
try {
|
|
677
|
+
for (let i = 0; i < list.length; i++) {
|
|
678
|
+
await uploadSingleFileToSelectedNamespace(list[i], i + 1, list.length);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
setQuickUploadStatus(`Done. Uploaded ${list.length} file(s).`, 'success');
|
|
682
|
+
currentPage = 1;
|
|
683
|
+
await loadAssets();
|
|
684
|
+
await loadNamespacesSummary();
|
|
685
|
+
} catch (e) {
|
|
686
|
+
setQuickUploadStatus(e?.message ? String(e.message) : 'Upload failed', 'error');
|
|
687
|
+
showToast(e?.message ? String(e.message) : 'Upload failed', 'error');
|
|
688
|
+
} finally {
|
|
689
|
+
if (btn) btn.disabled = false;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function uploadFromClipboard() {
|
|
694
|
+
const namespaceKey = getSelectedNamespaceKey();
|
|
695
|
+
if (!namespaceKey) {
|
|
696
|
+
showToast('Select a namespace first', 'error');
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (!navigator.clipboard?.read) {
|
|
701
|
+
showToast('Clipboard file upload not supported in this browser', 'error');
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
try {
|
|
706
|
+
setQuickUploadStatus('Reading clipboard...', '');
|
|
707
|
+
const items = await navigator.clipboard.read();
|
|
708
|
+
const files = [];
|
|
709
|
+
|
|
710
|
+
for (const item of items) {
|
|
711
|
+
for (const type of item.types) {
|
|
712
|
+
if (!type) continue;
|
|
713
|
+
// Prefer images, but accept any file-like blob
|
|
714
|
+
if (type.startsWith('image/') || type === 'application/octet-stream') {
|
|
715
|
+
const blob = await item.getType(type);
|
|
716
|
+
const ext = type.startsWith('image/') ? type.split('/')[1] : 'bin';
|
|
717
|
+
const name = `clipboard-${Date.now()}-${Math.random().toString(16).slice(2)}.${ext}`;
|
|
718
|
+
files.push(new File([blob], name, { type: blob.type || type }));
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (!files.length) {
|
|
725
|
+
setQuickUploadStatus('Clipboard has no files/images to upload.', 'error');
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
await uploadFilesToSelectedNamespace(files);
|
|
730
|
+
} catch (e) {
|
|
731
|
+
setQuickUploadStatus(e?.message ? String(e.message) : 'Clipboard read failed', 'error');
|
|
732
|
+
showToast(e?.message ? String(e.message) : 'Clipboard read failed', 'error');
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function toggleVisibility(id, currentVisibility) {
|
|
737
|
+
const newVisibility = currentVisibility === 'public' ? 'private' : 'public';
|
|
738
|
+
|
|
739
|
+
try {
|
|
740
|
+
const response = await fetch(`${API_BASE}/api/admin/assets/${id}`, {
|
|
741
|
+
method: 'PATCH',
|
|
742
|
+
headers: { 'Content-Type': 'application/json' },
|
|
743
|
+
body: JSON.stringify({ visibility: newVisibility })
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
const data = await response.json();
|
|
747
|
+
|
|
748
|
+
if (!response.ok) throw new Error(data?.error || 'Update failed');
|
|
749
|
+
|
|
750
|
+
showToast(`Asset is now ${newVisibility}`);
|
|
751
|
+
await loadAssets();
|
|
752
|
+
} catch (error) {
|
|
753
|
+
showToast(error.message, 'error');
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async function deleteAsset(id) {
|
|
758
|
+
if (!confirm('Are you sure you want to delete this asset? This cannot be undone.')) return;
|
|
759
|
+
|
|
760
|
+
try {
|
|
761
|
+
const response = await fetch(`${API_BASE}/api/admin/assets/${id}`, { method: 'DELETE' });
|
|
762
|
+
const data = await response.json();
|
|
763
|
+
|
|
764
|
+
if (!response.ok) throw new Error(data?.error || 'Delete failed');
|
|
765
|
+
|
|
766
|
+
showToast('Asset deleted successfully');
|
|
767
|
+
await loadAssets();
|
|
768
|
+
await loadNamespacesSummary();
|
|
769
|
+
} catch (error) {
|
|
770
|
+
showToast(error.message, 'error');
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function copyUrl(url) {
|
|
775
|
+
navigator.clipboard.writeText(url).then(() => {
|
|
776
|
+
showToast('URL copied to clipboard');
|
|
777
|
+
}).catch(() => {
|
|
778
|
+
showToast('Failed to copy URL', 'error');
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function copyAssetId(id) {
|
|
783
|
+
navigator.clipboard.writeText(String(id || '')).then(() => {
|
|
784
|
+
showToast('Asset ID copied');
|
|
785
|
+
}).catch(() => {
|
|
786
|
+
showToast('Failed to copy asset ID', 'error');
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function buildAssetPublicUrl(asset) {
|
|
791
|
+
if (!asset?.publicUrl) return null;
|
|
792
|
+
const base = `${API_BASE}${asset.publicUrl}`;
|
|
793
|
+
const v = asset.updatedAt || asset.createdAt;
|
|
794
|
+
if (!v) return base;
|
|
795
|
+
const ts = new Date(v).getTime();
|
|
796
|
+
if (!Number.isFinite(ts)) return base;
|
|
797
|
+
return `${base}${base.includes('?') ? '&' : '?'}v=${encodeURIComponent(String(ts))}`;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function normalizeAssetId(id) {
|
|
801
|
+
const v = String(id || '').trim();
|
|
802
|
+
return v || null;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function isAssetSelected(id) {
|
|
806
|
+
const v = normalizeAssetId(id);
|
|
807
|
+
if (!v) return false;
|
|
808
|
+
return selectedAssetIds.has(v);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function toggleAssetSelection(id, checked) {
|
|
812
|
+
const v = normalizeAssetId(id);
|
|
813
|
+
if (!v) return;
|
|
814
|
+
if (checked) {
|
|
815
|
+
selectedAssetIds.add(v);
|
|
816
|
+
} else {
|
|
817
|
+
selectedAssetIds.delete(v);
|
|
818
|
+
}
|
|
819
|
+
renderBulkActionsToolbar();
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function clearBulkSelection() {
|
|
823
|
+
selectedAssetIds = new Set();
|
|
824
|
+
renderAssets();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function toggleSelectAllVisible(checked) {
|
|
828
|
+
if (!Array.isArray(allAssets)) return;
|
|
829
|
+
for (const asset of allAssets) {
|
|
830
|
+
const v = normalizeAssetId(asset?._id);
|
|
831
|
+
if (!v) continue;
|
|
832
|
+
if (checked) selectedAssetIds.add(v);
|
|
833
|
+
else selectedAssetIds.delete(v);
|
|
834
|
+
}
|
|
835
|
+
renderAssets();
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function getSelectedAssetsFromCurrentPage() {
|
|
839
|
+
const ids = selectedAssetIds;
|
|
840
|
+
if (!ids || ids.size === 0) return [];
|
|
841
|
+
return (allAssets || []).filter((a) => ids.has(String(a?._id || '')));
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function renderBulkActionsToolbar() {
|
|
845
|
+
const container = document.getElementById('assets-bulk-actions');
|
|
846
|
+
if (!container) return;
|
|
847
|
+
|
|
848
|
+
if (viewMode !== 'list') {
|
|
849
|
+
container.innerHTML = '';
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const count = selectedAssetIds.size;
|
|
854
|
+
if (!count) {
|
|
855
|
+
container.innerHTML = '';
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
container.innerHTML = `
|
|
860
|
+
<div class="mb-3 bg-white rounded-lg shadow border p-3 flex flex-wrap items-center justify-between gap-2">
|
|
861
|
+
<div class="text-sm text-gray-700"><strong>${count}</strong> selected</div>
|
|
862
|
+
<div class="flex flex-wrap gap-2">
|
|
863
|
+
<button type="button" class="px-3 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 text-sm" onclick="copySelectedPublicUrls()">Copy public URLs</button>
|
|
864
|
+
<button type="button" class="px-3 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 text-sm" onclick="openBulkSetTagsModal()">Set tags</button>
|
|
865
|
+
<button type="button" class="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm" onclick="openBulkMoveNamespaceModal()">Move namespace</button>
|
|
866
|
+
<button type="button" class="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 text-sm" onclick="clearBulkSelection()">Clear</button>
|
|
867
|
+
</div>
|
|
868
|
+
</div>
|
|
869
|
+
`;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async function copySelectedPublicUrls() {
|
|
873
|
+
try {
|
|
874
|
+
const selected = getSelectedAssetsFromCurrentPage();
|
|
875
|
+
if (!selected.length) {
|
|
876
|
+
showToast('No assets selected', 'error');
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const urls = [];
|
|
881
|
+
let skipped = 0;
|
|
882
|
+
|
|
883
|
+
for (const asset of selected) {
|
|
884
|
+
const url = asset?.visibility === 'public' ? buildAssetPublicUrl(asset) : null;
|
|
885
|
+
if (url) urls.push(url);
|
|
886
|
+
else skipped += 1;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (!urls.length) {
|
|
890
|
+
showToast('No public URLs found in selection', 'error');
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
await navigator.clipboard.writeText(urls.join('\n'));
|
|
895
|
+
showToast(skipped ? `Copied ${urls.length} URL(s). Skipped ${skipped} non-public asset(s).` : `Copied ${urls.length} URL(s).`);
|
|
896
|
+
} catch (e) {
|
|
897
|
+
showToast(e?.message ? String(e.message) : 'Failed to copy URLs', 'error');
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function openBulkMoveNamespaceModal() {
|
|
902
|
+
const modal = document.getElementById('bulk-move-namespace-modal');
|
|
903
|
+
const select = document.getElementById('bulk-move-namespace-select');
|
|
904
|
+
const subtitle = document.getElementById('bulk-move-namespace-subtitle');
|
|
905
|
+
if (!modal || !select) return;
|
|
906
|
+
|
|
907
|
+
const count = selectedAssetIds.size;
|
|
908
|
+
if (!count) {
|
|
909
|
+
showToast('No assets selected', 'error');
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (subtitle) subtitle.textContent = `${count} asset(s) selected`;
|
|
914
|
+
|
|
915
|
+
const items = Array.isArray(namespaces) ? namespaces : [];
|
|
916
|
+
select.innerHTML = items
|
|
917
|
+
.filter((n) => n && n.key)
|
|
918
|
+
.map((n) => `<option value="${escapeHtml(String(n.key))}">${escapeHtml(String(n.key))}${n.enabled === false ? ' (disabled)' : ''}</option>`)
|
|
919
|
+
.join('');
|
|
920
|
+
|
|
921
|
+
modal.classList.remove('hidden');
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function closeBulkMoveNamespaceModal() {
|
|
925
|
+
const modal = document.getElementById('bulk-move-namespace-modal');
|
|
926
|
+
if (!modal) return;
|
|
927
|
+
modal.classList.add('hidden');
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function openBulkSetTagsModal() {
|
|
931
|
+
const modal = document.getElementById('bulk-set-tags-modal');
|
|
932
|
+
const subtitle = document.getElementById('bulk-set-tags-subtitle');
|
|
933
|
+
const input = document.getElementById('bulk-tags-input');
|
|
934
|
+
if (!modal || !input) return;
|
|
935
|
+
|
|
936
|
+
const count = selectedAssetIds.size;
|
|
937
|
+
if (!count) {
|
|
938
|
+
showToast('No assets selected', 'error');
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (subtitle) subtitle.textContent = `${count} asset(s) selected`;
|
|
943
|
+
input.value = '';
|
|
944
|
+
modal.classList.remove('hidden');
|
|
945
|
+
try { input.focus(); } catch {}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function closeBulkSetTagsModal() {
|
|
949
|
+
const modal = document.getElementById('bulk-set-tags-modal');
|
|
950
|
+
if (!modal) return;
|
|
951
|
+
modal.classList.add('hidden');
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
async function confirmBulkSetTags() {
|
|
955
|
+
const btn = document.getElementById('bulk-set-tags-confirm');
|
|
956
|
+
const input = document.getElementById('bulk-tags-input');
|
|
957
|
+
try {
|
|
958
|
+
const ids = Array.from(selectedAssetIds);
|
|
959
|
+
if (!ids.length) {
|
|
960
|
+
showToast('No assets selected', 'error');
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const tagsRaw = String(input?.value || '');
|
|
965
|
+
if (!confirm(`Set tags for ${ids.length} asset(s)?`)) return;
|
|
966
|
+
|
|
967
|
+
if (btn) {
|
|
968
|
+
btn.disabled = true;
|
|
969
|
+
btn.textContent = 'Saving...';
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const res = await fetch(`${API_BASE}/api/admin/assets/bulk/set-tags`, {
|
|
973
|
+
method: 'POST',
|
|
974
|
+
headers: { 'Content-Type': 'application/json' },
|
|
975
|
+
body: JSON.stringify({ assetIds: ids, tags: tagsRaw }),
|
|
976
|
+
});
|
|
977
|
+
const data = await res.json().catch(() => ({}));
|
|
978
|
+
if (!res.ok) throw new Error(data?.error || 'Bulk set tags failed');
|
|
979
|
+
|
|
980
|
+
const failedCount = Array.isArray(data?.failed) ? data.failed.length : 0;
|
|
981
|
+
const msg = failedCount
|
|
982
|
+
? `Updated ${data?.updated || 0}, failed ${failedCount}`
|
|
983
|
+
: `Updated ${data?.updated || 0}`;
|
|
984
|
+
|
|
985
|
+
showToast(msg);
|
|
986
|
+
closeBulkSetTagsModal();
|
|
987
|
+
clearBulkSelection();
|
|
988
|
+
await loadAssets();
|
|
989
|
+
} catch (e) {
|
|
990
|
+
showToast(e?.message ? String(e.message) : 'Bulk set tags failed', 'error');
|
|
991
|
+
} finally {
|
|
992
|
+
if (btn) {
|
|
993
|
+
btn.disabled = false;
|
|
994
|
+
btn.textContent = 'Set tags';
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async function confirmBulkMoveNamespace() {
|
|
1000
|
+
const btn = document.getElementById('bulk-move-namespace-confirm');
|
|
1001
|
+
const select = document.getElementById('bulk-move-namespace-select');
|
|
1002
|
+
try {
|
|
1003
|
+
const ids = Array.from(selectedAssetIds);
|
|
1004
|
+
if (!ids.length) {
|
|
1005
|
+
showToast('No assets selected', 'error');
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
const targetNamespace = String(select?.value || '').trim();
|
|
1009
|
+
if (!targetNamespace) {
|
|
1010
|
+
showToast('Select a target namespace', 'error');
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
if (!confirm(`Move ${ids.length} asset(s) to namespace '${targetNamespace}'?`)) return;
|
|
1015
|
+
|
|
1016
|
+
if (btn) {
|
|
1017
|
+
btn.disabled = true;
|
|
1018
|
+
btn.textContent = 'Moving...';
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const res = await fetch(`${API_BASE}/api/admin/assets/bulk/move-namespace`, {
|
|
1022
|
+
method: 'POST',
|
|
1023
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1024
|
+
body: JSON.stringify({ assetIds: ids, targetNamespace }),
|
|
1025
|
+
});
|
|
1026
|
+
const data = await res.json().catch(() => ({}));
|
|
1027
|
+
if (!res.ok) throw new Error(data?.error || 'Bulk move failed');
|
|
1028
|
+
|
|
1029
|
+
const failedCount = Array.isArray(data?.failed) ? data.failed.length : 0;
|
|
1030
|
+
const msg = failedCount
|
|
1031
|
+
? `Moved ${data?.moved || 0}, skipped ${data?.skipped || 0}, failed ${failedCount}`
|
|
1032
|
+
: `Moved ${data?.moved || 0}, skipped ${data?.skipped || 0}`;
|
|
1033
|
+
|
|
1034
|
+
showToast(msg);
|
|
1035
|
+
closeBulkMoveNamespaceModal();
|
|
1036
|
+
clearBulkSelection();
|
|
1037
|
+
await loadAssets();
|
|
1038
|
+
} catch (e) {
|
|
1039
|
+
showToast(e?.message ? String(e.message) : 'Bulk move failed', 'error');
|
|
1040
|
+
} finally {
|
|
1041
|
+
if (btn) {
|
|
1042
|
+
btn.disabled = false;
|
|
1043
|
+
btn.textContent = 'Move';
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
let replacingAssetId = null;
|
|
1049
|
+
|
|
1050
|
+
function setReplaceStatus(text, kind) {
|
|
1051
|
+
const el = document.getElementById('replace-status');
|
|
1052
|
+
if (!el) return;
|
|
1053
|
+
el.className = 'mt-3 text-xs ' + (kind === 'error' ? 'text-red-600' : kind === 'success' ? 'text-green-700' : 'text-gray-600');
|
|
1054
|
+
el.textContent = text || '';
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function openReplaceModal(assetId) {
|
|
1058
|
+
const asset = allAssets.find(a => a._id === assetId);
|
|
1059
|
+
replacingAssetId = assetId;
|
|
1060
|
+
const title = document.getElementById('replace-modal-title');
|
|
1061
|
+
const subtitle = document.getElementById('replace-modal-subtitle');
|
|
1062
|
+
if (title) title.textContent = 'Replace asset';
|
|
1063
|
+
if (subtitle) subtitle.textContent = asset ? `${asset.originalName} (${asset._id})` : String(assetId);
|
|
1064
|
+
const input = document.getElementById('replace-file-input');
|
|
1065
|
+
if (input) input.value = '';
|
|
1066
|
+
setReplaceStatus('', '');
|
|
1067
|
+
document.getElementById('replace-modal').classList.remove('hidden');
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function closeReplaceModal() {
|
|
1071
|
+
replacingAssetId = null;
|
|
1072
|
+
document.getElementById('replace-modal').classList.add('hidden');
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
async function uploadReplacementBlob(file, idx, total) {
|
|
1076
|
+
if (!replacingAssetId) throw new Error('No asset selected');
|
|
1077
|
+
|
|
1078
|
+
const formData = new FormData();
|
|
1079
|
+
formData.append('file', file);
|
|
1080
|
+
|
|
1081
|
+
setReplaceStatus(`Uploading ${idx}/${total}: ${file?.name || 'clipboard'}...`, '');
|
|
1082
|
+
|
|
1083
|
+
const response = await fetch(`${API_BASE}/api/admin/assets/${encodeURIComponent(replacingAssetId)}/replace`, {
|
|
1084
|
+
method: 'POST',
|
|
1085
|
+
body: formData,
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
const data = await response.json().catch(() => ({}));
|
|
1089
|
+
if (!response.ok) {
|
|
1090
|
+
const details = Array.isArray(data?.errors)
|
|
1091
|
+
? data.errors.map((e) => {
|
|
1092
|
+
if (!e) return '';
|
|
1093
|
+
if (e.reason && e.value !== undefined) return `${e.reason} (${e.value})`;
|
|
1094
|
+
return e.reason || 'policy error';
|
|
1095
|
+
}).join('; ')
|
|
1096
|
+
: '';
|
|
1097
|
+
const msg = data?.error || 'Replace failed';
|
|
1098
|
+
throw new Error(details ? `${msg}: ${details}` : msg);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
return data?.asset || null;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
async function replaceFiles(files) {
|
|
1105
|
+
const list = Array.from(files || []).filter(Boolean);
|
|
1106
|
+
if (!list.length) return;
|
|
1107
|
+
|
|
1108
|
+
const btn = document.getElementById('replace-clipboard-btn');
|
|
1109
|
+
if (btn) btn.disabled = true;
|
|
1110
|
+
|
|
1111
|
+
try {
|
|
1112
|
+
for (let i = 0; i < list.length; i++) {
|
|
1113
|
+
await uploadReplacementBlob(list[i], i + 1, list.length);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
setReplaceStatus(`Done. Replaced with ${list.length} file(s).`, 'success');
|
|
1117
|
+
currentPage = 1;
|
|
1118
|
+
await loadAssets();
|
|
1119
|
+
} catch (e) {
|
|
1120
|
+
setReplaceStatus(e?.message ? String(e.message) : 'Replace failed', 'error');
|
|
1121
|
+
showToast(e?.message ? String(e.message) : 'Replace failed', 'error');
|
|
1122
|
+
} finally {
|
|
1123
|
+
if (btn) btn.disabled = false;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
async function replaceFromClipboard() {
|
|
1128
|
+
if (!replacingAssetId) {
|
|
1129
|
+
showToast('Select an asset first', 'error');
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
if (!navigator.clipboard?.read) {
|
|
1134
|
+
showToast('Clipboard file upload not supported in this browser', 'error');
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
try {
|
|
1139
|
+
setReplaceStatus('Reading clipboard...', '');
|
|
1140
|
+
const items = await navigator.clipboard.read();
|
|
1141
|
+
const files = [];
|
|
1142
|
+
|
|
1143
|
+
for (const item of items) {
|
|
1144
|
+
for (const type of item.types) {
|
|
1145
|
+
if (!type) continue;
|
|
1146
|
+
if (type.startsWith('image/') || type === 'application/octet-stream') {
|
|
1147
|
+
const blob = await item.getType(type);
|
|
1148
|
+
const ext = type.startsWith('image/') ? type.split('/')[1] : 'bin';
|
|
1149
|
+
const name = `clipboard-${Date.now()}-${Math.random().toString(16).slice(2)}.${ext}`;
|
|
1150
|
+
files.push(new File([blob], name, { type: blob.type || type }));
|
|
1151
|
+
break;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
if (!files.length) {
|
|
1157
|
+
setReplaceStatus('Clipboard has no files/images to upload.', 'error');
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
await replaceFiles(files);
|
|
1162
|
+
} catch (e) {
|
|
1163
|
+
setReplaceStatus(e?.message ? String(e.message) : 'Clipboard read failed', 'error');
|
|
1164
|
+
showToast(e?.message ? String(e.message) : 'Clipboard read failed', 'error');
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function showPreview(id) {
|
|
1169
|
+
const asset = allAssets.find(a => a._id === id);
|
|
1170
|
+
if (!asset || !asset.publicUrl) return;
|
|
1171
|
+
|
|
1172
|
+
document.getElementById('preview-title').textContent = asset.originalName;
|
|
1173
|
+
|
|
1174
|
+
const url = buildAssetPublicUrl(asset);
|
|
1175
|
+
|
|
1176
|
+
const content = document.getElementById('preview-content');
|
|
1177
|
+
if (asset.contentType?.startsWith('image/')) {
|
|
1178
|
+
content.innerHTML = `<img src="${escapeHtml(url)}" alt="${escapeHtml(asset.originalName)}" class="max-w-full max-h-[60vh]">`;
|
|
1179
|
+
} else if (asset.contentType?.startsWith('video/')) {
|
|
1180
|
+
content.innerHTML = `<video src="${escapeHtml(url)}" controls class="max-w-full max-h-[60vh]"></video>`;
|
|
1181
|
+
} else {
|
|
1182
|
+
content.innerHTML = `<p>Preview not available for this file type.</p>`;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
document.getElementById('preview-modal').classList.remove('hidden');
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function closePreviewModal() {
|
|
1189
|
+
document.getElementById('preview-modal').classList.add('hidden');
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
document.getElementById('filter-visibility').addEventListener('change', () => {
|
|
1193
|
+
currentPage = 1;
|
|
1194
|
+
loadAssets();
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
document.getElementById('filter-namespace').addEventListener('change', () => {
|
|
1198
|
+
currentPage = 1;
|
|
1199
|
+
loadAssets();
|
|
1200
|
+
updateDevSnippet();
|
|
1201
|
+
updateQuickUploadVisibility();
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
document.getElementById('filter-tag').addEventListener('change', () => {
|
|
1205
|
+
currentPage = 1;
|
|
1206
|
+
loadAssets();
|
|
1207
|
+
updateDevSnippet();
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
document.getElementById('filter-content-type').addEventListener('change', () => {
|
|
1211
|
+
currentPage = 1;
|
|
1212
|
+
loadAssets();
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
function setViewMode(mode) {
|
|
1216
|
+
viewMode = mode === 'list' ? 'list' : 'cards';
|
|
1217
|
+
|
|
1218
|
+
try {
|
|
1219
|
+
window.localStorage.setItem(LS_KEYS.viewMode, viewMode);
|
|
1220
|
+
} catch {
|
|
1221
|
+
// ignore
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const btnCards = document.getElementById('view-cards');
|
|
1225
|
+
const btnList = document.getElementById('view-list');
|
|
1226
|
+
if (btnCards && btnList) {
|
|
1227
|
+
if (viewMode === 'cards') {
|
|
1228
|
+
btnCards.className = 'px-3 py-2 text-sm rounded border bg-blue-600 text-white hover:bg-blue-700';
|
|
1229
|
+
btnList.className = 'px-3 py-2 text-sm rounded border bg-white hover:bg-gray-50';
|
|
1230
|
+
} else {
|
|
1231
|
+
btnCards.className = 'px-3 py-2 text-sm rounded border bg-white hover:bg-gray-50';
|
|
1232
|
+
btnList.className = 'px-3 py-2 text-sm rounded border bg-blue-600 text-white hover:bg-blue-700';
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
renderAssets();
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function toggleStorageInfo() {
|
|
1239
|
+
const body = document.getElementById('storage-info-body');
|
|
1240
|
+
const btn = document.getElementById('storage-info-toggle');
|
|
1241
|
+
if (!body || !btn) return;
|
|
1242
|
+
|
|
1243
|
+
const isHidden = body.classList.contains('hidden');
|
|
1244
|
+
if (isHidden) {
|
|
1245
|
+
body.classList.remove('hidden');
|
|
1246
|
+
btn.textContent = 'Hide';
|
|
1247
|
+
} else {
|
|
1248
|
+
body.classList.add('hidden');
|
|
1249
|
+
btn.textContent = 'Show';
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function toggleDevHelpers() {
|
|
1254
|
+
const body = document.getElementById('dev-helpers-body');
|
|
1255
|
+
const btn = document.getElementById('dev-helpers-toggle');
|
|
1256
|
+
if (!body || !btn) return;
|
|
1257
|
+
|
|
1258
|
+
const isHidden = body.classList.contains('hidden');
|
|
1259
|
+
if (isHidden) {
|
|
1260
|
+
body.classList.remove('hidden');
|
|
1261
|
+
btn.textContent = 'Hide';
|
|
1262
|
+
} else {
|
|
1263
|
+
body.classList.add('hidden');
|
|
1264
|
+
btn.textContent = 'Show';
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function normalizeTagsInput(value) {
|
|
1269
|
+
const raw = String(value || '')
|
|
1270
|
+
.split(',')
|
|
1271
|
+
.map(s => s.trim().toLowerCase())
|
|
1272
|
+
.filter(Boolean);
|
|
1273
|
+
return Array.from(new Set(raw));
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function openTagsModal(assetId) {
|
|
1277
|
+
const asset = allAssets.find(a => a._id === assetId);
|
|
1278
|
+
tagsEditingAssetId = assetId;
|
|
1279
|
+
|
|
1280
|
+
const title = document.getElementById('tags-modal-title');
|
|
1281
|
+
if (title) {
|
|
1282
|
+
title.textContent = asset ? `Edit Tags: ${asset.originalName}` : 'Edit Tags';
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const input = document.getElementById('tags-input');
|
|
1286
|
+
const tags = Array.isArray(asset?.tags) ? asset.tags : [];
|
|
1287
|
+
if (input) input.value = tags.join(', ');
|
|
1288
|
+
|
|
1289
|
+
document.getElementById('tags-modal').classList.remove('hidden');
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function closeTagsModal() {
|
|
1293
|
+
tagsEditingAssetId = null;
|
|
1294
|
+
document.getElementById('tags-modal').classList.add('hidden');
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
async function saveTags() {
|
|
1298
|
+
if (!tagsEditingAssetId) return;
|
|
1299
|
+
const input = document.getElementById('tags-input');
|
|
1300
|
+
const btn = document.getElementById('tags-save-btn');
|
|
1301
|
+
const tags = normalizeTagsInput(input?.value);
|
|
1302
|
+
|
|
1303
|
+
try {
|
|
1304
|
+
btn.disabled = true;
|
|
1305
|
+
btn.textContent = 'Saving...';
|
|
1306
|
+
|
|
1307
|
+
const response = await fetch(`${API_BASE}/api/admin/assets/${tagsEditingAssetId}`, {
|
|
1308
|
+
method: 'PATCH',
|
|
1309
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1310
|
+
body: JSON.stringify({ tags }),
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
const data = await response.json().catch(() => ({}));
|
|
1314
|
+
if (!response.ok) throw new Error(data?.error || 'Failed to update tags');
|
|
1315
|
+
|
|
1316
|
+
showToast('Tags updated');
|
|
1317
|
+
closeTagsModal();
|
|
1318
|
+
await loadAssets();
|
|
1319
|
+
} catch (error) {
|
|
1320
|
+
showToast(error.message, 'error');
|
|
1321
|
+
} finally {
|
|
1322
|
+
btn.disabled = false;
|
|
1323
|
+
btn.textContent = 'Save';
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
async function loadNamespaces() {
|
|
1328
|
+
try {
|
|
1329
|
+
const response = await fetch(`${API_BASE}/api/admin/upload-namespaces`);
|
|
1330
|
+
const data = await response.json();
|
|
1331
|
+
if (!response.ok) throw new Error(data?.error || 'Failed to load namespaces');
|
|
1332
|
+
|
|
1333
|
+
namespaces = Array.isArray(data) ? data : [];
|
|
1334
|
+
if (!namespaces.find(n => n.key === 'default')) {
|
|
1335
|
+
namespaces = [{
|
|
1336
|
+
key: 'default',
|
|
1337
|
+
enabled: true,
|
|
1338
|
+
maxFileSizeBytes: undefined,
|
|
1339
|
+
allowedContentTypes: undefined,
|
|
1340
|
+
keyPrefix: undefined,
|
|
1341
|
+
defaultVisibility: 'private',
|
|
1342
|
+
enforceVisibility: false,
|
|
1343
|
+
}, ...namespaces];
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const select = document.getElementById('filter-namespace');
|
|
1347
|
+
const currentValue = select.value;
|
|
1348
|
+
|
|
1349
|
+
select.innerHTML = '<option value="">All namespaces</option>' + namespaces.map(n =>
|
|
1350
|
+
`<option value="${escapeHtml(n.key)}">${escapeHtml(n.key)}</option>`
|
|
1351
|
+
).join('');
|
|
1352
|
+
|
|
1353
|
+
if ([...select.options].some(o => o.value === currentValue)) {
|
|
1354
|
+
select.value = currentValue;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
const uploadNs = document.getElementById('upload-namespace');
|
|
1358
|
+
const currentUploadValue = uploadNs.value;
|
|
1359
|
+
uploadNs.innerHTML = namespaces.map(n =>
|
|
1360
|
+
`<option value="${escapeHtml(n.key)}">${escapeHtml(n.key)}</option>`
|
|
1361
|
+
).join('');
|
|
1362
|
+
|
|
1363
|
+
if ([...uploadNs.options].some(o => o.value === currentUploadValue)) {
|
|
1364
|
+
uploadNs.value = currentUploadValue;
|
|
1365
|
+
} else {
|
|
1366
|
+
uploadNs.value = 'default';
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
updateUploadNamespaceHint();
|
|
1370
|
+
} catch (error) {
|
|
1371
|
+
showToast(error.message, 'error');
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
async function loadNamespacesSummary() {
|
|
1376
|
+
try {
|
|
1377
|
+
const response = await fetch(`${API_BASE}/api/admin/upload-namespaces/summary`);
|
|
1378
|
+
const data = await response.json();
|
|
1379
|
+
if (!response.ok) throw new Error(data?.error || 'Failed to load namespaces summary');
|
|
1380
|
+
renderNamespacesSummaryCards(data);
|
|
1381
|
+
} catch (error) {
|
|
1382
|
+
document.getElementById('namespaces-summary-content').innerHTML = `<div class="text-red-600">Error: ${error.message}</div>`;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
async function loadAssets() {
|
|
1387
|
+
try {
|
|
1388
|
+
const namespace = document.getElementById('filter-namespace').value;
|
|
1389
|
+
const tag = String(document.getElementById('filter-tag').value || '').trim().toLowerCase();
|
|
1390
|
+
const visibility = document.getElementById('filter-visibility').value;
|
|
1391
|
+
const contentType = document.getElementById('filter-content-type').value;
|
|
1392
|
+
|
|
1393
|
+
let url = `${API_BASE}/api/admin/assets?page=${currentPage}&limit=12`;
|
|
1394
|
+
if (namespace) url += `&namespace=${encodeURIComponent(namespace)}`;
|
|
1395
|
+
if (tag) url += `&tag=${encodeURIComponent(tag)}`;
|
|
1396
|
+
if (visibility) url += `&visibility=${visibility}`;
|
|
1397
|
+
if (contentType) url += `&contentType=${contentType}`;
|
|
1398
|
+
|
|
1399
|
+
const response = await fetch(url);
|
|
1400
|
+
const data = await response.json();
|
|
1401
|
+
|
|
1402
|
+
if (!response.ok) throw new Error(data?.error || 'Failed to load assets');
|
|
1403
|
+
|
|
1404
|
+
allAssets = Array.isArray(data.assets) ? data.assets : [];
|
|
1405
|
+
totalPages = data.pagination?.pages || 1;
|
|
1406
|
+
|
|
1407
|
+
if (selectedAssetId && !allAssets.find(a => a._id === selectedAssetId)) {
|
|
1408
|
+
selectedAssetId = null;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
updateDevSnippet();
|
|
1412
|
+
renderAssets();
|
|
1413
|
+
renderPagination();
|
|
1414
|
+
} catch (error) {
|
|
1415
|
+
showToast(error.message, 'error');
|
|
1416
|
+
document.getElementById('assets-container').innerHTML = `
|
|
1417
|
+
<div class="text-center py-12 col-span-full text-red-600">
|
|
1418
|
+
<p>Error loading assets: ${escapeHtml(error.message)}</p>
|
|
1419
|
+
</div>
|
|
1420
|
+
`;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function escapeSnippetString(str) {
|
|
1425
|
+
return String(str ?? '').replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function updateDevSnippet() {
|
|
1429
|
+
const namespaceRaw = document.getElementById('filter-namespace')?.value || '';
|
|
1430
|
+
const namespace = String(namespaceRaw).trim();
|
|
1431
|
+
const tag = String(document.getElementById('filter-tag')?.value || '').trim().toLowerCase();
|
|
1432
|
+
|
|
1433
|
+
const selected = selectedAssetId ? allAssets.find(a => a._id === selectedAssetId) : null;
|
|
1434
|
+
const assetId = selected?._id || '<assetId>';
|
|
1435
|
+
const assetKey = selected?.key || '<assetKey>';
|
|
1436
|
+
const publicUrl = selected?.publicUrl ? `${API_BASE}${selected.publicUrl}` : null;
|
|
1437
|
+
|
|
1438
|
+
const snippet =
|
|
1439
|
+
`const saasbackend = require('saasbackend');
|
|
1440
|
+
|
|
1441
|
+
// Works when your host app mounts saasbackend.middleware(...) and shares the same process/DB connection
|
|
1442
|
+
const { assets } = saasbackend.services;
|
|
1443
|
+
|
|
1444
|
+
// 1) List assets (metadata)
|
|
1445
|
+
const { assets: list } = await assets.listAssets({
|
|
1446
|
+
namespace: ${namespace ? `'${escapeSnippetString(namespace)}'` : 'undefined'},
|
|
1447
|
+
tag: ${tag ? `'${escapeSnippetString(tag)}'` : 'undefined'},
|
|
1448
|
+
page: 1,
|
|
1449
|
+
limit: 50,
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
// 2) Get one asset (metadata)
|
|
1453
|
+
const asset = await assets.getAssetById('${escapeSnippetString(assetId)}');
|
|
1454
|
+
|
|
1455
|
+
// 3) Get raw bytes (Buffer) for an asset
|
|
1456
|
+
const { body, contentType } = await assets.getAssetBytesById('${escapeSnippetString(assetId)}');
|
|
1457
|
+
|
|
1458
|
+
// 4) Or fetch by storage key
|
|
1459
|
+
const { body: body2 } = await assets.getAssetBytesByKey('${escapeSnippetString(assetKey)}');
|
|
1460
|
+
|
|
1461
|
+
${publicUrl ? `// Public URL (works only when asset.visibility === 'public')
|
|
1462
|
+
// ${escapeSnippetString(publicUrl)}
|
|
1463
|
+
|
|
1464
|
+
` : ''}`;
|
|
1465
|
+
|
|
1466
|
+
const el = document.getElementById('dev-snippet');
|
|
1467
|
+
if (el) el.textContent = snippet;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
async function copyDevSnippet() {
|
|
1471
|
+
try {
|
|
1472
|
+
const el = document.getElementById('dev-snippet');
|
|
1473
|
+
await navigator.clipboard.writeText(el?.textContent || '');
|
|
1474
|
+
showToast('Copied');
|
|
1475
|
+
} catch {
|
|
1476
|
+
showToast('Copy failed', 'error');
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
function renderAssets() {
|
|
1481
|
+
const container = document.getElementById('assets-container');
|
|
1482
|
+
|
|
1483
|
+
renderBulkActionsToolbar();
|
|
1484
|
+
|
|
1485
|
+
if (viewMode === 'list') {
|
|
1486
|
+
container.className = 'bg-white rounded-lg shadow overflow-x-auto';
|
|
1487
|
+
} else {
|
|
1488
|
+
container.className = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6';
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
if (!allAssets || allAssets.length === 0) {
|
|
1492
|
+
container.innerHTML = `
|
|
1493
|
+
<div class="text-center py-12 col-span-full">
|
|
1494
|
+
<p class="text-gray-600">No assets found</p>
|
|
1495
|
+
</div>
|
|
1496
|
+
`;
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
if (viewMode === 'list') {
|
|
1501
|
+
container.innerHTML = `
|
|
1502
|
+
<table class="min-w-full divide-y divide-gray-200">
|
|
1503
|
+
<thead class="bg-gray-50">
|
|
1504
|
+
<tr>
|
|
1505
|
+
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
|
1506
|
+
<input type="checkbox" onclick="event.stopPropagation(); toggleSelectAllVisible(this.checked)" ${allAssets.length && allAssets.every(a => isAssetSelected(a._id)) ? 'checked' : ''}>
|
|
1507
|
+
</th>
|
|
1508
|
+
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
|
1509
|
+
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
|
1510
|
+
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Namespace</th>
|
|
1511
|
+
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
|
1512
|
+
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Size</th>
|
|
1513
|
+
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Visibility</th>
|
|
1514
|
+
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Storage</th>
|
|
1515
|
+
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Tags</th>
|
|
1516
|
+
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Updated</th>
|
|
1517
|
+
<th class="px-4 py-2"></th>
|
|
1518
|
+
</tr>
|
|
1519
|
+
</thead>
|
|
1520
|
+
<tbody class="bg-white divide-y divide-gray-200">
|
|
1521
|
+
${allAssets.map((asset) => {
|
|
1522
|
+
const tags = Array.isArray(asset.tags) ? asset.tags : [];
|
|
1523
|
+
const tagsText = tags.length ? tags.join(', ') : '';
|
|
1524
|
+
|
|
1525
|
+
const storageExists = asset.storageExists;
|
|
1526
|
+
const storageBadge = storageExists === false
|
|
1527
|
+
? '<span class="px-2 py-1 text-xs rounded bg-red-100 text-red-700" title="DB record exists but blob is missing">missing</span>'
|
|
1528
|
+
: storageExists === true
|
|
1529
|
+
? '<span class="px-2 py-1 text-xs rounded bg-gray-100 text-gray-600">ok</span>'
|
|
1530
|
+
: '<span class="px-2 py-1 text-xs rounded bg-gray-100 text-gray-600">unknown</span>';
|
|
1531
|
+
|
|
1532
|
+
const fullId = String(asset._id || '');
|
|
1533
|
+
const shortId = fullId ? fullId.slice(-4) : '';
|
|
1534
|
+
const updatedAt = asset.updatedAt || asset.createdAt;
|
|
1535
|
+
const updatedAtText = formatDateTime(updatedAt);
|
|
1536
|
+
const checked = isAssetSelected(asset._id);
|
|
1537
|
+
|
|
1538
|
+
return `
|
|
1539
|
+
<tr class="hover:bg-gray-50 cursor-pointer" onclick="selectAsset('${escapeJs(asset._id)}')" ondblclick="event.stopPropagation(); showPreview('${escapeJs(asset._id)}')">
|
|
1540
|
+
<td class="px-4 py-2 text-sm" onclick="event.stopPropagation();">
|
|
1541
|
+
<input type="checkbox" ${checked ? 'checked' : ''} onclick="event.stopPropagation(); toggleAssetSelection('${escapeJs(asset._id)}', this.checked)">
|
|
1542
|
+
</td>
|
|
1543
|
+
<td class="px-4 py-2 text-sm font-mono whitespace-nowrap">
|
|
1544
|
+
<span title="${escapeHtml(fullId)}">…${escapeHtml(shortId)}</span>
|
|
1545
|
+
<button onclick="event.stopPropagation(); copyAssetId('${escapeJs(fullId)}')" class="ml-2 text-blue-600 hover:text-blue-800 text-xs" title="Copy full asset id">copy</button>
|
|
1546
|
+
</td>
|
|
1547
|
+
<td class="px-4 py-2 text-sm max-w-xs truncate" title="${escapeHtml(asset.originalName)}">${escapeHtml(asset.originalName)}</td>
|
|
1548
|
+
<td class="px-4 py-2 text-sm font-mono">${escapeHtml(asset.namespace || 'default')}</td>
|
|
1549
|
+
<td class="px-4 py-2 text-sm">${escapeHtml(asset.contentType || '')}</td>
|
|
1550
|
+
<td class="px-4 py-2 text-sm">${formatBytes(asset.sizeBytes || 0)}</td>
|
|
1551
|
+
<td class="px-4 py-2 text-sm">
|
|
1552
|
+
<span class="px-2 py-1 text-xs rounded ${asset.visibility === 'public' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}">${asset.visibility}</span>
|
|
1553
|
+
</td>
|
|
1554
|
+
<td class="px-4 py-2 text-sm">${storageBadge}</td>
|
|
1555
|
+
<td class="px-4 py-2 text-sm">${escapeHtml(tagsText)}</td>
|
|
1556
|
+
<td class="px-4 py-2 text-sm whitespace-nowrap" title="${escapeHtml(String(updatedAt || ''))}">${escapeHtml(updatedAtText)}</td>
|
|
1557
|
+
<td class="px-4 py-2 text-right">
|
|
1558
|
+
<div class="flex justify-end gap-2">
|
|
1559
|
+
<button onclick="event.stopPropagation(); openReplaceModal('${escapeJs(asset._id)}')" class="text-indigo-600 hover:text-indigo-800" title="Replace asset">
|
|
1560
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1561
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
1562
|
+
</svg>
|
|
1563
|
+
</button>
|
|
1564
|
+
<button onclick="event.stopPropagation(); openTagsModal('${escapeJs(asset._id)}')" class="text-gray-700 hover:text-gray-900" title="Edit tags">
|
|
1565
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1566
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M3 11l6.586-6.586a2 2 0 012.828 0L21 13a2 2 0 010 2.828l-6.586 6.586a2 2 0 01-2.828 0L3 13v-2z"></path>
|
|
1567
|
+
</svg>
|
|
1568
|
+
</button>
|
|
1569
|
+
${asset.visibility === 'public' && asset.publicUrl ? `
|
|
1570
|
+
<button onclick="event.stopPropagation(); copyUrl('${escapeJs(API_BASE + asset.publicUrl)}')" class="text-blue-600 hover:text-blue-800" title="Copy URL">
|
|
1571
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1572
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"></path>
|
|
1573
|
+
</svg>
|
|
1574
|
+
</button>
|
|
1575
|
+
` : ''}
|
|
1576
|
+
<button onclick="event.stopPropagation(); toggleVisibility('${escapeJs(asset._id)}', '${asset.visibility}')" class="text-yellow-600 hover:text-yellow-800" title="Toggle visibility">
|
|
1577
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1578
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
|
1579
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
|
1580
|
+
</svg>
|
|
1581
|
+
</button>
|
|
1582
|
+
<button onclick="event.stopPropagation(); deleteAsset('${escapeJs(asset._id)}')" class="text-red-600 hover:text-red-800" title="Delete">
|
|
1583
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1584
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
|
1585
|
+
</svg>
|
|
1586
|
+
</button>
|
|
1587
|
+
</div>
|
|
1588
|
+
</td>
|
|
1589
|
+
</tr>
|
|
1590
|
+
`;
|
|
1591
|
+
}).join('')
|
|
1592
|
+
}
|
|
1593
|
+
</tbody>
|
|
1594
|
+
</table>
|
|
1595
|
+
`;
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
container.innerHTML = allAssets.map((asset) => {
|
|
1600
|
+
const isImage = asset.contentType?.startsWith('image/');
|
|
1601
|
+
const isVideo = asset.contentType?.startsWith('video/');
|
|
1602
|
+
const isPdf = asset.contentType === 'application/pdf';
|
|
1603
|
+
const tags = Array.isArray(asset.tags) ? asset.tags : [];
|
|
1604
|
+
const tagsText = tags.length ? tags.join(', ') : '';
|
|
1605
|
+
|
|
1606
|
+
const storageExists = asset.storageExists;
|
|
1607
|
+
const storageBadge = storageExists === false
|
|
1608
|
+
? '<span class="px-2 py-1 text-xs rounded bg-red-100 text-red-700" title="DB record exists but blob is missing">missing</span>'
|
|
1609
|
+
: '';
|
|
1610
|
+
|
|
1611
|
+
let thumbnail = '';
|
|
1612
|
+
if (isImage && asset.publicUrl) {
|
|
1613
|
+
const url = buildAssetPublicUrl(asset);
|
|
1614
|
+
thumbnail = `<img src="${escapeHtml(url)}" alt="${escapeHtml(asset.originalName)}" class="w-full h-32 object-cover rounded cursor-pointer" onclick="event.stopPropagation(); showPreview('${escapeJs(asset._id)}')">`;
|
|
1615
|
+
} else if (isImage) {
|
|
1616
|
+
thumbnail = `<div class="w-full h-32 bg-gray-200 rounded flex items-center justify-center text-gray-500">🖼️ Image (private)</div>`;
|
|
1617
|
+
} else if (isVideo) {
|
|
1618
|
+
thumbnail = `<div class="w-full h-32 bg-gray-200 rounded flex items-center justify-center text-gray-500">🎬 Video</div>`;
|
|
1619
|
+
} else if (isPdf) {
|
|
1620
|
+
thumbnail = `<div class="w-full h-32 bg-gray-200 rounded flex items-center justify-center text-gray-500">📄 PDF</div>`;
|
|
1621
|
+
} else {
|
|
1622
|
+
thumbnail = `<div class="w-full h-32 bg-gray-200 rounded flex items-center justify-center text-gray-500">📁 File</div>`;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
return `
|
|
1626
|
+
<div class="bg-white rounded-lg shadow p-4" onclick="selectAsset('${escapeJs(asset._id)}')">
|
|
1627
|
+
${thumbnail}
|
|
1628
|
+
<div class="mt-3">
|
|
1629
|
+
<h4 class="font-medium text-gray-900 truncate" title="${escapeHtml(asset.originalName)}">${escapeHtml(asset.originalName)}</h4>
|
|
1630
|
+
<p class="text-xs text-gray-500 mt-1">${formatBytes(asset.sizeBytes)} • ${asset.contentType}</p>
|
|
1631
|
+
${tagsText ? `<p class="text-xs text-gray-500 mt-1">Tags: ${escapeHtml(tagsText)}</p>` : ''}
|
|
1632
|
+
${storageBadge ? `<p class="text-xs text-red-700 mt-1">Storage: ${storageBadge}</p>` : ''}
|
|
1633
|
+
<div class="flex items-center justify-between mt-2">
|
|
1634
|
+
<span class="px-2 py-1 text-xs rounded ${asset.visibility === 'public' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}">${asset.visibility}</span>
|
|
1635
|
+
<div class="flex gap-2">
|
|
1636
|
+
<button onclick="event.stopPropagation(); openReplaceModal('${escapeJs(asset._id)}')" class="text-indigo-600 hover:text-indigo-800" title="Replace asset">
|
|
1637
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1638
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
1639
|
+
</svg>
|
|
1640
|
+
</button>
|
|
1641
|
+
<button onclick="event.stopPropagation(); openTagsModal('${escapeJs(asset._id)}')" class="text-gray-700 hover:text-gray-900" title="Edit tags">
|
|
1642
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1643
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M3 11l6.586-6.586a2 2 0 012.828 0L21 13a2 2 0 010 2.828l-6.586 6.586a2 2 0 01-2.828 0L3 13v-2z"></path>
|
|
1644
|
+
</svg>
|
|
1645
|
+
</button>
|
|
1646
|
+
${asset.visibility === 'public' && asset.publicUrl ? `
|
|
1647
|
+
<button onclick="event.stopPropagation(); copyUrl('${escapeJs(API_BASE + asset.publicUrl)}')" class="text-blue-600 hover:text-blue-800" title="Copy URL">
|
|
1648
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1649
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"></path>
|
|
1650
|
+
</svg>
|
|
1651
|
+
</button>
|
|
1652
|
+
` : ''}
|
|
1653
|
+
<button onclick="event.stopPropagation(); toggleVisibility('${escapeJs(asset._id)}', '${asset.visibility}')" class="text-yellow-600 hover:text-yellow-800" title="Toggle visibility">
|
|
1654
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1655
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
|
1656
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
|
1657
|
+
</svg>
|
|
1658
|
+
</button>
|
|
1659
|
+
<button onclick="event.stopPropagation(); deleteAsset('${escapeJs(asset._id)}')" class="text-red-600 hover:text-red-800" title="Delete">
|
|
1660
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1661
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
|
1662
|
+
</svg>
|
|
1663
|
+
</button>
|
|
1664
|
+
</div>
|
|
1665
|
+
</div>
|
|
1666
|
+
</div>
|
|
1667
|
+
</div>
|
|
1668
|
+
`;
|
|
1669
|
+
}).join('');
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
function renderPagination() {
|
|
1673
|
+
const container = document.getElementById('pagination');
|
|
1674
|
+
|
|
1675
|
+
if (totalPages <= 1) {
|
|
1676
|
+
container.innerHTML = '';
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
let html = '';
|
|
1681
|
+
if (currentPage > 1) {
|
|
1682
|
+
html += `<button onclick="goToPage(${currentPage - 1})" class="px-3 py-1 bg-gray-200 rounded hover:bg-gray-300">Prev</button>`;
|
|
1683
|
+
}
|
|
1684
|
+
html += `<span class="px-3 py-1">Page ${currentPage} of ${totalPages}</span>`;
|
|
1685
|
+
if (currentPage < totalPages) {
|
|
1686
|
+
html += `<button onclick="goToPage(${currentPage + 1})" class="px-3 py-1 bg-gray-200 rounded hover:bg-gray-300">Next</button>`;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
container.innerHTML = html;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
function goToPage(page) {
|
|
1693
|
+
currentPage = page;
|
|
1694
|
+
clearBulkSelection();
|
|
1695
|
+
loadAssets();
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
function selectAsset(id) {
|
|
1699
|
+
selectedAssetId = id;
|
|
1700
|
+
updateDevSnippet();
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
function setUploadNamespace(key) {
|
|
1704
|
+
const select = document.getElementById('upload-namespace');
|
|
1705
|
+
if (!select) return;
|
|
1706
|
+
select.value = key;
|
|
1707
|
+
updateUploadNamespaceHint();
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
function computeKeyPrefixHint(ns) {
|
|
1711
|
+
if (!ns || ns.key === 'default') return '';
|
|
1712
|
+
const explicit = (ns.keyPrefix || '').trim();
|
|
1713
|
+
if (explicit) return explicit;
|
|
1714
|
+
return `assets/${ns.key}`;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
function updateUploadNamespaceHint() {
|
|
1718
|
+
const select = document.getElementById('upload-namespace');
|
|
1719
|
+
const hint = document.getElementById('upload-namespace-hint');
|
|
1720
|
+
if (!select || !hint) return;
|
|
1721
|
+
const key = select.value || 'default';
|
|
1722
|
+
const ns = namespaces.find(n => n.key === key);
|
|
1723
|
+
if (!ns) {
|
|
1724
|
+
hint.textContent = '';
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
const prefix = computeKeyPrefixHint(ns);
|
|
1728
|
+
hint.textContent = prefix ? `keyPrefix: ${prefix}` : '';
|
|
1729
|
+
|
|
1730
|
+
const visHint = document.getElementById('upload-visibility-hint');
|
|
1731
|
+
if (visHint) {
|
|
1732
|
+
if (ns.enforceVisibility) {
|
|
1733
|
+
visHint.textContent = `Visibility is enforced by namespace (default: ${ns.defaultVisibility})`;
|
|
1734
|
+
} else {
|
|
1735
|
+
visHint.textContent = '';
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
async function showNamespacesModal() {
|
|
1741
|
+
document.getElementById('namespaces-modal').classList.remove('hidden');
|
|
1742
|
+
await loadNamespaces();
|
|
1743
|
+
renderNamespacesTable();
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
function closeNamespacesModal() {
|
|
1747
|
+
document.getElementById('namespaces-modal').classList.add('hidden');
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
function renderNamespacesTable() {
|
|
1751
|
+
const tbody = document.getElementById('namespaces-table-body') || document.getElementById('namespaces-table');
|
|
1752
|
+
if (!tbody) return;
|
|
1753
|
+
|
|
1754
|
+
tbody.innerHTML = namespaces.map((ns) => {
|
|
1755
|
+
const maxSizeText = ns.maxFileSizeBytes ? formatBytes(ns.maxFileSizeBytes) : '(hard cap)';
|
|
1756
|
+
const types = Array.isArray(ns.allowedContentTypes) ? ns.allowedContentTypes : [];
|
|
1757
|
+
const typesText = types.length ? `${types.length} types` : 'default types';
|
|
1758
|
+
const keyPrefixText = ns.keyPrefix ? escapeHtml(ns.keyPrefix) : `<span class="text-gray-400">(auto: ${escapeHtml(computeKeyPrefixHint(ns) || '')})</span>`;
|
|
1759
|
+
|
|
1760
|
+
return `
|
|
1761
|
+
<tr class="hover:bg-gray-50">
|
|
1762
|
+
<td class="px-4 py-2 text-sm font-mono">${escapeHtml(ns.key)}</td>
|
|
1763
|
+
<td class="px-4 py-2 text-sm">${ns.enabled ? 'yes' : 'no'}</td>
|
|
1764
|
+
<td class="px-4 py-2 text-sm">${escapeHtml(maxSizeText)}</td>
|
|
1765
|
+
<td class="px-4 py-2 text-sm">${escapeHtml(typesText)}</td>
|
|
1766
|
+
<td class="px-4 py-2 text-sm">${keyPrefixText}</td>
|
|
1767
|
+
<td class="px-4 py-2 text-sm">${escapeHtml(ns.defaultVisibility || 'private')}</td>
|
|
1768
|
+
<td class="px-4 py-2 text-sm">${ns.enforceVisibility ? 'true' : 'false'}</td>
|
|
1769
|
+
<td class="px-4 py-2 text-right">
|
|
1770
|
+
<div class="flex justify-end gap-2">
|
|
1771
|
+
<button onclick="openNamespaceEditor('${escapeJs(ns.key)}')" class="px-2 py-1 text-xs bg-gray-200 rounded hover:bg-gray-300">Edit</button>
|
|
1772
|
+
${ns.key !== 'default' ? `<button onclick="deleteNamespace('${escapeJs(ns.key)}')" class="px-2 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700">Delete</button>` : ''}
|
|
1773
|
+
</div>
|
|
1774
|
+
</td>
|
|
1775
|
+
</tr>
|
|
1776
|
+
`;
|
|
1777
|
+
}).join('');
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
function renderNamespacesSummaryCards(data) {
|
|
1781
|
+
const container = document.getElementById('namespaces-summary-content');
|
|
1782
|
+
if (!container) return;
|
|
1783
|
+
|
|
1784
|
+
const items = Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : [];
|
|
1785
|
+
if (!items.length) {
|
|
1786
|
+
container.innerHTML = '<div class="text-sm text-gray-600">No namespaces.</div>';
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
container.innerHTML = `
|
|
1791
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
1792
|
+
${items.map((ns) => {
|
|
1793
|
+
const types = Array.isArray(ns.allowedContentTypes) ? ns.allowedContentTypes : [];
|
|
1794
|
+
const typesText = types.length ? `${types.length} types` : 'default types';
|
|
1795
|
+
const maxSizeText = ns.maxFileSizeBytes ? formatBytes(ns.maxFileSizeBytes) : '(hard cap)';
|
|
1796
|
+
const keyPrefixText = ns.keyPrefix ? escapeHtml(ns.keyPrefix) : `<span class="text-gray-400">(auto: ${escapeHtml(computeKeyPrefixHint(ns) || '')})</span>`;
|
|
1797
|
+
|
|
1798
|
+
const stats = ns.stats || {};
|
|
1799
|
+
const count = Number(stats.totalFiles ?? stats.count ?? 0);
|
|
1800
|
+
const totalBytes = Number(stats.totalBytes || 0);
|
|
1801
|
+
|
|
1802
|
+
return `
|
|
1803
|
+
<div class="bg-white rounded-lg shadow p-4 cursor-pointer hover:bg-gray-50" onclick="filterByNamespace('${escapeJs(ns.key)}')">
|
|
1804
|
+
<div class="flex items-center justify-between">
|
|
1805
|
+
<div class="font-mono text-sm text-gray-900">${escapeHtml(ns.key)}</div>
|
|
1806
|
+
<div class="text-xs ${ns.enabled ? 'text-green-700' : 'text-gray-500'}">${ns.enabled ? 'enabled' : 'disabled'}</div>
|
|
1807
|
+
</div>
|
|
1808
|
+
<div class="mt-2 text-sm text-gray-700">
|
|
1809
|
+
<div><strong>Assets:</strong> ${count}</div>
|
|
1810
|
+
<div><strong>Total:</strong> ${formatBytes(totalBytes)}</div>
|
|
1811
|
+
</div>
|
|
1812
|
+
<div class="mt-2 text-xs text-gray-600">
|
|
1813
|
+
<div><strong>Max:</strong> ${escapeHtml(maxSizeText)}</div>
|
|
1814
|
+
<div><strong>Types:</strong> ${escapeHtml(typesText)}</div>
|
|
1815
|
+
<div><strong>keyPrefix:</strong> ${keyPrefixText}</div>
|
|
1816
|
+
</div>
|
|
1817
|
+
</div>
|
|
1818
|
+
`;
|
|
1819
|
+
}).join('')}
|
|
1820
|
+
</div>
|
|
1821
|
+
`;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function filterByNamespace(key) {
|
|
1825
|
+
const select = document.getElementById('filter-namespace');
|
|
1826
|
+
if (!select) return;
|
|
1827
|
+
select.value = key;
|
|
1828
|
+
currentPage = 1;
|
|
1829
|
+
loadAssets();
|
|
1830
|
+
updateDevSnippet();
|
|
1831
|
+
updateQuickUploadVisibility();
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
function openNamespaceEditor(key) {
|
|
1835
|
+
const createMode = key === 'create' || !key;
|
|
1836
|
+
namespaceEditorMode = createMode ? 'create' : 'edit';
|
|
1837
|
+
namespaceEditorOriginalKey = createMode ? null : key;
|
|
1838
|
+
|
|
1839
|
+
const ns = createMode ? null : namespaces.find(n => n.key === key);
|
|
1840
|
+
|
|
1841
|
+
document.getElementById('namespace-editor-title').textContent = createMode ? 'Create namespace' : `Edit namespace: ${key}`;
|
|
1842
|
+
document.getElementById('ns-key').value = ns?.key || '';
|
|
1843
|
+
document.getElementById('ns-key').readOnly = !createMode;
|
|
1844
|
+
document.getElementById('ns-enabled').value = String(ns?.enabled !== false);
|
|
1845
|
+
document.getElementById('ns-max').value = ns?.maxFileSizeBytes ? String(ns.maxFileSizeBytes) : '';
|
|
1846
|
+
document.getElementById('ns-types').value = Array.isArray(ns?.allowedContentTypes) ? ns.allowedContentTypes.join(',') : '';
|
|
1847
|
+
document.getElementById('ns-prefix').value = ns?.keyPrefix || '';
|
|
1848
|
+
document.getElementById('ns-default-vis').value = ns?.defaultVisibility === 'public' ? 'public' : 'private';
|
|
1849
|
+
document.getElementById('ns-enforce-vis').value = String(Boolean(ns?.enforceVisibility));
|
|
1850
|
+
|
|
1851
|
+
document.getElementById('namespace-editor-modal').classList.remove('hidden');
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function closeNamespaceEditor() {
|
|
1855
|
+
document.getElementById('namespace-editor-modal').classList.add('hidden');
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
async function saveNamespace(event) {
|
|
1859
|
+
event.preventDefault();
|
|
1860
|
+
|
|
1861
|
+
const key = document.getElementById('ns-key').value.trim();
|
|
1862
|
+
if (!key) {
|
|
1863
|
+
showToast('Namespace key is required', 'error');
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
const payload = {
|
|
1868
|
+
key,
|
|
1869
|
+
enabled: document.getElementById('ns-enabled').value === 'true',
|
|
1870
|
+
maxFileSizeBytes: document.getElementById('ns-max').value ? Number(document.getElementById('ns-max').value) : undefined,
|
|
1871
|
+
allowedContentTypes: document.getElementById('ns-types').value
|
|
1872
|
+
? document.getElementById('ns-types').value.split(',').map(s => s.trim()).filter(Boolean)
|
|
1873
|
+
: undefined,
|
|
1874
|
+
keyPrefix: document.getElementById('ns-prefix').value ? document.getElementById('ns-prefix').value.trim() : undefined,
|
|
1875
|
+
defaultVisibility: document.getElementById('ns-default-vis').value,
|
|
1876
|
+
enforceVisibility: document.getElementById('ns-enforce-vis').value === 'true',
|
|
1877
|
+
};
|
|
1878
|
+
|
|
1879
|
+
const btn = document.getElementById('ns-save-btn');
|
|
1880
|
+
btn.disabled = true;
|
|
1881
|
+
btn.textContent = 'Saving...';
|
|
1882
|
+
|
|
1883
|
+
try {
|
|
1884
|
+
const url = namespaceEditorMode === 'create'
|
|
1885
|
+
? `${API_BASE}/api/admin/upload-namespaces`
|
|
1886
|
+
: `${API_BASE}/api/admin/upload-namespaces/${encodeURIComponent(namespaceEditorOriginalKey)}`;
|
|
1887
|
+
|
|
1888
|
+
const method = namespaceEditorMode === 'create' ? 'POST' : 'PUT';
|
|
1889
|
+
|
|
1890
|
+
const response = await fetch(url, {
|
|
1891
|
+
method,
|
|
1892
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1893
|
+
body: JSON.stringify(payload)
|
|
1894
|
+
});
|
|
1895
|
+
|
|
1896
|
+
const data = await response.json();
|
|
1897
|
+
if (!response.ok) throw new Error(data?.error || 'Failed to save namespace');
|
|
1898
|
+
|
|
1899
|
+
showToast('Namespace saved');
|
|
1900
|
+
closeNamespaceEditor();
|
|
1901
|
+
await loadNamespaces();
|
|
1902
|
+
renderNamespacesTable();
|
|
1903
|
+
await loadNamespacesSummary();
|
|
1904
|
+
} catch (error) {
|
|
1905
|
+
showToast(error.message, 'error');
|
|
1906
|
+
} finally {
|
|
1907
|
+
btn.disabled = false;
|
|
1908
|
+
btn.textContent = 'Save';
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
async function deleteNamespace(key) {
|
|
1913
|
+
if (!confirm(`Delete namespace '${key}'?`)) return;
|
|
1914
|
+
|
|
1915
|
+
try {
|
|
1916
|
+
const response = await fetch(`${API_BASE}/api/admin/upload-namespaces/${encodeURIComponent(key)}`, { method: 'DELETE' });
|
|
1917
|
+
const data = await response.json();
|
|
1918
|
+
if (!response.ok) throw new Error(data?.error || 'Failed to delete namespace');
|
|
1919
|
+
showToast('Namespace deleted');
|
|
1920
|
+
await loadNamespaces();
|
|
1921
|
+
renderNamespacesTable();
|
|
1922
|
+
await loadNamespacesSummary();
|
|
1923
|
+
} catch (error) {
|
|
1924
|
+
showToast(error.message, 'error');
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
(async function init() {
|
|
1929
|
+
try {
|
|
1930
|
+
const stored = window.localStorage.getItem(LS_KEYS.viewMode);
|
|
1931
|
+
if (stored === 'list' || stored === 'cards') {
|
|
1932
|
+
viewMode = stored;
|
|
1933
|
+
}
|
|
1934
|
+
} catch {
|
|
1935
|
+
// ignore
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
// Ensure the initial mode is highlighted even before assets load
|
|
1939
|
+
setViewMode(viewMode);
|
|
1940
|
+
|
|
1941
|
+
// Quick upload: bind dropzone
|
|
1942
|
+
const dropzone = document.getElementById('quick-upload-dropzone');
|
|
1943
|
+
const fileInput = document.getElementById('quick-upload-file-input');
|
|
1944
|
+
if (dropzone && fileInput) {
|
|
1945
|
+
dropzone.addEventListener('click', () => fileInput.click());
|
|
1946
|
+
|
|
1947
|
+
fileInput.addEventListener('change', async () => {
|
|
1948
|
+
const files = fileInput.files;
|
|
1949
|
+
fileInput.value = '';
|
|
1950
|
+
await uploadFilesToSelectedNamespace(files);
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
const setDragActive = (active) => {
|
|
1954
|
+
if (active) {
|
|
1955
|
+
dropzone.classList.add('border-blue-400');
|
|
1956
|
+
dropzone.classList.add('bg-blue-50');
|
|
1957
|
+
} else {
|
|
1958
|
+
dropzone.classList.remove('border-blue-400');
|
|
1959
|
+
dropzone.classList.remove('bg-blue-50');
|
|
1960
|
+
}
|
|
1961
|
+
};
|
|
1962
|
+
|
|
1963
|
+
dropzone.addEventListener('dragover', (e) => {
|
|
1964
|
+
e.preventDefault();
|
|
1965
|
+
setDragActive(true);
|
|
1966
|
+
});
|
|
1967
|
+
dropzone.addEventListener('dragleave', (e) => {
|
|
1968
|
+
e.preventDefault();
|
|
1969
|
+
setDragActive(false);
|
|
1970
|
+
});
|
|
1971
|
+
dropzone.addEventListener('drop', async (e) => {
|
|
1972
|
+
e.preventDefault();
|
|
1973
|
+
setDragActive(false);
|
|
1974
|
+
const files = e.dataTransfer?.files;
|
|
1975
|
+
await uploadFilesToSelectedNamespace(files);
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
// Replace modal: bind dropzone + file input
|
|
1980
|
+
const replaceDropzone = document.getElementById('replace-dropzone');
|
|
1981
|
+
const replaceInput = document.getElementById('replace-file-input');
|
|
1982
|
+
if (replaceDropzone && replaceInput) {
|
|
1983
|
+
replaceInput.addEventListener('change', async () => {
|
|
1984
|
+
const files = Array.from(replaceInput.files || []);
|
|
1985
|
+
replaceInput.value = '';
|
|
1986
|
+
await replaceFiles(files);
|
|
1987
|
+
});
|
|
1988
|
+
|
|
1989
|
+
const setReplaceDragActive = (active) => {
|
|
1990
|
+
if (active) {
|
|
1991
|
+
replaceDropzone.classList.add('border-blue-400');
|
|
1992
|
+
replaceDropzone.classList.add('bg-blue-50');
|
|
1993
|
+
} else {
|
|
1994
|
+
replaceDropzone.classList.remove('border-blue-400');
|
|
1995
|
+
replaceDropzone.classList.remove('bg-blue-50');
|
|
1996
|
+
}
|
|
1997
|
+
};
|
|
1998
|
+
|
|
1999
|
+
replaceDropzone.addEventListener('dragover', (e) => {
|
|
2000
|
+
e.preventDefault();
|
|
2001
|
+
setReplaceDragActive(true);
|
|
2002
|
+
});
|
|
2003
|
+
replaceDropzone.addEventListener('dragleave', (e) => {
|
|
2004
|
+
e.preventDefault();
|
|
2005
|
+
setReplaceDragActive(false);
|
|
2006
|
+
});
|
|
2007
|
+
replaceDropzone.addEventListener('drop', async (e) => {
|
|
2008
|
+
e.preventDefault();
|
|
2009
|
+
setReplaceDragActive(false);
|
|
2010
|
+
const files = e.dataTransfer?.files;
|
|
2011
|
+
await replaceFiles(files);
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
await loadStorageInfo();
|
|
2016
|
+
await loadNamespaces();
|
|
2017
|
+
await loadNamespacesSummary();
|
|
2018
|
+
await loadAssets();
|
|
2019
|
+
|
|
2020
|
+
updateQuickUploadVisibility();
|
|
2021
|
+
})();
|
|
2022
|
+
</script>
|