feat: add media library picker for product images
- Add admin media API endpoints for vendor media management - Create reusable media_picker_modal macro in modals.html - Create mediaPickerMixin Alpine.js helper for media selection - Update product create/edit forms with media picker UI - Support main image + additional images selection - Add upload functionality within the picker modal - Update vendor_product_service to handle additional_images - Add additional_images field to Pydantic schemas 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
303
static/shared/js/media-picker.js
Normal file
303
static/shared/js/media-picker.js
Normal file
@@ -0,0 +1,303 @@
|
||||
// static/shared/js/media-picker.js
|
||||
/**
|
||||
* Media Picker Helper Functions
|
||||
*
|
||||
* Provides Alpine.js mixin for media library picker functionality.
|
||||
* Used in product create/edit forms to select images from vendor's media library.
|
||||
*
|
||||
* Usage:
|
||||
* In your Alpine component:
|
||||
* return {
|
||||
* ...mediaPickerMixin(vendorIdGetter, multiSelect),
|
||||
* // your other data/methods
|
||||
* }
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create media picker mixin for Alpine.js components
|
||||
*
|
||||
* @param {Function} vendorIdGetter - Function that returns the current vendor ID
|
||||
* @param {boolean} multiSelect - Allow selecting multiple images
|
||||
* @returns {Object} Alpine.js mixin object
|
||||
*/
|
||||
function mediaPickerMixin(vendorIdGetter, multiSelect = false) {
|
||||
return {
|
||||
// Modal visibility
|
||||
showMediaPicker: false,
|
||||
showMediaPickerAdditional: false,
|
||||
|
||||
// Picker state
|
||||
mediaPickerState: {
|
||||
loading: false,
|
||||
uploading: false,
|
||||
media: [],
|
||||
selected: [],
|
||||
total: 0,
|
||||
skip: 0,
|
||||
limit: 24,
|
||||
search: '',
|
||||
},
|
||||
|
||||
// Which picker is active (main or additional)
|
||||
activePickerTarget: 'main',
|
||||
|
||||
/**
|
||||
* Open media picker for main image
|
||||
*/
|
||||
openMediaPickerMain() {
|
||||
this.activePickerTarget = 'main';
|
||||
this.mediaPickerState.selected = [];
|
||||
this.showMediaPicker = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Open media picker for additional images
|
||||
*/
|
||||
openMediaPickerAdditional() {
|
||||
this.activePickerTarget = 'additional';
|
||||
this.mediaPickerState.selected = [];
|
||||
this.showMediaPickerAdditional = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load media library from API
|
||||
*/
|
||||
async loadMediaLibrary() {
|
||||
const vendorId = typeof vendorIdGetter === 'function' ? vendorIdGetter() : vendorIdGetter;
|
||||
|
||||
if (!vendorId) {
|
||||
console.warn('Media picker: No vendor ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
this.mediaPickerState.loading = true;
|
||||
this.mediaPickerState.skip = 0;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
skip: '0',
|
||||
limit: this.mediaPickerState.limit.toString(),
|
||||
media_type: 'image',
|
||||
});
|
||||
|
||||
if (this.mediaPickerState.search) {
|
||||
params.append('search', this.mediaPickerState.search);
|
||||
}
|
||||
|
||||
const response = await apiClient.get(
|
||||
`/admin/media/vendors/${vendorId}?${params.toString()}`
|
||||
);
|
||||
|
||||
this.mediaPickerState.media = response.media || [];
|
||||
this.mediaPickerState.total = response.total || 0;
|
||||
} catch (error) {
|
||||
console.error('Failed to load media library:', error);
|
||||
window.dispatchEvent(new CustomEvent('toast', {
|
||||
detail: { message: 'Failed to load media library', type: 'error' }
|
||||
}));
|
||||
} finally {
|
||||
this.mediaPickerState.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load more media (pagination)
|
||||
*/
|
||||
async loadMoreMedia() {
|
||||
const vendorId = typeof vendorIdGetter === 'function' ? vendorIdGetter() : vendorIdGetter;
|
||||
|
||||
if (!vendorId) return;
|
||||
|
||||
this.mediaPickerState.loading = true;
|
||||
this.mediaPickerState.skip += this.mediaPickerState.limit;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
skip: this.mediaPickerState.skip.toString(),
|
||||
limit: this.mediaPickerState.limit.toString(),
|
||||
media_type: 'image',
|
||||
});
|
||||
|
||||
if (this.mediaPickerState.search) {
|
||||
params.append('search', this.mediaPickerState.search);
|
||||
}
|
||||
|
||||
const response = await apiClient.get(
|
||||
`/admin/media/vendors/${vendorId}?${params.toString()}`
|
||||
);
|
||||
|
||||
this.mediaPickerState.media = [
|
||||
...this.mediaPickerState.media,
|
||||
...(response.media || [])
|
||||
];
|
||||
} catch (error) {
|
||||
console.error('Failed to load more media:', error);
|
||||
} finally {
|
||||
this.mediaPickerState.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload a new media file
|
||||
*/
|
||||
async uploadMediaFile(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const vendorId = typeof vendorIdGetter === 'function' ? vendorIdGetter() : vendorIdGetter;
|
||||
|
||||
if (!vendorId) {
|
||||
window.dispatchEvent(new CustomEvent('toast', {
|
||||
detail: { message: 'Please select a vendor first', type: 'error' }
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
window.dispatchEvent(new CustomEvent('toast', {
|
||||
detail: { message: 'Please select an image file', type: 'error' }
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (10MB max)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
window.dispatchEvent(new CustomEvent('toast', {
|
||||
detail: { message: 'Image must be less than 10MB', type: 'error' }
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
this.mediaPickerState.uploading = true;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await apiClient.postFormData(
|
||||
`/admin/media/vendors/${vendorId}/upload?folder=products`,
|
||||
formData
|
||||
);
|
||||
|
||||
if (response.success && response.media) {
|
||||
// Add to beginning of media list
|
||||
this.mediaPickerState.media.unshift(response.media);
|
||||
this.mediaPickerState.total++;
|
||||
|
||||
// Auto-select the uploaded image
|
||||
this.toggleMediaSelection(response.media);
|
||||
|
||||
window.dispatchEvent(new CustomEvent('toast', {
|
||||
detail: { message: 'Image uploaded successfully', type: 'success' }
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image:', error);
|
||||
window.dispatchEvent(new CustomEvent('toast', {
|
||||
detail: { message: error.message || 'Failed to upload image', type: 'error' }
|
||||
}));
|
||||
} finally {
|
||||
this.mediaPickerState.uploading = false;
|
||||
// Clear the file input
|
||||
event.target.value = '';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle media selection
|
||||
*/
|
||||
toggleMediaSelection(media) {
|
||||
const index = this.mediaPickerState.selected.findIndex(m => m.id === media.id);
|
||||
|
||||
if (index > -1) {
|
||||
// Deselect
|
||||
this.mediaPickerState.selected.splice(index, 1);
|
||||
} else {
|
||||
if (multiSelect) {
|
||||
// Multi-select: add to selection
|
||||
this.mediaPickerState.selected.push(media);
|
||||
} else {
|
||||
// Single-select: replace selection
|
||||
this.mediaPickerState.selected = [media];
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if media is selected
|
||||
*/
|
||||
isMediaSelected(mediaId) {
|
||||
return this.mediaPickerState.selected.some(m => m.id === mediaId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Confirm selection and call the appropriate callback
|
||||
*/
|
||||
confirmMediaSelection() {
|
||||
const selected = this.mediaPickerState.selected;
|
||||
|
||||
if (selected.length === 0) return;
|
||||
|
||||
if (this.activePickerTarget === 'main') {
|
||||
// Main image: use first selected
|
||||
this.setMainImage(selected[0]);
|
||||
this.showMediaPicker = false;
|
||||
} else {
|
||||
// Additional images: add all selected
|
||||
this.addAdditionalImages(selected);
|
||||
this.showMediaPickerAdditional = false;
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
this.mediaPickerState.selected = [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the main image (override in your component)
|
||||
*/
|
||||
setMainImage(media) {
|
||||
if (this.form) {
|
||||
this.form.primary_image_url = media.url;
|
||||
}
|
||||
console.log('Main image set:', media.url);
|
||||
},
|
||||
|
||||
/**
|
||||
* Add additional images (override in your component)
|
||||
*/
|
||||
addAdditionalImages(mediaList) {
|
||||
if (this.form && Array.isArray(this.form.additional_images)) {
|
||||
const newUrls = mediaList.map(m => m.url);
|
||||
this.form.additional_images = [
|
||||
...this.form.additional_images,
|
||||
...newUrls
|
||||
];
|
||||
}
|
||||
console.log('Additional images added:', mediaList.map(m => m.url));
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove an additional image by index
|
||||
*/
|
||||
removeAdditionalImage(index) {
|
||||
if (this.form && Array.isArray(this.form.additional_images)) {
|
||||
this.form.additional_images.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the main image
|
||||
*/
|
||||
clearMainImage() {
|
||||
if (this.form) {
|
||||
this.form.primary_image_url = '';
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Export for module systems
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { mediaPickerMixin };
|
||||
}
|
||||
Reference in New Issue
Block a user