@intranefr/superbackend 1.5.2 → 1.5.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.
Files changed (41) hide show
  1. package/index.js +2 -0
  2. package/manage.js +745 -0
  3. package/package.json +4 -2
  4. package/src/controllers/admin.controller.js +11 -5
  5. package/src/controllers/adminAgents.controller.js +37 -0
  6. package/src/controllers/adminLlm.controller.js +19 -0
  7. package/src/controllers/adminMarkdowns.controller.js +157 -0
  8. package/src/controllers/adminScripts.controller.js +138 -0
  9. package/src/controllers/adminTelegram.controller.js +72 -0
  10. package/src/controllers/markdowns.controller.js +42 -0
  11. package/src/helpers/mongooseHelper.js +6 -6
  12. package/src/helpers/scriptBase.js +2 -2
  13. package/src/middleware.js +136 -29
  14. package/src/models/Agent.js +105 -0
  15. package/src/models/AgentMessage.js +82 -0
  16. package/src/models/Markdown.js +75 -0
  17. package/src/models/ScriptRun.js +8 -0
  18. package/src/models/TelegramBot.js +42 -0
  19. package/src/routes/adminAgents.routes.js +13 -0
  20. package/src/routes/adminLlm.routes.js +1 -0
  21. package/src/routes/adminMarkdowns.routes.js +16 -0
  22. package/src/routes/adminScripts.routes.js +4 -1
  23. package/src/routes/adminTelegram.routes.js +14 -0
  24. package/src/routes/markdowns.routes.js +16 -0
  25. package/src/services/agent.service.js +546 -0
  26. package/src/services/agentHistory.service.js +345 -0
  27. package/src/services/agentTools.service.js +578 -0
  28. package/src/services/jsonConfigs.service.js +22 -10
  29. package/src/services/llm.service.js +219 -6
  30. package/src/services/markdowns.service.js +522 -0
  31. package/src/services/scriptsRunner.service.js +328 -37
  32. package/src/services/telegram.service.js +130 -0
  33. package/views/admin-agents.ejs +273 -0
  34. package/views/admin-coolify-deploy.ejs +8 -8
  35. package/views/admin-dashboard.ejs +36 -5
  36. package/views/admin-experiments.ejs +1 -1
  37. package/views/admin-markdowns.ejs +905 -0
  38. package/views/admin-scripts.ejs +221 -4
  39. package/views/admin-telegram.ejs +269 -0
  40. package/views/partials/dashboard/nav-items.ejs +3 -0
  41. package/analysis-only.skill +0 -0
@@ -0,0 +1,905 @@
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
+ <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
7
+ <meta http-equiv="Pragma" content="no-cache" />
8
+ <meta http-equiv="Expires" content="0" />
9
+ <title>Markdowns - Admin</title>
10
+ <script src="https://cdn.tailwindcss.com?plugins=typography"></script>
11
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
12
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
13
+ <style>
14
+ .toast {
15
+ animation: slideIn 0.3s ease-out;
16
+ }
17
+ @keyframes slideIn {
18
+ from {
19
+ transform: translateX(100%);
20
+ opacity: 0;
21
+ }
22
+ to {
23
+ transform: translateX(0);
24
+ opacity: 1;
25
+ }
26
+ }
27
+ .fade-out {
28
+ animation: fadeOut 0.3s ease-out forwards;
29
+ }
30
+ @keyframes fadeOut {
31
+ from {
32
+ opacity: 1;
33
+ }
34
+ to {
35
+ opacity: 0;
36
+ }
37
+ }
38
+ .tab-active {
39
+ border-bottom: 2px solid #3b82f6;
40
+ color: #3b82f6;
41
+ }
42
+ .tree-item {
43
+ cursor: pointer;
44
+ user-select: none;
45
+ }
46
+ .tree-item:hover {
47
+ background-color: #f3f4f6;
48
+ }
49
+ .tree-item.selected {
50
+ background-color: #dbeafe;
51
+ }
52
+ .folder-icon::before {
53
+ content: '📁';
54
+ }
55
+ .folder-icon.open::before {
56
+ content: '📂';
57
+ }
58
+ .file-icon::before {
59
+ content: '📄';
60
+ }
61
+ .category-icon::before {
62
+ content: '🏷️';
63
+ }
64
+ </style>
65
+ </head>
66
+ <body class="bg-gray-100">
67
+ <div id="app">
68
+ <div class="min-h-screen">
69
+ <div class="bg-white shadow">
70
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
71
+ <div class="flex justify-between items-center">
72
+ <div>
73
+ <h1 class="text-2xl font-bold text-gray-900">Markdowns</h1>
74
+ <p class="text-sm text-gray-600 mt-1">Manage markdown content with hierarchical organization and public access.</p>
75
+ </div>
76
+ <div class="flex items-center gap-4">
77
+ <button @click="loadData" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 flex items-center">
78
+ Refresh
79
+ </button>
80
+ <button @click="openCreateModal" class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 flex items-center">
81
+ New Markdown
82
+ </button>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ </div>
87
+
88
+ <!-- Tabs -->
89
+ <div class="bg-white border-b">
90
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
91
+ <div class="flex space-x-8">
92
+ <button
93
+ @click="activeTab = 'list'"
94
+ :class="['py-4 px-1 border-b-2 font-medium text-sm transition-colors', activeTab === 'list' ? 'tab-active' : 'border-transparent text-gray-500 hover:text-gray-700']">
95
+ List View
96
+ </button>
97
+ <button
98
+ @click="activeTab = 'explorer'"
99
+ :class="['py-4 px-1 border-b-2 font-medium text-sm transition-colors', activeTab === 'explorer' ? 'tab-active' : 'border-transparent text-gray-500 hover:text-gray-700']">
100
+ Explorer View
101
+ </button>
102
+ </div>
103
+ </div>
104
+ </div>
105
+
106
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
107
+ <!-- List Mode -->
108
+ <div v-if="activeTab === 'list'">
109
+ <!-- Filters -->
110
+ <div class="bg-white rounded-lg shadow mb-6 p-4">
111
+ <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
112
+ <div>
113
+ <label class="block text-sm font-medium text-gray-700 mb-1">Category</label>
114
+ <input v-model="filters.category" type="text" placeholder="Filter by category"
115
+ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
116
+ </div>
117
+ <div>
118
+ <label class="block text-sm font-medium text-gray-700 mb-1">Group Code</label>
119
+ <input v-model="filters.group_code" type="text" placeholder="Filter by group code"
120
+ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
121
+ </div>
122
+ <div>
123
+ <label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
124
+ <select v-model="filters.status" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
125
+ <option value="">All</option>
126
+ <option value="published">Published</option>
127
+ <option value="draft">Draft</option>
128
+ <option value="archived">Archived</option>
129
+ </select>
130
+ </div>
131
+ <div>
132
+ <label class="block text-sm font-medium text-gray-700 mb-1">Search</label>
133
+ <input v-model="filters.search" @input="debouncedLoadData" type="text" placeholder="Search..."
134
+ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
135
+ </div>
136
+ </div>
137
+ </div>
138
+
139
+ <!-- Data Table -->
140
+ <div class="bg-white rounded-lg shadow">
141
+ <div class="overflow-x-auto">
142
+ <table class="min-w-full divide-y divide-gray-200">
143
+ <thead class="bg-gray-50">
144
+ <tr>
145
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
146
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Category</th>
147
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Group Code</th>
148
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Slug</th>
149
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
150
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Public</th>
151
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Updated</th>
152
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
153
+ </tr>
154
+ </thead>
155
+ <tbody class="bg-white divide-y divide-gray-200">
156
+ <tr v-for="item in data.items" :key="item._id" class="hover:bg-gray-50">
157
+ <td class="px-4 py-3 text-sm font-medium text-gray-900">{{ item.title }}</td>
158
+ <td class="px-4 py-3 text-sm text-gray-500">{{ item.category }}</td>
159
+ <td class="px-4 py-3 text-sm text-gray-500">{{ item.group_code || '-' }}</td>
160
+ <td class="px-4 py-3 text-sm text-gray-500">{{ item.slug }}</td>
161
+ <td class="px-4 py-3 text-sm">
162
+ <span :class="['px-2 py-1 text-xs rounded-full',
163
+ item.status === 'published' ? 'bg-green-100 text-green-800' :
164
+ item.status === 'draft' ? 'bg-yellow-100 text-yellow-800' :
165
+ 'bg-gray-100 text-gray-800']">
166
+ {{ item.status }}
167
+ </span>
168
+ </td>
169
+ <td class="px-4 py-3 text-sm">
170
+ <span v-if="item.publicEnabled" class="text-green-600">✓</span>
171
+ <span v-else class="text-gray-400">-</span>
172
+ </td>
173
+ <td class="px-4 py-3 text-sm text-gray-500">{{ formatDate(item.updatedAt) }}</td>
174
+ <td class="px-4 py-3 text-sm">
175
+ <button v-if="item.publicEnabled" @click="copyPublicUrl(item)" class="text-green-600 hover:text-green-900 mr-3" title="Copy Public URL">
176
+ <svg class="w-5 h-5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path></svg>
177
+ </button>
178
+ <button @click="editItem(item)" class="text-blue-600 hover:text-blue-900 mr-3">Edit</button>
179
+ <button @click="deleteItem(item)" class="text-red-600 hover:text-red-900">Delete</button>
180
+ </td>
181
+ </tr>
182
+ </tbody>
183
+ </table>
184
+ </div>
185
+
186
+ <!-- Pagination -->
187
+ <div v-if="data.total > data.limit" class="px-4 py-3 border-t flex items-center justify-between">
188
+ <div class="text-sm text-gray-700">
189
+ Showing {{ data.skip + 1 }} to {{ Math.min(data.skip + data.limit, data.total) }} of {{ data.total }} results
190
+ </div>
191
+ <div class="flex gap-2">
192
+ <button @click="prevPage" :disabled="!hasPrevPage"
193
+ class="px-3 py-1 border rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed">
194
+ Previous
195
+ </button>
196
+ <button @click="nextPage" :disabled="!hasNextPage"
197
+ class="px-3 py-1 border rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed">
198
+ Next
199
+ </button>
200
+ </div>
201
+ </div>
202
+ </div>
203
+ </div>
204
+
205
+ <!-- Explorer Mode -->
206
+ <div v-if="activeTab === 'explorer'" class="grid grid-cols-1 lg:grid-cols-3 gap-6">
207
+ <!-- Tree View -->
208
+ <div class="bg-white rounded-lg shadow flex flex-col">
209
+ <div class="px-4 py-3 border-b">
210
+ <h2 class="text-lg font-semibold text-gray-900 mb-2">Categories</h2>
211
+ <div class="flex flex-wrap gap-2">
212
+ <button
213
+ v-for="cat in categories"
214
+ :key="cat"
215
+ @click="toggleCategory(cat)"
216
+ :class="['px-2 py-1 text-xs rounded-full border transition-colors',
217
+ selectedCategories.includes(cat) ? 'bg-blue-500 text-white border-blue-500' : 'bg-gray-100 text-gray-600 border-gray-200 hover:bg-gray-200']">
218
+ {{ cat }}
219
+ </button>
220
+ </div>
221
+ </div>
222
+ <div class="p-4 flex-1 max-h-[600px] overflow-y-auto">
223
+ <div v-if="selectedCategories.length === 0" class="text-gray-500 text-sm text-center py-4">
224
+ Select one or more categories to explore
225
+ </div>
226
+ <div v-else>
227
+ <tree-item
228
+ v-for="(node, name) in treeData"
229
+ :key="name"
230
+ :name="name"
231
+ :node="node"
232
+ :path="[]"
233
+ @select="selectNode"
234
+ @toggle="toggleNode">
235
+ </tree-item>
236
+ </div>
237
+ </div>
238
+ </div>
239
+
240
+ <!-- Folder Contents - Windows Explorer Style -->
241
+ <div class="lg:col-span-2 bg-white rounded-lg shadow">
242
+ <div class="px-4 py-3 border-b">
243
+ <div class="flex items-center justify-between">
244
+ <div>
245
+ <h2 class="text-lg font-semibold text-gray-900">Files</h2>
246
+ <!-- Breadcrumb Navigation -->
247
+ <div class="text-sm text-gray-600 mt-1">
248
+ <span v-if="activeCategory" class="font-medium text-gray-900">{{ activeCategory }}</span>
249
+ <span v-else class="italic text-gray-400">No folder selected</span>
250
+ <span v-for="(segment, index) in currentPath" :key="index">
251
+ / <button @click="navigateToFolder(index)" class="text-blue-600 hover:underline">{{ segment }}</button>
252
+ </span>
253
+ </div>
254
+ </div>
255
+ <button @click="openCreateModal" class="bg-green-500 text-white px-3 py-1 rounded text-sm hover:bg-green-600">
256
+ New Markdown
257
+ </button>
258
+ </div>
259
+ </div>
260
+ <div class="p-4">
261
+ <div v-if="folderContents.items.length === 0" class="text-gray-500 text-center py-8">
262
+ No files in this folder
263
+ </div>
264
+ <div v-else class="space-y-2">
265
+ <!-- Files List (Windows Explorer style) -->
266
+ <div v-for="item in folderContents.items"
267
+ :key="item._id"
268
+ class="flex items-center p-3 hover:bg-gray-100 rounded cursor-pointer border group">
269
+ <div @click="selectFileFromContent(item)" class="flex items-center flex-1">
270
+ <div class="w-8 h-8 bg-gray-100 rounded flex items-center justify-center mr-3">
271
+ <svg class="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
272
+ <path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"></path>
273
+ </svg>
274
+ </div>
275
+ <div class="flex-1">
276
+ <div class="font-medium">{{ item.title }}</div>
277
+ <div class="text-sm text-gray-500">{{ item.slug }} • {{ item.status }}</div>
278
+ </div>
279
+ </div>
280
+ <div class="flex items-center gap-2">
281
+ <button v-if="item.publicEnabled" @click.stop="copyPublicUrl(item)" class="p-1 text-gray-400 hover:text-green-600 rounded border border-transparent hover:border-green-200" title="Copy Public URL">
282
+ <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="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path></svg>
283
+ </button>
284
+ <div class="text-sm text-gray-400">
285
+ {{ formatDate(item.updatedAt) }}
286
+ </div>
287
+ </div>
288
+ </div>
289
+ </div>
290
+ </div>
291
+ </div>
292
+ </div>
293
+ </div>
294
+ </div>
295
+
296
+ <!-- Create/Edit Modal -->
297
+ <div v-if="showCreateModal || editingItem" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
298
+ <div :class="['bg-white shadow-xl flex flex-col transition-all duration-300',
299
+ zenMode ? 'w-screen h-screen rounded-none' : 'w-full mx-4 max-w-7xl h-[90vh] rounded-lg']">
300
+
301
+ <!-- Modal Header -->
302
+ <div :class="['px-6 py-4 border-b flex justify-between items-center bg-white', zenMode ? '' : 'rounded-t-lg']">
303
+ <div class="flex items-center gap-4 flex-1">
304
+ <h3 class="text-lg font-semibold text-gray-900 whitespace-nowrap">
305
+ {{ editingItem ? 'Edit Markdown' : 'Create Markdown' }}
306
+ </h3>
307
+ <input v-if="zenMode" v-model="formData.title" type="text" placeholder="Title..."
308
+ class="flex-1 max-w-md px-3 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500">
309
+ </div>
310
+ <div class="flex gap-2 ml-4">
311
+ <button @click="zenMode = !zenMode" class="text-gray-500 hover:text-blue-600 p-2 rounded border border-gray-200" :title="zenMode ? 'Exit Zen Mode' : 'Zen Mode'">
312
+ <svg v-if="!zenMode" 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="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"></path></svg>
313
+ <svg v-else 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="M6 18L18 6M6 6l12 12"></path></svg>
314
+ </button>
315
+ <button @click="copyToClipboard" class="text-gray-500 hover:text-blue-600 p-2 rounded border border-gray-200" title="Copy Markdown to Clipboard">
316
+ <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="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></svg>
317
+ </button>
318
+ </div>
319
+ </div>
320
+
321
+ <!-- Modal Body -->
322
+ <div :class="['flex-1 min-h-0', zenMode ? 'p-2' : 'p-6']">
323
+ <div class="h-full flex flex-col gap-4">
324
+ <!-- Meta fields (Hidden in Zen Mode) -->
325
+ <div v-if="!zenMode" class="grid grid-cols-1 md:grid-cols-2 gap-4 flex-none">
326
+ <div>
327
+ <label class="block text-sm font-medium text-gray-700 mb-1">Title *</label>
328
+ <input v-model="formData.title" type="text" required
329
+ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
330
+ </div>
331
+ <div class="grid grid-cols-2 gap-4">
332
+ <div>
333
+ <label class="block text-sm font-medium text-gray-700 mb-1">Category *</label>
334
+ <input v-model="formData.category" type="text" required
335
+ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
336
+ </div>
337
+ <div>
338
+ <label class="block text-sm font-medium text-gray-700 mb-1">Group Code</label>
339
+ <input v-model="formData.group_code" type="text" placeholder="folder__subfolder"
340
+ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
341
+ </div>
342
+ </div>
343
+ </div>
344
+
345
+ <!-- Editor/Preview Area -->
346
+ <div class="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-6 min-h-0">
347
+ <div class="flex flex-col h-full min-h-0">
348
+ <div v-if="!zenMode" class="flex justify-between items-center mb-1 flex-none">
349
+ <label class="block text-sm font-medium text-gray-700">Content *</label>
350
+ <span class="text-xs text-gray-500">Markdown supported</span>
351
+ </div>
352
+ <textarea v-model="formData.markdownRaw" required
353
+ class="flex-1 w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm resize-none overflow-y-auto"></textarea>
354
+ </div>
355
+ <div class="flex flex-col h-full min-h-0">
356
+ <label v-if="!zenMode" class="block text-sm font-medium text-gray-700 mb-1 flex-none">Preview</label>
357
+ <div class="flex-1 border border-gray-200 rounded-md p-4 bg-gray-50 overflow-y-auto prose prose-sm max-w-none" v-html="previewHtml">
358
+ </div>
359
+ </div>
360
+ </div>
361
+
362
+ <!-- Extra fields (Hidden in Zen Mode) -->
363
+ <div v-if="!zenMode" class="grid grid-cols-3 gap-4 flex-none">
364
+ <div>
365
+ <label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
366
+ <select v-model="formData.status" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
367
+ <option value="draft">Draft</option>
368
+ <option value="published">Published</option>
369
+ <option value="archived">Archived</option>
370
+ </select>
371
+ </div>
372
+ <div>
373
+ <label class="block text-sm font-medium text-gray-700 mb-1">Public Access</label>
374
+ <label class="flex items-center mt-2">
375
+ <input v-model="formData.publicEnabled" type="checkbox" class="mr-2">
376
+ <span class="text-sm">Enable public access</span>
377
+ </label>
378
+ </div>
379
+ <div>
380
+ <label class="block text-sm font-medium text-gray-700 mb-1">Cache TTL (seconds)</label>
381
+ <input v-model="formData.cacheTtlSeconds" type="number" min="0"
382
+ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
383
+ </div>
384
+ </div>
385
+ </div>
386
+ </div>
387
+
388
+ <!-- Modal Footer -->
389
+ <div v-if="!zenMode" class="px-6 py-4 border-t flex justify-end gap-3 bg-white rounded-b-lg">
390
+ <button @click="closeModal" class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">
391
+ Cancel
392
+ </button>
393
+ <button @click="saveItem" :disabled="saving"
394
+ class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
395
+ {{ saving ? 'Saving...' : (editingItem ? 'Update' : 'Create') }}
396
+ </button>
397
+ </div>
398
+
399
+ <!-- Floating Zen Footer -->
400
+ <div v-if="zenMode"
401
+ :class="['fixed bottom-8 left-1/2 -translate-x-1/2 flex gap-4 bg-white/90 backdrop-blur shadow-2xl border border-gray-200 p-4 rounded-full transition-all duration-300 z-50',
402
+ showFloatingFooter ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10 pointer-events-none']">
403
+ <button @click="closeModal" class="px-6 py-2 border border-gray-300 rounded-full text-gray-700 hover:bg-gray-100 font-medium">
404
+ Cancel
405
+ </button>
406
+ <button @click="saveItem" :disabled="saving"
407
+ class="px-8 py-2 bg-blue-600 text-white rounded-full hover:bg-blue-700 disabled:opacity-50 font-bold shadow-lg shadow-blue-200">
408
+ {{ saving ? 'Saving...' : (editingItem ? 'Update' : 'Create') }}
409
+ </button>
410
+ </div>
411
+ </div>
412
+ </div>
413
+
414
+ <!-- Toast Container -->
415
+ <div class="fixed top-4 right-4 z-50">
416
+ <div v-for="toast in toasts" :key="toast.id"
417
+ :class="['toast p-4 rounded-lg shadow-lg mb-2', toast.type === 'success' ? 'bg-green-500 text-white' : 'bg-red-500 text-white']">
418
+ {{ toast.message }}
419
+ </div>
420
+ </div>
421
+ </div>
422
+
423
+ <script>
424
+ const { createApp } = Vue;
425
+
426
+ const app = createApp({
427
+ data() {
428
+ return {
429
+ activeTab: 'list',
430
+ data: { items: [], total: 0, limit: 50, skip: 0 },
431
+ filters: { category: '', group_code: '', status: '', search: '' },
432
+ categories: [],
433
+ selectedCategories: [],
434
+ activeCategory: '',
435
+ treeData: {},
436
+ currentPath: [],
437
+ folderContents: { items: [], total: 0, limit: 100, skip: 0 },
438
+ showCreateModal: false,
439
+ editingItem: null,
440
+ formData: {
441
+ title: '',
442
+ category: '',
443
+ group_code: '',
444
+ markdownRaw: '',
445
+ status: 'draft',
446
+ publicEnabled: false,
447
+ cacheTtlSeconds: 0
448
+ },
449
+ saving: false,
450
+ zenMode: false,
451
+ showFloatingFooter: false,
452
+ initialFormData: '',
453
+ baseUrl: '<%= baseUrl %>',
454
+ toasts: [],
455
+ debounceTimer: null
456
+ };
457
+ },
458
+ computed: {
459
+ hasPrevPage() {
460
+ return this.data.skip > 0;
461
+ },
462
+ hasNextPage() {
463
+ return this.data.skip + this.data.limit < this.data.total;
464
+ },
465
+ previewHtml() {
466
+ if (!this.formData.markdownRaw) return '<p class="text-gray-400 italic">No content to preview</p>';
467
+ try {
468
+ return marked.parse(this.formData.markdownRaw);
469
+ } catch (e) {
470
+ return '<p class="text-red-500">Error parsing markdown</p>';
471
+ }
472
+ },
473
+ isDirty() {
474
+ return this.initialFormData !== JSON.stringify(this.formData);
475
+ }
476
+ },
477
+ methods: {
478
+ async loadData() {
479
+ try {
480
+ const params = new URLSearchParams({
481
+ ...this.filters,
482
+ page: Math.floor(this.data.skip / this.data.limit) + 1,
483
+ limit: this.data.limit
484
+ });
485
+
486
+ const response = await fetch(`<%= baseUrl %>/api/admin/markdowns?${params}`);
487
+ const result = await response.json();
488
+
489
+ if (response.ok) {
490
+ this.data = result;
491
+ this.extractCategories();
492
+ } else {
493
+ this.showToast(result.error || 'Failed to load data', 'error');
494
+ }
495
+ } catch (error) {
496
+ this.showToast('Network error', 'error');
497
+ }
498
+ },
499
+ debouncedLoadData() {
500
+ clearTimeout(this.debounceTimer);
501
+ this.debounceTimer = setTimeout(() => {
502
+ this.data.skip = 0;
503
+ this.loadData();
504
+ }, 500);
505
+ },
506
+ extractCategories() {
507
+ const cats = new Set(this.data.items.map(item => item.category));
508
+ this.categories = Array.from(cats).sort();
509
+ // If no categories selected and we have categories, select the first one by default
510
+ if (this.selectedCategories.length === 0 && this.categories.length > 0) {
511
+ this.toggleCategory(this.categories[0]);
512
+ }
513
+ },
514
+ toggleCategory(cat) {
515
+ const index = this.selectedCategories.indexOf(cat);
516
+ if (index === -1) {
517
+ this.selectedCategories.push(cat);
518
+ } else {
519
+ this.selectedCategories.splice(index, 1);
520
+ }
521
+ this.loadTree();
522
+ },
523
+ openCreateModal() {
524
+ this.formData = {
525
+ title: '',
526
+ category: this.activeCategory || (this.selectedCategories.length > 0 ? this.selectedCategories[0] : ''),
527
+ group_code: this.currentPath.join('__'),
528
+ markdownRaw: '',
529
+ status: 'draft',
530
+ publicEnabled: false,
531
+ cacheTtlSeconds: 0
532
+ };
533
+ this.initialFormData = JSON.stringify(this.formData);
534
+ this.showCreateModal = true;
535
+ },
536
+ async loadTree() {
537
+ if (this.selectedCategories.length === 0) {
538
+ this.treeData = {};
539
+ return;
540
+ }
541
+
542
+ try {
543
+ // Step 1: Fetch unique group codes for all selected categories
544
+ const results = await Promise.all(this.selectedCategories.map(async (cat) => {
545
+ const response = await fetch(`<%= baseUrl %>/api/admin/markdowns/group-codes/${cat}`);
546
+ const groupCodes = await response.json();
547
+ return { category: cat, groupCodes };
548
+ }));
549
+
550
+ // Step 2: Build combined tree client-side
551
+ this.treeData = this.buildTreeFromGroupCodes(results);
552
+ } catch (error) {
553
+ this.showToast('Network error loading tree', 'error');
554
+ }
555
+ },
556
+ async loadFolderContents() {
557
+ if (!this.activeCategory) return;
558
+
559
+ try {
560
+ const groupCode = this.currentPath.join('__');
561
+ const path = groupCode ? `/${this.activeCategory}/${groupCode}` : `/${this.activeCategory}`;
562
+ const response = await fetch(`<%= baseUrl %>/api/admin/markdowns/folder${path}`);
563
+ const result = await response.json();
564
+
565
+ if (response.ok) {
566
+ this.folderContents = result;
567
+ } else {
568
+ this.showToast(result.error || 'Failed to load folder contents', 'error');
569
+ }
570
+ } catch (error) {
571
+ this.showToast('Network error', 'error');
572
+ }
573
+ },
574
+ selectNode(node, path) {
575
+ if (node._type === 'file') {
576
+ // Find and edit the file
577
+ const item = this.data.items.find(i =>
578
+ i.slug === node.slug &&
579
+ i.group_code === node.group_code
580
+ );
581
+ if (item) this.editItem(item);
582
+ } else {
583
+ // Navigate to folder
584
+ // First element of path is the category
585
+ this.activeCategory = path[0];
586
+ const folderPath = path.slice(1);
587
+
588
+ if (folderPath.includes('(uncategorized)')) {
589
+ // Special handling for uncategorized folder
590
+ this.currentPath = []; // Empty path for root-level files
591
+ } else {
592
+ this.currentPath = folderPath;
593
+ }
594
+ this.loadFolderContents();
595
+ }
596
+ },
597
+ toggleNode(name, path) {
598
+ // Toggle folder expansion
599
+ let current = this.treeData;
600
+
601
+ // Navigate to the node
602
+ for (let i = 0; i < path.length - 1; i++) {
603
+ const part = path[i];
604
+ if (current[part] && current[part].children) {
605
+ current = current[part].children;
606
+ }
607
+ }
608
+
609
+ if (current[name] && current[name]._type === 'folder') {
610
+ current[name].expanded = !current[name].expanded;
611
+ }
612
+ },
613
+ buildTreeFromGroupCodes(results) {
614
+ const tree = {};
615
+
616
+ results.forEach(({ category, groupCodes }) => {
617
+ // Each category is a root folder
618
+ tree[category] = {
619
+ _type: 'folder',
620
+ _isCategory: true,
621
+ expanded: true,
622
+ children: {}
623
+ };
624
+
625
+ const catChildren = tree[category].children;
626
+
627
+ groupCodes.forEach(groupCode => {
628
+ if (!groupCode) {
629
+ // Create uncategorized folder for empty group codes
630
+ if (!catChildren['(uncategorized)']) {
631
+ catChildren['(uncategorized)'] = { _type: 'folder', children: {} };
632
+ }
633
+ } else {
634
+ const parts = groupCode.split('__');
635
+ let current = catChildren;
636
+
637
+ parts.forEach(part => {
638
+ if (!current[part]) {
639
+ current[part] = { _type: 'folder', children: {} };
640
+ }
641
+ current = current[part].children;
642
+ });
643
+ }
644
+ });
645
+ });
646
+
647
+ return tree;
648
+ },
649
+ editItem(item) {
650
+ this.editingItem = item;
651
+ this.formData = {
652
+ title: item.title,
653
+ category: item.category,
654
+ group_code: item.group_code || '',
655
+ markdownRaw: item.markdownRaw || '',
656
+ status: item.status,
657
+ publicEnabled: item.publicEnabled,
658
+ cacheTtlSeconds: item.cacheTtlSeconds || 0
659
+ };
660
+ this.initialFormData = JSON.stringify(this.formData);
661
+ },
662
+ async deleteItem(item) {
663
+ if (!confirm(`Are you sure you want to delete "${item.title}"?`)) return;
664
+
665
+ try {
666
+ const response = await fetch(`<%= baseUrl %>/api/admin/markdowns/${item._id}`, {
667
+ method: 'DELETE'
668
+ });
669
+
670
+ if (response.ok) {
671
+ this.showToast('Markdown deleted successfully', 'success');
672
+ this.loadData();
673
+ if (this.activeTab === 'explorer') {
674
+ this.loadTree();
675
+ this.loadFolderContents();
676
+ }
677
+ } else {
678
+ const result = await response.json();
679
+ this.showToast(result.error || 'Failed to delete', 'error');
680
+ }
681
+ } catch (error) {
682
+ this.showToast('Network error', 'error');
683
+ }
684
+ },
685
+ async saveItem() {
686
+ this.saving = true;
687
+
688
+ try {
689
+ const url = this.editingItem
690
+ ? `<%= baseUrl %>/api/admin/markdowns/${this.editingItem._id}`
691
+ : `<%= baseUrl %>/api/admin/markdowns`;
692
+
693
+ const method = this.editingItem ? 'PUT' : 'POST';
694
+
695
+ const response = await fetch(url, {
696
+ method,
697
+ headers: {
698
+ 'Content-Type': 'application/json'
699
+ },
700
+ body: JSON.stringify(this.formData)
701
+ });
702
+
703
+ if (response.ok) {
704
+ this.showToast(this.editingItem ? 'Markdown updated successfully' : 'Markdown created successfully', 'success');
705
+ this.closeModal();
706
+ this.loadData();
707
+ if (this.activeTab === 'explorer') {
708
+ this.loadTree();
709
+ this.loadFolderContents();
710
+ }
711
+ } else {
712
+ const result = await response.json();
713
+ this.showToast(result.error || 'Failed to save', 'error');
714
+ }
715
+ } catch (error) {
716
+ this.showToast('Network error', 'error');
717
+ } finally {
718
+ this.saving = false;
719
+ }
720
+ },
721
+ closeModal() {
722
+ this.showCreateModal = false;
723
+ this.editingItem = null;
724
+ this.zenMode = false;
725
+ this.formData = {
726
+ title: '',
727
+ category: '',
728
+ group_code: '',
729
+ markdownRaw: '',
730
+ status: 'draft',
731
+ publicEnabled: false,
732
+ cacheTtlSeconds: 0
733
+ };
734
+ },
735
+ prevPage() {
736
+ if (this.hasPrevPage) {
737
+ this.data.skip -= this.data.limit;
738
+ this.loadData();
739
+ }
740
+ },
741
+ nextPage() {
742
+ if (this.hasNextPage) {
743
+ this.data.skip += this.data.limit;
744
+ this.loadData();
745
+ }
746
+ },
747
+ formatDate(dateStr) {
748
+ return new Date(dateStr).toLocaleString();
749
+ },
750
+ showToast(message, type = 'success') {
751
+ const toast = { id: Date.now(), message, type };
752
+ this.toasts.push(toast);
753
+
754
+ setTimeout(() => {
755
+ const index = this.toasts.findIndex(t => t.id === toast.id);
756
+ if (index > -1) {
757
+ this.toasts.splice(index, 1);
758
+ }
759
+ }, 3000);
760
+ },
761
+ navigateToFolder(index) {
762
+ this.currentPath = this.currentPath.slice(0, index + 1);
763
+ this.loadFolderContents();
764
+ },
765
+ selectFileFromContent(file) {
766
+ // Ensure file has all required data for editing
767
+ this.editItem(file);
768
+
769
+ // Auto-expand tree to show file location
770
+ this.expandTreeToPath(file.group_code);
771
+ },
772
+ expandTreeToPath(groupCode) {
773
+ if (!groupCode) return;
774
+
775
+ const pathParts = groupCode.split('__');
776
+ let current = this.treeData;
777
+
778
+ // Expand each folder in the path
779
+ for (let i = 0; i < pathParts.length; i++) {
780
+ const part = pathParts[i];
781
+ if (current[part] && current[part]._type === 'folder') {
782
+ current[part].expanded = true;
783
+ current = current[part].children;
784
+ }
785
+ }
786
+ },
787
+ async copyToClipboard() {
788
+ if (!this.formData.markdownRaw) return;
789
+ try {
790
+ await navigator.clipboard.writeText(this.formData.markdownRaw);
791
+ this.showToast('Markdown copied to clipboard', 'success');
792
+ } catch (err) {
793
+ this.showToast('Failed to copy to clipboard', 'error');
794
+ }
795
+ },
796
+ async copyPublicUrl(item) {
797
+ if (!item.publicEnabled) return;
798
+
799
+ const groupCodePart = item.group_code ? `${item.group_code}/` : '';
800
+ const publicUrl = `${window.location.origin}${this.baseUrl}/api/markdowns/${item.category}/${groupCodePart}${item.slug}`;
801
+
802
+ try {
803
+ await navigator.clipboard.writeText(publicUrl);
804
+ this.showToast('Public URL copied to clipboard', 'success');
805
+ } catch (err) {
806
+ this.showToast('Failed to copy URL', 'error');
807
+ }
808
+ },
809
+ handleMouseMove(e) {
810
+ if (!this.zenMode) return;
811
+ // Show footer if mouse is in the bottom 100px of the screen
812
+ this.showFloatingFooter = (window.innerHeight - e.clientY) < 100;
813
+ },
814
+ handleEscKey() {
815
+ if (this.showCreateModal || this.editingItem) {
816
+ if (this.isDirty) {
817
+ if (confirm('Save before closing?')) {
818
+ this.saveItem();
819
+ } else {
820
+ this.closeModal();
821
+ }
822
+ } else {
823
+ this.closeModal();
824
+ }
825
+ return true; // Handled
826
+ }
827
+ return false; // Not handled
828
+ }
829
+ },
830
+ mounted() {
831
+ this.loadData();
832
+ window.addEventListener('mousemove', this.handleMouseMove);
833
+ },
834
+ beforeUnmount() {
835
+ window.removeEventListener('mousemove', this.handleMouseMove);
836
+ },
837
+ watch: {
838
+ showCreateModal(val) {
839
+ if (val) document.body.style.overflow = 'hidden';
840
+ else if (!this.editingItem) document.body.style.overflow = '';
841
+ },
842
+ editingItem(val) {
843
+ if (val) document.body.style.overflow = 'hidden';
844
+ else if (!this.showCreateModal) document.body.style.overflow = '';
845
+ }
846
+ }
847
+ }).component('tree-item', {
848
+ props: ['name', 'node', 'path'],
849
+ template: `
850
+ <div>
851
+ <div @click="handleClick" class="tree-item px-2 py-1 rounded flex items-center justify-between">
852
+ <div class="flex items-center">
853
+ <span :class="['mr-2', node._isCategory ? 'category-icon' : (node._type === 'folder' ? 'folder-icon' : 'file-icon')]"></span>
854
+ <span :class="{'font-bold': node._isCategory}">{{ name }}</span>
855
+ </div>
856
+ <span v-if="node._type === 'file' && node.status" :class="[
857
+ 'text-xs px-2 py-0.5 rounded',
858
+ node.status === 'published' ? 'bg-green-100 text-green-800' :
859
+ node.status === 'draft' ? 'bg-yellow-100 text-yellow-800' :
860
+ 'bg-gray-100 text-gray-800'
861
+ ]">
862
+ {{ node.status }}
863
+ </span>
864
+ </div>
865
+ <div v-if="node._type === 'folder' && node.expanded" class="ml-4">
866
+ <tree-item
867
+ v-for="(child, childName) in node.children"
868
+ :key="childName"
869
+ :name="childName"
870
+ :node="child"
871
+ :path="[...path, name]"
872
+ @select="(node, path) => $emit('select', node, path)"
873
+ @toggle="(name, path) => $emit('toggle', name, path)">
874
+ </tree-item>
875
+ </div>
876
+ </div>
877
+ `,
878
+ methods: {
879
+ handleClick() {
880
+ if (this.node._type === 'folder') {
881
+ this.$emit('toggle', this.name, [...this.path, this.name]);
882
+ }
883
+ this.$emit('select', this.node, [...this.path, this.name]);
884
+ }
885
+ }
886
+ });
887
+
888
+ window.app = app.mount('#app');
889
+ </script>
890
+ <script>
891
+ window.addEventListener("keydown", (e) => {
892
+ if ((e.ctrlKey || e.metaKey) && e.key === "k") {
893
+ e.preventDefault();
894
+ window.parent.postMessage({ type: "keydown", ctrlK: true }, "*");
895
+ }
896
+ if (e.key === "Escape") {
897
+ if (window.app && window.app.handleEscKey()) {
898
+ return;
899
+ }
900
+ window.parent.postMessage({ type: "keydown", key: "Escape" }, "*");
901
+ }
902
+ });
903
+ </script>
904
+ </body>
905
+ </html>