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 %}