Add WebP thumbnail endpoint for MLS property images
- Create MLS_Image_Endpoint class with on-demand thumbnail generation
- Use ImageMagick to convert images to WebP format
- Thumbnail sizes: 800px (thumb) and 1800px (full), maintain aspect ratio
- Only downsize images, never upsize
- Cache thumbnails in wp-content/uploads/mls-thumbnails/
- Add mls_get_image_url() helper function (1-based index)
- Update property cards to display thumbnail as background-cover image
- Long cache headers (1 year) with ETag support
URL format: /mls-image/{listing_key}/{index}/{size}/
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,430 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* MLS Image Endpoint
|
||||||
|
*
|
||||||
|
* Serves WebP thumbnails for MLS property images with on-demand conversion.
|
||||||
|
* Uses ImageMagick to convert and resize images.
|
||||||
|
*
|
||||||
|
* URL format: /mls-image/{listing_key}/{index}/{size}/
|
||||||
|
* - listing_key: MLS listing key
|
||||||
|
* - index: Image index (0-based)
|
||||||
|
* - size: 'thumb' (800px) or 'full' (1800px)
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MLS_Image_Endpoint {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Size configurations
|
||||||
|
*/
|
||||||
|
const SIZE_THUMB = 800;
|
||||||
|
const SIZE_FULL = 1800;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebP quality (0-100)
|
||||||
|
*/
|
||||||
|
const WEBP_QUALITY = 82;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache subdirectory for thumbnails
|
||||||
|
*/
|
||||||
|
const CACHE_SUBDIR = 'mls-thumbnails';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media handler instance
|
||||||
|
*/
|
||||||
|
private $media_handler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logger instance
|
||||||
|
*/
|
||||||
|
private $logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
public function __construct(MLS_Media_Handler $media_handler, MLS_Logger $logger) {
|
||||||
|
$this->media_handler = $media_handler;
|
||||||
|
$this->logger = $logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize hooks
|
||||||
|
*/
|
||||||
|
public function init() {
|
||||||
|
add_action('init', array($this, 'add_rewrite_rules'));
|
||||||
|
add_filter('query_vars', array($this, 'add_query_vars'));
|
||||||
|
add_action('template_redirect', array($this, 'handle_request'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add rewrite rules for image endpoint
|
||||||
|
*/
|
||||||
|
public function add_rewrite_rules() {
|
||||||
|
add_rewrite_rule(
|
||||||
|
'^mls-image/([^/]+)/([0-9]+)/(thumb|full)/?$',
|
||||||
|
'index.php?mls_image=1&mls_listing_key=$matches[1]&mls_image_index=$matches[2]&mls_image_size=$matches[3]',
|
||||||
|
'top'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add query vars
|
||||||
|
*/
|
||||||
|
public function add_query_vars($vars) {
|
||||||
|
$vars[] = 'mls_image';
|
||||||
|
$vars[] = 'mls_listing_key';
|
||||||
|
$vars[] = 'mls_image_index';
|
||||||
|
$vars[] = 'mls_image_size';
|
||||||
|
return $vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle image request
|
||||||
|
*/
|
||||||
|
public function handle_request() {
|
||||||
|
if (!get_query_var('mls_image')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$listing_key = sanitize_text_field(get_query_var('mls_listing_key'));
|
||||||
|
$index = absint(get_query_var('mls_image_index'));
|
||||||
|
$size = sanitize_text_field(get_query_var('mls_image_size'));
|
||||||
|
|
||||||
|
if (empty($listing_key) || !in_array($size, array('thumb', 'full'), true)) {
|
||||||
|
$this->logger->error('MLS Image: Invalid params', array(
|
||||||
|
'listing_key' => $listing_key,
|
||||||
|
'index' => $index,
|
||||||
|
'size' => $size,
|
||||||
|
));
|
||||||
|
$this->send_404();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$max_dimension = ($size === 'thumb') ? self::SIZE_THUMB : self::SIZE_FULL;
|
||||||
|
|
||||||
|
// Try to serve from cache first
|
||||||
|
$cached_path = $this->get_cached_path($listing_key, $index, $size);
|
||||||
|
if (file_exists($cached_path)) {
|
||||||
|
$this->serve_image($cached_path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the source image
|
||||||
|
$source_path = $this->get_source_image($listing_key, $index);
|
||||||
|
if (!$source_path) {
|
||||||
|
$this->logger->error('MLS Image: Source not found', array(
|
||||||
|
'listing_key' => $listing_key,
|
||||||
|
'index' => $index,
|
||||||
|
));
|
||||||
|
$this->send_404();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate thumbnail
|
||||||
|
$result = $this->generate_thumbnail($source_path, $cached_path, $max_dimension);
|
||||||
|
if (!$result) {
|
||||||
|
// Fall back to serving original if conversion fails
|
||||||
|
$this->serve_image($source_path, $this->get_mime_type($source_path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->serve_image($cached_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get source image path, fetching from MLS if needed
|
||||||
|
*/
|
||||||
|
private function get_source_image($listing_key, $index) {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$plugin = mls_plugin();
|
||||||
|
$db = $plugin->get_db();
|
||||||
|
|
||||||
|
// Get media record for this index
|
||||||
|
$media = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT * FROM {$db->media_table()}
|
||||||
|
WHERE listing_key = %s AND media_order = %d
|
||||||
|
LIMIT 1",
|
||||||
|
$listing_key,
|
||||||
|
$index
|
||||||
|
));
|
||||||
|
|
||||||
|
if (!$media) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already cached locally
|
||||||
|
if ($media->local_path) {
|
||||||
|
$full_path = $this->media_handler->get_upload_dir() . '/' . $media->local_path;
|
||||||
|
if (file_exists($full_path)) {
|
||||||
|
return $full_path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from MLS on demand
|
||||||
|
$url = $this->media_handler->get_image_url($media, true);
|
||||||
|
if (!$url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch the record to get updated local_path
|
||||||
|
$media = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT * FROM {$db->media_table()}
|
||||||
|
WHERE listing_key = %s AND media_order = %d
|
||||||
|
LIMIT 1",
|
||||||
|
$listing_key,
|
||||||
|
$index
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($media && $media->local_path) {
|
||||||
|
$full_path = $this->media_handler->get_upload_dir() . '/' . $media->local_path;
|
||||||
|
if (file_exists($full_path)) {
|
||||||
|
return $full_path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached thumbnail path
|
||||||
|
*/
|
||||||
|
private function get_cached_path($listing_key, $index, $size) {
|
||||||
|
$cache_dir = $this->get_cache_dir($listing_key);
|
||||||
|
return $cache_dir . '/' . $index . '-' . $size . '.webp';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache directory for a listing
|
||||||
|
*/
|
||||||
|
private function get_cache_dir($listing_key) {
|
||||||
|
$upload_dir = wp_upload_dir();
|
||||||
|
$prefix = substr($listing_key, 0, 2);
|
||||||
|
return $upload_dir['basedir'] . '/' . self::CACHE_SUBDIR . '/' . $prefix . '/' . $listing_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate WebP thumbnail using ImageMagick
|
||||||
|
*/
|
||||||
|
private function generate_thumbnail($source_path, $dest_path, $max_dimension) {
|
||||||
|
// Create destination directory
|
||||||
|
$dest_dir = dirname($dest_path);
|
||||||
|
if (!file_exists($dest_dir)) {
|
||||||
|
wp_mkdir_p($dest_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for ImageMagick
|
||||||
|
$convert_path = $this->get_imagemagick_path();
|
||||||
|
if (!$convert_path) {
|
||||||
|
$this->logger->error('ImageMagick not found');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get source dimensions
|
||||||
|
$size_info = @getimagesize($source_path);
|
||||||
|
if (!$size_info) {
|
||||||
|
$this->logger->error('Failed to get image dimensions', array('path' => $source_path));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$source_width = $size_info[0];
|
||||||
|
$source_height = $size_info[1];
|
||||||
|
|
||||||
|
// Only downsize, never upsize
|
||||||
|
if ($source_width <= $max_dimension && $source_height <= $max_dimension) {
|
||||||
|
// Just convert to WebP without resizing
|
||||||
|
$resize_arg = '';
|
||||||
|
} else {
|
||||||
|
// Resize maintaining aspect ratio, > means only shrink
|
||||||
|
$resize_arg = '-resize ' . escapeshellarg("{$max_dimension}x{$max_dimension}>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build ImageMagick command
|
||||||
|
// -strip: Remove metadata
|
||||||
|
// -quality: WebP quality
|
||||||
|
// -define webp:method=6: Best compression method
|
||||||
|
$command = sprintf(
|
||||||
|
'%s %s -strip %s -quality %d -define webp:method=6 %s 2>&1',
|
||||||
|
escapeshellcmd($convert_path),
|
||||||
|
escapeshellarg($source_path),
|
||||||
|
$resize_arg,
|
||||||
|
self::WEBP_QUALITY,
|
||||||
|
escapeshellarg($dest_path)
|
||||||
|
);
|
||||||
|
|
||||||
|
$output = array();
|
||||||
|
$return_var = 0;
|
||||||
|
exec($command, $output, $return_var);
|
||||||
|
|
||||||
|
if ($return_var !== 0) {
|
||||||
|
$this->logger->error('ImageMagick conversion failed', array(
|
||||||
|
'command' => $command,
|
||||||
|
'output' => implode("\n", $output),
|
||||||
|
'return' => $return_var,
|
||||||
|
));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return file_exists($dest_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ImageMagick convert path
|
||||||
|
*/
|
||||||
|
private function get_imagemagick_path() {
|
||||||
|
// Common paths
|
||||||
|
$paths = array(
|
||||||
|
'/usr/bin/convert',
|
||||||
|
'/usr/local/bin/convert',
|
||||||
|
'/opt/local/bin/convert',
|
||||||
|
'convert', // System PATH
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
if ($path === 'convert') {
|
||||||
|
// Check if available in PATH
|
||||||
|
$output = array();
|
||||||
|
$return_var = 0;
|
||||||
|
exec('which convert 2>/dev/null', $output, $return_var);
|
||||||
|
if ($return_var === 0 && !empty($output[0])) {
|
||||||
|
return trim($output[0]);
|
||||||
|
}
|
||||||
|
} elseif (file_exists($path) && is_executable($path)) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve image file
|
||||||
|
*/
|
||||||
|
private function serve_image($path, $mime_type = 'image/webp') {
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
$this->send_404();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file_size = filesize($path);
|
||||||
|
$last_modified = filemtime($path);
|
||||||
|
$etag = md5($path . $last_modified);
|
||||||
|
|
||||||
|
// Check for conditional request
|
||||||
|
$if_modified = isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])
|
||||||
|
? strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE'])
|
||||||
|
: false;
|
||||||
|
$if_none_match = isset($_SERVER['HTTP_IF_NONE_MATCH'])
|
||||||
|
? trim($_SERVER['HTTP_IF_NONE_MATCH'], '"')
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (($if_modified && $if_modified >= $last_modified) ||
|
||||||
|
($if_none_match && $if_none_match === $etag)) {
|
||||||
|
header('HTTP/1.1 304 Not Modified');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send headers
|
||||||
|
header('Content-Type: ' . $mime_type);
|
||||||
|
header('Content-Length: ' . $file_size);
|
||||||
|
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $last_modified) . ' GMT');
|
||||||
|
header('ETag: "' . $etag . '"');
|
||||||
|
header('Cache-Control: public, max-age=31536000'); // 1 year
|
||||||
|
header('Expires: ' . gmdate('D, d M Y H:i:s', time() + 31536000) . ' GMT');
|
||||||
|
|
||||||
|
// Stream file
|
||||||
|
readfile($path);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MIME type for file
|
||||||
|
*/
|
||||||
|
private function get_mime_type($path) {
|
||||||
|
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||||
|
$types = array(
|
||||||
|
'jpg' => 'image/jpeg',
|
||||||
|
'jpeg' => 'image/jpeg',
|
||||||
|
'png' => 'image/png',
|
||||||
|
'gif' => 'image/gif',
|
||||||
|
'webp' => 'image/webp',
|
||||||
|
);
|
||||||
|
return isset($types[$ext]) ? $types[$ext] : 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send 404 response
|
||||||
|
*/
|
||||||
|
private function send_404() {
|
||||||
|
status_header(404);
|
||||||
|
nocache_headers();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get URL for an MLS image
|
||||||
|
*
|
||||||
|
* @param string $listing_key Listing key
|
||||||
|
* @param int $index Image index (1-based, matches media_order from MLS)
|
||||||
|
* @param string $size 'thumb' or 'full'
|
||||||
|
* @return string Image URL
|
||||||
|
*/
|
||||||
|
public static function get_url($listing_key, $index = 1, $size = 'thumb') {
|
||||||
|
return home_url("/mls-image/{$listing_key}/{$index}/{$size}/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear thumbnail cache for a listing
|
||||||
|
*
|
||||||
|
* @param string $listing_key Listing key
|
||||||
|
*/
|
||||||
|
public function clear_cache($listing_key) {
|
||||||
|
$cache_dir = $this->get_cache_dir($listing_key);
|
||||||
|
if (is_dir($cache_dir)) {
|
||||||
|
$files = glob($cache_dir . '/*');
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if (is_file($file)) {
|
||||||
|
unlink($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rmdir($cache_dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache statistics
|
||||||
|
*
|
||||||
|
* @return array Cache stats
|
||||||
|
*/
|
||||||
|
public function get_cache_stats() {
|
||||||
|
$upload_dir = wp_upload_dir();
|
||||||
|
$cache_base = $upload_dir['basedir'] . '/' . self::CACHE_SUBDIR;
|
||||||
|
|
||||||
|
$stats = array(
|
||||||
|
'total_files' => 0,
|
||||||
|
'total_size' => 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!is_dir($cache_base)) {
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
$iterator = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator($cache_base, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||||
|
RecursiveIteratorIterator::SELF_FIRST
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if ($file->isFile() && $file->getExtension() === 'webp') {
|
||||||
|
$stats['total_files']++;
|
||||||
|
$stats['total_size'] += $file->getSize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ final class MLS_Plugin {
|
|||||||
private $api_client;
|
private $api_client;
|
||||||
private $sync_engine;
|
private $sync_engine;
|
||||||
private $media_handler;
|
private $media_handler;
|
||||||
|
private $image_endpoint;
|
||||||
private $query;
|
private $query;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,6 +85,7 @@ final class MLS_Plugin {
|
|||||||
require_once MLS_PLUGIN_DIR . 'includes/class-mls-api-client.php';
|
require_once MLS_PLUGIN_DIR . 'includes/class-mls-api-client.php';
|
||||||
require_once MLS_PLUGIN_DIR . 'includes/class-mls-sync-engine.php';
|
require_once MLS_PLUGIN_DIR . 'includes/class-mls-sync-engine.php';
|
||||||
require_once MLS_PLUGIN_DIR . 'includes/class-mls-media-handler.php';
|
require_once MLS_PLUGIN_DIR . 'includes/class-mls-media-handler.php';
|
||||||
|
require_once MLS_PLUGIN_DIR . 'includes/class-mls-image-endpoint.php';
|
||||||
require_once MLS_PLUGIN_DIR . 'includes/class-mls-query.php';
|
require_once MLS_PLUGIN_DIR . 'includes/class-mls-query.php';
|
||||||
|
|
||||||
// Activation/Deactivation
|
// Activation/Deactivation
|
||||||
@@ -126,6 +128,8 @@ final class MLS_Plugin {
|
|||||||
$this->rate_limiter = new MLS_Rate_Limiter($this->db);
|
$this->rate_limiter = new MLS_Rate_Limiter($this->db);
|
||||||
$this->api_client = new MLS_API_Client($this->options, $this->rate_limiter, $this->logger);
|
$this->api_client = new MLS_API_Client($this->options, $this->rate_limiter, $this->logger);
|
||||||
$this->media_handler = new MLS_Media_Handler($this->db, $this->logger);
|
$this->media_handler = new MLS_Media_Handler($this->db, $this->logger);
|
||||||
|
$this->image_endpoint = new MLS_Image_Endpoint($this->media_handler, $this->logger);
|
||||||
|
$this->image_endpoint->init();
|
||||||
$this->sync_engine = new MLS_Sync_Engine(
|
$this->sync_engine = new MLS_Sync_Engine(
|
||||||
$this->db,
|
$this->db,
|
||||||
$this->api_client,
|
$this->api_client,
|
||||||
@@ -206,6 +210,13 @@ final class MLS_Plugin {
|
|||||||
return $this->media_handler;
|
return $this->media_handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Image Endpoint instance
|
||||||
|
*/
|
||||||
|
public function get_image_endpoint() {
|
||||||
|
return $this->image_endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Query instance
|
* Get Query instance
|
||||||
*/
|
*/
|
||||||
@@ -359,3 +370,21 @@ function mls_get_cache_stats() {
|
|||||||
}
|
}
|
||||||
return $plugin->get_media_handler()->get_cache_stats();
|
return $plugin->get_media_handler()->get_cache_stats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get WebP thumbnail URL for a listing image
|
||||||
|
*
|
||||||
|
* Returns a URL to the WebP thumbnail endpoint which will:
|
||||||
|
* - Fetch the image from MLS Grid if not cached
|
||||||
|
* - Convert to WebP format using ImageMagick
|
||||||
|
* - Resize to requested dimension (800px thumb or 1800px full)
|
||||||
|
* - Cache the result for future requests
|
||||||
|
*
|
||||||
|
* @param string $listing_key The listing key
|
||||||
|
* @param int $index Image index (1-based, default: 1 for primary image)
|
||||||
|
* @param string $size 'thumb' (800px) or 'full' (1800px), default: 'thumb'
|
||||||
|
* @return string Image URL
|
||||||
|
*/
|
||||||
|
function mls_get_image_url($listing_key, $index = 1, $size = 'thumb') {
|
||||||
|
return MLS_Image_Endpoint::get_url($listing_key, $index, $size);
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -59,18 +59,23 @@ if ($status === 'Pending') {
|
|||||||
} elseif ($status === 'Closed' || $status === 'Sold') {
|
} elseif ($status === 'Closed' || $status === 'Sold') {
|
||||||
$status_class = 'badge-sold';
|
$status_class = 'badge-sold';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get thumbnail image URL (index 1 = first image)
|
||||||
|
$image_url = function_exists('mls_get_image_url') ? mls_get_image_url($listing_key, 1, 'thumb') : '';
|
||||||
|
$has_image = !empty($image_url);
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<article id="property-<?php echo esc_attr($listing_key); ?>" data-property-id="<?php echo esc_attr($listing_key); ?>" class="property-card card mls-property">
|
<article id="property-<?php echo esc_attr($listing_key); ?>" data-property-id="<?php echo esc_attr($listing_key); ?>" class="property-card card mls-property">
|
||||||
<a href="<?php echo esc_url($property_url); ?>" class="property-card-link-overlay" aria-hidden="true" tabindex="-1"></a>
|
<a href="<?php echo esc_url($property_url); ?>" class="property-card-link-overlay" aria-hidden="true" tabindex="-1"></a>
|
||||||
<div class="property-card-image">
|
<div class="property-card-image<?php echo $has_image ? ' has-image' : ''; ?>"<?php if ($has_image) : ?> style="background-image: url('<?php echo esc_url($image_url); ?>');"<?php endif; ?>>
|
||||||
<!-- Photo placeholder - will be implemented later -->
|
<?php if (!$has_image) : ?>
|
||||||
<div class="property-card-placeholder">
|
<div class="property-card-placeholder">
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($status) : ?>
|
<?php if ($status) : ?>
|
||||||
<span class="property-card-badge badge <?php echo esc_attr($status_class); ?>">
|
<span class="property-card-badge badge <?php echo esc_attr($status_class); ?>">
|
||||||
|
|||||||
@@ -40,6 +40,14 @@
|
|||||||
aspect-ratio: 16 / 10;
|
aspect-ratio: 16 / 10;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: var(--color-bg-dark);
|
background-color: var(--color-bg-dark);
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
|
||||||
|
&.has-image {
|
||||||
|
// Loading state - shows subtle animation while image loads
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user