Refactor Filament listing modules

This commit is contained in:
fatihalp 2026-03-09 01:42:29 +03:00
parent de09a50893
commit 3e413e2fed
25 changed files with 573 additions and 133 deletions

View File

@ -187,7 +187,7 @@ class Conversation extends Model
{ {
$this->loadMissing('listing'); $this->loadMissing('listing');
$url = $this->listing?->getFirstMediaUrl('listing-images'); $url = $this->listing?->primaryImageUrl('thumb', 'desktop');
return is_string($url) && trim($url) !== '' ? $url : null; return is_string($url) && trim($url) !== '' ? $url : null;
} }

View File

@ -19,14 +19,18 @@
$conversationListing = $conversation->listing; $conversationListing = $conversation->listing;
$partner = (int) $conversation->buyer_id === (int) auth()->id() ? $conversation->seller : $conversation->buyer; $partner = (int) $conversation->buyer_id === (int) auth()->id() ? $conversation->seller : $conversation->buyer;
$isSelected = $selectedConversation && (int) $selectedConversation->id === (int) $conversation->id; $isSelected = $selectedConversation && (int) $selectedConversation->id === (int) $conversation->id;
$conversationImage = $conversationListing?->getFirstMediaUrl('listing-images'); $conversationImage = $conversationListing?->primaryImageData('thumb');
$lastMessage = trim((string) ($conversation->lastMessage?->body ?? '')); $lastMessage = trim((string) ($conversation->lastMessage?->body ?? ''));
@endphp @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' }}"> <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="flex gap-3">
<div class="w-14 h-14 rounded-xl bg-slate-100 border border-slate-200 overflow-hidden shrink-0"> <div class="w-14 h-14 rounded-xl bg-slate-100 border border-slate-200 overflow-hidden shrink-0">
@if($conversationImage) @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 @else
<div class="w-full h-full grid place-items-center text-slate-400 text-xs">Listing</div> <div class="w-full h-full grid place-items-center text-slate-400 text-xs">Listing</div>
@endif @endif

View File

@ -64,7 +64,7 @@
<tbody> <tbody>
@forelse($favoriteListings as $listing) @forelse($favoriteListings as $listing)
@php @php
$listingImage = $listing->getFirstMediaUrl('listing-images'); $listingImage = $listing->primaryImageData('card');
$priceLabel = $listing->price ? number_format((float) $listing->price, 0).' '.$listing->currency : 'Free'; $priceLabel = $listing->price ? number_format((float) $listing->price, 0).' '.$listing->currency : 'Free';
$meta = collect([ $meta = collect([
$listing->category?->name, $listing->category?->name,
@ -80,7 +80,11 @@
<div class="flex gap-3"> <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"> <a href="{{ route('listings.show', $listing) }}" class="w-36 h-24 shrink-0 bg-slate-100 border border-slate-200 overflow-hidden">
@if($listingImage) @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 @else
<div class="w-full h-full grid place-items-center text-slate-400">No image</div> <div class="w-full h-full grid place-items-center text-slate-400">No image</div>
@endif @endif

View File

@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Modules\Category\Models\Category; use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing; use Modules\Listing\Models\Listing;
use Modules\Listing\Support\DemoListingImageFactory; use Modules\Listing\Support\SampleListingImageCatalog;
use Modules\Location\Models\City; use Modules\Location\Models\City;
use Modules\Location\Models\Country; use Modules\Location\Models\Country;
use Modules\User\App\Models\User; use Modules\User\App\Models\User;
@ -143,7 +143,6 @@ class ListingSeeder extends Seeder
$location = $this->resolveLocation($index, $countries, $turkeyCities); $location = $this->resolveLocation($index, $countries, $turkeyCities);
$title = $this->buildTitle($category, $index, $user); $title = $this->buildTitle($category, $index, $user);
$slug = 'demo-'.Str::slug($user->email).'-'.$category->slug; $slug = 'demo-'.Str::slug($user->email).'-'.$category->slug;
$familyName = trim((string) ($category->parent?->name ?? $category->name));
return [ return [
'slug' => $slug, 'slug' => $slug,
@ -156,13 +155,7 @@ class ListingSeeder extends Seeder
'is_featured' => $index % 7 === 0, 'is_featured' => $index % 7 === 0,
'expires_at' => now()->addDays(21 + ($index % 9)), 'expires_at' => now()->addDays(21 + ($index % 9)),
'created_at' => now()->subHours(6 + $index), 'created_at' => now()->subHours(6 + $index),
'image_path' => DemoListingImageFactory::ensure( 'image_path' => SampleListingImageCatalog::pathFor($category, $index),
$slug,
$title,
$familyName,
$user->name,
$index
),
]; ];
} }
@ -216,7 +209,7 @@ class ListingSeeder extends Seeder
$location = trim(collect([$city, $country])->filter()->join(', ')); $location = trim(collect([$city, $country])->filter()->join(', '));
return sprintf( 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', $categoryName !== '' ? $categoryName : 'Item',
trim((string) $user->name) !== '' ? trim((string) $user->name) : 'a marketplace user', trim((string) $user->name) !== '' ? trim((string) $user->name) : 'a marketplace user',
$location !== '' ? $location : 'Turkey' $location !== '' ? $location : 'Turkey'
@ -272,8 +265,15 @@ class ListingSeeder extends Seeder
return $listing; 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)
);
} }
} }

View File

@ -178,7 +178,7 @@ class ListingController extends Controller
$listing->category_id ? (int) $listing->category_id : null, $listing->category_id ? (int) $listing->category_id : null,
$listing->custom_fields ?? [], $listing->custom_fields ?? [],
); );
$gallery = $listing->themeGallery(); $gallery = $listing->galleryImageData();
$listingVideos = $listing->getRelation('videos'); $listingVideos = $listing->getRelation('videos');
$relatedListings = $listing->relatedSuggestions(12); $relatedListings = $listing->relatedSuggestions(12);
$themePillCategories = Category::themePills(10); $themePillCategories = Category::themePills(10);

View File

@ -10,13 +10,16 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Modules\Category\Models\Category; use Modules\Category\Models\Category;
use Modules\Listing\Support\ListingImageViewData;
use Modules\Listing\States\ListingStatus; use Modules\Listing\States\ListingStatus;
use Modules\Listing\Support\ListingPanelHelper; use Modules\Listing\Support\ListingPanelHelper;
use Modules\Video\Models\Video; use Modules\Video\Models\Video;
use Spatie\Image\Enums\Fit;
use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Spatie\ModelStates\HasStates; use Spatie\ModelStates\HasStates;
use Throwable; use Throwable;
@ -183,22 +186,56 @@ class Listing extends Model implements HasMedia
public function themeGallery(): array public function themeGallery(): array
{ {
$mediaUrls = $this->getMedia('listing-images') return collect($this->galleryImageData())
->map(fn ($media): string => $media->getUrl()) ->map(fn (array $image): ?string => ListingImageViewData::pickUrl($image['gallery'] ?? null))
->filter(fn (string $url): bool => $url !== '') ->filter(fn (?string $url): bool => is_string($url) && $url !== '')
->values() ->values()
->all(); ->all();
}
if ($mediaUrls !== []) { public function galleryImageData(): array
return $mediaUrls; {
$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 ?? []) return collect($this->images ?? [])
->filter(fn ($value): bool => is_string($value) && trim($value) !== '') ->filter(fn ($value): bool => is_string($value) && trim($value) !== '')
->map(fn (string $url): array => [
'gallery' => ListingImageViewData::fromUrl($url),
'thumb' => ListingImageViewData::fromUrl($url),
])
->values() ->values()
->all(); ->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 public function relatedSuggestions(int $limit = 8): Collection
{ {
$baseQuery = static::query() $baseQuery = static::query()
@ -254,9 +291,7 @@ class Listing extends Model implements HasMedia
public function panelPrimaryImageUrl(): ?string public function panelPrimaryImageUrl(): ?string
{ {
$url = trim((string) $this->getFirstMediaUrl('listing-images')); return $this->primaryImageUrl('card', 'desktop');
return $url !== '' ? $url : null;
} }
public function panelPriceLabel(): string public function panelPriceLabel(): string
@ -444,7 +479,73 @@ class Listing extends Model implements HasMedia
public function registerMediaCollections(): void 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 protected function location(): Attribute

View File

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

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

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

View File

@ -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"> <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) @foreach($listings as $listing)
@php @php
$listingImage = $listing->getFirstMediaUrl('listing-images'); $listingImage = $listing->primaryImageData('card');
$isFavorited = in_array($listing->id, $favoriteListingIds ?? [], true); $isFavorited = in_array($listing->id, $favoriteListingIds ?? [], true);
$priceValue = ! is_null($listing->price) ? (float) $listing->price : null; $priceValue = ! is_null($listing->price) ? (float) $listing->price : null;
$locationParts = array_filter([ $locationParts = array_filter([
@ -244,7 +244,11 @@
<div class="relative h-52 bg-slate-200"> <div class="relative h-52 bg-slate-200">
@if($listingImage) @if($listingImage)
<a href="{{ route('listings.show', $listing) }}" class="block w-full h-full"> <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> </a>
@else @else
<a href="{{ route('listings.show', $listing) }}" class="w-full h-full grid place-items-center text-slate-400"> <a href="{{ route('listings.show', $listing) }}" class="w-full h-full grid place-items-center text-slate-400">

View File

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

View File

@ -22,10 +22,10 @@
$publishedAt = $listing->created_at?->format('M j, Y') ?? 'Recently'; $publishedAt = $listing->created_at?->format('M j, Y') ?? 'Recently';
$postedAgo = $listing->created_at?->diffForHumans() ?? 'Listed recently'; $postedAgo = $listing->created_at?->diffForHumans() ?? 'Listed recently';
$galleryImages = collect($gallery ?? []) $galleryImages = collect($gallery ?? [])
->filter(fn ($value) => is_string($value) && trim($value) !== '') ->filter(fn ($value) => is_array($value) && is_array($value['gallery'] ?? null))
->values() ->values()
->all(); ->all();
$initialGalleryImage = $galleryImages[0] ?? null; $initialGalleryImage = $galleryImages[0]['gallery'] ?? null;
$galleryCount = count($galleryImages); $galleryCount = count($galleryImages);
$description = trim((string) ($listing->description ?? '')); $description = trim((string) ($listing->description ?? ''));
@ -143,7 +143,23 @@
</div> </div>
@if($initialGalleryImage) @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 @else
<div class="lt-gallery-main-empty">No photos uploaded yet.</div> <div class="lt-gallery-main-empty">No photos uploaded yet.</div>
@endif @endif
@ -171,15 +187,25 @@
@if($galleryImages !== []) @if($galleryImages !== [])
<div class="lt-thumbs" data-gallery-thumbs> <div class="lt-thumbs" data-gallery-thumbs>
@foreach($galleryImages as $index => $image) @foreach($galleryImages as $index => $image)
@php
$galleryImage = $image['gallery'] ?? null;
$thumbImage = $image['thumb'] ?? $galleryImage;
@endphp
<button <button
type="button" type="button"
class="lt-thumb {{ $index === 0 ? 'is-active' : '' }}" class="lt-thumb {{ $index === 0 ? 'is-active' : '' }}"
data-gallery-thumb data-gallery-thumb
data-gallery-index="{{ $index }}" 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 }}" 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> </button>
@endforeach @endforeach
</div> </div>
@ -439,11 +465,7 @@
<div class="lt-scroll-track" data-theme-scroll-track> <div class="lt-scroll-track" data-theme-scroll-track>
@foreach(($relatedListings ?? collect()) as $related) @foreach(($relatedListings ?? collect()) as $related)
@php @php
$relatedImage = $related->getFirstMediaUrl('listing-images'); $relatedImage = $related->primaryImageData('card');
if (! $relatedImage && is_array($related->images ?? null)) {
$relatedImage = collect($related->images)->first();
}
$relatedPrice = 'Price on request'; $relatedPrice = 'Price on request';
if (! is_null($related->price)) { if (! is_null($related->price)) {
$relatedPriceValue = (float) $related->price; $relatedPriceValue = (float) $related->price;
@ -458,7 +480,11 @@
<a href="{{ route('listings.show', $related) }}" class="lt-rel-card"> <a href="{{ route('listings.show', $related) }}" class="lt-rel-card">
<div class="lt-rel-photo"> <div class="lt-rel-photo">
@if($relatedImage) @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 @endif
</div> </div>
<div class="lt-rel-body"> <div class="lt-rel-body">
@ -496,6 +522,8 @@
(() => { (() => {
document.querySelectorAll('[data-gallery]').forEach((galleryRoot) => { document.querySelectorAll('[data-gallery]').forEach((galleryRoot) => {
const mainImage = galleryRoot.querySelector('[data-gallery-main]'); 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 thumbButtons = Array.from(galleryRoot.querySelectorAll('[data-gallery-thumb]'));
const prevButton = galleryRoot.querySelector('[data-gallery-prev]'); const prevButton = galleryRoot.querySelector('[data-gallery-prev]');
const nextButton = galleryRoot.querySelector('[data-gallery-next]'); const nextButton = galleryRoot.querySelector('[data-gallery-next]');
@ -517,9 +545,20 @@
} }
activeIndex = index; activeIndex = index;
const src = thumbButtons[index].dataset.gallerySrc; const mobileSrc = thumbButtons[index].dataset.galleryMobileSrc || '';
if (src) { const desktopSrc = thumbButtons[index].dataset.galleryDesktopSrc || '';
mainImage.src = src; 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) { if (currentCounter) {

View File

@ -3,7 +3,7 @@
@php @php
$menuCategories = $categories->take(8); $menuCategories = $categories->take(8);
$heroListing = $featuredListings->first() ?? $recentListings->first(); $heroListing = $featuredListings->first() ?? $recentListings->first();
$heroImage = $heroListing?->getFirstMediaUrl('listing-images'); $heroImage = $heroListing?->primaryImageData('gallery');
$listingCards = $recentListings->take(6); $listingCards = $recentListings->take(6);
$demoEnabled = (bool) config('demo.enabled'); $demoEnabled = (bool) config('demo.enabled');
$prepareDemoRoute = $demoEnabled ? route('demo.prepare') : null; $prepareDemoRoute = $demoEnabled ? route('demo.prepare') : null;
@ -180,7 +180,13 @@
@if($slide['image_url']) @if($slide['image_url'])
<img src="{{ $slide['image_url'] }}" alt="{{ $slide['title'] }}" class="w-full h-full object-cover rounded-2xl"> <img src="{{ $slide['image_url'] }}" alt="{{ $slide['title'] }}" class="w-full h-full object-cover rounded-2xl">
@elseif($heroImage) @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 @else
<div class="w-full h-full rounded-2xl bg-white/90 text-slate-800 flex flex-col justify-center items-center gap-3"> <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> <span class="text-6xl"></span>
@ -274,7 +280,7 @@
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 md:gap-4"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-3 md:gap-4">
@forelse($listingCards as $listing) @forelse($listingCards as $listing)
@php @php
$listingImage = $listing->getFirstMediaUrl('listing-images'); $listingImage = $listing->primaryImageData('card');
$priceLabel = $listing->price ? number_format((float) $listing->price, 0).' '.$listing->currency : __('messages.free'); $priceLabel = $listing->price ? number_format((float) $listing->price, 0).' '.$listing->currency : __('messages.free');
$locationLabel = trim(collect([$listing->city, $listing->country])->filter()->join(', ')); $locationLabel = trim(collect([$listing->city, $listing->country])->filter()->join(', '));
$isFavorited = in_array($listing->id, $favoriteListingIds ?? [], true); $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"> <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 }}"> <a href="{{ route('listings.show', $listing) }}" class="block h-full w-full" aria-label="{{ $listing->title }}">
@if($listingImage) @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 @else
<div class="w-full h-full grid place-items-center text-slate-400"> <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"> <svg class="w-14 h-14" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@ -131,7 +131,7 @@
@forelse ($listings as $listing) @forelse ($listings as $listing)
@php @php
$statusMeta = $listing->panelStatusMeta(); $statusMeta = $listing->panelStatusMeta();
$listingImage = $listing->panelPrimaryImageUrl(); $listingImage = $listing->primaryImageData('card');
$priceLabel = $listing->panelPriceLabel(); $priceLabel = $listing->panelPriceLabel();
$favoriteCount = (int) ($listing->favorited_by_users_count ?? 0); $favoriteCount = (int) ($listing->favorited_by_users_count ?? 0);
$viewCount = (int) ($listing->view_count ?? 0); $viewCount = (int) ($listing->view_count ?? 0);
@ -147,7 +147,11 @@
<article class="listings-dashboard-card"> <article class="listings-dashboard-card">
<a href="{{ route('listings.show', $listing) }}" class="listings-dashboard-media" aria-label="{{ $listing->title }}"> <a href="{{ route('listings.show', $listing) }}" class="listings-dashboard-media" aria-label="{{ $listing->title }}">
@if ($listingImage) @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 @else
<div class="listings-dashboard-placeholder"> <div class="listings-dashboard-placeholder">
<span>No image</span> <span>No image</span>

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB