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