feat: implement product search, media library, and vendor customers
- Add full-text product search in ProductService.search_products() searching titles, descriptions, SKUs, brands, and GTINs - Implement complete vendor media library with file uploads, thumbnails, folders, and product associations - Implement vendor customers API with listing, details, orders, statistics, and status management - Add shop search results UI with pagination and add-to-cart - Add vendor media library UI with drag-drop upload and grid view - Add database migration for media_files and product_media tables - Update TODO file with current launch status (~95% complete) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
289
static/vendor/js/media.js
vendored
Normal file
289
static/vendor/js/media.js
vendored
Normal file
@@ -0,0 +1,289 @@
|
||||
// static/vendor/js/media.js
|
||||
/**
|
||||
* Vendor media library management page logic
|
||||
* Upload and manage images, videos, and documents
|
||||
*/
|
||||
|
||||
const vendorMediaLog = window.LogConfig.loggers.vendorMedia ||
|
||||
window.LogConfig.createLogger('vendorMedia', false);
|
||||
|
||||
vendorMediaLog.info('Loading...');
|
||||
|
||||
function vendorMedia() {
|
||||
vendorMediaLog.info('vendorMedia() called');
|
||||
|
||||
return {
|
||||
// Inherit base layout state
|
||||
...data(),
|
||||
|
||||
// Set page identifier
|
||||
currentPage: 'media',
|
||||
|
||||
// Loading states
|
||||
loading: true,
|
||||
error: '',
|
||||
saving: false,
|
||||
|
||||
// Media data
|
||||
media: [],
|
||||
stats: {
|
||||
total: 0,
|
||||
images: 0,
|
||||
videos: 0,
|
||||
documents: 0
|
||||
},
|
||||
|
||||
// Filters
|
||||
filters: {
|
||||
search: '',
|
||||
type: '',
|
||||
folder: ''
|
||||
},
|
||||
|
||||
// Pagination
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 24,
|
||||
total: 0,
|
||||
pages: 0
|
||||
},
|
||||
|
||||
// Modal states
|
||||
showUploadModal: false,
|
||||
showDetailModal: false,
|
||||
selectedMedia: null,
|
||||
editingMedia: {
|
||||
filename: '',
|
||||
alt_text: '',
|
||||
description: '',
|
||||
folder: ''
|
||||
},
|
||||
|
||||
// Upload states
|
||||
isDragging: false,
|
||||
uploadFolder: 'general',
|
||||
uploadingFiles: [],
|
||||
|
||||
async init() {
|
||||
vendorMediaLog.info('Initializing media library...');
|
||||
await this.loadMedia();
|
||||
},
|
||||
|
||||
async loadMedia() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
skip: (this.pagination.page - 1) * this.pagination.per_page,
|
||||
limit: this.pagination.per_page
|
||||
});
|
||||
|
||||
if (this.filters.search) {
|
||||
params.append('search', this.filters.search);
|
||||
}
|
||||
if (this.filters.type) {
|
||||
params.append('media_type', this.filters.type);
|
||||
}
|
||||
if (this.filters.folder) {
|
||||
params.append('folder', this.filters.folder);
|
||||
}
|
||||
|
||||
vendorMediaLog.info(`Loading media: /api/v1/vendor/media?${params}`);
|
||||
|
||||
const response = await apiClient.get(`/vendor/media?${params.toString()}`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = response.data;
|
||||
this.media = data.media || [];
|
||||
this.pagination.total = data.total || 0;
|
||||
this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page);
|
||||
|
||||
// Update stats
|
||||
await this.loadStats();
|
||||
|
||||
vendorMediaLog.info(`Loaded ${this.media.length} media files`);
|
||||
} else {
|
||||
throw new Error(response.message || 'Failed to load media');
|
||||
}
|
||||
} catch (err) {
|
||||
vendorMediaLog.error('Failed to load media:', err);
|
||||
this.error = err.message || 'Failed to load media library';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadStats() {
|
||||
// Calculate stats from loaded media (simplified)
|
||||
// In production, you might have a separate stats endpoint
|
||||
try {
|
||||
// Get all media without pagination for stats
|
||||
const allResponse = await apiClient.get('/vendor/media?limit=1000');
|
||||
if (allResponse.ok) {
|
||||
const allMedia = allResponse.data.media || [];
|
||||
this.stats.total = allResponse.data.total || 0;
|
||||
this.stats.images = allMedia.filter(m => m.media_type === 'image').length;
|
||||
this.stats.videos = allMedia.filter(m => m.media_type === 'video').length;
|
||||
this.stats.documents = allMedia.filter(m => m.media_type === 'document').length;
|
||||
}
|
||||
} catch (err) {
|
||||
vendorMediaLog.warn('Could not load stats:', err);
|
||||
}
|
||||
},
|
||||
|
||||
selectMedia(item) {
|
||||
this.selectedMedia = item;
|
||||
this.editingMedia = {
|
||||
filename: item.original_filename || item.filename,
|
||||
alt_text: item.alt_text || '',
|
||||
description: item.description || '',
|
||||
folder: item.folder || 'general'
|
||||
};
|
||||
this.showDetailModal = true;
|
||||
},
|
||||
|
||||
async saveMediaDetails() {
|
||||
if (!this.selectedMedia) return;
|
||||
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
const response = await apiClient.put(`/vendor/media/${this.selectedMedia.id}`, {
|
||||
filename: this.editingMedia.filename,
|
||||
alt_text: this.editingMedia.alt_text,
|
||||
description: this.editingMedia.description,
|
||||
folder: this.editingMedia.folder
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.showToast('Media updated successfully', 'success');
|
||||
this.showDetailModal = false;
|
||||
await this.loadMedia();
|
||||
} else {
|
||||
throw new Error(response.message || 'Failed to update media');
|
||||
}
|
||||
} catch (err) {
|
||||
vendorMediaLog.error('Failed to save media:', err);
|
||||
this.showToast(err.message || 'Failed to save changes', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteMedia() {
|
||||
if (!this.selectedMedia) return;
|
||||
|
||||
if (!confirm('Are you sure you want to delete this file? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
const response = await apiClient.delete(`/vendor/media/${this.selectedMedia.id}`);
|
||||
|
||||
if (response.ok) {
|
||||
this.showToast('Media deleted successfully', 'success');
|
||||
this.showDetailModal = false;
|
||||
this.selectedMedia = null;
|
||||
await this.loadMedia();
|
||||
} else {
|
||||
throw new Error(response.message || 'Failed to delete media');
|
||||
}
|
||||
} catch (err) {
|
||||
vendorMediaLog.error('Failed to delete media:', err);
|
||||
this.showToast(err.message || 'Failed to delete media', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
handleDrop(event) {
|
||||
this.isDragging = false;
|
||||
const files = event.dataTransfer.files;
|
||||
if (files.length) {
|
||||
this.uploadFiles(files);
|
||||
}
|
||||
},
|
||||
|
||||
handleFileSelect(event) {
|
||||
const files = event.target.files;
|
||||
if (files.length) {
|
||||
this.uploadFiles(files);
|
||||
}
|
||||
// Reset input
|
||||
event.target.value = '';
|
||||
},
|
||||
|
||||
async uploadFiles(files) {
|
||||
vendorMediaLog.info(`Uploading ${files.length} files...`);
|
||||
|
||||
for (const file of files) {
|
||||
const uploadItem = {
|
||||
name: file.name,
|
||||
status: 'uploading',
|
||||
error: null
|
||||
};
|
||||
this.uploadingFiles.push(uploadItem);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(`/api/v1/vendor/media/upload?folder=${this.uploadFolder}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('vendor_token')}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
uploadItem.status = 'success';
|
||||
vendorMediaLog.info(`Uploaded: ${file.name}`);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
uploadItem.status = 'error';
|
||||
uploadItem.error = errorData.detail || 'Upload failed';
|
||||
vendorMediaLog.error(`Upload failed for ${file.name}:`, errorData);
|
||||
}
|
||||
} catch (err) {
|
||||
uploadItem.status = 'error';
|
||||
uploadItem.error = err.message || 'Upload failed';
|
||||
vendorMediaLog.error(`Upload error for ${file.name}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh media list after all uploads
|
||||
await this.loadMedia();
|
||||
|
||||
// Clear upload list after a delay
|
||||
setTimeout(() => {
|
||||
this.uploadingFiles = this.uploadingFiles.filter(f => f.status === 'uploading');
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0;
|
||||
while (bytes >= 1024 && i < units.length - 1) {
|
||||
bytes /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${bytes.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
},
|
||||
|
||||
copyToClipboard(text) {
|
||||
if (!text) return;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
this.showToast('URL copied to clipboard', 'success');
|
||||
}).catch(() => {
|
||||
this.showToast('Failed to copy URL', 'error');
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
vendorMediaLog.info('Loaded successfully');
|
||||
Reference in New Issue
Block a user