From 5271ecb378e06626f6de595d440a1e407e256107 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Thu, 8 Jan 2026 02:16:55 +0100 Subject: [PATCH] feat: add media library picker for product images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/api/v1/admin/__init__.py | 4 + app/api/v1/admin/media.py | 138 ++++++++ app/services/vendor_product_service.py | 2 + .../admin/vendor-product-create.html | 118 ++++++- app/templates/admin/vendor-product-edit.html | 115 +++++-- app/templates/shared/macros/modals.html | 213 ++++++++++++ models/schema/vendor_product.py | 9 +- static/admin/js/vendor-product-create.js | 172 +++++++++- static/admin/js/vendor-product-edit.js | 175 +++++++++- static/shared/js/media-picker.js | 303 ++++++++++++++++++ 10 files changed, 1207 insertions(+), 42 deletions(-) create mode 100644 app/api/v1/admin/media.py create mode 100644 static/shared/js/media-picker.js diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py index f2ff84ba..feaaaf7c 100644 --- a/app/api/v1/admin/__init__.py +++ b/app/api/v1/admin/__init__.py @@ -40,6 +40,7 @@ from . import ( letzshop, logs, marketplace, + media, messages, monitoring, notifications, @@ -173,6 +174,9 @@ router.include_router(logs.router, tags=["admin-logs"]) # Include image management endpoints router.include_router(images.router, tags=["admin-images"]) +# Include media library management endpoints +router.include_router(media.router, tags=["admin-media"]) + # Include platform health endpoints router.include_router( platform_health.router, prefix="/platform", tags=["admin-platform-health"] diff --git a/app/api/v1/admin/media.py b/app/api/v1/admin/media.py new file mode 100644 index 00000000..b53badbe --- /dev/null +++ b/app/api/v1/admin/media.py @@ -0,0 +1,138 @@ +# app/api/v1/admin/media.py +""" +Admin media management endpoints for vendor media libraries. + +Allows admins to manage media files on behalf of vendors. +""" + +import logging + +from fastapi import APIRouter, Depends, File, Query, UploadFile +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_api +from app.core.database import get_db +from app.services.media_service import media_service +from models.database.user import User +from models.schema.media import ( + MediaDetailResponse, + MediaItemResponse, + MediaListResponse, + MediaUploadResponse, +) + +router = APIRouter(prefix="/media") +logger = logging.getLogger(__name__) + + +@router.get("/vendors/{vendor_id}", response_model=MediaListResponse) +def get_vendor_media_library( + vendor_id: int, + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=1000), + media_type: str | None = Query(None, description="image, video, document"), + folder: str | None = Query(None, description="Filter by folder"), + search: str | None = Query(None), + current_admin: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + Get media library for a specific vendor. + + Admin can browse any vendor's media library. + """ + media_files, total = media_service.get_media_library( + db=db, + vendor_id=vendor_id, + skip=skip, + limit=limit, + media_type=media_type, + folder=folder, + search=search, + ) + + return MediaListResponse( + media=[MediaItemResponse.model_validate(m) for m in media_files], + total=total, + skip=skip, + limit=limit, + ) + + +@router.post("/vendors/{vendor_id}/upload", response_model=MediaUploadResponse) +async def upload_vendor_media( + vendor_id: int, + file: UploadFile = File(...), + folder: str | None = Query("products", description="products, general, etc."), + current_admin: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + Upload media file for a specific vendor. + + Admin can upload media on behalf of any vendor. + Files are stored in vendor-specific directories. + """ + # Read file content + file_content = await file.read() + + # Upload using service + media_file = await media_service.upload_file( + db=db, + vendor_id=vendor_id, + file_content=file_content, + filename=file.filename or "unnamed", + folder=folder or "products", + ) + + logger.info(f"Admin uploaded media for vendor {vendor_id}: {media_file.id}") + + return MediaUploadResponse( + success=True, + message="File uploaded successfully", + media=MediaItemResponse.model_validate(media_file), + ) + + +@router.get("/vendors/{vendor_id}/{media_id}", response_model=MediaDetailResponse) +def get_vendor_media_detail( + vendor_id: int, + media_id: int, + current_admin: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + Get detailed info about a specific media file. + """ + media_file = media_service.get_media_by_id(db=db, media_id=media_id) + + # Verify media belongs to the specified vendor + if media_file.vendor_id != vendor_id: + from app.exceptions.media import MediaNotFoundException + raise MediaNotFoundException(media_id) + + return MediaDetailResponse.model_validate(media_file) + + +@router.delete("/vendors/{vendor_id}/{media_id}") +def delete_vendor_media( + vendor_id: int, + media_id: int, + current_admin: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """ + Delete a media file for a vendor. + """ + media_file = media_service.get_media_by_id(db=db, media_id=media_id) + + # Verify media belongs to the specified vendor + if media_file.vendor_id != vendor_id: + from app.exceptions.media import MediaNotFoundException + raise MediaNotFoundException(media_id) + + media_service.delete_media(db=db, media_id=media_id) + + logger.info(f"Admin deleted media {media_id} for vendor {vendor_id}") + + return {"success": True, "message": "Media deleted successfully"} diff --git a/app/services/vendor_product_service.py b/app/services/vendor_product_service.py index acf19372..2ee336f0 100644 --- a/app/services/vendor_product_service.py +++ b/app/services/vendor_product_service.py @@ -286,6 +286,7 @@ class VendorProductService: tax_rate_percent=data.get("tax_rate_percent", 17), availability=data.get("availability"), primary_image_url=data.get("primary_image_url"), + additional_images=data.get("additional_images"), is_active=data.get("is_active", True), is_featured=data.get("is_featured", False), is_digital=is_digital, @@ -399,6 +400,7 @@ class VendorProductService: "is_active", "is_featured", "primary_image_url", + "additional_images", "supplier", ] diff --git a/app/templates/admin/vendor-product-create.html b/app/templates/admin/vendor-product-create.html index cea076e8..ac3155c2 100644 --- a/app/templates/admin/vendor-product-create.html +++ b/app/templates/admin/vendor-product-create.html @@ -1,6 +1,7 @@ {# app/templates/admin/vendor-product-create.html #} {% extends "admin/base.html" %} {% from 'shared/macros/headers.html' import detail_page_header %} +{% from 'shared/macros/modals.html' import media_picker_modal %} {% block title %}Create Vendor Product{% endblock %} @@ -252,35 +253,97 @@ - +

- Primary Image + Product Images

-
-
- - -
-
- -
+ + +
+ +
+ +
+ + +
+ +

Or enter URL directly:

+ +
+ + +
+ +
+ + + + + +
+

Click "Add" to select images from the media library or upload new ones

+
@@ -334,6 +397,26 @@
+ + +{{ media_picker_modal( + id='mediaPickerMain', + show_var='showMediaPicker', + vendor_id_var='form.vendor_id', + on_select='setMainImage', + multi_select=false, + title='Select Main Image' +) }} + + +{{ media_picker_modal( + id='mediaPickerAdditional', + show_var='showMediaPickerAdditional', + vendor_id_var='form.vendor_id', + on_select='addAdditionalImages', + multi_select=true, + title='Select Additional Images' +) }} {% endblock %} {% block extra_scripts %} @@ -351,5 +434,6 @@ document.head.appendChild(script); })(); + {% endblock %} diff --git a/app/templates/admin/vendor-product-edit.html b/app/templates/admin/vendor-product-edit.html index eade7d1a..2f9ab554 100644 --- a/app/templates/admin/vendor-product-edit.html +++ b/app/templates/admin/vendor-product-edit.html @@ -2,6 +2,7 @@ {% extends "admin/base.html" %} {% from 'shared/macros/alerts.html' import loading_state, error_state %} {% from 'shared/macros/headers.html' import detail_page_header %} +{% from 'shared/macros/modals.html' import media_picker_modal %} {% block title %}Edit Vendor Product{% endblock %} @@ -228,40 +229,115 @@
- +

- Primary Image * + Product Images

-
-
- - -
-
- -
+ + +
+ +
+ +
+ +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + + + +
+

+ Click the + button to add more images from the media library +

+
+ + {{ media_picker_modal( + id='media-picker-main', + show_var='showMediaPicker', + vendor_id_var='product?.vendor_id', + title='Select Main Image' + ) }} + + {{ media_picker_modal( + id='media-picker-additional', + show_var='showMediaPickerAdditional', + vendor_id_var='product?.vendor_id', + multi_select=true, + title='Select Additional Images' + ) }} +

@@ -349,5 +425,6 @@ {% endblock %} {% block extra_scripts %} + {% endblock %} diff --git a/app/templates/shared/macros/modals.html b/app/templates/shared/macros/modals.html index 064ac67c..7962c73d 100644 --- a/app/templates/shared/macros/modals.html +++ b/app/templates/shared/macros/modals.html @@ -699,3 +699,216 @@

{% endmacro %} + + +{# + Media Picker Modal + ================== + A modal for selecting images from the vendor's media library. + Supports browsing existing media, uploading new files, and single/multi-select. + + Parameters: + - id: Unique modal ID (default: 'mediaPicker') + - show_var: Alpine.js variable controlling visibility (default: 'showMediaPicker') + - vendor_id_var: Variable containing vendor ID (default: 'vendorId') + - on_select: Callback function when images are selected (default: 'onMediaSelected') + - multi_select: Allow selecting multiple images (default: false) + - title: Modal title (default: 'Select Image') + + Required Alpine.js state: + - showMediaPicker: boolean + - vendorId: number + - mediaPickerState: object (managed by initMediaPicker()) + - onMediaSelected(images): callback function + + Usage: + {% from 'shared/macros/modals.html' import media_picker_modal %} + {{ media_picker_modal(vendor_id_var='form.vendor_id', on_select='setMainImage', multi_select=false) }} +#} +{% macro media_picker_modal(id='mediaPicker', show_var='showMediaPicker', vendor_id_var='vendorId', on_select='onMediaSelected', multi_select=false, title='Select Image') %} + +{% endmacro %} diff --git a/models/schema/vendor_product.py b/models/schema/vendor_product.py index cfffca01..cec20768 100644 --- a/models/schema/vendor_product.py +++ b/models/schema/vendor_product.py @@ -107,6 +107,7 @@ class VendorProductDetail(BaseModel): availability_source: str | None = None primary_image_url: str | None = None primary_image_url_source: str | None = None + additional_images: list[str] | None = None is_digital: bool | None = None product_type: str | None = None # Vendor-specific fields @@ -138,6 +139,10 @@ class VendorProductDetail(BaseModel): # Translations marketplace_translations: dict | None = None vendor_translations: dict | None = None + # Convenience fields for UI display + title: str | None = None + description: str | None = None + image_url: str | None = None # Alias for primary_image_url # Timestamps created_at: str | None = None updated_at: str | None = None @@ -177,8 +182,9 @@ class VendorProductCreate(BaseModel): tax_rate_percent: int | None = 17 # Default Luxembourg VAT availability: str | None = None - # Image + # Images primary_image_url: str | None = None + additional_images: list[str] | None = None # Status is_active: bool = True @@ -227,6 +233,7 @@ class VendorProductUpdate(BaseModel): # Images primary_image_url: str | None = None + additional_images: list[str] | None = None # Optional supplier info supplier: str | None = None diff --git a/static/admin/js/vendor-product-create.js b/static/admin/js/vendor-product-create.js index 610f7e86..4465af20 100644 --- a/static/admin/js/vendor-product-create.js +++ b/static/admin/js/vendor-product-create.js @@ -24,6 +24,9 @@ function adminVendorProductCreate() { // Inherit base layout state ...data(), + // Include media picker functionality (vendor ID getter will be bound via loadMediaLibrary override) + ...mediaPickerMixin(() => null, false), + // Set page identifier currentPage: 'vendor-products', @@ -53,8 +56,9 @@ function adminVendorProductCreate() { currency: 'EUR', tax_rate_percent: 17, availability: '', - // Image + // Images primary_image_url: '', + additional_images: [], // Status is_active: true, is_featured: false, @@ -204,8 +208,10 @@ function adminVendorProductCreate() { tax_rate_percent: this.form.tax_rate_percent !== null ? parseInt(this.form.tax_rate_percent) : 17, availability: this.form.availability || null, - // Image + // Images primary_image_url: this.form.primary_image_url?.trim() || null, + additional_images: this.form.additional_images?.length > 0 + ? this.form.additional_images : null, // Status is_active: this.form.is_active, is_featured: this.form.is_featured, @@ -230,6 +236,168 @@ function adminVendorProductCreate() { } finally { this.saving = false; } + }, + + // === Media Picker Overrides === + // These override the mixin methods to use proper form context + + /** + * Load media library for the selected vendor + */ + async loadMediaLibrary() { + const vendorId = this.form?.vendor_id; + + if (!vendorId) { + adminVendorProductCreateLog.warn('Media picker: No vendor ID selected'); + 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) { + adminVendorProductCreateLog.error('Failed to load media library:', error); + Utils.showToast('Failed to load media library', 'error'); + } finally { + this.mediaPickerState.loading = false; + } + }, + + /** + * Load more media (pagination) + */ + async loadMoreMedia() { + const vendorId = this.form?.vendor_id; + 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) { + adminVendorProductCreateLog.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 = this.form?.vendor_id; + + if (!vendorId) { + Utils.showToast('Please select a vendor first', 'error'); + return; + } + + if (!file.type.startsWith('image/')) { + Utils.showToast('Please select an image file', 'error'); + return; + } + + if (file.size > 10 * 1024 * 1024) { + Utils.showToast('Image must be less than 10MB', '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) { + this.mediaPickerState.media.unshift(response.media); + this.mediaPickerState.total++; + this.toggleMediaSelection(response.media); + Utils.showToast('Image uploaded successfully', 'success'); + } + } catch (error) { + adminVendorProductCreateLog.error('Failed to upload image:', error); + Utils.showToast(error.message || 'Failed to upload image', 'error'); + } finally { + this.mediaPickerState.uploading = false; + event.target.value = ''; + } + }, + + /** + * Set main image from media picker + */ + setMainImage(media) { + this.form.primary_image_url = media.url; + adminVendorProductCreateLog.info('Main image set:', media.url); + }, + + /** + * Add additional images from media picker + */ + addAdditionalImages(mediaList) { + const newUrls = mediaList.map(m => m.url); + this.form.additional_images = [ + ...this.form.additional_images, + ...newUrls + ]; + adminVendorProductCreateLog.info('Additional images added:', newUrls); + }, + + /** + * Remove an additional image by index + */ + removeAdditionalImage(index) { + this.form.additional_images.splice(index, 1); + }, + + /** + * Clear the main image + */ + clearMainImage() { + this.form.primary_image_url = ''; } }; } diff --git a/static/admin/js/vendor-product-edit.js b/static/admin/js/vendor-product-edit.js index c2aa2b8c..846d4894 100644 --- a/static/admin/js/vendor-product-edit.js +++ b/static/admin/js/vendor-product-edit.js @@ -28,6 +28,9 @@ function adminVendorProductEdit() { // Inherit base layout state ...data(), + // Include media picker functionality (vendor ID comes from loaded product) + ...mediaPickerMixin(() => null, false), + // Set page identifier currentPage: 'vendor-products', @@ -60,8 +63,9 @@ function adminVendorProductEdit() { currency: 'EUR', tax_rate_percent: 17, availability: '', - // Image + // Images primary_image_url: '', + additional_images: [], // Product type & status is_digital: false, is_active: true, @@ -127,8 +131,9 @@ function adminVendorProductEdit() { currency: response.currency || 'EUR', tax_rate_percent: response.tax_rate_percent ?? 17, availability: response.availability || '', - // Image + // Images primary_image_url: response.primary_image_url || '', + additional_images: response.additional_images || [], // Product type & status is_digital: response.is_digital ?? false, is_active: response.is_active ?? true, @@ -241,8 +246,10 @@ function adminVendorProductEdit() { tax_rate_percent: this.form.tax_rate_percent !== null && this.form.tax_rate_percent !== '' ? parseInt(this.form.tax_rate_percent) : null, availability: this.form.availability || null, - // Image + // Images primary_image_url: this.form.primary_image_url?.trim() || null, + additional_images: this.form.additional_images?.length > 0 + ? this.form.additional_images : null, // Status is_digital: this.form.is_digital, is_active: this.form.is_active, @@ -271,6 +278,168 @@ function adminVendorProductEdit() { } finally { this.saving = false; } + }, + + // === Media Picker Overrides === + // These override the mixin methods to use proper form/product context + + /** + * Load media library for the product's vendor + */ + async loadMediaLibrary() { + const vendorId = this.product?.vendor_id; + + if (!vendorId) { + adminVendorProductEditLog.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) { + adminVendorProductEditLog.error('Failed to load media library:', error); + Utils.showToast('Failed to load media library', 'error'); + } finally { + this.mediaPickerState.loading = false; + } + }, + + /** + * Load more media (pagination) + */ + async loadMoreMedia() { + const vendorId = this.product?.vendor_id; + 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) { + adminVendorProductEditLog.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 = this.product?.vendor_id; + + if (!vendorId) { + Utils.showToast('No vendor associated with this product', 'error'); + return; + } + + if (!file.type.startsWith('image/')) { + Utils.showToast('Please select an image file', 'error'); + return; + } + + if (file.size > 10 * 1024 * 1024) { + Utils.showToast('Image must be less than 10MB', '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) { + this.mediaPickerState.media.unshift(response.media); + this.mediaPickerState.total++; + this.toggleMediaSelection(response.media); + Utils.showToast('Image uploaded successfully', 'success'); + } + } catch (error) { + adminVendorProductEditLog.error('Failed to upload image:', error); + Utils.showToast(error.message || 'Failed to upload image', 'error'); + } finally { + this.mediaPickerState.uploading = false; + event.target.value = ''; + } + }, + + /** + * Set main image from media picker + */ + setMainImage(media) { + this.form.primary_image_url = media.url; + adminVendorProductEditLog.info('Main image set:', media.url); + }, + + /** + * Add additional images from media picker + */ + addAdditionalImages(mediaList) { + const newUrls = mediaList.map(m => m.url); + this.form.additional_images = [ + ...this.form.additional_images, + ...newUrls + ]; + adminVendorProductEditLog.info('Additional images added:', newUrls); + }, + + /** + * Remove an additional image by index + */ + removeAdditionalImage(index) { + this.form.additional_images.splice(index, 1); + }, + + /** + * Clear the main image + */ + clearMainImage() { + this.form.primary_image_url = ''; } }; } diff --git a/static/shared/js/media-picker.js b/static/shared/js/media-picker.js new file mode 100644 index 00000000..9e9306fa --- /dev/null +++ b/static/shared/js/media-picker.js @@ -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 }; +}