Add demo mode seeders and UI tweaks

This commit is contained in:
fatihalp 2026-03-07 17:07:32 +03:00
parent 6ee6da3d83
commit 93ce5a0925
45 changed files with 2271 additions and 61 deletions

View File

@ -36,6 +36,7 @@ use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\Location\Models\City;
use Modules\Location\Models\Country;
use Modules\Video\Support\Filament\VideoFormSchema;
use UnitEnum;
use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput;
@ -125,6 +126,7 @@ class ListingResource extends Resource
->multiple()
->image()
->reorderable(),
VideoFormSchema::listingSection(),
]);
}

View File

@ -12,6 +12,7 @@ use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\View\PanelsRenderHook;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
@ -37,8 +38,10 @@ class AdminPanelProvider extends PanelProvider
->login()
->colors(['primary' => Color::Blue])
->discoverResources(in: module_path('Admin', 'Filament/Resources'), for: 'Modules\\Admin\\Filament\\Resources')
->discoverResources(in: module_path('Video', 'Filament/Admin/Resources'), for: 'Modules\\Video\\Filament\\Admin\\Resources')
->discoverPages(in: module_path('Admin', 'Filament/Pages'), for: 'Modules\\Admin\\Filament\\Pages')
->discoverWidgets(in: module_path('Admin', 'Filament/Widgets'), for: 'Modules\\Admin\\Filament\\Widgets')
->renderHook(PanelsRenderHook::BODY_END, fn () => view('video::partials.video-upload-optimizer'))
->userMenuItems([
'view-site' => MenuItem::make()
->label('View Site')

View File

@ -0,0 +1,118 @@
<?php
namespace Modules\Conversation\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Modules\Conversation\App\Models\Conversation;
use Modules\Conversation\App\Models\ConversationMessage;
use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User;
class ConversationDemoSeeder extends Seeder
{
public function run(): void
{
if (! $this->conversationTablesExist()) {
return;
}
$admin = User::query()->where('email', 'a@a.com')->first();
$partner = User::query()->where('email', 'b@b.com')->first();
if (! $admin || ! $partner) {
return;
}
$listings = Listing::query()
->where('user_id', $admin->getKey())
->where('status', 'active')
->orderBy('id')
->take(2)
->get();
if ($listings->count() < 2) {
return;
}
$this->seedConversationThread(
$listings->get(0),
$admin,
$partner,
[
['sender' => 'partner', 'body' => 'Hi, is this still available?', 'hours_ago' => 30, 'read_after_minutes' => 4],
['sender' => 'admin', 'body' => 'Yes, it is available. I can share more photos.', 'hours_ago' => 29, 'read_after_minutes' => 7],
['sender' => 'partner', 'body' => 'Perfect. Can we meet tomorrow afternoon?', 'hours_ago' => 4, 'read_after_minutes' => null],
]
);
$this->seedConversationThread(
$listings->get(1),
$admin,
$partner,
[
['sender' => 'partner', 'body' => 'Can you confirm the final price?', 'hours_ago' => 20, 'read_after_minutes' => 8],
['sender' => 'admin', 'body' => 'I can do a small discount if you pick it up today.', 'hours_ago' => 18, 'read_after_minutes' => null],
]
);
}
private function conversationTablesExist(): bool
{
return Schema::hasTable('conversations') && Schema::hasTable('conversation_messages');
}
private function seedConversationThread(
?Listing $listing,
User $admin,
User $partner,
array $messages
): void {
if (! $listing) {
return;
}
$conversation = Conversation::updateOrCreate(
[
'listing_id' => $listing->getKey(),
'buyer_id' => $partner->getKey(),
],
[
'seller_id' => $admin->getKey(),
'last_message_at' => now(),
]
);
ConversationMessage::query()
->where('conversation_id', $conversation->getKey())
->delete();
$lastMessageAt = null;
foreach ($messages as $payload) {
$createdAt = now()->subHours((int) $payload['hours_ago']);
$sender = ($payload['sender'] ?? 'partner') === 'admin' ? $admin : $partner;
$readAfterMinutes = $payload['read_after_minutes'];
$readAt = is_numeric($readAfterMinutes) ? $createdAt->copy()->addMinutes((int) $readAfterMinutes) : null;
$message = new ConversationMessage();
$message->forceFill([
'conversation_id' => $conversation->getKey(),
'sender_id' => $sender->getKey(),
'body' => (string) $payload['body'],
'read_at' => $readAt,
'created_at' => $createdAt,
'updated_at' => $readAt ?? $createdAt,
])->save();
$lastMessageAt = $createdAt;
}
$conversation->forceFill([
'seller_id' => $admin->getKey(),
'last_message_at' => $lastMessageAt,
'updated_at' => $lastMessageAt,
])->saveQuietly();
}
}

View File

@ -0,0 +1,173 @@
<?php
namespace Modules\Favorite\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\Category\Models\Category;
use Modules\Favorite\App\Models\FavoriteSearch;
use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User;
class FavoriteDemoSeeder extends Seeder
{
public function run(): void
{
if (! $this->favoriteTablesExist()) {
return;
}
$admin = User::query()->where('email', 'a@a.com')->first();
$partner = User::query()->where('email', 'b@b.com')->first();
if (! $admin || ! $partner) {
return;
}
$adminListings = Listing::query()
->where('user_id', $admin->getKey())
->orderByDesc('is_featured')
->orderBy('id')
->get();
if ($adminListings->isEmpty()) {
return;
}
$activeAdminListings = $adminListings->where('status', 'active')->values();
$this->seedFavoriteListings(
$partner,
$activeAdminListings->take(6)
);
$this->seedFavoriteListings(
$admin,
$adminListings->take(3)->values()
);
$this->seedFavoriteSeller($partner, $admin, now()->subDays(2));
$this->seedFavoriteSeller($admin, $partner, now()->subDays(1));
$this->seedFavoriteSearches($partner, $this->partnerSearchPayloads());
$this->seedFavoriteSearches($admin, $this->adminSearchPayloads());
}
private function favoriteTablesExist(): bool
{
return Schema::hasTable('favorite_listings')
&& Schema::hasTable('favorite_sellers')
&& Schema::hasTable('favorite_searches');
}
private function seedFavoriteListings(User $user, Collection $listings): void
{
$rows = $listings
->values()
->map(function (Listing $listing, int $index) use ($user): array {
$timestamp = now()->subHours(12 + ($index * 5));
return [
'user_id' => $user->getKey(),
'listing_id' => $listing->getKey(),
'created_at' => $timestamp,
'updated_at' => $timestamp,
];
})
->all();
if ($rows === []) {
return;
}
DB::table('favorite_listings')->upsert(
$rows,
['user_id', 'listing_id'],
['updated_at']
);
}
private function seedFavoriteSeller(User $user, User $seller, \Illuminate\Support\Carbon $timestamp): void
{
if ((int) $user->getKey() === (int) $seller->getKey()) {
return;
}
DB::table('favorite_sellers')->upsert(
[[
'user_id' => $user->getKey(),
'seller_id' => $seller->getKey(),
'created_at' => $timestamp,
'updated_at' => $timestamp,
]],
['user_id', 'seller_id'],
['updated_at']
);
}
private function seedFavoriteSearches(User $user, array $payloads): void
{
foreach ($payloads as $index => $payload) {
$filters = FavoriteSearch::normalizeFilters([
'search' => $payload['search'] ?? null,
'category' => $payload['category_id'] ?? null,
]);
if ($filters === []) {
continue;
}
$signature = FavoriteSearch::signatureFor($filters);
$categoryName = null;
if (! empty($payload['category_id'])) {
$categoryName = Category::query()->whereKey($payload['category_id'])->value('name');
}
$favoriteSearch = FavoriteSearch::updateOrCreate(
[
'user_id' => $user->getKey(),
'signature' => $signature,
],
[
'label' => FavoriteSearch::labelFor($filters, is_string($categoryName) ? $categoryName : null),
'search_term' => $filters['search'] ?? null,
'category_id' => $filters['category'] ?? null,
'filters' => $filters,
]
);
$timestamp = now()->subDays($index + 1);
$favoriteSearch->forceFill([
'created_at' => $favoriteSearch->wasRecentlyCreated ? $timestamp : $favoriteSearch->created_at,
'updated_at' => $timestamp,
])->saveQuietly();
}
}
private function partnerSearchPayloads(): array
{
$electronicsId = Category::query()->where('name', 'Electronics')->value('id');
$vehiclesId = Category::query()->where('name', 'Vehicles')->value('id');
$realEstateId = Category::query()->where('name', 'Real Estate')->value('id');
return [
['search' => 'iphone', 'category_id' => $electronicsId],
['search' => 'sedan', 'category_id' => $vehiclesId],
['search' => 'apartment', 'category_id' => $realEstateId],
];
}
private function adminSearchPayloads(): array
{
$fashionId = Category::query()->where('name', 'Fashion')->value('id');
$homeGardenId = Category::query()->where('name', 'Home & Garden')->value('id');
return [
['search' => 'vintage', 'category_id' => $fashionId],
['search' => 'garden', 'category_id' => $homeGardenId],
];
}
}

View File

@ -0,0 +1,175 @@
<?php
namespace Modules\Listing\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User;
class ListingPanelDemoSeeder extends Seeder
{
private const PANEL_LISTINGS = [
[
'slug' => 'admin-demo-sold-camera',
'title' => 'Admin Demo Camera Bundle',
'description' => 'Sample sold listing for the panel filters and activity cards.',
'price' => 18450,
'status' => 'sold',
'city' => 'Istanbul',
'country' => 'Turkey',
'image' => 'sample_image/macbook.jpg',
'expires_offset_days' => 12,
'is_featured' => false,
],
[
'slug' => 'admin-demo-expired-sofa',
'title' => 'Admin Demo Sofa Set',
'description' => 'Sample expired listing for the panel filters and republish flow.',
'price' => 9800,
'status' => 'expired',
'city' => 'Ankara',
'country' => 'Turkey',
'image' => 'sample_image/cup.jpg',
'expires_offset_days' => -5,
'is_featured' => false,
],
[
'slug' => 'admin-demo-expired-bike',
'title' => 'Admin Demo City Bike',
'description' => 'Extra expired sample listing so My Listings is not empty in filtered views.',
'price' => 6200,
'status' => 'expired',
'city' => 'Izmir',
'country' => 'Turkey',
'image' => 'sample_image/car2.jpeg',
'expires_offset_days' => -11,
'is_featured' => false,
],
];
public function run(): void
{
$admin = $this->resolveAdminUser();
if (! $admin) {
return;
}
$this->claimAllListingsForAdmin($admin);
$categories = $this->resolveCategories();
if ($categories->isEmpty()) {
return;
}
foreach (self::PANEL_LISTINGS as $index => $payload) {
$category = $categories->get($index % $categories->count());
if (! $category instanceof Category) {
continue;
}
$listing = Listing::updateOrCreate(
['slug' => $payload['slug']],
[
'slug' => $payload['slug'],
'title' => $payload['title'],
'description' => $payload['description'],
'price' => $payload['price'],
'currency' => 'TRY',
'city' => $payload['city'],
'country' => $payload['country'],
'category_id' => $category->getKey(),
'user_id' => $admin->getKey(),
'status' => $payload['status'],
'contact_email' => $admin->email,
'contact_phone' => '+905551112233',
'is_featured' => $payload['is_featured'],
'expires_at' => now()->addDays((int) $payload['expires_offset_days']),
]
);
$this->syncListingImage($listing, (string) $payload['image']);
}
}
private function resolveAdminUser(): ?User
{
return User::query()->where('email', 'a@a.com')->first()
?? User::query()->whereHas('roles', fn ($query) => $query->where('name', 'admin'))->first()
?? User::query()->first();
}
private function claimAllListingsForAdmin(User $admin): void
{
Listing::query()
->where(function ($query) use ($admin): void {
$query
->whereNull('user_id')
->orWhere('user_id', '!=', $admin->getKey());
})
->update([
'user_id' => $admin->getKey(),
'contact_email' => $admin->email,
'updated_at' => now(),
]);
}
private function resolveCategories(): Collection
{
$leafCategories = Category::query()
->where('is_active', true)
->whereDoesntHave('children')
->orderBy('sort_order')
->orderBy('name')
->get();
if ($leafCategories->isNotEmpty()) {
return $leafCategories->values();
}
return Category::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('name')
->get()
->values();
}
private function syncListingImage(Listing $listing, string $imageRelativePath): void
{
$imageAbsolutePath = public_path($imageRelativePath);
if (! is_file($imageAbsolutePath)) {
return;
}
$targetFileName = basename($imageAbsolutePath);
$existingMedia = $listing->getMedia('listing-images')->first();
if (
$existingMedia
&& (string) $existingMedia->file_name === $targetFileName
&& (string) $existingMedia->disk === 'public'
) {
try {
if (is_file($existingMedia->getPath())) {
return;
}
} catch (\Throwable) {
}
}
$listing->clearMediaCollection('listing-images');
$listing
->addMedia($imageAbsolutePath)
->usingFileName(Str::slug($listing->slug).'-'.basename($imageAbsolutePath))
->preservingOriginal()
->toMediaCollection('listing-images', 'public');
}
}

View File

@ -55,10 +55,12 @@ class ListingSeeder extends Seeder
private function resolveSeederUser(): ?User
{
return User::query()
->where('email', 'b@b.com')
->orWhere('email', 'partner@openclassify.com')
->first();
return User::query()->where('email', 'a@a.com')->first()
?? User::query()->where('email', 'admin@openclassify.com')->first()
?? User::query()
->whereHas('roles', fn ($query) => $query->where('name', 'admin'))
->first()
?? User::query()->first();
}
private function resolveSeedableCategories(): Collection

View File

@ -164,12 +164,14 @@ class ListingController extends Controller
'category:id,name,parent_id,slug',
'category.parent:id,name,parent_id,slug',
'category.parent.parent:id,name,parent_id,slug',
'videos' => fn ($query) => $query->published()->ordered(),
]);
$presentableCustomFields = ListingCustomFieldSchemaBuilder::presentableValues(
$listing->category_id ? (int) $listing->category_id : null,
$listing->custom_fields ?? [],
);
$gallery = $listing->themeGallery();
$listingVideos = $listing->getRelation('videos');
$relatedListings = $listing->relatedSuggestions(12);
$themePillCategories = Category::themePills(10);
$breadcrumbCategories = $listing->category
@ -210,6 +212,7 @@ class ListingController extends Controller
'presentableCustomFields',
'existingConversationId',
'gallery',
'listingVideos',
'relatedListings',
'themePillCategories',
'breadcrumbCategories',

View File

@ -11,6 +11,7 @@ use Illuminate\Support\Str;
use Modules\Category\Models\Category;
use Modules\Listing\States\ListingStatus;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\Video\Models\Video;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\MediaLibrary\HasMedia;
@ -71,6 +72,11 @@ class Listing extends Model implements HasMedia
return $this->hasMany(\Modules\Conversation\App\Models\Conversation::class);
}
public function videos()
{
return $this->hasMany(Video::class)->ordered();
}
public function scopePublicFeed(Builder $query): Builder
{
return $query
@ -178,7 +184,7 @@ class Listing extends Model implements HasMedia
{
$baseQuery = static::query()
->publicFeed()
->with('category:id,name')
->with(['category:id,name', 'videos'])
->whereKeyNot($this->getKey());
$primary = (clone $baseQuery)

View File

@ -77,6 +77,19 @@
<h2 class="font-semibold text-lg mb-2">Description</h2>
<p class="text-gray-700">{{ $displayDescription }}</p>
</div>
@if(($listingVideos ?? collect())->isNotEmpty())
<div class="mt-6 border-t pt-4">
<h2 class="font-semibold text-lg mb-3">Videos</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
@foreach($listingVideos as $video)
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<video class="w-full rounded-lg bg-black" controls preload="metadata" src="{{ $video->playableUrl() }}"></video>
<p class="mt-2 text-sm font-semibold text-slate-800">{{ $video->titleLabel() }}</p>
</div>
@endforeach
</div>
</div>
@endif
@if(($presentableCustomFields ?? []) !== [])
<div class="mt-6 border-t pt-4">
<h2 class="font-semibold text-lg mb-3">İlan Özellikleri</h2>

View File

@ -126,6 +126,20 @@
@endforeach
</div>
</section>
@if(($listingVideos ?? collect())->isNotEmpty())
<section class="lt-card lt-detail-card">
<h2 class="lt-section-title">Videolar</h2>
<div class="grid gap-4 md:grid-cols-2 mt-4">
@foreach($listingVideos as $video)
<div class="rounded-2xl border border-slate-200 bg-white p-3">
<video class="w-full rounded-xl bg-slate-950" controls preload="metadata" src="{{ $video->playableUrl() }}"></video>
<p class="mt-3 text-sm font-semibold text-slate-800">{{ $video->titleLabel() }}</p>
</div>
@endforeach
</div>
</section>
@endif
</div>
<aside class="lt-card lt-side-card">

View File

@ -34,6 +34,7 @@ use Modules\Listing\Support\ListingPanelHelper;
use Modules\Location\Models\City;
use Modules\Location\Models\Country;
use Modules\Partner\Filament\Resources\ListingResource\Pages;
use Modules\Video\Support\Filament\VideoFormSchema;
use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput;
class ListingResource extends Resource
@ -157,6 +158,7 @@ class ListingResource extends Resource
->multiple()
->image()
->reorderable(),
VideoFormSchema::listingSection(),
]);
}

View File

@ -13,6 +13,7 @@ use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\View\PanelsRenderHook;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
@ -38,8 +39,10 @@ class PartnerPanelProvider extends PanelProvider
->colors(['primary' => Color::Emerald])
->tenant(User::class, slugAttribute: 'id')
->discoverResources(in: module_path('Partner', 'Filament/Resources'), for: 'Modules\\Partner\\Filament\\Resources')
->discoverResources(in: module_path('Video', 'Filament/Partner/Resources'), for: 'Modules\\Video\\Filament\\Partner\\Resources')
->discoverPages(in: module_path('Partner', 'Filament/Pages'), for: 'Modules\\Partner\\Filament\\Pages')
->discoverWidgets(in: module_path('Partner', 'Filament/Widgets'), for: 'Modules\\Partner\\Filament\\Widgets')
->renderHook(PanelsRenderHook::BODY_END, fn () => view('video::partials.video-upload-optimizer'))
->plugins([
FilamentStateFusionPlugin::make(),
BreezyCore::make()

View File

@ -66,6 +66,7 @@ class User extends Authenticatable implements FilamentUser, HasTenants, HasAvata
{
return match ($panel->getId()) {
'admin' => $this->hasRole('admin'),
'partner' => true,
default => false,
};
}

View File

@ -0,0 +1,38 @@
<?php
namespace Modules\Video\Enums;
enum VideoStatus: string
{
case Pending = 'pending';
case Processing = 'processing';
case Ready = 'ready';
case Failed = 'failed';
public function label(): string
{
return match ($this) {
self::Pending => 'Queued',
self::Processing => 'Processing',
self::Ready => 'Ready',
self::Failed => 'Failed',
};
}
public function color(): string
{
return match ($this) {
self::Pending => 'warning',
self::Processing => 'info',
self::Ready => 'success',
self::Failed => 'danger',
};
}
public static function options(): array
{
return collect(self::cases())
->mapWithKeys(fn (self $status): array => [$status->value => $status->label()])
->all();
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace Modules\Video\Filament\Admin\Resources;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Table;
use Modules\Video\Filament\Admin\Resources\VideoResource\Pages;
use Modules\Video\Models\Video;
use Modules\Video\Support\Filament\VideoFormSchema;
use Modules\Video\Support\Filament\VideoTableSchema;
use UnitEnum;
class VideoResource extends Resource
{
protected static ?string $model = Video::class;
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-film';
protected static string | UnitEnum | null $navigationGroup = 'Catalog';
protected static ?string $navigationLabel = 'Videos';
public static function form(Schema $schema): Schema
{
return $schema->schema(VideoFormSchema::resourceSchema());
}
public static function table(Table $table): Table
{
return VideoTableSchema::configure($table);
}
public static function getPages(): array
{
return [
'index' => Pages\ListVideos::route('/'),
'create' => Pages\CreateVideo::route('/create'),
'edit' => Pages\EditVideo::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace Modules\Video\Filament\Admin\Resources\VideoResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\Video\Filament\Admin\Resources\VideoResource;
class CreateVideo extends CreateRecord
{
protected static string $resource = VideoResource::class;
}

View File

@ -0,0 +1,19 @@
<?php
namespace Modules\Video\Filament\Admin\Resources\VideoResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Video\Filament\Admin\Resources\VideoResource;
class EditVideo extends EditRecord
{
protected static string $resource = VideoResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Modules\Video\Filament\Admin\Resources\VideoResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Video\Filament\Admin\Resources\VideoResource;
class ListVideos extends ListRecords
{
protected static string $resource = VideoResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace Modules\Video\Filament\Partner\Resources;
use BackedEnum;
use Filament\Facades\Filament;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Modules\Video\Filament\Partner\Resources\VideoResource\Pages;
use Modules\Video\Models\Video;
use Modules\Video\Support\Filament\VideoFormSchema;
use Modules\Video\Support\Filament\VideoTableSchema;
class VideoResource extends Resource
{
protected static ?string $model = Video::class;
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-film';
protected static ?string $navigationLabel = 'Videos';
public static function form(Schema $schema): Schema
{
return $schema->schema(VideoFormSchema::resourceSchema(partnerScoped: true));
}
public static function table(Table $table): Table
{
return VideoTableSchema::configure($table, showOwner: false);
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->whereHas('listing', fn (Builder $query): Builder => $query->where('user_id', Filament::auth()->id()));
}
public static function getPages(): array
{
return [
'index' => Pages\ListVideos::route('/'),
'create' => Pages\CreateVideo::route('/create'),
'edit' => Pages\EditVideo::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace Modules\Video\Filament\Partner\Resources\VideoResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\Video\Filament\Partner\Resources\VideoResource;
class CreateVideo extends CreateRecord
{
protected static string $resource = VideoResource::class;
}

View File

@ -0,0 +1,19 @@
<?php
namespace Modules\Video\Filament\Partner\Resources\VideoResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Video\Filament\Partner\Resources\VideoResource;
class EditVideo extends EditRecord
{
protected static string $resource = VideoResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Modules\Video\Filament\Partner\Resources\VideoResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Video\Filament\Partner\Resources\VideoResource;
class ListVideos extends ListRecords
{
protected static string $resource = VideoResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace Modules\Video\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
use Modules\Video\Models\Video;
use Modules\Video\Support\VideoTranscoder;
use Throwable;
class ProcessVideo implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $timeout;
public function __construct(public int $videoId)
{
$this->timeout = (int) config('video.timeout', 1800);
}
public function handle(VideoTranscoder $transcoder): void
{
$video = Video::query()->find($this->videoId);
if (! $video || blank($video->upload_path)) {
return;
}
$video->markAsProcessing();
try {
$video->markAsProcessed($transcoder->transcode($video));
} catch (Throwable $exception) {
report($exception);
$video->markAsFailed(Str::limit(trim($exception->getMessage()), 500));
}
}
}

View File

@ -0,0 +1,397 @@
<?php
namespace Modules\Video\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User;
use Modules\Video\Enums\VideoStatus;
use Modules\Video\Jobs\ProcessVideo;
class Video extends Model
{
protected $fillable = [
'listing_id',
'user_id',
'title',
'description',
'upload_disk',
'upload_path',
'mime_type',
'size',
'sort_order',
'is_active',
];
protected ?string $previousUploadDisk = null;
protected ?string $previousUploadPath = null;
protected function casts(): array
{
return [
'is_active' => 'boolean',
'processed_at' => 'datetime',
'status' => VideoStatus::class,
];
}
protected static function booted(): void
{
static::saving(function (self $video): void {
$video->rememberPreviousUpload();
$video->syncListingOwner();
$video->normalizeStatus();
});
static::saved(function (self $video): void {
$video->deletePreviousUploadIfReplaced();
$video->scheduleProcessingIfNeeded();
});
static::deleting(function (self $video): void {
$video->deleteStoredFiles();
});
}
public function listing()
{
return $this->belongsTo(Listing::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function scopeOrdered(Builder $query): Builder
{
return $query->orderBy('sort_order')->orderBy('id');
}
public function scopeReady(Builder $query): Builder
{
return $query->where('status', VideoStatus::Ready->value);
}
public function scopePublished(Builder $query): Builder
{
return $query
->where('is_active', true)
->whereNotNull('path');
}
public static function createFromTemporaryUpload(Listing $listing, TemporaryUploadedFile $file, array $attributes = []): self
{
$disk = (string) config('video.disk', 'public');
$path = $file->storeAs(
trim((string) config('video.upload_directory', 'videos/uploads').'/'.$listing->getKey(), '/'),
Str::ulid().'.'.($file->getClientOriginalExtension() ?: $file->guessExtension() ?: 'mp4'),
$disk,
);
return static::query()->create([
'listing_id' => $listing->getKey(),
'user_id' => $listing->user_id,
'title' => trim((string) ($attributes['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME))),
'description' => $attributes['description'] ?? null,
'upload_disk' => $disk,
'upload_path' => $path,
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'sort_order' => (int) ($attributes['sort_order'] ?? static::nextSortOrderForListing($listing)),
'is_active' => (bool) ($attributes['is_active'] ?? true),
]);
}
public static function nextSortOrderForListing(Listing $listing): int
{
return ((int) $listing->videos()->max('sort_order')) + 1;
}
public function markAsProcessing(): void
{
if (blank($this->upload_path)) {
return;
}
$this->forceFill([
'status' => VideoStatus::Processing,
'processing_error' => null,
])->saveQuietly();
}
public function markAsProcessed(array $attributes): void
{
$previousDisk = $this->disk;
$previousPath = $this->path;
$uploadDisk = $this->upload_disk;
$uploadPath = $this->upload_path;
$this->forceFill([
'disk' => $attributes['disk'] ?? (string) config('video.disk', 'public'),
'path' => $attributes['path'] ?? null,
'upload_disk' => (string) config('video.disk', 'public'),
'upload_path' => null,
'mime_type' => $attributes['mime_type'] ?? 'video/mp4',
'size' => $attributes['size'] ?? null,
'duration_seconds' => $attributes['duration_seconds'] ?? null,
'width' => $attributes['width'] ?? null,
'height' => $attributes['height'] ?? null,
'status' => VideoStatus::Ready,
'processing_error' => null,
'processed_at' => now(),
])->saveQuietly();
if ($previousPath !== $this->path) {
$this->deleteStoredFile($previousDisk, $previousPath);
}
if ($uploadPath !== $this->path) {
$this->deleteStoredFile($uploadDisk, $uploadPath);
}
}
public function markAsFailed(string $message): void
{
$this->forceFill([
'status' => VideoStatus::Failed,
'processing_error' => trim($message),
])->saveQuietly();
}
public function playablePath(): ?string
{
$status = $this->currentStatus();
if (($status !== VideoStatus::Ready) && filled($this->upload_path)) {
return $this->upload_path;
}
if (filled($this->path)) {
return $this->path;
}
return $this->upload_path;
}
public function playableDisk(): string
{
$status = $this->currentStatus();
if (($status !== VideoStatus::Ready) && filled($this->upload_path)) {
return (string) ($this->upload_disk ?: config('video.disk', 'public'));
}
if (filled($this->path)) {
return (string) ($this->disk ?: config('video.disk', 'public'));
}
return (string) ($this->upload_disk ?: config('video.disk', 'public'));
}
public function playableUrl(): ?string
{
$path = $this->playablePath();
if (blank($path)) {
return null;
}
return Storage::disk($this->playableDisk())->url($path);
}
public function previewMimeType(): string
{
return (string) ($this->mime_type ?: 'video/mp4');
}
public function titleLabel(): string
{
$title = trim((string) $this->title);
if ($title !== '') {
return $title;
}
$name = trim((string) pathinfo((string) ($this->playablePath() ?? ''), PATHINFO_FILENAME));
if ($name !== '') {
return str($name)->after('--')->replace('-', ' ')->title()->toString();
}
return sprintf('Video #%d', $this->getKey());
}
public function statusLabel(): string
{
return $this->currentStatus()->label();
}
public function statusColor(): string
{
return $this->currentStatus()->color();
}
public function durationLabel(): string
{
$duration = (int) ($this->duration_seconds ?? 0);
if ($duration < 1) {
return '-';
}
return gmdate($duration >= 3600 ? 'H:i:s' : 'i:s', $duration);
}
public function resolutionLabel(): string
{
if (! $this->width || ! $this->height) {
return '-';
}
return "{$this->width}x{$this->height}";
}
public function sizeLabel(): string
{
$size = (int) ($this->size ?? 0);
if ($size < 1) {
return '-';
}
$units = ['B', 'KB', 'MB', 'GB'];
$power = min((int) floor(log($size, 1024)), count($units) - 1);
$value = $size / (1024 ** $power);
return number_format($value, $power === 0 ? 0 : 1).' '.$units[$power];
}
public function mobileOutputPath(): string
{
return trim(
(string) config('video.processed_directory', 'videos/mobile')
.'/'.$this->listing_id
.'/'.$this->getKey().'-'.Str::slug($this->titleLabel() ?: 'video').'.mp4',
'/',
);
}
protected function rememberPreviousUpload(): void
{
if (! ($this->exists && $this->isDirty('upload_path'))) {
$this->previousUploadDisk = null;
$this->previousUploadPath = null;
return;
}
$this->previousUploadDisk = filled($this->getOriginal('upload_disk'))
? (string) $this->getOriginal('upload_disk')
: (string) config('video.disk', 'public');
$this->previousUploadPath = filled($this->getOriginal('upload_path'))
? (string) $this->getOriginal('upload_path')
: null;
}
protected function syncListingOwner(): void
{
if (! $this->listing_id) {
return;
}
$ownerId = Listing::query()
->whereKey($this->listing_id)
->value('user_id');
if ($ownerId) {
$this->user_id = $ownerId;
}
}
protected function normalizeStatus(): void
{
if (blank($this->disk)) {
$this->disk = (string) config('video.disk', 'public');
}
if (blank($this->upload_disk)) {
$this->upload_disk = (string) config('video.disk', 'public');
}
if (! $this->isDirty('upload_path')) {
return;
}
if (filled($this->upload_path)) {
$this->status = $this->path ? VideoStatus::Processing : VideoStatus::Pending;
$this->processing_error = null;
return;
}
if (filled($this->path)) {
$this->status = VideoStatus::Ready;
$this->processing_error = null;
}
}
protected function deletePreviousUploadIfReplaced(): void
{
if (
blank($this->previousUploadPath)
|| ($this->previousUploadPath === $this->upload_path)
|| ($this->previousUploadPath === $this->path)
) {
return;
}
$this->deleteStoredFile($this->previousUploadDisk, $this->previousUploadPath);
}
protected function scheduleProcessingIfNeeded(): void
{
if (
blank($this->upload_path)
|| (! $this->wasRecentlyCreated)
&& (! $this->wasChanged('upload_path'))
) {
return;
}
ProcessVideo::dispatch($this->getKey())
->onQueue((string) config('video.queue', 'videos'))
->afterCommit();
}
protected function deleteStoredFiles(): void
{
$this->deleteStoredFile($this->disk, $this->path);
if ($this->upload_path !== $this->path) {
$this->deleteStoredFile($this->upload_disk, $this->upload_path);
}
}
protected function deleteStoredFile(?string $disk, ?string $path): void
{
if (blank($disk) || blank($path)) {
return;
}
Storage::disk($disk)->delete($path);
}
protected function currentStatus(): VideoStatus
{
return $this->status instanceof VideoStatus
? $this->status
: (VideoStatus::tryFrom((string) $this->status) ?? VideoStatus::Pending);
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Modules\Video\Providers;
use Illuminate\Support\ServiceProvider;
class VideoServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadViewsFrom(module_path('Video', 'resources/views'), 'video');
$this->loadMigrationsFrom(module_path('Video', 'database/migrations'));
}
public function register(): void
{
$this->mergeConfigFrom(module_path('Video', 'config/video.php'), 'video');
}
}

View File

@ -0,0 +1,206 @@
<?php
namespace Modules\Video\Support\Filament;
use Filament\Facades\Filament;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Modules\Video\Models\Video;
class VideoFormSchema
{
public static function listingSection(): Section
{
return Section::make('Videos')
->description('Uploads are optimized in the browser when supported, then converted to a mobile MP4 in the queue.')
->schema([
self::listingRepeater(),
])
->columnSpanFull();
}
public static function resourceSchema(bool $partnerScoped = false): array
{
return [
Section::make('Video')
->schema([
self::listingField($partnerScoped),
self::titleField(),
self::activeField(),
self::descriptionField(),
self::uploadField(),
self::previewField(),
self::metaField(),
])
->columns(2),
];
}
public static function listingRepeater(): Repeater
{
return Repeater::make('videos')
->relationship(
'videos',
modifyQueryUsing: fn (Builder $query): Builder => $query->ordered(),
)
->schema(self::itemSchema())
->defaultItems(0)
->addActionLabel('Add video')
->itemLabel(fn (array $state): string => trim((string) ($state['title'] ?? '')) ?: 'Video')
->maxItems((int) config('video.max_listing_videos', 5))
->orderColumn('sort_order')
->collapsible()
->collapsed()
->columns(1)
->mutateRelationshipDataBeforeCreateUsing(fn (array $data): array => self::normalizeData($data))
->mutateRelationshipDataBeforeSaveUsing(fn (array $data): array => self::normalizeData($data));
}
protected static function itemSchema(): array
{
return [
self::titleField(),
self::activeField(),
self::descriptionField(),
self::uploadField(),
self::previewField(),
self::metaField(),
];
}
protected static function listingField(bool $partnerScoped): Select
{
return Select::make('listing_id')
->label('Listing')
->relationship(
'listing',
'title',
modifyQueryUsing: fn (Builder $query): Builder => $query
->when(
$partnerScoped,
fn (Builder $query): Builder => $query->where('user_id', Filament::auth()->id()),
)
->latest('id'),
)
->searchable()
->preload()
->required()
->columnSpanFull();
}
protected static function titleField(): TextInput
{
return TextInput::make('title')
->maxLength(120)
->placeholder('Front view, walkaround, detail clip');
}
protected static function descriptionField(): Textarea
{
return Textarea::make('description')
->rows(3)
->maxLength(500)
->columnSpanFull();
}
protected static function activeField(): Toggle
{
return Toggle::make('is_active')
->label('Visible')
->default(true);
}
protected static function uploadField(): FileUpload
{
$clientConfig = config('video.client_side', []);
return FileUpload::make('upload_path')
->label('Source video')
->disk((string) config('video.disk', 'public'))
->directory(trim((string) config('video.upload_directory', 'videos/uploads'), '/'))
->visibility('public')
->acceptedFileTypes([
'video/mp4',
'video/quicktime',
'video/webm',
'video/x-matroska',
'video/x-msvideo',
])
->getUploadedFileNameForStorageUsing(
fn (TemporaryUploadedFile $file): string => Str::ulid()
.'--'
.Str::slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME))
.'.'
.($file->getClientOriginalExtension() ?: $file->guessExtension() ?: 'mp4'),
)
->maxSize((int) config('video.max_upload_size_kb', 102400))
->downloadable()
->openable()
->helperText('Browser-supported uploads are reduced before transfer. Laravel then converts them to a mobile MP4 with the queue.')
->required(fn (?Video $record): bool => blank($record?->path) && blank($record?->upload_path))
->extraInputAttributes([
'data-video-upload-optimizer' => ($clientConfig['enabled'] ?? true) ? 'true' : 'false',
'data-video-optimize-width' => (string) ($clientConfig['max_width'] ?? 854),
'data-video-optimize-bitrate' => (string) ($clientConfig['bitrate'] ?? 900000),
'data-video-optimize-fps' => (string) ($clientConfig['fps'] ?? 24),
'data-video-optimize-min-bytes' => (string) ($clientConfig['min_size_bytes'] ?? 1048576),
])
->columnSpanFull();
}
protected static function previewField(): Placeholder
{
return Placeholder::make('preview')
->label('Preview')
->content(
fn (?Video $record): HtmlString => new HtmlString(
view('video::filament.video-preview-field', ['video' => $record])->render()
),
)
->columnSpanFull();
}
protected static function metaField(): Placeholder
{
return Placeholder::make('details')
->label('Details')
->content(function (?Video $record): string {
if (! $record) {
return 'Resolution, duration, and size are filled after the queue completes.';
}
return collect([
'Status: '.$record->statusLabel(),
'Resolution: '.$record->resolutionLabel(),
'Duration: '.$record->durationLabel(),
'Size: '.$record->sizeLabel(),
])->implode(' • ');
})
->columnSpanFull();
}
protected static function normalizeData(array $data): array
{
$data['upload_disk'] = (string) config('video.disk', 'public');
if (blank($data['title'] ?? null) && filled($data['upload_path'] ?? null)) {
$data['title'] = str(pathinfo((string) $data['upload_path'], PATHINFO_FILENAME))
->after('--')
->replace('-', ' ')
->title()
->toString();
}
return $data;
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace Modules\Video\Support\Filament;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Modules\Video\Enums\VideoStatus;
use Modules\Video\Models\Video;
class VideoTableSchema
{
public static function configure(Table $table, bool $showOwner = true): Table
{
$columns = [
TextColumn::make('title')
->searchable()
->sortable()
->limit(40)
->formatStateUsing(fn (Video $record): string => $record->titleLabel()),
TextColumn::make('listing.title')
->label('Listing')
->searchable()
->sortable()
->limit(40),
];
if ($showOwner) {
$columns[] = TextColumn::make('user.email')
->label('Owner')
->searchable()
->sortable()
->toggleable();
}
return $table
->columns([
...$columns,
TextColumn::make('status')
->badge()
->color(fn (Video $record): string => $record->statusColor())
->formatStateUsing(fn (Video $record): string => $record->statusLabel()),
IconColumn::make('is_active')
->label('Visible')
->boolean(),
TextColumn::make('resolution')
->label('Resolution')
->state(fn (Video $record): string => $record->resolutionLabel()),
TextColumn::make('duration')
->label('Duration')
->state(fn (Video $record): string => $record->durationLabel()),
TextColumn::make('size')
->label('Size')
->state(fn (Video $record): string => $record->sizeLabel()),
TextColumn::make('processed_at')
->label('Processed')
->since()
->sortable(),
])
->filters([
SelectFilter::make('status')
->options(VideoStatus::options()),
SelectFilter::make('listing_id')
->label('Listing')
->relationship('listing', 'title')
->searchable()
->preload(),
TernaryFilter::make('is_active')
->label('Visible'),
...($showOwner ? [
SelectFilter::make('user_id')
->label('Owner')
->relationship('user', 'email')
->searchable()
->preload(),
] : []),
])
->defaultSort('id', 'desc')
->actions([
Action::make('watch')
->icon('heroicon-o-play-circle')
->color('gray')
->modalHeading(fn (Video $record): string => $record->titleLabel())
->modalWidth('5xl')
->modalSubmitAction(false)
->modalContent(
fn (Video $record): View => view('video::filament.video-player', ['video' => $record->loadMissing('listing')]),
),
EditAction::make(),
DeleteAction::make(),
]);
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace Modules\Video\Support;
use Illuminate\Support\Facades\Storage;
use Modules\Video\Models\Video;
use RuntimeException;
use Symfony\Component\Process\Process;
class VideoTranscoder
{
public function transcode(Video $video): array
{
$disk = (string) config('video.disk', 'public');
$inputDisk = Storage::disk((string) ($video->upload_disk ?: $disk));
$outputDisk = Storage::disk($disk);
$inputPath = $inputDisk->path((string) $video->upload_path);
$outputRelativePath = $video->mobileOutputPath();
$outputPath = $outputDisk->path($outputRelativePath);
$outputDirectory = dirname($outputPath);
if (! is_dir($outputDirectory)) {
mkdir($outputDirectory, 0775, true);
}
$process = new Process([
(string) config('video.ffmpeg', 'ffmpeg'),
'-y',
'-i',
$inputPath,
'-map',
'0:v:0',
'-map',
'0:a:0?',
'-vf',
'scale=min('.(int) config('video.mobile_width', 854).'\\,iw):-2',
'-c:v',
'libx264',
'-preset',
'veryfast',
'-crf',
(string) config('video.mobile_crf', 31),
'-maxrate',
(string) config('video.mobile_video_bitrate', '900k'),
'-bufsize',
'1800k',
'-movflags',
'+faststart',
'-pix_fmt',
'yuv420p',
'-c:a',
'aac',
'-b:a',
(string) config('video.mobile_audio_bitrate', '96k'),
'-ac',
'2',
$outputPath,
]);
$process->setTimeout((int) config('video.timeout', 1800));
$process->run();
if (! $process->isSuccessful()) {
throw new RuntimeException(trim($process->getErrorOutput()) ?: 'Video transcoding failed.');
}
$probe = new Process([
(string) config('video.ffprobe', 'ffprobe'),
'-v',
'error',
'-select_streams',
'v:0',
'-show_entries',
'stream=width,height:format=duration',
'-of',
'json',
$outputPath,
]);
$probe->setTimeout(30);
$probe->run();
$metadata = json_decode($probe->getOutput(), true);
$stream = $metadata['streams'][0] ?? [];
$format = $metadata['format'] ?? [];
return [
'disk' => $disk,
'path' => $outputRelativePath,
'mime_type' => $outputDisk->mimeType($outputRelativePath) ?: 'video/mp4',
'size' => $outputDisk->size($outputRelativePath),
'width' => isset($stream['width']) ? (int) $stream['width'] : null,
'height' => isset($stream['height']) ? (int) $stream['height'] : null,
'duration_seconds' => isset($format['duration']) ? (int) round((float) $format['duration']) : null,
];
}
}

View File

@ -0,0 +1,24 @@
<?php
return [
'disk' => env('VIDEO_DISK', 'public'),
'upload_directory' => env('VIDEO_UPLOAD_DIRECTORY', 'videos/uploads'),
'processed_directory' => env('VIDEO_PROCESSED_DIRECTORY', 'videos/mobile'),
'queue' => env('VIDEO_QUEUE', 'videos'),
'ffmpeg' => env('VIDEO_FFMPEG_PATH', 'ffmpeg'),
'ffprobe' => env('VIDEO_FFPROBE_PATH', 'ffprobe'),
'timeout' => (int) env('VIDEO_PROCESS_TIMEOUT', 1800),
'max_upload_size_kb' => (int) env('VIDEO_MAX_UPLOAD_SIZE_KB', 102400),
'max_listing_videos' => (int) env('VIDEO_MAX_LISTING_VIDEOS', 5),
'mobile_width' => (int) env('VIDEO_MOBILE_WIDTH', 854),
'mobile_crf' => (int) env('VIDEO_MOBILE_CRF', 31),
'mobile_video_bitrate' => env('VIDEO_MOBILE_VIDEO_BITRATE', '900k'),
'mobile_audio_bitrate' => env('VIDEO_MOBILE_AUDIO_BITRATE', '96k'),
'client_side' => [
'enabled' => (bool) env('VIDEO_CLIENT_SIDE_ENABLED', true),
'max_width' => (int) env('VIDEO_CLIENT_SIDE_MAX_WIDTH', 854),
'bitrate' => (int) env('VIDEO_CLIENT_SIDE_BITRATE', 900000),
'fps' => (int) env('VIDEO_CLIENT_SIDE_FPS', 24),
'min_size_bytes' => (int) env('VIDEO_CLIENT_SIDE_MIN_SIZE_BYTES', 1048576),
],
];

View File

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('videos', function (Blueprint $table): void {
$table->id();
$table->foreignId('listing_id')->constrained('listings')->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('title')->nullable();
$table->text('description')->nullable();
$table->string('status')->default('pending')->index();
$table->string('disk')->default('public');
$table->string('path')->nullable();
$table->string('upload_disk')->default('public');
$table->string('upload_path')->nullable();
$table->string('mime_type')->nullable();
$table->unsignedBigInteger('size')->nullable();
$table->unsignedInteger('duration_seconds')->nullable();
$table->unsignedInteger('width')->nullable();
$table->unsignedInteger('height')->nullable();
$table->unsignedInteger('sort_order')->default(1);
$table->boolean('is_active')->default(true);
$table->text('processing_error')->nullable();
$table->timestamp('processed_at')->nullable();
$table->timestamps();
$table->index(['listing_id', 'sort_order']);
});
}
public function down(): void
{
Schema::dropIfExists('videos');
}
};

12
Modules/Video/module.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "Video",
"alias": "video",
"description": "Listing video management and processing module",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\Video\\Providers\\VideoServiceProvider"
],
"aliases": {},
"files": []
}

View File

@ -0,0 +1,62 @@
@php
$video = $video ?? null;
$url = $video?->playableUrl();
@endphp
<div class="space-y-4">
@if($url)
<video
class="w-full rounded-2xl bg-slate-950 max-h-[32rem]"
controls
preload="metadata"
src="{{ $url }}"
type="{{ $video?->previewMimeType() }}"
></video>
@else
<div class="rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-4 py-10 text-center text-sm text-slate-500">
This video does not have a playable file yet.
</div>
@endif
@if($video)
<div class="grid gap-3 md:grid-cols-2">
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Title</p>
<p class="mt-1 text-sm font-medium text-slate-800">{{ $video->titleLabel() }}</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Status</p>
<p class="mt-1 text-sm font-medium text-slate-800">{{ $video->statusLabel() }}</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Listing</p>
<p class="mt-1 text-sm font-medium text-slate-800">{{ $video->listing?->title ?? '-' }}</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Resolution</p>
<p class="mt-1 text-sm font-medium text-slate-800">{{ $video->resolutionLabel() }}</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Duration</p>
<p class="mt-1 text-sm font-medium text-slate-800">{{ $video->durationLabel() }}</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Size</p>
<p class="mt-1 text-sm font-medium text-slate-800">{{ $video->sizeLabel() }}</p>
</div>
</div>
@if(filled($video->description))
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Description</p>
<p class="mt-1 text-sm text-slate-700">{{ $video->description }}</p>
</div>
@endif
@if(filled($video->processing_error))
<div class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{{ $video->processing_error }}
</div>
@endif
@endif
</div>

View File

@ -0,0 +1,50 @@
@php
$video = $video ?? null;
$url = $video?->playableUrl();
$statusColor = match ($video?->statusColor()) {
'success' => 'bg-emerald-100 text-emerald-700',
'info' => 'bg-sky-100 text-sky-700',
'danger' => 'bg-rose-100 text-rose-700',
default => 'bg-amber-100 text-amber-700',
};
@endphp
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold {{ $statusColor }}">
{{ $video?->statusLabel() ?? 'New' }}
</span>
@if($video && ($video->resolutionLabel() !== '-'))
<span class="text-xs text-slate-500">{{ $video->resolutionLabel() }}</span>
@endif
@if($video && ($video->durationLabel() !== '-'))
<span class="text-xs text-slate-500">{{ $video->durationLabel() }}</span>
@endif
@if($video && ($video->sizeLabel() !== '-'))
<span class="text-xs text-slate-500">{{ $video->sizeLabel() }}</span>
@endif
</div>
@if($url)
<video
class="w-full rounded-xl bg-slate-950 max-h-80"
controls
preload="metadata"
src="{{ $url }}"
type="{{ $video?->previewMimeType() }}"
></video>
@else
<div class="rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-6 text-sm text-slate-500">
Preview will appear after the first upload.
</div>
@endif
@if($video && filled($video->processing_error))
<div class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{{ $video->processing_error }}
</div>
@endif
</div>

View File

@ -0,0 +1,195 @@
<script>
(() => {
if (window.__videoUploadOptimizerLoaded) {
return;
}
window.__videoUploadOptimizerLoaded = true;
const selector = 'input[type="file"][data-video-upload-optimizer="true"]';
const replaying = new WeakSet();
const processing = new WeakSet();
const waitForEvent = (target, eventName) => new Promise((resolve, reject) => {
const cleanup = () => {
target.removeEventListener(eventName, onResolve);
target.removeEventListener('error', onReject);
};
const onResolve = () => {
cleanup();
resolve();
};
const onReject = () => {
cleanup();
reject(new Error(eventName + ' failed'));
};
target.addEventListener(eventName, onResolve, { once: true });
target.addEventListener('error', onReject, { once: true });
});
const preferredMimeType = () => {
const supported = [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm',
];
return supported.find((mimeType) => window.MediaRecorder?.isTypeSupported?.(mimeType)) || null;
};
const even = (value) => Math.max(2, Math.round(value / 2) * 2);
const optimizeFile = async (file, input) => {
if (
! file.type.startsWith('video/')
|| ! window.MediaRecorder
|| ! window.DataTransfer
|| ! HTMLCanvasElement.prototype.captureStream
|| file.size < Number(input.dataset.videoOptimizeMinBytes || 0)
) {
return file;
}
const mimeType = preferredMimeType();
if (! mimeType) {
return file;
}
const objectUrl = URL.createObjectURL(file);
try {
const video = document.createElement('video');
video.preload = 'auto';
video.muted = true;
video.playsInline = true;
video.src = objectUrl;
await waitForEvent(video, 'loadedmetadata');
const maxWidth = Number(input.dataset.videoOptimizeWidth || 854);
const fps = Number(input.dataset.videoOptimizeFps || 24);
const bitrate = Number(input.dataset.videoOptimizeBitrate || 900000);
const scale = Math.min(1, maxWidth / (video.videoWidth || maxWidth));
const width = even((video.videoWidth || maxWidth) * scale);
const height = even((video.videoHeight || maxWidth) * scale);
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d', { alpha: false });
if (! context) {
return file;
}
canvas.width = width;
canvas.height = height;
const canvasStream = canvas.captureStream(fps);
const sourceStream = typeof video.captureStream === 'function'
? video.captureStream()
: (typeof video.mozCaptureStream === 'function' ? video.mozCaptureStream() : null);
const stream = new MediaStream([
...canvasStream.getVideoTracks(),
...(sourceStream ? sourceStream.getAudioTracks() : []),
]);
const chunks = [];
const recorder = new MediaRecorder(stream, {
mimeType,
videoBitsPerSecond: bitrate,
audioBitsPerSecond: 96000,
});
recorder.addEventListener('dataavailable', (event) => {
if (event.data?.size) {
chunks.push(event.data);
}
});
const stopped = new Promise((resolve, reject) => {
recorder.addEventListener('stop', resolve, { once: true });
recorder.addEventListener('error', () => reject(new Error('recorder failed')), { once: true });
});
const draw = () => {
if (video.paused || video.ended) {
return;
}
context.drawImage(video, 0, 0, width, height);
requestAnimationFrame(draw);
};
recorder.start(250);
await video.play();
draw();
await waitForEvent(video, 'ended');
if (recorder.state !== 'inactive') {
recorder.stop();
}
await stopped;
const blob = new Blob(chunks, { type: mimeType });
if (! blob.size || blob.size >= file.size) {
return file;
}
const baseName = file.name.replace(/\.[^.]+$/, '');
return new File(
[blob],
`${baseName}-mobile.webm`,
{
type: mimeType,
lastModified: Date.now(),
},
);
} catch {
return file;
} finally {
URL.revokeObjectURL(objectUrl);
}
};
document.addEventListener('change', async (event) => {
const input = event.target;
if (! (input instanceof HTMLInputElement) || ! input.matches(selector)) {
return;
}
if (replaying.has(input)) {
replaying.delete(input);
return;
}
if (processing.has(input) || ! input.files?.length) {
return;
}
processing.add(input);
event.preventDefault();
event.stopImmediatePropagation();
try {
const files = await Promise.all(
Array.from(input.files).map((file) => optimizeFile(file, input)),
);
const dataTransfer = new DataTransfer();
files.forEach((file) => dataTransfer.items.add(file));
input.files = dataTransfer.files;
replaying.add(input);
input.dispatchEvent(new Event('change', { bubbles: true }));
} finally {
processing.delete(input);
}
}, true);
})();
</script>

View File

@ -6,6 +6,7 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Modules\Listing\Models\Listing;
use Modules\Video\Enums\VideoStatus;
class PanelController extends Controller
{
@ -32,6 +33,14 @@ class PanelController extends Controller
$listings = $user->listings()
->with('category:id,name')
->withCount('favoritedByUsers')
->withCount('videos')
->withCount([
'videos as ready_videos_count' => fn ($query) => $query->whereNotNull('path')->where('is_active', true),
'videos as pending_videos_count' => fn ($query) => $query->whereIn('status', [
VideoStatus::Pending->value,
VideoStatus::Processing->value,
]),
])
->when($search !== '', fn ($query) => $query->where('title', 'like', "%{$search}%"))
->when($status !== 'all', fn ($query) => $query->where('status', $status))
->latest('id')

View File

@ -4,6 +4,7 @@ namespace App\Livewire;
use App\Support\QuickListingCategorySuggester;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Component;
@ -17,6 +18,7 @@ use Modules\Listing\Support\ListingPanelHelper;
use Modules\Location\Models\City;
use Modules\Location\Models\Country;
use Modules\User\App\Models\Profile;
use Modules\Video\Models\Video;
use Throwable;
class PanelQuickListingForm extends Component
@ -26,6 +28,7 @@ class PanelQuickListingForm extends Component
private const TOTAL_STEPS = 5;
public array $photos = [];
public array $videos = [];
public array $categories = [];
public array $countries = [];
public array $cities = [];
@ -68,6 +71,11 @@ class PanelQuickListingForm extends Component
$this->validatePhotos();
}
public function updatedVideos(): void
{
$this->validateVideos();
}
public function updatedSelectedCountryId(): void
{
$this->selectedCityId = null;
@ -83,6 +91,16 @@ class PanelQuickListingForm extends Component
$this->photos = array_values($this->photos);
}
public function removeVideo(int $index): void
{
if (! isset($this->videos[$index])) {
return;
}
unset($this->videos[$index]);
$this->videos = array_values($this->videos);
}
public function goToStep(int $step): void
{
$this->currentStep = max(1, min(self::TOTAL_STEPS, $step));
@ -91,6 +109,7 @@ class PanelQuickListingForm extends Component
public function goToCategoryStep(): void
{
$this->validatePhotos();
$this->validateVideos();
$this->currentStep = 2;
if (! $this->isDetecting && ! $this->detectedCategoryId) {
@ -179,12 +198,13 @@ class PanelQuickListingForm extends Component
$this->isPublishing = true;
$this->validatePhotos();
$this->validateVideos();
$this->validateCategoryStep();
$this->validateDetailsStep();
$this->validateCustomFieldsStep();
try {
$this->createListing();
$listing = $this->createListing();
} catch (Throwable $exception) {
report($exception);
$this->isPublishing = false;
@ -196,6 +216,15 @@ class PanelQuickListingForm extends Component
$this->isPublishing = false;
session()->flash('success', 'Your listing has been created successfully.');
if (Route::has('filament.partner.resources.listings.edit')) {
$this->redirect(route('filament.partner.resources.listings.edit', [
'tenant' => $listing->user_id,
'record' => $listing,
]), navigate: true);
return;
}
$this->redirectRoute('panel.listings.index');
}
@ -266,7 +295,7 @@ class PanelQuickListingForm extends Component
public function getCurrentStepHintProperty(): string
{
return match ($this->currentStep) {
1 => 'Add photos first.',
1 => 'Add photos and optional videos first.',
2 => 'Pick the right category.',
3 => 'Add the basics.',
4 => 'Add extra details if needed.',
@ -405,6 +434,23 @@ class PanelQuickListingForm extends Component
]);
}
private function validateVideos(): void
{
$this->validate([
'videos' => [
'nullable',
'array',
'max:'.config('video.max_listing_videos', 5),
],
'videos.*' => [
'required',
'file',
'mimetypes:video/mp4,video/quicktime,video/webm,video/x-matroska,video/x-msvideo',
'max:'.config('video.max_upload_size_kb', 102400),
],
]);
}
private function validateCategoryStep(): void
{
$this->validate([
@ -531,6 +577,17 @@ class PanelQuickListingForm extends Component
->toMediaCollection('listing-images');
}
foreach ($this->videos as $index => $video) {
if (! $video instanceof TemporaryUploadedFile) {
continue;
}
Video::createFromTemporaryUpload($listing, $video, [
'sort_order' => $index + 1,
'title' => pathinfo($video->getClientOriginalName(), PATHINFO_FILENAME),
]);
}
return $listing;
}

View File

@ -3,4 +3,5 @@
return [
App\Providers\AppServiceProvider::class,
Modules\Admin\Providers\AdminPanelProvider::class,
Modules\Partner\Providers\PartnerPanelProvider::class,
];

View File

@ -34,6 +34,9 @@ class DatabaseSeeder extends Seeder
\Modules\Category\Database\Seeders\CategorySeeder::class,
\Modules\Listing\Database\Seeders\ListingCustomFieldSeeder::class,
\Modules\Listing\Database\Seeders\ListingSeeder::class,
\Modules\Listing\Database\Seeders\ListingPanelDemoSeeder::class,
\Modules\Favorite\Database\Seeders\FavoriteDemoSeeder::class,
\Modules\Conversation\Database\Seeders\ConversationDemoSeeder::class,
]);
}
}

View File

@ -3,9 +3,10 @@
"Listing": true,
"Location": true,
"Admin": true,
"Partner": false,
"Partner": true,
"Theme": true,
"Conversation": true,
"Favorite": true,
"User": true
"User": true,
"Video": true
}

View File

@ -38,22 +38,22 @@ h6 {
.brand-logo {
position: relative;
width: 1.45rem;
height: 1.7rem;
width: 1.18rem;
height: 1.42rem;
display: inline-block;
border-radius: 60% 60% 55% 55% / 62% 62% 70% 70%;
background: var(--oc-text);
box-shadow: 0 5px 14px rgba(29, 29, 31, 0.18);
box-shadow: 0 4px 12px rgba(29, 29, 31, 0.16);
transform: translateY(1px);
}
.brand-logo::before {
content: "";
position: absolute;
top: -0.42rem;
right: 0.14rem;
width: 0.54rem;
height: 0.31rem;
top: -0.34rem;
right: 0.12rem;
width: 0.44rem;
height: 0.25rem;
border-radius: 100% 0;
background: var(--oc-text);
transform: rotate(-32deg);
@ -62,18 +62,22 @@ h6 {
.brand-logo::after {
content: "";
position: absolute;
top: 0.18rem;
top: 0.14rem;
left: 50%;
transform: translateX(-50%);
width: 0.16rem;
height: 0.2rem;
width: 0.12rem;
height: 0.16rem;
border-radius: 999px;
background: var(--oc-surface);
}
.brand-text {
font-size: 1.7rem;
font-weight: 600;
max-width: 7.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1.12rem;
font-weight: 700;
color: var(--oc-text);
letter-spacing: -0.03em;
}
@ -87,32 +91,38 @@ h6 {
.oc-nav-wrap {
max-width: 1320px;
margin: 0 auto;
padding: 12px 16px;
padding: 10px 16px 12px;
}
.oc-nav-main {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
gap: 12px;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px 12px;
}
.oc-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
justify-content: flex-start;
gap: 10px;
grid-column: 1 / 2;
min-width: 0;
}
.oc-brand {
display: inline-flex;
align-items: center;
gap: 14px;
gap: 10px;
min-width: 0;
}
.oc-brand-image {
height: 1.8rem;
}
.oc-topbar .oc-brand {
flex: 1 1 auto;
flex: 0 1 auto;
}
.oc-search {
@ -120,16 +130,17 @@ h6 {
align-items: center;
gap: 12px;
width: 100%;
min-height: 52px;
padding: 0 14px 0 18px;
min-height: 50px;
padding: 0 16px;
border: 1px solid rgba(29, 29, 31, 0.08);
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
box-shadow: 0 8px 20px rgba(29, 29, 31, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.75);
}
.oc-search-main {
grid-column: 1 / -1;
grid-row: 2;
}
.oc-search-icon {
@ -152,6 +163,7 @@ h6 {
}
.oc-search-submit {
display: none;
border: 0;
background: transparent;
color: #4b5563;
@ -164,13 +176,17 @@ h6 {
.oc-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
grid-column: 2 / 4;
grid-row: 1;
justify-content: flex-end;
gap: 8px;
flex-wrap: nowrap;
min-width: 0;
}
.oc-location {
position: relative;
flex: 1 1 0;
flex: 1 1 auto;
min-width: 0;
}
@ -191,14 +207,15 @@ h6 {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 44px;
padding: 0 16px;
min-height: 42px;
padding: 0 14px;
border: 1px solid rgba(29, 29, 31, 0.08);
border-radius: 999px;
background: rgba(255, 255, 255, 0.84);
background: rgba(255, 255, 255, 0.92);
color: #4b5563;
font-size: 0.95rem;
font-weight: 500;
font-size: 0.92rem;
font-weight: 600;
box-shadow: 0 6px 18px rgba(29, 29, 31, 0.05);
}
.oc-pill-strong {
@ -212,10 +229,10 @@ h6 {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0 18px;
font-size: 0.96rem;
font-weight: 600;
min-height: 42px;
padding: 0 16px;
font-size: 0.92rem;
font-weight: 700;
flex-shrink: 0;
}
@ -480,16 +497,17 @@ h6 {
}
.header-utility {
width: 2.75rem;
height: 2.75rem;
flex: 0 0 2.75rem;
width: 2.6rem;
height: 2.6rem;
flex: 0 0 2.6rem;
border-radius: 999px;
border: 1px solid var(--oc-border);
background: #ffffff;
background: rgba(255, 255, 255, 0.96);
display: none;
align-items: center;
justify-content: center;
color: #4b5563;
box-shadow: 0 6px 16px rgba(29, 29, 31, 0.06);
transition: all 0.2s ease;
}
@ -529,7 +547,8 @@ h6 {
}
.brand-text {
font-size: 1.42rem;
max-width: none;
font-size: 1.32rem;
}
.oc-actions {
@ -551,10 +570,33 @@ h6 {
}
@media (min-width: 1024px) {
.brand-logo {
width: 1.45rem;
height: 1.7rem;
box-shadow: 0 5px 14px rgba(29, 29, 31, 0.18);
}
.brand-logo::before {
top: -0.42rem;
right: 0.14rem;
width: 0.54rem;
height: 0.31rem;
}
.brand-logo::after {
top: 0.18rem;
width: 0.16rem;
height: 0.2rem;
}
.oc-nav-wrap {
padding: 18px 16px 14px;
}
.oc-brand-image {
height: 2.25rem;
}
.oc-nav-main {
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
@ -562,25 +604,33 @@ h6 {
}
.oc-topbar {
justify-content: flex-start;
min-width: 0;
gap: 16px;
}
.oc-search-main {
grid-column: auto;
grid-row: auto;
}
.oc-search {
min-height: 3.35rem;
padding: 0 16px 0 18px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
}
.oc-search-submit {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 5.25rem;
text-align: center;
}
.oc-actions {
grid-column: auto;
grid-row: auto;
justify-content: flex-end;
gap: 12px;
flex-wrap: nowrap;
@ -595,14 +645,23 @@ h6 {
min-height: 3rem;
}
.oc-pill {
padding: 0 16px;
font-size: 0.95rem;
font-weight: 500;
box-shadow: none;
}
.oc-cta {
padding: 0 22px;
font-size: 0.96rem;
}
.header-utility {
width: 3rem;
height: 3rem;
flex-basis: 3rem;
box-shadow: none;
}
.oc-text-link {
@ -635,6 +694,7 @@ h6 {
@media (min-width: 1280px) {
.brand-text {
max-width: none;
font-size: 1.7rem;
}
}

View File

@ -54,15 +54,6 @@
<div class="oc-nav-wrap">
<div class="oc-nav-main">
<div class="oc-topbar">
<a href="{{ route('home') }}" class="oc-brand">
@if($siteLogoUrl)
<img src="{{ $siteLogoUrl }}" alt="{{ $siteName }}" class="h-9 w-auto rounded-xl">
@else
<span class="brand-logo" aria-hidden="true"></span>
@endif
<span class="brand-text leading-none">{{ $siteName }}</span>
</a>
<button
type="button"
class="header-utility oc-compact-menu-trigger"
@ -75,6 +66,15 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M4 7h16M7 12h10M10 17h4"/>
</svg>
</button>
<a href="{{ route('home') }}" class="oc-brand">
@if($siteLogoUrl)
<img src="{{ $siteLogoUrl }}" alt="{{ $siteName }}" class="oc-brand-image w-auto rounded-xl">
@else
<span class="brand-logo" aria-hidden="true"></span>
@endif
<span class="brand-text leading-none">{{ $siteName }}</span>
</a>
</div>
<form action="{{ route('listings.index') }}" method="GET" class="oc-search oc-search-main">

View File

@ -52,6 +52,9 @@
$favoriteCount = (int) ($listing->favorited_by_users_count ?? 0);
$viewCount = (int) ($listing->view_count ?? 0);
$expiresAt = $listing->expires_at?->format('d/m/Y');
$videoCount = (int) ($listing->videos_count ?? 0);
$readyVideoCount = (int) ($listing->ready_videos_count ?? 0);
$pendingVideoCount = (int) ($listing->pending_videos_count ?? 0);
@endphp
<article class="panel-list-card">
<div class="panel-list-card-body">
@ -71,6 +74,12 @@
<h2 class="panel-list-title text-slate-800">{{ $listing->title }}</h2>
<div class="panel-list-actions">
@if(Route::has('filament.partner.resources.listings.edit'))
<a href="{{ route('filament.partner.resources.listings.edit', ['tenant' => auth()->id(), 'record' => $listing]) }}" class="panel-action-btn panel-action-btn-secondary">
İlanı Düzenle
</a>
@endif
<form method="POST" action="{{ route('panel.listings.destroy', $listing) }}">
@csrf
<button type="submit" class="panel-action-btn panel-action-btn-secondary">
@ -116,6 +125,13 @@
{{ $listing->created_at?->format('d/m/Y') ?? '-' }} - {{ $expiresAt ?: '-' }}
</strong>
</p>
<p class="panel-list-dates">
Video Durumu:
<strong class="text-slate-700">
{{ $videoCount }} toplam, {{ $readyVideoCount }} hazır, {{ $pendingVideoCount }} işleniyor
</strong>
</p>
</div>
</div>

View File

@ -10,6 +10,11 @@
<a href="{{ route('panel.listings.index') }}" class="block px-5 py-4 border-t border-slate-200 text-base {{ $activeMenu === 'listings' ? 'bg-rose-50 text-rose-600 font-semibold' : 'text-slate-700 hover:bg-slate-50' }}">
My Listings
</a>
@if (Route::has('filament.partner.resources.videos.index'))
<a href="{{ route('filament.partner.resources.videos.index', ['tenant' => auth()->id()]) }}" class="block px-5 py-4 border-t border-slate-200 text-base {{ $activeMenu === 'videos' ? 'bg-rose-50 text-rose-600 font-semibold' : 'text-slate-700 hover:bg-slate-50' }}">
Videos
</a>
@endif
<a href="{{ route('favorites.index', ['tab' => 'listings']) }}" class="block px-5 py-4 border-t border-slate-200 text-base {{ $activeMenu === 'favorites' ? 'bg-rose-50 text-rose-600 font-semibold' : 'text-slate-700 hover:bg-slate-50' }}">
Favorites
</a>

View File

@ -1369,6 +1369,64 @@
<p>We suggest a category after the first upload.</p>
</div>
@endif
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-base font-semibold text-slate-900">Add videos</h3>
<p class="text-sm text-slate-500">Optional. Supported browsers reduce the file before upload, then Laravel converts it to the mobile version in the queue.</p>
</div>
<label for="quick-listing-video-input" class="inline-flex min-h-11 items-center justify-center rounded-full bg-slate-900 px-5 py-3 text-sm font-semibold text-white cursor-pointer">
Select videos
</label>
</div>
<input
id="quick-listing-video-input"
type="file"
wire:model="videos"
accept="video/mp4,video/quicktime,video/webm,video/x-matroska,video/x-msvideo"
multiple
class="hidden"
data-video-upload-optimizer="{{ config('video.client_side.enabled', true) ? 'true' : 'false' }}"
data-video-optimize-width="{{ config('video.client_side.max_width', 854) }}"
data-video-optimize-bitrate="{{ config('video.client_side.bitrate', 900000) }}"
data-video-optimize-fps="{{ config('video.client_side.fps', 24) }}"
data-video-optimize-min-bytes="{{ config('video.client_side.min_size_bytes', 1048576) }}"
/>
@error('videos')
<div class="qc-error">{{ $message }}</div>
@enderror
@error('videos.*')
<div class="qc-error">{{ $message }}</div>
@enderror
@if (count($videos) > 0)
<div class="mt-4 space-y-3">
@foreach ($videos as $index => $video)
@php
$videoName = method_exists($video, 'getClientOriginalName') ? $video->getClientOriginalName() : 'Video '.($index + 1);
$videoSize = method_exists($video, 'getSize') ? (int) $video->getSize() : 0;
@endphp
<div class="flex items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-slate-800">{{ $videoName }}</p>
<p class="text-xs text-slate-500">{{ $videoSize > 0 ? number_format($videoSize / 1048576, 1, ',', '.') : '-' }} MB</p>
</div>
<button type="button" class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-900 text-sm font-semibold text-white" wire:click="removeVideo({{ $index }})">
×
</button>
</div>
@endforeach
</div>
@else
<p class="mt-4 text-sm text-slate-500">
Add up to {{ (int) config('video.max_listing_videos', 5) }} clips. Mobile MP4 will be generated automatically after upload.
</p>
@endif
</div>
</div>
<div class="qc-footer">
@ -1708,6 +1766,17 @@
<p class="qc-main-desc">{{ $description }}</p>
</div>
@if (count($videos) > 0)
<div class="mt-5 rounded-2xl border border-slate-200 bg-slate-50 p-4">
<h5 class="text-sm font-semibold text-slate-900">Videos</h5>
<div class="mt-3 space-y-2">
@foreach ($videos as $video)
<p class="text-sm text-slate-700">{{ method_exists($video, 'getClientOriginalName') ? $video->getClientOriginalName() : 'Video' }}</p>
@endforeach
</div>
</div>
@endif
<div class="qc-preview-features">
<h5>Details</h5>
@if ($this->previewCustomFields !== [])
@ -1761,3 +1830,4 @@
</div>
</div>
</div>
@include('video::partials.video-upload-optimizer')

View File

@ -21,7 +21,4 @@ Route::middleware('auth')->prefix('panel')->name('panel.')->group(function () {
Route::post('/ilanlarim/{listing}/yeniden-yayinla', [PanelController::class, 'republishListing'])->name('listings.republish');
});
Route::get('/partner/{any?}', fn () => redirect()->route('panel.listings.index'))
->where('any', '.*');
require __DIR__.'/auth.php';