@intranefr/superbackend 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/.env.example +15 -0
  2. package/README.md +11 -0
  3. package/analysis-only.skill +0 -0
  4. package/index.js +23 -0
  5. package/package.json +8 -2
  6. package/src/admin/endpointRegistry.js +120 -0
  7. package/src/controllers/admin.controller.js +90 -6
  8. package/src/controllers/adminBlockDefinitions.controller.js +127 -0
  9. package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
  10. package/src/controllers/adminCache.controller.js +342 -0
  11. package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
  12. package/src/controllers/adminCrons.controller.js +388 -0
  13. package/src/controllers/adminDbBrowser.controller.js +124 -0
  14. package/src/controllers/adminEjsVirtual.controller.js +13 -3
  15. package/src/controllers/adminExperiments.controller.js +200 -0
  16. package/src/controllers/adminHeadless.controller.js +9 -2
  17. package/src/controllers/adminHealthChecks.controller.js +570 -0
  18. package/src/controllers/adminI18n.controller.js +51 -29
  19. package/src/controllers/adminLlm.controller.js +126 -2
  20. package/src/controllers/adminPages.controller.js +720 -0
  21. package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
  22. package/src/controllers/adminProxy.controller.js +113 -0
  23. package/src/controllers/adminRateLimits.controller.js +138 -0
  24. package/src/controllers/adminRbac.controller.js +803 -0
  25. package/src/controllers/adminScripts.controller.js +126 -4
  26. package/src/controllers/adminSeoConfig.controller.js +71 -48
  27. package/src/controllers/blogAdmin.controller.js +279 -0
  28. package/src/controllers/blogAiAdmin.controller.js +224 -0
  29. package/src/controllers/blogAutomationAdmin.controller.js +141 -0
  30. package/src/controllers/blogInternal.controller.js +26 -0
  31. package/src/controllers/blogPublic.controller.js +89 -0
  32. package/src/controllers/experiments.controller.js +85 -0
  33. package/src/controllers/fileManager.controller.js +190 -0
  34. package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
  35. package/src/controllers/healthChecksPublic.controller.js +196 -0
  36. package/src/controllers/internalExperiments.controller.js +17 -0
  37. package/src/controllers/metrics.controller.js +64 -4
  38. package/src/controllers/orgAdmin.controller.js +80 -0
  39. package/src/helpers/mongooseHelper.js +258 -0
  40. package/src/helpers/scriptBase.js +230 -0
  41. package/src/helpers/scriptRunner.js +335 -0
  42. package/src/middleware/rbac.js +62 -0
  43. package/src/middleware.js +810 -48
  44. package/src/models/BlockDefinition.js +27 -0
  45. package/src/models/BlogAutomationLock.js +14 -0
  46. package/src/models/BlogAutomationRun.js +39 -0
  47. package/src/models/BlogPost.js +42 -0
  48. package/src/models/CacheEntry.js +26 -0
  49. package/src/models/ConsoleEntry.js +32 -0
  50. package/src/models/ConsoleLog.js +23 -0
  51. package/src/models/ContextBlockDefinition.js +33 -0
  52. package/src/models/CronExecution.js +47 -0
  53. package/src/models/CronJob.js +70 -0
  54. package/src/models/Experiment.js +75 -0
  55. package/src/models/ExperimentAssignment.js +23 -0
  56. package/src/models/ExperimentEvent.js +26 -0
  57. package/src/models/ExperimentMetricBucket.js +30 -0
  58. package/src/models/ExternalDbConnection.js +49 -0
  59. package/src/models/FileEntry.js +22 -0
  60. package/src/models/GlobalSetting.js +1 -2
  61. package/src/models/HealthAutoHealAttempt.js +57 -0
  62. package/src/models/HealthCheck.js +132 -0
  63. package/src/models/HealthCheckRun.js +51 -0
  64. package/src/models/HealthIncident.js +49 -0
  65. package/src/models/Page.js +95 -0
  66. package/src/models/PageCollection.js +42 -0
  67. package/src/models/ProxyEntry.js +66 -0
  68. package/src/models/RateLimitCounter.js +19 -0
  69. package/src/models/RateLimitMetricBucket.js +20 -0
  70. package/src/models/RbacGrant.js +25 -0
  71. package/src/models/RbacGroup.js +16 -0
  72. package/src/models/RbacGroupMember.js +13 -0
  73. package/src/models/RbacGroupRole.js +13 -0
  74. package/src/models/RbacRole.js +25 -0
  75. package/src/models/RbacUserRole.js +13 -0
  76. package/src/models/ScriptDefinition.js +1 -0
  77. package/src/models/Webhook.js +2 -0
  78. package/src/routes/admin.routes.js +2 -0
  79. package/src/routes/adminBlog.routes.js +21 -0
  80. package/src/routes/adminBlogAi.routes.js +16 -0
  81. package/src/routes/adminBlogAutomation.routes.js +27 -0
  82. package/src/routes/adminCache.routes.js +20 -0
  83. package/src/routes/adminConsoleManager.routes.js +302 -0
  84. package/src/routes/adminCrons.routes.js +25 -0
  85. package/src/routes/adminDbBrowser.routes.js +65 -0
  86. package/src/routes/adminEjsVirtual.routes.js +2 -1
  87. package/src/routes/adminExperiments.routes.js +29 -0
  88. package/src/routes/adminHeadless.routes.js +2 -1
  89. package/src/routes/adminHealthChecks.routes.js +28 -0
  90. package/src/routes/adminI18n.routes.js +4 -3
  91. package/src/routes/adminLlm.routes.js +4 -2
  92. package/src/routes/adminPages.routes.js +55 -0
  93. package/src/routes/adminProxy.routes.js +15 -0
  94. package/src/routes/adminRateLimits.routes.js +17 -0
  95. package/src/routes/adminRbac.routes.js +38 -0
  96. package/src/routes/adminSeoConfig.routes.js +5 -4
  97. package/src/routes/adminUiComponents.routes.js +2 -1
  98. package/src/routes/blogInternal.routes.js +14 -0
  99. package/src/routes/blogPublic.routes.js +9 -0
  100. package/src/routes/experiments.routes.js +30 -0
  101. package/src/routes/fileManager.routes.js +62 -0
  102. package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
  103. package/src/routes/healthChecksPublic.routes.js +9 -0
  104. package/src/routes/internalExperiments.routes.js +15 -0
  105. package/src/routes/log.routes.js +43 -60
  106. package/src/routes/metrics.routes.js +4 -2
  107. package/src/routes/orgAdmin.routes.js +1 -0
  108. package/src/routes/pages.routes.js +123 -0
  109. package/src/routes/proxy.routes.js +46 -0
  110. package/src/routes/rbac.routes.js +47 -0
  111. package/src/routes/webhook.routes.js +2 -1
  112. package/src/routes/workflows.routes.js +4 -0
  113. package/src/services/blockDefinitionsAi.service.js +247 -0
  114. package/src/services/blog.service.js +99 -0
  115. package/src/services/blogAutomation.service.js +978 -0
  116. package/src/services/blogCronsBootstrap.service.js +185 -0
  117. package/src/services/blogPublishing.service.js +58 -0
  118. package/src/services/cacheLayer.service.js +696 -0
  119. package/src/services/consoleManager.service.js +738 -0
  120. package/src/services/consoleOverride.service.js +7 -1
  121. package/src/services/cronScheduler.service.js +350 -0
  122. package/src/services/dbBrowser.service.js +536 -0
  123. package/src/services/ejsVirtual.service.js +102 -32
  124. package/src/services/experiments.service.js +273 -0
  125. package/src/services/experimentsAggregation.service.js +308 -0
  126. package/src/services/experimentsCronsBootstrap.service.js +118 -0
  127. package/src/services/experimentsRetention.service.js +43 -0
  128. package/src/services/experimentsWs.service.js +134 -0
  129. package/src/services/fileManager.service.js +475 -0
  130. package/src/services/fileManagerStoragePolicy.service.js +285 -0
  131. package/src/services/globalSettings.service.js +15 -0
  132. package/src/services/healthChecks.service.js +650 -0
  133. package/src/services/healthChecksBootstrap.service.js +109 -0
  134. package/src/services/healthChecksScheduler.service.js +106 -0
  135. package/src/services/jsonConfigs.service.js +2 -2
  136. package/src/services/llmDefaults.service.js +190 -0
  137. package/src/services/migrationAssets/s3.js +2 -2
  138. package/src/services/pages.service.js +602 -0
  139. package/src/services/pagesContext.service.js +331 -0
  140. package/src/services/pagesContextBlocksAi.service.js +349 -0
  141. package/src/services/proxy.service.js +535 -0
  142. package/src/services/rateLimiter.service.js +623 -0
  143. package/src/services/rbac.service.js +212 -0
  144. package/src/services/scriptsRunner.service.js +215 -15
  145. package/src/services/uiComponentsAi.service.js +6 -19
  146. package/src/services/workflow.service.js +23 -8
  147. package/src/utils/orgRoles.js +14 -0
  148. package/src/utils/rbac/engine.js +60 -0
  149. package/src/utils/rbac/rightsRegistry.js +33 -0
  150. package/views/admin-blog-automation.ejs +877 -0
  151. package/views/admin-blog-edit.ejs +542 -0
  152. package/views/admin-blog.ejs +399 -0
  153. package/views/admin-cache.ejs +681 -0
  154. package/views/admin-console-manager.ejs +680 -0
  155. package/views/admin-crons.ejs +645 -0
  156. package/views/admin-dashboard.ejs +28 -8
  157. package/views/admin-db-browser.ejs +445 -0
  158. package/views/admin-ejs-virtual.ejs +16 -10
  159. package/views/admin-experiments.ejs +91 -0
  160. package/views/admin-file-manager.ejs +942 -0
  161. package/views/admin-health-checks.ejs +725 -0
  162. package/views/admin-i18n.ejs +59 -5
  163. package/views/admin-llm.ejs +99 -1
  164. package/views/admin-organizations.ejs +163 -1
  165. package/views/admin-pages.ejs +2424 -0
  166. package/views/admin-proxy.ejs +491 -0
  167. package/views/admin-rate-limiter.ejs +625 -0
  168. package/views/admin-rbac.ejs +1331 -0
  169. package/views/admin-scripts.ejs +597 -3
  170. package/views/admin-seo-config.ejs +61 -7
  171. package/views/admin-ui-components.ejs +57 -25
  172. package/views/admin-workflows.ejs +7 -7
  173. package/views/file-manager.ejs +866 -0
  174. package/views/pages/blocks/contact.ejs +27 -0
  175. package/views/pages/blocks/cta.ejs +18 -0
  176. package/views/pages/blocks/faq.ejs +20 -0
  177. package/views/pages/blocks/features.ejs +19 -0
  178. package/views/pages/blocks/hero.ejs +13 -0
  179. package/views/pages/blocks/html.ejs +5 -0
  180. package/views/pages/blocks/image.ejs +14 -0
  181. package/views/pages/blocks/testimonials.ejs +26 -0
  182. package/views/pages/blocks/text.ejs +10 -0
  183. package/views/pages/layouts/default.ejs +51 -0
  184. package/views/pages/layouts/minimal.ejs +42 -0
  185. package/views/pages/layouts/sidebar.ejs +54 -0
  186. package/views/pages/partials/footer.ejs +13 -0
  187. package/views/pages/partials/header.ejs +12 -0
  188. package/views/pages/partials/sidebar.ejs +8 -0
  189. package/views/pages/runtime/page.ejs +10 -0
  190. package/views/pages/templates/article.ejs +20 -0
  191. package/views/pages/templates/default.ejs +12 -0
  192. package/views/pages/templates/landing.ejs +14 -0
  193. package/views/pages/templates/listing.ejs +15 -0
  194. package/views/partials/admin-image-upload-modal.ejs +221 -0
  195. package/views/partials/dashboard/nav-items.ejs +12 -0
  196. package/views/partials/dashboard/palette.ejs +5 -3
  197. package/views/partials/llm-provider-model-picker.ejs +183 -0
  198. package/src/routes/llmUi.routes.js +0 -26
@@ -0,0 +1,866 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>File Manager</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.14/dist/full.min.css" rel="stylesheet" type="text/css" />
9
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
10
+ </head>
11
+ <body class="bg-gray-50">
12
+ <div id="app" class="min-h-screen flex flex-col">
13
+ <!-- Top bar -->
14
+ <header class="bg-white border-b border-gray-200 px-4 py-3 flex items-center justify-between">
15
+ <div class="flex items-center gap-4">
16
+ <div class="text-blue-600 text-xl font-medium">File Manager</div>
17
+ </div>
18
+ <div class="flex-1 max-w-2xl mx-8">
19
+ <div class="relative">
20
+ <input class="w-full pl-10 pr-4 py-2 rounded-full border border-gray-300 bg-gray-50 hover:bg-white focus:bg-white focus:border-blue-500 focus:outline-none transition-colors" v-model.trim="search" placeholder="Search files" />
21
+ <svg class="absolute left-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
22
+ </div>
23
+ </div>
24
+ <div class="flex items-center gap-2">
25
+ <button v-if="token" class="text-gray-600 hover:text-gray-900 px-3 py-1 rounded-md hover:bg-gray-100 transition-colors text-sm" @click="logout">Logout</button>
26
+ </div>
27
+ </header>
28
+
29
+ <div class="flex-1 flex">
30
+ <div v-if="route === 'login'" class="flex-1 flex items-center justify-center p-8">
31
+ <div class="w-full max-w-md bg-white rounded-lg border border-gray-200 p-8">
32
+ <h2 class="text-2xl font-medium text-gray-900 mb-6">Sign in to File Manager</h2>
33
+ <div class="space-y-4">
34
+ <div>
35
+ <label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
36
+ <input class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all" v-model.trim="loginEmail" type="email" autocomplete="email" />
37
+ </div>
38
+ <div>
39
+ <label class="block text-sm font-medium text-gray-700 mb-1">Password</label>
40
+ <input class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all" v-model="loginPassword" type="password" autocomplete="current-password" />
41
+ </div>
42
+ </div>
43
+ <div class="mt-6">
44
+ <button class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors font-medium" :disabled="busy" @click="doLogin">Sign in</button>
45
+ </div>
46
+ <div v-if="error" class="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
47
+ {{ error }}
48
+ </div>
49
+ </div>
50
+ </div>
51
+
52
+ <div v-else-if="route === 'browse'" class="flex-1 flex">
53
+ <!-- Left sidebar -->
54
+ <aside class="w-64 bg-white border-r border-gray-200 p-4">
55
+ <!-- New button -->
56
+ <button class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors font-medium mb-6 flex items-center justify-center gap-2" :disabled="busy || !orgId || !selectedDriveKey" @click="fileInputRef?.click()">
57
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path></svg>
58
+ Upload files
59
+ </button>
60
+
61
+ <!-- Navigation -->
62
+ <nav class="space-y-1">
63
+ <div class="px-3 py-2 text-sm font-medium text-gray-500 uppercase tracking-wide">Navigation</div>
64
+ <a href="#" @click.prevent="goRoot" class="flex items-center gap-3 px-3 py-2 text-sm text-gray-700 rounded-lg hover:bg-gray-100 transition-colors">
65
+ <svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path></svg>
66
+ My Drive
67
+ </a>
68
+ </nav>
69
+
70
+ <!-- Organization selector -->
71
+ <div class="mt-8">
72
+ <div class="px-3 py-2 text-sm font-medium text-gray-500 uppercase tracking-wide">Workspace</div>
73
+ <select class="w-full mt-2 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm" v-model="orgId">
74
+ <option disabled value="">Select organization</option>
75
+ <option v-for="o in orgs" :key="o._id" :value="o._id">{{ o.name }}</option>
76
+ </select>
77
+ </div>
78
+
79
+ <!-- Drive selector -->
80
+ <div class="mt-4">
81
+ <select class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm" v-model="selectedDriveKey" :disabled="!orgId">
82
+ <option disabled value="">Select drive</option>
83
+ <option v-for="d in drives" :key="d.key" :value="d.key">{{ d.label }}</option>
84
+ </select>
85
+ </div>
86
+
87
+ <!-- Folder navigation -->
88
+ <div class="mt-6">
89
+ <div class="px-3 py-2 text-sm font-medium text-gray-500 uppercase tracking-wide">Folder</div>
90
+ <div class="flex gap-1 mt-2">
91
+ <button class="flex-1 px-3 py-2 text-sm text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" :disabled="busy || !orgId || !selectedDriveKey" @click="goRoot">Root</button>
92
+ <button class="flex-1 px-3 py-2 text-sm text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" :disabled="busy || !orgId || !selectedDriveKey" @click="goUp">Up</button>
93
+ </div>
94
+ <input class="w-full mt-2 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm font-mono" v-model.trim="folderPath" placeholder="/" />
95
+ </div>
96
+
97
+ <!-- Storage indicator -->
98
+ <div class="mt-auto pt-8">
99
+ <div class="px-3 py-2 text-sm font-medium text-gray-500 uppercase tracking-wide">Storage</div>
100
+ <div class="mt-2 px-3 py-2 bg-gray-50 rounded-lg text-sm text-gray-600">
101
+ <div v-if="storagePolicy" class="space-y-1">
102
+ <div class="flex items-center justify-between">
103
+ <span class="text-gray-500">Used</span>
104
+ <span class="font-medium text-gray-800">{{ formatBytes(storagePolicy.usage.usedBytes) }}</span>
105
+ </div>
106
+ <div class="flex items-center justify-between">
107
+ <span class="text-gray-500">Max</span>
108
+ <span class="font-medium text-gray-800">{{ formatBytes(storagePolicy.effective.maxStorageBytes) }}</span>
109
+ </div>
110
+ <div v-if="storagePolicy.usage.overageBytes > 0" class="text-xs text-red-700">
111
+ Over by {{ formatBytes(storagePolicy.usage.overageBytes) }}
112
+ </div>
113
+ </div>
114
+ <div v-else class="text-gray-500">Select org + drive</div>
115
+ </div>
116
+ </div>
117
+
118
+ <div v-if="error" class="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
119
+ {{ error }}
120
+ </div>
121
+ </aside>
122
+
123
+ <!-- Main content -->
124
+ <main class="flex-1 bg-gray-50">
125
+ <div class="p-6">
126
+ <!-- Breadcrumb -->
127
+ <div class="mb-4">
128
+ <div class="flex items-center gap-2 text-sm text-gray-600 flex-wrap">
129
+ <a @click.prevent="goRoot" href="#" class="hover:text-gray-900 transition-colors">My Drive</a>
130
+ <template v-for="c in breadcrumbCrumbs" :key="c.path">
131
+ <span class="text-gray-400">/</span>
132
+ <a v-if="!c.isCurrent" @click.prevent="goToPath(c.path)" href="#" class="hover:text-gray-900 transition-colors">{{ c.label }}</a>
133
+ <span v-else class="text-gray-900 font-medium">{{ c.label }}</span>
134
+ </template>
135
+ </div>
136
+ </div>
137
+
138
+ <!-- Quick filters -->
139
+ <div class="mb-6 flex items-center justify-between">
140
+ <div class="flex items-center gap-2">
141
+ <button class="px-3 py-1 text-sm bg-white border border-gray-300 rounded-full hover:bg-gray-50 transition-colors">Type</button>
142
+ <button class="px-3 py-1 text-sm bg-white border border-gray-300 rounded-full hover:bg-gray-50 transition-colors">Last modified</button>
143
+ <button class="px-3 py-1 text-sm bg-white border border-gray-300 rounded-full hover:bg-gray-50 transition-colors">Name</button>
144
+ </div>
145
+ <div class="flex items-center gap-2">
146
+ <button class="px-3 py-1 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-full transition-colors" :class="viewMode === 'list' ? 'bg-gray-100 text-gray-900' : ''" @click="viewMode='list'">List</button>
147
+ <button class="px-3 py-1 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-full transition-colors" :class="viewMode === 'grid' ? 'bg-gray-100 text-gray-900' : ''" @click="viewMode='grid'">Grid</button>
148
+ <button class="px-3 py-1 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-full transition-colors flex items-center gap-1" :disabled="busy || !orgId || !selectedDriveKey" @click="refreshFolder">
149
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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></svg>
150
+ Refresh
151
+ </button>
152
+ </div>
153
+ </div>
154
+
155
+ <!-- List view -->
156
+ <div v-if="viewMode === 'list'" class="bg-white rounded-lg border border-gray-200">
157
+ <div class="overflow-x-auto">
158
+ <table class="w-full">
159
+ <thead class="bg-gray-50 border-b border-gray-200">
160
+ <tr>
161
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
162
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Size</th>
163
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Visibility</th>
164
+ <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
165
+ </tr>
166
+ </thead>
167
+ <tbody class="divide-y divide-gray-200">
168
+ <tr v-for="d in filteredFolders" :key="d.path" class="hover:bg-gray-50 transition-colors cursor-pointer" @click="goToPath(d.path)">
169
+ <td class="px-6 py-4">
170
+ <div class="flex items-center gap-3">
171
+ <div class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
172
+ <svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7a2 2 0 012-2h5l2 2h7a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"></path></svg>
173
+ </div>
174
+ <div>
175
+ <div class="text-sm font-medium text-gray-900">{{ d.name }}</div>
176
+ <div class="text-xs text-gray-500">Folder</div>
177
+ </div>
178
+ </div>
179
+ </td>
180
+ <td class="px-6 py-4 text-sm text-gray-600">-</td>
181
+ <td class="px-6 py-4 text-sm text-gray-600">-</td>
182
+ <td class="px-6 py-4"></td>
183
+ </tr>
184
+
185
+ <tr v-for="f in filteredFiles" :key="f.id" class="hover:bg-gray-50 transition-colors">
186
+ <td class="px-6 py-4">
187
+ <div class="flex items-center gap-3">
188
+ <div class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
189
+ <svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
190
+ </div>
191
+ <div>
192
+ <div class="text-sm font-medium text-gray-900">{{ f.name }}</div>
193
+ <div class="text-xs text-gray-500">{{ f.contentType || '' }}</div>
194
+ </div>
195
+ </div>
196
+ </td>
197
+ <td class="px-6 py-4 text-sm text-gray-600">{{ formatBytes(f.size) }}</td>
198
+ <td class="px-6 py-4">
199
+ <span class="inline-flex px-2 py-1 text-xs font-medium rounded-full" :class="f.visibility === 'public' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'">{{ f.visibility }}</span>
200
+ </td>
201
+ <td class="px-6 py-4 text-right">
202
+ <div class="flex items-center justify-end gap-1">
203
+ <button class="p-1 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded transition-colors" @click="downloadFile(f)" :disabled="busy" title="Download">
204
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"></path></svg>
205
+ </button>
206
+ <a v-if="f.publicUrl" class="p-1 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded transition-colors" :href="f.publicUrl" target="_blank" title="Open public link">
207
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
208
+ </a>
209
+ <button class="p-1 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded transition-colors" @click="openEdit(f)" :disabled="busy" title="Rename">
210
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
211
+ </button>
212
+ <button class="p-1 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded transition-colors" @click="toggleShare(f)" :disabled="busy" :title="f.visibility === 'public' ? 'Make private' : 'Make public'">
213
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.367 2.684 3 3 0 00-5.367-2.684z"></path></svg>
214
+ </button>
215
+ <button class="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 rounded transition-colors" @click="removeFile(f)" :disabled="busy" title="Delete">
216
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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></svg>
217
+ </button>
218
+ </div>
219
+ </td>
220
+ </tr>
221
+
222
+ <tr v-if="filteredFiles.length === 0 && filteredFolders.length === 0">
223
+ <td colspan="4" class="px-6 py-12 text-center text-sm text-gray-500">
224
+ No files in this folder
225
+ </td>
226
+ </tr>
227
+ </tbody>
228
+ </table>
229
+ </div>
230
+ </div>
231
+
232
+ <!-- Grid view -->
233
+ <div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
234
+ <button v-for="d in filteredFolders" :key="d.path" class="text-left bg-white border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors" @click="goToPath(d.path)">
235
+ <div class="flex items-center gap-3">
236
+ <div class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
237
+ <svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7a2 2 0 012-2h5l2 2h7a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"></path></svg>
238
+ </div>
239
+ <div>
240
+ <div class="text-sm font-medium text-gray-900 truncate">{{ d.name }}</div>
241
+ <div class="text-xs text-gray-500">Folder</div>
242
+ </div>
243
+ </div>
244
+ </button>
245
+
246
+ <div v-for="f in filteredFiles" :key="f.id" class="bg-white border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
247
+ <div class="flex items-start gap-3">
248
+ <div class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
249
+ <svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
250
+ </div>
251
+ <div class="min-w-0 flex-1">
252
+ <div class="text-sm font-medium text-gray-900 truncate">{{ f.name }}</div>
253
+ <div class="text-xs text-gray-500 truncate">{{ f.contentType || '' }}</div>
254
+ <div class="text-xs text-gray-500 mt-1">{{ formatBytes(f.size) }}</div>
255
+ </div>
256
+ </div>
257
+
258
+ <div class="mt-3 flex items-center justify-between">
259
+ <span class="inline-flex px-2 py-1 text-xs font-medium rounded-full" :class="f.visibility === 'public' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'">{{ f.visibility }}</span>
260
+ <div class="flex items-center gap-1">
261
+ <button class="p-1 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded transition-colors" @click="downloadFile(f)" :disabled="busy" title="Download">
262
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"></path></svg>
263
+ </button>
264
+ <a v-if="f.publicUrl" class="p-1 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded transition-colors" :href="f.publicUrl" target="_blank" title="Open public link">
265
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
266
+ </a>
267
+ <button class="p-1 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded transition-colors" @click="openEdit(f)" :disabled="busy" title="Rename">
268
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
269
+ </button>
270
+ <button class="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 rounded transition-colors" @click="removeFile(f)" :disabled="busy" title="Delete">
271
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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></svg>
272
+ </button>
273
+ </div>
274
+ </div>
275
+ </div>
276
+
277
+ <div v-if="filteredFiles.length === 0 && filteredFolders.length === 0" class="col-span-full text-center text-sm text-gray-500 py-12">
278
+ No files in this folder
279
+ </div>
280
+ </div>
281
+ </div>
282
+ </main>
283
+ </div>
284
+
285
+ <div v-else class="flex-1 flex items-center justify-center">
286
+ <div class="text-gray-500">Loading...</div>
287
+ </div>
288
+ </div>
289
+
290
+ <!-- Hidden file input -->
291
+ <input type="file" class="hidden" ref="fileInputRef" @change="handleFileSelect" multiple />
292
+
293
+ <!-- Rename/Move modal -->
294
+ <div v-if="editOpen" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" @click.self="closeEdit">
295
+ <div class="bg-white rounded-lg border border-gray-200 p-6 w-full max-w-md">
296
+ <h3 class="text-lg font-medium text-gray-900 mb-4">Rename / Move</h3>
297
+ <div class="space-y-4">
298
+ <div>
299
+ <label class="block text-sm font-medium text-gray-700 mb-1">Name</label>
300
+ <input class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" v-model.trim="editName" />
301
+ </div>
302
+ <div>
303
+ <label class="block text-sm font-medium text-gray-700 mb-1">Folder path</label>
304
+ <input class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none font-mono" v-model.trim="editFolderPath" placeholder="/" />
305
+ </div>
306
+ </div>
307
+ <div v-if="editError" class="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
308
+ {{ editError }}
309
+ </div>
310
+ <div class="mt-6 flex gap-3 justify-end">
311
+ <button class="px-4 py-2 text-sm text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" @click="closeEdit" :disabled="busy">Cancel</button>
312
+ <button class="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors" @click="saveEdit" :disabled="busy">Save</button>
313
+ </div>
314
+ </div>
315
+ </div>
316
+ </div>
317
+
318
+ <script>
319
+ window.FILE_MANAGER_BASE_PATH = "<%= typeof fileManagerBasePath !== 'undefined' ? fileManagerBasePath : '/files' %>";
320
+ window.BASE_URL = "<%= (typeof baseUrl !== 'undefined' && baseUrl) ? baseUrl : '' %>";
321
+ </script>
322
+
323
+ <script>
324
+ const { createApp, ref, computed, onMounted, watch } = Vue;
325
+
326
+ createApp({
327
+ setup() {
328
+ const token = ref(localStorage.getItem('sb_fm_access_token') || '');
329
+ const busy = ref(false);
330
+ const error = ref('');
331
+
332
+ const route = ref('loading');
333
+
334
+ const loginEmail = ref('');
335
+ const loginPassword = ref('');
336
+
337
+ const orgs = ref([]);
338
+ const orgId = ref('');
339
+
340
+ const drives = ref([]);
341
+ const selectedDriveKey = ref('');
342
+ const folderPath = ref('/');
343
+ const files = ref([]);
344
+ const folders = ref([]);
345
+
346
+ const storagePolicy = ref(null);
347
+
348
+ const editOpen = ref(false);
349
+ const editFileId = ref('');
350
+ const editName = ref('');
351
+ const editFolderPath = ref('/');
352
+ const editError = ref('');
353
+
354
+ const apiBase = computed(() => window.location.origin + (window.BASE_URL || ''));
355
+
356
+ const selectedDrive = computed(() => {
357
+ const key = selectedDriveKey.value;
358
+ if (!key) return null;
359
+ const [driveType, driveId] = key.split(':');
360
+ return { driveType, driveId };
361
+ });
362
+
363
+ const selectedDriveLabel = computed(() => {
364
+ const key = selectedDriveKey.value;
365
+ if (!key) return '';
366
+ const match = (drives.value || []).find((d) => d.key === key);
367
+ return match?.label || '';
368
+ });
369
+
370
+ const authHeaders = () => ({
371
+ Authorization: `Bearer ${token.value}`,
372
+ });
373
+
374
+ const STORAGE_LAST_ORG_KEY = 'sb_fm_last_org_id';
375
+ const STORAGE_LAST_DRIVE_KEY = 'sb_fm_last_drive_key';
376
+
377
+ const search = ref('');
378
+ const viewMode = ref('list');
379
+
380
+ const filteredFiles = computed(() => {
381
+ const q = String(search.value || '').trim().toLowerCase();
382
+ if (!q) return files.value;
383
+ return (files.value || []).filter((f) => String(f?.name || '').toLowerCase().includes(q));
384
+ });
385
+
386
+ const filteredFolders = computed(() => {
387
+ const q = String(search.value || '').trim().toLowerCase();
388
+ if (!q) return folders.value;
389
+ return (folders.value || []).filter((d) => String(d?.name || '').toLowerCase().includes(q));
390
+ });
391
+
392
+ const breadcrumbCrumbs = computed(() => {
393
+ const path = normalizePath(folderPath.value || '/');
394
+ if (path === '/') return [{ label: 'Root', path: '/', isCurrent: true }];
395
+
396
+ const segs = path.split('/').filter(Boolean);
397
+ const crumbs = [{ label: 'Root', path: '/', isCurrent: false }];
398
+ let acc = '';
399
+ for (let i = 0; i < segs.length; i++) {
400
+ const s = segs[i];
401
+ acc = `${acc}/${s}`;
402
+ crumbs.push({ label: s, path: normalizePath(acc), isCurrent: i === segs.length - 1 });
403
+ }
404
+ return crumbs;
405
+ });
406
+
407
+ const formatBytes = (n) => {
408
+ const num = Number(n);
409
+ if (!Number.isFinite(num) || num < 0) return '-';
410
+ if (num === 0) return '0 B';
411
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
412
+ const exp = Math.min(units.length - 1, Math.floor(Math.log(num) / Math.log(1024)));
413
+ const val = num / Math.pow(1024, exp);
414
+ return `${val.toFixed(val >= 10 || exp === 0 ? 0 : 1)} ${units[exp]}`;
415
+ };
416
+
417
+ const normalizePath = (value) => {
418
+ const raw = String(value || '/').trim();
419
+ if (!raw || raw === '/') return '/';
420
+ let out = raw.startsWith('/') ? raw : `/${raw}`;
421
+ out = out.replace(/\/+?/g, '/');
422
+ if (out.length > 1 && out.endsWith('/')) out = out.slice(0, -1);
423
+ return out || '/';
424
+ };
425
+
426
+ const parentPathOf = (path) => {
427
+ const p = normalizePath(path);
428
+ if (p === '/') return '/';
429
+ const idx = p.lastIndexOf('/');
430
+ if (idx <= 0) return '/';
431
+ return p.slice(0, idx) || '/';
432
+ };
433
+
434
+ const doLogin = async () => {
435
+ error.value = '';
436
+ busy.value = true;
437
+ try {
438
+ const res = await fetch(`${apiBase.value}/api/auth/login`, {
439
+ method: 'POST',
440
+ headers: { 'Content-Type': 'application/json' },
441
+ body: JSON.stringify({ email: loginEmail.value, password: loginPassword.value }),
442
+ });
443
+ const data = await res.json().catch(() => ({}));
444
+ if (!res.ok) throw new Error(data.error || 'Login failed');
445
+
446
+ token.value = data.token;
447
+ localStorage.setItem('sb_fm_access_token', token.value);
448
+ await loadOrgs();
449
+ navigate('browse');
450
+ } catch (e) {
451
+ error.value = e.message || 'Login failed';
452
+ } finally {
453
+ busy.value = false;
454
+ }
455
+ };
456
+
457
+ const goRoot = async () => {
458
+ folderPath.value = '/';
459
+ };
460
+
461
+ const goUp = async () => {
462
+ folderPath.value = parentPathOf(folderPath.value);
463
+ };
464
+
465
+ const goToPath = async (path) => {
466
+ folderPath.value = normalizePath(path || '/');
467
+ };
468
+
469
+ const openEdit = (file) => {
470
+ editError.value = '';
471
+ editFileId.value = file?.id || '';
472
+ editName.value = file?.name || '';
473
+ editFolderPath.value = folderPath.value || '/';
474
+ editOpen.value = true;
475
+ };
476
+
477
+ const closeEdit = () => {
478
+ editOpen.value = false;
479
+ editFileId.value = '';
480
+ editName.value = '';
481
+ editFolderPath.value = '/';
482
+ editError.value = '';
483
+ };
484
+
485
+ const saveEdit = async () => {
486
+ editError.value = '';
487
+ if (!orgId.value || !selectedDrive.value || !editFileId.value) return;
488
+
489
+ busy.value = true;
490
+ try {
491
+ const res = await fetch(`${apiBase.value}/api/file-manager/files/${encodeURIComponent(editFileId.value)}`, {
492
+ method: 'PATCH',
493
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
494
+ body: JSON.stringify({
495
+ orgId: orgId.value,
496
+ driveType: selectedDrive.value.driveType,
497
+ driveId: selectedDrive.value.driveId,
498
+ name: editName.value,
499
+ folderPath: editFolderPath.value || '/',
500
+ }),
501
+ });
502
+
503
+ const data = await res.json().catch(() => ({}));
504
+ if (!res.ok) throw new Error(data.error || 'Failed to update file');
505
+
506
+ closeEdit();
507
+ await refreshFolder();
508
+ } catch (e) {
509
+ editError.value = e.message || 'Failed to update file';
510
+ } finally {
511
+ busy.value = false;
512
+ }
513
+ };
514
+
515
+ const logout = () => {
516
+ token.value = '';
517
+ localStorage.removeItem('sb_fm_access_token');
518
+ orgs.value = [];
519
+ orgId.value = '';
520
+ drives.value = [];
521
+ selectedDriveKey.value = '';
522
+ files.value = [];
523
+ folderPath.value = '/';
524
+ navigate('login');
525
+ };
526
+
527
+ const parseHashRoute = () => {
528
+ const hash = String(window.location.hash || '');
529
+ const clean = hash.startsWith('#') ? hash.slice(1) : hash;
530
+ const path = clean.startsWith('/') ? clean : `/${clean}`;
531
+ if (path === '/login') return 'login';
532
+ if (path === '/browse' || path === '/' || path === '') return 'browse';
533
+ return 'browse';
534
+ };
535
+
536
+ const navigate = (name) => {
537
+ const target = name === 'login' ? '#/login' : '#/browse';
538
+ if (window.location.hash !== target) {
539
+ window.location.hash = target;
540
+ } else {
541
+ route.value = name;
542
+ }
543
+ };
544
+
545
+ const syncRoute = () => {
546
+ const desired = parseHashRoute();
547
+ if (!token.value && desired !== 'login') {
548
+ return navigate('login');
549
+ }
550
+ if (token.value && desired === 'login') {
551
+ return navigate('browse');
552
+ }
553
+ route.value = desired;
554
+ };
555
+
556
+ const loadOrgs = async () => {
557
+ error.value = '';
558
+ const res = await fetch(`${apiBase.value}/api/orgs`, {
559
+ headers: { ...authHeaders() },
560
+ });
561
+ const data = await res.json().catch(() => ({}));
562
+ if (!res.ok) throw new Error(data.error || 'Failed to load orgs');
563
+ orgs.value = data.orgs || [];
564
+
565
+ const lastOrg = String(localStorage.getItem(STORAGE_LAST_ORG_KEY) || '').trim();
566
+ if (!orgId.value && lastOrg && (orgs.value || []).some((o) => String(o?._id) === lastOrg)) {
567
+ orgId.value = lastOrg;
568
+ }
569
+ };
570
+
571
+ const loadDrives = async () => {
572
+ error.value = '';
573
+ drives.value = [];
574
+ selectedDriveKey.value = '';
575
+ files.value = [];
576
+ folders.value = [];
577
+ if (!orgId.value) return;
578
+
579
+ const res = await fetch(`${apiBase.value}/api/file-manager/drives?orgId=${encodeURIComponent(orgId.value)}`, {
580
+ headers: { ...authHeaders() },
581
+ });
582
+ const data = await res.json().catch(() => ({}));
583
+ if (!res.ok) throw new Error(data.error || 'Failed to load drives');
584
+
585
+ drives.value = (data.drives || []).map((d) => ({
586
+ ...d,
587
+ key: `${d.driveType}:${d.driveId}`,
588
+ }));
589
+
590
+ const lastDrive = String(localStorage.getItem(STORAGE_LAST_DRIVE_KEY) || '').trim();
591
+ if (lastDrive && (drives.value || []).some((d) => d.key === lastDrive)) {
592
+ selectedDriveKey.value = lastDrive;
593
+ }
594
+ };
595
+
596
+ const loadStoragePolicy = async () => {
597
+ storagePolicy.value = null;
598
+ if (!orgId.value || !selectedDrive.value) return;
599
+
600
+ const params = new URLSearchParams({
601
+ orgId: orgId.value,
602
+ driveType: selectedDrive.value.driveType,
603
+ driveId: selectedDrive.value.driveId,
604
+ });
605
+
606
+ const res = await fetch(`${apiBase.value}/api/file-manager/storage-policy?${params.toString()}`, {
607
+ headers: { ...authHeaders() },
608
+ });
609
+ const data = await res.json().catch(() => ({}));
610
+ if (!res.ok) throw new Error(data.error || 'Failed to load storage policy');
611
+ storagePolicy.value = data;
612
+ };
613
+
614
+ const refreshFolder = async () => {
615
+ error.value = '';
616
+ files.value = [];
617
+ folders.value = [];
618
+
619
+ if (!orgId.value || !selectedDrive.value) return;
620
+
621
+ const params = new URLSearchParams({
622
+ orgId: orgId.value,
623
+ driveType: selectedDrive.value.driveType,
624
+ driveId: selectedDrive.value.driveId,
625
+ folderPath: normalizePath(folderPath.value || '/'),
626
+ });
627
+
628
+ const res = await fetch(`${apiBase.value}/api/file-manager/folders?${params.toString()}`, {
629
+ headers: { ...authHeaders() },
630
+ });
631
+ const data = await res.json().catch(() => ({}));
632
+ if (!res.ok) throw new Error(data.error || 'Failed to list folder');
633
+ files.value = data.files || data.entries || [];
634
+ folders.value = data.folders || [];
635
+ };
636
+
637
+ const fileInputRef = ref(null);
638
+
639
+ const handleFileSelect = (event) => {
640
+ const files = event.target.files;
641
+ if (files && files.length > 0) {
642
+ upload(false);
643
+ }
644
+ };
645
+
646
+ const upload = async (overwrite) => {
647
+ error.value = '';
648
+ if (!orgId.value || !selectedDrive.value) return;
649
+ const input = fileInputRef.value;
650
+ const file = input?.files?.[0];
651
+ if (!file) {
652
+ error.value = 'Select a file first';
653
+ return;
654
+ }
655
+
656
+ busy.value = true;
657
+ try {
658
+ const params = new URLSearchParams({
659
+ orgId: orgId.value,
660
+ driveType: selectedDrive.value.driveType,
661
+ driveId: selectedDrive.value.driveId,
662
+ folderPath: normalizePath(folderPath.value || '/'),
663
+ overwrite: overwrite ? 'true' : 'false',
664
+ });
665
+
666
+ const form = new FormData();
667
+ form.append('file', file);
668
+
669
+ const res = await fetch(`${apiBase.value}/api/file-manager/files/upload?${params.toString()}`, {
670
+ method: 'POST',
671
+ headers: { ...authHeaders() },
672
+ body: form,
673
+ });
674
+ const data = await res.json().catch(() => ({}));
675
+ if (!res.ok) throw new Error(data.error || 'Upload failed');
676
+ await refreshFolder();
677
+ // Clear file input
678
+ if (input) input.value = '';
679
+ } catch (e) {
680
+ error.value = e.message || 'Upload failed';
681
+ } finally {
682
+ busy.value = false;
683
+ }
684
+ };
685
+
686
+ const downloadUrl = (file) => {
687
+ if (!orgId.value || !selectedDrive.value) return null;
688
+ const params = new URLSearchParams({
689
+ orgId: orgId.value,
690
+ driveType: selectedDrive.value.driveType,
691
+ driveId: selectedDrive.value.driveId,
692
+ });
693
+ return `${apiBase.value}/api/file-manager/files/${file.id}/download?${params.toString()}`;
694
+ };
695
+
696
+ const downloadFile = async (file) => {
697
+ error.value = '';
698
+ const url = downloadUrl(file);
699
+ if (!url) return;
700
+
701
+ busy.value = true;
702
+ try {
703
+ const res = await fetch(url, {
704
+ headers: { ...authHeaders() },
705
+ });
706
+ if (!res.ok) {
707
+ const data = await res.json().catch(() => ({}));
708
+ throw new Error(data.error || 'Download failed');
709
+ }
710
+
711
+ const blob = await res.blob();
712
+ const a = document.createElement('a');
713
+ const objectUrl = URL.createObjectURL(blob);
714
+ a.href = objectUrl;
715
+ a.download = file?.name || 'download';
716
+ document.body.appendChild(a);
717
+ a.click();
718
+ a.remove();
719
+ URL.revokeObjectURL(objectUrl);
720
+ } catch (e) {
721
+ error.value = e.message || 'Download failed';
722
+ } finally {
723
+ busy.value = false;
724
+ }
725
+ };
726
+
727
+ const toggleShare = async (file) => {
728
+ error.value = '';
729
+ busy.value = true;
730
+ try {
731
+ const res = await fetch(`${apiBase.value}/api/file-manager/files/${file.id}/share`, {
732
+ method: 'POST',
733
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
734
+ body: JSON.stringify({
735
+ orgId: orgId.value,
736
+ driveType: selectedDrive.value.driveType,
737
+ driveId: selectedDrive.value.driveId,
738
+ enabled: file.visibility !== 'public',
739
+ }),
740
+ });
741
+ const data = await res.json().catch(() => ({}));
742
+ if (!res.ok) throw new Error(data.error || 'Failed to update sharing');
743
+ await refreshFolder();
744
+ } catch (e) {
745
+ error.value = e.message || 'Failed to update sharing';
746
+ } finally {
747
+ busy.value = false;
748
+ }
749
+ };
750
+
751
+ const removeFile = async (file) => {
752
+ if (!confirm(`Delete '${file.name}'?`)) return;
753
+ error.value = '';
754
+ busy.value = true;
755
+ try {
756
+ const params = new URLSearchParams({
757
+ orgId: orgId.value,
758
+ driveType: selectedDrive.value.driveType,
759
+ driveId: selectedDrive.value.driveId,
760
+ });
761
+ const res = await fetch(`${apiBase.value}/api/file-manager/files/${file.id}?${params.toString()}`, {
762
+ method: 'DELETE',
763
+ headers: { ...authHeaders() },
764
+ });
765
+ const data = await res.json().catch(() => ({}));
766
+ if (!res.ok) throw new Error(data.error || 'Delete failed');
767
+ await refreshFolder();
768
+ } catch (e) {
769
+ error.value = e.message || 'Delete failed';
770
+ } finally {
771
+ busy.value = false;
772
+ }
773
+ };
774
+
775
+ onMounted(async () => {
776
+ syncRoute();
777
+ window.addEventListener('hashchange', syncRoute);
778
+
779
+ if (token.value) {
780
+ try {
781
+ await loadOrgs();
782
+ } catch (e) {
783
+ error.value = e.message || 'Failed to load orgs';
784
+ }
785
+ }
786
+ });
787
+
788
+ watch(orgId, async () => {
789
+ if (!token.value) return;
790
+ try {
791
+ folderPath.value = '/';
792
+ await loadDrives();
793
+ localStorage.setItem(STORAGE_LAST_ORG_KEY, String(orgId.value || ''));
794
+ await loadStoragePolicy();
795
+ } catch (e) {
796
+ error.value = e.message || 'Failed to load drives';
797
+ }
798
+ });
799
+
800
+ watch(selectedDriveKey, async () => {
801
+ if (!token.value) return;
802
+ try {
803
+ localStorage.setItem(STORAGE_LAST_DRIVE_KEY, String(selectedDriveKey.value || ''));
804
+ await refreshFolder();
805
+ await loadStoragePolicy();
806
+ } catch (e) {
807
+ error.value = e.message || 'Failed to list folder';
808
+ }
809
+ });
810
+
811
+ watch(folderPath, async () => {
812
+ if (!token.value) return;
813
+ if (route.value !== 'browse') return;
814
+ try {
815
+ await refreshFolder();
816
+ } catch (e) {
817
+ error.value = e.message || 'Failed to list folder';
818
+ }
819
+ });
820
+
821
+ return {
822
+ token,
823
+ busy,
824
+ error,
825
+ route,
826
+ search,
827
+ viewMode,
828
+ filteredFiles,
829
+ filteredFolders,
830
+ breadcrumbCrumbs,
831
+ formatBytes,
832
+ storagePolicy,
833
+ editOpen,
834
+ editName,
835
+ editFolderPath,
836
+ editError,
837
+ loginEmail,
838
+ loginPassword,
839
+ doLogin,
840
+ logout,
841
+ orgs,
842
+ orgId,
843
+ drives,
844
+ selectedDriveKey,
845
+ folderPath,
846
+ files,
847
+ folders,
848
+ fileInputRef,
849
+ handleFileSelect,
850
+ refreshFolder,
851
+ goRoot,
852
+ goUp,
853
+ goToPath,
854
+ upload,
855
+ downloadFile,
856
+ openEdit,
857
+ closeEdit,
858
+ saveEdit,
859
+ toggleShare,
860
+ removeFile,
861
+ };
862
+ },
863
+ }).mount('#app');
864
+ </script>
865
+ </body>
866
+ </html>