Refactor Filament listing modules
@ -187,7 +187,7 @@ class Conversation extends Model
|
||||
{
|
||||
$this->loadMissing('listing');
|
||||
|
||||
$url = $this->listing?->getFirstMediaUrl('listing-images');
|
||||
$url = $this->listing?->primaryImageUrl('thumb', 'desktop');
|
||||
|
||||
return is_string($url) && trim($url) !== '' ? $url : null;
|
||||
}
|
||||
|
||||
@ -19,14 +19,18 @@
|
||||
$conversationListing = $conversation->listing;
|
||||
$partner = (int) $conversation->buyer_id === (int) auth()->id() ? $conversation->seller : $conversation->buyer;
|
||||
$isSelected = $selectedConversation && (int) $selectedConversation->id === (int) $conversation->id;
|
||||
$conversationImage = $conversationListing?->getFirstMediaUrl('listing-images');
|
||||
$conversationImage = $conversationListing?->primaryImageData('thumb');
|
||||
$lastMessage = trim((string) ($conversation->lastMessage?->body ?? ''));
|
||||
@endphp
|
||||
<a href="{{ route('panel.inbox.index', ['message_filter' => $messageFilter, 'conversation' => $conversation->id]) }}" class="block px-6 py-4 transition {{ $isSelected ? 'bg-rose-50' : 'hover:bg-slate-50' }}">
|
||||
<div class="flex gap-3">
|
||||
<div class="w-14 h-14 rounded-xl bg-slate-100 border border-slate-200 overflow-hidden shrink-0">
|
||||
@if($conversationImage)
|
||||
<img src="{{ $conversationImage }}" alt="{{ $conversationListing?->title }}" class="w-full h-full object-cover">
|
||||
@include('listing::partials.responsive-image', [
|
||||
'image' => $conversationImage,
|
||||
'alt' => $conversationListing?->title,
|
||||
'class' => 'w-full h-full object-cover',
|
||||
])
|
||||
@else
|
||||
<div class="w-full h-full grid place-items-center text-slate-400 text-xs">Listing</div>
|
||||
@endif
|
||||
|
||||
@ -64,7 +64,7 @@
|
||||
<tbody>
|
||||
@forelse($favoriteListings as $listing)
|
||||
@php
|
||||
$listingImage = $listing->getFirstMediaUrl('listing-images');
|
||||
$listingImage = $listing->primaryImageData('card');
|
||||
$priceLabel = $listing->price ? number_format((float) $listing->price, 0).' '.$listing->currency : 'Free';
|
||||
$meta = collect([
|
||||
$listing->category?->name,
|
||||
@ -80,7 +80,11 @@
|
||||
<div class="flex gap-3">
|
||||
<a href="{{ route('listings.show', $listing) }}" class="w-36 h-24 shrink-0 bg-slate-100 border border-slate-200 overflow-hidden">
|
||||
@if($listingImage)
|
||||
<img src="{{ $listingImage }}" alt="{{ $listing->title }}" class="w-full h-full object-cover">
|
||||
@include('listing::partials.responsive-image', [
|
||||
'image' => $listingImage,
|
||||
'alt' => $listing->title,
|
||||
'class' => 'w-full h-full object-cover',
|
||||
])
|
||||
@else
|
||||
<div class="w-full h-full grid place-items-center text-slate-400">No image</div>
|
||||
@endif
|
||||
|
||||
@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Category\Models\Category;
|
||||
use Modules\Listing\Models\Listing;
|
||||
use Modules\Listing\Support\DemoListingImageFactory;
|
||||
use Modules\Listing\Support\SampleListingImageCatalog;
|
||||
use Modules\Location\Models\City;
|
||||
use Modules\Location\Models\Country;
|
||||
use Modules\User\App\Models\User;
|
||||
@ -143,7 +143,6 @@ class ListingSeeder extends Seeder
|
||||
$location = $this->resolveLocation($index, $countries, $turkeyCities);
|
||||
$title = $this->buildTitle($category, $index, $user);
|
||||
$slug = 'demo-'.Str::slug($user->email).'-'.$category->slug;
|
||||
$familyName = trim((string) ($category->parent?->name ?? $category->name));
|
||||
|
||||
return [
|
||||
'slug' => $slug,
|
||||
@ -156,13 +155,7 @@ class ListingSeeder extends Seeder
|
||||
'is_featured' => $index % 7 === 0,
|
||||
'expires_at' => now()->addDays(21 + ($index % 9)),
|
||||
'created_at' => now()->subHours(6 + $index),
|
||||
'image_path' => DemoListingImageFactory::ensure(
|
||||
$slug,
|
||||
$title,
|
||||
$familyName,
|
||||
$user->name,
|
||||
$index
|
||||
),
|
||||
'image_path' => SampleListingImageCatalog::pathFor($category, $index),
|
||||
];
|
||||
}
|
||||
|
||||
@ -216,7 +209,7 @@ class ListingSeeder extends Seeder
|
||||
$location = trim(collect([$city, $country])->filter()->join(', '));
|
||||
|
||||
return sprintf(
|
||||
'%s listed by %s. Clean demo condition, unique seeded media, and ready for browsing, favorites, inbox, and panel testing. Pickup area: %s.',
|
||||
'%s listed by %s. Clean demo condition, sample product photo assigned from the provided catalog, and ready for browsing, favorites, inbox, and panel testing. Pickup area: %s.',
|
||||
$categoryName !== '' ? $categoryName : 'Item',
|
||||
trim((string) $user->name) !== '' ? trim((string) $user->name) : 'a marketplace user',
|
||||
$location !== '' ? $location : 'Turkey'
|
||||
@ -272,8 +265,15 @@ class ListingSeeder extends Seeder
|
||||
return $listing;
|
||||
}
|
||||
|
||||
private function syncListingImage(Listing $listing, string $imageAbsolutePath): void
|
||||
private function syncListingImage(Listing $listing, ?string $imageAbsolutePath): void
|
||||
{
|
||||
$listing->replacePublicImage($imageAbsolutePath, $listing->slug.'.svg');
|
||||
if (! is_string($imageAbsolutePath) || ! is_file($imageAbsolutePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$listing->replacePublicImage(
|
||||
$imageAbsolutePath,
|
||||
SampleListingImageCatalog::fileNameFor($imageAbsolutePath, $listing->slug)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -178,7 +178,7 @@ class ListingController extends Controller
|
||||
$listing->category_id ? (int) $listing->category_id : null,
|
||||
$listing->custom_fields ?? [],
|
||||
);
|
||||
$gallery = $listing->themeGallery();
|
||||
$gallery = $listing->galleryImageData();
|
||||
$listingVideos = $listing->getRelation('videos');
|
||||
$relatedListings = $listing->relatedSuggestions(12);
|
||||
$themePillCategories = Category::themePills(10);
|
||||
|
||||
@ -10,13 +10,16 @@ use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Category\Models\Category;
|
||||
use Modules\Listing\Support\ListingImageViewData;
|
||||
use Modules\Listing\States\ListingStatus;
|
||||
use Modules\Listing\Support\ListingPanelHelper;
|
||||
use Modules\Video\Models\Video;
|
||||
use Spatie\Image\Enums\Fit;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
use Spatie\ModelStates\HasStates;
|
||||
use Throwable;
|
||||
|
||||
@ -183,22 +186,56 @@ class Listing extends Model implements HasMedia
|
||||
|
||||
public function themeGallery(): array
|
||||
{
|
||||
$mediaUrls = $this->getMedia('listing-images')
|
||||
->map(fn ($media): string => $media->getUrl())
|
||||
->filter(fn (string $url): bool => $url !== '')
|
||||
return collect($this->galleryImageData())
|
||||
->map(fn (array $image): ?string => ListingImageViewData::pickUrl($image['gallery'] ?? null))
|
||||
->filter(fn (?string $url): bool => is_string($url) && $url !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
if ($mediaUrls !== []) {
|
||||
return $mediaUrls;
|
||||
public function galleryImageData(): array
|
||||
{
|
||||
$mediaItems = $this->getMedia('listing-images');
|
||||
|
||||
if ($mediaItems->isNotEmpty()) {
|
||||
return $mediaItems
|
||||
->map(fn (Media $media): array => [
|
||||
'gallery' => ListingImageViewData::fromMedia($media, 'gallery'),
|
||||
'thumb' => ListingImageViewData::fromMedia($media, 'thumb'),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
return collect($this->images ?? [])
|
||||
->filter(fn ($value): bool => is_string($value) && trim($value) !== '')
|
||||
->map(fn (string $url): array => [
|
||||
'gallery' => ListingImageViewData::fromUrl($url),
|
||||
'thumb' => ListingImageViewData::fromUrl($url),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function primaryImageData(string $context = 'card'): ?array
|
||||
{
|
||||
$media = $this->getFirstMedia('listing-images');
|
||||
|
||||
if ($media instanceof Media) {
|
||||
return ListingImageViewData::fromMedia($media, $context);
|
||||
}
|
||||
|
||||
$fallback = collect($this->images ?? [])
|
||||
->first(fn ($value): bool => is_string($value) && trim($value) !== '');
|
||||
|
||||
return ListingImageViewData::fromUrl(is_string($fallback) ? $fallback : null);
|
||||
}
|
||||
|
||||
public function primaryImageUrl(string $context = 'card', string $viewport = 'desktop'): ?string
|
||||
{
|
||||
return ListingImageViewData::pickUrl($this->primaryImageData($context), $viewport);
|
||||
}
|
||||
|
||||
public function relatedSuggestions(int $limit = 8): Collection
|
||||
{
|
||||
$baseQuery = static::query()
|
||||
@ -254,9 +291,7 @@ class Listing extends Model implements HasMedia
|
||||
|
||||
public function panelPrimaryImageUrl(): ?string
|
||||
{
|
||||
$url = trim((string) $this->getFirstMediaUrl('listing-images'));
|
||||
|
||||
return $url !== '' ? $url : null;
|
||||
return $this->primaryImageUrl('card', 'desktop');
|
||||
}
|
||||
|
||||
public function panelPriceLabel(): string
|
||||
@ -444,7 +479,73 @@ class Listing extends Model implements HasMedia
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('listing-images');
|
||||
$this->addMediaCollection('listing-images')->useDisk('public');
|
||||
}
|
||||
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
{
|
||||
if ($this->shouldSkipConversionsForSeeder()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this
|
||||
->addMediaConversion('gallery-mobile')
|
||||
->fit(Fit::Max, 960, 960)
|
||||
->format('webp')
|
||||
->quality(78)
|
||||
->performOnCollections('listing-images')
|
||||
->nonQueued();
|
||||
|
||||
$this
|
||||
->addMediaConversion('gallery-desktop')
|
||||
->fit(Fit::Max, 1680, 1680)
|
||||
->format('webp')
|
||||
->quality(82)
|
||||
->performOnCollections('listing-images')
|
||||
->nonQueued();
|
||||
|
||||
$this
|
||||
->addMediaConversion('card-mobile')
|
||||
->fit(Fit::Crop, 720, 540)
|
||||
->format('webp')
|
||||
->quality(76)
|
||||
->performOnCollections('listing-images')
|
||||
->nonQueued();
|
||||
|
||||
$this
|
||||
->addMediaConversion('card-desktop')
|
||||
->fit(Fit::Crop, 1080, 810)
|
||||
->format('webp')
|
||||
->quality(80)
|
||||
->performOnCollections('listing-images')
|
||||
->nonQueued();
|
||||
|
||||
$this
|
||||
->addMediaConversion('thumb-mobile')
|
||||
->fit(Fit::Crop, 220, 220)
|
||||
->format('webp')
|
||||
->quality(74)
|
||||
->performOnCollections('listing-images')
|
||||
->nonQueued();
|
||||
|
||||
$this
|
||||
->addMediaConversion('thumb-desktop')
|
||||
->fit(Fit::Crop, 320, 320)
|
||||
->format('webp')
|
||||
->quality(78)
|
||||
->performOnCollections('listing-images')
|
||||
->nonQueued();
|
||||
}
|
||||
|
||||
private function shouldSkipConversionsForSeeder(): bool
|
||||
{
|
||||
if (! app()->runningInConsole()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$argv = implode(' ', (array) ($_SERVER['argv'] ?? []));
|
||||
|
||||
return str_contains($argv, 'db:seed') || str_contains($argv, '--seed');
|
||||
}
|
||||
|
||||
protected function location(): Attribute
|
||||
|
||||
@ -1,84 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Listing\Support;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class DemoListingImageFactory
|
||||
{
|
||||
private const PALETTES = [
|
||||
['#0f172a', '#1d4ed8', '#dbeafe'],
|
||||
['#172554', '#2563eb', '#dbeafe'],
|
||||
['#0f3b2e', '#059669', '#d1fae5'],
|
||||
['#3f2200', '#ea580c', '#ffedd5'],
|
||||
['#3b0764', '#9333ea', '#f3e8ff'],
|
||||
['#3f3f46', '#e11d48', '#ffe4e6'],
|
||||
['#0b3b66', '#0891b2', '#cffafe'],
|
||||
['#422006', '#ca8a04', '#fef3c7'],
|
||||
];
|
||||
|
||||
public static function ensure(
|
||||
string $slug,
|
||||
string $title,
|
||||
string $categoryName,
|
||||
string $ownerName,
|
||||
int $seed
|
||||
): string {
|
||||
$directory = public_path('generated/demo-listings');
|
||||
|
||||
if (! is_dir($directory)) {
|
||||
mkdir($directory, 0755, true);
|
||||
}
|
||||
|
||||
$filePath = $directory.'/'.Str::slug($slug).'.svg';
|
||||
$palette = self::PALETTES[$seed % count(self::PALETTES)];
|
||||
[$baseColor, $accentColor, $surfaceColor] = $palette;
|
||||
|
||||
$shortTitle = self::escape(Str::limit($title, 36, ''));
|
||||
$shortCategory = self::escape(Str::limit($categoryName, 18, ''));
|
||||
$shortOwner = self::escape(Str::limit($ownerName, 18, ''));
|
||||
$code = self::escape(strtoupper(Str::substr(md5($slug), 0, 6)));
|
||||
|
||||
$svg = <<<SVG
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1200" viewBox="0 0 1600 1200" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="120" y1="80" x2="1480" y2="1120" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="{$baseColor}"/>
|
||||
<stop offset="1" stop-color="{$accentColor}"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="glass" x1="320" y1="220" x2="1200" y2="920" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white" stop-opacity="0.95"/>
|
||||
<stop offset="1" stop-color="{$surfaceColor}" stop-opacity="0.86"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1600" height="1200" rx="64" fill="url(#bg)"/>
|
||||
<circle cx="1320" cy="230" r="170" fill="white" fill-opacity="0.08"/>
|
||||
<circle cx="240" cy="1010" r="220" fill="white" fill-opacity="0.06"/>
|
||||
<rect x="170" y="146" width="1260" height="908" rx="58" fill="url(#glass)" stroke="white" stroke-opacity="0.22" stroke-width="6"/>
|
||||
<rect x="260" y="248" width="420" height="700" rx="44" fill="{$baseColor}" fill-opacity="0.94"/>
|
||||
<rect x="740" y="248" width="520" height="200" rx="34" fill="white" fill-opacity="0.72"/>
|
||||
<rect x="740" y="490" width="520" height="210" rx="34" fill="white" fill-opacity="0.52"/>
|
||||
<rect x="740" y="742" width="240" height="180" rx="30" fill="white" fill-opacity="0.58"/>
|
||||
<rect x="1020" y="742" width="240" height="180" rx="30" fill="white" fill-opacity="0.32"/>
|
||||
<rect x="340" y="338" width="260" height="260" rx="130" fill="white" fill-opacity="0.12"/>
|
||||
<rect x="830" y="310" width="230" height="38" rx="19" fill="{$accentColor}" fill-opacity="0.16"/>
|
||||
<rect x="830" y="548" width="340" height="26" rx="13" fill="{$baseColor}" fill-opacity="0.12"/>
|
||||
<rect x="830" y="596" width="260" height="26" rx="13" fill="{$baseColor}" fill-opacity="0.08"/>
|
||||
<text x="262" y="214" fill="white" fill-opacity="0.92" font-family="Arial, Helvetica, sans-serif" font-size="40" font-weight="700" letter-spacing="10">OPENCLASSIFY DEMO</text>
|
||||
<text x="258" y="760" fill="white" font-family="Arial, Helvetica, sans-serif" font-size="86" font-weight="700">{$shortCategory}</text>
|
||||
<text x="258" y="840" fill="white" fill-opacity="0.78" font-family="Arial, Helvetica, sans-serif" font-size="44" font-weight="500">{$shortOwner}</text>
|
||||
<text x="818" y="390" fill="{$baseColor}" font-family="Arial, Helvetica, sans-serif" font-size="72" font-weight="700">{$shortTitle}</text>
|
||||
<text x="818" y="474" fill="{$accentColor}" font-family="Arial, Helvetica, sans-serif" font-size="34" font-weight="700" letter-spacing="8">{$code}</text>
|
||||
</svg>
|
||||
SVG;
|
||||
|
||||
file_put_contents($filePath, $svg);
|
||||
|
||||
return $filePath;
|
||||
}
|
||||
|
||||
private static function escape(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
}
|
||||
}
|
||||
63
Modules/Listing/Support/ListingImageViewData.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Listing\Support;
|
||||
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
final class ListingImageViewData
|
||||
{
|
||||
private const CONTEXTS = [
|
||||
'card' => ['mobile' => 'card-mobile', 'desktop' => 'card-desktop'],
|
||||
'gallery' => ['mobile' => 'gallery-mobile', 'desktop' => 'gallery-desktop'],
|
||||
'thumb' => ['mobile' => 'thumb-mobile', 'desktop' => 'thumb-desktop'],
|
||||
];
|
||||
|
||||
public static function fromMedia(Media $media, string $context = 'card'): array
|
||||
{
|
||||
$conversion = self::CONTEXTS[$context] ?? self::CONTEXTS['card'];
|
||||
|
||||
return [
|
||||
'mobile' => $media->getAvailableUrl([$conversion['mobile'], $conversion['desktop']]),
|
||||
'desktop' => $media->getAvailableUrl([$conversion['desktop'], $conversion['mobile']]),
|
||||
'fallback' => $media->getUrl(),
|
||||
'alt' => trim((string) $media->name),
|
||||
];
|
||||
}
|
||||
|
||||
public static function fromUrl(?string $url): ?array
|
||||
{
|
||||
$value = is_string($url) ? trim($url) : '';
|
||||
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'mobile' => $value,
|
||||
'desktop' => $value,
|
||||
'fallback' => $value,
|
||||
'alt' => '',
|
||||
];
|
||||
}
|
||||
|
||||
public static function pickUrl(?array $image, string $viewport = 'desktop'): ?string
|
||||
{
|
||||
if (! is_array($image)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$preferred = $viewport === 'mobile'
|
||||
? ($image['mobile'] ?? null)
|
||||
: ($image['desktop'] ?? null);
|
||||
|
||||
if (is_string($preferred) && trim($preferred) !== '') {
|
||||
return trim($preferred);
|
||||
}
|
||||
|
||||
$fallback = $image['fallback'] ?? null;
|
||||
|
||||
return is_string($fallback) && trim($fallback) !== ''
|
||||
? trim($fallback)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
264
Modules/Listing/Support/SampleListingImageCatalog.php
Normal file
@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Listing\Support;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\Category\Models\Category;
|
||||
|
||||
final class SampleListingImageCatalog
|
||||
{
|
||||
private const DIRECTORY = 'sample_image';
|
||||
private const MAX_PIXELS = 12000000;
|
||||
private const MAX_EDGE = 4200;
|
||||
|
||||
private const CATEGORY_IMAGES = [
|
||||
'electronics-phones' => [
|
||||
'phone.jpeg',
|
||||
],
|
||||
'electronics-computers' => [
|
||||
'laptop.jpg',
|
||||
'macbook.jpg',
|
||||
'tech product macbook digital image render macbook pro.jpg',
|
||||
],
|
||||
'electronics-tablets' => [
|
||||
'tech product macbook digital image render macbook pro.jpg',
|
||||
'phone.jpeg',
|
||||
],
|
||||
'electronics-tvs' => [
|
||||
'headphones.jpg',
|
||||
'macbook.jpg',
|
||||
],
|
||||
'vehicles-cars' => [
|
||||
'car.jpeg',
|
||||
'car2.jpeg',
|
||||
'sportscars car sports car vehicle.jpg',
|
||||
],
|
||||
'vehicles-motorcycles' => [
|
||||
'sport motorbike product photography products moto sports bike enduro motorsports clothing.jpg',
|
||||
],
|
||||
'vehicles-trucks' => [
|
||||
'car2.jpeg',
|
||||
'car.jpeg',
|
||||
],
|
||||
'vehicles-boats' => [
|
||||
'sportscars car sports car vehicle.jpg',
|
||||
'car.jpeg',
|
||||
],
|
||||
'real-estate-for-sale' => [
|
||||
'roof large house fence gate.jpg',
|
||||
'house interior design home interior bedroom.jpg',
|
||||
],
|
||||
'real-estate-for-rent' => [
|
||||
'house interior design home interior bedroom.jpg',
|
||||
'roof large house fence gate.jpg',
|
||||
],
|
||||
'real-estate-commercial' => [
|
||||
'office building black laptop programming grey interior desk men .jpg',
|
||||
'roof large house fence gate.jpg',
|
||||
],
|
||||
'fashion-men' => [
|
||||
'grey product photography hat sustainable fashion beanie ethical fashion ambleside .jpg',
|
||||
'sunglasses.jpg',
|
||||
],
|
||||
'fashion-women' => [
|
||||
'fashion natural wedding product shoes.jpg',
|
||||
'sunglasses.jpg',
|
||||
],
|
||||
'fashion-kids' => [
|
||||
'nike-sport-wear.png',
|
||||
'fashion natural wedding product shoes.jpg',
|
||||
],
|
||||
'fashion-shoes' => [
|
||||
'fashion natural wedding product shoes.jpg',
|
||||
'nike-sport-wear.png',
|
||||
],
|
||||
'home-garden-furniture' => [
|
||||
'house interior design home interior bedroom.jpg',
|
||||
'cup.jpg',
|
||||
],
|
||||
'home-garden-garden' => [
|
||||
'roof large house fence gate.jpg',
|
||||
'house interior design home interior bedroom.jpg',
|
||||
],
|
||||
'home-garden-appliances' => [
|
||||
'cup.jpg',
|
||||
'house interior design home interior bedroom.jpg',
|
||||
],
|
||||
'sports-outdoor' => [
|
||||
'sport motorbike product photography products moto sports bike enduro motorsports clothing.jpg',
|
||||
'nike-sport-wear.png',
|
||||
],
|
||||
'sports-fitness' => [
|
||||
'smart-watch.jpg',
|
||||
' watch_band.jpg',
|
||||
'nike-sport-wear.png',
|
||||
],
|
||||
'sports-team-sports' => [
|
||||
'nike-sport-wear.png',
|
||||
'smart-watch.jpg',
|
||||
],
|
||||
'jobs-full-time' => [
|
||||
'business white career hiring recruitment academic jobs.jpg',
|
||||
'office business people laptop work team classroom grey teamwork table.jpg',
|
||||
],
|
||||
'jobs-part-time' => [
|
||||
'jobs.jpg',
|
||||
'business white career hiring recruitment academic jobs.jpg',
|
||||
],
|
||||
'jobs-freelance' => [
|
||||
'office business technology meeting coding grey engineering engineer software engineer professional woman whiteboard tutor.jpg',
|
||||
'office building black laptop programming grey interior desk men .jpg',
|
||||
],
|
||||
'services-cleaning' => [
|
||||
'office business work team white customer service studio office building.jpg',
|
||||
'cup.jpg',
|
||||
],
|
||||
'services-repair' => [
|
||||
'office building black laptop programming grey interior desk men .jpg',
|
||||
'office business technology meeting coding grey engineering engineer software engineer professional woman whiteboard tutor.jpg',
|
||||
],
|
||||
'services-education' => [
|
||||
'office business people laptop work team classroom grey teamwork table.jpg',
|
||||
'business white career hiring recruitment academic jobs.jpg',
|
||||
],
|
||||
];
|
||||
|
||||
private const FAMILY_IMAGES = [
|
||||
'electronics' => [
|
||||
'phone.jpeg',
|
||||
'laptop.jpg',
|
||||
'macbook.jpg',
|
||||
'tech product macbook digital image render macbook pro.jpg',
|
||||
'headphones.jpg',
|
||||
'smart-watch.jpg',
|
||||
' watch_band.jpg',
|
||||
],
|
||||
'vehicles' => [
|
||||
'car.jpeg',
|
||||
'car2.jpeg',
|
||||
'sportscars car sports car vehicle.jpg',
|
||||
'sport motorbike product photography products moto sports bike enduro motorsports clothing.jpg',
|
||||
],
|
||||
'real-estate' => [
|
||||
'roof large house fence gate.jpg',
|
||||
'house interior design home interior bedroom.jpg',
|
||||
'office building black laptop programming grey interior desk men .jpg',
|
||||
],
|
||||
'fashion' => [
|
||||
'fashion natural wedding product shoes.jpg',
|
||||
'grey product photography hat sustainable fashion beanie ethical fashion ambleside .jpg',
|
||||
'nike-sport-wear.png',
|
||||
'sunglasses.jpg',
|
||||
],
|
||||
'home-garden' => [
|
||||
'house interior design home interior bedroom.jpg',
|
||||
'roof large house fence gate.jpg',
|
||||
'cup.jpg',
|
||||
],
|
||||
'sports' => [
|
||||
'sport motorbike product photography products moto sports bike enduro motorsports clothing.jpg',
|
||||
'nike-sport-wear.png',
|
||||
'smart-watch.jpg',
|
||||
' watch_band.jpg',
|
||||
],
|
||||
'jobs' => [
|
||||
'jobs.jpg',
|
||||
'business white career hiring recruitment academic jobs.jpg',
|
||||
'office business people laptop work team classroom grey teamwork table.jpg',
|
||||
'office business technology meeting coding grey engineering engineer software engineer professional woman whiteboard tutor.jpg',
|
||||
'vintage red text retro machine sign blur bokeh flag hiring.jpg',
|
||||
],
|
||||
'services' => [
|
||||
'office business work team white customer service studio office building.jpg',
|
||||
'office building black laptop programming grey interior desk men .jpg',
|
||||
'office business people laptop work team classroom grey teamwork table.jpg',
|
||||
'cup.jpg',
|
||||
],
|
||||
];
|
||||
|
||||
public static function pathFor(Category $category, int $seed): ?string
|
||||
{
|
||||
$categorySlug = trim((string) $category->slug);
|
||||
$familySlug = trim((string) ($category->parent?->slug ?? $category->slug));
|
||||
|
||||
$paths = self::resolvePathsForSlug($categorySlug);
|
||||
|
||||
if ($paths->isEmpty()) {
|
||||
$paths = self::resolvePathsForSlug($familySlug);
|
||||
}
|
||||
|
||||
if ($paths->isEmpty()) {
|
||||
$paths = self::allPaths();
|
||||
}
|
||||
|
||||
if ($paths->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $paths->values()->get($seed % $paths->count());
|
||||
}
|
||||
|
||||
public static function fileNameFor(string $absolutePath, string $slug): string
|
||||
{
|
||||
$extension = strtolower((string) pathinfo($absolutePath, PATHINFO_EXTENSION));
|
||||
|
||||
return $slug.($extension !== '' ? '.'.$extension : '');
|
||||
}
|
||||
|
||||
private static function resolvePathsForSlug(string $slug): Collection
|
||||
{
|
||||
$fileNames = self::CATEGORY_IMAGES[$slug] ?? self::FAMILY_IMAGES[$slug] ?? [];
|
||||
|
||||
return collect($fileNames)
|
||||
->map(fn (string $fileName): string => public_path(self::DIRECTORY.'/'.$fileName))
|
||||
->filter(fn (string $path): bool => self::isAllowed($path))
|
||||
->values();
|
||||
}
|
||||
|
||||
private static function allPaths(): Collection
|
||||
{
|
||||
$paths = glob(public_path(self::DIRECTORY.'/*')) ?: [];
|
||||
|
||||
return collect($paths)
|
||||
->filter(function (string $path): bool {
|
||||
if (! self::isAllowed($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
return in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true);
|
||||
})
|
||||
->values();
|
||||
}
|
||||
|
||||
private static function isAllowed(string $path): bool
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filesize($path) > (int) config('media-library.max_file_size', 10 * 1024 * 1024)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$dimensions = @getimagesize($path);
|
||||
|
||||
if (! is_array($dimensions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$width = (int) ($dimensions[0] ?? 0);
|
||||
$height = (int) ($dimensions[1] ?? 0);
|
||||
|
||||
if ($width < 1 || $height < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (max($width, $height) > self::MAX_EDGE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ($width * $height) <= self::MAX_PIXELS;
|
||||
}
|
||||
}
|
||||
@ -231,7 +231,7 @@
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-3.5">
|
||||
@foreach($listings as $listing)
|
||||
@php
|
||||
$listingImage = $listing->getFirstMediaUrl('listing-images');
|
||||
$listingImage = $listing->primaryImageData('card');
|
||||
$isFavorited = in_array($listing->id, $favoriteListingIds ?? [], true);
|
||||
$priceValue = ! is_null($listing->price) ? (float) $listing->price : null;
|
||||
$locationParts = array_filter([
|
||||
@ -244,7 +244,11 @@
|
||||
<div class="relative h-52 bg-slate-200">
|
||||
@if($listingImage)
|
||||
<a href="{{ route('listings.show', $listing) }}" class="block w-full h-full">
|
||||
<img src="{{ $listingImage }}" alt="{{ $listing->title }}" class="w-full h-full object-cover">
|
||||
@include('listing::partials.responsive-image', [
|
||||
'image' => $listingImage,
|
||||
'alt' => $listing->title,
|
||||
'class' => 'w-full h-full object-cover',
|
||||
])
|
||||
</a>
|
||||
@else
|
||||
<a href="{{ route('listings.show', $listing) }}" class="w-full h-full grid place-items-center text-slate-400">
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
@php
|
||||
$image = is_array($image ?? null) ? $image : null;
|
||||
$fallback = is_string($image['fallback'] ?? null) ? trim((string) $image['fallback']) : '';
|
||||
$mobile = is_string($image['mobile'] ?? null) ? trim((string) $image['mobile']) : '';
|
||||
$desktop = is_string($image['desktop'] ?? null) ? trim((string) $image['desktop']) : '';
|
||||
$altText = trim((string) ($alt ?? ($image['alt'] ?? '')));
|
||||
$imageClass = trim((string) ($class ?? ''));
|
||||
$loadingMode = trim((string) ($loading ?? 'lazy'));
|
||||
$fetchPriority = trim((string) ($fetchpriority ?? ''));
|
||||
$sizesValue = trim((string) ($sizes ?? ''));
|
||||
@endphp
|
||||
|
||||
@if($fallback !== '' || $mobile !== '' || $desktop !== '')
|
||||
<picture>
|
||||
@if($mobile !== '')
|
||||
<source media="(max-width: 767px)" srcset="{{ $mobile }}">
|
||||
@endif
|
||||
@if($desktop !== '')
|
||||
<source media="(min-width: 768px)" srcset="{{ $desktop }}">
|
||||
@endif
|
||||
<img
|
||||
src="{{ $fallback !== '' ? $fallback : ($desktop !== '' ? $desktop : $mobile) }}"
|
||||
alt="{{ $altText }}"
|
||||
class="{{ $imageClass }}"
|
||||
loading="{{ $loadingMode }}"
|
||||
decoding="async"
|
||||
@if($fetchPriority !== '') fetchpriority="{{ $fetchPriority }}" @endif
|
||||
@if($sizesValue !== '') sizes="{{ $sizesValue }}" @endif
|
||||
>
|
||||
</picture>
|
||||
@endif
|
||||
@ -22,10 +22,10 @@
|
||||
$publishedAt = $listing->created_at?->format('M j, Y') ?? 'Recently';
|
||||
$postedAgo = $listing->created_at?->diffForHumans() ?? 'Listed recently';
|
||||
$galleryImages = collect($gallery ?? [])
|
||||
->filter(fn ($value) => is_string($value) && trim($value) !== '')
|
||||
->filter(fn ($value) => is_array($value) && is_array($value['gallery'] ?? null))
|
||||
->values()
|
||||
->all();
|
||||
$initialGalleryImage = $galleryImages[0] ?? null;
|
||||
$initialGalleryImage = $galleryImages[0]['gallery'] ?? null;
|
||||
$galleryCount = count($galleryImages);
|
||||
|
||||
$description = trim((string) ($listing->description ?? ''));
|
||||
@ -143,7 +143,23 @@
|
||||
</div>
|
||||
|
||||
@if($initialGalleryImage)
|
||||
<img src="{{ $initialGalleryImage }}" alt="{{ $displayTitle }}" data-gallery-main>
|
||||
<picture data-gallery-picture>
|
||||
<source
|
||||
data-gallery-source-mobile
|
||||
media="(max-width: 767px)"
|
||||
srcset="{{ $initialGalleryImage['mobile'] ?? ($initialGalleryImage['fallback'] ?? '') }}"
|
||||
>
|
||||
<source
|
||||
data-gallery-source-desktop
|
||||
media="(min-width: 768px)"
|
||||
srcset="{{ $initialGalleryImage['desktop'] ?? ($initialGalleryImage['fallback'] ?? '') }}"
|
||||
>
|
||||
<img
|
||||
src="{{ $initialGalleryImage['fallback'] ?? ($initialGalleryImage['desktop'] ?? ($initialGalleryImage['mobile'] ?? '')) }}"
|
||||
alt="{{ $displayTitle }}"
|
||||
data-gallery-main
|
||||
>
|
||||
</picture>
|
||||
@else
|
||||
<div class="lt-gallery-main-empty">No photos uploaded yet.</div>
|
||||
@endif
|
||||
@ -171,15 +187,25 @@
|
||||
@if($galleryImages !== [])
|
||||
<div class="lt-thumbs" data-gallery-thumbs>
|
||||
@foreach($galleryImages as $index => $image)
|
||||
@php
|
||||
$galleryImage = $image['gallery'] ?? null;
|
||||
$thumbImage = $image['thumb'] ?? $galleryImage;
|
||||
@endphp
|
||||
<button
|
||||
type="button"
|
||||
class="lt-thumb {{ $index === 0 ? 'is-active' : '' }}"
|
||||
data-gallery-thumb
|
||||
data-gallery-index="{{ $index }}"
|
||||
data-gallery-src="{{ $image }}"
|
||||
data-gallery-mobile-src="{{ $galleryImage['mobile'] ?? ($galleryImage['fallback'] ?? '') }}"
|
||||
data-gallery-desktop-src="{{ $galleryImage['desktop'] ?? ($galleryImage['fallback'] ?? '') }}"
|
||||
data-gallery-fallback-src="{{ $galleryImage['fallback'] ?? '' }}"
|
||||
aria-label="Open photo {{ $index + 1 }}"
|
||||
>
|
||||
<img src="{{ $image }}" alt="{{ $displayTitle }} {{ $index + 1 }}">
|
||||
@include('listing::partials.responsive-image', [
|
||||
'image' => $thumbImage,
|
||||
'alt' => $displayTitle.' '.($index + 1),
|
||||
'class' => 'w-full h-full object-cover',
|
||||
])
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@ -439,11 +465,7 @@
|
||||
<div class="lt-scroll-track" data-theme-scroll-track>
|
||||
@foreach(($relatedListings ?? collect()) as $related)
|
||||
@php
|
||||
$relatedImage = $related->getFirstMediaUrl('listing-images');
|
||||
if (! $relatedImage && is_array($related->images ?? null)) {
|
||||
$relatedImage = collect($related->images)->first();
|
||||
}
|
||||
|
||||
$relatedImage = $related->primaryImageData('card');
|
||||
$relatedPrice = 'Price on request';
|
||||
if (! is_null($related->price)) {
|
||||
$relatedPriceValue = (float) $related->price;
|
||||
@ -458,7 +480,11 @@
|
||||
<a href="{{ route('listings.show', $related) }}" class="lt-rel-card">
|
||||
<div class="lt-rel-photo">
|
||||
@if($relatedImage)
|
||||
<img src="{{ $relatedImage }}" alt="{{ $related->title }}">
|
||||
@include('listing::partials.responsive-image', [
|
||||
'image' => $relatedImage,
|
||||
'alt' => $related->title,
|
||||
'class' => 'w-full h-full object-cover',
|
||||
])
|
||||
@endif
|
||||
</div>
|
||||
<div class="lt-rel-body">
|
||||
@ -496,6 +522,8 @@
|
||||
(() => {
|
||||
document.querySelectorAll('[data-gallery]').forEach((galleryRoot) => {
|
||||
const mainImage = galleryRoot.querySelector('[data-gallery-main]');
|
||||
const mainMobileSource = galleryRoot.querySelector('[data-gallery-source-mobile]');
|
||||
const mainDesktopSource = galleryRoot.querySelector('[data-gallery-source-desktop]');
|
||||
const thumbButtons = Array.from(galleryRoot.querySelectorAll('[data-gallery-thumb]'));
|
||||
const prevButton = galleryRoot.querySelector('[data-gallery-prev]');
|
||||
const nextButton = galleryRoot.querySelector('[data-gallery-next]');
|
||||
@ -517,9 +545,20 @@
|
||||
}
|
||||
|
||||
activeIndex = index;
|
||||
const src = thumbButtons[index].dataset.gallerySrc;
|
||||
if (src) {
|
||||
mainImage.src = src;
|
||||
const mobileSrc = thumbButtons[index].dataset.galleryMobileSrc || '';
|
||||
const desktopSrc = thumbButtons[index].dataset.galleryDesktopSrc || '';
|
||||
const fallbackSrc = thumbButtons[index].dataset.galleryFallbackSrc || desktopSrc || mobileSrc;
|
||||
|
||||
if (mainMobileSource && mobileSrc) {
|
||||
mainMobileSource.srcset = mobileSrc;
|
||||
}
|
||||
|
||||
if (mainDesktopSource && desktopSrc) {
|
||||
mainDesktopSource.srcset = desktopSrc;
|
||||
}
|
||||
|
||||
if (fallbackSrc) {
|
||||
mainImage.src = fallbackSrc;
|
||||
}
|
||||
|
||||
if (currentCounter) {
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
@php
|
||||
$menuCategories = $categories->take(8);
|
||||
$heroListing = $featuredListings->first() ?? $recentListings->first();
|
||||
$heroImage = $heroListing?->getFirstMediaUrl('listing-images');
|
||||
$heroImage = $heroListing?->primaryImageData('gallery');
|
||||
$listingCards = $recentListings->take(6);
|
||||
$demoEnabled = (bool) config('demo.enabled');
|
||||
$prepareDemoRoute = $demoEnabled ? route('demo.prepare') : null;
|
||||
@ -180,7 +180,13 @@
|
||||
@if($slide['image_url'])
|
||||
<img src="{{ $slide['image_url'] }}" alt="{{ $slide['title'] }}" class="w-full h-full object-cover rounded-2xl">
|
||||
@elseif($heroImage)
|
||||
<img src="{{ $heroImage }}" alt="{{ $heroListing?->title }}" class="w-full h-full object-cover rounded-2xl">
|
||||
@include('listing::partials.responsive-image', [
|
||||
'image' => $heroImage,
|
||||
'alt' => $heroListing?->title,
|
||||
'class' => 'w-full h-full object-cover rounded-2xl',
|
||||
'loading' => 'eager',
|
||||
'fetchpriority' => 'high',
|
||||
])
|
||||
@else
|
||||
<div class="w-full h-full rounded-2xl bg-white/90 text-slate-800 flex flex-col justify-center items-center gap-3">
|
||||
<span class="text-6xl">◌</span>
|
||||
@ -274,7 +280,7 @@
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 md:gap-4">
|
||||
@forelse($listingCards as $listing)
|
||||
@php
|
||||
$listingImage = $listing->getFirstMediaUrl('listing-images');
|
||||
$listingImage = $listing->primaryImageData('card');
|
||||
$priceLabel = $listing->price ? number_format((float) $listing->price, 0).' '.$listing->currency : __('messages.free');
|
||||
$locationLabel = trim(collect([$listing->city, $listing->country])->filter()->join(', '));
|
||||
$isFavorited = in_array($listing->id, $favoriteListingIds ?? [], true);
|
||||
@ -283,7 +289,11 @@
|
||||
<div class="relative h-44 sm:h-64 md:h-[290px] bg-slate-100">
|
||||
<a href="{{ route('listings.show', $listing) }}" class="block h-full w-full" aria-label="{{ $listing->title }}">
|
||||
@if($listingImage)
|
||||
<img src="{{ $listingImage }}" alt="{{ $listing->title }}" class="w-full h-full object-cover">
|
||||
@include('listing::partials.responsive-image', [
|
||||
'image' => $listingImage,
|
||||
'alt' => $listing->title,
|
||||
'class' => 'w-full h-full object-cover',
|
||||
])
|
||||
@else
|
||||
<div class="w-full h-full grid place-items-center text-slate-400">
|
||||
<svg class="w-14 h-14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@ -131,7 +131,7 @@
|
||||
@forelse ($listings as $listing)
|
||||
@php
|
||||
$statusMeta = $listing->panelStatusMeta();
|
||||
$listingImage = $listing->panelPrimaryImageUrl();
|
||||
$listingImage = $listing->primaryImageData('card');
|
||||
$priceLabel = $listing->panelPriceLabel();
|
||||
$favoriteCount = (int) ($listing->favorited_by_users_count ?? 0);
|
||||
$viewCount = (int) ($listing->view_count ?? 0);
|
||||
@ -147,7 +147,11 @@
|
||||
<article class="listings-dashboard-card">
|
||||
<a href="{{ route('listings.show', $listing) }}" class="listings-dashboard-media" aria-label="{{ $listing->title }}">
|
||||
@if ($listingImage)
|
||||
<img src="{{ $listingImage }}" alt="{{ $listing->title }}" class="h-full w-full object-cover">
|
||||
@include('listing::partials.responsive-image', [
|
||||
'image' => $listingImage,
|
||||
'alt' => $listing->title,
|
||||
'class' => 'h-full w-full object-cover',
|
||||
])
|
||||
@else
|
||||
<div class="listings-dashboard-placeholder">
|
||||
<span>No image</span>
|
||||
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 3.4 MiB |