|
|
@@ -0,0 +1,510 @@
|
|
|
+<!-- Media Manager Component for TinyMCE -->
|
|
|
+<script type="text/x-template" id="v-media-manager-template">
|
|
|
+ <div>
|
|
|
+ <!-- Media Manager Modal -->
|
|
|
+ <x-admin::modal ref="mediaManagerModal">
|
|
|
+ <!-- Modal Header -->
|
|
|
+ <x-slot:header>
|
|
|
+ <p class="flex items-center gap-2.5 text-lg font-bold text-gray-800 dark:text-white">
|
|
|
+ <span class="icon-image text-2xl text-gray-800"></span>
|
|
|
+ @lang('imageupload::app.admin.media-manager.title')
|
|
|
+ </p>
|
|
|
+ </x-slot>
|
|
|
+
|
|
|
+ <!-- Modal Content -->
|
|
|
+ <x-slot:content>
|
|
|
+ <div class="flex gap-4 h-[500px]">
|
|
|
+ <!-- Left Sidebar: Folder Tree -->
|
|
|
+ <div class="w-56 border-r border-gray-200 dark:border-gray-600 pr-4 flex flex-col">
|
|
|
+ <!-- Create Folder Button -->
|
|
|
+ <div class="mb-3">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="primary-button w-full text-sm py-1.5"
|
|
|
+ @click="showCreateFolder = true"
|
|
|
+ >
|
|
|
+ <span class="icon-folder text-sm"></span>
|
|
|
+ @lang('imageupload::app.admin.media-manager.create_folder')
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Create Folder Input -->
|
|
|
+ <div v-if="showCreateFolder" class="mb-3">
|
|
|
+ <div class="flex gap-1">
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ v-model="newFolderName"
|
|
|
+ placeholder="@lang('imageupload::app.admin.media-manager.folder_name_placeholder')"
|
|
|
+ class="flex-1 border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-sm dark:bg-gray-700 dark:text-white"
|
|
|
+ @keyup.enter="createFolder"
|
|
|
+ />
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="primary-button text-xs px-2 py-1"
|
|
|
+ @click="createFolder"
|
|
|
+ :disabled="!newFolderName.trim()"
|
|
|
+ >
|
|
|
+ @lang('imageupload::app.admin.media-manager.create')
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="text-xs text-gray-500 hover:text-gray-700 mt-1"
|
|
|
+ @click="showCreateFolder = false; newFolderName = ''"
|
|
|
+ >
|
|
|
+ @lang('imageupload::app.admin.media-manager.cancel')
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Folder List -->
|
|
|
+ <div class="flex-1 overflow-y-auto">
|
|
|
+ <p class="text-xs font-semibold text-gray-500 uppercase mb-2">
|
|
|
+ @lang('imageupload::app.admin.media-manager.all_folders')
|
|
|
+ </p>
|
|
|
+
|
|
|
+ <!-- Sub Folders -->
|
|
|
+ <div
|
|
|
+ v-for="folder in folders"
|
|
|
+ :key="folder.id"
|
|
|
+ class="flex items-center justify-between px-2 py-1.5 rounded cursor-pointer text-sm mb-0.5"
|
|
|
+ :class="currentFolderId === folder.id ? 'bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-200' : 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'"
|
|
|
+ @click="selectFolder(folder.id)"
|
|
|
+ >
|
|
|
+ <span class="flex items-center gap-1.5 truncate flex-1">
|
|
|
+ <span class="icon-folder text-sm"></span>
|
|
|
+ <span class="truncate">@{{ folder.name }}</span>
|
|
|
+ </span>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="text-red-500 hover:text-red-700 ml-1 text-xs px-1 py-0.5 rounded hover:bg-red-50"
|
|
|
+ @click.stop="confirmDeleteFolder(folder)"
|
|
|
+ title="@lang('imageupload::app.admin.media-manager.delete')"
|
|
|
+ >
|
|
|
+ 🗑
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <p v-if="folders.length === 0" class="text-xs text-gray-400 py-2">
|
|
|
+ @lang('imageupload::app.admin.media-manager.no_folders')
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Right Side: Image Browser & Upload -->
|
|
|
+ <div class="flex-1 flex flex-col">
|
|
|
+ <!-- Upload Area -->
|
|
|
+ <div
|
|
|
+ class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-6 mb-4 text-center cursor-pointer transition-colors hover:border-blue-400"
|
|
|
+ :class="{ 'border-blue-400 bg-blue-50 dark:bg-blue-900/20': isDragOver }"
|
|
|
+ @dragover.prevent="isDragOver = true"
|
|
|
+ @dragleave.prevent="isDragOver = false"
|
|
|
+ @drop.prevent="handleDrop"
|
|
|
+ @click="$refs.fileInput.click()"
|
|
|
+ >
|
|
|
+ <input
|
|
|
+ ref="fileInput"
|
|
|
+ type="file"
|
|
|
+ accept="image/*"
|
|
|
+ multiple
|
|
|
+ class="hidden"
|
|
|
+ @change="handleFileSelect"
|
|
|
+ />
|
|
|
+
|
|
|
+ <div v-if="uploading">
|
|
|
+ <div class="flex items-center justify-center gap-2 text-blue-600">
|
|
|
+ <span class="animate-spin inline-block w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full"></span>
|
|
|
+ @lang('imageupload::app.admin.media-manager.uploading')
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-else>
|
|
|
+ <span class="icon-upload text-3xl text-gray-400 block mb-2"></span>
|
|
|
+ <p class="text-sm text-gray-500 dark:text-gray-400">
|
|
|
+ @lang('imageupload::app.admin.media-manager.drag_or_click')
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Image Grid -->
|
|
|
+ <div class="flex-1 overflow-y-auto">
|
|
|
+ <div v-if="loading" class="flex items-center justify-center h-full">
|
|
|
+ <span class="animate-spin inline-block w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full"></span>
|
|
|
+ <span class="ml-2 text-sm text-gray-500">@lang('imageupload::app.admin.media-manager.loading')</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else-if="images.length === 0" class="flex items-center justify-center h-full">
|
|
|
+ <p class="text-sm text-gray-400">@lang('imageupload::app.admin.media-manager.no_images')</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else class="grid grid-cols-4 gap-3">
|
|
|
+ <div
|
|
|
+ v-for="image in images"
|
|
|
+ :key="image.path"
|
|
|
+ class="relative group border border-gray-200 dark:border-gray-600 rounded-lg overflow-hidden cursor-pointer aspect-square"
|
|
|
+ :class="{ 'ring-2 ring-blue-500': selectedImage && selectedImage.path === image.path }"
|
|
|
+ @click="selectImage(image)"
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ :src="image.url"
|
|
|
+ :alt="image.name"
|
|
|
+ class="w-full h-full object-cover"
|
|
|
+ />
|
|
|
+ <!-- Delete Button -->
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="absolute top-1 right-1 bg-red-500 hover:bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs"
|
|
|
+ @click.stop="confirmDeleteImage(image)"
|
|
|
+ title="@lang('imageupload::app.admin.media-manager.delete')"
|
|
|
+ >
|
|
|
+ ✕
|
|
|
+ </button>
|
|
|
+ <!-- Selected Check -->
|
|
|
+ <div
|
|
|
+ v-if="selectedImage && selectedImage.path === image.path"
|
|
|
+ class="absolute top-1 right-1 bg-blue-500 text-white rounded-full w-5 h-5 flex items-center justify-center"
|
|
|
+ >
|
|
|
+ <span class="text-xs">✓</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </x-slot>
|
|
|
+
|
|
|
+ <!-- Modal Footer -->
|
|
|
+ <x-slot:footer>
|
|
|
+ <div class="flex items-center justify-between w-full">
|
|
|
+ <p v-if="selectedImage" class="text-sm text-gray-600 dark:text-gray-400 truncate max-w-xs">
|
|
|
+ @lang('imageupload::app.admin.media-manager.selected'): @{{ selectedImage.name }}
|
|
|
+ </p>
|
|
|
+ <div class="flex gap-2">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="secondary-button"
|
|
|
+ @click="close"
|
|
|
+ >
|
|
|
+ @lang('imageupload::app.admin.media-manager.cancel')
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="primary-button"
|
|
|
+ :disabled="!selectedImage"
|
|
|
+ @click="insertImage"
|
|
|
+ >
|
|
|
+ @lang('imageupload::app.admin.media-manager.insert')
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </x-slot>
|
|
|
+ </x-admin::modal>
|
|
|
+ </div>
|
|
|
+</script>
|
|
|
+
|
|
|
+<script type="module">
|
|
|
+ app.component('v-media-manager', {
|
|
|
+ template: '#v-media-manager-template',
|
|
|
+
|
|
|
+ props: {
|
|
|
+ onInsert: {
|
|
|
+ type: Function,
|
|
|
+ default: null,
|
|
|
+ },
|
|
|
+ },
|
|
|
+
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ folders: [],
|
|
|
+ images: [],
|
|
|
+ currentFolderId: 0,
|
|
|
+ currentFolderName: '',
|
|
|
+ selectedImage: null,
|
|
|
+ showCreateFolder: false,
|
|
|
+ newFolderName: '',
|
|
|
+ isDragOver: false,
|
|
|
+ uploading: false,
|
|
|
+ loading: false,
|
|
|
+ };
|
|
|
+ },
|
|
|
+
|
|
|
+ methods: {
|
|
|
+ open() {
|
|
|
+ this.$refs.mediaManagerModal.open();
|
|
|
+ this.loadFolders();
|
|
|
+ this.loadImages();
|
|
|
+ },
|
|
|
+
|
|
|
+ close() {
|
|
|
+ this.$refs.mediaManagerModal.close();
|
|
|
+ this.selectedImage = null;
|
|
|
+ this.showCreateFolder = false;
|
|
|
+ this.newFolderName = '';
|
|
|
+ },
|
|
|
+
|
|
|
+ toggle() {
|
|
|
+ this.$refs.mediaManagerModal.toggle();
|
|
|
+ if (this.$refs.mediaManagerModal.isOpen) {
|
|
|
+ this.loadFolders();
|
|
|
+ this.loadImages();
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // Load folders from API (database)
|
|
|
+ async loadFolders() {
|
|
|
+ try {
|
|
|
+ const response = await fetch('{{ route("admin.image_upload.folders") }}', {
|
|
|
+ headers: {
|
|
|
+ 'Accept': 'application/json',
|
|
|
+ 'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
|
+ },
|
|
|
+ });
|
|
|
+ const data = await response.json();
|
|
|
+ if (data.success) {
|
|
|
+ this.folders = data.data.folders || [];
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Failed to load folders:', error);
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // Load images from current folder (database)
|
|
|
+ async loadImages() {
|
|
|
+ this.loading = true;
|
|
|
+ try {
|
|
|
+ const url = new URL('{{ route("admin.image_upload.images") }}', window.location.origin);
|
|
|
+ url.searchParams.set('folder_id', this.currentFolderId);
|
|
|
+
|
|
|
+ const response = await fetch(url.toString(), {
|
|
|
+ headers: {
|
|
|
+ 'Accept': 'application/json',
|
|
|
+ 'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
|
+ },
|
|
|
+ });
|
|
|
+ const data = await response.json();
|
|
|
+ if (data.success) {
|
|
|
+ this.images = data.data || [];
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Failed to load images:', error);
|
|
|
+ } finally {
|
|
|
+ this.loading = false;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // Select a folder by id
|
|
|
+ selectFolder(folderId) {
|
|
|
+ this.currentFolderId = folderId;
|
|
|
+ this.selectedImage = null;
|
|
|
+ if (folderId === 0) {
|
|
|
+ this.currentFolderName = '';
|
|
|
+ } else {
|
|
|
+ const folder = this.folders.find(f => f.id === folderId);
|
|
|
+ this.currentFolderName = folder ? folder.name : '';
|
|
|
+ }
|
|
|
+ this.loadImages();
|
|
|
+ },
|
|
|
+
|
|
|
+ // Create a new folder (database only)
|
|
|
+ async createFolder() {
|
|
|
+ if (!this.newFolderName.trim()) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await fetch('{{ route("admin.image_upload.folder.create") }}', {
|
|
|
+ method: 'POST',
|
|
|
+ headers: {
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ 'Accept': 'application/json',
|
|
|
+ 'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
|
+ },
|
|
|
+ body: JSON.stringify({
|
|
|
+ folder_name: this.newFolderName.trim(),
|
|
|
+ parent_folder: '',
|
|
|
+ }),
|
|
|
+ });
|
|
|
+
|
|
|
+ const data = await response.json();
|
|
|
+ if (data.success) {
|
|
|
+ this.$emitter.emit('add-flash', {
|
|
|
+ type: 'success',
|
|
|
+ message: '@lang('imageupload::app.admin.media-manager.folder_created')',
|
|
|
+ });
|
|
|
+ this.newFolderName = '';
|
|
|
+ this.showCreateFolder = false;
|
|
|
+ await this.loadFolders();
|
|
|
+ } else {
|
|
|
+ this.$emitter.emit('add-flash', {
|
|
|
+ type: 'error',
|
|
|
+ message: data.message || 'Failed to create folder',
|
|
|
+ });
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Failed to create folder:', error);
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // Confirm delete folder
|
|
|
+ confirmDeleteFolder(folder) {
|
|
|
+ if (!confirm('@lang('imageupload::app.admin.media-manager.confirm_delete_folder')')) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.deleteFolder(folder);
|
|
|
+ },
|
|
|
+
|
|
|
+ // Delete folder (database + S3 images)
|
|
|
+ async deleteFolder(folder) {
|
|
|
+ try {
|
|
|
+ const response = await fetch('{{ route("admin.image_upload.folder.delete") }}', {
|
|
|
+ method: 'DELETE',
|
|
|
+ headers: {
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ 'Accept': 'application/json',
|
|
|
+ 'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
|
+ },
|
|
|
+ body: JSON.stringify({
|
|
|
+ folder_id: folder.id,
|
|
|
+ }),
|
|
|
+ });
|
|
|
+
|
|
|
+ const data = await response.json();
|
|
|
+ if (data.success) {
|
|
|
+ this.$emitter.emit('add-flash', {
|
|
|
+ type: 'success',
|
|
|
+ message: '@lang('imageupload::app.admin.media-manager.folder_deleted')',
|
|
|
+ });
|
|
|
+
|
|
|
+ if (this.currentFolderId === folder.id) {
|
|
|
+ this.currentFolderId = 0;
|
|
|
+ this.currentFolderName = '';
|
|
|
+ }
|
|
|
+ await this.loadFolders();
|
|
|
+ await this.loadImages();
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Failed to delete folder:', error);
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // Select an image
|
|
|
+ selectImage(image) {
|
|
|
+ if (this.selectedImage && this.selectedImage.path === image.path) {
|
|
|
+ this.selectedImage = null;
|
|
|
+ } else {
|
|
|
+ this.selectedImage = image;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // Confirm delete image
|
|
|
+ confirmDeleteImage(image) {
|
|
|
+ if (!confirm('@lang('imageupload::app.admin.media-manager.confirm_delete_image')')) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.deleteImage(image);
|
|
|
+ },
|
|
|
+
|
|
|
+ // Delete image (S3 + database)
|
|
|
+ async deleteImage(image) {
|
|
|
+ try {
|
|
|
+ const response = await fetch('{{ route("admin.image_upload.delete") }}', {
|
|
|
+ method: 'DELETE',
|
|
|
+ headers: {
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ 'Accept': 'application/json',
|
|
|
+ 'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
|
+ },
|
|
|
+ body: JSON.stringify({
|
|
|
+ path: image.path,
|
|
|
+ }),
|
|
|
+ });
|
|
|
+
|
|
|
+ const data = await response.json();
|
|
|
+ if (data.success) {
|
|
|
+ this.$emitter.emit('add-flash', {
|
|
|
+ type: 'success',
|
|
|
+ message: '@lang('imageupload::app.admin.media-manager.image_deleted')',
|
|
|
+ });
|
|
|
+
|
|
|
+ if (this.selectedImage && this.selectedImage.path === image.path) {
|
|
|
+ this.selectedImage = null;
|
|
|
+ }
|
|
|
+ await this.loadImages();
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Failed to delete image:', error);
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // Handle file drop
|
|
|
+ handleDrop(event) {
|
|
|
+ this.isDragOver = false;
|
|
|
+ const files = Array.from(event.dataTransfer.files);
|
|
|
+ this.uploadFiles(files);
|
|
|
+ },
|
|
|
+
|
|
|
+ // Handle file select
|
|
|
+ handleFileSelect(event) {
|
|
|
+ const files = Array.from(event.target.files);
|
|
|
+ this.uploadFiles(files);
|
|
|
+ event.target.value = '';
|
|
|
+ },
|
|
|
+
|
|
|
+ // Upload files to S3, record in database
|
|
|
+ async uploadFiles(files) {
|
|
|
+ if (files.length === 0) return;
|
|
|
+
|
|
|
+ this.uploading = true;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const formData = new FormData();
|
|
|
+ files.forEach(file => {
|
|
|
+ formData.append('images[]', file);
|
|
|
+ });
|
|
|
+
|
|
|
+ // 传当前文件夹名作为 directory,后端会用它在 image_uploads 表关联 folder_id
|
|
|
+ if (this.currentFolderId > 0 && this.currentFolderName) {
|
|
|
+ formData.append('directory', this.currentFolderName);
|
|
|
+ }
|
|
|
+
|
|
|
+ const response = await fetch('{{ route("admin.image_upload.upload_multiple") }}', {
|
|
|
+ method: 'POST',
|
|
|
+ body: formData,
|
|
|
+ headers: {
|
|
|
+ 'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ const data = await response.json();
|
|
|
+ if (data.success) {
|
|
|
+ this.$emitter.emit('add-flash', {
|
|
|
+ type: 'success',
|
|
|
+ message: '@lang('imageupload::app.admin.media-manager.upload_success')',
|
|
|
+ });
|
|
|
+ await this.loadImages();
|
|
|
+ } else {
|
|
|
+ this.$emitter.emit('add-flash', {
|
|
|
+ type: 'error',
|
|
|
+ message: data.message || 'Upload failed',
|
|
|
+ });
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Upload failed:', error);
|
|
|
+ this.$emitter.emit('add-flash', {
|
|
|
+ type: 'error',
|
|
|
+ message: 'Upload failed',
|
|
|
+ });
|
|
|
+ } finally {
|
|
|
+ this.uploading = false;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // Insert selected image into TinyMCE
|
|
|
+ insertImage() {
|
|
|
+ if (!this.selectedImage) return;
|
|
|
+
|
|
|
+ if (this.onInsert) {
|
|
|
+ this.onInsert(this.selectedImage.url);
|
|
|
+ }
|
|
|
+
|
|
|
+ this.close();
|
|
|
+ },
|
|
|
+ },
|
|
|
+ });
|
|
|
+</script>
|