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

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.
@@ -9946,6 +9946,2049 @@ userRoutes.get("/activity-logs/export", chunkBUKT6HP5_cjs.requirePermission("act
9946
9946
  }
9947
9947
  });
9948
9948
 
9949
+ // src/templates/components/media-grid.template.ts
9950
+ function renderMediaGrid(data) {
9951
+ if (data.files.length === 0) {
9952
+ return `
9953
+ <div class="text-center py-12">
9954
+ <svg class="mx-auto h-12 w-12 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
9955
+ <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" />
9956
+ </svg>
9957
+ <h3 class="mt-2 text-sm font-medium text-zinc-950 dark:text-white">No media files</h3>
9958
+ <p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">${data.emptyMessage || "Get started by uploading your first file."}</p>
9959
+ </div>
9960
+ `;
9961
+ }
9962
+ const gridClass = data.viewMode === "list" ? "space-y-4" : "media-grid";
9963
+ return `
9964
+ <div class="${gridClass} ${data.className || ""}">
9965
+ ${data.files.map(
9966
+ (file) => renderMediaFileCard(file, data.viewMode, data.selectable)
9967
+ ).join("")}
9968
+ </div>
9969
+ `;
9970
+ }
9971
+ function renderMediaFileCard(file, viewMode = "grid", selectable = false) {
9972
+ if (viewMode === "list") {
9973
+ return `
9974
+ <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}">
9975
+ <div class="flex items-center p-4">
9976
+ ${selectable ? `
9977
+ <div class="flex h-6 shrink-0 items-center mr-4">
9978
+ <div class="group grid size-4 grid-cols-1">
9979
+ <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" />
9980
+ <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">
9981
+ <path d="M3 8L6 11L11 3.5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-0 group-has-[:checked]:opacity-100" />
9982
+ <path d="M3 7H11" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-0 group-has-[:indeterminate]:opacity-100" />
9983
+ </svg>
9984
+ </div>
9985
+ </div>
9986
+ ` : ""}
9987
+
9988
+ <div class="flex-shrink-0 mr-4">
9989
+ ${file.isImage ? `
9990
+ <img src="${file.thumbnail_url || file.public_url}" alt="${file.alt || file.original_name}"
9991
+ class="w-16 h-16 object-cover rounded-lg ring-1 ring-zinc-950/10 dark:ring-white/10">
9992
+ ` : `
9993
+ <div class="w-16 h-16 bg-zinc-100 dark:bg-zinc-800 rounded-lg flex items-center justify-center">
9994
+ ${getFileIcon(file.mime_type)}
9995
+ </div>
9996
+ `}
9997
+ </div>
9998
+
9999
+ <div class="flex-1 min-w-0">
10000
+ <div class="flex items-center justify-between">
10001
+ <h4 class="text-sm font-medium text-zinc-950 dark:text-white truncate" title="${file.original_name}">
10002
+ ${file.original_name}
10003
+ </h4>
10004
+ <div class="flex items-center space-x-2">
10005
+ <span class="text-sm text-zinc-500 dark:text-zinc-400">${file.fileSize}</span>
10006
+ <button
10007
+ class="text-zinc-500 dark:text-zinc-400 hover:text-zinc-950 dark:hover:text-white transition-colors"
10008
+ hx-get="/admin/media/${file.id}/details"
10009
+ hx-target="#file-modal-content"
10010
+ onclick="document.getElementById('file-modal').classList.remove('hidden')"
10011
+ >
10012
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10013
+ <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>
10014
+ </svg>
10015
+ </button>
10016
+ </div>
10017
+ </div>
10018
+ <div class="mt-1 flex items-center text-sm text-zinc-500 dark:text-zinc-400">
10019
+ <span>${file.uploadedAt}</span>
10020
+ ${file.tags.length > 0 ? `
10021
+ <span class="mx-2">\u2022</span>
10022
+ <div class="flex items-center space-x-1">
10023
+ ${file.tags.slice(0, 2).map(
10024
+ (tag) => `
10025
+ <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">
10026
+ ${tag}
10027
+ </span>
10028
+ `
10029
+ ).join("")}
10030
+ ${file.tags.length > 2 ? `<span class="text-xs text-zinc-500 dark:text-zinc-400">+${file.tags.length - 2}</span>` : ""}
10031
+ </div>
10032
+ ` : ""}
10033
+ </div>
10034
+ </div>
10035
+ </div>
10036
+ </div>
10037
+ `;
10038
+ }
10039
+ return `
10040
+ <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}">
10041
+ ${selectable ? `
10042
+ <div class="absolute top-2 left-2 z-10">
10043
+ <div class="flex h-6 shrink-0 items-center">
10044
+ <div class="group grid size-4 grid-cols-1">
10045
+ <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" />
10046
+ <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">
10047
+ <path d="M3 8L6 11L11 3.5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-0 group-has-[:checked]:opacity-100" />
10048
+ <path d="M3 7H11" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-0 group-has-[:indeterminate]:opacity-100" />
10049
+ </svg>
10050
+ </div>
10051
+ </div>
10052
+ </div>
10053
+ ` : ""}
10054
+
10055
+ <div class="aspect-square relative">
10056
+ ${file.isImage ? `
10057
+ <img src="${file.thumbnail_url || file.public_url}" alt="${file.alt || file.original_name}"
10058
+ class="w-full h-full object-cover">
10059
+ ` : `
10060
+ <div class="w-full h-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center">
10061
+ ${getFileIcon(file.mime_type)}
10062
+ </div>
10063
+ `}
10064
+
10065
+ <!-- Overlay actions -->
10066
+ <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">
10067
+ <div class="flex space-x-2">
10068
+ <button
10069
+ hx-get="/admin/media/${file.id}/details"
10070
+ hx-target="#file-modal-content"
10071
+ onclick="document.getElementById('file-modal').classList.remove('hidden')"
10072
+ class="p-2 bg-white/20 rounded-full hover:bg-white/30 transition-colors"
10073
+ >
10074
+ <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10075
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
10076
+ <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>
10077
+ </svg>
10078
+ </button>
10079
+ <button
10080
+ onclick="event.stopPropagation(); copyToClipboard('${file.public_url}')"
10081
+ class="p-2 bg-white/20 rounded-full hover:bg-white/30 transition-colors"
10082
+ >
10083
+ <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10084
+ <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>
10085
+ </svg>
10086
+ </button>
10087
+ </div>
10088
+ </div>
10089
+ </div>
10090
+
10091
+ <div class="p-3">
10092
+ <h4 class="text-sm font-medium text-zinc-950 dark:text-white truncate" title="${file.original_name}">
10093
+ ${file.original_name}
10094
+ </h4>
10095
+ <div class="flex justify-between items-center mt-1">
10096
+ <span class="text-xs text-zinc-500 dark:text-zinc-400">${file.fileSize}</span>
10097
+ <span class="text-xs text-zinc-500 dark:text-zinc-400">${file.uploadedAt}</span>
10098
+ </div>
10099
+ ${file.tags.length > 0 ? `
10100
+ <div class="flex flex-wrap gap-1 mt-2">
10101
+ ${file.tags.slice(0, 2).map(
10102
+ (tag) => `
10103
+ <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">
10104
+ ${tag}
10105
+ </span>
10106
+ `
10107
+ ).join("")}
10108
+ ${file.tags.length > 2 ? `<span class="text-xs text-zinc-500 dark:text-zinc-400">+${file.tags.length - 2}</span>` : ""}
10109
+ </div>
10110
+ ` : ""}
10111
+ </div>
10112
+ </div>
10113
+ `;
10114
+ }
10115
+ function getFileIcon(mimeType) {
10116
+ if (mimeType.startsWith("image/")) {
10117
+ return `
10118
+ <svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10119
+ <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" />
10120
+ </svg>
10121
+ `;
10122
+ } else if (mimeType.startsWith("video/")) {
10123
+ return `
10124
+ <svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10125
+ <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" />
10126
+ </svg>
10127
+ `;
10128
+ } else if (mimeType === "application/pdf") {
10129
+ return `
10130
+ <svg class="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10131
+ <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" />
10132
+ </svg>
10133
+ `;
10134
+ } else {
10135
+ return `
10136
+ <svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10137
+ <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" />
10138
+ </svg>
10139
+ `;
10140
+ }
10141
+ }
10142
+
10143
+ // src/templates/pages/admin-media-library.template.ts
10144
+ init_admin_layout_catalyst_template();
10145
+ function renderMediaLibraryPage(data) {
10146
+ const pageContent = `
10147
+ <div>
10148
+ <!-- Header -->
10149
+ <div class="sm:flex sm:items-center sm:justify-between mb-6">
10150
+ <div class="sm:flex-auto">
10151
+ <h1 class="text-2xl/8 font-semibold text-zinc-950 dark:text-white sm:text-xl/8">Media Library</h1>
10152
+ <p class="mt-2 text-sm/6 text-zinc-500 dark:text-zinc-400">Manage your media files and assets</p>
10153
+ </div>
10154
+ <div class="mt-4 sm:mt-0 sm:ml-16 flex gap-x-2">
10155
+ <button
10156
+ 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"
10157
+ onclick="document.getElementById('upload-modal').classList.remove('hidden')"
10158
+ >
10159
+ <svg class="-ml-0.5 mr-1.5 h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
10160
+ <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" />
10161
+ </svg>
10162
+ Upload Media
10163
+ </button>
10164
+ </div>
10165
+ </div>
10166
+
10167
+ <div class="flex gap-6">
10168
+ <!-- Sidebar -->
10169
+ <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">
10170
+ <div class="space-y-6">
10171
+ <!-- Upload Button -->
10172
+ <div>
10173
+ <button
10174
+ 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"
10175
+ onclick="document.getElementById('upload-modal').classList.remove('hidden')"
10176
+ >
10177
+ Upload Files
10178
+ </button>
10179
+ </div>
10180
+
10181
+ <!-- Folders -->
10182
+ <div>
10183
+ <h3 class="text-sm font-medium text-zinc-950 dark:text-white mb-3">Folders</h3>
10184
+ <ul class="space-y-1">
10185
+ <li>
10186
+ <a href="/admin/media?folder=all"
10187
+ 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"}">
10188
+ All Files (${data.totalFiles})
10189
+ </a>
10190
+ </li>
10191
+ ${data.folders.map(
10192
+ (folder) => `
10193
+ <li>
10194
+ <a href="/admin/media?folder=${folder.folder}"
10195
+ 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"}">
10196
+ ${folder.folder} (${folder.count})
10197
+ </a>
10198
+ </li>
10199
+ `
10200
+ ).join("")}
10201
+ </ul>
10202
+ </div>
10203
+
10204
+ <!-- File Types -->
10205
+ <div>
10206
+ <h3 class="text-sm font-medium text-zinc-950 dark:text-white mb-3">File Types</h3>
10207
+ <ul class="space-y-1">
10208
+ <li>
10209
+ <a href="/admin/media?type=all"
10210
+ 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"}">
10211
+ All Types
10212
+ </a>
10213
+ </li>
10214
+ ${data.types.map(
10215
+ (type) => `
10216
+ <li>
10217
+ <a href="/admin/media?type=${type.type}"
10218
+ 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"}">
10219
+ ${type.type.charAt(0).toUpperCase() + type.type.slice(1)} (${type.count})
10220
+ </a>
10221
+ </li>
10222
+ `
10223
+ ).join("")}
10224
+ </ul>
10225
+ </div>
10226
+
10227
+ <!-- Quick Actions -->
10228
+ <div>
10229
+ <h3 class="text-sm font-medium text-zinc-950 dark:text-white mb-3">Quick Actions</h3>
10230
+ <div class="space-y-2">
10231
+ <button
10232
+ onclick="openCreateFolderModal()"
10233
+ 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">
10234
+ Create Folder
10235
+ </button>
10236
+ <button
10237
+ 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"
10238
+ hx-delete="/media/cleanup"
10239
+ hx-confirm="Delete unused files?"
10240
+ >
10241
+ Cleanup Unused
10242
+ </button>
10243
+ </div>
10244
+ </div>
10245
+ </div>
10246
+ </div>
10247
+
10248
+ <!-- Main Content -->
10249
+ <div class="flex-1">
10250
+ <!-- Toolbar -->
10251
+ <div class="relative rounded-xl mb-6 z-10">
10252
+ <!-- Gradient Background -->
10253
+ <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>
10254
+
10255
+ <div class="relative bg-white dark:bg-zinc-900 shadow-sm ring-1 ring-zinc-950/5 dark:ring-white/10">
10256
+ <div class="px-6 py-5">
10257
+ <div class="flex items-center justify-between">
10258
+ <div class="flex items-center space-x-4">
10259
+ <div class="flex items-center space-x-2">
10260
+ <label class="text-sm/6 font-medium text-zinc-950 dark:text-white">View:</label>
10261
+ <div class="grid grid-cols-1">
10262
+ <select
10263
+ 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"
10264
+ onchange="window.location.href = updateUrlParam('view', this.value)"
10265
+ >
10266
+ <option value="grid" ${data.currentView === "grid" ? "selected" : ""}>Grid</option>
10267
+ <option value="list" ${data.currentView === "list" ? "selected" : ""}>List</option>
10268
+ </select>
10269
+ <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">
10270
+ <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" />
10271
+ </svg>
10272
+ </div>
10273
+ </div>
10274
+
10275
+ <div class="relative group">
10276
+ <input
10277
+ type="text"
10278
+ id="media-search-input"
10279
+ name="search"
10280
+ placeholder="Search files..."
10281
+ oninput="toggleMediaClearButton()"
10282
+ 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"
10283
+ hx-get="/admin/media/search"
10284
+ hx-trigger="keyup changed delay:300ms"
10285
+ hx-target="#media-grid"
10286
+ hx-include="[name='folder'], [name='type']"
10287
+ >
10288
+ <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">
10289
+ <svg class="h-3 w-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
10290
+ <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
10291
+ </svg>
10292
+ </div>
10293
+ <button
10294
+ type="button"
10295
+ id="clear-media-search"
10296
+ 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"
10297
+ onclick="clearMediaSearch()"
10298
+ title="Clear search"
10299
+ >
10300
+ <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">
10301
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
10302
+ </svg>
10303
+ </button>
10304
+ <input type="hidden" name="folder" value="${data.currentFolder}">
10305
+ <input type="hidden" name="type" value="${data.currentType}">
10306
+ </div>
10307
+ </div>
10308
+
10309
+ <div class="flex items-center gap-x-3">
10310
+ <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>
10311
+ <button
10312
+ id="select-all-btn"
10313
+ 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"
10314
+ onclick="toggleSelectAll()"
10315
+ >
10316
+ Select All
10317
+ </button>
10318
+ <div class="relative inline-block z-50" id="bulk-actions-dropdown">
10319
+ <button
10320
+ id="bulk-actions-btn"
10321
+ onclick="toggleBulkActionsDropdown()"
10322
+ 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"
10323
+ disabled
10324
+ >
10325
+ Bulk Actions
10326
+ <svg viewBox="0 0 20 20" fill="currentColor" class="size-4">
10327
+ <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" />
10328
+ </svg>
10329
+ </button>
10330
+
10331
+ <div
10332
+ id="bulk-actions-menu"
10333
+ 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"
10334
+ style="transition-behavior: allow-discrete;"
10335
+ >
10336
+ <div class="py-1">
10337
+ <button
10338
+ onclick="openMoveToFolderModal()"
10339
+ 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"
10340
+ >
10341
+ <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">
10342
+ <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" />
10343
+ </svg>
10344
+ Move to Folder
10345
+ </button>
10346
+ </div>
10347
+ <div class="py-1">
10348
+ <button
10349
+ onclick="confirmBulkDelete()"
10350
+ 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"
10351
+ >
10352
+ <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">
10353
+ <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" />
10354
+ </svg>
10355
+ Delete Selected Files
10356
+ </button>
10357
+ </div>
10358
+ </div>
10359
+ </div>
10360
+ </div>
10361
+ </div>
10362
+ </div>
10363
+ </div>
10364
+ </div>
10365
+
10366
+ <!-- Media Grid -->
10367
+ <div id="media-grid">
10368
+ ${renderMediaGrid({
10369
+ files: data.files,
10370
+ viewMode: data.currentView,
10371
+ selectable: true,
10372
+ emptyMessage: "No media files found. Upload your first file to get started."
10373
+ })}
10374
+ </div>
10375
+
10376
+ <!-- Pagination -->
10377
+ ${data.hasNextPage ? `
10378
+ <div class="mt-6 flex justify-center">
10379
+ <div class="flex space-x-2">
10380
+ ${data.currentPage > 1 ? `
10381
+ <a href="${buildPageUrl(
10382
+ data.currentPage - 1,
10383
+ data.currentFolder,
10384
+ data.currentType
10385
+ )}"
10386
+ 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">
10387
+ Previous
10388
+ </a>
10389
+ ` : ""}
10390
+ <span class="px-3 py-2 text-sm text-zinc-500 dark:text-zinc-400">Page ${data.currentPage}</span>
10391
+ <a href="${buildPageUrl(
10392
+ data.currentPage + 1,
10393
+ data.currentFolder,
10394
+ data.currentType
10395
+ )}"
10396
+ 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">
10397
+ Next
10398
+ </a>
10399
+ </div>
10400
+ </div>
10401
+ ` : ""}
10402
+ </div>
10403
+ </div>
10404
+ </div>
10405
+
10406
+ <!-- Upload Modal -->
10407
+ <div id="upload-modal" class="hidden fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
10408
+ <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">
10409
+ <div class="flex justify-between items-center mb-4">
10410
+ <h3 class="text-lg font-semibold text-zinc-950 dark:text-white">Upload Files</h3>
10411
+ <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">
10412
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10413
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
10414
+ </svg>
10415
+ </button>
10416
+ </div>
10417
+
10418
+ <!-- Upload Form -->
10419
+ <form
10420
+ id="upload-form"
10421
+ hx-post="/admin/media/upload"
10422
+ hx-encoding="multipart/form-data"
10423
+ hx-target="#upload-results"
10424
+ class="space-y-4"
10425
+ >
10426
+ <!-- Drag and Drop Zone -->
10427
+ <div
10428
+ id="upload-zone"
10429
+ 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"
10430
+ onclick="document.getElementById('file-input').click()"
10431
+ >
10432
+ <svg class="mx-auto h-12 w-12 text-zinc-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
10433
+ <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" />
10434
+ </svg>
10435
+ <div class="mt-4">
10436
+ <p class="text-lg text-zinc-950 dark:text-white">Drop files here or click to upload</p>
10437
+ <p class="text-sm text-zinc-500 dark:text-zinc-400">PNG, JPG, GIF, PDF up to 10MB</p>
10438
+ </div>
10439
+ </div>
10440
+
10441
+ <input
10442
+ type="file"
10443
+ id="file-input"
10444
+ name="files"
10445
+ multiple
10446
+ accept="image/*,application/pdf,text/plain"
10447
+ class="hidden"
10448
+ onchange="handleFileSelect(this.files)"
10449
+ >
10450
+
10451
+ <!-- Folder Selection -->
10452
+ <div>
10453
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-2">Upload to folder:</label>
10454
+ <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">
10455
+ <option value="uploads">uploads</option>
10456
+ <option value="images">images</option>
10457
+ <option value="documents">documents</option>
10458
+ </select>
10459
+ </div>
10460
+
10461
+ <!-- File List -->
10462
+ <div id="file-list" class="hidden">
10463
+ <h4 class="text-sm font-medium text-zinc-950 dark:text-white mb-2">Selected Files:</h4>
10464
+ <div id="selected-files" class="space-y-2 max-h-40 overflow-y-auto"></div>
10465
+ </div>
10466
+
10467
+ <!-- Upload Button -->
10468
+ <div class="flex justify-end space-x-2">
10469
+ <button
10470
+ type="button"
10471
+ onclick="document.getElementById('upload-modal').classList.add('hidden')"
10472
+ 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"
10473
+ >
10474
+ Cancel
10475
+ </button>
10476
+ <button
10477
+ type="submit"
10478
+ id="upload-btn"
10479
+ 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"
10480
+ disabled
10481
+ >
10482
+ Upload Files
10483
+ </button>
10484
+ </div>
10485
+ </form>
10486
+
10487
+ <!-- Upload Results -->
10488
+ <div id="upload-results" class="mt-4"></div>
10489
+ </div>
10490
+ </div>
10491
+
10492
+ <!-- File Details Modal -->
10493
+ <div id="file-modal" class="hidden fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
10494
+ <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">
10495
+ <!-- Content loaded via HTMX -->
10496
+ </div>
10497
+ </div>
10498
+
10499
+ <!-- Move to Folder Modal -->
10500
+ <div id="move-to-folder-modal" class="hidden fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
10501
+ <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">
10502
+ <div class="flex justify-between items-center mb-4">
10503
+ <h3 class="text-lg font-semibold text-zinc-950 dark:text-white">Move to Folder</h3>
10504
+ <button onclick="closeMoveToFolderModal()" class="text-zinc-500 dark:text-zinc-400 hover:text-zinc-950 dark:hover:text-white transition-colors">
10505
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10506
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
10507
+ </svg>
10508
+ </button>
10509
+ </div>
10510
+
10511
+ <p class="text-sm text-zinc-500 dark:text-zinc-400 mb-4">
10512
+ 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:
10513
+ </p>
10514
+
10515
+ <div class="space-y-2 mb-6">
10516
+ ${data.folders.length > 0 ? data.folders.map(
10517
+ (folder) => `
10518
+ <button
10519
+ onclick="performBulkMove('${folder.folder}')"
10520
+ 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"
10521
+ >
10522
+ <div class="flex items-center justify-between">
10523
+ <span class="font-medium">${folder.folder}</span>
10524
+ <span class="text-sm text-zinc-500 dark:text-zinc-400">${folder.count} files</span>
10525
+ </div>
10526
+ </button>
10527
+ `
10528
+ ).join("") : '<p class="text-sm text-zinc-500 dark:text-zinc-400 text-center py-4">No folders available</p>'}
10529
+ </div>
10530
+
10531
+ <div class="flex justify-end space-x-2">
10532
+ <button
10533
+ onclick="closeMoveToFolderModal()"
10534
+ 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"
10535
+ >
10536
+ Cancel
10537
+ </button>
10538
+ </div>
10539
+ </div>
10540
+ </div>
10541
+
10542
+ <!-- Create Folder Modal -->
10543
+ <div id="create-folder-modal" class="hidden fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
10544
+ <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">
10545
+ <div class="flex justify-between items-center mb-4">
10546
+ <h3 class="text-lg font-semibold text-zinc-950 dark:text-white">Create New Folder</h3>
10547
+ <button onclick="closeCreateFolderModal()" aria-label="Close" class="text-zinc-500 dark:text-zinc-400 hover:text-zinc-950 dark:hover:text-white transition-colors">
10548
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10549
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
10550
+ </svg>
10551
+ </button>
10552
+ </div>
10553
+
10554
+ <form onsubmit="createNewFolder(event)" class="space-y-4">
10555
+ <div>
10556
+ <label for="folder-name" class="block text-sm font-medium text-zinc-950 dark:text-white mb-2">
10557
+ Folder Name
10558
+ </label>
10559
+ <input
10560
+ type="text"
10561
+ id="folder-name"
10562
+ name="folderName"
10563
+ placeholder="e.g., images, documents"
10564
+ required
10565
+ pattern="[a-z0-9-_]+"
10566
+ title="Only lowercase letters, numbers, hyphens, and underscores allowed"
10567
+ 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"
10568
+ >
10569
+ <p class="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
10570
+ Use lowercase letters, numbers, hyphens, and underscores only
10571
+ </p>
10572
+ </div>
10573
+
10574
+ <div class="flex justify-end space-x-2">
10575
+ <button
10576
+ type="button"
10577
+ onclick="closeCreateFolderModal()"
10578
+ 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"
10579
+ >
10580
+ Cancel
10581
+ </button>
10582
+ <button
10583
+ type="submit"
10584
+ 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"
10585
+ >
10586
+ Create Folder
10587
+ </button>
10588
+ </div>
10589
+ </form>
10590
+ </div>
10591
+ </div>
10592
+
10593
+ <style>
10594
+ .media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
10595
+ .media-item { position: relative; border-radius: 8px; overflow: hidden; transition: transform 0.2s; }
10596
+ .media-item:hover { transform: scale(1.02); }
10597
+ .media-item.selected { ring: 2px solid rgba(255, 255, 255, 0.4); }
10598
+ .upload-zone { border: 2px dashed rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.1); min-height: 200px; }
10599
+ .upload-zone.dragover { border-color: rgba(255, 255, 255, 0.4); background: rgba(255, 255, 255, 0.2); }
10600
+ .file-icon { width: 48px; height: 48px; }
10601
+ .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; }
10602
+ .media-item:hover .preview-overlay { opacity: 1; }
10603
+ </style>
10604
+
10605
+ <script>
10606
+ let selectedFiles = new Set();
10607
+ let dragDropFiles = [];
10608
+
10609
+ // File selection handling
10610
+ function toggleFileSelection(fileId) {
10611
+ if (selectedFiles.has(fileId)) {
10612
+ selectedFiles.delete(fileId);
10613
+ document.querySelector(\`[data-file-id="\${fileId}"]\`).classList.remove('selected');
10614
+ } else {
10615
+ selectedFiles.add(fileId);
10616
+ document.querySelector(\`[data-file-id="\${fileId}"]\`).classList.add('selected');
10617
+ }
10618
+ updateBulkActionsButton();
10619
+ }
10620
+
10621
+ function toggleSelectAll() {
10622
+ const allItems = document.querySelectorAll('[data-file-id]');
10623
+ if (selectedFiles.size === allItems.length) {
10624
+ selectedFiles.clear();
10625
+ allItems.forEach(item => item.classList.remove('selected'));
10626
+ document.getElementById('select-all-btn').textContent = 'Select All';
10627
+ } else {
10628
+ allItems.forEach(item => {
10629
+ const fileId = item.dataset.fileId;
10630
+ selectedFiles.add(fileId);
10631
+ item.classList.add('selected');
10632
+ });
10633
+ document.getElementById('select-all-btn').textContent = 'Deselect All';
10634
+ }
10635
+ updateBulkActionsButton();
10636
+ }
10637
+
10638
+ function updateBulkActionsButton() {
10639
+ const btn = document.getElementById('bulk-actions-btn');
10640
+ const chevronIcon = btn.querySelector('svg');
10641
+
10642
+ if (selectedFiles.size > 0) {
10643
+ btn.disabled = false;
10644
+ 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';
10645
+ 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>\`;
10646
+ // Re-attach onclick handler after innerHTML update
10647
+ btn.onclick = toggleBulkActionsDropdown;
10648
+ } else {
10649
+ btn.disabled = true;
10650
+ 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';
10651
+ 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>\`;
10652
+ btn.onclick = null; // Remove handler when disabled
10653
+ // Hide menu when no files selected
10654
+ const menu = document.getElementById('bulk-actions-menu');
10655
+ menu.classList.remove('scale-100', 'opacity-100');
10656
+ menu.classList.add('scale-95', 'opacity-0', 'hidden');
10657
+ }
10658
+ }
10659
+
10660
+ function toggleBulkActionsDropdown() {
10661
+ const menu = document.getElementById('bulk-actions-menu');
10662
+ const isHidden = menu.classList.contains('hidden');
10663
+
10664
+ if (isHidden) {
10665
+ menu.classList.remove('hidden');
10666
+ setTimeout(() => {
10667
+ menu.classList.remove('scale-95', 'opacity-0');
10668
+ menu.classList.add('scale-100', 'opacity-100');
10669
+ }, 10);
10670
+ } else {
10671
+ menu.classList.remove('scale-100', 'opacity-100');
10672
+ menu.classList.add('scale-95', 'opacity-0');
10673
+ setTimeout(() => {
10674
+ menu.classList.add('hidden');
10675
+ }, 100);
10676
+ }
10677
+ }
10678
+
10679
+ function confirmBulkDelete() {
10680
+ if (selectedFiles.size === 0) return;
10681
+ showConfirmDialog('media-bulk-delete-confirm');
10682
+ }
10683
+
10684
+ async function performBulkDelete() {
10685
+ if (selectedFiles.size === 0) return;
10686
+
10687
+ try {
10688
+ // Show loading state
10689
+ const btn = document.getElementById('bulk-actions-btn');
10690
+ const originalText = btn.innerHTML;
10691
+ btn.innerHTML = 'Deleting...';
10692
+ btn.disabled = true;
10693
+
10694
+ // Hide menu
10695
+ const menu = document.getElementById('bulk-actions-menu');
10696
+ menu.classList.remove('scale-100', 'opacity-100');
10697
+ menu.classList.add('scale-95', 'opacity-0');
10698
+ setTimeout(() => menu.classList.add('hidden'), 100);
10699
+
10700
+ const response = await fetch('/api/media/bulk-delete', {
10701
+ method: 'POST',
10702
+ headers: {
10703
+ 'Content-Type': 'application/json',
10704
+ },
10705
+ body: JSON.stringify({
10706
+ fileIds: Array.from(selectedFiles)
10707
+ })
10708
+ });
10709
+
10710
+ const result = await response.json();
10711
+
10712
+ if (result.success) {
10713
+ // Show success notification
10714
+ showNotification(\`Successfully deleted \${result.summary.successful} file\${result.summary.successful > 1 ? 's' : ''}\`, 'success');
10715
+
10716
+ // Remove deleted files from DOM
10717
+ result.deleted.forEach(item => {
10718
+ const element = document.querySelector(\`[data-file-id="\${item.fileId}"]\`);
10719
+ if (element) {
10720
+ element.remove();
10721
+ }
10722
+ });
10723
+
10724
+ // Show errors if any
10725
+ if (result.errors.length > 0) {
10726
+ console.warn('Some files failed to delete:', result.errors);
10727
+ showNotification(\`\${result.errors.length} file\${result.errors.length > 1 ? 's' : ''} failed to delete\`, 'warning');
10728
+ }
10729
+
10730
+ // Clear selection
10731
+ selectedFiles.clear();
10732
+ updateBulkActionsButton();
10733
+ document.getElementById('select-all-btn').textContent = 'Select All';
10734
+ } else {
10735
+ showNotification('Failed to delete files', 'error');
10736
+ }
10737
+ } catch (error) {
10738
+ console.error('Bulk delete error:', error);
10739
+ showNotification('An error occurred while deleting files', 'error');
10740
+ } finally {
10741
+ // Reset button state
10742
+ updateBulkActionsButton();
10743
+ }
10744
+ }
10745
+
10746
+ function openMoveToFolderModal() {
10747
+ if (selectedFiles.size === 0) return;
10748
+
10749
+ // Update file count in modal
10750
+ document.getElementById('move-file-count').textContent = selectedFiles.size.toString();
10751
+
10752
+ // Show modal
10753
+ document.getElementById('move-to-folder-modal').classList.remove('hidden');
10754
+
10755
+ // Hide bulk actions menu
10756
+ const menu = document.getElementById('bulk-actions-menu');
10757
+ menu.classList.remove('scale-100', 'opacity-100');
10758
+ menu.classList.add('scale-95', 'opacity-0');
10759
+ setTimeout(() => menu.classList.add('hidden'), 100);
10760
+ }
10761
+
10762
+ function closeMoveToFolderModal() {
10763
+ document.getElementById('move-to-folder-modal').classList.add('hidden');
10764
+ }
10765
+
10766
+ function openCreateFolderModal() {
10767
+ document.getElementById('create-folder-modal').classList.remove('hidden');
10768
+ // Clear and focus the input
10769
+ const input = document.getElementById('folder-name');
10770
+ input.value = '';
10771
+ setTimeout(() => input.focus(), 100);
10772
+ }
10773
+
10774
+ function closeCreateFolderModal() {
10775
+ document.getElementById('create-folder-modal').classList.add('hidden');
10776
+ }
10777
+
10778
+ async function createNewFolder(event) {
10779
+ event.preventDefault();
10780
+
10781
+ const folderName = document.getElementById('folder-name').value.trim();
10782
+
10783
+ if (!folderName) {
10784
+ showNotification('Please enter a folder name', 'error');
10785
+ return;
10786
+ }
10787
+
10788
+ // Validate folder name format
10789
+ const folderPattern = /^[a-z0-9-_]+$/;
10790
+ if (!folderPattern.test(folderName)) {
10791
+ showNotification('Folder name can only contain lowercase letters, numbers, hyphens, and underscores', 'error');
10792
+ return;
10793
+ }
10794
+
10795
+ try {
10796
+ const response = await fetch('/api/media/create-folder', {
10797
+ method: 'POST',
10798
+ headers: {
10799
+ 'Content-Type': 'application/json',
10800
+ },
10801
+ body: JSON.stringify({ folderName })
10802
+ });
10803
+
10804
+ const result = await response.json();
10805
+
10806
+ if (result.success) {
10807
+ showNotification(\`Folder "\${folderName}" created successfully\`, 'success');
10808
+ closeCreateFolderModal();
10809
+
10810
+ // Reload the page to show the new folder (delay to allow notification to show)
10811
+ setTimeout(() => {
10812
+ window.location.reload();
10813
+ }, 2000);
10814
+ } else {
10815
+ showNotification(result.error || 'Failed to create folder', 'error');
10816
+ }
10817
+ } catch (error) {
10818
+ console.error('Create folder error:', error);
10819
+ showNotification('An error occurred while creating the folder', 'error');
10820
+ }
10821
+ }
10822
+
10823
+ async function performBulkMove(targetFolder) {
10824
+ if (selectedFiles.size === 0) return;
10825
+
10826
+ try {
10827
+ // Show loading state
10828
+ closeMoveToFolderModal();
10829
+ const btn = document.getElementById('bulk-actions-btn');
10830
+ const originalText = btn.innerHTML;
10831
+ btn.innerHTML = 'Moving...';
10832
+ btn.disabled = true;
10833
+
10834
+ const response = await fetch('/api/media/bulk-move', {
10835
+ method: 'POST',
10836
+ headers: {
10837
+ 'Content-Type': 'application/json',
10838
+ },
10839
+ body: JSON.stringify({
10840
+ fileIds: Array.from(selectedFiles),
10841
+ folder: targetFolder
10842
+ })
10843
+ });
10844
+
10845
+ const result = await response.json();
10846
+
10847
+ if (result.success) {
10848
+ // Show success notification
10849
+ const movedCount = result.summary.successful;
10850
+ showNotification(\`Successfully moved \${movedCount} file\${movedCount > 1 ? 's' : ''} to \${targetFolder}\`, 'success');
10851
+
10852
+ // Reload the page to show updated file locations
10853
+ setTimeout(() => {
10854
+ window.location.reload();
10855
+ }, 1000);
10856
+ } else {
10857
+ showNotification('Failed to move files', 'error');
10858
+ updateBulkActionsButton();
10859
+ }
10860
+ } catch (error) {
10861
+ console.error('Bulk move error:', error);
10862
+ showNotification('An error occurred while moving files', 'error');
10863
+ updateBulkActionsButton();
10864
+ }
10865
+ }
10866
+
10867
+ function showNotification(message, type = 'info') {
10868
+ const notification = document.createElement('div');
10869
+ const bgColor = type === 'success' ? 'bg-green-600' :
10870
+ type === 'warning' ? 'bg-yellow-600' :
10871
+ type === 'error' ? 'bg-red-600' : 'bg-blue-600';
10872
+
10873
+ 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\`;
10874
+ notification.textContent = message;
10875
+ document.body.appendChild(notification);
10876
+
10877
+ // Animate in
10878
+ setTimeout(() => {
10879
+ notification.classList.remove('translate-x-full');
10880
+ }, 100);
10881
+
10882
+ // Remove after 3 seconds
10883
+ setTimeout(() => {
10884
+ notification.classList.add('translate-x-full');
10885
+ setTimeout(() => {
10886
+ if (document.body.contains(notification)) {
10887
+ document.body.removeChild(notification);
10888
+ }
10889
+ }, 300);
10890
+ }, 3000);
10891
+ }
10892
+
10893
+ // URL parameter helpers
10894
+ function updateUrlParam(param, value) {
10895
+ const url = new URL(window.location);
10896
+ url.searchParams.set(param, value);
10897
+ return url.toString();
10898
+ }
10899
+
10900
+ // Drag and drop handling
10901
+ const uploadZone = document.getElementById('upload-zone');
10902
+
10903
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
10904
+ uploadZone.addEventListener(eventName, preventDefaults, false);
10905
+ });
10906
+
10907
+ function preventDefaults(e) {
10908
+ e.preventDefault();
10909
+ e.stopPropagation();
10910
+ }
10911
+
10912
+ ['dragenter', 'dragover'].forEach(eventName => {
10913
+ uploadZone.addEventListener(eventName, () => uploadZone.classList.add('dragover'), false);
10914
+ });
10915
+
10916
+ ['dragleave', 'drop'].forEach(eventName => {
10917
+ uploadZone.addEventListener(eventName, () => uploadZone.classList.remove('dragover'), false);
10918
+ });
10919
+
10920
+ uploadZone.addEventListener('drop', handleDrop, false);
10921
+
10922
+ function handleDrop(e) {
10923
+ const dt = e.dataTransfer;
10924
+ const files = dt.files;
10925
+ handleFileSelect(files);
10926
+ }
10927
+
10928
+ function handleFileSelect(files) {
10929
+ dragDropFiles = Array.from(files);
10930
+
10931
+ // Update the actual file input with the selected files
10932
+ const fileInput = document.getElementById('file-input');
10933
+ const dt = new DataTransfer();
10934
+ dragDropFiles.forEach(file => dt.items.add(file));
10935
+ fileInput.files = dt.files;
10936
+
10937
+ displaySelectedFiles();
10938
+ document.getElementById('upload-btn').disabled = false;
10939
+ }
10940
+
10941
+ function displaySelectedFiles() {
10942
+ const fileList = document.getElementById('file-list');
10943
+ const selectedFilesDiv = document.getElementById('selected-files');
10944
+
10945
+ selectedFilesDiv.innerHTML = '';
10946
+
10947
+ dragDropFiles.forEach((file, index) => {
10948
+ const fileItem = document.createElement('div');
10949
+ 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';
10950
+ fileItem.innerHTML = \`
10951
+ <div class="flex items-center space-x-2">
10952
+ <span class="text-sm text-zinc-950 dark:text-white">\${file.name}</span>
10953
+ <span class="text-xs text-zinc-500 dark:text-zinc-400">(\${formatFileSize(file.size)})</span>
10954
+ </div>
10955
+ <button onclick="removeFile(\${index})" class="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 transition-colors">
10956
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10957
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
10958
+ </svg>
10959
+ </button>
10960
+ \`;
10961
+ selectedFilesDiv.appendChild(fileItem);
10962
+ });
10963
+
10964
+ fileList.classList.toggle('hidden', dragDropFiles.length === 0);
10965
+ }
10966
+
10967
+ function removeFile(index) {
10968
+ dragDropFiles.splice(index, 1);
10969
+ displaySelectedFiles();
10970
+
10971
+ const fileInput = document.getElementById('file-input');
10972
+ const dt = new DataTransfer();
10973
+ dragDropFiles.forEach(file => dt.items.add(file));
10974
+ fileInput.files = dt.files;
10975
+
10976
+ document.getElementById('upload-btn').disabled = dragDropFiles.length === 0;
10977
+ }
10978
+
10979
+ function formatFileSize(bytes) {
10980
+ if (bytes === 0) return '0 Bytes';
10981
+ const k = 1024;
10982
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
10983
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
10984
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
10985
+ }
10986
+
10987
+ // Copy to clipboard function
10988
+ function copyToClipboard(text) {
10989
+ navigator.clipboard.writeText(text).then(() => {
10990
+ const notification = document.createElement('div');
10991
+ 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';
10992
+ notification.textContent = 'URL copied to clipboard!';
10993
+ document.body.appendChild(notification);
10994
+ setTimeout(() => document.body.removeChild(notification), 2000);
10995
+ }).catch(err => {
10996
+ console.error('Failed to copy: ', err);
10997
+ });
10998
+ }
10999
+
11000
+ // Toggle clear button visibility
11001
+ function toggleMediaClearButton() {
11002
+ const searchInput = document.getElementById('media-search-input');
11003
+ const clearButton = document.getElementById('clear-media-search');
11004
+ if (searchInput.value.trim()) {
11005
+ clearButton.classList.remove('hidden');
11006
+ } else {
11007
+ clearButton.classList.add('hidden');
11008
+ }
11009
+ }
11010
+
11011
+ // Clear search input
11012
+ function clearMediaSearch() {
11013
+ const searchInput = document.getElementById('media-search-input');
11014
+ searchInput.value = '';
11015
+ toggleMediaClearButton();
11016
+ // Trigger htmx to refresh the grid
11017
+ htmx.trigger(searchInput, 'keyup');
11018
+ }
11019
+
11020
+ // Initialize clear button visibility on page load
11021
+ document.addEventListener('DOMContentLoaded', function() {
11022
+ toggleMediaClearButton();
11023
+ });
11024
+
11025
+ // Close modal when clicking outside
11026
+ document.getElementById('file-modal').addEventListener('click', function(e) {
11027
+ if (e.target === this) {
11028
+ this.classList.add('hidden');
11029
+ }
11030
+ });
11031
+
11032
+ // Close bulk actions dropdown when clicking outside
11033
+ document.addEventListener('click', function(e) {
11034
+ const dropdown = document.getElementById('bulk-actions-dropdown');
11035
+ const menu = document.getElementById('bulk-actions-menu');
11036
+ if (dropdown && menu && !dropdown.contains(e.target)) {
11037
+ menu.classList.remove('scale-100', 'opacity-100');
11038
+ menu.classList.add('scale-95', 'opacity-0');
11039
+ setTimeout(() => {
11040
+ menu.classList.add('hidden');
11041
+ }, 100);
11042
+ }
11043
+ });
11044
+ </script>
11045
+
11046
+ <!-- Confirmation Dialog for Bulk Delete -->
11047
+ ${renderConfirmationDialog({
11048
+ id: "media-bulk-delete-confirm",
11049
+ title: "Delete Selected Files",
11050
+ 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.`,
11051
+ confirmText: "Delete Files",
11052
+ cancelText: "Cancel",
11053
+ confirmClass: "bg-red-500 hover:bg-red-400",
11054
+ iconColor: "red",
11055
+ onConfirm: "performBulkDelete()"
11056
+ })}
11057
+
11058
+ <!-- Confirmation Dialog Script -->
11059
+ ${getConfirmationDialogScript()}
11060
+ `;
11061
+ function buildPageUrl(page, folder, type) {
11062
+ const params = new URLSearchParams();
11063
+ params.set("page", page.toString());
11064
+ if (folder !== "all") params.set("folder", folder);
11065
+ if (type !== "all") params.set("type", type);
11066
+ return `/admin/media?${params.toString()}`;
11067
+ }
11068
+ const layoutData = {
11069
+ title: "Media Library",
11070
+ currentPath: "/admin/media",
11071
+ user: data.user,
11072
+ version: data.version,
11073
+ content: pageContent
11074
+ };
11075
+ return renderAdminLayoutCatalyst(layoutData);
11076
+ }
11077
+
11078
+ // src/templates/components/media-file-details.template.ts
11079
+ function renderMediaFileDetails(data) {
11080
+ const { file } = data;
11081
+ return `
11082
+ <div class="flex justify-between items-center mb-4">
11083
+ <h3 class="text-lg font-semibold text-zinc-950 dark:text-white">File Details</h3>
11084
+ <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">
11085
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
11086
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
11087
+ </svg>
11088
+ </button>
11089
+ </div>
11090
+
11091
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
11092
+ <!-- Preview -->
11093
+ <div class="space-y-4">
11094
+ <div class="rounded-xl bg-zinc-50 dark:bg-zinc-800 p-4">
11095
+ ${file.isImage ? `
11096
+ <img src="${file.public_url}" alt="${file.alt || file.filename}" class="w-full h-auto rounded-lg">
11097
+ ` : file.isVideo ? `
11098
+ <video src="${file.public_url}" controls class="w-full h-auto rounded-lg"></video>
11099
+ ` : `
11100
+ <div class="flex items-center justify-center h-32">
11101
+ <svg class="w-12 h-12 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
11102
+ <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>
11103
+ </svg>
11104
+ </div>
11105
+ `}
11106
+ </div>
11107
+
11108
+ <div class="text-center space-x-2">
11109
+ <button
11110
+ onclick="copyToClipboard('${file.public_url}')"
11111
+ 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"
11112
+ >
11113
+ Copy URL
11114
+ </button>
11115
+ <a
11116
+ href="${file.public_url}"
11117
+ target="_blank"
11118
+ 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"
11119
+ >
11120
+ Open Original
11121
+ </a>
11122
+ </div>
11123
+ </div>
11124
+
11125
+ <!-- Details -->
11126
+ <div class="space-y-4">
11127
+ <div>
11128
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-1">Filename</label>
11129
+ <p class="text-sm text-zinc-500 dark:text-zinc-400">${file.original_name}</p>
11130
+ </div>
11131
+
11132
+ <div class="grid grid-cols-2 gap-4">
11133
+ <div>
11134
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-1">Size</label>
11135
+ <p class="text-sm text-zinc-500 dark:text-zinc-400">${file.fileSize}</p>
11136
+ </div>
11137
+ <div>
11138
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-1">Type</label>
11139
+ <p class="text-sm text-zinc-500 dark:text-zinc-400">${file.mime_type}</p>
11140
+ </div>
11141
+ </div>
11142
+
11143
+ ${file.width && file.height ? `
11144
+ <div class="grid grid-cols-2 gap-4">
11145
+ <div>
11146
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-1">Width</label>
11147
+ <p class="text-sm text-zinc-500 dark:text-zinc-400">${file.width}px</p>
11148
+ </div>
11149
+ <div>
11150
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-1">Height</label>
11151
+ <p class="text-sm text-zinc-500 dark:text-zinc-400">${file.height}px</p>
11152
+ </div>
11153
+ </div>
11154
+ ` : ""}
11155
+
11156
+ <div>
11157
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-1">Folder</label>
11158
+ <p class="text-sm text-zinc-500 dark:text-zinc-400">${file.folder}</p>
11159
+ </div>
11160
+
11161
+ <div>
11162
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-1">Uploaded</label>
11163
+ <p class="text-sm text-zinc-500 dark:text-zinc-400">${file.uploadedAt}</p>
11164
+ </div>
11165
+
11166
+ <!-- Editable Fields -->
11167
+ <form hx-put="/admin/media/${file.id}" hx-target="#file-modal-content" class="space-y-4">
11168
+ <div>
11169
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-1">Alt Text</label>
11170
+ <input
11171
+ type="text"
11172
+ name="alt"
11173
+ value="${file.alt || ""}"
11174
+ 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"
11175
+ placeholder="Describe this image..."
11176
+ >
11177
+ </div>
11178
+
11179
+ <div>
11180
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-1">Caption</label>
11181
+ <textarea
11182
+ name="caption"
11183
+ rows="3"
11184
+ 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"
11185
+ placeholder="Optional caption..."
11186
+ >${file.caption || ""}</textarea>
11187
+ </div>
11188
+
11189
+ <div>
11190
+ <label class="block text-sm font-medium text-zinc-950 dark:text-white mb-1">Tags</label>
11191
+ <input
11192
+ type="text"
11193
+ name="tags"
11194
+ value="${file.tags.join(", ")}"
11195
+ 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"
11196
+ placeholder="tag1, tag2, tag3"
11197
+ >
11198
+ </div>
11199
+
11200
+ <div class="flex justify-between">
11201
+ <button
11202
+ type="submit"
11203
+ class="rounded-lg bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-700 transition-colors"
11204
+ >
11205
+ Save Changes
11206
+ </button>
11207
+ <button
11208
+ type="button"
11209
+ hx-delete="/admin/media/${file.id}"
11210
+ hx-confirm="Are you sure you want to delete this file?"
11211
+ hx-target="body"
11212
+ class="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 transition-colors"
11213
+ >
11214
+ Delete File
11215
+ </button>
11216
+ </div>
11217
+ </form>
11218
+ </div>
11219
+ </div>
11220
+ `;
11221
+ }
11222
+
11223
+ // src/routes/admin-media.ts
11224
+ var fileValidationSchema2 = zod.z.object({
11225
+ name: zod.z.string().min(1).max(255),
11226
+ type: zod.z.string().refine(
11227
+ (type) => {
11228
+ const allowedTypes = [
11229
+ // Images
11230
+ "image/jpeg",
11231
+ "image/jpg",
11232
+ "image/png",
11233
+ "image/gif",
11234
+ "image/webp",
11235
+ "image/svg+xml",
11236
+ // Documents
11237
+ "application/pdf",
11238
+ "text/plain",
11239
+ "application/msword",
11240
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
11241
+ // Videos
11242
+ "video/mp4",
11243
+ "video/webm",
11244
+ "video/ogg",
11245
+ "video/avi",
11246
+ "video/mov",
11247
+ // Audio
11248
+ "audio/mp3",
11249
+ "audio/wav",
11250
+ "audio/ogg",
11251
+ "audio/m4a"
11252
+ ];
11253
+ return allowedTypes.includes(type);
11254
+ },
11255
+ { message: "Unsupported file type" }
11256
+ ),
11257
+ size: zod.z.number().min(1).max(50 * 1024 * 1024)
11258
+ // 50MB max
11259
+ });
11260
+ var adminMediaRoutes = new hono.Hono();
11261
+ adminMediaRoutes.get("/", async (c) => {
11262
+ try {
11263
+ const user = c.get("user");
11264
+ const { searchParams } = new URL(c.req.url);
11265
+ const folder = searchParams.get("folder") || "all";
11266
+ const type = searchParams.get("type") || "all";
11267
+ const view = searchParams.get("view") || "grid";
11268
+ const page = parseInt(searchParams.get("page") || "1");
11269
+ const cacheBust = searchParams.get("t");
11270
+ const limit = 24;
11271
+ const offset = (page - 1) * limit;
11272
+ const db = c.env.DB;
11273
+ let query = "SELECT * FROM media";
11274
+ const params = [];
11275
+ const conditions = ["deleted_at IS NULL"];
11276
+ if (folder !== "all") {
11277
+ conditions.push("folder = ?");
11278
+ params.push(folder);
11279
+ }
11280
+ if (type !== "all") {
11281
+ switch (type) {
11282
+ case "images":
11283
+ conditions.push("mime_type LIKE ?");
11284
+ params.push("image/%");
11285
+ break;
11286
+ case "documents":
11287
+ conditions.push("mime_type IN (?, ?, ?)");
11288
+ params.push("application/pdf", "text/plain", "application/msword");
11289
+ break;
11290
+ case "videos":
11291
+ conditions.push("mime_type LIKE ?");
11292
+ params.push("video/%");
11293
+ break;
11294
+ }
11295
+ }
11296
+ if (conditions.length > 0) {
11297
+ query += ` WHERE ${conditions.join(" AND ")}`;
11298
+ }
11299
+ query += ` ORDER BY uploaded_at DESC LIMIT ${limit} OFFSET ${offset}`;
11300
+ const stmt = db.prepare(query);
11301
+ const { results } = await stmt.bind(...params).all();
11302
+ const foldersStmt = db.prepare(`
11303
+ SELECT folder, COUNT(*) as count, SUM(size) as totalSize
11304
+ FROM media
11305
+ GROUP BY folder
11306
+ ORDER BY folder
11307
+ `);
11308
+ const { results: folders } = await foldersStmt.all();
11309
+ const typesStmt = db.prepare(`
11310
+ SELECT
11311
+ CASE
11312
+ WHEN mime_type LIKE 'image/%' THEN 'images'
11313
+ WHEN mime_type LIKE 'video/%' THEN 'videos'
11314
+ WHEN mime_type IN ('application/pdf', 'text/plain') THEN 'documents'
11315
+ ELSE 'other'
11316
+ END as type,
11317
+ COUNT(*) as count
11318
+ FROM media
11319
+ GROUP BY type
11320
+ `);
11321
+ const { results: types } = await typesStmt.all();
11322
+ const mediaFiles = results.map((row) => ({
11323
+ id: row.id,
11324
+ filename: row.filename,
11325
+ original_name: row.original_name,
11326
+ mime_type: row.mime_type,
11327
+ size: row.size,
11328
+ public_url: `/files/${row.r2_key}`,
11329
+ thumbnail_url: row.mime_type.startsWith("image/") ? `/files/${row.r2_key}` : void 0,
11330
+ alt: row.alt,
11331
+ caption: row.caption,
11332
+ tags: row.tags ? JSON.parse(row.tags) : [],
11333
+ uploaded_at: row.uploaded_at,
11334
+ fileSize: formatFileSize(row.size),
11335
+ uploadedAt: new Date(row.uploaded_at).toLocaleDateString(),
11336
+ isImage: row.mime_type.startsWith("image/"),
11337
+ isVideo: row.mime_type.startsWith("video/"),
11338
+ isDocument: !row.mime_type.startsWith("image/") && !row.mime_type.startsWith("video/")
11339
+ }));
11340
+ const pageData = {
11341
+ files: mediaFiles,
11342
+ folders: folders.map((f) => ({
11343
+ folder: f.folder,
11344
+ count: f.count,
11345
+ totalSize: f.totalSize
11346
+ })),
11347
+ types: types.map((t) => ({
11348
+ type: t.type,
11349
+ count: t.count
11350
+ })),
11351
+ currentFolder: folder,
11352
+ currentType: type,
11353
+ currentView: view,
11354
+ currentPage: page,
11355
+ totalFiles: results.length,
11356
+ hasNextPage: results.length === limit,
11357
+ user: {
11358
+ name: user.email,
11359
+ email: user.email,
11360
+ role: user.role
11361
+ },
11362
+ version: c.get("appVersion")
11363
+ };
11364
+ return c.html(renderMediaLibraryPage(pageData));
11365
+ } catch (error) {
11366
+ console.error("Error loading media library:", error);
11367
+ return c.html(html.html`<p>Error loading media library</p>`);
11368
+ }
11369
+ });
11370
+ adminMediaRoutes.get("/selector", async (c) => {
11371
+ try {
11372
+ const { searchParams } = new URL(c.req.url);
11373
+ const search = searchParams.get("search") || "";
11374
+ const db = c.env.DB;
11375
+ let query = "SELECT * FROM media WHERE deleted_at IS NULL";
11376
+ const params = [];
11377
+ if (search.trim()) {
11378
+ query += " AND (filename LIKE ? OR original_name LIKE ? OR alt LIKE ?)";
11379
+ const searchTerm = `%${search}%`;
11380
+ params.push(searchTerm, searchTerm, searchTerm);
11381
+ }
11382
+ query += " ORDER BY uploaded_at DESC LIMIT 24";
11383
+ const stmt = db.prepare(query);
11384
+ const { results } = await stmt.bind(...params).all();
11385
+ const mediaFiles = results.map((row) => ({
11386
+ id: row.id,
11387
+ filename: row.filename,
11388
+ original_name: row.original_name,
11389
+ mime_type: row.mime_type,
11390
+ size: row.size,
11391
+ public_url: `/files/${row.r2_key}`,
11392
+ thumbnail_url: row.mime_type.startsWith("image/") ? `/files/${row.r2_key}` : void 0,
11393
+ alt: row.alt,
11394
+ tags: row.tags ? JSON.parse(row.tags) : [],
11395
+ uploaded_at: row.uploaded_at,
11396
+ fileSize: formatFileSize(row.size),
11397
+ uploadedAt: new Date(row.uploaded_at).toLocaleDateString(),
11398
+ isImage: row.mime_type.startsWith("image/"),
11399
+ isVideo: row.mime_type.startsWith("video/"),
11400
+ isDocument: !row.mime_type.startsWith("image/") && !row.mime_type.startsWith("video/")
11401
+ }));
11402
+ return c.html(html.html`
11403
+ <div class="mb-4">
11404
+ <input
11405
+ type="search"
11406
+ id="media-selector-search"
11407
+ placeholder="Search files..."
11408
+ 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"
11409
+ hx-get="/admin/media/selector"
11410
+ hx-trigger="keyup changed delay:300ms"
11411
+ hx-target="#media-selector-grid"
11412
+ hx-include="[name='search']"
11413
+ >
11414
+ </div>
11415
+
11416
+ <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">
11417
+ ${html.raw(mediaFiles.map((file) => `
11418
+ <div
11419
+ class="relative group cursor-pointer rounded-lg overflow-hidden bg-zinc-50 dark:bg-zinc-800 shadow-sm hover:shadow-md transition-shadow"
11420
+ data-media-id="${file.id}"
11421
+ >
11422
+ <div class="aspect-square relative">
11423
+ ${file.isImage ? `
11424
+ <img
11425
+ src="${file.public_url}"
11426
+ alt="${file.alt || file.filename}"
11427
+ class="w-full h-full object-cover"
11428
+ loading="lazy"
11429
+ >
11430
+ ` : file.isVideo ? `
11431
+ <video
11432
+ src="${file.public_url}"
11433
+ class="w-full h-full object-cover"
11434
+ muted
11435
+ ></video>
11436
+ ` : `
11437
+ <div class="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-700">
11438
+ <div class="text-center">
11439
+ <svg class="w-12 h-12 mx-auto text-zinc-400 dark:text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
11440
+ <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>
11441
+ </svg>
11442
+ <span class="text-xs text-zinc-500 dark:text-zinc-400 mt-1">${file.filename.split(".").pop()?.toUpperCase()}</span>
11443
+ </div>
11444
+ </div>
11445
+ `}
11446
+
11447
+ <div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
11448
+ <button
11449
+ type="button"
11450
+ onclick="selectMediaFile('${file.id}', '${file.public_url.replace(/'/g, "\\'")}', '${file.filename.replace(/'/g, "\\'")}')"
11451
+ 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"
11452
+ >
11453
+ Select
11454
+ </button>
11455
+ </div>
11456
+ </div>
11457
+
11458
+ <div class="p-2">
11459
+ <p class="text-xs text-zinc-700 dark:text-zinc-300 truncate" title="${file.original_name}">
11460
+ ${file.original_name}
11461
+ </p>
11462
+ <p class="text-xs text-zinc-500 dark:text-zinc-400">
11463
+ ${file.fileSize}
11464
+ </p>
11465
+ </div>
11466
+ </div>
11467
+ `).join(""))}
11468
+ </div>
11469
+
11470
+ ${mediaFiles.length === 0 ? html.html`
11471
+ <div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
11472
+ <svg class="mx-auto h-12 w-12 text-zinc-400 dark:text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
11473
+ <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>
11474
+ </svg>
11475
+ <p class="mt-2">No media files found</p>
11476
+ </div>
11477
+ ` : ""}
11478
+ `);
11479
+ } catch (error) {
11480
+ console.error("Error loading media selector:", error);
11481
+ return c.html(html.html`<div class="text-red-500 dark:text-red-400">Error loading media files</div>`);
11482
+ }
11483
+ });
11484
+ adminMediaRoutes.get("/search", async (c) => {
11485
+ try {
11486
+ const { searchParams } = new URL(c.req.url);
11487
+ const search = searchParams.get("search") || "";
11488
+ const folder = searchParams.get("folder") || "all";
11489
+ const type = searchParams.get("type") || "all";
11490
+ const db = c.env.DB;
11491
+ let query = "SELECT * FROM media";
11492
+ const params = [];
11493
+ const conditions = [];
11494
+ if (search.trim()) {
11495
+ conditions.push("(filename LIKE ? OR original_name LIKE ? OR alt LIKE ?)");
11496
+ const searchTerm = `%${search}%`;
11497
+ params.push(searchTerm, searchTerm, searchTerm);
11498
+ }
11499
+ if (folder !== "all") {
11500
+ conditions.push("folder = ?");
11501
+ params.push(folder);
11502
+ }
11503
+ if (type !== "all") {
11504
+ switch (type) {
11505
+ case "images":
11506
+ conditions.push("mime_type LIKE ?");
11507
+ params.push("image/%");
11508
+ break;
11509
+ case "documents":
11510
+ conditions.push("mime_type IN (?, ?, ?)");
11511
+ params.push("application/pdf", "text/plain", "application/msword");
11512
+ break;
11513
+ case "videos":
11514
+ conditions.push("mime_type LIKE ?");
11515
+ params.push("video/%");
11516
+ break;
11517
+ }
11518
+ }
11519
+ if (conditions.length > 0) {
11520
+ query += ` WHERE ${conditions.join(" AND ")}`;
11521
+ }
11522
+ query += ` ORDER BY uploaded_at DESC LIMIT 24`;
11523
+ const stmt = db.prepare(query);
11524
+ const { results } = await stmt.bind(...params).all();
11525
+ const mediaFiles = results.map((row) => ({
11526
+ ...row,
11527
+ public_url: `/files/${row.r2_key}`,
11528
+ thumbnail_url: row.mime_type.startsWith("image/") ? `/files/${row.r2_key}` : void 0,
11529
+ tags: row.tags ? JSON.parse(row.tags) : [],
11530
+ uploadedAt: new Date(row.uploaded_at).toLocaleDateString(),
11531
+ fileSize: formatFileSize(row.size),
11532
+ isImage: row.mime_type.startsWith("image/"),
11533
+ isVideo: row.mime_type.startsWith("video/"),
11534
+ isDocument: !row.mime_type.startsWith("image/") && !row.mime_type.startsWith("video/")
11535
+ }));
11536
+ const gridHTML = mediaFiles.map((file) => generateMediaItemHTML(file)).join("");
11537
+ return c.html(html.raw(gridHTML));
11538
+ } catch (error) {
11539
+ console.error("Error searching media:", error);
11540
+ return c.html('<div class="text-red-500">Error searching files</div>');
11541
+ }
11542
+ });
11543
+ adminMediaRoutes.get("/:id/details", async (c) => {
11544
+ try {
11545
+ const id = c.req.param("id");
11546
+ const db = c.env.DB;
11547
+ const stmt = db.prepare("SELECT * FROM media WHERE id = ?");
11548
+ const result = await stmt.bind(id).first();
11549
+ if (!result) {
11550
+ return c.html('<div class="text-red-500">File not found</div>');
11551
+ }
11552
+ const file = {
11553
+ id: result.id,
11554
+ filename: result.filename,
11555
+ original_name: result.original_name,
11556
+ mime_type: result.mime_type,
11557
+ size: result.size,
11558
+ public_url: `/files/${result.r2_key}`,
11559
+ thumbnail_url: result.mime_type.startsWith("image/") ? `/files/${result.r2_key}` : void 0,
11560
+ alt: result.alt,
11561
+ caption: result.caption,
11562
+ tags: result.tags ? JSON.parse(result.tags) : [],
11563
+ uploaded_at: result.uploaded_at,
11564
+ fileSize: formatFileSize(result.size),
11565
+ uploadedAt: new Date(result.uploaded_at).toLocaleString(),
11566
+ isImage: result.mime_type.startsWith("image/"),
11567
+ isVideo: result.mime_type.startsWith("video/"),
11568
+ isDocument: !result.mime_type.startsWith("image/") && !result.mime_type.startsWith("video/"),
11569
+ width: result.width,
11570
+ height: result.height,
11571
+ folder: result.folder
11572
+ };
11573
+ const detailsData = { file };
11574
+ return c.html(renderMediaFileDetails(detailsData));
11575
+ } catch (error) {
11576
+ console.error("Error fetching file details:", error);
11577
+ return c.html('<div class="text-red-500">Error loading file details</div>');
11578
+ }
11579
+ });
11580
+ adminMediaRoutes.post("/upload", async (c) => {
11581
+ try {
11582
+ const user = c.get("user");
11583
+ const formData = await c.req.formData();
11584
+ const files = formData.getAll("files");
11585
+ if (!files || files.length === 0) {
11586
+ return c.html(html.html`
11587
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
11588
+ No files provided
11589
+ </div>
11590
+ `);
11591
+ }
11592
+ const uploadResults = [];
11593
+ const errors = [];
11594
+ for (const file of files) {
11595
+ try {
11596
+ const validation = fileValidationSchema2.safeParse({
11597
+ name: file.name,
11598
+ type: file.type,
11599
+ size: file.size
11600
+ });
11601
+ if (!validation.success) {
11602
+ errors.push({
11603
+ filename: file.name,
11604
+ error: validation.error.issues[0]?.message || "Validation failed"
11605
+ });
11606
+ continue;
11607
+ }
11608
+ const fileId = globalThis.crypto.randomUUID();
11609
+ const fileExtension = file.name.split(".").pop() || "";
11610
+ const filename = `${fileId}.${fileExtension}`;
11611
+ const folder = formData.get("folder") || "uploads";
11612
+ const r2Key = `${folder}/${filename}`;
11613
+ const arrayBuffer = await file.arrayBuffer();
11614
+ const uploadResult = await c.env.MEDIA_BUCKET.put(r2Key, arrayBuffer, {
11615
+ httpMetadata: {
11616
+ contentType: file.type,
11617
+ contentDisposition: `inline; filename="${file.name}"`
11618
+ },
11619
+ customMetadata: {
11620
+ originalName: file.name,
11621
+ uploadedBy: user.userId,
11622
+ uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
11623
+ }
11624
+ });
11625
+ if (!uploadResult) {
11626
+ errors.push({
11627
+ filename: file.name,
11628
+ error: "Failed to upload to storage"
11629
+ });
11630
+ continue;
11631
+ }
11632
+ let width;
11633
+ let height;
11634
+ if (file.type.startsWith("image/") && !file.type.includes("svg")) {
11635
+ try {
11636
+ const dimensions = await getImageDimensions2(arrayBuffer);
11637
+ width = dimensions.width;
11638
+ height = dimensions.height;
11639
+ } catch (error) {
11640
+ console.warn("Failed to extract image dimensions:", error);
11641
+ }
11642
+ }
11643
+ const publicUrl = `/files/${r2Key}`;
11644
+ const thumbnailUrl = file.type.startsWith("image/") ? publicUrl : void 0;
11645
+ const stmt = c.env.DB.prepare(`
11646
+ INSERT INTO media (
11647
+ id, filename, original_name, mime_type, size, width, height,
11648
+ folder, r2_key, public_url, thumbnail_url, uploaded_by, uploaded_at
11649
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
11650
+ `);
11651
+ await stmt.bind(
11652
+ fileId,
11653
+ filename,
11654
+ file.name,
11655
+ file.type,
11656
+ file.size,
11657
+ width,
11658
+ height,
11659
+ folder,
11660
+ r2Key,
11661
+ publicUrl,
11662
+ thumbnailUrl,
11663
+ user.userId,
11664
+ Math.floor(Date.now() / 1e3)
11665
+ ).run();
11666
+ uploadResults.push({
11667
+ id: fileId,
11668
+ filename,
11669
+ originalName: file.name,
11670
+ mimeType: file.type,
11671
+ size: file.size,
11672
+ publicUrl
11673
+ });
11674
+ } catch (error) {
11675
+ errors.push({
11676
+ filename: file.name,
11677
+ error: "Upload failed: " + (error instanceof Error ? error.message : "Unknown error")
11678
+ });
11679
+ }
11680
+ }
11681
+ let mediaGridHTML = "";
11682
+ if (uploadResults.length > 0) {
11683
+ try {
11684
+ const folder = formData.get("folder") || "uploads";
11685
+ const query = "SELECT * FROM media WHERE deleted_at IS NULL ORDER BY uploaded_at DESC LIMIT 24";
11686
+ const stmt = c.env.DB.prepare(query);
11687
+ const { results } = await stmt.all();
11688
+ const mediaFiles = results.map((row) => ({
11689
+ id: row.id,
11690
+ filename: row.filename,
11691
+ original_name: row.original_name,
11692
+ mime_type: row.mime_type,
11693
+ size: row.size,
11694
+ public_url: `/files/${row.r2_key}`,
11695
+ thumbnail_url: row.mime_type.startsWith("image/") ? `/files/${row.r2_key}` : void 0,
11696
+ tags: row.tags ? JSON.parse(row.tags) : [],
11697
+ uploaded_at: row.uploaded_at,
11698
+ fileSize: formatFileSize(row.size),
11699
+ uploadedAt: new Date(row.uploaded_at).toLocaleDateString(),
11700
+ isImage: row.mime_type.startsWith("image/"),
11701
+ isVideo: row.mime_type.startsWith("video/"),
11702
+ isDocument: !row.mime_type.startsWith("image/") && !row.mime_type.startsWith("video/")
11703
+ }));
11704
+ mediaGridHTML = mediaFiles.map((file) => renderMediaFileCard(file, "grid", true)).join("");
11705
+ } catch (error) {
11706
+ console.error("Error fetching updated media list:", error);
11707
+ }
11708
+ }
11709
+ return c.html(html.html`
11710
+ ${uploadResults.length > 0 ? html.html`
11711
+ <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
11712
+ Successfully uploaded ${uploadResults.length} file${uploadResults.length > 1 ? "s" : ""}
11713
+ </div>
11714
+ ` : ""}
11715
+
11716
+ ${errors.length > 0 ? html.html`
11717
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
11718
+ <p class="font-medium">Upload errors:</p>
11719
+ <ul class="list-disc list-inside mt-2">
11720
+ ${errors.map((error) => html.html`
11721
+ <li>${error.filename}: ${error.error}</li>
11722
+ `)}
11723
+ </ul>
11724
+ </div>
11725
+ ` : ""}
11726
+
11727
+ ${uploadResults.length > 0 ? html.html`
11728
+ <script>
11729
+ // Close modal and refresh page after successful upload with cache busting
11730
+ setTimeout(() => {
11731
+ document.getElementById('upload-modal').classList.add('hidden');
11732
+ window.location.href = '/admin/media?t=' + Date.now();
11733
+ }, 1500);
11734
+ </script>
11735
+ ` : ""}
11736
+ `);
11737
+ } catch (error) {
11738
+ console.error("Upload error:", error);
11739
+ return c.html(html.html`
11740
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
11741
+ Upload failed: ${error instanceof Error ? error.message : "Unknown error"}
11742
+ </div>
11743
+ `);
11744
+ }
11745
+ });
11746
+ adminMediaRoutes.get("/file/*", async (c) => {
11747
+ try {
11748
+ const r2Key = c.req.path.replace("/admin/media/file/", "");
11749
+ if (!r2Key) {
11750
+ return c.notFound();
11751
+ }
11752
+ const object = await c.env.MEDIA_BUCKET.get(r2Key);
11753
+ if (!object) {
11754
+ return c.notFound();
11755
+ }
11756
+ const headers = new Headers();
11757
+ object.httpMetadata?.contentType && headers.set("Content-Type", object.httpMetadata.contentType);
11758
+ object.httpMetadata?.contentDisposition && headers.set("Content-Disposition", object.httpMetadata.contentDisposition);
11759
+ headers.set("Cache-Control", "public, max-age=31536000");
11760
+ return new Response(object.body, {
11761
+ headers
11762
+ });
11763
+ } catch (error) {
11764
+ console.error("Error serving file:", error);
11765
+ return c.notFound();
11766
+ }
11767
+ });
11768
+ adminMediaRoutes.put("/:id", async (c) => {
11769
+ try {
11770
+ const user = c.get("user");
11771
+ const fileId = c.req.param("id");
11772
+ const formData = await c.req.formData();
11773
+ const stmt = c.env.DB.prepare("SELECT * FROM media WHERE id = ? AND deleted_at IS NULL");
11774
+ const fileRecord = await stmt.bind(fileId).first();
11775
+ if (!fileRecord) {
11776
+ return c.html(html.html`
11777
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
11778
+ File not found
11779
+ </div>
11780
+ `);
11781
+ }
11782
+ if (fileRecord.uploaded_by !== user.userId && user.role !== "admin") {
11783
+ return c.html(html.html`
11784
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
11785
+ Permission denied
11786
+ </div>
11787
+ `);
11788
+ }
11789
+ const alt = formData.get("alt") || null;
11790
+ const caption = formData.get("caption") || null;
11791
+ const tagsString = formData.get("tags") || "";
11792
+ const tags = tagsString ? tagsString.split(",").map((tag) => tag.trim()).filter((tag) => tag) : [];
11793
+ const updateStmt = c.env.DB.prepare(`
11794
+ UPDATE media
11795
+ SET alt = ?, caption = ?, tags = ?, updated_at = ?
11796
+ WHERE id = ?
11797
+ `);
11798
+ await updateStmt.bind(
11799
+ alt,
11800
+ caption,
11801
+ JSON.stringify(tags),
11802
+ Math.floor(Date.now() / 1e3),
11803
+ fileId
11804
+ ).run();
11805
+ return c.html(html.html`
11806
+ <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
11807
+ File updated successfully
11808
+ </div>
11809
+ <script>
11810
+ // Refresh the file details
11811
+ setTimeout(() => {
11812
+ htmx.trigger('#file-modal-content', 'htmx:load');
11813
+ }, 1000);
11814
+ </script>
11815
+ `);
11816
+ } catch (error) {
11817
+ console.error("Update error:", error);
11818
+ return c.html(html.html`
11819
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
11820
+ Update failed: ${error instanceof Error ? error.message : "Unknown error"}
11821
+ </div>
11822
+ `);
11823
+ }
11824
+ });
11825
+ adminMediaRoutes.delete("/:id", async (c) => {
11826
+ try {
11827
+ const user = c.get("user");
11828
+ const fileId = c.req.param("id");
11829
+ const stmt = c.env.DB.prepare("SELECT * FROM media WHERE id = ? AND deleted_at IS NULL");
11830
+ const fileRecord = await stmt.bind(fileId).first();
11831
+ if (!fileRecord) {
11832
+ return c.html(html.html`
11833
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
11834
+ File not found
11835
+ </div>
11836
+ `);
11837
+ }
11838
+ if (fileRecord.uploaded_by !== user.userId && user.role !== "admin") {
11839
+ return c.html(html.html`
11840
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
11841
+ Permission denied
11842
+ </div>
11843
+ `);
11844
+ }
11845
+ try {
11846
+ await c.env.MEDIA_BUCKET.delete(fileRecord.r2_key);
11847
+ } catch (error) {
11848
+ console.warn("Failed to delete from R2:", error);
11849
+ }
11850
+ const deleteStmt = c.env.DB.prepare("UPDATE media SET deleted_at = ? WHERE id = ?");
11851
+ await deleteStmt.bind(Math.floor(Date.now() / 1e3), fileId).run();
11852
+ return c.html(html.html`
11853
+ <script>
11854
+ // Close modal if open
11855
+ const modal = document.getElementById('file-modal');
11856
+ if (modal) {
11857
+ modal.classList.add('hidden');
11858
+ }
11859
+ // Redirect to media library
11860
+ window.location.href = '/admin/media';
11861
+ </script>
11862
+ `);
11863
+ } catch (error) {
11864
+ console.error("Delete error:", error);
11865
+ return c.html(html.html`
11866
+ <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
11867
+ Delete failed: ${error instanceof Error ? error.message : "Unknown error"}
11868
+ </div>
11869
+ `);
11870
+ }
11871
+ });
11872
+ async function getImageDimensions2(arrayBuffer) {
11873
+ const uint8Array = new Uint8Array(arrayBuffer);
11874
+ if (uint8Array[0] === 255 && uint8Array[1] === 216) {
11875
+ return getJPEGDimensions2(uint8Array);
11876
+ }
11877
+ if (uint8Array[0] === 137 && uint8Array[1] === 80 && uint8Array[2] === 78 && uint8Array[3] === 71) {
11878
+ return getPNGDimensions2(uint8Array);
11879
+ }
11880
+ return { width: 0, height: 0 };
11881
+ }
11882
+ function getJPEGDimensions2(uint8Array) {
11883
+ let i = 2;
11884
+ while (i < uint8Array.length - 8) {
11885
+ if (uint8Array[i] === 255 && uint8Array[i + 1] === 192) {
11886
+ return {
11887
+ height: uint8Array[i + 5] << 8 | uint8Array[i + 6],
11888
+ width: uint8Array[i + 7] << 8 | uint8Array[i + 8]
11889
+ };
11890
+ }
11891
+ const segmentLength = uint8Array[i + 2] << 8 | uint8Array[i + 3];
11892
+ i += 2 + segmentLength;
11893
+ }
11894
+ return { width: 0, height: 0 };
11895
+ }
11896
+ function getPNGDimensions2(uint8Array) {
11897
+ if (uint8Array.length < 24) {
11898
+ return { width: 0, height: 0 };
11899
+ }
11900
+ return {
11901
+ width: uint8Array[16] << 24 | uint8Array[17] << 16 | uint8Array[18] << 8 | uint8Array[19],
11902
+ height: uint8Array[20] << 24 | uint8Array[21] << 16 | uint8Array[22] << 8 | uint8Array[23]
11903
+ };
11904
+ }
11905
+ function generateMediaItemHTML(file) {
11906
+ const isImage = file.isImage;
11907
+ const isVideo = file.isVideo;
11908
+ return `
11909
+ <div
11910
+ class="media-item bg-white rounded-lg shadow-sm overflow-hidden cursor-pointer"
11911
+ data-file-id="${file.id}"
11912
+ onclick="toggleFileSelection('${file.id}')"
11913
+ >
11914
+ <div class="aspect-square relative">
11915
+ ${isImage ? `
11916
+ <img
11917
+ src="${file.public_url}"
11918
+ alt="${file.alt || file.filename}"
11919
+ class="w-full h-full object-cover"
11920
+ loading="lazy"
11921
+ >
11922
+ ` : isVideo ? `
11923
+ <video
11924
+ src="${file.public_url}"
11925
+ class="w-full h-full object-cover"
11926
+ muted
11927
+ ></video>
11928
+ ` : `
11929
+ <div class="w-full h-full flex items-center justify-center bg-gray-100">
11930
+ <div class="text-center">
11931
+ <svg class="file-icon mx-auto text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
11932
+ <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>
11933
+ </svg>
11934
+ <span class="text-xs text-gray-500 mt-1">${file.filename.split(".").pop()?.toUpperCase()}</span>
11935
+ </div>
11936
+ </div>
11937
+ `}
11938
+
11939
+ <div class="preview-overlay flex items-center justify-center">
11940
+ <div class="flex space-x-2">
11941
+ <button
11942
+ onclick="event.stopPropagation(); showFileDetails('${file.id}')"
11943
+ class="p-2 bg-white bg-opacity-20 rounded-full hover:bg-opacity-30"
11944
+ >
11945
+ <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
11946
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
11947
+ <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>
11948
+ </svg>
11949
+ </button>
11950
+ <button
11951
+ onclick="event.stopPropagation(); copyToClipboard('${file.public_url}')"
11952
+ class="p-2 bg-white bg-opacity-20 rounded-full hover:bg-opacity-30"
11953
+ >
11954
+ <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
11955
+ <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>
11956
+ </svg>
11957
+ </button>
11958
+ </div>
11959
+ </div>
11960
+ </div>
11961
+
11962
+ <div class="p-3">
11963
+ <h4 class="text-sm font-medium text-gray-900 truncate" title="${file.original_name}">
11964
+ ${file.original_name}
11965
+ </h4>
11966
+ <div class="flex justify-between items-center mt-1">
11967
+ <span class="text-xs text-gray-500">${file.fileSize}</span>
11968
+ <span class="text-xs text-gray-500">${file.uploadedAt}</span>
11969
+ </div>
11970
+ ${file.tags.length > 0 ? `
11971
+ <div class="flex flex-wrap gap-1 mt-2">
11972
+ ${file.tags.slice(0, 2).map((tag) => `
11973
+ <span class="inline-block px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded">
11974
+ ${tag}
11975
+ </span>
11976
+ `).join("")}
11977
+ ${file.tags.length > 2 ? `<span class="text-xs text-gray-400">+${file.tags.length - 2}</span>` : ""}
11978
+ </div>
11979
+ ` : ""}
11980
+ </div>
11981
+ </div>
11982
+ `;
11983
+ }
11984
+ function formatFileSize(bytes) {
11985
+ if (bytes === 0) return "0 Bytes";
11986
+ const k = 1024;
11987
+ const sizes = ["Bytes", "KB", "MB", "GB"];
11988
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
11989
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
11990
+ }
11991
+
9949
11992
  // src/routes/index.ts
9950
11993
  var ROUTES_INFO = {
9951
11994
  message: "Routes migration in progress",
@@ -9957,13 +12000,15 @@ var ROUTES_INFO = {
9957
12000
  "adminApiRoutes",
9958
12001
  "authRoutes",
9959
12002
  "adminContentRoutes",
9960
- "adminUsersRoutes"
12003
+ "adminUsersRoutes",
12004
+ "adminMediaRoutes"
9961
12005
  ],
9962
12006
  status: "Routes are being added incrementally",
9963
12007
  reference: "https://github.com/sonicjs/sonicjs"
9964
12008
  };
9965
12009
 
9966
12010
  exports.ROUTES_INFO = ROUTES_INFO;
12011
+ exports.adminMediaRoutes = adminMediaRoutes;
9967
12012
  exports.admin_api_default = admin_api_default;
9968
12013
  exports.admin_content_default = admin_content_default;
9969
12014
  exports.api_content_crud_default = api_content_crud_default;
@@ -9972,5 +12017,5 @@ exports.api_media_default = api_media_default;
9972
12017
  exports.api_system_default = api_system_default;
9973
12018
  exports.auth_default = auth_default;
9974
12019
  exports.userRoutes = userRoutes;
9975
- //# sourceMappingURL=chunk-Y6VF3YFA.cjs.map
9976
- //# sourceMappingURL=chunk-Y6VF3YFA.cjs.map
12020
+ //# sourceMappingURL=chunk-E57DCHKC.cjs.map
12021
+ //# sourceMappingURL=chunk-E57DCHKC.cjs.map