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:
2026-01-08 02:16:55 +01:00
parent 5e188cd253
commit 5271ecb378
10 changed files with 1207 additions and 42 deletions

View File

@@ -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"]

138
app/api/v1/admin/media.py Normal file
View File

@@ -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"}

View File

@@ -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",
]

View File

@@ -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 @@
</div>
</div>
<!-- Image -->
<!-- Product Images -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Primary Image
Product Images
</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Image URL</label>
<input
type="url"
x-model="form.primary_image_url"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="https://..."
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Preview</label>
<div class="w-24 h-24 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600">
<!-- Main Image -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Main Image</label>
<div class="flex items-start gap-4">
<!-- Preview -->
<div class="w-32 h-32 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 flex-shrink-0">
<template x-if="form.primary_image_url">
<img :src="form.primary_image_url" class="w-full h-full object-cover" />
<div class="relative w-full h-full group">
<img :src="form.primary_image_url" class="w-full h-full object-cover" />
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
type="button"
@click="clearMainImage()"
class="p-2 bg-red-500 rounded-full text-white hover:bg-red-600"
title="Remove image"
>
<span x-html="$icon('delete', 'w-4 h-4')"></span>
</button>
</div>
</div>
</template>
<template x-if="!form.primary_image_url">
<div class="w-full h-full flex items-center justify-center">
<span x-html="$icon('photograph', 'w-8 h-8 text-gray-400')"></span>
<span x-html="$icon('photograph', 'w-10 h-10 text-gray-400')"></span>
</div>
</template>
</div>
<!-- Actions -->
<div class="flex flex-col gap-2">
<button
type="button"
@click="openMediaPickerMain()"
:disabled="!form.vendor_id"
class="flex items-center px-4 py-2 text-sm font-medium text-purple-600 dark:text-purple-400 border border-purple-300 dark:border-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<span x-html="$icon('photograph', 'w-4 h-4 mr-2')"></span>
Browse Media Library
</button>
<p class="text-xs text-gray-500 dark:text-gray-400">Or enter URL directly:</p>
<input
type="url"
x-model="form.primary_image_url"
class="w-64 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="https://..."
/>
</div>
</div>
</div>
<!-- Additional Images -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Additional Images</label>
<div class="flex flex-wrap gap-3">
<!-- Existing Additional Images -->
<template x-for="(imgUrl, index) in form.additional_images" :key="index">
<div class="relative w-24 h-24 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 group">
<img :src="imgUrl" class="w-full h-full object-cover" />
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
type="button"
@click="removeAdditionalImage(index)"
class="p-1.5 bg-red-500 rounded-full text-white hover:bg-red-600"
title="Remove image"
>
<span x-html="$icon('close', 'w-3 h-3')"></span>
</button>
</div>
<div class="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs text-center py-0.5" x-text="index + 1"></div>
</div>
</template>
<!-- Add Image Button -->
<button
type="button"
@click="openMediaPickerAdditional()"
:disabled="!form.vendor_id"
class="w-24 h-24 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-purple-400 dark:hover:border-purple-500 flex flex-col items-center justify-center text-gray-400 hover:text-purple-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-html="$icon('plus', 'w-6 h-6')"></span>
<span class="text-xs mt-1">Add</span>
</button>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Click "Add" to select images from the media library or upload new ones</p>
</div>
</div>
<!-- Product Type & Status -->
@@ -334,6 +397,26 @@
</button>
</div>
</form>
<!-- Media Picker Modal for Main Image -->
{{ 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 for Additional Images -->
{{ 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);
})();
</script>
<script src="{{ url_for('static', path='shared/js/media-picker.js') }}"></script>
<script src="{{ url_for('static', path='admin/js/vendor-product-create.js') }}"></script>
{% endblock %}

View File

@@ -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 @@
</div>
</div>
<!-- Image -->
<!-- Images -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Primary Image <span class="text-red-500">*</span>
Product Images
</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Image URL <span class="text-red-500">*</span>
</label>
<input
type="url"
x-model="form.primary_image_url"
required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="https://..."
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Preview</label>
<div class="w-24 h-24 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600">
<!-- Main Image -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Main Image <span class="text-red-500">*</span>
</label>
<div class="flex items-start gap-4">
<!-- Preview -->
<div class="w-32 h-32 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 flex-shrink-0">
<template x-if="form.primary_image_url">
<img :src="form.primary_image_url" class="w-full h-full object-cover" />
</template>
<template x-if="!form.primary_image_url">
<div class="w-full h-full flex items-center justify-center">
<span x-html="$icon('photograph', 'w-8 h-8 text-gray-400')"></span>
<span x-html="$icon('photograph', 'w-10 h-10 text-gray-400')"></span>
</div>
</template>
</div>
<!-- Actions -->
<div class="flex-1 space-y-2">
<div class="flex gap-2">
<button
type="button"
@click="openMediaPickerMain(); loadMediaLibrary()"
class="px-3 py-2 text-sm font-medium text-purple-600 dark:text-purple-400 border border-purple-300 dark:border-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20 flex items-center gap-2"
>
<span x-html="$icon('photograph', 'w-4 h-4')"></span>
Browse Media
</button>
<button
type="button"
x-show="form.primary_image_url"
@click="clearMainImage()"
class="px-3 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-red-300 dark:border-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20"
>
Remove
</button>
</div>
<!-- URL fallback -->
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Or enter URL directly:</label>
<input
type="url"
x-model="form.primary_image_url"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="https://..."
/>
</div>
</div>
</div>
</div>
<!-- Additional Images -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Additional Images
</label>
<div class="grid grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-3 mb-3">
<!-- Existing additional images -->
<template x-for="(url, index) in form.additional_images" :key="index">
<div class="relative group">
<div class="w-full aspect-square rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600">
<img :src="url" class="w-full h-full object-cover" />
</div>
<button
type="button"
@click="removeAdditionalImage(index)"
class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<span x-html="$icon('x', 'w-4 h-4')"></span>
</button>
</div>
</template>
<!-- Add button -->
<button
type="button"
@click="openMediaPickerAdditional(); loadMediaLibrary()"
class="w-full aspect-square rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-purple-400 dark:hover:border-purple-500 flex items-center justify-center text-gray-400 hover:text-purple-500 transition-colors"
>
<span x-html="$icon('plus', 'w-8 h-8')"></span>
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Click the + button to add more images from the media library
</p>
</div>
</div>
<!-- Media Picker Modals -->
{{ 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'
) }}
<!-- Product Type & Status -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
@@ -349,5 +425,6 @@
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='shared/js/media-picker.js') }}"></script>
<script src="{{ url_for('static', path='admin/js/vendor-product-edit.js') }}"></script>
{% endblock %}

View File

@@ -699,3 +699,216 @@
</div>
</div>
{% 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') %}
<div
x-show="{{ show_var }}"
x-cloak
@keydown.escape.window="{{ show_var }} = false"
class="fixed inset-0 z-50 overflow-y-auto"
role="dialog"
aria-modal="true"
>
{# Backdrop #}
<div
x-show="{{ show_var }}"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-gray-500/50 dark:bg-gray-900/80 backdrop-blur-sm"
@click="{{ show_var }} = false"
></div>
{# Modal Container #}
<div class="flex min-h-full items-center justify-center p-4">
<div
x-show="{{ show_var }}"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@click.stop
class="relative w-full max-w-4xl bg-white dark:bg-gray-800 rounded-xl shadow-xl"
x-init="$watch('{{ show_var }}', value => value && loadMediaLibrary())"
>
{# Header #}
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ title }}
{% if multi_select %}
<span class="ml-2 text-sm font-normal text-gray-500 dark:text-gray-400">(select multiple)</span>
{% endif %}
</h3>
<button
@click="{{ show_var }} = false"
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
{# Toolbar #}
<div class="px-6 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div class="flex items-center justify-between gap-4">
{# Search #}
<div class="flex-1 max-w-xs">
<div class="relative">
<span x-html="$icon('search', 'absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400')"></span>
<input
type="text"
x-model="mediaPickerState.search"
@input.debounce.300ms="loadMediaLibrary()"
placeholder="Search images..."
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
</div>
{# Upload Button #}
<div>
<label class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 cursor-pointer transition-colors">
<span x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
Upload New
<input
type="file"
accept="image/*"
@change="uploadMediaFile($event)"
class="hidden"
:disabled="mediaPickerState.uploading"
/>
</label>
</div>
</div>
{# Upload Progress #}
<div x-show="mediaPickerState.uploading" class="mt-3">
<div class="flex items-center gap-2 text-sm text-purple-600 dark:text-purple-400">
<span x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
<span>Uploading...</span>
</div>
</div>
</div>
{# Media Grid #}
<div class="px-6 py-4 max-h-[400px] overflow-y-auto">
{# Loading State #}
<div x-show="mediaPickerState.loading" class="flex items-center justify-center py-12">
<span x-html="$icon('spinner', 'w-8 h-8 text-purple-600 animate-spin')"></span>
</div>
{# Empty State #}
<div x-show="!mediaPickerState.loading && mediaPickerState.media.length === 0" class="text-center py-12">
<span x-html="$icon('photograph', 'w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto mb-4')"></span>
<p class="text-gray-500 dark:text-gray-400">No images found</p>
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Upload an image to get started</p>
</div>
{# Image Grid #}
<div x-show="!mediaPickerState.loading && mediaPickerState.media.length > 0" class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-3">
<template x-for="media in mediaPickerState.media" :key="media.id">
<div
@click="toggleMediaSelection(media)"
class="relative aspect-square rounded-lg overflow-hidden cursor-pointer border-2 transition-all"
:class="isMediaSelected(media.id) ? 'border-purple-500 ring-2 ring-purple-500/50' : 'border-transparent hover:border-gray-300 dark:hover:border-gray-600'"
>
<img
:src="media.thumbnail_url || media.url"
:alt="media.alt_text || media.filename"
class="w-full h-full object-cover"
/>
{# Selection Indicator #}
<div
x-show="isMediaSelected(media.id)"
class="absolute inset-0 bg-purple-500/20 flex items-center justify-center"
>
<span class="w-6 h-6 bg-purple-500 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</span>
</div>
{# Filename tooltip #}
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<p class="text-xs text-white truncate" x-text="media.filename"></p>
</div>
</div>
</template>
</div>
{# Pagination #}
<div x-show="mediaPickerState.total > mediaPickerState.media.length" class="mt-4 text-center">
<button
@click="loadMoreMedia()"
:disabled="mediaPickerState.loading"
class="px-4 py-2 text-sm text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded-lg transition-colors disabled:opacity-50"
>
Load more (<span x-text="mediaPickerState.media.length"></span> of <span x-text="mediaPickerState.total"></span>)
</button>
</div>
</div>
{# Footer #}
<div class="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 rounded-b-xl">
<div class="text-sm text-gray-500 dark:text-gray-400">
<span x-show="mediaPickerState.selected.length > 0">
<span x-text="mediaPickerState.selected.length"></span> selected
</span>
</div>
<div class="flex items-center gap-3">
<button
@click="{{ show_var }} = false"
type="button"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
@click="confirmMediaSelection()"
:disabled="mediaPickerState.selected.length === 0"
type="button"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{% if multi_select %}
Add Selected
{% else %}
Select Image
{% endif %}
</button>
</div>
</div>
</div>
</div>
</div>
{% endmacro %}

View File

@@ -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

View File

@@ -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 = '';
}
};
}

View File

@@ -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 = '';
}
};
}

View 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 };
}