- Fix JS-008: Replace raw fetch() with apiClient in letzshop-vendor-directory.js - Fix JS-005: Add init guard to letzshop-vendor-directory.js - Fix JS-004: Increase search region in validator (800→2000 chars) to detect currentPage in files with setup code before return statement - Fix JS-001: Use centralized logger in media-picker.js - Fix API-002: Move database query from onboarding.py to order_service.py - Fix FE-001: Add noqa comment to search.html (shop uses custom themed pagination) - Add audit validator to validate_all.py script - Update frontend.yaml with vendor exclusion pattern Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
308 lines
9.9 KiB
JavaScript
308 lines
9.9 KiB
JavaScript
// 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
|
|
* }
|
|
*/
|
|
|
|
// Use centralized logger
|
|
const mediaPickerLog = window.LogConfig.loggers.mediaPicker ||
|
|
window.LogConfig.createLogger('mediaPicker', false);
|
|
|
|
/**
|
|
* 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) {
|
|
mediaPickerLog.warn('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) {
|
|
mediaPickerLog.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) {
|
|
mediaPickerLog.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) {
|
|
mediaPickerLog.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;
|
|
}
|
|
mediaPickerLog.info('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
|
|
];
|
|
}
|
|
mediaPickerLog.info('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 };
|
|
}
|