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