mirror of
https://github.com/openclassify/openclassify.git
synced 2026-04-14 11:12:09 -05:00
Add demo mode seeders and UI tweaks
This commit is contained in:
parent
6ee6da3d83
commit
93ce5a0925
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
118
Modules/Conversation/database/seeders/ConversationDemoSeeder.php
Normal file
118
Modules/Conversation/database/seeders/ConversationDemoSeeder.php
Normal 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();
|
||||
}
|
||||
}
|
||||
173
Modules/Favorite/database/seeders/FavoriteDemoSeeder.php
Normal file
173
Modules/Favorite/database/seeders/FavoriteDemoSeeder.php
Normal 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],
|
||||
];
|
||||
}
|
||||
}
|
||||
175
Modules/Listing/Database/Seeders/ListingPanelDemoSeeder.php
Normal file
175
Modules/Listing/Database/Seeders/ListingPanelDemoSeeder.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -66,6 +66,7 @@ class User extends Authenticatable implements FilamentUser, HasTenants, HasAvata
|
||||
{
|
||||
return match ($panel->getId()) {
|
||||
'admin' => $this->hasRole('admin'),
|
||||
'partner' => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
38
Modules/Video/Enums/VideoStatus.php
Normal file
38
Modules/Video/Enums/VideoStatus.php
Normal 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();
|
||||
}
|
||||
}
|
||||
43
Modules/Video/Filament/Admin/Resources/VideoResource.php
Normal file
43
Modules/Video/Filament/Admin/Resources/VideoResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
48
Modules/Video/Filament/Partner/Resources/VideoResource.php
Normal file
48
Modules/Video/Filament/Partner/Resources/VideoResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
47
Modules/Video/Jobs/ProcessVideo.php
Normal file
47
Modules/Video/Jobs/ProcessVideo.php
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
397
Modules/Video/Models/Video.php
Normal file
397
Modules/Video/Models/Video.php
Normal 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);
|
||||
}
|
||||
}
|
||||
19
Modules/Video/Providers/VideoServiceProvider.php
Normal file
19
Modules/Video/Providers/VideoServiceProvider.php
Normal 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');
|
||||
}
|
||||
}
|
||||
206
Modules/Video/Support/Filament/VideoFormSchema.php
Normal file
206
Modules/Video/Support/Filament/VideoFormSchema.php
Normal 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;
|
||||
}
|
||||
}
|
||||
99
Modules/Video/Support/Filament/VideoTableSchema.php
Normal file
99
Modules/Video/Support/Filament/VideoTableSchema.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
97
Modules/Video/Support/VideoTranscoder.php
Normal file
97
Modules/Video/Support/VideoTranscoder.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
24
Modules/Video/config/video.php
Normal file
24
Modules/Video/config/video.php
Normal 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),
|
||||
],
|
||||
];
|
||||
@ -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
12
Modules/Video/module.json
Normal 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": []
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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')
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -3,4 +3,5 @@
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
Modules\Admin\Providers\AdminPanelProvider::class,
|
||||
Modules\Partner\Providers\PartnerPanelProvider::class,
|
||||
];
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user