@sonicjs-cms/core 2.0.0-alpha.7 → 2.0.0-alpha.9

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  var chunkAGOE25LF_cjs = require('./chunk-AGOE25LF.cjs');
4
4
  var chunkBUKT6HP5_cjs = require('./chunk-BUKT6HP5.cjs');
5
+ var chunkRNR4HA23_cjs = require('./chunk-RNR4HA23.cjs');
5
6
  var chunkRGCQSFKC_cjs = require('./chunk-RGCQSFKC.cjs');
6
7
  var chunkIGJUBJBW_cjs = require('./chunk-IGJUBJBW.cjs');
7
8
  var hono = require('hono');
@@ -9946,6 +9947,3669 @@ userRoutes.get("/activity-logs/export", chunkBUKT6HP5_cjs.requirePermission("act
9946
9947
  }
9947
9948
  });
9948
9949
 
9950
+ // src/templates/components/media-grid.template.ts
9951
+ function renderMediaGrid(data) {
9952
+ if (data.files.length === 0) {
9953
+ return `
9954
+ <div class="text-center py-12">
9955
+ <svg class="mx-auto h-12 w-12 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
9956
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
9957
+ </svg>
9958
+ <h3 class="mt-2 text-sm font-medium text-zinc-950 dark:text-white">No media files</h3>
9959
+ <p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">${data.emptyMessage || "Get started by uploading your first file."}</p>
9960
+ </div>
9961
+ `;
9962
+ }
9963
+ const gridClass = data.viewMode === "list" ? "space-y-4" : "media-grid";
9964
+ return `
9965
+ <div class="${gridClass} ${data.className || ""}">
9966
+ ${data.files.map(
9967
+ (file) => renderMediaFileCard(file, data.viewMode, data.selectable)
9968
+ ).join("")}
9969
+ </div>
9970
+ `;
9971
+ }
9972
+ function renderMediaFileCard(file, viewMode = "grid", selectable = false) {
9973
+ if (viewMode === "list") {
9974
+ return `
9975
+ <div class="media-item rounded-xl bg-white dark:bg-zinc-900 shadow-sm ring-1 ring-zinc-950/5 dark:ring-white/10 hover:shadow-md transition-all" data-file-id="${file.id}">
9976
+ <div class="flex items-center p-4">
9977
+ ${selectable ? `
9978
+ <div class="flex h-6 shrink-0 items-center mr-4">
9979
+ <div class="group grid size-4 grid-cols-1">
9980
+ <input type="checkbox" value="${file.id}" onchange="toggleFileSelection('${file.id}')" class="col-start-1 row-start-1 appearance-none rounded border border-zinc-950/10 dark:border-white/10 bg-white dark:bg-white/5 checked:border-indigo-500 checked:bg-indigo-500 indeterminate:border-indigo-500 indeterminate:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 disabled:border-zinc-950/5 dark:disabled:border-white/5 disabled:bg-zinc-950/10 dark:disabled:bg-white/10 disabled:checked:bg-zinc-950/10 dark:disabled:checked:bg-white/10 forced-colors:appearance-auto media-checkbox" />
9981
+ <svg viewBox="0 0 14 14" fill="none" class="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-[:disabled]:stroke-zinc-950/25 dark:group-has-[:disabled]:stroke-white/25">
9982
+ <path d="M3 8L6 11L11 3.5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-0 group-has-[:checked]:opacity-100" />
9983
+ <path d="M3 7H11" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-0 group-has-[:indeterminate]:opacity-100" />
9984
+ </svg>
9985
+ </div>
9986
+ </div>
9987
+ ` : ""}
9988
+
9989
+ <div class="flex-shrink-0 mr-4">
9990
+ ${file.isImage ? `
9991
+ <img src="${file.thumbnail_url || file.public_url}" alt="${file.alt || file.original_name}"
9992
+ class="w-16 h-16 object-cover rounded-lg ring-1 ring-zinc-950/10 dark:ring-white/10">
9993
+ ` : `
9994
+ <div class="w-16 h-16 bg-zinc-100 dark:bg-zinc-800 rounded-lg flex items-center justify-center">
9995
+ ${getFileIcon(file.mime_type)}
9996
+ </div>
9997
+ `}
9998
+ </div>
9999
+
10000
+ <div class="flex-1 min-w-0">
10001
+ <div class="flex items-center justify-between">
10002
+ <h4 class="text-sm font-medium text-zinc-950 dark:text-white truncate" title="${file.original_name}">
10003
+ ${file.original_name}
10004
+ </h4>
10005
+ <div class="flex items-center space-x-2">
10006
+ <span class="text-sm text-zinc-500 dark:text-zinc-400">${file.fileSize}</span>
10007
+ <button
10008
+ class="text-zinc-500 dark:text-zinc-400 hover:text-zinc-950 dark:hover:text-white transition-colors"
10009
+ hx-get="/admin/media/${file.id}/details"
10010
+ hx-target="#file-modal-content"
10011
+ onclick="document.getElementById('file-modal').classList.remove('hidden')"
10012
+ >
10013
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10014
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
10015
+ </svg>
10016
+ </button>
10017
+ </div>
10018
+ </div>
10019
+ <div class="mt-1 flex items-center text-sm text-zinc-500 dark:text-zinc-400">
10020
+ <span>${file.uploadedAt}</span>
10021
+ ${file.tags.length > 0 ? `
10022
+ <span class="mx-2">\u2022</span>
10023
+ <div class="flex items-center space-x-1">
10024
+ ${file.tags.slice(0, 2).map(
10025
+ (tag) => `
10026
+ <span class="inline-block px-2 py-1 text-xs rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-950 dark:text-white">
10027
+ ${tag}
10028
+ </span>
10029
+ `
10030
+ ).join("")}
10031
+ ${file.tags.length > 2 ? `<span class="text-xs text-zinc-500 dark:text-zinc-400">+${file.tags.length - 2}</span>` : ""}
10032
+ </div>
10033
+ ` : ""}
10034
+ </div>
10035
+ </div>
10036
+ </div>
10037
+ </div>
10038
+ `;
10039
+ }
10040
+ return `
10041
+ <div class="media-item relative rounded-xl bg-white dark:bg-zinc-900 shadow-sm ring-1 ring-zinc-950/5 dark:ring-white/10 hover:shadow-md transition-all duration-200 overflow-hidden group" data-file-id="${file.id}">
10042
+ ${selectable ? `
10043
+ <div class="absolute top-2 left-2 z-10">
10044
+ <div class="flex h-6 shrink-0 items-center">
10045
+ <div class="group grid size-4 grid-cols-1">
10046
+ <input type="checkbox" value="${file.id}" onchange="toggleFileSelection('${file.id}')" class="col-start-1 row-start-1 appearance-none rounded border border-zinc-950/10 dark:border-white/10 bg-white dark:bg-white/5 checked:border-indigo-500 checked:bg-indigo-500 indeterminate:border-indigo-500 indeterminate:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 disabled:border-zinc-950/5 dark:disabled:border-white/5 disabled:bg-zinc-950/10 dark:disabled:bg-white/10 disabled:checked:bg-zinc-950/10 dark:disabled:checked:bg-white/10 forced-colors:appearance-auto media-checkbox" />
10047
+ <svg viewBox="0 0 14 14" fill="none" class="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-[:disabled]:stroke-zinc-950/25 dark:group-has-[:disabled]:stroke-white/25">
10048
+ <path d="M3 8L6 11L11 3.5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-0 group-has-[:checked]:opacity-100" />
10049
+ <path d="M3 7H11" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-0 group-has-[:indeterminate]:opacity-100" />
10050
+ </svg>
10051
+ </div>
10052
+ </div>
10053
+ </div>
10054
+ ` : ""}
10055
+
10056
+ <div class="aspect-square relative">
10057
+ ${file.isImage ? `
10058
+ <img src="${file.thumbnail_url || file.public_url}" alt="${file.alt || file.original_name}"
10059
+ class="w-full h-full object-cover">
10060
+ ` : `
10061
+ <div class="w-full h-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center">
10062
+ ${getFileIcon(file.mime_type)}
10063
+ </div>
10064
+ `}
10065
+
10066
+ <!-- Overlay actions -->
10067
+ <div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-40 transition-all duration-200 flex items-center justify-center opacity-0 group-hover:opacity-100">
10068
+ <div class="flex space-x-2">
10069
+ <button
10070
+ hx-get="/admin/media/${file.id}/details"
10071
+ hx-target="#file-modal-content"
10072
+ onclick="document.getElementById('file-modal').classList.remove('hidden')"
10073
+ class="p-2 bg-white/20 rounded-full hover:bg-white/30 transition-colors"
10074
+ >
10075
+ <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10076
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
10077
+ <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>
10078
+ </svg>
10079
+ </button>
10080
+ <button
10081
+ onclick="event.stopPropagation(); copyToClipboard('${file.public_url}')"
10082
+ class="p-2 bg-white/20 rounded-full hover:bg-white/30 transition-colors"
10083
+ >
10084
+ <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10085
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
10086
+ </svg>
10087
+ </button>
10088
+ </div>
10089
+ </div>
10090
+ </div>
10091
+
10092
+ <div class="p-3">
10093
+ <h4 class="text-sm font-medium text-zinc-950 dark:text-white truncate" title="${file.original_name}">
10094
+ ${file.original_name}
10095
+ </h4>
10096
+ <div class="flex justify-between items-center mt-1">
10097
+ <span class="text-xs text-zinc-500 dark:text-zinc-400">${file.fileSize}</span>
10098
+ <span class="text-xs text-zinc-500 dark:text-zinc-400">${file.uploadedAt}</span>
10099
+ </div>
10100
+ ${file.tags.length > 0 ? `
10101
+ <div class="flex flex-wrap gap-1 mt-2">
10102
+ ${file.tags.slice(0, 2).map(
10103
+ (tag) => `
10104
+ <span class="inline-block px-2 py-1 text-xs rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-950 dark:text-white">
10105
+ ${tag}
10106
+ </span>
10107
+ `
10108
+ ).join("")}
10109
+ ${file.tags.length > 2 ? `<span class="text-xs text-zinc-500 dark:text-zinc-400">+${file.tags.length - 2}</span>` : ""}
10110
+ </div>
10111
+ ` : ""}
10112
+ </div>
10113
+ </div>
10114
+ `;
10115
+ }
10116
+ function getFileIcon(mimeType) {
10117
+ if (mimeType.startsWith("image/")) {
10118
+ return `
10119
+ <svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10120
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
10121
+ </svg>
10122
+ `;
10123
+ } else if (mimeType.startsWith("video/")) {
10124
+ return `
10125
+ <svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10126
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
10127
+ </svg>
10128
+ `;
10129
+ } else if (mimeType === "application/pdf") {
10130
+ return `
10131
+ <svg class="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10132
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
10133
+ </svg>
10134
+ `;
10135
+ } else {
10136
+ return `
10137
+ <svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10138
+ <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" />
10139
+ </svg>
10140
+ `;
10141
+ }
10142
+ }
10143
+
10144
+ // src/templates/pages/admin-media-library.template.ts
10145
+ init_admin_layout_catalyst_template();
10146
+ function renderMediaLibraryPage(data) {
10147
+ const pageContent = `
10148
+ <div>
10149
+ <!-- Header -->
10150
+ <div class="sm:flex sm:items-center sm:justify-between mb-6">
10151
+ <div class="sm:flex-auto">
10152
+ <h1 class="text-2xl/8 font-semibold text-zinc-950 dark:text-white sm:text-xl/8">Media Library</h1>
10153
+ <p class="mt-2 text-sm/6 text-zinc-500 dark:text-zinc-400">Manage your media files and assets</p>
10154
+ </div>
10155
+ <div class="mt-4 sm:mt-0 sm:ml-16 flex gap-x-2">
10156
+ <button
10157
+ class="inline-flex items-center justify-center rounded-lg bg-zinc-950 dark:bg-white px-3.5 py-2.5 text-sm font-semibold text-white dark:text-zinc-950 hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors shadow-sm"
10158
+ onclick="document.getElementById('upload-modal').classList.remove('hidden')"
10159
+ >
10160
+ <svg class="-ml-0.5 mr-1.5 h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
10161
+ <path d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z" />
10162
+ </svg>
10163
+ Upload Media
10164
+ </button>
10165
+ </div>
10166
+ </div>
10167
+
10168
+ <div class="flex gap-6">
10169
+ <!-- Sidebar -->
10170
+ <div class="w-64 rounded-xl bg-white dark:bg-zinc-900 shadow-sm ring-1 ring-zinc-950/5 dark:ring-white/10 p-6">
10171
+ <div class="space-y-6">
10172
+ <!-- Upload Button -->
10173
+ <div>
10174
+ <button
10175
+ class="w-full rounded-lg bg-zinc-950 dark:bg-white px-4 py-2.5 text-sm font-semibold text-white dark:text-zinc-950 hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors shadow-sm"
10176
+ onclick="document.getElementById('upload-modal').classList.remove('hidden')"
10177
+ >
10178
+ Upload Files
10179
+ </button>
10180
+ </div>
10181
+
10182
+ <!-- Folders -->
10183
+ <div>
10184
+ <h3 class="text-sm font-medium text-zinc-950 dark:text-white mb-3">Folders</h3>
10185
+ <ul class="space-y-1">
10186
+ <li>
10187
+ <a href="/admin/media?folder=all"
10188
+ class="block px-3 py-2 text-sm rounded-lg transition-colors ${data.currentFolder === "all" ? "bg-zinc-950 dark:bg-white text-white dark:text-zinc-950 font-medium" : "text-zinc-700 dark:text-zinc-300 hover:text-zinc-950 dark:hover:text-white hover:bg-zinc-50 dark:hover:bg-zinc-800/50"}">
10189
+ All Files (${data.totalFiles})
10190
+ </a>
10191
+ </li>
10192
+ ${data.folders.map(
10193
+ (folder) => `
10194
+ <li>
10195
+ <a href="/admin/media?folder=${folder.folder}"
10196
+ class="block px-3 py-2 text-sm rounded-lg transition-colors ${data.currentFolder === folder.folder ? "bg-zinc-950 dark:bg-white text-white dark:text-zinc-950 font-medium" : "text-zinc-700 dark:text-zinc-300 hover:text-zinc-950 dark:hover:text-white hover:bg-zinc-50 dark:hover:bg-zinc-800/50"}">
10197
+ ${folder.folder} (${folder.count})
10198
+ </a>
10199
+ </li>
10200
+ `
10201
+ ).join("")}
10202
+ </ul>
10203
+ </div>
10204
+
10205
+ <!-- File Types -->
10206
+ <div>
10207
+ <h3 class="text-sm font-medium text-zinc-950 dark:text-white mb-3">File Types</h3>
10208
+ <ul class="space-y-1">
10209
+ <li>
10210
+ <a href="/admin/media?type=all"
10211
+ class="block px-3 py-2 text-sm rounded-lg transition-colors ${data.currentType === "all" ? "bg-zinc-950 dark:bg-white text-white dark:text-zinc-950 font-medium" : "text-zinc-700 dark:text-zinc-300 hover:text-zinc-950 dark:hover:text-white hover:bg-zinc-50 dark:hover:bg-zinc-800/50"}">
10212
+ All Types
10213
+ </a>
10214
+ </li>
10215
+ ${data.types.map(
10216
+ (type) => `
10217
+ <li>
10218
+ <a href="/admin/media?type=${type.type}"
10219
+ class="block px-3 py-2 text-sm rounded-lg transition-colors ${data.currentType === type.type ? "bg-zinc-950 dark:bg-white text-white dark:text-zinc-950 font-medium" : "text-zinc-700 dark:text-zinc-300 hover:text-zinc-950 dark:hover:text-white hover:bg-zinc-50 dark:hover:bg-zinc-800/50"}">
10220
+ ${type.type.charAt(0).toUpperCase() + type.type.slice(1)} (${type.count})
10221
+ </a>
10222
+ </li>
10223
+ `
10224
+ ).join("")}
10225
+ </ul>
10226
+ </div>
10227
+
10228
+ <!-- Quick Actions -->
10229
+ <div>
10230
+ <h3 class="text-sm font-medium text-zinc-950 dark:text-white mb-3">Quick Actions</h3>
10231
+ <div class="space-y-2">
10232
+ <button
10233
+ onclick="openCreateFolderModal()"
10234
+ class="w-full text-left px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300 hover:text-zinc-950 dark:hover:text-white hover:bg-zinc-50 dark:hover:bg-zinc-800/50 rounded-lg transition-colors">
10235
+ Create Folder
10236
+ </button>
10237
+ <button
10238
+ class="w-full text-left px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300 hover:text-zinc-950 dark:hover:text-white hover:bg-zinc-50 dark:hover:bg-zinc-800/50 rounded-lg transition-colors"
10239
+ hx-delete="/media/cleanup"
10240
+ hx-confirm="Delete unused files?"
10241
+ >
10242
+ Cleanup Unused
10243
+ </button>
10244
+ </div>
10245
+ </div>
10246
+ </div>
10247
+ </div>
10248
+
10249
+ <!-- Main Content -->
10250
+ <div class="flex-1">
10251
+ <!-- Toolbar -->
10252
+ <div class="relative rounded-xl mb-6 z-10">
10253
+ <!-- Gradient Background -->
10254
+ <div class="absolute inset-0 bg-gradient-to-r from-cyan-500/10 via-pink-500/10 to-purple-500/10 dark:from-cyan-400/20 dark:via-pink-400/20 dark:to-purple-400/20"></div>
10255
+
10256
+ <div class="relative bg-white dark:bg-zinc-900 shadow-sm ring-1 ring-zinc-950/5 dark:ring-white/10">
10257
+ <div class="px-6 py-5">
10258
+ <div class="flex items-center justify-between">
10259
+ <div class="flex items-center space-x-4">
10260
+ <div class="flex items-center space-x-2">
10261
+ <label class="text-sm/6 font-medium text-zinc-950 dark:text-white">View:</label>
10262
+ <div class="grid grid-cols-1">
10263
+ <select
10264
+ class="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white/5 dark:bg-white/5 py-1.5 pl-3 pr-8 text-base text-zinc-950 dark:text-white outline outline-1 -outline-offset-1 outline-cyan-500/30 dark:outline-cyan-400/30 *:bg-white dark:*:bg-zinc-800 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-cyan-500 dark:focus-visible:outline-cyan-400 sm:text-sm/6 min-w-32"
10265
+ onchange="window.location.href = updateUrlParam('view', this.value)"
10266
+ >
10267
+ <option value="grid" ${data.currentView === "grid" ? "selected" : ""}>Grid</option>
10268
+ <option value="list" ${data.currentView === "list" ? "selected" : ""}>List</option>
10269
+ </select>
10270
+ <svg class="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end text-zinc-500 dark:text-zinc-400 sm:size-4" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
10271
+ <path fill-rule="evenodd" d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
10272
+ </svg>
10273
+ </div>
10274
+ </div>
10275
+
10276
+ <div class="relative group">
10277
+ <input
10278
+ type="text"
10279
+ id="media-search-input"
10280
+ name="search"
10281
+ placeholder="Search files..."
10282
+ oninput="toggleMediaClearButton()"
10283
+ class="rounded-full bg-white/90 dark:bg-zinc-800/90 backdrop-blur-sm px-4 py-2.5 pl-11 pr-10 text-sm w-72 text-zinc-950 dark:text-white border-2 border-cyan-200/50 dark:border-cyan-700/50 placeholder:text-zinc-400 dark:placeholder:text-zinc-500 focus:outline-none focus:border-cyan-500 dark:focus:border-cyan-400 focus:bg-white dark:focus:bg-zinc-800 focus:shadow-lg focus:shadow-cyan-500/20 dark:focus:shadow-cyan-400/20 transition-all duration-300"
10284
+ hx-get="/admin/media/search"
10285
+ hx-trigger="keyup changed delay:300ms"
10286
+ hx-target="#media-grid"
10287
+ hx-include="[name='folder'], [name='type']"
10288
+ >
10289
+ <div class="absolute left-3.5 top-2.5 flex items-center justify-center w-5 h-5 rounded-full bg-gradient-to-br from-cyan-400 to-blue-500 dark:from-cyan-300 dark:to-blue-400 opacity-90 group-focus-within:opacity-100 transition-opacity">
10290
+ <svg class="h-3 w-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
10291
+ <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
10292
+ </svg>
10293
+ </div>
10294
+ <button
10295
+ type="button"
10296
+ id="clear-media-search"
10297
+ class="hidden absolute right-3 top-2.5 p-0.5 rounded-full bg-zinc-200/80 dark:bg-zinc-700/80 hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors"
10298
+ onclick="clearMediaSearch()"
10299
+ title="Clear search"
10300
+ >
10301
+ <svg class="h-3.5 w-3.5 text-zinc-600 dark:text-zinc-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
10302
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
10303
+ </svg>
10304
+ </button>
10305
+ <input type="hidden" name="folder" value="${data.currentFolder}">
10306
+ <input type="hidden" name="type" value="${data.currentType}">
10307
+ </div>
10308
+ </div>
10309
+
10310
+ <div class="flex items-center gap-x-3">
10311
+ <span class="text-sm/6 font-medium text-zinc-700 dark:text-zinc-300 px-3 py-1.5 rounded-full bg-white/60 dark:bg-zinc-800/60 backdrop-blur-sm">${data.files.length} files</span>
10312
+ <button
10313
+ id="select-all-btn"
10314
+ class="inline-flex items-center gap-x-1.5 px-3 py-1.5 bg-white/90 dark:bg-zinc-800/90 backdrop-blur-sm text-zinc-950 dark:text-white text-sm font-medium rounded-full ring-1 ring-inset ring-cyan-200/50 dark:ring-cyan-700/50 hover:bg-gradient-to-r hover:from-cyan-50 hover:to-pink-50 dark:hover:from-cyan-900/30 dark:hover:to-pink-900/30 hover:ring-cyan-300 dark:hover:ring-cyan-600 transition-all duration-200"
10315
+ onclick="toggleSelectAll()"
10316
+ >
10317
+ Select All
10318
+ </button>
10319
+ <div class="relative inline-block z-50" id="bulk-actions-dropdown">
10320
+ <button
10321
+ id="bulk-actions-btn"
10322
+ onclick="toggleBulkActionsDropdown()"
10323
+ class="inline-flex items-center gap-x-1.5 px-3 py-1.5 bg-zinc-100/60 dark:bg-zinc-800/60 backdrop-blur-sm text-zinc-400 dark:text-zinc-600 text-sm font-medium rounded-full ring-1 ring-inset ring-zinc-200/50 dark:ring-zinc-700/50 cursor-not-allowed"
10324
+ disabled
10325
+ >
10326
+ Bulk Actions
10327
+ <svg viewBox="0 0 20 20" fill="currentColor" class="size-4">
10328
+ <path d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" fill-rule="evenodd" />
10329
+ </svg>
10330
+ </button>
10331
+
10332
+ <div
10333
+ id="bulk-actions-menu"
10334
+ class="hidden absolute right-0 mt-2 w-56 origin-top-right divide-y divide-zinc-200 dark:divide-white/10 rounded-lg bg-white dark:bg-zinc-900 shadow-xl ring-1 ring-zinc-950/5 dark:ring-white/10 transition-all duration-100 scale-95 opacity-0 z-50"
10335
+ style="transition-behavior: allow-discrete;"
10336
+ >
10337
+ <div class="py-1">
10338
+ <button
10339
+ onclick="openMoveToFolderModal()"
10340
+ class="group/item flex w-full items-center px-4 py-2 text-sm text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-white/5 hover:text-zinc-950 dark:hover:text-white transition-colors"
10341
+ >
10342
+ <svg viewBox="0 0 20 20" fill="currentColor" class="mr-3 size-5 text-zinc-400 dark:text-zinc-500 group-hover/item:text-zinc-950 dark:group-hover/item:text-white">
10343
+ <path d="M2 6a2 2 0 0 1 2-2h5.532a2 2 0 0 1 1.536.72l1.9 2.28a1 1 0 0 0 .768.36H17a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6Z" />
10344
+ </svg>
10345
+ Move to Folder
10346
+ </button>
10347
+ </div>
10348
+ <div class="py-1">
10349
+ <button
10350
+ onclick="confirmBulkDelete()"
10351
+ class="group/item flex w-full items-center px-4 py-2 text-sm text-zinc-700 dark:text-zinc-300 hover:bg-red-50 dark:hover:bg-red-500/10 hover:text-red-600 dark:hover:text-red-400 transition-colors"
10352
+ >
10353
+ <svg viewBox="0 0 20 20" fill="currentColor" class="mr-3 size-5 text-zinc-400 dark:text-zinc-500 group-hover/item:text-red-600 dark:group-hover/item:text-red-400">
10354
+ <path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52.149.023a.75.75 0 0 0 .23-1.482A41.03 41.03 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1h-2.5ZM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4ZM8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06l-.3-7.5Zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06l.3-7.5Z" clip-rule="evenodd" fill-rule="evenodd" />
10355
+ </svg>
10356
+ Delete Selected Files
10357
+ </button>
10358
+ </div>
10359
+ </div>
10360
+ </div>
10361
+ </div>
10362
+ </div>
10363
+ </div>
10364
+ </div>
10365
+ </div>
10366
+
10367
+ <!-- Media Grid -->
10368
+ <div id="media-grid">
10369
+ ${renderMediaGrid({
10370
+ files: data.files,
10371
+ viewMode: data.currentView,
10372
+ selectable: true,
10373
+ emptyMessage: "No media files found. Upload your first file to get started."
10374
+ })}
10375
+ </div>
10376
+
10377
+ <!-- Pagination -->
10378
+ ${data.hasNextPage ? `
10379
+ <div class="mt-6 flex justify-center">
10380
+ <div class="flex space-x-2">
10381
+ ${data.currentPage > 1 ? `
10382
+ <a href="${buildPageUrl(
10383
+ data.currentPage - 1,
10384
+ data.currentFolder,
10385
+ data.currentType
10386
+ )}"
10387
+ class="rounded-lg bg-white dark:bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-950 dark:text-white ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors">
10388
+ Previous
10389
+ </a>
10390
+ ` : ""}
10391
+ <span class="px-3 py-2 text-sm text-zinc-500 dark:text-zinc-400">Page ${data.currentPage}</span>
10392
+ <a href="${buildPageUrl(
10393
+ data.currentPage + 1,
10394
+ data.currentFolder,
10395
+ data.currentType
10396
+ )}"
10397
+ class="rounded-lg bg-white dark:bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-950 dark:text-white ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors">
10398
+ Next
10399
+ </a>
10400
+ </div>
10401
+ </div>
10402
+ ` : ""}
10403
+ </div>
10404
+ </div>
10405
+ </div>
10406
+
10407
+ <!-- Upload Modal -->
10408
+ <div id="upload-modal" class="hidden fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
10409
+ <div class="rounded-xl bg-white dark:bg-zinc-900 shadow-xl ring-1 ring-zinc-950/5 dark:ring-white/10 p-6 w-full max-w-2xl">
10410
+ <div class="flex justify-between items-center mb-4">
10411
+ <h3 class="text-lg font-semibold text-zinc-950 dark:text-white">Upload Files</h3>
10412
+ <button onclick="document.getElementById('upload-modal').classList.add('hidden')" class="text-zinc-500 dark:text-zinc-400 hover:text-zinc-950 dark:hover:text-white transition-colors">
10413
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10414
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
10415
+ </svg>
10416
+ </button>
10417
+ </div>
10418
+
10419
+ <!-- Upload Form -->
10420
+ <form
10421
+ id="upload-form"
10422
+ hx-post="/admin/media/upload"
10423
+ hx-encoding="multipart/form-data"
10424
+ hx-target="#upload-results"
10425
+ class="space-y-4"
10426
+ >
10427
+ <!-- Drag and Drop Zone -->
10428
+ <div
10429
+ id="upload-zone"
10430
+ class="upload-zone border-2 border-dashed border-zinc-950/10 dark:border-white/20 rounded-xl p-8 text-center cursor-pointer bg-zinc-50 dark:bg-zinc-800/50 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
10431
+ onclick="document.getElementById('file-input').click()"
10432
+ >
10433
+ <svg class="mx-auto h-12 w-12 text-zinc-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
10434
+ <path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
10435
+ </svg>
10436
+ <div class="mt-4">
10437
+ <p class="text-lg text-zinc-950 dark:text-white">Drop files here or click to upload</p>
10438
+ <p class="text-sm text-zinc-500 dark:text-zinc-400">PNG, JPG, GIF, PDF up to 10MB</p>
10439
+ </div>
10440
+ </div>
10441
+
10442
+ <input
10443
+ type="file"
10444
+ id="file-input"
10445
+ name="files"
10446
+ multiple
10447
+ accept="image/*,application/pdf,text/plain"
10448
+ class="hidden"
10449
+ onchange="handleFileSelect(this.files)"
10450
+ >
10451
+
10452
+ <!-- Folder Selection -->
10453
+ <div>
10454
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-2">Upload to folder:</label>
10455
+ <select name="folder" class="w-full rounded-lg bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-950 dark:text-white shadow-sm ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 focus:outline-none focus:ring-2 focus:ring-zinc-950 dark:focus:ring-white transition-shadow">
10456
+ <option value="uploads">uploads</option>
10457
+ <option value="images">images</option>
10458
+ <option value="documents">documents</option>
10459
+ </select>
10460
+ </div>
10461
+
10462
+ <!-- File List -->
10463
+ <div id="file-list" class="hidden">
10464
+ <h4 class="text-sm font-medium text-zinc-950 dark:text-white mb-2">Selected Files:</h4>
10465
+ <div id="selected-files" class="space-y-2 max-h-40 overflow-y-auto"></div>
10466
+ </div>
10467
+
10468
+ <!-- Upload Button -->
10469
+ <div class="flex justify-end space-x-2">
10470
+ <button
10471
+ type="button"
10472
+ onclick="document.getElementById('upload-modal').classList.add('hidden')"
10473
+ class="rounded-lg bg-white dark:bg-zinc-800 px-4 py-2.5 text-sm font-semibold text-zinc-950 dark:text-white shadow-sm ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors"
10474
+ >
10475
+ Cancel
10476
+ </button>
10477
+ <button
10478
+ type="submit"
10479
+ id="upload-btn"
10480
+ class="rounded-lg bg-zinc-950 dark:bg-white px-4 py-2.5 text-sm font-semibold text-white dark:text-zinc-950 hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50 transition-colors shadow-sm"
10481
+ disabled
10482
+ >
10483
+ Upload Files
10484
+ </button>
10485
+ </div>
10486
+ </form>
10487
+
10488
+ <!-- Upload Results -->
10489
+ <div id="upload-results" class="mt-4"></div>
10490
+ </div>
10491
+ </div>
10492
+
10493
+ <!-- File Details Modal -->
10494
+ <div id="file-modal" class="hidden fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
10495
+ <div id="file-modal-content" class="rounded-xl bg-white dark:bg-zinc-900 shadow-xl ring-1 ring-zinc-950/5 dark:ring-white/10 p-6 w-full max-w-4xl max-h-screen overflow-y-auto">
10496
+ <!-- Content loaded via HTMX -->
10497
+ </div>
10498
+ </div>
10499
+
10500
+ <!-- Move to Folder Modal -->
10501
+ <div id="move-to-folder-modal" class="hidden fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
10502
+ <div class="rounded-xl bg-white dark:bg-zinc-900 shadow-xl ring-1 ring-zinc-950/5 dark:ring-white/10 p-6 w-full max-w-md">
10503
+ <div class="flex justify-between items-center mb-4">
10504
+ <h3 class="text-lg font-semibold text-zinc-950 dark:text-white">Move to Folder</h3>
10505
+ <button onclick="closeMoveToFolderModal()" class="text-zinc-500 dark:text-zinc-400 hover:text-zinc-950 dark:hover:text-white transition-colors">
10506
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10507
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
10508
+ </svg>
10509
+ </button>
10510
+ </div>
10511
+
10512
+ <p class="text-sm text-zinc-500 dark:text-zinc-400 mb-4">
10513
+ Select a folder to move <span id="move-file-count" class="font-medium text-zinc-950 dark:text-white">0</span> selected file(s) to:
10514
+ </p>
10515
+
10516
+ <div class="space-y-2 mb-6">
10517
+ ${data.folders.length > 0 ? data.folders.map(
10518
+ (folder) => `
10519
+ <button
10520
+ onclick="performBulkMove('${folder.folder}')"
10521
+ class="w-full text-left px-4 py-3 rounded-lg bg-zinc-50 dark:bg-zinc-800/50 hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-950 dark:text-white transition-colors ring-1 ring-inset ring-zinc-200 dark:ring-zinc-700"
10522
+ >
10523
+ <div class="flex items-center justify-between">
10524
+ <span class="font-medium">${folder.folder}</span>
10525
+ <span class="text-sm text-zinc-500 dark:text-zinc-400">${folder.count} files</span>
10526
+ </div>
10527
+ </button>
10528
+ `
10529
+ ).join("") : '<p class="text-sm text-zinc-500 dark:text-zinc-400 text-center py-4">No folders available</p>'}
10530
+ </div>
10531
+
10532
+ <div class="flex justify-end space-x-2">
10533
+ <button
10534
+ onclick="closeMoveToFolderModal()"
10535
+ class="rounded-lg bg-white dark:bg-zinc-800 px-4 py-2.5 text-sm font-semibold text-zinc-950 dark:text-white shadow-sm ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors"
10536
+ >
10537
+ Cancel
10538
+ </button>
10539
+ </div>
10540
+ </div>
10541
+ </div>
10542
+
10543
+ <!-- Create Folder Modal -->
10544
+ <div id="create-folder-modal" class="hidden fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
10545
+ <div class="rounded-xl bg-white dark:bg-zinc-900 shadow-xl ring-1 ring-zinc-950/5 dark:ring-white/10 p-6 w-full max-w-md">
10546
+ <div class="flex justify-between items-center mb-4">
10547
+ <h3 class="text-lg font-semibold text-zinc-950 dark:text-white">Create New Folder</h3>
10548
+ <button onclick="closeCreateFolderModal()" aria-label="Close" class="text-zinc-500 dark:text-zinc-400 hover:text-zinc-950 dark:hover:text-white transition-colors">
10549
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10550
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
10551
+ </svg>
10552
+ </button>
10553
+ </div>
10554
+
10555
+ <form onsubmit="createNewFolder(event)" class="space-y-4">
10556
+ <div>
10557
+ <label for="folder-name" class="block text-sm font-medium text-zinc-950 dark:text-white mb-2">
10558
+ Folder Name
10559
+ </label>
10560
+ <input
10561
+ type="text"
10562
+ id="folder-name"
10563
+ name="folderName"
10564
+ placeholder="e.g., images, documents"
10565
+ required
10566
+ pattern="[a-z0-9-_]+"
10567
+ title="Only lowercase letters, numbers, hyphens, and underscores allowed"
10568
+ class="w-full rounded-lg bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-950 dark:text-white shadow-sm ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 focus:outline-none focus:ring-2 focus:ring-cyan-500 dark:focus:ring-cyan-400 transition-shadow"
10569
+ >
10570
+ <p class="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
10571
+ Use lowercase letters, numbers, hyphens, and underscores only
10572
+ </p>
10573
+ </div>
10574
+
10575
+ <div class="flex justify-end space-x-2">
10576
+ <button
10577
+ type="button"
10578
+ onclick="closeCreateFolderModal()"
10579
+ class="rounded-lg bg-white dark:bg-zinc-800 px-4 py-2.5 text-sm font-semibold text-zinc-950 dark:text-white shadow-sm ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors"
10580
+ >
10581
+ Cancel
10582
+ </button>
10583
+ <button
10584
+ type="submit"
10585
+ class="rounded-lg bg-zinc-950 dark:bg-white px-4 py-2.5 text-sm font-semibold text-white dark:text-zinc-950 hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors shadow-sm"
10586
+ >
10587
+ Create Folder
10588
+ </button>
10589
+ </div>
10590
+ </form>
10591
+ </div>
10592
+ </div>
10593
+
10594
+ <style>
10595
+ .media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
10596
+ .media-item { position: relative; border-radius: 8px; overflow: hidden; transition: transform 0.2s; }
10597
+ .media-item:hover { transform: scale(1.02); }
10598
+ .media-item.selected { ring: 2px solid rgba(255, 255, 255, 0.4); }
10599
+ .upload-zone { border: 2px dashed rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.1); min-height: 200px; }
10600
+ .upload-zone.dragover { border-color: rgba(255, 255, 255, 0.4); background: rgba(255, 255, 255, 0.2); }
10601
+ .file-icon { width: 48px; height: 48px; }
10602
+ .preview-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); opacity: 0; transition: opacity 0.2s; }
10603
+ .media-item:hover .preview-overlay { opacity: 1; }
10604
+ </style>
10605
+
10606
+ <script>
10607
+ let selectedFiles = new Set();
10608
+ let dragDropFiles = [];
10609
+
10610
+ // File selection handling
10611
+ function toggleFileSelection(fileId) {
10612
+ if (selectedFiles.has(fileId)) {
10613
+ selectedFiles.delete(fileId);
10614
+ document.querySelector(\`[data-file-id="\${fileId}"]\`).classList.remove('selected');
10615
+ } else {
10616
+ selectedFiles.add(fileId);
10617
+ document.querySelector(\`[data-file-id="\${fileId}"]\`).classList.add('selected');
10618
+ }
10619
+ updateBulkActionsButton();
10620
+ }
10621
+
10622
+ function toggleSelectAll() {
10623
+ const allItems = document.querySelectorAll('[data-file-id]');
10624
+ if (selectedFiles.size === allItems.length) {
10625
+ selectedFiles.clear();
10626
+ allItems.forEach(item => item.classList.remove('selected'));
10627
+ document.getElementById('select-all-btn').textContent = 'Select All';
10628
+ } else {
10629
+ allItems.forEach(item => {
10630
+ const fileId = item.dataset.fileId;
10631
+ selectedFiles.add(fileId);
10632
+ item.classList.add('selected');
10633
+ });
10634
+ document.getElementById('select-all-btn').textContent = 'Deselect All';
10635
+ }
10636
+ updateBulkActionsButton();
10637
+ }
10638
+
10639
+ function updateBulkActionsButton() {
10640
+ const btn = document.getElementById('bulk-actions-btn');
10641
+ const chevronIcon = btn.querySelector('svg');
10642
+
10643
+ if (selectedFiles.size > 0) {
10644
+ btn.disabled = false;
10645
+ btn.className = 'inline-flex items-center gap-x-1.5 px-3 py-1.5 bg-white/90 dark:bg-zinc-800/90 backdrop-blur-sm text-zinc-950 dark:text-white text-sm font-medium rounded-full ring-1 ring-inset ring-cyan-200/50 dark:ring-cyan-700/50 hover:bg-gradient-to-r hover:from-cyan-50 hover:to-blue-50 dark:hover:from-cyan-900/30 dark:hover:to-blue-900/30 hover:ring-cyan-300 dark:hover:ring-cyan-600 transition-all duration-200';
10646
+ btn.innerHTML = \`Actions (\${selectedFiles.size}) <svg viewBox="0 0 20 20" fill="currentColor" class="size-4"><path d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" fill-rule="evenodd" /></svg>\`;
10647
+ // Re-attach onclick handler after innerHTML update
10648
+ btn.onclick = toggleBulkActionsDropdown;
10649
+ } else {
10650
+ btn.disabled = true;
10651
+ btn.className = 'inline-flex items-center gap-x-1.5 px-3 py-1.5 bg-zinc-100/60 dark:bg-zinc-800/60 backdrop-blur-sm text-zinc-400 dark:text-zinc-600 text-sm font-medium rounded-full ring-1 ring-inset ring-zinc-200/50 dark:ring-zinc-700/50 cursor-not-allowed';
10652
+ btn.innerHTML = \`Bulk Actions <svg viewBox="0 0 20 20" fill="currentColor" class="size-4"><path d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" fill-rule="evenodd" /></svg>\`;
10653
+ btn.onclick = null; // Remove handler when disabled
10654
+ // Hide menu when no files selected
10655
+ const menu = document.getElementById('bulk-actions-menu');
10656
+ menu.classList.remove('scale-100', 'opacity-100');
10657
+ menu.classList.add('scale-95', 'opacity-0', 'hidden');
10658
+ }
10659
+ }
10660
+
10661
+ function toggleBulkActionsDropdown() {
10662
+ const menu = document.getElementById('bulk-actions-menu');
10663
+ const isHidden = menu.classList.contains('hidden');
10664
+
10665
+ if (isHidden) {
10666
+ menu.classList.remove('hidden');
10667
+ setTimeout(() => {
10668
+ menu.classList.remove('scale-95', 'opacity-0');
10669
+ menu.classList.add('scale-100', 'opacity-100');
10670
+ }, 10);
10671
+ } else {
10672
+ menu.classList.remove('scale-100', 'opacity-100');
10673
+ menu.classList.add('scale-95', 'opacity-0');
10674
+ setTimeout(() => {
10675
+ menu.classList.add('hidden');
10676
+ }, 100);
10677
+ }
10678
+ }
10679
+
10680
+ function confirmBulkDelete() {
10681
+ if (selectedFiles.size === 0) return;
10682
+ showConfirmDialog('media-bulk-delete-confirm');
10683
+ }
10684
+
10685
+ async function performBulkDelete() {
10686
+ if (selectedFiles.size === 0) return;
10687
+
10688
+ try {
10689
+ // Show loading state
10690
+ const btn = document.getElementById('bulk-actions-btn');
10691
+ const originalText = btn.innerHTML;
10692
+ btn.innerHTML = 'Deleting...';
10693
+ btn.disabled = true;
10694
+
10695
+ // Hide menu
10696
+ const menu = document.getElementById('bulk-actions-menu');
10697
+ menu.classList.remove('scale-100', 'opacity-100');
10698
+ menu.classList.add('scale-95', 'opacity-0');
10699
+ setTimeout(() => menu.classList.add('hidden'), 100);
10700
+
10701
+ const response = await fetch('/api/media/bulk-delete', {
10702
+ method: 'POST',
10703
+ headers: {
10704
+ 'Content-Type': 'application/json',
10705
+ },
10706
+ body: JSON.stringify({
10707
+ fileIds: Array.from(selectedFiles)
10708
+ })
10709
+ });
10710
+
10711
+ const result = await response.json();
10712
+
10713
+ if (result.success) {
10714
+ // Show success notification
10715
+ showNotification(\`Successfully deleted \${result.summary.successful} file\${result.summary.successful > 1 ? 's' : ''}\`, 'success');
10716
+
10717
+ // Remove deleted files from DOM
10718
+ result.deleted.forEach(item => {
10719
+ const element = document.querySelector(\`[data-file-id="\${item.fileId}"]\`);
10720
+ if (element) {
10721
+ element.remove();
10722
+ }
10723
+ });
10724
+
10725
+ // Show errors if any
10726
+ if (result.errors.length > 0) {
10727
+ console.warn('Some files failed to delete:', result.errors);
10728
+ showNotification(\`\${result.errors.length} file\${result.errors.length > 1 ? 's' : ''} failed to delete\`, 'warning');
10729
+ }
10730
+
10731
+ // Clear selection
10732
+ selectedFiles.clear();
10733
+ updateBulkActionsButton();
10734
+ document.getElementById('select-all-btn').textContent = 'Select All';
10735
+ } else {
10736
+ showNotification('Failed to delete files', 'error');
10737
+ }
10738
+ } catch (error) {
10739
+ console.error('Bulk delete error:', error);
10740
+ showNotification('An error occurred while deleting files', 'error');
10741
+ } finally {
10742
+ // Reset button state
10743
+ updateBulkActionsButton();
10744
+ }
10745
+ }
10746
+
10747
+ function openMoveToFolderModal() {
10748
+ if (selectedFiles.size === 0) return;
10749
+
10750
+ // Update file count in modal
10751
+ document.getElementById('move-file-count').textContent = selectedFiles.size.toString();
10752
+
10753
+ // Show modal
10754
+ document.getElementById('move-to-folder-modal').classList.remove('hidden');
10755
+
10756
+ // Hide bulk actions menu
10757
+ const menu = document.getElementById('bulk-actions-menu');
10758
+ menu.classList.remove('scale-100', 'opacity-100');
10759
+ menu.classList.add('scale-95', 'opacity-0');
10760
+ setTimeout(() => menu.classList.add('hidden'), 100);
10761
+ }
10762
+
10763
+ function closeMoveToFolderModal() {
10764
+ document.getElementById('move-to-folder-modal').classList.add('hidden');
10765
+ }
10766
+
10767
+ function openCreateFolderModal() {
10768
+ document.getElementById('create-folder-modal').classList.remove('hidden');
10769
+ // Clear and focus the input
10770
+ const input = document.getElementById('folder-name');
10771
+ input.value = '';
10772
+ setTimeout(() => input.focus(), 100);
10773
+ }
10774
+
10775
+ function closeCreateFolderModal() {
10776
+ document.getElementById('create-folder-modal').classList.add('hidden');
10777
+ }
10778
+
10779
+ async function createNewFolder(event) {
10780
+ event.preventDefault();
10781
+
10782
+ const folderName = document.getElementById('folder-name').value.trim();
10783
+
10784
+ if (!folderName) {
10785
+ showNotification('Please enter a folder name', 'error');
10786
+ return;
10787
+ }
10788
+
10789
+ // Validate folder name format
10790
+ const folderPattern = /^[a-z0-9-_]+$/;
10791
+ if (!folderPattern.test(folderName)) {
10792
+ showNotification('Folder name can only contain lowercase letters, numbers, hyphens, and underscores', 'error');
10793
+ return;
10794
+ }
10795
+
10796
+ try {
10797
+ const response = await fetch('/api/media/create-folder', {
10798
+ method: 'POST',
10799
+ headers: {
10800
+ 'Content-Type': 'application/json',
10801
+ },
10802
+ body: JSON.stringify({ folderName })
10803
+ });
10804
+
10805
+ const result = await response.json();
10806
+
10807
+ if (result.success) {
10808
+ showNotification(\`Folder "\${folderName}" created successfully\`, 'success');
10809
+ closeCreateFolderModal();
10810
+
10811
+ // Reload the page to show the new folder (delay to allow notification to show)
10812
+ setTimeout(() => {
10813
+ window.location.reload();
10814
+ }, 2000);
10815
+ } else {
10816
+ showNotification(result.error || 'Failed to create folder', 'error');
10817
+ }
10818
+ } catch (error) {
10819
+ console.error('Create folder error:', error);
10820
+ showNotification('An error occurred while creating the folder', 'error');
10821
+ }
10822
+ }
10823
+
10824
+ async function performBulkMove(targetFolder) {
10825
+ if (selectedFiles.size === 0) return;
10826
+
10827
+ try {
10828
+ // Show loading state
10829
+ closeMoveToFolderModal();
10830
+ const btn = document.getElementById('bulk-actions-btn');
10831
+ const originalText = btn.innerHTML;
10832
+ btn.innerHTML = 'Moving...';
10833
+ btn.disabled = true;
10834
+
10835
+ const response = await fetch('/api/media/bulk-move', {
10836
+ method: 'POST',
10837
+ headers: {
10838
+ 'Content-Type': 'application/json',
10839
+ },
10840
+ body: JSON.stringify({
10841
+ fileIds: Array.from(selectedFiles),
10842
+ folder: targetFolder
10843
+ })
10844
+ });
10845
+
10846
+ const result = await response.json();
10847
+
10848
+ if (result.success) {
10849
+ // Show success notification
10850
+ const movedCount = result.summary.successful;
10851
+ showNotification(\`Successfully moved \${movedCount} file\${movedCount > 1 ? 's' : ''} to \${targetFolder}\`, 'success');
10852
+
10853
+ // Reload the page to show updated file locations
10854
+ setTimeout(() => {
10855
+ window.location.reload();
10856
+ }, 1000);
10857
+ } else {
10858
+ showNotification('Failed to move files', 'error');
10859
+ updateBulkActionsButton();
10860
+ }
10861
+ } catch (error) {
10862
+ console.error('Bulk move error:', error);
10863
+ showNotification('An error occurred while moving files', 'error');
10864
+ updateBulkActionsButton();
10865
+ }
10866
+ }
10867
+
10868
+ function showNotification(message, type = 'info') {
10869
+ const notification = document.createElement('div');
10870
+ const bgColor = type === 'success' ? 'bg-green-600' :
10871
+ type === 'warning' ? 'bg-yellow-600' :
10872
+ type === 'error' ? 'bg-red-600' : 'bg-blue-600';
10873
+
10874
+ notification.className = \`fixed top-4 right-4 \${bgColor} text-white px-4 py-3 rounded-lg shadow-xl ring-1 ring-white/10 z-50 transition-all transform translate-x-full\`;
10875
+ notification.textContent = message;
10876
+ document.body.appendChild(notification);
10877
+
10878
+ // Animate in
10879
+ setTimeout(() => {
10880
+ notification.classList.remove('translate-x-full');
10881
+ }, 100);
10882
+
10883
+ // Remove after 3 seconds
10884
+ setTimeout(() => {
10885
+ notification.classList.add('translate-x-full');
10886
+ setTimeout(() => {
10887
+ if (document.body.contains(notification)) {
10888
+ document.body.removeChild(notification);
10889
+ }
10890
+ }, 300);
10891
+ }, 3000);
10892
+ }
10893
+
10894
+ // URL parameter helpers
10895
+ function updateUrlParam(param, value) {
10896
+ const url = new URL(window.location);
10897
+ url.searchParams.set(param, value);
10898
+ return url.toString();
10899
+ }
10900
+
10901
+ // Drag and drop handling
10902
+ const uploadZone = document.getElementById('upload-zone');
10903
+
10904
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
10905
+ uploadZone.addEventListener(eventName, preventDefaults, false);
10906
+ });
10907
+
10908
+ function preventDefaults(e) {
10909
+ e.preventDefault();
10910
+ e.stopPropagation();
10911
+ }
10912
+
10913
+ ['dragenter', 'dragover'].forEach(eventName => {
10914
+ uploadZone.addEventListener(eventName, () => uploadZone.classList.add('dragover'), false);
10915
+ });
10916
+
10917
+ ['dragleave', 'drop'].forEach(eventName => {
10918
+ uploadZone.addEventListener(eventName, () => uploadZone.classList.remove('dragover'), false);
10919
+ });
10920
+
10921
+ uploadZone.addEventListener('drop', handleDrop, false);
10922
+
10923
+ function handleDrop(e) {
10924
+ const dt = e.dataTransfer;
10925
+ const files = dt.files;
10926
+ handleFileSelect(files);
10927
+ }
10928
+
10929
+ function handleFileSelect(files) {
10930
+ dragDropFiles = Array.from(files);
10931
+
10932
+ // Update the actual file input with the selected files
10933
+ const fileInput = document.getElementById('file-input');
10934
+ const dt = new DataTransfer();
10935
+ dragDropFiles.forEach(file => dt.items.add(file));
10936
+ fileInput.files = dt.files;
10937
+
10938
+ displaySelectedFiles();
10939
+ document.getElementById('upload-btn').disabled = false;
10940
+ }
10941
+
10942
+ function displaySelectedFiles() {
10943
+ const fileList = document.getElementById('file-list');
10944
+ const selectedFilesDiv = document.getElementById('selected-files');
10945
+
10946
+ selectedFilesDiv.innerHTML = '';
10947
+
10948
+ dragDropFiles.forEach((file, index) => {
10949
+ const fileItem = document.createElement('div');
10950
+ fileItem.className = 'flex items-center justify-between p-2 rounded-lg bg-zinc-50 dark:bg-zinc-800 ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10';
10951
+ fileItem.innerHTML = \`
10952
+ <div class="flex items-center space-x-2">
10953
+ <span class="text-sm text-zinc-950 dark:text-white">\${file.name}</span>
10954
+ <span class="text-xs text-zinc-500 dark:text-zinc-400">(\${formatFileSize(file.size)})</span>
10955
+ </div>
10956
+ <button onclick="removeFile(\${index})" class="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 transition-colors">
10957
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10958
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
10959
+ </svg>
10960
+ </button>
10961
+ \`;
10962
+ selectedFilesDiv.appendChild(fileItem);
10963
+ });
10964
+
10965
+ fileList.classList.toggle('hidden', dragDropFiles.length === 0);
10966
+ }
10967
+
10968
+ function removeFile(index) {
10969
+ dragDropFiles.splice(index, 1);
10970
+ displaySelectedFiles();
10971
+
10972
+ const fileInput = document.getElementById('file-input');
10973
+ const dt = new DataTransfer();
10974
+ dragDropFiles.forEach(file => dt.items.add(file));
10975
+ fileInput.files = dt.files;
10976
+
10977
+ document.getElementById('upload-btn').disabled = dragDropFiles.length === 0;
10978
+ }
10979
+
10980
+ function formatFileSize(bytes) {
10981
+ if (bytes === 0) return '0 Bytes';
10982
+ const k = 1024;
10983
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
10984
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
10985
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
10986
+ }
10987
+
10988
+ // Copy to clipboard function
10989
+ function copyToClipboard(text) {
10990
+ navigator.clipboard.writeText(text).then(() => {
10991
+ const notification = document.createElement('div');
10992
+ notification.className = 'fixed top-4 right-4 bg-green-600 text-white px-4 py-3 rounded-lg shadow-xl ring-1 ring-white/10 z-50';
10993
+ notification.textContent = 'URL copied to clipboard!';
10994
+ document.body.appendChild(notification);
10995
+ setTimeout(() => document.body.removeChild(notification), 2000);
10996
+ }).catch(err => {
10997
+ console.error('Failed to copy: ', err);
10998
+ });
10999
+ }
11000
+
11001
+ // Toggle clear button visibility
11002
+ function toggleMediaClearButton() {
11003
+ const searchInput = document.getElementById('media-search-input');
11004
+ const clearButton = document.getElementById('clear-media-search');
11005
+ if (searchInput.value.trim()) {
11006
+ clearButton.classList.remove('hidden');
11007
+ } else {
11008
+ clearButton.classList.add('hidden');
11009
+ }
11010
+ }
11011
+
11012
+ // Clear search input
11013
+ function clearMediaSearch() {
11014
+ const searchInput = document.getElementById('media-search-input');
11015
+ searchInput.value = '';
11016
+ toggleMediaClearButton();
11017
+ // Trigger htmx to refresh the grid
11018
+ htmx.trigger(searchInput, 'keyup');
11019
+ }
11020
+
11021
+ // Initialize clear button visibility on page load
11022
+ document.addEventListener('DOMContentLoaded', function() {
11023
+ toggleMediaClearButton();
11024
+ });
11025
+
11026
+ // Close modal when clicking outside
11027
+ document.getElementById('file-modal').addEventListener('click', function(e) {
11028
+ if (e.target === this) {
11029
+ this.classList.add('hidden');
11030
+ }
11031
+ });
11032
+
11033
+ // Close bulk actions dropdown when clicking outside
11034
+ document.addEventListener('click', function(e) {
11035
+ const dropdown = document.getElementById('bulk-actions-dropdown');
11036
+ const menu = document.getElementById('bulk-actions-menu');
11037
+ if (dropdown && menu && !dropdown.contains(e.target)) {
11038
+ menu.classList.remove('scale-100', 'opacity-100');
11039
+ menu.classList.add('scale-95', 'opacity-0');
11040
+ setTimeout(() => {
11041
+ menu.classList.add('hidden');
11042
+ }, 100);
11043
+ }
11044
+ });
11045
+ </script>
11046
+
11047
+ <!-- Confirmation Dialog for Bulk Delete -->
11048
+ ${renderConfirmationDialog({
11049
+ id: "media-bulk-delete-confirm",
11050
+ title: "Delete Selected Files",
11051
+ message: `Are you sure you want to delete ${data.files.length > 0 ? "the selected files" : "these files"}? This action cannot be undone and the files will be permanently removed.`,
11052
+ confirmText: "Delete Files",
11053
+ cancelText: "Cancel",
11054
+ confirmClass: "bg-red-500 hover:bg-red-400",
11055
+ iconColor: "red",
11056
+ onConfirm: "performBulkDelete()"
11057
+ })}
11058
+
11059
+ <!-- Confirmation Dialog Script -->
11060
+ ${getConfirmationDialogScript()}
11061
+ `;
11062
+ function buildPageUrl(page, folder, type) {
11063
+ const params = new URLSearchParams();
11064
+ params.set("page", page.toString());
11065
+ if (folder !== "all") params.set("folder", folder);
11066
+ if (type !== "all") params.set("type", type);
11067
+ return `/admin/media?${params.toString()}`;
11068
+ }
11069
+ const layoutData = {
11070
+ title: "Media Library",
11071
+ currentPath: "/admin/media",
11072
+ user: data.user,
11073
+ version: data.version,
11074
+ content: pageContent
11075
+ };
11076
+ return renderAdminLayoutCatalyst(layoutData);
11077
+ }
11078
+
11079
+ // src/templates/components/media-file-details.template.ts
11080
+ function renderMediaFileDetails(data) {
11081
+ const { file } = data;
11082
+ return `
11083
+ <div class="flex justify-between items-center mb-4">
11084
+ <h3 class="text-lg font-semibold text-zinc-950 dark:text-white">File Details</h3>
11085
+ <button onclick="document.getElementById('file-modal').classList.add('hidden')" class="text-zinc-500 dark:text-zinc-400 hover:text-zinc-950 dark:hover:text-white transition-colors">
11086
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
11087
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
11088
+ </svg>
11089
+ </button>
11090
+ </div>
11091
+
11092
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
11093
+ <!-- Preview -->
11094
+ <div class="space-y-4">
11095
+ <div class="rounded-xl bg-zinc-50 dark:bg-zinc-800 p-4">
11096
+ ${file.isImage ? `
11097
+ <img src="${file.public_url}" alt="${file.alt || file.filename}" class="w-full h-auto rounded-lg">
11098
+ ` : file.isVideo ? `
11099
+ <video src="${file.public_url}" controls class="w-full h-auto rounded-lg"></video>
11100
+ ` : `
11101
+ <div class="flex items-center justify-center h-32">
11102
+ <svg class="w-12 h-12 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
11103
+ <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>
11104
+ </svg>
11105
+ </div>
11106
+ `}
11107
+ </div>
11108
+
11109
+ <div class="text-center space-x-2">
11110
+ <button
11111
+ onclick="copyToClipboard('${file.public_url}')"
11112
+ class="inline-flex items-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700 transition-colors"
11113
+ >
11114
+ Copy URL
11115
+ </button>
11116
+ <a
11117
+ href="${file.public_url}"
11118
+ target="_blank"
11119
+ class="inline-flex items-center rounded-lg bg-white dark:bg-zinc-800 px-4 py-2 text-sm font-semibold text-zinc-950 dark:text-white ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors"
11120
+ >
11121
+ Open Original
11122
+ </a>
11123
+ </div>
11124
+ </div>
11125
+
11126
+ <!-- Details -->
11127
+ <div class="space-y-4">
11128
+ <div>
11129
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-1">Filename</label>
11130
+ <p class="text-sm text-zinc-500 dark:text-zinc-400">${file.original_name}</p>
11131
+ </div>
11132
+
11133
+ <div class="grid grid-cols-2 gap-4">
11134
+ <div>
11135
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-1">Size</label>
11136
+ <p class="text-sm text-zinc-500 dark:text-zinc-400">${file.fileSize}</p>
11137
+ </div>
11138
+ <div>
11139
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-1">Type</label>
11140
+ <p class="text-sm text-zinc-500 dark:text-zinc-400">${file.mime_type}</p>
11141
+ </div>
11142
+ </div>
11143
+
11144
+ ${file.width && file.height ? `
11145
+ <div class="grid grid-cols-2 gap-4">
11146
+ <div>
11147
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-1">Width</label>
11148
+ <p class="text-sm text-zinc-500 dark:text-zinc-400">${file.width}px</p>
11149
+ </div>
11150
+ <div>
11151
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-1">Height</label>
11152
+ <p class="text-sm text-zinc-500 dark:text-zinc-400">${file.height}px</p>
11153
+ </div>
11154
+ </div>
11155
+ ` : ""}
11156
+
11157
+ <div>
11158
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-1">Folder</label>
11159
+ <p class="text-sm text-zinc-500 dark:text-zinc-400">${file.folder}</p>
11160
+ </div>
11161
+
11162
+ <div>
11163
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-1">Uploaded</label>
11164
+ <p class="text-sm text-zinc-500 dark:text-zinc-400">${file.uploadedAt}</p>
11165
+ </div>
11166
+
11167
+ <!-- Editable Fields -->
11168
+ <form hx-put="/admin/media/${file.id}" hx-target="#file-modal-content" class="space-y-4">
11169
+ <div>
11170
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-1">Alt Text</label>
11171
+ <input
11172
+ type="text"
11173
+ name="alt"
11174
+ value="${file.alt || ""}"
11175
+ class="w-full rounded-lg bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-950 dark:text-white placeholder-zinc-500 dark:placeholder-zinc-400 ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 focus:ring-2 focus:ring-blue-600 dark:focus:ring-blue-500 focus:outline-none transition-colors"
11176
+ placeholder="Describe this image..."
11177
+ >
11178
+ </div>
11179
+
11180
+ <div>
11181
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-1">Caption</label>
11182
+ <textarea
11183
+ name="caption"
11184
+ rows="3"
11185
+ class="w-full rounded-lg bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-950 dark:text-white placeholder-zinc-500 dark:placeholder-zinc-400 ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 focus:ring-2 focus:ring-blue-600 dark:focus:ring-blue-500 focus:outline-none transition-colors"
11186
+ placeholder="Optional caption..."
11187
+ >${file.caption || ""}</textarea>
11188
+ </div>
11189
+
11190
+ <div>
11191
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-1">Tags</label>
11192
+ <input
11193
+ type="text"
11194
+ name="tags"
11195
+ value="${file.tags.join(", ")}"
11196
+ class="w-full rounded-lg bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-950 dark:text-white placeholder-zinc-500 dark:placeholder-zinc-400 ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 focus:ring-2 focus:ring-blue-600 dark:focus:ring-blue-500 focus:outline-none transition-colors"
11197
+ placeholder="tag1, tag2, tag3"
11198
+ >
11199
+ </div>
11200
+
11201
+ <div class="flex justify-between">
11202
+ <button
11203
+ type="submit"
11204
+ class="rounded-lg bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-700 transition-colors"
11205
+ >
11206
+ Save Changes
11207
+ </button>
11208
+ <button
11209
+ type="button"
11210
+ hx-delete="/admin/media/${file.id}"
11211
+ hx-confirm="Are you sure you want to delete this file?"
11212
+ hx-target="body"
11213
+ class="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 transition-colors"
11214
+ >
11215
+ Delete File
11216
+ </button>
11217
+ </div>
11218
+ </form>
11219
+ </div>
11220
+ </div>
11221
+ `;
11222
+ }
11223
+
11224
+ // src/routes/admin-media.ts
11225
+ var fileValidationSchema2 = zod.z.object({
11226
+ name: zod.z.string().min(1).max(255),
11227
+ type: zod.z.string().refine(
11228
+ (type) => {
11229
+ const allowedTypes = [
11230
+ // Images
11231
+ "image/jpeg",
11232
+ "image/jpg",
11233
+ "image/png",
11234
+ "image/gif",
11235
+ "image/webp",
11236
+ "image/svg+xml",
11237
+ // Documents
11238
+ "application/pdf",
11239
+ "text/plain",
11240
+ "application/msword",
11241
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
11242
+ // Videos
11243
+ "video/mp4",
11244
+ "video/webm",
11245
+ "video/ogg",
11246
+ "video/avi",
11247
+ "video/mov",
11248
+ // Audio
11249
+ "audio/mp3",
11250
+ "audio/wav",
11251
+ "audio/ogg",
11252
+ "audio/m4a"
11253
+ ];
11254
+ return allowedTypes.includes(type);
11255
+ },
11256
+ { message: "Unsupported file type" }
11257
+ ),
11258
+ size: zod.z.number().min(1).max(50 * 1024 * 1024)
11259
+ // 50MB max
11260
+ });
11261
+ var adminMediaRoutes = new hono.Hono();
11262
+ adminMediaRoutes.get("/", async (c) => {
11263
+ try {
11264
+ const user = c.get("user");
11265
+ const { searchParams } = new URL(c.req.url);
11266
+ const folder = searchParams.get("folder") || "all";
11267
+ const type = searchParams.get("type") || "all";
11268
+ const view = searchParams.get("view") || "grid";
11269
+ const page = parseInt(searchParams.get("page") || "1");
11270
+ const cacheBust = searchParams.get("t");
11271
+ const limit = 24;
11272
+ const offset = (page - 1) * limit;
11273
+ const db = c.env.DB;
11274
+ let query = "SELECT * FROM media";
11275
+ const params = [];
11276
+ const conditions = ["deleted_at IS NULL"];
11277
+ if (folder !== "all") {
11278
+ conditions.push("folder = ?");
11279
+ params.push(folder);
11280
+ }
11281
+ if (type !== "all") {
11282
+ switch (type) {
11283
+ case "images":
11284
+ conditions.push("mime_type LIKE ?");
11285
+ params.push("image/%");
11286
+ break;
11287
+ case "documents":
11288
+ conditions.push("mime_type IN (?, ?, ?)");
11289
+ params.push("application/pdf", "text/plain", "application/msword");
11290
+ break;
11291
+ case "videos":
11292
+ conditions.push("mime_type LIKE ?");
11293
+ params.push("video/%");
11294
+ break;
11295
+ }
11296
+ }
11297
+ if (conditions.length > 0) {
11298
+ query += ` WHERE ${conditions.join(" AND ")}`;
11299
+ }
11300
+ query += ` ORDER BY uploaded_at DESC LIMIT ${limit} OFFSET ${offset}`;
11301
+ const stmt = db.prepare(query);
11302
+ const { results } = await stmt.bind(...params).all();
11303
+ const foldersStmt = db.prepare(`
11304
+ SELECT folder, COUNT(*) as count, SUM(size) as totalSize
11305
+ FROM media
11306
+ GROUP BY folder
11307
+ ORDER BY folder
11308
+ `);
11309
+ const { results: folders } = await foldersStmt.all();
11310
+ const typesStmt = db.prepare(`
11311
+ SELECT
11312
+ CASE
11313
+ WHEN mime_type LIKE 'image/%' THEN 'images'
11314
+ WHEN mime_type LIKE 'video/%' THEN 'videos'
11315
+ WHEN mime_type IN ('application/pdf', 'text/plain') THEN 'documents'
11316
+ ELSE 'other'
11317
+ END as type,
11318
+ COUNT(*) as count
11319
+ FROM media
11320
+ GROUP BY type
11321
+ `);
11322
+ const { results: types } = await typesStmt.all();
11323
+ const mediaFiles = results.map((row) => ({
11324
+ id: row.id,
11325
+ filename: row.filename,
11326
+ original_name: row.original_name,
11327
+ mime_type: row.mime_type,
11328
+ size: row.size,
11329
+ public_url: `/files/${row.r2_key}`,
11330
+ thumbnail_url: row.mime_type.startsWith("image/") ? `/files/${row.r2_key}` : void 0,
11331
+ alt: row.alt,
11332
+ caption: row.caption,
11333
+ tags: row.tags ? JSON.parse(row.tags) : [],
11334
+ uploaded_at: row.uploaded_at,
11335
+ fileSize: formatFileSize(row.size),
11336
+ uploadedAt: new Date(row.uploaded_at).toLocaleDateString(),
11337
+ isImage: row.mime_type.startsWith("image/"),
11338
+ isVideo: row.mime_type.startsWith("video/"),
11339
+ isDocument: !row.mime_type.startsWith("image/") && !row.mime_type.startsWith("video/")
11340
+ }));
11341
+ const pageData = {
11342
+ files: mediaFiles,
11343
+ folders: folders.map((f) => ({
11344
+ folder: f.folder,
11345
+ count: f.count,
11346
+ totalSize: f.totalSize
11347
+ })),
11348
+ types: types.map((t) => ({
11349
+ type: t.type,
11350
+ count: t.count
11351
+ })),
11352
+ currentFolder: folder,
11353
+ currentType: type,
11354
+ currentView: view,
11355
+ currentPage: page,
11356
+ totalFiles: results.length,
11357
+ hasNextPage: results.length === limit,
11358
+ user: {
11359
+ name: user.email,
11360
+ email: user.email,
11361
+ role: user.role
11362
+ },
11363
+ version: c.get("appVersion")
11364
+ };
11365
+ return c.html(renderMediaLibraryPage(pageData));
11366
+ } catch (error) {
11367
+ console.error("Error loading media library:", error);
11368
+ return c.html(html.html`<p>Error loading media library</p>`);
11369
+ }
11370
+ });
11371
+ adminMediaRoutes.get("/selector", async (c) => {
11372
+ try {
11373
+ const { searchParams } = new URL(c.req.url);
11374
+ const search = searchParams.get("search") || "";
11375
+ const db = c.env.DB;
11376
+ let query = "SELECT * FROM media WHERE deleted_at IS NULL";
11377
+ const params = [];
11378
+ if (search.trim()) {
11379
+ query += " AND (filename LIKE ? OR original_name LIKE ? OR alt LIKE ?)";
11380
+ const searchTerm = `%${search}%`;
11381
+ params.push(searchTerm, searchTerm, searchTerm);
11382
+ }
11383
+ query += " ORDER BY uploaded_at DESC LIMIT 24";
11384
+ const stmt = db.prepare(query);
11385
+ const { results } = await stmt.bind(...params).all();
11386
+ const mediaFiles = results.map((row) => ({
11387
+ id: row.id,
11388
+ filename: row.filename,
11389
+ original_name: row.original_name,
11390
+ mime_type: row.mime_type,
11391
+ size: row.size,
11392
+ public_url: `/files/${row.r2_key}`,
11393
+ thumbnail_url: row.mime_type.startsWith("image/") ? `/files/${row.r2_key}` : void 0,
11394
+ alt: row.alt,
11395
+ tags: row.tags ? JSON.parse(row.tags) : [],
11396
+ uploaded_at: row.uploaded_at,
11397
+ fileSize: formatFileSize(row.size),
11398
+ uploadedAt: new Date(row.uploaded_at).toLocaleDateString(),
11399
+ isImage: row.mime_type.startsWith("image/"),
11400
+ isVideo: row.mime_type.startsWith("video/"),
11401
+ isDocument: !row.mime_type.startsWith("image/") && !row.mime_type.startsWith("video/")
11402
+ }));
11403
+ return c.html(html.html`
11404
+ <div class="mb-4">
11405
+ <input
11406
+ type="search"
11407
+ id="media-selector-search"
11408
+ placeholder="Search files..."
11409
+ class="w-full rounded-lg bg-white dark:bg-zinc-800 px-4 py-2 text-sm text-zinc-950 dark:text-white shadow-sm ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 placeholder:text-zinc-400 dark:placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-950 dark:focus:ring-white transition-shadow"
11410
+ hx-get="/admin/media/selector"
11411
+ hx-trigger="keyup changed delay:300ms"
11412
+ hx-target="#media-selector-grid"
11413
+ hx-include="[name='search']"
11414
+ >
11415
+ </div>
11416
+
11417
+ <div id="media-selector-grid" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4 max-h-96 overflow-y-auto">
11418
+ ${html.raw(mediaFiles.map((file) => `
11419
+ <div
11420
+ class="relative group cursor-pointer rounded-lg overflow-hidden bg-zinc-50 dark:bg-zinc-800 shadow-sm hover:shadow-md transition-shadow"
11421
+ data-media-id="${file.id}"
11422
+ >
11423
+ <div class="aspect-square relative">
11424
+ ${file.isImage ? `
11425
+ <img
11426
+ src="${file.public_url}"
11427
+ alt="${file.alt || file.filename}"
11428
+ class="w-full h-full object-cover"
11429
+ loading="lazy"
11430
+ >
11431
+ ` : file.isVideo ? `
11432
+ <video
11433
+ src="${file.public_url}"
11434
+ class="w-full h-full object-cover"
11435
+ muted
11436
+ ></video>
11437
+ ` : `
11438
+ <div class="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-700">
11439
+ <div class="text-center">
11440
+ <svg class="w-12 h-12 mx-auto text-zinc-400 dark:text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
11441
+ <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>
11442
+ </svg>
11443
+ <span class="text-xs text-zinc-500 dark:text-zinc-400 mt-1">${file.filename.split(".").pop()?.toUpperCase()}</span>
11444
+ </div>
11445
+ </div>
11446
+ `}
11447
+
11448
+ <div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
11449
+ <button
11450
+ type="button"
11451
+ onclick="selectMediaFile('${file.id}', '${file.public_url.replace(/'/g, "\\'")}', '${file.filename.replace(/'/g, "\\'")}')"
11452
+ class="px-4 py-2 bg-white dark:bg-zinc-900 text-zinc-950 dark:text-white rounded-lg font-medium hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
11453
+ >
11454
+ Select
11455
+ </button>
11456
+ </div>
11457
+ </div>
11458
+
11459
+ <div class="p-2">
11460
+ <p class="text-xs text-zinc-700 dark:text-zinc-300 truncate" title="${file.original_name}">
11461
+ ${file.original_name}
11462
+ </p>
11463
+ <p class="text-xs text-zinc-500 dark:text-zinc-400">
11464
+ ${file.fileSize}
11465
+ </p>
11466
+ </div>
11467
+ </div>
11468
+ `).join(""))}
11469
+ </div>
11470
+
11471
+ ${mediaFiles.length === 0 ? html.html`
11472
+ <div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
11473
+ <svg class="mx-auto h-12 w-12 text-zinc-400 dark:text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
11474
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
11475
+ </svg>
11476
+ <p class="mt-2">No media files found</p>
11477
+ </div>
11478
+ ` : ""}
11479
+ `);
11480
+ } catch (error) {
11481
+ console.error("Error loading media selector:", error);
11482
+ return c.html(html.html`<div class="text-red-500 dark:text-red-400">Error loading media files</div>`);
11483
+ }
11484
+ });
11485
+ adminMediaRoutes.get("/search", async (c) => {
11486
+ try {
11487
+ const { searchParams } = new URL(c.req.url);
11488
+ const search = searchParams.get("search") || "";
11489
+ const folder = searchParams.get("folder") || "all";
11490
+ const type = searchParams.get("type") || "all";
11491
+ const db = c.env.DB;
11492
+ let query = "SELECT * FROM media";
11493
+ const params = [];
11494
+ const conditions = [];
11495
+ if (search.trim()) {
11496
+ conditions.push("(filename LIKE ? OR original_name LIKE ? OR alt LIKE ?)");
11497
+ const searchTerm = `%${search}%`;
11498
+ params.push(searchTerm, searchTerm, searchTerm);
11499
+ }
11500
+ if (folder !== "all") {
11501
+ conditions.push("folder = ?");
11502
+ params.push(folder);
11503
+ }
11504
+ if (type !== "all") {
11505
+ switch (type) {
11506
+ case "images":
11507
+ conditions.push("mime_type LIKE ?");
11508
+ params.push("image/%");
11509
+ break;
11510
+ case "documents":
11511
+ conditions.push("mime_type IN (?, ?, ?)");
11512
+ params.push("application/pdf", "text/plain", "application/msword");
11513
+ break;
11514
+ case "videos":
11515
+ conditions.push("mime_type LIKE ?");
11516
+ params.push("video/%");
11517
+ break;
11518
+ }
11519
+ }
11520
+ if (conditions.length > 0) {
11521
+ query += ` WHERE ${conditions.join(" AND ")}`;
11522
+ }
11523
+ query += ` ORDER BY uploaded_at DESC LIMIT 24`;
11524
+ const stmt = db.prepare(query);
11525
+ const { results } = await stmt.bind(...params).all();
11526
+ const mediaFiles = results.map((row) => ({
11527
+ ...row,
11528
+ public_url: `/files/${row.r2_key}`,
11529
+ thumbnail_url: row.mime_type.startsWith("image/") ? `/files/${row.r2_key}` : void 0,
11530
+ tags: row.tags ? JSON.parse(row.tags) : [],
11531
+ uploadedAt: new Date(row.uploaded_at).toLocaleDateString(),
11532
+ fileSize: formatFileSize(row.size),
11533
+ isImage: row.mime_type.startsWith("image/"),
11534
+ isVideo: row.mime_type.startsWith("video/"),
11535
+ isDocument: !row.mime_type.startsWith("image/") && !row.mime_type.startsWith("video/")
11536
+ }));
11537
+ const gridHTML = mediaFiles.map((file) => generateMediaItemHTML(file)).join("");
11538
+ return c.html(html.raw(gridHTML));
11539
+ } catch (error) {
11540
+ console.error("Error searching media:", error);
11541
+ return c.html('<div class="text-red-500">Error searching files</div>');
11542
+ }
11543
+ });
11544
+ adminMediaRoutes.get("/:id/details", async (c) => {
11545
+ try {
11546
+ const id = c.req.param("id");
11547
+ const db = c.env.DB;
11548
+ const stmt = db.prepare("SELECT * FROM media WHERE id = ?");
11549
+ const result = await stmt.bind(id).first();
11550
+ if (!result) {
11551
+ return c.html('<div class="text-red-500">File not found</div>');
11552
+ }
11553
+ const file = {
11554
+ id: result.id,
11555
+ filename: result.filename,
11556
+ original_name: result.original_name,
11557
+ mime_type: result.mime_type,
11558
+ size: result.size,
11559
+ public_url: `/files/${result.r2_key}`,
11560
+ thumbnail_url: result.mime_type.startsWith("image/") ? `/files/${result.r2_key}` : void 0,
11561
+ alt: result.alt,
11562
+ caption: result.caption,
11563
+ tags: result.tags ? JSON.parse(result.tags) : [],
11564
+ uploaded_at: result.uploaded_at,
11565
+ fileSize: formatFileSize(result.size),
11566
+ uploadedAt: new Date(result.uploaded_at).toLocaleString(),
11567
+ isImage: result.mime_type.startsWith("image/"),
11568
+ isVideo: result.mime_type.startsWith("video/"),
11569
+ isDocument: !result.mime_type.startsWith("image/") && !result.mime_type.startsWith("video/"),
11570
+ width: result.width,
11571
+ height: result.height,
11572
+ folder: result.folder
11573
+ };
11574
+ const detailsData = { file };
11575
+ return c.html(renderMediaFileDetails(detailsData));
11576
+ } catch (error) {
11577
+ console.error("Error fetching file details:", error);
11578
+ return c.html('<div class="text-red-500">Error loading file details</div>');
11579
+ }
11580
+ });
11581
+ adminMediaRoutes.post("/upload", async (c) => {
11582
+ try {
11583
+ const user = c.get("user");
11584
+ const formData = await c.req.formData();
11585
+ const files = formData.getAll("files");
11586
+ if (!files || files.length === 0) {
11587
+ return c.html(html.html`
11588
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
11589
+ No files provided
11590
+ </div>
11591
+ `);
11592
+ }
11593
+ const uploadResults = [];
11594
+ const errors = [];
11595
+ for (const file of files) {
11596
+ try {
11597
+ const validation = fileValidationSchema2.safeParse({
11598
+ name: file.name,
11599
+ type: file.type,
11600
+ size: file.size
11601
+ });
11602
+ if (!validation.success) {
11603
+ errors.push({
11604
+ filename: file.name,
11605
+ error: validation.error.issues[0]?.message || "Validation failed"
11606
+ });
11607
+ continue;
11608
+ }
11609
+ const fileId = globalThis.crypto.randomUUID();
11610
+ const fileExtension = file.name.split(".").pop() || "";
11611
+ const filename = `${fileId}.${fileExtension}`;
11612
+ const folder = formData.get("folder") || "uploads";
11613
+ const r2Key = `${folder}/${filename}`;
11614
+ const arrayBuffer = await file.arrayBuffer();
11615
+ const uploadResult = await c.env.MEDIA_BUCKET.put(r2Key, arrayBuffer, {
11616
+ httpMetadata: {
11617
+ contentType: file.type,
11618
+ contentDisposition: `inline; filename="${file.name}"`
11619
+ },
11620
+ customMetadata: {
11621
+ originalName: file.name,
11622
+ uploadedBy: user.userId,
11623
+ uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
11624
+ }
11625
+ });
11626
+ if (!uploadResult) {
11627
+ errors.push({
11628
+ filename: file.name,
11629
+ error: "Failed to upload to storage"
11630
+ });
11631
+ continue;
11632
+ }
11633
+ let width;
11634
+ let height;
11635
+ if (file.type.startsWith("image/") && !file.type.includes("svg")) {
11636
+ try {
11637
+ const dimensions = await getImageDimensions2(arrayBuffer);
11638
+ width = dimensions.width;
11639
+ height = dimensions.height;
11640
+ } catch (error) {
11641
+ console.warn("Failed to extract image dimensions:", error);
11642
+ }
11643
+ }
11644
+ const publicUrl = `/files/${r2Key}`;
11645
+ const thumbnailUrl = file.type.startsWith("image/") ? publicUrl : void 0;
11646
+ const stmt = c.env.DB.prepare(`
11647
+ INSERT INTO media (
11648
+ id, filename, original_name, mime_type, size, width, height,
11649
+ folder, r2_key, public_url, thumbnail_url, uploaded_by, uploaded_at
11650
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
11651
+ `);
11652
+ await stmt.bind(
11653
+ fileId,
11654
+ filename,
11655
+ file.name,
11656
+ file.type,
11657
+ file.size,
11658
+ width,
11659
+ height,
11660
+ folder,
11661
+ r2Key,
11662
+ publicUrl,
11663
+ thumbnailUrl,
11664
+ user.userId,
11665
+ Math.floor(Date.now() / 1e3)
11666
+ ).run();
11667
+ uploadResults.push({
11668
+ id: fileId,
11669
+ filename,
11670
+ originalName: file.name,
11671
+ mimeType: file.type,
11672
+ size: file.size,
11673
+ publicUrl
11674
+ });
11675
+ } catch (error) {
11676
+ errors.push({
11677
+ filename: file.name,
11678
+ error: "Upload failed: " + (error instanceof Error ? error.message : "Unknown error")
11679
+ });
11680
+ }
11681
+ }
11682
+ let mediaGridHTML = "";
11683
+ if (uploadResults.length > 0) {
11684
+ try {
11685
+ const folder = formData.get("folder") || "uploads";
11686
+ const query = "SELECT * FROM media WHERE deleted_at IS NULL ORDER BY uploaded_at DESC LIMIT 24";
11687
+ const stmt = c.env.DB.prepare(query);
11688
+ const { results } = await stmt.all();
11689
+ const mediaFiles = results.map((row) => ({
11690
+ id: row.id,
11691
+ filename: row.filename,
11692
+ original_name: row.original_name,
11693
+ mime_type: row.mime_type,
11694
+ size: row.size,
11695
+ public_url: `/files/${row.r2_key}`,
11696
+ thumbnail_url: row.mime_type.startsWith("image/") ? `/files/${row.r2_key}` : void 0,
11697
+ tags: row.tags ? JSON.parse(row.tags) : [],
11698
+ uploaded_at: row.uploaded_at,
11699
+ fileSize: formatFileSize(row.size),
11700
+ uploadedAt: new Date(row.uploaded_at).toLocaleDateString(),
11701
+ isImage: row.mime_type.startsWith("image/"),
11702
+ isVideo: row.mime_type.startsWith("video/"),
11703
+ isDocument: !row.mime_type.startsWith("image/") && !row.mime_type.startsWith("video/")
11704
+ }));
11705
+ mediaGridHTML = mediaFiles.map((file) => renderMediaFileCard(file, "grid", true)).join("");
11706
+ } catch (error) {
11707
+ console.error("Error fetching updated media list:", error);
11708
+ }
11709
+ }
11710
+ return c.html(html.html`
11711
+ ${uploadResults.length > 0 ? html.html`
11712
+ <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
11713
+ Successfully uploaded ${uploadResults.length} file${uploadResults.length > 1 ? "s" : ""}
11714
+ </div>
11715
+ ` : ""}
11716
+
11717
+ ${errors.length > 0 ? html.html`
11718
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
11719
+ <p class="font-medium">Upload errors:</p>
11720
+ <ul class="list-disc list-inside mt-2">
11721
+ ${errors.map((error) => html.html`
11722
+ <li>${error.filename}: ${error.error}</li>
11723
+ `)}
11724
+ </ul>
11725
+ </div>
11726
+ ` : ""}
11727
+
11728
+ ${uploadResults.length > 0 ? html.html`
11729
+ <script>
11730
+ // Close modal and refresh page after successful upload with cache busting
11731
+ setTimeout(() => {
11732
+ document.getElementById('upload-modal').classList.add('hidden');
11733
+ window.location.href = '/admin/media?t=' + Date.now();
11734
+ }, 1500);
11735
+ </script>
11736
+ ` : ""}
11737
+ `);
11738
+ } catch (error) {
11739
+ console.error("Upload error:", error);
11740
+ return c.html(html.html`
11741
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
11742
+ Upload failed: ${error instanceof Error ? error.message : "Unknown error"}
11743
+ </div>
11744
+ `);
11745
+ }
11746
+ });
11747
+ adminMediaRoutes.get("/file/*", async (c) => {
11748
+ try {
11749
+ const r2Key = c.req.path.replace("/admin/media/file/", "");
11750
+ if (!r2Key) {
11751
+ return c.notFound();
11752
+ }
11753
+ const object = await c.env.MEDIA_BUCKET.get(r2Key);
11754
+ if (!object) {
11755
+ return c.notFound();
11756
+ }
11757
+ const headers = new Headers();
11758
+ object.httpMetadata?.contentType && headers.set("Content-Type", object.httpMetadata.contentType);
11759
+ object.httpMetadata?.contentDisposition && headers.set("Content-Disposition", object.httpMetadata.contentDisposition);
11760
+ headers.set("Cache-Control", "public, max-age=31536000");
11761
+ return new Response(object.body, {
11762
+ headers
11763
+ });
11764
+ } catch (error) {
11765
+ console.error("Error serving file:", error);
11766
+ return c.notFound();
11767
+ }
11768
+ });
11769
+ adminMediaRoutes.put("/:id", async (c) => {
11770
+ try {
11771
+ const user = c.get("user");
11772
+ const fileId = c.req.param("id");
11773
+ const formData = await c.req.formData();
11774
+ const stmt = c.env.DB.prepare("SELECT * FROM media WHERE id = ? AND deleted_at IS NULL");
11775
+ const fileRecord = await stmt.bind(fileId).first();
11776
+ if (!fileRecord) {
11777
+ return c.html(html.html`
11778
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
11779
+ File not found
11780
+ </div>
11781
+ `);
11782
+ }
11783
+ if (fileRecord.uploaded_by !== user.userId && user.role !== "admin") {
11784
+ return c.html(html.html`
11785
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
11786
+ Permission denied
11787
+ </div>
11788
+ `);
11789
+ }
11790
+ const alt = formData.get("alt") || null;
11791
+ const caption = formData.get("caption") || null;
11792
+ const tagsString = formData.get("tags") || "";
11793
+ const tags = tagsString ? tagsString.split(",").map((tag) => tag.trim()).filter((tag) => tag) : [];
11794
+ const updateStmt = c.env.DB.prepare(`
11795
+ UPDATE media
11796
+ SET alt = ?, caption = ?, tags = ?, updated_at = ?
11797
+ WHERE id = ?
11798
+ `);
11799
+ await updateStmt.bind(
11800
+ alt,
11801
+ caption,
11802
+ JSON.stringify(tags),
11803
+ Math.floor(Date.now() / 1e3),
11804
+ fileId
11805
+ ).run();
11806
+ return c.html(html.html`
11807
+ <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
11808
+ File updated successfully
11809
+ </div>
11810
+ <script>
11811
+ // Refresh the file details
11812
+ setTimeout(() => {
11813
+ htmx.trigger('#file-modal-content', 'htmx:load');
11814
+ }, 1000);
11815
+ </script>
11816
+ `);
11817
+ } catch (error) {
11818
+ console.error("Update error:", error);
11819
+ return c.html(html.html`
11820
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
11821
+ Update failed: ${error instanceof Error ? error.message : "Unknown error"}
11822
+ </div>
11823
+ `);
11824
+ }
11825
+ });
11826
+ adminMediaRoutes.delete("/:id", async (c) => {
11827
+ try {
11828
+ const user = c.get("user");
11829
+ const fileId = c.req.param("id");
11830
+ const stmt = c.env.DB.prepare("SELECT * FROM media WHERE id = ? AND deleted_at IS NULL");
11831
+ const fileRecord = await stmt.bind(fileId).first();
11832
+ if (!fileRecord) {
11833
+ return c.html(html.html`
11834
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
11835
+ File not found
11836
+ </div>
11837
+ `);
11838
+ }
11839
+ if (fileRecord.uploaded_by !== user.userId && user.role !== "admin") {
11840
+ return c.html(html.html`
11841
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
11842
+ Permission denied
11843
+ </div>
11844
+ `);
11845
+ }
11846
+ try {
11847
+ await c.env.MEDIA_BUCKET.delete(fileRecord.r2_key);
11848
+ } catch (error) {
11849
+ console.warn("Failed to delete from R2:", error);
11850
+ }
11851
+ const deleteStmt = c.env.DB.prepare("UPDATE media SET deleted_at = ? WHERE id = ?");
11852
+ await deleteStmt.bind(Math.floor(Date.now() / 1e3), fileId).run();
11853
+ return c.html(html.html`
11854
+ <script>
11855
+ // Close modal if open
11856
+ const modal = document.getElementById('file-modal');
11857
+ if (modal) {
11858
+ modal.classList.add('hidden');
11859
+ }
11860
+ // Redirect to media library
11861
+ window.location.href = '/admin/media';
11862
+ </script>
11863
+ `);
11864
+ } catch (error) {
11865
+ console.error("Delete error:", error);
11866
+ return c.html(html.html`
11867
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
11868
+ Delete failed: ${error instanceof Error ? error.message : "Unknown error"}
11869
+ </div>
11870
+ `);
11871
+ }
11872
+ });
11873
+ async function getImageDimensions2(arrayBuffer) {
11874
+ const uint8Array = new Uint8Array(arrayBuffer);
11875
+ if (uint8Array[0] === 255 && uint8Array[1] === 216) {
11876
+ return getJPEGDimensions2(uint8Array);
11877
+ }
11878
+ if (uint8Array[0] === 137 && uint8Array[1] === 80 && uint8Array[2] === 78 && uint8Array[3] === 71) {
11879
+ return getPNGDimensions2(uint8Array);
11880
+ }
11881
+ return { width: 0, height: 0 };
11882
+ }
11883
+ function getJPEGDimensions2(uint8Array) {
11884
+ let i = 2;
11885
+ while (i < uint8Array.length - 8) {
11886
+ if (uint8Array[i] === 255 && uint8Array[i + 1] === 192) {
11887
+ return {
11888
+ height: uint8Array[i + 5] << 8 | uint8Array[i + 6],
11889
+ width: uint8Array[i + 7] << 8 | uint8Array[i + 8]
11890
+ };
11891
+ }
11892
+ const segmentLength = uint8Array[i + 2] << 8 | uint8Array[i + 3];
11893
+ i += 2 + segmentLength;
11894
+ }
11895
+ return { width: 0, height: 0 };
11896
+ }
11897
+ function getPNGDimensions2(uint8Array) {
11898
+ if (uint8Array.length < 24) {
11899
+ return { width: 0, height: 0 };
11900
+ }
11901
+ return {
11902
+ width: uint8Array[16] << 24 | uint8Array[17] << 16 | uint8Array[18] << 8 | uint8Array[19],
11903
+ height: uint8Array[20] << 24 | uint8Array[21] << 16 | uint8Array[22] << 8 | uint8Array[23]
11904
+ };
11905
+ }
11906
+ function generateMediaItemHTML(file) {
11907
+ const isImage = file.isImage;
11908
+ const isVideo = file.isVideo;
11909
+ return `
11910
+ <div
11911
+ class="media-item bg-white rounded-lg shadow-sm overflow-hidden cursor-pointer"
11912
+ data-file-id="${file.id}"
11913
+ onclick="toggleFileSelection('${file.id}')"
11914
+ >
11915
+ <div class="aspect-square relative">
11916
+ ${isImage ? `
11917
+ <img
11918
+ src="${file.public_url}"
11919
+ alt="${file.alt || file.filename}"
11920
+ class="w-full h-full object-cover"
11921
+ loading="lazy"
11922
+ >
11923
+ ` : isVideo ? `
11924
+ <video
11925
+ src="${file.public_url}"
11926
+ class="w-full h-full object-cover"
11927
+ muted
11928
+ ></video>
11929
+ ` : `
11930
+ <div class="w-full h-full flex items-center justify-center bg-gray-100">
11931
+ <div class="text-center">
11932
+ <svg class="file-icon mx-auto text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
11933
+ <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>
11934
+ </svg>
11935
+ <span class="text-xs text-gray-500 mt-1">${file.filename.split(".").pop()?.toUpperCase()}</span>
11936
+ </div>
11937
+ </div>
11938
+ `}
11939
+
11940
+ <div class="preview-overlay flex items-center justify-center">
11941
+ <div class="flex space-x-2">
11942
+ <button
11943
+ onclick="event.stopPropagation(); showFileDetails('${file.id}')"
11944
+ class="p-2 bg-white bg-opacity-20 rounded-full hover:bg-opacity-30"
11945
+ >
11946
+ <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
11947
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
11948
+ <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>
11949
+ </svg>
11950
+ </button>
11951
+ <button
11952
+ onclick="event.stopPropagation(); copyToClipboard('${file.public_url}')"
11953
+ class="p-2 bg-white bg-opacity-20 rounded-full hover:bg-opacity-30"
11954
+ >
11955
+ <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
11956
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
11957
+ </svg>
11958
+ </button>
11959
+ </div>
11960
+ </div>
11961
+ </div>
11962
+
11963
+ <div class="p-3">
11964
+ <h4 class="text-sm font-medium text-gray-900 truncate" title="${file.original_name}">
11965
+ ${file.original_name}
11966
+ </h4>
11967
+ <div class="flex justify-between items-center mt-1">
11968
+ <span class="text-xs text-gray-500">${file.fileSize}</span>
11969
+ <span class="text-xs text-gray-500">${file.uploadedAt}</span>
11970
+ </div>
11971
+ ${file.tags.length > 0 ? `
11972
+ <div class="flex flex-wrap gap-1 mt-2">
11973
+ ${file.tags.slice(0, 2).map((tag) => `
11974
+ <span class="inline-block px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded">
11975
+ ${tag}
11976
+ </span>
11977
+ `).join("")}
11978
+ ${file.tags.length > 2 ? `<span class="text-xs text-gray-400">+${file.tags.length - 2}</span>` : ""}
11979
+ </div>
11980
+ ` : ""}
11981
+ </div>
11982
+ </div>
11983
+ `;
11984
+ }
11985
+ function formatFileSize(bytes) {
11986
+ if (bytes === 0) return "0 Bytes";
11987
+ const k = 1024;
11988
+ const sizes = ["Bytes", "KB", "MB", "GB"];
11989
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
11990
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
11991
+ }
11992
+
11993
+ // src/templates/pages/admin-plugins-list.template.ts
11994
+ init_admin_layout_catalyst_template();
11995
+ function renderPluginsListPage(data) {
11996
+ const pageContent = `
11997
+ <div>
11998
+ <!-- Header -->
11999
+ <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6">
12000
+ <div>
12001
+ <h1 class="text-2xl/8 font-semibold text-zinc-950 dark:text-white sm:text-xl/8">Plugins</h1>
12002
+ <p class="mt-2 text-sm/6 text-zinc-500 dark:text-zinc-400">Manage and extend functionality with plugins</p>
12003
+ </div>
12004
+ <div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
12005
+ <div class="relative inline-block text-left">
12006
+ <button onclick="toggleDropdown()" class="inline-flex items-center justify-center rounded-lg bg-zinc-950 dark:bg-white px-3.5 py-2.5 text-sm font-semibold text-white dark:text-zinc-950 hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors shadow-sm">
12007
+ <svg class="-ml-0.5 mr-1.5 h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
12008
+ <path d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z" />
12009
+ </svg>
12010
+ Install Plugin
12011
+ <svg class="-mr-1 ml-2 h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
12012
+ <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
12013
+ </svg>
12014
+ </button>
12015
+ <div id="plugin-dropdown" class="hidden absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-xl bg-white dark:bg-zinc-900 shadow-xl ring-1 ring-zinc-950/5 dark:ring-white/10 focus:outline-none">
12016
+ <div class="py-1">
12017
+ <button onclick="installPlugin('faq-plugin')" class="block w-full text-left px-4 py-2 text-sm text-zinc-950 dark:text-white hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors first:rounded-t-xl">
12018
+ <div class="flex items-center">
12019
+ <span class="text-lg mr-2">\u2753</span>
12020
+ <div>
12021
+ <div class="font-medium">FAQ System</div>
12022
+ <div class="text-xs text-zinc-500 dark:text-zinc-400">Community FAQ management plugin</div>
12023
+ </div>
12024
+ </div>
12025
+ </button>
12026
+ <div class="border-t border-zinc-950/5 dark:border-white/10 my-1"></div>
12027
+ <button onclick="showNotification('Plugin marketplace coming soon!', 'info')" class="block w-full text-left px-4 py-2 text-sm text-zinc-950 dark:text-white hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors last:rounded-b-xl">
12028
+ <div class="flex items-center">
12029
+ <svg class="w-5 h-5 mr-2 text-zinc-500 dark:text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
12030
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
12031
+ </svg>
12032
+ <div>
12033
+ <div class="font-medium">Browse Marketplace</div>
12034
+ <div class="text-xs text-zinc-500 dark:text-zinc-400">Discover more plugins</div>
12035
+ </div>
12036
+ </div>
12037
+ </button>
12038
+ </div>
12039
+ </div>
12040
+ </div>
12041
+ </div>
12042
+ </div>
12043
+
12044
+ <!-- Stats -->
12045
+ <div class="mb-6">
12046
+ <h3 class="text-base font-semibold text-zinc-950 dark:text-white">Plugin Statistics</h3>
12047
+ <dl class="mt-5 grid grid-cols-1 divide-zinc-950/5 dark:divide-white/10 overflow-hidden rounded-lg bg-zinc-800/75 dark:bg-zinc-800/75 ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 md:grid-cols-4 md:divide-x md:divide-y-0">
12048
+ <div class="px-4 py-5 sm:p-6">
12049
+ <dt class="text-base font-normal text-zinc-700 dark:text-zinc-100">Total Plugins</dt>
12050
+ <dd class="mt-1 flex items-baseline justify-between md:block lg:flex">
12051
+ <div class="flex items-baseline text-2xl font-semibold text-cyan-400">
12052
+ ${data.stats?.total || 0}
12053
+ </div>
12054
+ <div class="inline-flex items-baseline rounded-full bg-lime-400/10 text-lime-600 dark:text-lime-400 px-2.5 py-0.5 text-sm font-medium md:mt-2 lg:mt-0">
12055
+ <svg viewBox="0 0 20 20" fill="currentColor" class="-ml-1 mr-0.5 size-5 shrink-0 self-center">
12056
+ <path d="M10 17a.75.75 0 0 1-.75-.75V5.612L5.29 9.77a.75.75 0 0 1-1.08-1.04l5.25-5.5a.75.75 0 0 1 1.08 0l5.25 5.5a.75.75 0 1 1-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0 1 10 17Z" clip-rule="evenodd" fill-rule="evenodd" />
12057
+ </svg>
12058
+ <span class="sr-only">Increased by</span>
12059
+ 8.5%
12060
+ </div>
12061
+ </dd>
12062
+ </div>
12063
+ <div class="px-4 py-5 sm:p-6">
12064
+ <dt class="text-base font-normal text-zinc-700 dark:text-zinc-100">Active Plugins</dt>
12065
+ <dd class="mt-1 flex items-baseline justify-between md:block lg:flex">
12066
+ <div class="flex items-baseline text-2xl font-semibold text-lime-400">
12067
+ ${data.stats?.active || 0}
12068
+ </div>
12069
+ <div class="inline-flex items-baseline rounded-full bg-lime-400/10 text-lime-600 dark:text-lime-400 px-2.5 py-0.5 text-sm font-medium md:mt-2 lg:mt-0">
12070
+ <svg viewBox="0 0 20 20" fill="currentColor" class="-ml-1 mr-0.5 size-5 shrink-0 self-center">
12071
+ <path d="M10 17a.75.75 0 0 1-.75-.75V5.612L5.29 9.77a.75.75 0 0 1-1.08-1.04l5.25-5.5a.75.75 0 0 1 1.08 0l5.25 5.5a.75.75 0 1 1-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0 1 10 17Z" clip-rule="evenodd" fill-rule="evenodd" />
12072
+ </svg>
12073
+ <span class="sr-only">Increased by</span>
12074
+ 12.3%
12075
+ </div>
12076
+ </dd>
12077
+ </div>
12078
+ <div class="px-4 py-5 sm:p-6">
12079
+ <dt class="text-base font-normal text-zinc-700 dark:text-zinc-100">Inactive Plugins</dt>
12080
+ <dd class="mt-1 flex items-baseline justify-between md:block lg:flex">
12081
+ <div class="flex items-baseline text-2xl font-semibold text-purple-400">
12082
+ ${data.stats?.inactive || 0}
12083
+ </div>
12084
+ <div class="inline-flex items-baseline rounded-full bg-pink-400/10 text-pink-600 dark:text-pink-400 px-2.5 py-0.5 text-sm font-medium md:mt-2 lg:mt-0">
12085
+ <svg viewBox="0 0 20 20" fill="currentColor" class="-ml-1 mr-0.5 size-5 shrink-0 self-center">
12086
+ <path d="M10 3a.75.75 0 0 1 .75.75v10.638l3.96-4.158a.75.75 0 1 1 1.08 1.04l-5.25 5.5a.75.75 0 0 1-1.08 0l-5.25-5.5a.75.75 0 1 1 1.08-1.04l3.96 4.158V3.75A.75.75 0 0 1 10 3Z" clip-rule="evenodd" fill-rule="evenodd" />
12087
+ </svg>
12088
+ <span class="sr-only">Decreased by</span>
12089
+ 3.2%
12090
+ </div>
12091
+ </dd>
12092
+ </div>
12093
+ <div class="px-4 py-5 sm:p-6">
12094
+ <dt class="text-base font-normal text-zinc-700 dark:text-zinc-100">Plugin Errors</dt>
12095
+ <dd class="mt-1 flex items-baseline justify-between md:block lg:flex">
12096
+ <div class="flex items-baseline text-2xl font-semibold text-pink-400">
12097
+ ${data.stats?.errors || 0}
12098
+ </div>
12099
+ <div class="inline-flex items-baseline rounded-full bg-pink-400/10 text-pink-600 dark:text-pink-400 px-2.5 py-0.5 text-sm font-medium md:mt-2 lg:mt-0">
12100
+ <svg viewBox="0 0 20 20" fill="currentColor" class="-ml-1 mr-0.5 size-5 shrink-0 self-center">
12101
+ <path d="M10 3a.75.75 0 0 1 .75.75v10.638l3.96-4.158a.75.75 0 1 1 1.08 1.04l-5.25 5.5a.75.75 0 0 1-1.08 0l-5.25-5.5a.75.75 0 1 1 1.08-1.04l3.96 4.158V3.75A.75.75 0 0 1 10 3Z" clip-rule="evenodd" fill-rule="evenodd" />
12102
+ </svg>
12103
+ <span class="sr-only">Decreased by</span>
12104
+ 1.5%
12105
+ </div>
12106
+ </dd>
12107
+ </div>
12108
+ </dl>
12109
+ </div>
12110
+
12111
+ <!-- Filters -->
12112
+ <div class="relative rounded-xl overflow-hidden mb-6">
12113
+ <!-- Gradient Background -->
12114
+ <div class="absolute inset-0 bg-gradient-to-r from-cyan-500/10 via-blue-500/10 to-purple-500/10 dark:from-cyan-400/20 dark:via-blue-400/20 dark:to-purple-400/20"></div>
12115
+
12116
+ <div class="relative bg-white/80 dark:bg-zinc-900/80 backdrop-blur-xl shadow-sm ring-1 ring-zinc-950/5 dark:ring-white/10">
12117
+ <div class="px-6 py-5">
12118
+ <div class="flex items-center justify-between">
12119
+ <div class="flex items-center space-x-4 flex-1">
12120
+ <div>
12121
+ <label class="block text-sm/6 font-medium text-zinc-950 dark:text-white">Category</label>
12122
+ <div class="mt-2 grid grid-cols-1">
12123
+ <select class="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white/5 dark:bg-white/5 py-1.5 pl-3 pr-8 text-base text-zinc-950 dark:text-white outline outline-1 -outline-offset-1 outline-cyan-500/30 dark:outline-cyan-400/30 *:bg-white dark:*:bg-zinc-800 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-cyan-500 dark:focus-visible:outline-cyan-400 sm:text-sm/6 min-w-48">
12124
+ <option value="">All Categories</option>
12125
+ <option value="content">Content Management</option>
12126
+ <option value="media">Media</option>
12127
+ <option value="seo">SEO & Analytics</option>
12128
+ <option value="security">Security</option>
12129
+ <option value="utilities">Utilities</option>
12130
+ </select>
12131
+ <svg viewBox="0 0 16 16" fill="currentColor" data-slot="icon" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end text-cyan-600 dark:text-cyan-400 sm:size-4">
12132
+ <path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" fill-rule="evenodd" />
12133
+ </svg>
12134
+ </div>
12135
+ </div>
12136
+ <div>
12137
+ <label class="block text-sm/6 font-medium text-zinc-950 dark:text-white">Status</label>
12138
+ <div class="mt-2 grid grid-cols-1">
12139
+ <select class="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white/5 dark:bg-white/5 py-1.5 pl-3 pr-8 text-base text-zinc-950 dark:text-white outline outline-1 -outline-offset-1 outline-cyan-500/30 dark:outline-cyan-400/30 *:bg-white dark:*:bg-zinc-800 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-cyan-500 dark:focus-visible:outline-cyan-400 sm:text-sm/6 min-w-48">
12140
+ <option value="">All Status</option>
12141
+ <option value="active">Active</option>
12142
+ <option value="inactive">Inactive</option>
12143
+ <option value="error">Error</option>
12144
+ </select>
12145
+ <svg viewBox="0 0 16 16" fill="currentColor" data-slot="icon" aria-hidden="true" class="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end text-cyan-600 dark:text-cyan-400 sm:size-4">
12146
+ <path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" fill-rule="evenodd" />
12147
+ </svg>
12148
+ </div>
12149
+ </div>
12150
+ <div class="flex-1 max-w-md">
12151
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-2">Search</label>
12152
+ <div class="relative group">
12153
+ <div class="absolute left-3.5 top-2.5 flex items-center justify-center w-5 h-5 rounded-full bg-gradient-to-br from-cyan-400 to-blue-500 dark:from-cyan-300 dark:to-blue-400 opacity-90 group-focus-within:opacity-100 transition-opacity">
12154
+ <svg class="h-3 w-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
12155
+ <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
12156
+ </svg>
12157
+ </div>
12158
+ <input
12159
+ type="text"
12160
+ placeholder="Search plugins..."
12161
+ class="w-full rounded-full bg-transparent px-11 py-2 text-sm text-zinc-950 dark:text-white placeholder-zinc-500 dark:placeholder-zinc-400 border-2 border-cyan-200/50 dark:border-cyan-700/50 focus:outline-none focus:border-cyan-500 dark:focus:border-cyan-400 focus:shadow-lg focus:shadow-cyan-500/20 dark:focus:shadow-cyan-400/20 transition-all duration-300"
12162
+ />
12163
+ </div>
12164
+ </div>
12165
+ </div>
12166
+ <div class="flex items-center gap-x-3 ml-4">
12167
+ <button
12168
+ onclick="location.reload()"
12169
+ class="inline-flex items-center gap-x-1.5 px-3 py-1.5 bg-white/90 dark:bg-zinc-800/90 backdrop-blur-sm text-zinc-950 dark:text-white text-sm font-medium rounded-full ring-1 ring-inset ring-cyan-200/50 dark:ring-cyan-700/50 hover:bg-gradient-to-r hover:from-cyan-50 hover:to-blue-50 dark:hover:from-cyan-900/30 dark:hover:to-blue-900/30 hover:ring-cyan-300 dark:hover:ring-cyan-600 transition-all duration-200"
12170
+ >
12171
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
12172
+ <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"/>
12173
+ </svg>
12174
+ Refresh
12175
+ </button>
12176
+ </div>
12177
+ </div>
12178
+ </div>
12179
+ </div>
12180
+ </div>
12181
+
12182
+ <!-- Plugins Grid -->
12183
+ <div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
12184
+ ${data.plugins.map((plugin) => renderPluginCard(plugin)).join("")}
12185
+ </div>
12186
+
12187
+ <script>
12188
+ async function togglePlugin(pluginId, action) {
12189
+ const button = event.target;
12190
+ const originalText = button.textContent;
12191
+ button.disabled = true;
12192
+ button.textContent = action === 'activate' ? 'Activating...' : 'Deactivating...';
12193
+
12194
+ try {
12195
+ const response = await fetch(\`/admin/plugins/\${pluginId}/\${action}\`, {
12196
+ method: 'POST',
12197
+ headers: {
12198
+ 'Content-Type': 'application/json'
12199
+ }
12200
+ });
12201
+
12202
+ const result = await response.json();
12203
+
12204
+ if (result.success) {
12205
+ // Update UI
12206
+ const card = button.closest('.plugin-card');
12207
+ const statusBadge = card.querySelector('.status-badge');
12208
+
12209
+ if (action === 'activate') {
12210
+ // Update status badge
12211
+ statusBadge.className = 'status-badge inline-flex items-center rounded-md px-2.5 py-1 text-sm font-medium ring-1 ring-inset bg-lime-50 dark:bg-lime-500/10 text-lime-700 dark:text-lime-300 ring-lime-700/10 dark:ring-lime-400/20';
12212
+ statusBadge.innerHTML = '<div class="w-2 h-2 bg-lime-500 dark:bg-lime-400 rounded-full mr-2"></div>Active';
12213
+ // Update card border to green
12214
+ card.className = 'plugin-card rounded-xl bg-white dark:bg-zinc-900 shadow-sm ring-[3px] ring-lime-500 dark:ring-lime-400 p-6 hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-all';
12215
+ // Update button
12216
+ button.textContent = 'Deactivate';
12217
+ button.onclick = () => togglePlugin(pluginId, 'deactivate');
12218
+ button.className = 'bg-red-600 dark:bg-red-700 hover:bg-red-700 dark:hover:bg-red-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-colors';
12219
+ } else {
12220
+ // Update status badge
12221
+ statusBadge.className = 'status-badge inline-flex items-center rounded-md px-2.5 py-1 text-sm font-medium ring-1 ring-inset bg-zinc-50 dark:bg-zinc-500/10 text-zinc-700 dark:text-zinc-400 ring-zinc-700/10 dark:ring-zinc-400/20';
12222
+ statusBadge.innerHTML = '<div class="w-2 h-2 bg-zinc-500 dark:bg-zinc-400 rounded-full mr-2"></div>Inactive';
12223
+ // Update card border to pink
12224
+ card.className = 'plugin-card rounded-xl bg-white dark:bg-zinc-900 shadow-sm ring-[3px] ring-pink-500 dark:ring-pink-400 p-6 hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-all';
12225
+ // Update button
12226
+ button.textContent = 'Activate';
12227
+ button.onclick = () => togglePlugin(pluginId, 'activate');
12228
+ button.className = 'bg-lime-600 dark:bg-lime-700 hover:bg-lime-700 dark:hover:bg-lime-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-colors';
12229
+ }
12230
+
12231
+ showNotification(\`Plugin \${action}d successfully\`, 'success');
12232
+ } else {
12233
+ throw new Error(result.error || \`Failed to \${action} plugin\`);
12234
+ }
12235
+ } catch (error) {
12236
+ showNotification(error.message, 'error');
12237
+ button.textContent = originalText;
12238
+ } finally {
12239
+ button.disabled = false;
12240
+ }
12241
+ }
12242
+
12243
+ async function installPlugin(pluginName) {
12244
+ const button = event.target;
12245
+ button.disabled = true;
12246
+ button.textContent = 'Installing...';
12247
+
12248
+ try {
12249
+ const response = await fetch('/admin/plugins/install', {
12250
+ method: 'POST',
12251
+ headers: {
12252
+ 'Content-Type': 'application/json'
12253
+ },
12254
+ body: JSON.stringify({ name: pluginName })
12255
+ });
12256
+
12257
+ const result = await response.json();
12258
+
12259
+ if (result.success) {
12260
+ showNotification('Plugin installed successfully!', 'success');
12261
+ setTimeout(() => location.reload(), 1500);
12262
+ } else {
12263
+ throw new Error(result.error || 'Failed to install plugin');
12264
+ }
12265
+ } catch (error) {
12266
+ showNotification(error.message, 'error');
12267
+ button.disabled = false;
12268
+ button.textContent = 'Install';
12269
+ }
12270
+ }
12271
+
12272
+ let pluginToUninstall = null;
12273
+
12274
+ async function uninstallPlugin(pluginId) {
12275
+ pluginToUninstall = pluginId;
12276
+ showConfirmDialog('uninstall-plugin-confirm');
12277
+ }
12278
+
12279
+ async function performUninstallPlugin() {
12280
+ if (!pluginToUninstall) return;
12281
+
12282
+ const button = event.target;
12283
+ if (button) button.disabled = true;
12284
+
12285
+ try {
12286
+ const response = await fetch(\`/admin/plugins/\${pluginToUninstall}/uninstall\`, {
12287
+ method: 'POST',
12288
+ headers: {
12289
+ 'Content-Type': 'application/json'
12290
+ }
12291
+ });
12292
+
12293
+ const result = await response.json();
12294
+
12295
+ if (result.success) {
12296
+ showNotification('Plugin uninstalled successfully!', 'success');
12297
+ setTimeout(() => location.reload(), 1500);
12298
+ } else {
12299
+ throw new Error(result.error || 'Failed to uninstall plugin');
12300
+ }
12301
+ } catch (error) {
12302
+ showNotification(error.message, 'error');
12303
+ if (button) button.disabled = false;
12304
+ } finally {
12305
+ pluginToUninstall = null;
12306
+ }
12307
+ }
12308
+
12309
+ function openPluginSettings(pluginId) {
12310
+ window.location.href = \`/admin/plugins/\${pluginId}\`;
12311
+ }
12312
+
12313
+ function showPluginDetails(pluginId) {
12314
+ // TODO: Implement plugin details modal
12315
+ showNotification('Plugin details coming soon!', 'info');
12316
+ }
12317
+
12318
+ function showNotification(message, type) {
12319
+ const notification = document.createElement('div');
12320
+ const bgColor = type === 'success' ? 'bg-green-600' : type === 'error' ? 'bg-red-600' : 'bg-blue-600';
12321
+ notification.className = \`fixed top-4 right-4 px-4 py-2 rounded-lg text-white z-50 \${bgColor}\`;
12322
+ notification.textContent = message;
12323
+ document.body.appendChild(notification);
12324
+
12325
+ setTimeout(() => {
12326
+ notification.remove();
12327
+ }, 3000);
12328
+ }
12329
+
12330
+ function toggleDropdown() {
12331
+ const dropdown = document.getElementById('plugin-dropdown');
12332
+ dropdown.classList.toggle('hidden');
12333
+ }
12334
+
12335
+ // Close dropdown when clicking outside
12336
+ document.addEventListener('click', (event) => {
12337
+ const dropdown = document.getElementById('plugin-dropdown');
12338
+ const button = event.target.closest('button[onclick="toggleDropdown()"]');
12339
+
12340
+ if (!button && !dropdown.contains(event.target)) {
12341
+ dropdown.classList.add('hidden');
12342
+ }
12343
+ });
12344
+ </script>
12345
+
12346
+ <!-- Confirmation Dialogs -->
12347
+ ${renderConfirmationDialog({
12348
+ id: "uninstall-plugin-confirm",
12349
+ title: "Uninstall Plugin",
12350
+ message: "Are you sure you want to uninstall this plugin? This action cannot be undone.",
12351
+ confirmText: "Uninstall",
12352
+ cancelText: "Cancel",
12353
+ iconColor: "red",
12354
+ confirmClass: "bg-red-500 hover:bg-red-400",
12355
+ onConfirm: "performUninstallPlugin()"
12356
+ })}
12357
+
12358
+ ${getConfirmationDialogScript()}
12359
+ `;
12360
+ const layoutData = {
12361
+ title: "Plugins",
12362
+ currentPath: "/admin/plugins",
12363
+ user: data.user,
12364
+ version: data.version,
12365
+ content: pageContent
12366
+ };
12367
+ return renderAdminLayoutCatalyst(layoutData);
12368
+ }
12369
+ function renderPluginCard(plugin) {
12370
+ const statusColors = {
12371
+ active: "bg-lime-50 dark:bg-lime-500/10 text-lime-700 dark:text-lime-300 ring-lime-700/10 dark:ring-lime-400/20",
12372
+ inactive: "bg-zinc-50 dark:bg-zinc-500/10 text-zinc-700 dark:text-zinc-400 ring-zinc-700/10 dark:ring-zinc-400/20",
12373
+ error: "bg-red-50 dark:bg-red-500/10 text-red-700 dark:text-red-400 ring-red-700/10 dark:ring-red-400/20"
12374
+ };
12375
+ const statusIcons = {
12376
+ active: '<div class="w-2 h-2 bg-lime-500 dark:bg-lime-400 rounded-full mr-2"></div>',
12377
+ inactive: '<div class="w-2 h-2 bg-zinc-500 dark:bg-zinc-400 rounded-full mr-2"></div>',
12378
+ error: '<div class="w-2 h-2 bg-red-500 dark:bg-red-400 rounded-full mr-2"></div>'
12379
+ };
12380
+ const borderColors = {
12381
+ active: "ring-[3px] ring-lime-500 dark:ring-lime-400",
12382
+ inactive: "ring-[3px] ring-pink-500 dark:ring-pink-400",
12383
+ error: "ring-[3px] ring-red-500 dark:ring-red-400"
12384
+ };
12385
+ const criticalCorePlugins = ["core-auth", "core-media"];
12386
+ const canToggle = !criticalCorePlugins.includes(plugin.id);
12387
+ const actionButton = plugin.status === "active" ? `<button onclick="togglePlugin('${plugin.id}', 'deactivate')" class="bg-red-600 dark:bg-red-700 hover:bg-red-700 dark:hover:bg-red-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-colors">Deactivate</button>` : `<button onclick="togglePlugin('${plugin.id}', 'activate')" class="bg-lime-600 dark:bg-lime-700 hover:bg-lime-700 dark:hover:bg-lime-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-colors">Activate</button>`;
12388
+ return `
12389
+ <div class="plugin-card rounded-xl bg-white dark:bg-zinc-900 shadow-sm ${borderColors[plugin.status]} p-6 hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-all">
12390
+ <div class="flex items-start justify-between mb-4">
12391
+ <div class="flex items-center gap-3">
12392
+ <div class="w-12 h-12 rounded-lg flex items-center justify-center ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 bg-zinc-50 dark:bg-zinc-800">
12393
+ ${plugin.icon || getDefaultPluginIcon(plugin.category)}
12394
+ </div>
12395
+ <div>
12396
+ <h3 class="text-lg font-semibold text-zinc-950 dark:text-white">${plugin.displayName}</h3>
12397
+ <p class="text-sm text-zinc-500 dark:text-zinc-400">v${plugin.version} by ${plugin.author}</p>
12398
+ </div>
12399
+ </div>
12400
+ <div class="flex flex-col items-end gap-2">
12401
+ <span class="status-badge inline-flex items-center rounded-md px-2.5 py-1 text-sm font-medium ring-1 ring-inset ${statusColors[plugin.status]}">
12402
+ ${statusIcons[plugin.status]}${plugin.status.charAt(0).toUpperCase() + plugin.status.slice(1)}
12403
+ </span>
12404
+ ${plugin.isCore ? '<span class="inline-flex items-center rounded-md px-2.5 py-1 text-sm font-medium bg-cyan-50 dark:bg-cyan-500/10 text-cyan-700 dark:text-cyan-300 ring-1 ring-inset ring-cyan-700/10 dark:ring-cyan-400/20">Core</span>' : ""}
12405
+ </div>
12406
+ </div>
12407
+
12408
+ <p class="text-zinc-600 dark:text-zinc-300 text-sm mb-4 line-clamp-3">${plugin.description}</p>
12409
+
12410
+ <div class="flex items-center gap-4 mb-4 text-xs text-zinc-500 dark:text-zinc-400">
12411
+ <span class="flex items-center gap-1">
12412
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
12413
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/>
12414
+ </svg>
12415
+ ${plugin.category}
12416
+ </span>
12417
+
12418
+ ${plugin.downloadCount ? `
12419
+ <span class="flex items-center gap-1">
12420
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
12421
+ <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"/>
12422
+ </svg>
12423
+ ${plugin.downloadCount.toLocaleString()}
12424
+ </span>
12425
+ ` : ""}
12426
+
12427
+ ${plugin.rating ? `
12428
+ <span class="flex items-center gap-1">
12429
+ <svg class="w-4 h-4 text-yellow-500 dark:text-yellow-400 fill-current" viewBox="0 0 24 24">
12430
+ <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
12431
+ </svg>
12432
+ ${plugin.rating}
12433
+ </span>
12434
+ ` : ""}
12435
+
12436
+ <span>${plugin.lastUpdated}</span>
12437
+ </div>
12438
+
12439
+ ${plugin.dependencies && plugin.dependencies.length > 0 ? `
12440
+ <div class="mb-4">
12441
+ <p class="text-xs text-zinc-500 dark:text-zinc-400 mb-2">Dependencies:</p>
12442
+ <div class="flex flex-wrap gap-1">
12443
+ ${plugin.dependencies.map((dep) => `<span class="inline-block bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-xs px-2 py-1 rounded">${dep}</span>`).join("")}
12444
+ </div>
12445
+ </div>
12446
+ ` : ""}
12447
+
12448
+ <div class="flex items-center justify-between">
12449
+ <div class="flex gap-2">
12450
+ ${canToggle ? actionButton : ""}
12451
+ <button onclick="openPluginSettings('${plugin.id}')" class="bg-white dark:bg-zinc-800 hover:bg-zinc-50 dark:hover:bg-zinc-700 text-zinc-950 dark:text-white ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors">
12452
+ Settings
12453
+ </button>
12454
+ </div>
12455
+
12456
+ <div class="flex items-center gap-2">
12457
+ <button onclick="showPluginDetails('${plugin.id}')" class="text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300 p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors" title="Plugin Details">
12458
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
12459
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
12460
+ </svg>
12461
+ </button>
12462
+
12463
+ ${!plugin.isCore ? `
12464
+ <button onclick="uninstallPlugin('${plugin.id}')" class="text-zinc-500 dark:text-zinc-400 hover:text-red-600 dark:hover:text-red-400 p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors" title="Uninstall Plugin">
12465
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
12466
+ <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"/>
12467
+ </svg>
12468
+ </button>
12469
+ ` : ""}
12470
+ </div>
12471
+ </div>
12472
+ </div>
12473
+ `;
12474
+ }
12475
+ function getDefaultPluginIcon(category) {
12476
+ const iconColor = "text-zinc-600 dark:text-zinc-400";
12477
+ const icons = {
12478
+ "content": `
12479
+ <svg class="w-6 h-6 ${iconColor}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
12480
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
12481
+ </svg>
12482
+ `,
12483
+ "media": `
12484
+ <svg class="w-6 h-6 ${iconColor}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
12485
+ <path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
12486
+ </svg>
12487
+ `,
12488
+ "seo": `
12489
+ <svg class="w-6 h-6 ${iconColor}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
12490
+ <path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
12491
+ </svg>
12492
+ `,
12493
+ "analytics": `
12494
+ <svg class="w-6 h-6 ${iconColor}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
12495
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
12496
+ </svg>
12497
+ `,
12498
+ "ecommerce": `
12499
+ <svg class="w-6 h-6 ${iconColor}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
12500
+ <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
12501
+ </svg>
12502
+ `,
12503
+ "email": `
12504
+ <svg class="w-6 h-6 ${iconColor}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
12505
+ <path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
12506
+ </svg>
12507
+ `,
12508
+ "workflow": `
12509
+ <svg class="w-6 h-6 ${iconColor}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
12510
+ <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
12511
+ </svg>
12512
+ `,
12513
+ "security": `
12514
+ <svg class="w-6 h-6 ${iconColor}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
12515
+ <path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
12516
+ </svg>
12517
+ `,
12518
+ "social": `
12519
+ <svg class="w-6 h-6 ${iconColor}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
12520
+ <path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />
12521
+ </svg>
12522
+ `,
12523
+ "utility": `
12524
+ <svg class="w-6 h-6 ${iconColor}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
12525
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
12526
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
12527
+ </svg>
12528
+ `
12529
+ };
12530
+ const iconKey = category.toLowerCase();
12531
+ return icons[iconKey] || icons["utility"] || "";
12532
+ }
12533
+
12534
+ // src/templates/components/auth-settings-form.template.ts
12535
+ function renderAuthSettingsForm(settings) {
12536
+ const fields = settings.requiredFields;
12537
+ const validation = settings.validation;
12538
+ const registration = settings.registration;
12539
+ return `
12540
+ <div class="space-y-8">
12541
+ <!-- Required Fields Section -->
12542
+ <div class="backdrop-blur-md bg-black/20 rounded-xl border border-white/10 shadow-xl p-6">
12543
+ <h3 class="text-lg font-semibold text-white mb-4">Registration Fields</h3>
12544
+ <p class="text-sm text-gray-400 mb-6">Configure which fields are required during user registration and their minimum lengths.</p>
12545
+
12546
+ <div class="space-y-6">
12547
+ ${Object.entries(fields).map(([fieldName, config]) => `
12548
+ <div class="border-b border-white/10 pb-6 last:border-b-0 last:pb-0">
12549
+ <div class="flex items-start justify-between mb-4">
12550
+ <div>
12551
+ <h4 class="text-sm font-medium text-white">${config.label}</h4>
12552
+ <p class="text-xs text-gray-400 mt-1">Field type: ${config.type}</p>
12553
+ </div>
12554
+ <label class="relative inline-flex items-center cursor-pointer">
12555
+ <input
12556
+ type="checkbox"
12557
+ name="requiredFields_${fieldName}_required"
12558
+ ${config.required ? "checked" : ""}
12559
+ class="sr-only peer"
12560
+ >
12561
+ <div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
12562
+ <span class="ml-3 text-sm font-medium text-gray-300">Required</span>
12563
+ </label>
12564
+ </div>
12565
+
12566
+ <div class="grid grid-cols-2 gap-4">
12567
+ <div>
12568
+ <label class="block text-xs font-medium text-gray-400 mb-2">Minimum Length</label>
12569
+ <input
12570
+ type="number"
12571
+ name="requiredFields_${fieldName}_minLength"
12572
+ value="${config.minLength}"
12573
+ min="0"
12574
+ max="100"
12575
+ class="backdrop-blur-sm bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-white placeholder-gray-300 focus:border-blue-400 focus:outline-none transition-colors w-full"
12576
+ >
12577
+ </div>
12578
+ <div>
12579
+ <label class="block text-xs font-medium text-gray-400 mb-2">Field Label</label>
12580
+ <input
12581
+ type="text"
12582
+ name="requiredFields_${fieldName}_label"
12583
+ value="${config.label}"
12584
+ class="backdrop-blur-sm bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-white placeholder-gray-300 focus:border-blue-400 focus:outline-none transition-colors w-full"
12585
+ >
12586
+ </div>
12587
+ </div>
12588
+ </div>
12589
+ `).join("")}
12590
+ </div>
12591
+ </div>
12592
+
12593
+ <!-- Password Requirements Section -->
12594
+ <div class="backdrop-blur-md bg-black/20 rounded-xl border border-white/10 shadow-xl p-6">
12595
+ <h3 class="text-lg font-semibold text-white mb-4">Password Requirements</h3>
12596
+ <p class="text-sm text-gray-400 mb-6">Additional password complexity requirements.</p>
12597
+
12598
+ <div class="space-y-4">
12599
+ <div class="flex items-center justify-between">
12600
+ <div>
12601
+ <label class="text-sm font-medium text-gray-300">Require Uppercase Letter</label>
12602
+ <p class="text-xs text-gray-400">Password must contain at least one uppercase letter (A-Z)</p>
12603
+ </div>
12604
+ <label class="relative inline-flex items-center cursor-pointer">
12605
+ <input
12606
+ type="checkbox"
12607
+ name="validation_passwordRequirements_requireUppercase"
12608
+ ${validation.passwordRequirements.requireUppercase ? "checked" : ""}
12609
+ class="sr-only peer"
12610
+ >
12611
+ <div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
12612
+ </label>
12613
+ </div>
12614
+
12615
+ <div class="flex items-center justify-between">
12616
+ <div>
12617
+ <label class="text-sm font-medium text-gray-300">Require Lowercase Letter</label>
12618
+ <p class="text-xs text-gray-400">Password must contain at least one lowercase letter (a-z)</p>
12619
+ </div>
12620
+ <label class="relative inline-flex items-center cursor-pointer">
12621
+ <input
12622
+ type="checkbox"
12623
+ name="validation_passwordRequirements_requireLowercase"
12624
+ ${validation.passwordRequirements.requireLowercase ? "checked" : ""}
12625
+ class="sr-only peer"
12626
+ >
12627
+ <div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
12628
+ </label>
12629
+ </div>
12630
+
12631
+ <div class="flex items-center justify-between">
12632
+ <div>
12633
+ <label class="text-sm font-medium text-gray-300">Require Numbers</label>
12634
+ <p class="text-xs text-gray-400">Password must contain at least one number (0-9)</p>
12635
+ </div>
12636
+ <label class="relative inline-flex items-center cursor-pointer">
12637
+ <input
12638
+ type="checkbox"
12639
+ name="validation_passwordRequirements_requireNumbers"
12640
+ ${validation.passwordRequirements.requireNumbers ? "checked" : ""}
12641
+ class="sr-only peer"
12642
+ >
12643
+ <div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
12644
+ </label>
12645
+ </div>
12646
+
12647
+ <div class="flex items-center justify-between">
12648
+ <div>
12649
+ <label class="text-sm font-medium text-gray-300">Require Special Characters</label>
12650
+ <p class="text-xs text-gray-400">Password must contain at least one special character (!@#$%^&*)</p>
12651
+ </div>
12652
+ <label class="relative inline-flex items-center cursor-pointer">
12653
+ <input
12654
+ type="checkbox"
12655
+ name="validation_passwordRequirements_requireSpecialChars"
12656
+ ${validation.passwordRequirements.requireSpecialChars ? "checked" : ""}
12657
+ class="sr-only peer"
12658
+ >
12659
+ <div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
12660
+ </label>
12661
+ </div>
12662
+ </div>
12663
+ </div>
12664
+
12665
+ <!-- Registration Settings Section -->
12666
+ <div class="backdrop-blur-md bg-black/20 rounded-xl border border-white/10 shadow-xl p-6">
12667
+ <h3 class="text-lg font-semibold text-white mb-4">Registration Settings</h3>
12668
+ <p class="text-sm text-gray-400 mb-6">General registration behavior.</p>
12669
+
12670
+ <div class="space-y-4">
12671
+ <div class="flex items-center justify-between">
12672
+ <div>
12673
+ <label class="text-sm font-medium text-gray-300">Allow User Registration</label>
12674
+ <p class="text-xs text-gray-400">Enable or disable public user registration</p>
12675
+ </div>
12676
+ <label class="relative inline-flex items-center cursor-pointer">
12677
+ <input
12678
+ type="checkbox"
12679
+ name="registration_enabled"
12680
+ ${registration.enabled ? "checked" : ""}
12681
+ class="sr-only peer"
12682
+ >
12683
+ <div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
12684
+ </label>
12685
+ </div>
12686
+
12687
+ <div class="flex items-center justify-between">
12688
+ <div>
12689
+ <label class="text-sm font-medium text-gray-300">Require Email Verification</label>
12690
+ <p class="text-xs text-gray-400">Users must verify their email before accessing the system</p>
12691
+ </div>
12692
+ <label class="relative inline-flex items-center cursor-pointer">
12693
+ <input
12694
+ type="checkbox"
12695
+ name="registration_requireEmailVerification"
12696
+ ${registration.requireEmailVerification ? "checked" : ""}
12697
+ class="sr-only peer"
12698
+ >
12699
+ <div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
12700
+ </label>
12701
+ </div>
12702
+
12703
+ <div>
12704
+ <label class="block text-sm font-medium text-gray-300 mb-2">Default User Role</label>
12705
+ <select
12706
+ name="registration_defaultRole"
12707
+ class="backdrop-blur-sm bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-white focus:border-blue-400 focus:outline-none transition-colors w-full"
12708
+ >
12709
+ <option value="viewer" ${registration.defaultRole === "viewer" ? "selected" : ""}>Viewer</option>
12710
+ <option value="editor" ${registration.defaultRole === "editor" ? "selected" : ""}>Editor</option>
12711
+ <option value="admin" ${registration.defaultRole === "admin" ? "selected" : ""}>Admin</option>
12712
+ </select>
12713
+ <p class="text-xs text-gray-400 mt-1">Role assigned to new users upon registration</p>
12714
+ </div>
12715
+ </div>
12716
+ </div>
12717
+
12718
+ <!-- Validation Settings Section -->
12719
+ <div class="backdrop-blur-md bg-black/20 rounded-xl border border-white/10 shadow-xl p-6">
12720
+ <h3 class="text-lg font-semibold text-white mb-4">Validation Settings</h3>
12721
+ <p class="text-sm text-gray-400 mb-6">Additional validation rules.</p>
12722
+
12723
+ <div class="space-y-4">
12724
+ <div class="flex items-center justify-between">
12725
+ <div>
12726
+ <label class="text-sm font-medium text-gray-300">Enforce Email Format</label>
12727
+ <p class="text-xs text-gray-400">Validate that email addresses are in correct format</p>
12728
+ </div>
12729
+ <label class="relative inline-flex items-center cursor-pointer">
12730
+ <input
12731
+ type="checkbox"
12732
+ name="validation_emailFormat"
12733
+ ${validation.emailFormat ? "checked" : ""}
12734
+ class="sr-only peer"
12735
+ >
12736
+ <div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
12737
+ </label>
12738
+ </div>
12739
+
12740
+ <div class="flex items-center justify-between">
12741
+ <div>
12742
+ <label class="text-sm font-medium text-gray-300">Prevent Duplicate Usernames</label>
12743
+ <p class="text-xs text-gray-400">Ensure usernames are unique across all users</p>
12744
+ </div>
12745
+ <label class="relative inline-flex items-center cursor-pointer">
12746
+ <input
12747
+ type="checkbox"
12748
+ name="validation_allowDuplicateUsernames"
12749
+ ${!validation.allowDuplicateUsernames ? "checked" : ""}
12750
+ class="sr-only peer"
12751
+ >
12752
+ <div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
12753
+ </label>
12754
+ </div>
12755
+ </div>
12756
+ </div>
12757
+ </div>
12758
+ `;
12759
+ }
12760
+
12761
+ // src/templates/pages/admin-plugin-settings.template.ts
12762
+ function renderPluginSettingsPage(data) {
12763
+ const { plugin, activity = [], user } = data;
12764
+ const pageContent = `
12765
+ <div class="w-full px-4 sm:px-6 lg:px-8 py-6">
12766
+ <!-- Header with breadcrumb -->
12767
+ <div class="flex items-center mb-6">
12768
+ <nav class="flex" aria-label="Breadcrumb">
12769
+ <ol class="flex items-center space-x-2">
12770
+ <li>
12771
+ <a href="/admin/plugins" class="text-gray-400 hover:text-white transition-colors">
12772
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
12773
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
12774
+ </svg>
12775
+ Plugins
12776
+ </a>
12777
+ </li>
12778
+ <li>
12779
+ <svg class="w-5 h-5 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
12780
+ <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
12781
+ </svg>
12782
+ </li>
12783
+ <li>
12784
+ <span class="text-gray-300">${plugin.displayName}</span>
12785
+ </li>
12786
+ </ol>
12787
+ </nav>
12788
+ </div>
12789
+
12790
+ <!-- Plugin Header -->
12791
+ <div class="backdrop-blur-md bg-black/20 rounded-xl border border-white/10 shadow-xl p-6 mb-6">
12792
+ <div class="flex items-start justify-between">
12793
+ <div class="flex items-center gap-4">
12794
+ <div class="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center text-white text-2xl font-bold">
12795
+ ${plugin.icon || plugin.displayName.charAt(0).toUpperCase()}
12796
+ </div>
12797
+ <div>
12798
+ <h1 class="text-2xl font-semibold text-white mb-1">${plugin.displayName}</h1>
12799
+ <p class="text-gray-300 mb-2">${plugin.description}</p>
12800
+ <div class="flex items-center gap-4 text-sm text-gray-400">
12801
+ <span>v${plugin.version}</span>
12802
+ <span>by ${plugin.author}</span>
12803
+ <span>${plugin.category}</span>
12804
+ ${plugin.downloadCount ? `<span>${plugin.downloadCount.toLocaleString()} downloads</span>` : ""}
12805
+ ${plugin.rating ? `<span>\u2605 ${plugin.rating}</span>` : ""}
12806
+ </div>
12807
+ </div>
12808
+ </div>
12809
+
12810
+ <div class="flex items-center gap-3">
12811
+ ${renderStatusBadge(plugin.status)}
12812
+ ${renderToggleButton(plugin)}
12813
+ </div>
12814
+ </div>
12815
+ </div>
12816
+
12817
+ <!-- Tabs -->
12818
+ <div class="mb-6">
12819
+ <nav class="flex space-x-8" aria-label="Tabs">
12820
+ <button onclick="showTab('settings')" id="settings-tab" class="tab-button active border-b-2 border-blue-400 py-2 px-1 text-sm font-medium text-blue-400">
12821
+ Settings
12822
+ </button>
12823
+ <button onclick="showTab('activity')" id="activity-tab" class="tab-button border-b-2 border-transparent py-2 px-1 text-sm font-medium text-gray-400 hover:text-gray-300">
12824
+ Activity Log
12825
+ </button>
12826
+ <button onclick="showTab('info')" id="info-tab" class="tab-button border-b-2 border-transparent py-2 px-1 text-sm font-medium text-gray-400 hover:text-gray-300">
12827
+ Information
12828
+ </button>
12829
+ </nav>
12830
+ </div>
12831
+
12832
+ <!-- Tab Content -->
12833
+ <div id="tab-content">
12834
+ <!-- Settings Tab -->
12835
+ <div id="settings-content" class="tab-content">
12836
+ ${renderSettingsTab(plugin)}
12837
+ </div>
12838
+
12839
+ <!-- Activity Tab -->
12840
+ <div id="activity-content" class="tab-content hidden">
12841
+ ${renderActivityTab(activity)}
12842
+ </div>
12843
+
12844
+ <!-- Information Tab -->
12845
+ <div id="info-content" class="tab-content hidden">
12846
+ ${renderInformationTab(plugin)}
12847
+ </div>
12848
+ </div>
12849
+ </div>
12850
+
12851
+ <script>
12852
+ function showTab(tabName) {
12853
+ // Hide all tab contents
12854
+ document.querySelectorAll('.tab-content').forEach(content => {
12855
+ content.classList.add('hidden');
12856
+ });
12857
+
12858
+ // Remove active class from all tabs
12859
+ document.querySelectorAll('.tab-button').forEach(tab => {
12860
+ tab.classList.remove('active', 'border-blue-400', 'text-blue-400');
12861
+ tab.classList.add('border-transparent', 'text-gray-400');
12862
+ });
12863
+
12864
+ // Show selected tab content
12865
+ document.getElementById(tabName + '-content').classList.remove('hidden');
12866
+
12867
+ // Add active class to selected tab
12868
+ const activeTab = document.getElementById(tabName + '-tab');
12869
+ activeTab.classList.add('active', 'border-blue-400', 'text-blue-400');
12870
+ activeTab.classList.remove('border-transparent', 'text-gray-400');
12871
+ }
12872
+
12873
+ async function togglePlugin(pluginId, action) {
12874
+ const button = event.target;
12875
+ const originalText = button.textContent;
12876
+ button.disabled = true;
12877
+ button.textContent = action === 'activate' ? 'Activating...' : 'Deactivating...';
12878
+
12879
+ try {
12880
+ const response = await fetch(\`/admin/plugins/\${pluginId}/\${action}\`, {
12881
+ method: 'POST',
12882
+ headers: {
12883
+ 'Content-Type': 'application/json'
12884
+ }
12885
+ });
12886
+
12887
+ const result = await response.json();
12888
+
12889
+ if (result.success) {
12890
+ showNotification(\`Plugin \${action}d successfully\`, 'success');
12891
+ setTimeout(() => location.reload(), 1000);
12892
+ } else {
12893
+ throw new Error(result.error || \`Failed to \${action} plugin\`);
12894
+ }
12895
+ } catch (error) {
12896
+ showNotification(error.message, 'error');
12897
+ button.textContent = originalText;
12898
+ button.disabled = false;
12899
+ }
12900
+ }
12901
+
12902
+ async function saveSettings() {
12903
+ const form = document.getElementById('settings-form');
12904
+ const formData = new FormData(form);
12905
+ const isAuthPlugin = '${plugin.id}' === 'core-auth';
12906
+ let settings = {};
12907
+
12908
+ if (isAuthPlugin) {
12909
+ // Handle nested auth settings structure
12910
+ settings = {
12911
+ requiredFields: {},
12912
+ validation: {
12913
+ passwordRequirements: {}
12914
+ },
12915
+ registration: {}
12916
+ };
12917
+
12918
+ for (let [key, value] of formData.entries()) {
12919
+ const input = form.querySelector(\`[name="\${key}"]\`);
12920
+ const fieldValue = input.type === 'checkbox' ? input.checked :
12921
+ input.type === 'number' ? parseInt(value) || 0 : value;
12922
+
12923
+ // Parse nested field names like "requiredFields_email_required"
12924
+ if (key.startsWith('requiredFields_')) {
12925
+ const parts = key.replace('requiredFields_', '').split('_');
12926
+ const fieldName = parts[0];
12927
+ const propName = parts[1];
12928
+
12929
+ if (!settings.requiredFields[fieldName]) {
12930
+ settings.requiredFields[fieldName] = { type: 'text', label: '' };
12931
+ }
12932
+ settings.requiredFields[fieldName][propName] = fieldValue;
12933
+ } else if (key.startsWith('validation_passwordRequirements_')) {
12934
+ const propName = key.replace('validation_passwordRequirements_', '');
12935
+ settings.validation.passwordRequirements[propName] = fieldValue;
12936
+ } else if (key.startsWith('validation_')) {
12937
+ const propName = key.replace('validation_', '');
12938
+ // Invert the allowDuplicateUsernames logic
12939
+ if (propName === 'allowDuplicateUsernames') {
12940
+ settings.validation[propName] = !fieldValue;
12941
+ } else {
12942
+ settings.validation[propName] = fieldValue;
12943
+ }
12944
+ } else if (key.startsWith('registration_')) {
12945
+ const propName = key.replace('registration_', '');
12946
+ settings.registration[propName] = fieldValue;
12947
+ }
12948
+ }
12949
+ } else {
12950
+ // Handle regular plugin settings
12951
+ for (let [key, value] of formData.entries()) {
12952
+ if (key.startsWith('setting_')) {
12953
+ const settingKey = key.replace('setting_', '');
12954
+
12955
+ const input = form.querySelector(\`[name="\${key}"]\`);
12956
+ if (input.type === 'checkbox') {
12957
+ settings[settingKey] = input.checked;
12958
+ } else if (input.type === 'number') {
12959
+ settings[settingKey] = parseInt(value) || 0;
12960
+ } else {
12961
+ settings[settingKey] = value;
12962
+ }
12963
+ }
12964
+ }
12965
+ }
12966
+
12967
+ const saveButton = document.getElementById('save-button');
12968
+ saveButton.disabled = true;
12969
+ saveButton.textContent = 'Saving...';
12970
+
12971
+ try {
12972
+ const response = await fetch(\`/admin/plugins/${plugin.id}/settings\`, {
12973
+ method: 'POST',
12974
+ headers: {
12975
+ 'Content-Type': 'application/json'
12976
+ },
12977
+ body: JSON.stringify(settings)
12978
+ });
12979
+
12980
+ const result = await response.json();
12981
+
12982
+ if (result.success) {
12983
+ showNotification('Settings saved successfully', 'success');
12984
+ // Reload page after 1 second to show updated settings
12985
+ setTimeout(() => location.reload(), 1000);
12986
+ } else {
12987
+ throw new Error(result.error || 'Failed to save settings');
12988
+ }
12989
+ } catch (error) {
12990
+ showNotification(error.message, 'error');
12991
+ } finally {
12992
+ saveButton.disabled = false;
12993
+ saveButton.textContent = 'Save Settings';
12994
+ }
12995
+ }
12996
+
12997
+ function showNotification(message, type) {
12998
+ const notification = document.createElement('div');
12999
+ const bgColor = type === 'success' ? 'bg-green-600' : type === 'error' ? 'bg-red-600' : 'bg-blue-600';
13000
+ notification.className = \`fixed top-4 right-4 px-4 py-2 rounded-lg text-white z-50 \${bgColor}\`;
13001
+ notification.textContent = message;
13002
+ document.body.appendChild(notification);
13003
+
13004
+ setTimeout(() => {
13005
+ notification.remove();
13006
+ }, 3000);
13007
+ }
13008
+ </script>
13009
+ `;
13010
+ const layoutData = {
13011
+ title: `${plugin.displayName} Settings`,
13012
+ pageTitle: `${plugin.displayName} Settings`,
13013
+ currentPath: `/admin/plugins/${plugin.id}`,
13014
+ user,
13015
+ content: pageContent
13016
+ };
13017
+ return renderAdminLayout(layoutData);
13018
+ }
13019
+ function renderStatusBadge(status) {
13020
+ const statusColors = {
13021
+ active: "bg-green-900/50 text-green-300 border-green-600/30",
13022
+ inactive: "bg-gray-800/50 text-gray-400 border-gray-600/30",
13023
+ error: "bg-red-900/50 text-red-300 border-red-600/30"
13024
+ };
13025
+ const statusIcons = {
13026
+ active: '<div class="w-2 h-2 bg-green-400 rounded-full mr-2"></div>',
13027
+ inactive: '<div class="w-2 h-2 bg-gray-500 rounded-full mr-2"></div>',
13028
+ error: '<div class="w-2 h-2 bg-red-400 rounded-full mr-2"></div>'
13029
+ };
13030
+ return `
13031
+ <span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${statusColors[status] || statusColors.inactive} border">
13032
+ ${statusIcons[status] || statusIcons.inactive}${status.charAt(0).toUpperCase() + status.slice(1)}
13033
+ </span>
13034
+ `;
13035
+ }
13036
+ function renderToggleButton(plugin) {
13037
+ if (plugin.isCore) {
13038
+ return '<span class="text-sm text-gray-400">Core Plugin</span>';
13039
+ }
13040
+ return plugin.status === "active" ? `<button onclick="togglePlugin('${plugin.id}', 'deactivate')" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors">Deactivate</button>` : `<button onclick="togglePlugin('${plugin.id}', 'activate')" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors">Activate</button>`;
13041
+ }
13042
+ function renderSettingsTab(plugin) {
13043
+ const settings = plugin.settings || {};
13044
+ const isSeedDataPlugin = plugin.id === "seed-data" || plugin.name === "seed-data";
13045
+ const isAuthPlugin = plugin.id === "core-auth" || plugin.name === "core-auth";
13046
+ return `
13047
+ ${isSeedDataPlugin ? `
13048
+ <div class="backdrop-blur-md bg-black/20 rounded-xl border border-white/10 shadow-xl p-6 mb-6">
13049
+ <div class="flex items-center justify-between">
13050
+ <div>
13051
+ <h2 class="text-xl font-semibold text-white mb-2">Seed Data Generator</h2>
13052
+ <p class="text-gray-400">Generate realistic example data for testing and development.</p>
13053
+ </div>
13054
+ <a
13055
+ href="/admin/seed-data"
13056
+ target="_blank"
13057
+ class="inline-flex items-center gap-2 bg-green-600 hover:bg-green-700 text-white px-6 py-3 rounded-lg font-medium transition-colors"
13058
+ >
13059
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
13060
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
13061
+ </svg>
13062
+ Open Seed Data Tool
13063
+ </a>
13064
+ </div>
13065
+ </div>
13066
+ ` : ""}
13067
+
13068
+ <div class="backdrop-blur-md bg-black/20 rounded-xl border border-white/10 shadow-xl p-6">
13069
+ ${isAuthPlugin ? `
13070
+ <h2 class="text-xl font-semibold text-white mb-4">Authentication Settings</h2>
13071
+ <p class="text-gray-400 mb-6">Configure user registration fields and validation rules.</p>
13072
+ ` : `
13073
+ <h2 class="text-xl font-semibold text-white mb-4">Plugin Settings</h2>
13074
+ `}
13075
+
13076
+ <form id="settings-form" class="space-y-6">
13077
+ ${isAuthPlugin && Object.keys(settings).length > 0 ? renderAuthSettingsForm(settings) : Object.keys(settings).length > 0 ? renderSettingsFields(settings) : renderNoSettings(plugin)}
13078
+
13079
+ ${Object.keys(settings).length > 0 ? `
13080
+ <div class="flex items-center justify-end pt-6 border-t border-white/10">
13081
+ <button
13082
+ type="button"
13083
+ id="save-button"
13084
+ onclick="saveSettings()"
13085
+ class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg font-medium transition-colors"
13086
+ >
13087
+ Save Settings
13088
+ </button>
13089
+ </div>
13090
+ ` : ""}
13091
+ </form>
13092
+ </div>
13093
+ `;
13094
+ }
13095
+ function renderSettingsFields(settings) {
13096
+ return Object.entries(settings).map(([key, value]) => {
13097
+ const fieldId = `setting_${key}`;
13098
+ const displayName = key.replace(/([A-Z])/g, " $1").replace(/^./, (str) => str.toUpperCase());
13099
+ if (typeof value === "boolean") {
13100
+ return `
13101
+ <div class="flex items-center justify-between">
13102
+ <div>
13103
+ <label for="${fieldId}" class="text-sm font-medium text-gray-300">${displayName}</label>
13104
+ <p class="text-xs text-gray-400">Enable or disable this feature</p>
13105
+ </div>
13106
+ <label class="relative inline-flex items-center cursor-pointer">
13107
+ <input type="checkbox" name="${fieldId}" id="${fieldId}" ${value ? "checked" : ""} class="sr-only peer">
13108
+ <div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
13109
+ </label>
13110
+ </div>
13111
+ `;
13112
+ } else if (typeof value === "number") {
13113
+ return `
13114
+ <div>
13115
+ <label for="${fieldId}" class="block text-sm font-medium text-gray-300 mb-2">${displayName}</label>
13116
+ <input
13117
+ type="number"
13118
+ name="${fieldId}"
13119
+ id="${fieldId}"
13120
+ value="${value}"
13121
+ class="backdrop-blur-sm bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-white placeholder-gray-300 focus:border-blue-400 focus:outline-none transition-colors w-full"
13122
+ >
13123
+ </div>
13124
+ `;
13125
+ } else {
13126
+ return `
13127
+ <div>
13128
+ <label for="${fieldId}" class="block text-sm font-medium text-gray-300 mb-2">${displayName}</label>
13129
+ <input
13130
+ type="text"
13131
+ name="${fieldId}"
13132
+ id="${fieldId}"
13133
+ value="${value}"
13134
+ class="backdrop-blur-sm bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-white placeholder-gray-300 focus:border-blue-400 focus:outline-none transition-colors w-full"
13135
+ >
13136
+ </div>
13137
+ `;
13138
+ }
13139
+ }).join("");
13140
+ }
13141
+ function renderNoSettings(plugin) {
13142
+ if (plugin.id === "seed-data" || plugin.name === "seed-data") {
13143
+ return `
13144
+ <div class="text-center py-8">
13145
+ <svg class="w-16 h-16 text-green-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
13146
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
13147
+ </svg>
13148
+ <h3 class="text-lg font-medium text-gray-300 mb-2">Seed Data Generator</h3>
13149
+ <p class="text-gray-400 mb-6">Generate realistic example data for testing and development.</p>
13150
+ <a
13151
+ href="/admin/seed-data"
13152
+ class="inline-flex items-center gap-2 bg-green-600 hover:bg-green-700 text-white px-6 py-3 rounded-lg font-medium transition-colors"
13153
+ >
13154
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
13155
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
13156
+ </svg>
13157
+ Generate Seed Data
13158
+ </a>
13159
+ </div>
13160
+ `;
13161
+ }
13162
+ return `
13163
+ <div class="text-center py-8">
13164
+ <svg class="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
13165
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
13166
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
13167
+ </svg>
13168
+ <h3 class="text-lg font-medium text-gray-300 mb-2">No Settings Available</h3>
13169
+ <p class="text-gray-400">This plugin doesn't have any configurable settings.</p>
13170
+ </div>
13171
+ `;
13172
+ }
13173
+ function renderActivityTab(activity) {
13174
+ return `
13175
+ <div class="backdrop-blur-md bg-black/20 rounded-xl border border-white/10 shadow-xl p-6">
13176
+ <h2 class="text-xl font-semibold text-white mb-4">Activity Log</h2>
13177
+
13178
+ ${activity.length > 0 ? `
13179
+ <div class="space-y-4">
13180
+ ${activity.map((item) => `
13181
+ <div class="flex items-start gap-3 p-3 rounded-lg bg-white/5">
13182
+ <div class="w-2 h-2 bg-blue-400 rounded-full mt-2"></div>
13183
+ <div class="flex-1">
13184
+ <div class="flex items-center justify-between">
13185
+ <span class="text-sm font-medium text-white">${item.action}</span>
13186
+ <span class="text-xs text-gray-400">${formatTimestamp(item.timestamp)}</span>
13187
+ </div>
13188
+ <p class="text-sm text-gray-300 mt-1">${item.message}</p>
13189
+ ${item.user ? `<p class="text-xs text-gray-400 mt-1">by ${item.user}</p>` : ""}
13190
+ </div>
13191
+ </div>
13192
+ `).join("")}
13193
+ </div>
13194
+ ` : `
13195
+ <div class="text-center py-8">
13196
+ <svg class="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
13197
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
13198
+ </svg>
13199
+ <h3 class="text-lg font-medium text-gray-300 mb-2">No Activity</h3>
13200
+ <p class="text-gray-400">No recent activity for this plugin.</p>
13201
+ </div>
13202
+ `}
13203
+ </div>
13204
+ `;
13205
+ }
13206
+ function renderInformationTab(plugin) {
13207
+ return `
13208
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
13209
+ <!-- Plugin Details -->
13210
+ <div class="backdrop-blur-md bg-black/20 rounded-xl border border-white/10 shadow-xl p-6">
13211
+ <h2 class="text-xl font-semibold text-white mb-4">Plugin Details</h2>
13212
+ <div class="space-y-3">
13213
+ <div class="flex justify-between">
13214
+ <span class="text-gray-400">Name:</span>
13215
+ <span class="text-white">${plugin.displayName}</span>
13216
+ </div>
13217
+ <div class="flex justify-between">
13218
+ <span class="text-gray-400">Version:</span>
13219
+ <span class="text-white">${plugin.version}</span>
13220
+ </div>
13221
+ <div class="flex justify-between">
13222
+ <span class="text-gray-400">Author:</span>
13223
+ <span class="text-white">${plugin.author}</span>
13224
+ </div>
13225
+ <div class="flex justify-between">
13226
+ <span class="text-gray-400">Category:</span>
13227
+ <span class="text-white">${plugin.category}</span>
13228
+ </div>
13229
+ <div class="flex justify-between">
13230
+ <span class="text-gray-400">Status:</span>
13231
+ <span class="text-white">${plugin.status}</span>
13232
+ </div>
13233
+ <div class="flex justify-between">
13234
+ <span class="text-gray-400">Last Updated:</span>
13235
+ <span class="text-white">${plugin.lastUpdated}</span>
13236
+ </div>
13237
+ </div>
13238
+ </div>
13239
+
13240
+ <!-- Dependencies & Permissions -->
13241
+ <div class="backdrop-blur-md bg-black/20 rounded-xl border border-white/10 shadow-xl p-6">
13242
+ <h2 class="text-xl font-semibold text-white mb-4">Dependencies & Permissions</h2>
13243
+
13244
+ ${plugin.dependencies && plugin.dependencies.length > 0 ? `
13245
+ <div class="mb-6">
13246
+ <h3 class="text-sm font-medium text-gray-300 mb-2">Dependencies:</h3>
13247
+ <div class="space-y-1">
13248
+ ${plugin.dependencies.map((dep) => `
13249
+ <div class="inline-block bg-white/10 text-gray-300 text-sm px-2 py-1 rounded mr-2">${dep}</div>
13250
+ `).join("")}
13251
+ </div>
13252
+ </div>
13253
+ ` : ""}
13254
+
13255
+ ${plugin.permissions && plugin.permissions.length > 0 ? `
13256
+ <div>
13257
+ <h3 class="text-sm font-medium text-gray-300 mb-2">Permissions:</h3>
13258
+ <div class="space-y-1">
13259
+ ${plugin.permissions.map((perm) => `
13260
+ <div class="inline-block bg-white/10 text-gray-300 text-sm px-2 py-1 rounded mr-2">${perm}</div>
13261
+ `).join("")}
13262
+ </div>
13263
+ </div>
13264
+ ` : ""}
13265
+
13266
+ ${(!plugin.dependencies || plugin.dependencies.length === 0) && (!plugin.permissions || plugin.permissions.length === 0) ? `
13267
+ <p class="text-gray-400">No dependencies or special permissions required.</p>
13268
+ ` : ""}
13269
+ </div>
13270
+ </div>
13271
+ `;
13272
+ }
13273
+ function formatTimestamp(timestamp) {
13274
+ const date = new Date(timestamp * 1e3);
13275
+ return date.toLocaleString();
13276
+ }
13277
+
13278
+ // src/routes/admin-plugins.ts
13279
+ var adminPluginRoutes = new hono.Hono();
13280
+ adminPluginRoutes.get("/", async (c) => {
13281
+ try {
13282
+ const user = c.get("user");
13283
+ const db = c.env.DB;
13284
+ if (user?.role !== "admin") {
13285
+ return c.text("Access denied", 403);
13286
+ }
13287
+ const pluginService = new chunkRNR4HA23_cjs.PluginService(db);
13288
+ let plugins = [];
13289
+ let stats = { total: 0, active: 0, inactive: 0, errors: 0 };
13290
+ try {
13291
+ plugins = await pluginService.getAllPlugins();
13292
+ stats = await pluginService.getPluginStats();
13293
+ } catch (error) {
13294
+ console.error("Error loading plugins:", error);
13295
+ }
13296
+ const templatePlugins = plugins.map((p) => ({
13297
+ id: p.id,
13298
+ name: p.name,
13299
+ displayName: p.display_name,
13300
+ description: p.description,
13301
+ version: p.version,
13302
+ author: p.author,
13303
+ status: p.status,
13304
+ category: p.category,
13305
+ icon: p.icon,
13306
+ downloadCount: p.download_count,
13307
+ rating: p.rating,
13308
+ lastUpdated: formatLastUpdated(p.last_updated),
13309
+ dependencies: p.dependencies,
13310
+ permissions: p.permissions,
13311
+ isCore: p.is_core
13312
+ }));
13313
+ const pageData = {
13314
+ plugins: templatePlugins,
13315
+ stats,
13316
+ user: {
13317
+ name: user?.email || "User",
13318
+ email: user?.email || "",
13319
+ role: user?.role || "user"
13320
+ },
13321
+ version: c.get("appVersion")
13322
+ };
13323
+ return c.html(renderPluginsListPage(pageData));
13324
+ } catch (error) {
13325
+ console.error("Error loading plugins page:", error);
13326
+ return c.text("Internal server error", 500);
13327
+ }
13328
+ });
13329
+ adminPluginRoutes.get("/:id", async (c) => {
13330
+ try {
13331
+ const user = c.get("user");
13332
+ const db = c.env.DB;
13333
+ const pluginId = c.req.param("id");
13334
+ if (user?.role !== "admin") {
13335
+ return c.redirect("/admin/plugins");
13336
+ }
13337
+ const pluginService = new chunkRNR4HA23_cjs.PluginService(db);
13338
+ const plugin = await pluginService.getPlugin(pluginId);
13339
+ if (!plugin) {
13340
+ return c.text("Plugin not found", 404);
13341
+ }
13342
+ const activity = await pluginService.getPluginActivity(pluginId, 20);
13343
+ const templatePlugin = {
13344
+ id: plugin.id,
13345
+ name: plugin.name,
13346
+ displayName: plugin.display_name,
13347
+ description: plugin.description,
13348
+ version: plugin.version,
13349
+ author: plugin.author,
13350
+ status: plugin.status,
13351
+ category: plugin.category,
13352
+ icon: plugin.icon,
13353
+ downloadCount: plugin.download_count,
13354
+ rating: plugin.rating,
13355
+ lastUpdated: formatLastUpdated(plugin.last_updated),
13356
+ dependencies: plugin.dependencies,
13357
+ permissions: plugin.permissions,
13358
+ isCore: plugin.is_core,
13359
+ settings: plugin.settings
13360
+ };
13361
+ const templateActivity = (activity || []).map((item) => ({
13362
+ id: item.id,
13363
+ action: item.action,
13364
+ message: item.message,
13365
+ timestamp: item.timestamp,
13366
+ user: item.user_email
13367
+ }));
13368
+ const pageData = {
13369
+ plugin: templatePlugin,
13370
+ activity: templateActivity,
13371
+ user: {
13372
+ name: user?.email || "User",
13373
+ email: user?.email || "",
13374
+ role: user?.role || "user"
13375
+ }
13376
+ };
13377
+ return c.html(renderPluginSettingsPage(pageData));
13378
+ } catch (error) {
13379
+ console.error("Error getting plugin settings page:", error);
13380
+ return c.text("Internal server error", 500);
13381
+ }
13382
+ });
13383
+ adminPluginRoutes.post("/:id/activate", async (c) => {
13384
+ try {
13385
+ const user = c.get("user");
13386
+ const db = c.env.DB;
13387
+ const pluginId = c.req.param("id");
13388
+ if (user?.role !== "admin") {
13389
+ return c.json({ error: "Access denied" }, 403);
13390
+ }
13391
+ const pluginService = new chunkRNR4HA23_cjs.PluginService(db);
13392
+ await pluginService.activatePlugin(pluginId);
13393
+ return c.json({ success: true });
13394
+ } catch (error) {
13395
+ console.error("Error activating plugin:", error);
13396
+ const message = error instanceof Error ? error.message : "Failed to activate plugin";
13397
+ return c.json({ error: message }, 400);
13398
+ }
13399
+ });
13400
+ adminPluginRoutes.post("/:id/deactivate", async (c) => {
13401
+ try {
13402
+ const user = c.get("user");
13403
+ const db = c.env.DB;
13404
+ const pluginId = c.req.param("id");
13405
+ if (user?.role !== "admin") {
13406
+ return c.json({ error: "Access denied" }, 403);
13407
+ }
13408
+ const pluginService = new chunkRNR4HA23_cjs.PluginService(db);
13409
+ await pluginService.deactivatePlugin(pluginId);
13410
+ return c.json({ success: true });
13411
+ } catch (error) {
13412
+ console.error("Error deactivating plugin:", error);
13413
+ const message = error instanceof Error ? error.message : "Failed to deactivate plugin";
13414
+ return c.json({ error: message }, 400);
13415
+ }
13416
+ });
13417
+ adminPluginRoutes.post("/install", async (c) => {
13418
+ try {
13419
+ const user = c.get("user");
13420
+ const db = c.env.DB;
13421
+ if (user?.role !== "admin") {
13422
+ return c.json({ error: "Access denied" }, 403);
13423
+ }
13424
+ const body = await c.req.json();
13425
+ const pluginService = new chunkRNR4HA23_cjs.PluginService(db);
13426
+ if (body.name === "faq-plugin") {
13427
+ const faqPlugin = await pluginService.installPlugin({
13428
+ id: "third-party-faq",
13429
+ name: "faq-plugin",
13430
+ display_name: "FAQ System",
13431
+ description: "Frequently Asked Questions management system with categories, search, and custom styling",
13432
+ version: "2.0.0",
13433
+ author: "Community Developer",
13434
+ category: "content",
13435
+ icon: "\u2753",
13436
+ permissions: ["manage:faqs"],
13437
+ dependencies: [],
13438
+ settings: {
13439
+ enableSearch: true,
13440
+ enableCategories: true,
13441
+ questionsPerPage: 10
13442
+ }
13443
+ });
13444
+ return c.json({ success: true, plugin: faqPlugin });
13445
+ }
13446
+ if (body.name === "demo-login-plugin") {
13447
+ const demoPlugin = await pluginService.installPlugin({
13448
+ id: "demo-login-prefill",
13449
+ name: "demo-login-plugin",
13450
+ display_name: "Demo Login Prefill",
13451
+ description: "Prefills login form with demo credentials (admin@sonicjs.com/admin123) for easy site demonstration",
13452
+ version: "1.0.0-beta.1",
13453
+ author: "SonicJS",
13454
+ category: "demo",
13455
+ icon: "\u{1F3AF}",
13456
+ permissions: [],
13457
+ dependencies: [],
13458
+ settings: {
13459
+ enableNotice: true,
13460
+ demoEmail: "admin@sonicjs.com",
13461
+ demoPassword: "admin123"
13462
+ }
13463
+ });
13464
+ return c.json({ success: true, plugin: demoPlugin });
13465
+ }
13466
+ if (body.name === "core-auth") {
13467
+ const authPlugin = await pluginService.installPlugin({
13468
+ id: "core-auth",
13469
+ name: "core-auth",
13470
+ display_name: "Authentication System",
13471
+ description: "Core authentication and user management system",
13472
+ version: "1.0.0-beta.1",
13473
+ author: "SonicJS Team",
13474
+ category: "security",
13475
+ icon: "\u{1F510}",
13476
+ permissions: ["manage:users", "manage:roles", "manage:permissions"],
13477
+ dependencies: [],
13478
+ is_core: true,
13479
+ settings: {}
13480
+ });
13481
+ return c.json({ success: true, plugin: authPlugin });
13482
+ }
13483
+ if (body.name === "core-media") {
13484
+ const mediaPlugin = await pluginService.installPlugin({
13485
+ id: "core-media",
13486
+ name: "core-media",
13487
+ display_name: "Media Manager",
13488
+ description: "Core media upload and management system",
13489
+ version: "1.0.0-beta.1",
13490
+ author: "SonicJS Team",
13491
+ category: "media",
13492
+ icon: "\u{1F4F8}",
13493
+ permissions: ["manage:media", "upload:files"],
13494
+ dependencies: [],
13495
+ is_core: true,
13496
+ settings: {}
13497
+ });
13498
+ return c.json({ success: true, plugin: mediaPlugin });
13499
+ }
13500
+ if (body.name === "core-workflow") {
13501
+ const workflowPlugin = await pluginService.installPlugin({
13502
+ id: "core-workflow",
13503
+ name: "core-workflow",
13504
+ display_name: "Workflow Engine",
13505
+ description: "Content workflow and approval system",
13506
+ version: "1.0.0-beta.1",
13507
+ author: "SonicJS Team",
13508
+ category: "content",
13509
+ icon: "\u{1F504}",
13510
+ permissions: ["manage:workflows", "approve:content"],
13511
+ dependencies: [],
13512
+ is_core: true,
13513
+ settings: {}
13514
+ });
13515
+ return c.json({ success: true, plugin: workflowPlugin });
13516
+ }
13517
+ if (body.name === "database-tools") {
13518
+ const databaseToolsPlugin = await pluginService.installPlugin({
13519
+ id: "database-tools",
13520
+ name: "database-tools",
13521
+ display_name: "Database Tools",
13522
+ description: "Database management tools including truncate, backup, and validation",
13523
+ version: "1.0.0-beta.1",
13524
+ author: "SonicJS Team",
13525
+ category: "system",
13526
+ icon: "\u{1F5C4}\uFE0F",
13527
+ permissions: ["manage:database", "admin"],
13528
+ dependencies: [],
13529
+ is_core: false,
13530
+ settings: {
13531
+ enableTruncate: true,
13532
+ enableBackup: true,
13533
+ enableValidation: true,
13534
+ requireConfirmation: true
13535
+ }
13536
+ });
13537
+ return c.json({ success: true, plugin: databaseToolsPlugin });
13538
+ }
13539
+ if (body.name === "seed-data") {
13540
+ const seedDataPlugin = await pluginService.installPlugin({
13541
+ id: "seed-data",
13542
+ name: "seed-data",
13543
+ display_name: "Seed Data",
13544
+ description: "Generate realistic example users and content for testing and development",
13545
+ version: "1.0.0-beta.1",
13546
+ author: "SonicJS Team",
13547
+ category: "development",
13548
+ icon: "\u{1F331}",
13549
+ permissions: ["admin"],
13550
+ dependencies: [],
13551
+ is_core: false,
13552
+ settings: {
13553
+ userCount: 20,
13554
+ contentCount: 200,
13555
+ defaultPassword: "password123"
13556
+ }
13557
+ });
13558
+ return c.json({ success: true, plugin: seedDataPlugin });
13559
+ }
13560
+ return c.json({ error: "Plugin not found in registry" }, 404);
13561
+ } catch (error) {
13562
+ console.error("Error installing plugin:", error);
13563
+ const message = error instanceof Error ? error.message : "Failed to install plugin";
13564
+ return c.json({ error: message }, 400);
13565
+ }
13566
+ });
13567
+ adminPluginRoutes.post("/:id/uninstall", async (c) => {
13568
+ try {
13569
+ const user = c.get("user");
13570
+ const db = c.env.DB;
13571
+ const pluginId = c.req.param("id");
13572
+ if (user?.role !== "admin") {
13573
+ return c.json({ error: "Access denied" }, 403);
13574
+ }
13575
+ const pluginService = new chunkRNR4HA23_cjs.PluginService(db);
13576
+ await pluginService.uninstallPlugin(pluginId);
13577
+ return c.json({ success: true });
13578
+ } catch (error) {
13579
+ console.error("Error uninstalling plugin:", error);
13580
+ const message = error instanceof Error ? error.message : "Failed to uninstall plugin";
13581
+ return c.json({ error: message }, 400);
13582
+ }
13583
+ });
13584
+ adminPluginRoutes.post("/:id/settings", async (c) => {
13585
+ try {
13586
+ const user = c.get("user");
13587
+ const db = c.env.DB;
13588
+ const pluginId = c.req.param("id");
13589
+ if (user?.role !== "admin") {
13590
+ return c.json({ error: "Access denied" }, 403);
13591
+ }
13592
+ const settings = await c.req.json();
13593
+ const pluginService = new chunkRNR4HA23_cjs.PluginService(db);
13594
+ await pluginService.updatePluginSettings(pluginId, settings);
13595
+ return c.json({ success: true });
13596
+ } catch (error) {
13597
+ console.error("Error updating plugin settings:", error);
13598
+ const message = error instanceof Error ? error.message : "Failed to update settings";
13599
+ return c.json({ error: message }, 400);
13600
+ }
13601
+ });
13602
+ function formatLastUpdated(timestamp) {
13603
+ const now = Date.now() / 1e3;
13604
+ const diff = now - timestamp;
13605
+ if (diff < 60) return "just now";
13606
+ if (diff < 3600) return `${Math.floor(diff / 60)} minutes ago`;
13607
+ if (diff < 86400) return `${Math.floor(diff / 3600)} hours ago`;
13608
+ if (diff < 604800) return `${Math.floor(diff / 86400)} days ago`;
13609
+ if (diff < 2592e3) return `${Math.floor(diff / 604800)} weeks ago`;
13610
+ return `${Math.floor(diff / 2592e3)} months ago`;
13611
+ }
13612
+
9949
13613
  // src/routes/index.ts
9950
13614
  var ROUTES_INFO = {
9951
13615
  message: "Routes migration in progress",
@@ -9957,13 +13621,17 @@ var ROUTES_INFO = {
9957
13621
  "adminApiRoutes",
9958
13622
  "authRoutes",
9959
13623
  "adminContentRoutes",
9960
- "adminUsersRoutes"
13624
+ "adminUsersRoutes",
13625
+ "adminMediaRoutes",
13626
+ "adminPluginRoutes"
9961
13627
  ],
9962
13628
  status: "Routes are being added incrementally",
9963
13629
  reference: "https://github.com/sonicjs/sonicjs"
9964
13630
  };
9965
13631
 
9966
13632
  exports.ROUTES_INFO = ROUTES_INFO;
13633
+ exports.adminMediaRoutes = adminMediaRoutes;
13634
+ exports.adminPluginRoutes = adminPluginRoutes;
9967
13635
  exports.admin_api_default = admin_api_default;
9968
13636
  exports.admin_content_default = admin_content_default;
9969
13637
  exports.api_content_crud_default = api_content_crud_default;
@@ -9972,5 +13640,5 @@ exports.api_media_default = api_media_default;
9972
13640
  exports.api_system_default = api_system_default;
9973
13641
  exports.auth_default = auth_default;
9974
13642
  exports.userRoutes = userRoutes;
9975
- //# sourceMappingURL=chunk-Y6VF3YFA.cjs.map
9976
- //# sourceMappingURL=chunk-Y6VF3YFA.cjs.map
13643
+ //# sourceMappingURL=chunk-H6E2I5GW.cjs.map
13644
+ //# sourceMappingURL=chunk-H6E2I5GW.cjs.map