Refactor modules to SOLID structure

This commit is contained in:
fatihalp 2026-03-14 01:57:30 +03:00
parent d2345cbeda
commit 6b3a8b8581
114 changed files with 1338 additions and 4845 deletions

View File

@ -2,23 +2,23 @@
namespace Modules\Admin\Filament\Pages;
use App\Settings\GeneralSettings;
use App\Support\CountryCodeManager;
use App\Support\HomeSlideDefaults;
use BackedEnum;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Pages\SettingsPage;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Modules\Admin\Support\HomeSlideFormSchema;
use Modules\Location\Support\CountryCodeManager;
use Modules\S3\Support\MediaStorage;
use Modules\Site\App\Settings\GeneralSettings;
use Modules\Site\App\Support\HomeSlideDefaults;
use Tapp\FilamentCountryCodeField\Forms\Components\CountryCodeSelect;
use UnitEnum;
use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput;
@ -31,9 +31,9 @@ class ManageGeneralSettings extends SettingsPage
protected static ?string $navigationLabel = 'Genel Ayarlar';
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-cog-6-tooth';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cog-6-tooth';
protected static string | UnitEnum | null $navigationGroup = 'Ayarlar';
protected static string|UnitEnum|null $navigationGroup = 'Ayarlar';
protected static ?int $navigationSort = 1;
@ -246,7 +246,7 @@ class ManageGeneralSettings extends SettingsPage
'home_slides' => $this->defaultHomeSlides(),
'site_logo_disk' => null,
'sender_name' => $siteName,
'sender_email' => (string) config('mail.from.address', 'info@' . $siteHost),
'sender_email' => (string) config('mail.from.address', 'info@'.$siteHost),
'default_language' => in_array(config('app.locale'), array_keys($this->localeOptions()), true) ? (string) config('app.locale') : 'en',
'default_country_code' => CountryCodeManager::normalizeCountryCode(config('app.default_country_code', '+90')),
'currencies' => $this->normalizeCurrencies(config('app.currencies', ['TRY'])),
@ -272,7 +272,7 @@ class ManageGeneralSettings extends SettingsPage
->all();
}
private function normalizeCurrencies(null | array | string $state): array
private function normalizeCurrencies(null|array|string $state): array
{
$source = is_array($state) ? $state : (filled($state) ? [$state] : []);

View File

@ -1,68 +0,0 @@
<?php
namespace Modules\Admin\Filament\Pages;
use App\Settings\GeneralSettings;
use App\Support\HomeSlideDefaults;
use BackedEnum;
use Filament\Pages\SettingsPage;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Width;
use Modules\Admin\Support\HomeSlideFormSchema;
use Modules\S3\Support\MediaStorage;
use UnitEnum;
class ManageHomeSlides extends SettingsPage
{
protected static string $settings = GeneralSettings::class;
protected static ?string $title = 'Home Slides';
protected static ?string $navigationLabel = 'Home Slides';
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-photo';
protected static string | UnitEnum | null $navigationGroup = 'Content';
protected static ?int $navigationSort = 2;
protected Width | string | null $maxContentWidth = Width::Full;
protected function mutateFormDataBeforeFill(array $data): array
{
return [
'home_slides' => $this->normalizeHomeSlides(
$data['home_slides'] ?? $this->defaultHomeSlides(),
MediaStorage::storedDisk('public'),
),
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
$data['home_slides'] = $this->normalizeHomeSlides($data['home_slides'] ?? [], MediaStorage::activeDisk());
return $data;
}
public function form(Schema $schema): Schema
{
return $schema
->components([
HomeSlideFormSchema::make(
$this->defaultHomeSlides(),
fn ($state): array => $this->normalizeHomeSlides($state, MediaStorage::activeDisk()),
),
]);
}
private function defaultHomeSlides(): array
{
return HomeSlideDefaults::defaults();
}
private function normalizeHomeSlides(mixed $state, ?string $defaultDisk = null): array
{
return HomeSlideDefaults::normalize($state, $defaultDisk);
}
}

View File

@ -2,42 +2,14 @@
namespace Modules\Admin\Filament\Resources;
use A909M\FilamentStateFusion\Forms\Components\StateFusionSelect;
use A909M\FilamentStateFusion\Tables\Columns\StateFusionSelectColumn;
use A909M\FilamentStateFusion\Tables\Filters\StateFusionSelectFilter;
use App\Support\CountryCodeManager;
use BackedEnum;
use Cheesegrits\FilamentGoogleMaps\Fields\Map;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\SpatieMediaLibraryFileUpload;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\SpatieMediaLibraryImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Enums\FiltersLayout;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Modules\Admin\Filament\Resources\ListingResource\Pages;
use Modules\Admin\Support\Filament\ResourceTableActions;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing;
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 Modules\Listing\Support\Filament\AdminListingResourceSchema;
use UnitEnum;
use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput;
class ListingResource extends Resource
{
@ -49,138 +21,12 @@ class ListingResource extends Resource
public static function form(Schema $schema): Schema
{
return $schema->schema([
TextInput::make('title')->required()->maxLength(255)->live(onBlur: true)->afterStateUpdated(fn ($state, $set) => $set('slug', \Illuminate\Support\Str::slug($state).'-'.\Illuminate\Support\Str::random(4))),
TextInput::make('slug')->required()->maxLength(255)->unique(ignoreRecord: true),
Textarea::make('description')->rows(4),
TextInput::make('price')
->numeric()
->currencyMask(thousandSeparator: ',', decimalSeparator: '.', precision: 2),
Select::make('currency')
->options(fn () => ListingPanelHelper::currencyOptions())
->default(fn () => ListingPanelHelper::defaultCurrency())
->required(),
Select::make('category_id')
->label('Category')
->options(fn (): array => Category::activeIdNameOptions())
->searchable()
->live()
->afterStateUpdated(fn ($state, $set) => $set('custom_fields', []))
->nullable(),
Select::make('user_id')->relationship('user', 'email')->label('Owner')->searchable()->preload()->nullable(),
Section::make('Custom Fields')
->description('Category specific listing attributes.')
->schema(fn (Get $get): array => ListingCustomFieldSchemaBuilder::formComponents(
($categoryId = $get('category_id')) ? (int) $categoryId : null
))
->columns(2)
->columnSpanFull()
->visible(fn (Get $get): bool => ListingCustomFieldSchemaBuilder::hasFields(
($categoryId = $get('category_id')) ? (int) $categoryId : null
)),
StateFusionSelect::make('status')->required(),
PhoneInput::make('contact_phone')->defaultCountry(CountryCodeManager::defaultCountryIso2())->nullable(),
TextInput::make('contact_email')->email()->maxLength(255),
Toggle::make('is_featured')->default(false),
Select::make('country')
->label('Country')
->options(fn (): array => Country::nameOptions())
->searchable()
->preload()
->live()
->afterStateUpdated(fn ($state, $set) => $set('city', null))
->nullable(),
Select::make('city')
->label('City')
->options(fn (Get $get): array => City::nameOptions($get('country')))
->searchable()
->preload()
->nullable(),
Map::make('location')
->label('Location')
->visible(fn (): bool => ListingPanelHelper::googleMapsEnabled())
->draggable()
->clickable()
->autocomplete('city')
->autocompleteReverse(true)
->reverseGeocode([
'city' => '%L',
])
->defaultLocation([41.0082, 28.9784])
->defaultZoom(10)
->height('320px')
->columnSpanFull(),
SpatieMediaLibraryFileUpload::make('images')
->collection('listing-images')
->multiple()
->image()
->reorderable(),
VideoFormSchema::listingSection(),
]);
return $schema->schema(AdminListingResourceSchema::form());
}
public static function table(Table $table): Table
{
return $table->columns([
SpatieMediaLibraryImageColumn::make('images')
->collection('listing-images')
->circular(),
TextColumn::make('id')->sortable(),
TextColumn::make('title')->searchable()->sortable()->limit(40),
TextColumn::make('category.name')->label('Category')->sortable(),
TextColumn::make('user.email')->label('Owner')->searchable()->toggleable()->sortable(),
TextColumn::make('price')
->currency(fn (Listing $record): string => $record->currency ?: ListingPanelHelper::defaultCurrency())
->sortable(),
StateFusionSelectColumn::make('status')->sortable(),
IconColumn::make('is_featured')->boolean()->label('Featured')->sortable(),
TextColumn::make('city')->sortable(),
TextColumn::make('country')->sortable(),
TextColumn::make('created_at')->dateTime()->sortable(),
])->filters([
StateFusionSelectFilter::make('status'),
SelectFilter::make('category_id')
->label('Category')
->relationship('category', 'name')
->searchable()
->preload(),
SelectFilter::make('user_id')
->label('Owner')
->relationship('user', 'email')
->searchable()
->preload(),
SelectFilter::make('country')
->options(fn (): array => Country::nameOptions())
->searchable(),
SelectFilter::make('city')
->options(fn (): array => City::nameOptions(null, false))
->searchable(),
TernaryFilter::make('is_featured')->label('Featured'),
Filter::make('created_at')
->label('Created Date')
->schema([
DatePicker::make('from')->label('From'),
DatePicker::make('until')->label('Until'),
])
->query(fn (Builder $query, array $data): Builder => $query
->when($data['from'] ?? null, fn (Builder $query, string $date): Builder => $query->whereDate('created_at', '>=', $date))
->when($data['until'] ?? null, fn (Builder $query, string $date): Builder => $query->whereDate('created_at', '<=', $date))),
Filter::make('price')
->label('Price Range')
->schema([
TextInput::make('min')->numeric()->label('Min'),
TextInput::make('max')->numeric()->label('Max'),
])
->query(fn (Builder $query, array $data): Builder => $query
->when($data['min'] ?? null, fn (Builder $query, string $amount): Builder => $query->where('price', '>=', (float) $amount))
->when($data['max'] ?? null, fn (Builder $query, string $amount): Builder => $query->where('price', '<=', (float) $amount))),
])
->filtersLayout(FiltersLayout::AboveContent)
->filtersFormColumns(3)
->filtersFormWidth('7xl')
->persistFiltersInSession()
->defaultSort('id', 'desc')
->actions(ResourceTableActions::editActivityDelete(static::class));
return AdminListingResourceSchema::configureTable($table, static::class);
}
public static function getPages(): array

View File

@ -1,4 +1,5 @@
<?php
namespace Modules\Admin\Filament\Widgets;
use Filament\Widgets\ChartWidget;
@ -8,6 +9,8 @@ class ListingsTrendChart extends ChartWidget
{
protected static ?int $sort = 2;
protected int|string|array $columnSpan = 'full';
protected ?string $heading = 'Listing Creation Trend';
protected ?string $description = 'Daily listing volume by selected period.';

View File

@ -1,7 +1,7 @@
<?php
namespace Modules\Admin\Providers;
use App\Http\Middleware\BootstrapAppData;
use A909M\FilamentStateFusion\FilamentStateFusionPlugin;
use DutchCodingCompany\FilamentDeveloperLogins\FilamentDeveloperLoginsPlugin;
use Filament\Http\Middleware\Authenticate;
@ -21,13 +21,10 @@ use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Jeffgreco13\FilamentBreezy\BreezyCore;
use MWGuerra\FileManager\FileManagerPlugin;
use MWGuerra\FileManager\Filament\Pages\FileManager;
use Modules\Demo\App\Http\Middleware\ResolveDemoRequest;
use Modules\Admin\Filament\Resources\CategoryResource;
use Modules\Admin\Filament\Resources\ListingResource;
use Modules\Admin\Filament\Resources\LocationResource;
use Modules\Admin\Filament\Resources\UserResource;
use Modules\Site\App\Http\Middleware\BootstrapAppData;
use MWGuerra\FileManager\Filament\Pages\FileManager;
use MWGuerra\FileManager\FileManagerPlugin;
class AdminPanelProvider extends PanelProvider
{

View File

@ -1,4 +1,5 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

View File

@ -114,6 +114,72 @@ class Category extends Model
->all();
}
public static function activeCount(): int
{
return (int) static::query()
->active()
->count();
}
public static function homeParentCategories(int $limit = 8): Collection
{
return static::query()
->active()
->whereNull('parent_id')
->ordered()
->limit($limit)
->get();
}
public static function headerNavigationItems(int $limit = 8): array
{
return static::query()
->active()
->whereNull('parent_id')
->ordered()
->limit($limit)
->get(['id', 'name', 'icon'])
->map(fn (self $category): array => [
'id' => (int) $category->id,
'name' => (string) $category->name,
'icon_url' => $category->iconUrl(),
])
->all();
}
public static function activeAiCatalog(): Collection
{
return static::query()
->active()
->ordered()
->get(['id', 'name', 'parent_id']);
}
public static function panelQuickCatalog(): array
{
$all = static::query()
->active()
->ordered()
->get(['id', 'name', 'parent_id', 'icon']);
$childrenCount = static::query()
->active()
->selectRaw('parent_id, count(*) as aggregate')
->whereNotNull('parent_id')
->groupBy('parent_id')
->pluck('aggregate', 'parent_id');
return $all
->map(fn (self $category): array => [
'id' => (int) $category->id,
'name' => (string) $category->name,
'parent_id' => $category->parent_id ? (int) $category->parent_id : null,
'icon' => $category->icon,
'has_children' => ((int) ($childrenCount[$category->id] ?? 0)) > 0,
])
->all();
}
public static function rootIdNameOptions(): array
{
return static::query()

View File

@ -107,7 +107,7 @@ class ConversationDemoSeeder extends Seeder
$readAfterMinutes = $payload['read_after_minutes'];
$readAt = is_numeric($readAfterMinutes) ? $createdAt->copy()->addMinutes((int) $readAfterMinutes) : null;
$message = new ConversationMessage();
$message = new ConversationMessage;
$message->forceFill([
'conversation_id' => $conversation->getKey(),
'sender_id' => $sender->getKey(),

View File

@ -5,10 +5,10 @@
@section('content')
<div class="max-w-[1320px] mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-[220px,1fr] gap-4">
@include('panel.partials.sidebar', ['activeMenu' => 'inbox'])
@include('panel::partials.sidebar', ['activeMenu' => 'inbox'])
<section class="space-y-4">
@include('panel.partials.page-header', [
@include('panel::partials.page-header', [
'title' => 'Inbox',
'description' => 'Read and reply to buyer messages from the same panel shell used across the site.',
'actions' => $requiresLogin ?? false

View File

@ -2,11 +2,11 @@
namespace Modules\Demo\App\Support;
use App\Settings\GeneralSettings;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Modules\Demo\App\Models\DemoInstance;
use Modules\Site\App\Settings\GeneralSettings;
use Modules\User\App\Models\User;
use Spatie\Permission\PermissionRegistrar;
use Throwable;

View File

@ -5,7 +5,7 @@
@section('content')
<div class="max-w-[1320px] mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-[220px,1fr] gap-4">
@include('panel.partials.sidebar', ['activeMenu' => 'favorites', 'activeFavoritesTab' => $activeTab])
@include('panel::partials.sidebar', ['activeMenu' => 'favorites', 'activeFavoritesTab' => $activeTab])
<section class="bg-white border border-slate-200">
@if($requiresLogin ?? false)

View File

@ -1,4 +1,5 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@ -30,10 +31,26 @@ return new class extends Migration
$table->decimal('longitude', 10, 7)->nullable();
$table->timestamps();
});
Schema::create('listing_custom_fields', function (Blueprint $table): void {
$table->id();
$table->string('name')->unique();
$table->string('label');
$table->string('type', 32);
$table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete();
$table->text('placeholder')->nullable();
$table->text('help_text')->nullable();
$table->json('options')->nullable();
$table->boolean('is_required')->default(false);
$table->boolean('is_active')->default(true);
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('listing_custom_fields');
Schema::dropIfExists('listings');
}
};

View File

@ -1,31 +0,0 @@
<?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('listing_custom_fields', function (Blueprint $table): void {
$table->id();
$table->string('name')->unique();
$table->string('label');
$table->string('type', 32);
$table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete();
$table->text('placeholder')->nullable();
$table->text('help_text')->nullable();
$table->json('options')->nullable();
$table->boolean('is_required')->default(false);
$table->boolean('is_active')->default(true);
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('listing_custom_fields');
}
};

View File

@ -1,22 +1,25 @@
<?php
namespace Modules\Listing\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Modules\Category\Models\Category;
use Modules\Listing\Support\ListingImageViewData;
use Modules\Listing\States\ListingStatus;
use Modules\Listing\Support\ListingImageViewData;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\User\App\Models\User;
use Modules\Video\Enums\VideoStatus;
use Modules\Video\Models\Video;
use Spatie\Image\Enums\Fit;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
@ -97,7 +100,7 @@ class Listing extends Model implements HasMedia
return $query->where('status', 'active');
}
public function scopeOwnedByUser(Builder $query, int | string | null $userId): Builder
public function scopeOwnedByUser(Builder $query, int|string|null $userId): Builder
{
return $query->where('user_id', $userId);
}
@ -127,6 +130,24 @@ class Listing extends Model implements HasMedia
});
}
public function scopeWithPanelIndexState(Builder $query): Builder
{
return $query
->with('category:id,name')
->withCount('favoritedByUsers')
->withCount('videos')
->withCount([
'videos as ready_videos_count' => fn (Builder $videoQuery): Builder => $videoQuery
->whereNotNull('path')
->where('is_active', true),
'videos as pending_videos_count' => fn (Builder $videoQuery): Builder => $videoQuery
->whereIn('status', [
VideoStatus::Pending->value,
VideoStatus::Processing->value,
]),
]);
}
public function scopeForCategory(Builder $query, ?int $categoryId): Builder
{
return $query->forCategoryIds(Category::listingFilterIds($categoryId));
@ -272,7 +293,7 @@ class Listing extends Model implements HasMedia
];
}
public static function panelStatusCountsForUser(int | string $userId): array
public static function panelStatusCountsForUser(int|string $userId): array
{
$counts = static::query()
->ownedByUser($userId)
@ -289,6 +310,49 @@ class Listing extends Model implements HasMedia
];
}
public static function activeCount(): int
{
return (int) static::query()
->active()
->count();
}
public static function homeFeatured(int $limit = 4): Collection
{
return static::query()
->active()
->where('is_featured', true)
->latest()
->take($limit)
->get();
}
public static function homeRecent(int $limit = 8): Collection
{
return static::query()
->active()
->latest()
->take($limit)
->get();
}
public static function panelIndexDataForUser(User $user, string $search, string $status): array
{
$listings = static::query()
->ownedByUser($user->getKey())
->withPanelIndexState()
->searchTerm($search)
->forPanelStatus($status)
->latest('id')
->paginate(10)
->withQueryString();
return [
'listings' => $listings,
'counts' => static::panelStatusCountsForUser($user->getKey()),
];
}
public function panelPrimaryImageUrl(): ?string
{
return $this->primaryImageUrl('card', 'desktop');
@ -435,6 +499,34 @@ class Listing extends Model implements HasMedia
};
}
public function loadPanelEditor(): self
{
return $this->load([
'category:id,name',
'videos:id,listing_id,title,status,is_active,path,upload_path,duration_seconds,size',
]);
}
public function assertOwnedBy(User $user): void
{
abort_unless((int) $this->user_id === (int) $user->getKey(), 403);
}
public function markAsSold(): void
{
$this->forceFill([
'status' => 'sold',
])->save();
}
public function republish(): void
{
$this->forceFill([
'status' => 'active',
'expires_at' => now()->addDays(self::DEFAULT_PANEL_EXPIRY_WINDOW_DAYS),
])->save();
}
public function updateFromPanel(array $attributes): void
{
$payload = Arr::only($attributes, [
@ -460,7 +552,7 @@ class Listing extends Model implements HasMedia
$this->forceFill($payload)->save();
}
public static function createFromFrontend(array $data, null | int | string $userId): self
public static function createFromFrontend(array $data, null|int|string $userId): self
{
$baseSlug = Str::slug((string) ($data['title'] ?? 'listing'));
$baseSlug = $baseSlug !== '' ? $baseSlug : 'listing';

View File

@ -9,10 +9,15 @@ use Modules\Category\Models\Category;
class ListingCustomField extends Model
{
public const TYPE_TEXT = 'text';
public const TYPE_TEXTAREA = 'textarea';
public const TYPE_NUMBER = 'number';
public const TYPE_SELECT = 'select';
public const TYPE_BOOLEAN = 'boolean';
public const TYPE_DATE = 'date';
protected $fillable = [
@ -100,4 +105,26 @@ class ListingCustomField extends Model
],
);
}
public static function panelFieldDefinitions(?int $categoryId): array
{
return static::query()
->active()
->forCategory($categoryId)
->ordered()
->get(['name', 'label', 'type', 'is_required', 'placeholder', 'help_text', 'options'])
->map(fn (self $field): array => [
'name' => (string) $field->name,
'label' => (string) $field->label,
'type' => (string) $field->type,
'is_required' => (bool) $field->is_required,
'placeholder' => $field->placeholder,
'help_text' => $field->help_text,
'options' => collect($field->options ?? [])
->map(fn ($option): string => (string) $option)
->values()
->all(),
])
->all();
}
}

View File

@ -0,0 +1,176 @@
<?php
namespace Modules\Listing\Support\Filament;
use A909M\FilamentStateFusion\Forms\Components\StateFusionSelect;
use A909M\FilamentStateFusion\Tables\Columns\StateFusionSelectColumn;
use A909M\FilamentStateFusion\Tables\Filters\StateFusionSelectFilter;
use Cheesegrits\FilamentGoogleMaps\Fields\Map;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\SpatieMediaLibraryFileUpload;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\SpatieMediaLibraryImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Enums\FiltersLayout;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Modules\Admin\Support\Filament\ResourceTableActions;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing;
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\Location\Models\City;
use Modules\Location\Models\Country;
use Modules\Location\Support\CountryCodeManager;
use Modules\Video\Support\Filament\VideoFormSchema;
use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput;
class AdminListingResourceSchema
{
public static function form(): array
{
return [
TextInput::make('title')->required()->maxLength(255)->live(onBlur: true)->afterStateUpdated(fn ($state, $set) => $set('slug', \Illuminate\Support\Str::slug($state).'-'.\Illuminate\Support\Str::random(4))),
TextInput::make('slug')->required()->maxLength(255)->unique(ignoreRecord: true),
Textarea::make('description')->rows(4),
TextInput::make('price')
->numeric()
->currencyMask(thousandSeparator: ',', decimalSeparator: '.', precision: 2),
Select::make('currency')
->options(fn (): array => ListingPanelHelper::currencyOptions())
->default(fn (): string => ListingPanelHelper::defaultCurrency())
->required(),
Select::make('category_id')
->label('Category')
->options(fn (): array => Category::activeIdNameOptions())
->searchable()
->live()
->afterStateUpdated(fn ($state, $set) => $set('custom_fields', []))
->nullable(),
Select::make('user_id')->relationship('user', 'email')->label('Owner')->searchable()->preload()->nullable(),
Section::make('Custom Fields')
->description('Category specific listing attributes.')
->schema(fn (Get $get): array => ListingCustomFieldSchemaBuilder::formComponents(
($categoryId = $get('category_id')) ? (int) $categoryId : null
))
->columns(2)
->columnSpanFull()
->visible(fn (Get $get): bool => ListingCustomFieldSchemaBuilder::hasFields(
($categoryId = $get('category_id')) ? (int) $categoryId : null
)),
StateFusionSelect::make('status')->required(),
PhoneInput::make('contact_phone')->defaultCountry(CountryCodeManager::defaultCountryIso2())->nullable(),
TextInput::make('contact_email')->email()->maxLength(255),
Toggle::make('is_featured')->default(false),
Select::make('country')
->label('Country')
->options(fn (): array => Country::nameOptions())
->searchable()
->preload()
->live()
->afterStateUpdated(fn ($state, $set) => $set('city', null))
->nullable(),
Select::make('city')
->label('City')
->options(fn (Get $get): array => City::nameOptions($get('country')))
->searchable()
->preload()
->nullable(),
Map::make('location')
->label('Location')
->visible(fn (): bool => ListingPanelHelper::googleMapsEnabled())
->draggable()
->clickable()
->autocomplete('city')
->autocompleteReverse(true)
->reverseGeocode([
'city' => '%L',
])
->defaultLocation([41.0082, 28.9784])
->defaultZoom(10)
->height('320px')
->columnSpanFull(),
SpatieMediaLibraryFileUpload::make('images')
->collection('listing-images')
->multiple()
->image()
->reorderable(),
VideoFormSchema::listingSection(),
];
}
public static function configureTable(Table $table, string $resourceClass): Table
{
return $table
->columns([
SpatieMediaLibraryImageColumn::make('images')
->collection('listing-images')
->circular(),
TextColumn::make('id')->sortable(),
TextColumn::make('title')->searchable()->sortable()->limit(40),
TextColumn::make('category.name')->label('Category')->sortable(),
TextColumn::make('user.email')->label('Owner')->searchable()->toggleable()->sortable(),
TextColumn::make('price')
->currency(fn (Listing $record): string => $record->currency ?: ListingPanelHelper::defaultCurrency())
->sortable(),
StateFusionSelectColumn::make('status')->sortable(),
IconColumn::make('is_featured')->boolean()->label('Featured')->sortable(),
TextColumn::make('city')->sortable(),
TextColumn::make('country')->sortable(),
TextColumn::make('created_at')->dateTime()->sortable(),
])
->filters([
StateFusionSelectFilter::make('status'),
SelectFilter::make('category_id')
->label('Category')
->relationship('category', 'name')
->searchable()
->preload(),
SelectFilter::make('user_id')
->label('Owner')
->relationship('user', 'email')
->searchable()
->preload(),
SelectFilter::make('country')
->options(fn (): array => Country::nameOptions())
->searchable(),
SelectFilter::make('city')
->options(fn (): array => City::nameOptions(null, false))
->searchable(),
TernaryFilter::make('is_featured')->label('Featured'),
Filter::make('created_at')
->label('Created Date')
->schema([
DatePicker::make('from')->label('From'),
DatePicker::make('until')->label('Until'),
])
->query(fn (Builder $query, array $data): Builder => $query
->when($data['from'] ?? null, fn (Builder $builder, string $date): Builder => $builder->whereDate('created_at', '>=', $date))
->when($data['until'] ?? null, fn (Builder $builder, string $date): Builder => $builder->whereDate('created_at', '<=', $date))),
Filter::make('price')
->label('Price Range')
->schema([
TextInput::make('min')->numeric()->label('Min'),
TextInput::make('max')->numeric()->label('Max'),
])
->query(fn (Builder $query, array $data): Builder => $query
->when($data['min'] ?? null, fn (Builder $builder, string $amount): Builder => $builder->where('price', '>=', (float) $amount))
->when($data['max'] ?? null, fn (Builder $builder, string $amount): Builder => $builder->where('price', '<=', (float) $amount))),
])
->filtersLayout(FiltersLayout::AboveContentCollapsible)
->filtersFormColumns(3)
->filtersFormWidth('7xl')
->persistFiltersInSession()
->defaultSort('id', 'desc')
->actions(ResourceTableActions::editActivityDelete($resourceClass));
}
}

View File

@ -4,8 +4,8 @@ namespace Modules\Listing\Support;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Component;
use Illuminate\Support\Carbon;
@ -22,9 +22,6 @@ class ListingCustomFieldSchemaBuilder
->exists();
}
/**
* @return array<int, Component>
*/
public static function formComponents(?int $categoryId): array
{
return ListingCustomField::query()
@ -38,10 +35,6 @@ class ListingCustomFieldSchemaBuilder
->all();
}
/**
* @param array<string, mixed> $values
* @return array<int, array{label: string, value: string}>
*/
public static function presentableValues(?int $categoryId, array $values): array
{
if ($values === []) {

View File

@ -2,7 +2,7 @@
namespace Modules\Listing\Support;
use App\Settings\GeneralSettings;
use Modules\Site\App\Settings\GeneralSettings;
use Throwable;
class ListingPanelHelper
@ -32,7 +32,7 @@ class ListingPanelHelper
return self::currencyCodes()[0] ?? 'USD';
}
public static function normalizeCurrency(null | string $currency): string
public static function normalizeCurrency(?string $currency): string
{
$normalized = strtoupper(substr(trim((string) $currency), 0, 3));
$codes = self::currencyCodes();

View File

@ -1,6 +1,6 @@
<?php
namespace App\Support;
namespace Modules\Listing\Support;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Http\UploadedFile;
@ -12,16 +12,6 @@ use function Laravel\Ai\agent;
class QuickListingCategorySuggester
{
/**
* @return array{
* detected: bool,
* category_id: int|null,
* confidence: float|null,
* reason: string,
* alternatives: array<int>,
* error: string|null
* }
*/
public function suggestFromImage(UploadedFile $image): array
{
$provider = (string) config('quick-listing.ai_provider', 'openai');
@ -39,11 +29,7 @@ class QuickListingCategorySuggester
];
}
$categories = Category::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('name')
->get(['id', 'name', 'parent_id']);
$categories = Category::activeAiCatalog();
if ($categories->isEmpty()) {
return [
@ -131,10 +117,6 @@ class QuickListingCategorySuggester
}
}
/**
* @param Collection<int, Category> $categories
* @return Collection<int, array{id: int, path: string}>
*/
private function buildCatalog(Collection $categories): Collection
{
$byId = $categories->keyBy('id');
@ -156,4 +138,3 @@ class QuickListingCategorySuggester
});
}
}

View File

@ -229,12 +229,12 @@
</p>
</div>
<div class="listing-filter-card px-4 py-3 hidden lg:flex flex-col xl:flex-row xl:items-center gap-3">
<p class="text-sm text-slate-700 mr-auto">
<div class="listing-results-bar listing-filter-card hidden lg:flex">
<p class="listing-results-meta">
<strong>{{ number_format($resultListingsCount) }}</strong>
{{ $activeCategoryName !== '' ? ' listings found in '.$activeCategoryName : ' listings found' }}
</p>
<div class="flex flex-wrap items-center gap-2">
<div class="listing-results-actions">
@auth
<form method="POST" action="{{ route('favorites.searches.store') }}">
@csrf
@ -276,9 +276,9 @@
<input type="hidden" name="date_filter" value="{{ $dateFilter }}">
@endif
<label class="h-10 px-4 rounded-full border border-slate-300 bg-white inline-flex items-center gap-2 text-sm font-semibold text-slate-700">
<label class="listing-results-sort">
<span>Sort by</span>
<select name="sort" class="bg-transparent text-sm font-semibold focus:outline-none" onchange="this.form.submit()">
<select name="sort" class="listing-results-sort-select" onchange="this.form.submit()">
<option value="smart" @selected($sort === 'smart')>Recommended</option>
<option value="newest" @selected($sort === 'newest')>Newest</option>
<option value="oldest" @selected($sort === 'oldest')>Oldest</option>
@ -570,7 +570,6 @@
countrySelect.value = matchedCountryOption.value;
await loadCities(matchedCountryOption.value, cityName);
} catch (error) {
// no-op
}
});
})();

View File

@ -43,9 +43,6 @@ class LocationSeeder extends Seeder
->delete();
}
/**
* @return array<int, array{code: string, name: string, phone_code: string}>
*/
private function countries(): array
{
$countries = [];
@ -84,7 +81,7 @@ class LocationSeeder extends Seeder
continue;
}
$key = 'filament-country-code-field::countries.' . $value;
$key = 'filament-country-code-field::countries.'.$value;
$labelEn = trim((string) trans($key, [], 'en'));
$name = $labelEn !== '' && $labelEn !== $key ? $labelEn : strtoupper($value);
@ -112,9 +109,6 @@ class LocationSeeder extends Seeder
return substr($normalized, 0, 10);
}
/**
* @return array<int, string>
*/
private function turkeyCities(): array
{
return [

View File

@ -1,4 +1,5 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

View File

@ -0,0 +1,27 @@
<?php
namespace Modules\Location\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Modules\Location\Models\City;
use Modules\Location\Models\Country;
class LocationLookupController extends Controller
{
public function cities(string $country): JsonResponse
{
$countryModel = Country::resolveLookup($country);
if (! $countryModel) {
return response()->json([]);
}
return response()->json($countryModel->cityPayloads());
}
public function districts(City $city): JsonResponse
{
return response()->json($city->districtPayloads());
}
}

View File

@ -4,6 +4,8 @@ namespace Modules\Location\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
@ -28,12 +30,12 @@ class City extends Model
->dontSubmitEmptyLogs();
}
public function country()
public function country(): BelongsTo
{
return $this->belongsTo(Country::class);
}
public function districts()
public function districts(): HasMany
{
return $this->hasMany(District::class);
}
@ -53,4 +55,31 @@ class City extends Model
->pluck('name', 'name')
->all();
}
public static function quickCreateOptions(): array
{
return static::query()
->active()
->orderBy('name')
->get(['id', 'name', 'country_id'])
->map(fn (self $city): array => [
'id' => (int) $city->id,
'name' => (string) $city->name,
'country_id' => (int) $city->country_id,
])
->all();
}
public function districtPayloads(): array
{
return $this->districts()
->orderBy('name')
->get()
->map(fn (District $district): array => [
'id' => (int) $district->id,
'name' => (string) $district->name,
'city_id' => (int) $district->city_id,
])
->all();
}
}

View File

@ -4,6 +4,7 @@ namespace Modules\Location\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
@ -28,7 +29,7 @@ class Country extends Model
->dontSubmitEmptyLogs();
}
public function cities()
public function cities(): HasMany
{
return $this->hasMany(City::class);
}
@ -59,4 +60,75 @@ class Country extends Model
->pluck('name', 'name')
->all();
}
public static function quickCreateOptions(): array
{
return static::query()
->active()
->orderBy('name')
->get(['id', 'name'])
->map(fn (self $country): array => [
'id' => (int) $country->id,
'name' => (string) $country->name,
])
->all();
}
public static function headerLocationOptions(): array
{
return static::query()
->active()
->orderBy('name')
->get(['id', 'name', 'code'])
->map(fn (self $country): array => [
'id' => (int) $country->id,
'name' => (string) $country->name,
'code' => strtoupper((string) $country->code),
])
->all();
}
public static function resolveLookup(string $value): ?self
{
$lookupValue = trim($value);
if ($lookupValue === '') {
return null;
}
$lookupCode = strtoupper($lookupValue);
$lookupName = mb_strtolower($lookupValue);
return static::query()
->where(function (Builder $query) use ($lookupCode, $lookupName, $lookupValue): void {
if (ctype_digit($lookupValue)) {
$query->orWhere('id', (int) $lookupValue);
}
$query
->orWhereRaw('UPPER(code) = ?', [$lookupCode])
->orWhereRaw('LOWER(name) = ?', [$lookupName]);
})
->first();
}
public function cityPayloads(bool $onlyActive = true): array
{
$cities = $this->cities()
->when($onlyActive, fn (Builder $query): Builder => $query->active())
->orderBy('name')
->get(['id', 'name', 'country_id']);
if ($onlyActive && $cities->isEmpty()) {
return $this->cityPayloads(false);
}
return $cities
->map(fn (City $city): array => [
'id' => (int) $city->id,
'name' => (string) $city->name,
'country_id' => (int) $city->country_id,
])
->all();
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Support;
namespace Modules\Location\Support;
use Illuminate\Support\Collection;
use Tapp\FilamentCountryCodeField\Enums\CountriesEnum;
@ -17,7 +17,7 @@ class CountryCodeManager
return self::iso2FromCountryCode(self::defaultCountryCode()) ?? 'TR';
}
public static function normalizeCountryCode(null | string $value): string
public static function normalizeCountryCode(?string $value): string
{
$value = trim((string) $value);
@ -32,7 +32,7 @@ class CountryCodeManager
return self::countryCodeFromIso2($value) ?? '+90';
}
public static function isValidCountryCode(null | string $value): bool
public static function isValidCountryCode(?string $value): bool
{
if (! filled($value)) {
return false;
@ -41,7 +41,7 @@ class CountryCodeManager
return self::countries()->contains(fn (array $country): bool => $country['country_code'] === trim((string) $value));
}
public static function countryCodeFromIso2(null | string $iso2): ?string
public static function countryCodeFromIso2(?string $iso2): ?string
{
$iso2 = strtoupper(trim((string) $iso2));
@ -53,7 +53,7 @@ class CountryCodeManager
->first(fn (array $country): bool => $country['iso2'] === $iso2)['country_code'] ?? null;
}
public static function iso2FromCountryCode(null | string $countryCode): ?string
public static function iso2FromCountryCode(?string $countryCode): ?string
{
$countryCode = trim((string) $countryCode);
@ -65,7 +65,7 @@ class CountryCodeManager
->first(fn (array $country): bool => $country['country_code'] === $countryCode)['iso2'] ?? null;
}
public static function labelFromCountryCode(null | string $countryCode): ?string
public static function labelFromCountryCode(?string $countryCode): ?string
{
$countryCode = trim((string) $countryCode);
@ -77,7 +77,7 @@ class CountryCodeManager
->first(fn (array $country): bool => $country['country_code'] === $countryCode)['english_label'] ?? null;
}
public static function countryCodeFromLabelOrCode(null | string $value): ?string
public static function countryCodeFromLabelOrCode(?string $value): ?string
{
$value = trim((string) $value);
@ -111,7 +111,7 @@ class CountryCodeManager
})['country_code'] ?? null;
}
public static function normalizeStoredCountry(null | string $value): ?string
public static function normalizeStoredCountry(?string $value): ?string
{
$value = trim((string) $value);
@ -128,9 +128,6 @@ class CountryCodeManager
return self::labelFromCountryCode($countryCode) ?? $value;
}
/**
* @return Collection<int, array{country_code: string, iso2: string, label: string, english_label: string}>
*/
private static function countries(): Collection
{
static $countries;

View File

@ -1,50 +1,11 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Location\Models\Country;
use Modules\Location\Http\Controllers\LocationLookupController;
Route::get('/locations/cities/{country}', function (string $country) {
$lookupValue = trim($country);
if ($lookupValue === '') {
return response()->json([]);
}
$lookupCode = strtoupper($lookupValue);
$lookupName = mb_strtolower($lookupValue);
$countryModel = Country::query()
->where(function ($query) use ($lookupValue, $lookupCode, $lookupName): void {
if (ctype_digit($lookupValue)) {
$query->orWhere('id', (int) $lookupValue);
}
$query
->orWhereRaw('UPPER(code) = ?', [$lookupCode])
->orWhereRaw('LOWER(name) = ?', [$lookupName]);
})
->first();
if (! $countryModel) {
return response()->json([]);
}
$activeCities = $countryModel->cities()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name', 'country_id']);
if ($activeCities->isNotEmpty()) {
return response()->json($activeCities);
}
return response()->json(
$countryModel->cities()
->orderBy('name')
->get(['id', 'name', 'country_id'])
);
})->name('locations.cities');
Route::get('/locations/districts/{city}', function (\Modules\Location\Models\City $city) {
return response()->json($city->districts);
})->name('locations.districts');
Route::middleware('web')->group(function () {
Route::get('/locations/cities/{country}', [LocationLookupController::class, 'cities'])
->name('locations.cities');
Route::get('/locations/districts/{city}', [LocationLookupController::class, 'districts'])
->name('locations.districts');
});

View File

@ -0,0 +1,171 @@
<?php
namespace Modules\Panel\App\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Modules\Listing\Models\Listing;
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\Panel\App\Http\Requests\StoreVideoRequest;
use Modules\Panel\App\Http\Requests\UpdateListingRequest;
use Modules\Panel\App\Http\Requests\UpdateVideoRequest;
use Modules\Video\Models\Video;
class PanelController extends Controller
{
public function index(): RedirectResponse
{
return redirect()->route('panel.listings.index');
}
public function create(): View
{
return view('panel::create');
}
public function listings(Request $request): View
{
$user = $request->user();
$search = trim((string) $request->string('search'));
$status = (string) $request->string('status', 'all');
if (! in_array($status, ['all', 'sold', 'expired'], true)) {
$status = 'all';
}
$payload = Listing::panelIndexDataForUser($user, $search, $status);
return view('panel::listings', [
'listings' => $payload['listings'],
'status' => $status,
'search' => $search,
'counts' => $payload['counts'],
]);
}
public function editListing(Request $request, Listing $listing): View
{
$listing->assertOwnedBy($request->user());
return view('panel::edit-listing', [
'listing' => $listing->loadPanelEditor(),
'customFieldValues' => ListingCustomFieldSchemaBuilder::presentableValues(
$listing->category_id ? (int) $listing->category_id : null,
(array) $listing->custom_fields,
),
'statusOptions' => Listing::panelStatusOptions(),
]);
}
public function updateListing(UpdateListingRequest $request, Listing $listing): RedirectResponse
{
$listing->assertOwnedBy($request->user());
$listing->updateFromPanel($request->validated() + [
'currency' => $listing->currency ?: ListingPanelHelper::defaultCurrency(),
]);
return redirect()
->route('panel.listings.edit', $listing)
->with('success', 'Listing updated.');
}
public function videos(Request $request): View
{
return view('panel::videos', Video::panelIndexDataForUser($request->user()));
}
public function storeVideo(StoreVideoRequest $request): RedirectResponse
{
$validated = $request->validated();
$listing = $request->user()->listings()->whereKey($validated['listing_id'])->firstOrFail();
$video = Video::createFromUploadedFile($listing, $request->file('video_file'), [
'title' => $validated['title'] ?? null,
'description' => $validated['description'] ?? null,
'sort_order' => Video::nextSortOrderForListing($listing),
'is_active' => true,
]);
return redirect()
->route('panel.videos.edit', $video)
->with('success', 'Video uploaded.');
}
public function editVideo(Request $request, Video $video): View
{
$video->assertOwnedBy($request->user());
return view('panel::video-edit', [
'video' => $video->load('listing:id,title,user_id'),
'listingOptions' => $request->user()->panelListingOptions(),
]);
}
public function updateVideo(UpdateVideoRequest $request, Video $video): RedirectResponse
{
$video->assertOwnedBy($request->user());
$validated = $request->validated();
$listing = $request->user()->listings()->whereKey($validated['listing_id'])->firstOrFail();
$video->updateFromPanel([
'listing_id' => $listing->getKey(),
'title' => $validated['title'] ?? null,
'description' => $validated['description'] ?? null,
'video_file' => $request->file('video_file'),
'is_active' => $request->boolean('is_active'),
]);
return redirect()
->route('panel.videos.edit', $video)
->with('success', 'Video updated.');
}
public function destroyVideo(Request $request, Video $video): RedirectResponse
{
$video->assertOwnedBy($request->user());
$video->delete();
return redirect()
->route('panel.videos.index')
->with('success', 'Video deleted.');
}
public function profile(Request $request): View
{
$user = $request->user()->loadPanelProfile();
return view('panel::profile', [
'user' => $user,
]);
}
public function destroyListing(Request $request, Listing $listing): RedirectResponse
{
$listing->assertOwnedBy($request->user());
$listing->delete();
return back()->with('success', 'Listing removed.');
}
public function markListingAsSold(Request $request, Listing $listing): RedirectResponse
{
$listing->assertOwnedBy($request->user());
$listing->markAsSold();
return back()->with('success', 'Listing marked as sold.');
}
public function republishListing(Request $request, Listing $listing): RedirectResponse
{
$listing->assertOwnedBy($request->user());
$listing->republish();
return back()->with('success', 'Listing republished.');
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace Modules\Panel\App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreVideoRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'listing_id' => ['required', 'integer', 'exists:listings,id'],
'title' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'video_file' => ['required', 'file', 'mimes:mp4,mov,webm,m4v', 'max:256000'],
];
}
public function messages(): array
{
return [
'listing_id.required' => 'Choose a listing for the video.',
'listing_id.exists' => 'Choose a valid listing for the video.',
'video_file.required' => 'A video file is required.',
'video_file.mimes' => 'Video must be an mp4, mov, webm, or m4v file.',
];
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace Modules\Panel\App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Listing\Models\Listing;
class UpdateListingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:5000'],
'price' => ['nullable', 'numeric', 'min:0'],
'status' => ['required', Rule::in(array_keys(Listing::panelStatusOptions()))],
'contact_phone' => ['nullable', 'string', 'max:60'],
'contact_email' => ['nullable', 'email', 'max:255'],
'country' => ['nullable', 'string', 'max:255'],
'city' => ['nullable', 'string', 'max:255'],
'expires_at' => ['nullable', 'date'],
];
}
public function messages(): array
{
return [
'title.required' => 'Listing title is required.',
'price.numeric' => 'Listing price must be numeric.',
'status.required' => 'Listing status is required.',
'status.in' => 'Listing status is invalid.',
'contact_email.email' => 'Contact email must be valid.',
'expires_at.date' => 'Expiry date must be a valid date.',
];
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace Modules\Panel\App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateVideoRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'listing_id' => ['required', 'integer', 'exists:listings,id'],
'title' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'video_file' => ['nullable', 'file', 'mimes:mp4,mov,webm,m4v', 'max:256000'],
'is_active' => ['nullable', 'boolean'],
];
}
public function messages(): array
{
return [
'listing_id.required' => 'Choose a listing for the video.',
'listing_id.exists' => 'Choose a valid listing for the video.',
'video_file.mimes' => 'Video must be an mp4, mov, webm, or m4v file.',
];
}
}

View File

@ -1,8 +1,7 @@
<?php
namespace App\Livewire;
namespace Modules\Panel\App\Livewire;
use App\Support\QuickListingCategorySuggester;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
@ -16,6 +15,7 @@ use Modules\Listing\Models\Listing;
use Modules\Listing\Models\ListingCustomField;
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\Listing\Support\QuickListingCategorySuggester;
use Modules\Location\Models\City;
use Modules\Location\Models\Country;
use Modules\S3\Support\MediaStorage;
@ -28,36 +28,59 @@ class PanelQuickListingForm extends Component
use WithFileUploads;
private const TOTAL_STEPS = 5;
private const DRAFT_SESSION_KEY = 'panel_quick_listing_draft';
private const OTHER_CITY_ID = -1;
public array $photos = [];
public array $videos = [];
public array $categories = [];
public array $countries = [];
public array $cities = [];
public array $listingCustomFields = [];
public array $customFieldValues = [];
public int $currentStep = 1;
public string $categorySearch = '';
public ?int $selectedCategoryId = null;
public ?int $activeParentCategoryId = null;
public ?int $detectedCategoryId = null;
public ?float $detectedConfidence = null;
public ?string $detectedReason = null;
public ?string $detectedError = null;
public array $detectedAlternatives = [];
public bool $isDetecting = false;
public string $listingTitle = '';
public string $price = '';
public string $description = '';
public ?int $selectedCountryId = null;
public ?int $selectedCityId = null;
public bool $isPublishing = false;
public bool $shouldPersistDraft = true;
public ?string $publishError = null;
public function mount(): void
@ -70,7 +93,7 @@ class PanelQuickListingForm extends Component
public function render()
{
return view('panel.quick-create');
return view('panel::quick-create');
}
public function dehydrate(): void
@ -596,10 +619,6 @@ class PanelQuickListingForm extends Component
abort(403);
}
$profilePhone = Profile::query()
->where('user_id', $user->getKey())
->value('phone');
$payload = [
'title' => trim($this->listingTitle),
'description' => trim($this->description),
@ -609,7 +628,7 @@ class PanelQuickListingForm extends Component
'status' => 'pending',
'custom_fields' => $this->sanitizedCustomFieldValues(),
'contact_email' => (string) $user->email,
'contact_phone' => $profilePhone,
'contact_phone' => Profile::phoneForUser($user),
'country' => $this->selectedCountryName,
'city' => $this->selectedCityName,
];
@ -674,75 +693,18 @@ class PanelQuickListingForm extends Component
private function loadCategories(): void
{
$all = Category::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('name')
->get(['id', 'name', 'parent_id', 'icon']);
$childrenCount = Category::query()
->where('is_active', true)
->selectRaw('parent_id, count(*) as aggregate')
->whereNotNull('parent_id')
->groupBy('parent_id')
->pluck('aggregate', 'parent_id');
$this->categories = $all
->map(fn (Category $category): array => [
'id' => (int) $category->id,
'name' => (string) $category->name,
'parent_id' => $category->parent_id ? (int) $category->parent_id : null,
'icon' => $category->icon,
'has_children' => ((int) ($childrenCount[$category->id] ?? 0)) > 0,
])
->values()
->all();
$this->categories = Category::panelQuickCatalog();
}
private function loadLocations(): void
{
$this->countries = Country::query()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name'])
->map(fn (Country $country): array => [
'id' => (int) $country->id,
'name' => (string) $country->name,
])
->all();
$this->cities = City::query()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name', 'country_id'])
->map(fn (City $city): array => [
'id' => (int) $city->id,
'name' => (string) $city->name,
'country_id' => (int) $city->country_id,
])
->all();
$this->countries = Country::quickCreateOptions();
$this->cities = City::quickCreateOptions();
}
private function loadListingCustomFields(): void
{
$this->listingCustomFields = ListingCustomField::query()
->active()
->forCategory($this->selectedCategoryId)
->ordered()
->get(['name', 'label', 'type', 'is_required', 'placeholder', 'help_text', 'options'])
->map(fn (ListingCustomField $field): array => [
'name' => (string) $field->name,
'label' => (string) $field->label,
'type' => (string) $field->type,
'is_required' => (bool) $field->is_required,
'placeholder' => $field->placeholder,
'help_text' => $field->help_text,
'options' => collect($field->options ?? [])
->map(fn ($option): string => (string) $option)
->values()
->all(),
])
->all();
$this->listingCustomFields = ListingCustomField::panelFieldDefinitions($this->selectedCategoryId);
$allowed = collect($this->listingCustomFields)->pluck('name')->all();
$this->customFieldValues = collect($this->customFieldValues)->only($allowed)->all();
@ -762,7 +724,7 @@ class PanelQuickListingForm extends Component
return;
}
$profile = Profile::query()->where('user_id', $user->getKey())->first();
$profile = Profile::detailsForUser($user);
if (! $profile) {
return;

View File

@ -0,0 +1,18 @@
<?php
namespace Modules\Panel\App\Providers;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
use Modules\Panel\App\Livewire\PanelQuickListingForm;
class PanelServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadRoutesFrom(module_path('Panel', 'routes/web.php'));
$this->loadViewsFrom(module_path('Panel', 'resources/views'), 'panel');
Livewire::component('panel-quick-listing-form', PanelQuickListingForm::class);
}
}

11
Modules/Panel/module.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "Panel",
"alias": "panel",
"description": "Authenticated seller panel",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\Panel\\App\\Providers\\PanelServiceProvider"
],
"files": []
}

View File

@ -5,7 +5,7 @@
@section('content')
<div class="max-w-[1320px] mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-[220px,1fr] gap-4">
@include('panel.partials.sidebar', ['activeMenu' => 'listings'])
@include('panel::partials.sidebar', ['activeMenu' => 'listings'])
<section class="space-y-4">
<div class="panel-surface p-6">

View File

@ -52,7 +52,7 @@
<div class="listings-dashboard-page mx-auto max-w-[1320px] px-4 py-6 md:py-8">
<div class="grid gap-6 xl:grid-cols-[300px,minmax(0,1fr)]">
<aside class="listings-dashboard-sidebar space-y-6">
@include('panel.partials.sidebar', ['activeMenu' => 'listings'])
@include('panel::partials.sidebar', ['activeMenu' => 'listings'])
</aside>
<section class="space-y-6">

View File

@ -73,14 +73,11 @@
<div class="mt-6 rounded-[24px] bg-slate-950 px-5 py-4 text-white shadow-[0_18px_38px_rgba(15,23,42,0.22)]">
<p class="text-[0.68rem] font-semibold uppercase tracking-[0.26em] text-slate-300">Profile visibility</p>
<p class="mt-2 text-sm leading-6 text-slate-200">
Keep your name and email current so buyers can recognize you quickly in conversations and listing activity.
</p>
</div>
</div>
</div>
@include('panel.partials.sidebar', ['activeMenu' => 'profile'])
@include('panel::partials.sidebar', ['activeMenu' => 'profile'])
</aside>
<section class="space-y-6">

View File

@ -0,0 +1 @@
@include('panel::partials.quick-create.form')

View File

@ -5,7 +5,7 @@
@section('content')
<div class="max-w-[1320px] mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-[220px,1fr] gap-4">
@include('panel.partials.sidebar', ['activeMenu' => 'videos'])
@include('panel::partials.sidebar', ['activeMenu' => 'videos'])
<section class="space-y-4">
<div class="panel-surface p-6">

View File

@ -5,10 +5,10 @@
@section('content')
<div class="max-w-[1320px] mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-[220px,1fr] gap-4">
@include('panel.partials.sidebar', ['activeMenu' => 'videos'])
@include('panel::partials.sidebar', ['activeMenu' => 'videos'])
<section class="space-y-4">
@include('panel.partials.page-header', [
@include('panel::partials.page-header', [
'title' => 'Videos',
'description' => 'Upload listing videos and manage processing from one frontend workspace.',
])

View File

@ -0,0 +1,21 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Panel\App\Http\Controllers\PanelController;
Route::middleware(['web', 'auth'])->prefix('panel')->name('panel.')->group(function () {
Route::get('/', [PanelController::class, 'index'])->name('index');
Route::get('/my-listings', [PanelController::class, 'listings'])->name('listings.index');
Route::get('/create-listing', [PanelController::class, 'create'])->name('listings.create');
Route::get('/my-listings/{listing}/edit', [PanelController::class, 'editListing'])->name('listings.edit');
Route::put('/my-listings/{listing}', [PanelController::class, 'updateListing'])->name('listings.update');
Route::post('/my-listings/{listing}/remove', [PanelController::class, 'destroyListing'])->name('listings.destroy');
Route::post('/my-listings/{listing}/mark-sold', [PanelController::class, 'markListingAsSold'])->name('listings.mark-sold');
Route::post('/my-listings/{listing}/republish', [PanelController::class, 'republishListing'])->name('listings.republish');
Route::get('/videos', [PanelController::class, 'videos'])->name('videos.index');
Route::post('/videos', [PanelController::class, 'storeVideo'])->name('videos.store');
Route::get('/videos/{video}/edit', [PanelController::class, 'editVideo'])->name('videos.edit');
Route::put('/videos/{video}', [PanelController::class, 'updateVideo'])->name('videos.update');
Route::delete('/videos/{video}', [PanelController::class, 'destroyVideo'])->name('videos.destroy');
Route::get('/my-profile', [PanelController::class, 'profile'])->name('profile.edit');
});

View File

@ -2,9 +2,9 @@
namespace Modules\S3\Support;
use App\Settings\GeneralSettings;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Modules\Site\App\Settings\GeneralSettings;
use Throwable;
final class MediaStorage
@ -24,8 +24,6 @@ final class MediaStorage
public static function defaultDriver(): string
{
return self::coerceDriver(config('media_storage.default_driver'))
?? self::coerceDriver(env('MEDIA_DISK'))
?? self::coerceDriver(env('FILESYSTEM_DISK'))
?? self::DRIVER_S3;
}
@ -104,7 +102,7 @@ final class MediaStorage
config([
'filesystems.default' => $disk,
'filemanager.disk' => env('FILEMANAGER_DISK', $disk),
'filemanager.disk' => $disk,
'filament.default_filesystem_disk' => $disk,
'media-library.disk_name' => $disk,
'video.disk' => $disk,

View File

@ -0,0 +1,34 @@
<?php
namespace Modules\Site\App\Http\Controllers;
use App\Http\Controllers\Controller;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User;
class HomeController extends Controller
{
public function index()
{
$categories = Category::homeParentCategories();
$featuredListings = Listing::homeFeatured();
$recentListings = Listing::homeRecent();
$listingCount = Listing::activeCount();
$categoryCount = Category::activeCount();
$userCount = User::totalCount();
$favoriteListingIds = auth()->check()
? auth()->user()->homeFavoriteListingIds()
: [];
return view('site::home', compact(
'categories',
'featuredListings',
'recentListings',
'listingCount',
'categoryCount',
'userCount',
'favoriteListingIds',
));
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace Modules\Site\App\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
class LanguageController extends Controller
{
public function switch(string $locale): RedirectResponse
{
$available = config('app.available_locales', ['en']);
if (in_array($locale, $available, true)) {
session(['locale' => $locale]);
}
return redirect()->back()->withInput();
}
}

View File

@ -1,18 +1,16 @@
<?php
namespace App\Http\Middleware;
namespace Modules\Site\App\Http\Middleware;
use App\Support\RequestAppData;
use Closure;
use Illuminate\Http\Request;
use Modules\Site\App\Support\RequestAppData;
class BootstrapAppData
{
public function __construct(private readonly RequestAppData $requestAppData)
{
}
public function __construct(private readonly RequestAppData $requestAppData) {}
public function handle(Request $request, Closure $next)
public function handle(Request $request, Closure $next): mixed
{
$this->requestAppData->bootstrap();

View File

@ -1,19 +1,23 @@
<?php
namespace App\Http\Middleware;
namespace Modules\Site\App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class SetLocale
{
public function handle(Request $request, Closure $next)
public function handle(Request $request, Closure $next): mixed
{
$locale = session('locale', config('app.locale'));
$available = config('app.available_locales', ['en']);
if (!in_array($locale, $available)) {
if (! in_array($locale, $available, true)) {
$locale = config('app.locale');
}
app()->setLocale($locale);
return $next($request);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Modules\Site\App\Providers;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class SiteServiceProvider extends ServiceProvider
{
public function boot(): void
{
$viewPath = module_path('Site', 'resources/views');
$this->loadRoutesFrom(module_path('Site', 'routes/web.php'));
$this->loadViewsFrom($viewPath, 'site');
View::addNamespace('app', $viewPath);
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Settings;
namespace Modules\Site\App\Settings;
use Spatie\LaravelSettings\Settings;

View File

@ -1,6 +1,6 @@
<?php
namespace App\Support;
namespace Modules\Site\App\Support;
use Illuminate\Support\Arr;
use Modules\S3\Support\MediaStorage;

View File

@ -1,14 +1,15 @@
<?php
namespace App\Support;
namespace Modules\Site\App\Support;
use App\Settings\GeneralSettings;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\View;
use Modules\Category\Models\Category;
use Modules\Location\Models\Country;
use Modules\Location\Support\CountryCodeManager;
use Modules\S3\Support\MediaStorage;
use Modules\Site\App\Settings\GeneralSettings;
use Modules\User\App\Models\User;
use Throwable;
@ -33,14 +34,14 @@ final class RequestAppData
$fallbackCurrencies = $this->normalizeCurrencies(config('app.currencies', ['USD']));
$fallbackDescription = 'Buy and sell everything in your area.';
$fallbackHomeSlides = HomeSlideDefaults::defaults();
$fallbackGoogleMapsApiKey = env('GOOGLE_MAPS_API_KEY');
$fallbackGoogleClientId = env('GOOGLE_CLIENT_ID');
$fallbackGoogleClientSecret = env('GOOGLE_CLIENT_SECRET');
$fallbackFacebookClientId = env('FACEBOOK_CLIENT_ID');
$fallbackFacebookClientSecret = env('FACEBOOK_CLIENT_SECRET');
$fallbackAppleClientId = env('APPLE_CLIENT_ID');
$fallbackAppleClientSecret = env('APPLE_CLIENT_SECRET');
$fallbackDefaultCountryCode = '+90';
$fallbackGoogleMapsApiKey = config('services.google_maps.api_key');
$fallbackGoogleClientId = config('services.google.client_id');
$fallbackGoogleClientSecret = config('services.google.client_secret');
$fallbackFacebookClientId = config('services.facebook.client_id');
$fallbackFacebookClientSecret = config('services.facebook.client_secret');
$fallbackAppleClientId = config('services.apple.client_id');
$fallbackAppleClientSecret = config('services.apple.client_secret');
$fallbackDefaultCountryCode = (string) config('app.default_country_code', '+90');
$fallbackMediaDriver = MediaStorage::defaultDriver();
$generalSettings = [
@ -59,13 +60,13 @@ final class RequestAppData
'whatsapp' => null,
'google_maps_enabled' => false,
'google_maps_api_key' => $fallbackGoogleMapsApiKey,
'google_login_enabled' => (bool) env('ENABLE_GOOGLE_LOGIN', false),
'google_login_enabled' => (bool) config('services.google.enabled', false),
'google_client_id' => $fallbackGoogleClientId,
'google_client_secret' => $fallbackGoogleClientSecret,
'facebook_login_enabled' => (bool) env('ENABLE_FACEBOOK_LOGIN', false),
'facebook_login_enabled' => (bool) config('services.facebook.enabled', false),
'facebook_client_id' => $fallbackFacebookClientId,
'facebook_client_secret' => $fallbackFacebookClientSecret,
'apple_login_enabled' => (bool) env('ENABLE_APPLE_LOGIN', false),
'apple_login_enabled' => (bool) config('services.apple.enabled', false),
'apple_client_id' => $fallbackAppleClientId,
'apple_client_secret' => $fallbackAppleClientSecret,
];
@ -174,17 +175,7 @@ final class RequestAppData
return [];
}
return Country::query()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name', 'code'])
->map(fn (Country $country): array => [
'id' => (int) $country->id,
'name' => (string) $country->name,
'code' => strtoupper((string) $country->code),
])
->values()
->all();
return Country::headerLocationOptions();
} catch (Throwable) {
return [];
}
@ -197,20 +188,7 @@ final class RequestAppData
return [];
}
return Category::query()
->where('is_active', true)
->whereNull('parent_id')
->orderBy('sort_order')
->orderBy('name')
->limit(8)
->get(['id', 'name', 'icon'])
->map(fn (Category $category): array => [
'id' => (int) $category->id,
'name' => (string) $category->name,
'icon_url' => $category->iconUrl(),
])
->values()
->all();
return Category::headerNavigationItems();
} catch (Throwable) {
return [];
}

11
Modules/Site/module.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "Site",
"alias": "site",
"description": "Site shell, locale, and landing experience",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\Site\\App\\Providers\\SiteServiceProvider"
],
"files": []
}

View File

@ -0,0 +1,14 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Site\App\Http\Controllers\HomeController;
use Modules\Site\App\Http\Controllers\LanguageController;
Route::middleware('web')->group(function () {
Route::get('/', [HomeController::class, 'index'])->name('home');
Route::get('/lang/{locale}', [LanguageController::class, 'switch'])->name('lang.switch');
Route::get('/dashboard', fn () => auth()->check()
? redirect()->route('panel.listings.index')
: redirect()->route('login'))
->name('dashboard');
});

View File

@ -26,4 +26,18 @@ class Profile extends Model
{
return $this->belongsTo(User::class);
}
public static function detailsForUser(User $user): ?self
{
return static::query()
->where('user_id', $user->getKey())
->first();
}
public static function phoneForUser(User $user): ?string
{
return static::query()
->where('user_id', $user->getKey())
->value('phone');
}
}

View File

@ -5,6 +5,7 @@ namespace Modules\User\App\Models;
use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasAvatar;
use Filament\Panel;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
@ -29,9 +30,9 @@ class User extends Authenticatable implements FilamentUser, HasAvatar
use HasApiTokens;
use HasFactory;
use HasRoles;
use HasStates;
use LogsActivity;
use Notifiable;
use HasStates;
use TwoFactorAuthenticatable;
protected $fillable = ['name', 'email', 'password', 'avatar_url', 'status'];
@ -219,4 +220,34 @@ class User extends Authenticatable implements FilamentUser, HasAvatar
'favorites' => $this->savedListingsCount(),
];
}
public static function totalCount(): int
{
return (int) static::query()->count();
}
public function homeFavoriteListingIds(): array
{
return $this->favoriteListings()
->pluck('listings.id')
->map(fn ($id): int => (int) $id)
->all();
}
public function panelListingOptions(): Collection
{
return $this->listings()
->latest('id')
->get(['id', 'title', 'status']);
}
public function loadPanelProfile(): self
{
return $this->loadCount([
'listings',
'favoriteListings',
'favoriteSearches',
'favoriteSellers',
]);
}
}

View File

@ -22,7 +22,7 @@ class AuthUserSeeder extends Seeder
],
));
if (! class_exists(Role::class) || ! Schema::hasTable((new Role())->getTable())) {
if (! class_exists(Role::class) || ! Schema::hasTable((new Role)->getTable())) {
return;
}

View File

@ -75,7 +75,7 @@ class Video extends Model
return $query->orderBy('sort_order')->orderBy('id');
}
public function scopeOwnedByUser(Builder $query, int | string | null $userId): Builder
public function scopeOwnedByUser(Builder $query, int|string|null $userId): Builder
{
return $query->where('user_id', $userId);
}
@ -143,6 +143,19 @@ class Video extends Model
return ((int) $listing->videos()->max('sort_order')) + 1;
}
public static function panelIndexDataForUser(User $user): array
{
return [
'videos' => static::query()
->ownedByUser($user->getKey())
->with('listing:id,title,user_id')
->latest('id')
->paginate(10)
->withQueryString(),
'listingOptions' => $user->panelListingOptions(),
];
}
public function markAsProcessing(): void
{
if (blank($this->upload_path)) {
@ -302,6 +315,11 @@ class Video extends Model
return number_format($value, $power === 0 ? 0 : 1).' '.$units[$power];
}
public function assertOwnedBy(User $user): void
{
abort_unless((int) $this->user_id === (int) $user->getKey(), 403);
}
public function updateFromPanel(array $attributes): void
{
$this->forceFill([

View File

@ -2,7 +2,4 @@
namespace App\Http\Controllers;
abstract class Controller
{
//
}
abstract class Controller {}

View File

@ -1,33 +0,0 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Modules\Listing\Models\Listing;
use Modules\Category\Models\Category;
use Modules\User\App\Models\User;
class HomeController extends Controller
{
public function index()
{
$categories = Category::whereNull('parent_id')->where('is_active', true)->get();
$featuredListings = Listing::where('status', 'active')->where('is_featured', true)->latest()->take(4)->get();
$recentListings = Listing::where('status', 'active')->latest()->take(8)->get();
$listingCount = Listing::where('status', 'active')->count();
$categoryCount = Category::where('is_active', true)->count();
$userCount = User::count();
$favoriteListingIds = auth()->check()
? auth()->user()->favoriteListings()->pluck('listings.id')->all()
: [];
return view('home', compact(
'categories',
'featuredListings',
'recentListings',
'listingCount',
'categoryCount',
'userCount',
'favoriteListingIds',
));
}
}

View File

@ -1,16 +0,0 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class LanguageController extends Controller
{
public function switch(string $locale)
{
$available = config('app.available_locales', ['en']);
if (in_array($locale, $available)) {
session(['locale' => $locale]);
}
return redirect()->back()->withInput();
}
}

View File

@ -1,247 +0,0 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use Modules\Listing\Models\Listing;
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\Video\Enums\VideoStatus;
use Modules\Video\Models\Video;
class PanelController extends Controller
{
public function index(): RedirectResponse
{
return redirect()->route('panel.listings.index');
}
public function create(): View
{
return view('panel.create');
}
public function listings(Request $request): View
{
$user = $request->user();
$search = trim((string) $request->string('search'));
$status = (string) $request->string('status', 'all');
if (! in_array($status, ['all', 'sold', 'expired'], true)) {
$status = 'all';
}
$listings = Listing::query()
->ownedByUser($user->getKey())
->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,
]),
])
->searchTerm($search)
->forPanelStatus($status)
->latest('id')
->paginate(10)
->withQueryString();
return view('panel.listings', [
'listings' => $listings,
'status' => $status,
'search' => $search,
'counts' => Listing::panelStatusCountsForUser($user->getKey()),
]);
}
public function editListing(Request $request, Listing $listing): View
{
$this->guardListingOwner($request, $listing);
return view('panel.edit-listing', [
'listing' => $listing->load(['category:id,name', 'videos:id,listing_id,title,status,is_active,path,upload_path,duration_seconds,size']),
'customFieldValues' => ListingCustomFieldSchemaBuilder::presentableValues(
$listing->category_id ? (int) $listing->category_id : null,
(array) $listing->custom_fields,
),
'statusOptions' => Listing::panelStatusOptions(),
]);
}
public function updateListing(Request $request, Listing $listing): RedirectResponse
{
$this->guardListingOwner($request, $listing);
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:5000'],
'price' => ['nullable', 'numeric', 'min:0'],
'status' => ['required', Rule::in(array_keys(Listing::panelStatusOptions()))],
'contact_phone' => ['nullable', 'string', 'max:60'],
'contact_email' => ['nullable', 'email', 'max:255'],
'country' => ['nullable', 'string', 'max:255'],
'city' => ['nullable', 'string', 'max:255'],
'expires_at' => ['nullable', 'date'],
]);
$listing->updateFromPanel($validated + [
'currency' => $listing->currency ?: ListingPanelHelper::defaultCurrency(),
]);
return redirect()
->route('panel.listings.edit', $listing)
->with('success', 'Listing updated.');
}
public function videos(Request $request): View
{
$user = $request->user();
return view('panel.videos', [
'videos' => Video::query()
->ownedByUser($user->getKey())
->with('listing:id,title,user_id')
->latest('id')
->paginate(10)
->withQueryString(),
'listingOptions' => $user->listings()
->latest('id')
->get(['id', 'title', 'status']),
]);
}
public function storeVideo(Request $request): RedirectResponse
{
$validated = $request->validate([
'listing_id' => ['required', 'integer'],
'title' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'video_file' => ['required', 'file', 'mimes:mp4,mov,webm,m4v', 'max:256000'],
]);
$listing = $request->user()->listings()->whereKey($validated['listing_id'])->firstOrFail();
$video = Video::createFromUploadedFile($listing, $request->file('video_file'), [
'title' => $validated['title'] ?? null,
'description' => $validated['description'] ?? null,
'sort_order' => Video::nextSortOrderForListing($listing),
'is_active' => true,
]);
return redirect()
->route('panel.videos.edit', $video)
->with('success', 'Video uploaded.');
}
public function editVideo(Request $request, Video $video): View
{
$this->guardVideoOwner($request, $video);
return view('panel.video-edit', [
'video' => $video->load('listing:id,title,user_id'),
'listingOptions' => $request->user()->listings()
->latest('id')
->get(['id', 'title', 'status']),
]);
}
public function updateVideo(Request $request, Video $video): RedirectResponse
{
$this->guardVideoOwner($request, $video);
$validated = $request->validate([
'listing_id' => ['required', 'integer'],
'title' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'video_file' => ['nullable', 'file', 'mimes:mp4,mov,webm,m4v', 'max:256000'],
'is_active' => ['nullable', 'boolean'],
]);
$listing = $request->user()->listings()->whereKey($validated['listing_id'])->firstOrFail();
$video->updateFromPanel([
'listing_id' => $listing->getKey(),
'title' => $validated['title'] ?? null,
'description' => $validated['description'] ?? null,
'video_file' => $request->file('video_file'),
'is_active' => $request->boolean('is_active'),
]);
return redirect()
->route('panel.videos.edit', $video)
->with('success', 'Video updated.');
}
public function destroyVideo(Request $request, Video $video): RedirectResponse
{
$this->guardVideoOwner($request, $video);
$video->delete();
return redirect()
->route('panel.videos.index')
->with('success', 'Video deleted.');
}
public function profile(Request $request): View
{
$user = $request->user()->loadCount([
'listings',
'favoriteListings',
'favoriteSearches',
'favoriteSellers',
]);
return view('panel.profile', [
'user' => $user,
]);
}
public function destroyListing(Request $request, Listing $listing): RedirectResponse
{
$this->guardListingOwner($request, $listing);
$listing->delete();
return back()->with('success', 'Listing removed.');
}
public function markListingAsSold(Request $request, Listing $listing): RedirectResponse
{
$this->guardListingOwner($request, $listing);
$listing->forceFill([
'status' => 'sold',
])->save();
return back()->with('success', 'Listing marked as sold.');
}
public function republishListing(Request $request, Listing $listing): RedirectResponse
{
$this->guardListingOwner($request, $listing);
$listing->forceFill([
'status' => 'active',
'expires_at' => now()->addDays(30),
])->save();
return back()->with('success', 'Listing republished.');
}
private function guardListingOwner(Request $request, Listing $listing): void
{
if ((int) $listing->user_id !== (int) $request->user()->getKey()) {
abort(403);
}
}
private function guardVideoOwner(Request $request, Video $video): void
{
if ((int) $video->user_id !== (int) $request->user()->getKey()) {
abort(403);
}
}
}

View File

@ -1,17 +0,0 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class AppLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.app');
}
}

View File

@ -1,11 +1,13 @@
<?php
use Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests;
use Illuminate\Session\Middleware\StartSession;
use Modules\Demo\App\Http\Middleware\ResolveDemoRequest;
use Modules\Site\App\Http\Middleware\BootstrapAppData;
use Modules\Site\App\Http\Middleware\SetLocale;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
@ -17,13 +19,13 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
ResolveDemoRequest::class,
\App\Http\Middleware\BootstrapAppData::class,
\App\Http\Middleware\SetLocale::class,
BootstrapAppData::class,
SetLocale::class,
]);
$middleware->appendToPriorityList(StartSession::class, ResolveDemoRequest::class);
$middleware->appendToPriorityList(ResolveDemoRequest::class, \App\Http\Middleware\BootstrapAppData::class);
$middleware->appendToPriorityList(\App\Http\Middleware\BootstrapAppData::class, \App\Http\Middleware\SetLocale::class);
$middleware->appendToPriorityList(ResolveDemoRequest::class, BootstrapAppData::class);
$middleware->appendToPriorityList(BootstrapAppData::class, SetLocale::class);
$middleware->prependToPriorityList(AuthenticatesRequests::class, ResolveDemoRequest::class);
})
->withExceptions(function (Exceptions $exceptions): void {

View File

@ -1,83 +1,11 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'available_locales' => ['en', 'tr', 'ar', 'zh', 'es', 'fr', 'de', 'pt', 'ru', 'ja'],
@ -87,18 +15,6 @@ return [
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
@ -108,20 +24,6 @@ return [
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),

View File

@ -1,95 +1,22 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', Modules\User\App\Models\User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
@ -98,18 +25,6 @@ return [
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

View File

@ -1,33 +1,7 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Broadcaster
|--------------------------------------------------------------------------
|
| This option controls the default broadcaster that will be used by the
| framework when an event needs to be broadcast. You may set this to
| any of the connections defined in the "connections" array below.
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
*/
'default' => env('BROADCAST_CONNECTION', 'null'),
/*
|--------------------------------------------------------------------------
| Broadcast Connections
|--------------------------------------------------------------------------
|
| Here you may define all of the broadcast connections that will be used
| to broadcast events to other systems or over WebSockets. Samples of
| each available type of connection are provided inside this array.
|
*/
'connections' => [
'reverb' => [
@ -42,7 +16,6 @@ return [
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
@ -60,7 +33,6 @@ return [
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],

View File

@ -3,35 +3,7 @@
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane",
| "failover", "null"
|
*/
'stores' => [
'array' => [
@ -61,7 +33,6 @@ return [
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
@ -100,18 +71,6 @@ return [
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
];

View File

@ -3,32 +3,7 @@
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
@ -124,39 +99,13 @@ return [
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),

View File

@ -1,122 +1,23 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| File Manager Mode
|--------------------------------------------------------------------------
|
| The file manager supports two modes:
|
| - 'database': Files and folders are tracked in a database table.
| Metadata, hierarchy, and relationships are stored in the database.
| File contents are stored on the configured disk. Best for applications
| that need to attach metadata, tags, or relationships to files.
|
| - 'storage': Files and folders are read directly from a storage disk.
| No database is used. The file manager shows the actual file system
| structure. Renaming and moving actually rename/move files on the disk.
| Best for managing cloud storage (S3, etc.) or local file systems.
|
*/
'mode' => 'database', // 'database' or 'storage'
/*
|--------------------------------------------------------------------------
| Storage Mode Settings
|--------------------------------------------------------------------------
|
| These settings only apply when mode is set to 'storage'.
|
| - disk: The Laravel filesystem disk to use (e.g., 'local', 's3', 'public')
| - root: The root path within the disk (empty string for disk root)
| - show_hidden: Whether to show hidden files (starting with .)
|
*/
'storage_mode' => [
'disk' => env('FILEMANAGER_DISK', env('FILESYSTEM_DISK', 'public')),
'root' => env('FILEMANAGER_ROOT', ''),
'show_hidden' => env('FILEMANAGER_SHOW_HIDDEN', false),
// For S3/MinIO: URL expiration time in minutes for signed URLs
'url_expiration' => env('FILEMANAGER_URL_EXPIRATION', 60),
],
/*
|--------------------------------------------------------------------------
| File Streaming Settings
|--------------------------------------------------------------------------
|
| Configure how files are served for preview and download.
|
| The file manager uses different URL strategies based on the disk:
| - S3-compatible disks: Uses temporaryUrl() for pre-signed URLs
| - Public disk: Uses direct Storage::url() (works via symlink)
| - Local/other disks: Uses signed routes to a streaming controller
|
*/
'streaming' => [
// URL generation strategy:
// - 'auto': Automatically detect best strategy per disk (recommended)
// - 'signed_route': Always use signed routes to streaming controller
// - 'direct': Always use Storage::url() (only works for public disk)
'url_strategy' => env('FILEMANAGER_URL_STRATEGY', 'auto'),
// URL expiration in minutes (for signed URLs and S3 temporary URLs)
'url_expiration' => env('FILEMANAGER_URL_EXPIRATION', 60),
// Route prefix for streaming endpoints
'route_prefix' => env('FILEMANAGER_ROUTE_PREFIX', 'filemanager'),
// Middleware applied to streaming routes
'middleware' => ['web'],
// Disks that should always use signed routes (even if public)
// Useful if you want extra security for certain disks
'force_signed_disks' => [],
// Disks that are publicly accessible via URL (override auto-detection)
// Files on these disks can be accessed directly without streaming
'public_disks' => ['public'],
// Disks that don't require authentication for streaming access
// Use with caution - files on these disks can be accessed without login
// Note: Signed URLs are still required, this just skips the auth check
'public_access_disks' => [],
],
/*
|--------------------------------------------------------------------------
| File System Item Model (Database Mode)
|--------------------------------------------------------------------------
|
| This is the model that represents files and folders in your application.
| Only used when mode is 'database'.
| It must implement the MWGuerra\FileManager\Contracts\FileSystemItemInterface.
|
| The package provides a default model. You can extend it or create your own:
|
| Option 1: Use the package model directly (default)
| 'model' => \MWGuerra\FileManager\Models\FileSystemItem::class,
|
| Option 2: Extend the package model in your app
| 'model' => \App\Models\FileSystemItem::class,
| // where App\Models\FileSystemItem extends MWGuerra\FileManager\Models\FileSystemItem
|
| Option 3: Create your own model implementing FileSystemItemInterface
| 'model' => \App\Models\CustomFileModel::class,
|
*/
'model' => \MWGuerra\FileManager\Models\FileSystemItem::class,
/*
|--------------------------------------------------------------------------
| File Manager Page (Database Mode)
|--------------------------------------------------------------------------
|
| Configure the File Manager page which uses database mode to track
| files with metadata, hierarchy, and relationships.
|
*/
'file_manager' => [
'enabled' => true,
'navigation' => [
@ -126,16 +27,6 @@ return [
'group' => 'FileManager',
],
],
/*
|--------------------------------------------------------------------------
| File System Page (Storage Mode)
|--------------------------------------------------------------------------
|
| Configure the File System page which shows files directly from the
| storage disk without using the database.
|
*/
'file_system' => [
'enabled' => true,
'navigation' => [
@ -145,46 +36,16 @@ return [
'group' => 'FileManager',
],
],
/*
|--------------------------------------------------------------------------
| Schema Example Page
|--------------------------------------------------------------------------
|
| Enable or disable the Schema Example page which demonstrates
| how to embed the file manager components into Filament forms.
|
*/
'schema_example' => [
'enabled' => true,
],
/*
|--------------------------------------------------------------------------
| Upload Settings
|--------------------------------------------------------------------------
|
| Configure upload settings for the file manager.
|
| Note: You may also need to adjust PHP settings in php.ini:
| - upload_max_filesize (default: 2M)
| - post_max_size (default: 8M)
| - max_execution_time (default: 30)
|
| For Livewire temporary uploads, also check config/livewire.php:
| - temporary_file_upload.rules (default: max:12288 = 12MB)
|
*/
'upload' => [
'disk' => env('FILEMANAGER_DISK', env('FILESYSTEM_DISK', 'public')),
'directory' => env('FILEMANAGER_UPLOAD_DIR', 'uploads'),
'max_file_size' => 100 * 1024, // 100 MB in kilobytes
'allowed_mimes' => [
// Videos
'video/mp4', 'video/webm', 'video/ogg', 'video/quicktime', 'video/x-msvideo',
// Images (SVG excluded by default - can contain scripts)
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
// Documents
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
@ -193,52 +54,25 @@ return [
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain',
// Audio
'audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/webm', 'audio/flac',
// Archives
'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed',
],
],
/*
|--------------------------------------------------------------------------
| Security Settings
|--------------------------------------------------------------------------
|
| Configure security settings to prevent malicious file uploads and access.
|
*/
'security' => [
// Dangerous extensions that should NEVER be uploaded (executable files)
'blocked_extensions' => [
// Server-side scripts
'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'phar',
'pl', 'py', 'pyc', 'pyo', 'rb', 'sh', 'bash', 'zsh', 'cgi',
'asp', 'aspx', 'jsp', 'jspx', 'cfm', 'cfc',
// Executables
'exe', 'msi', 'dll', 'com', 'bat', 'cmd', 'vbs', 'vbe',
'js', 'jse', 'ws', 'wsf', 'wsc', 'wsh', 'ps1', 'psm1',
// Other dangerous
'htaccess', 'htpasswd', 'ini', 'log', 'sql', 'env',
'pem', 'key', 'crt', 'cer',
],
// Files that can contain embedded scripts (XSS risk when served inline)
'sanitize_extensions' => ['svg', 'html', 'htm', 'xml'],
// Validate MIME type matches extension (prevents spoofing)
'validate_mime' => true,
// Rename files to prevent execution (adds random prefix)
'rename_uploads' => false,
// Strip potentially dangerous characters from filenames
'sanitize_filenames' => true,
// Maximum filename length
'max_filename_length' => 255,
// Patterns blocked in filenames (regex)
'blocked_filename_patterns' => [
'/\.{2,}/', // Multiple dots (path traversal)
'/^\./', // Hidden files
@ -246,27 +80,8 @@ return [
'/[<>:"|?*]/', // Windows reserved characters
],
],
/*
|--------------------------------------------------------------------------
| Authorization Settings
|--------------------------------------------------------------------------
|
| Configure authorization for file manager operations.
|
| When enabled, the package will check permissions before allowing operations.
| You can specify permission names that will be checked via the user's can() method.
|
| To customize authorization logic, extend FileSystemItemPolicy and register
| your custom policy in your application's AuthServiceProvider.
|
*/
'authorization' => [
// Enable/disable authorization checks (set to false during development)
'enabled' => env('FILEMANAGER_AUTH_ENABLED', true),
// Permission names to check (uses user->can() method)
// Set to null to skip permission check and just require authentication
'permissions' => [
'view_any' => null, // Access file manager page
'view' => null, // View/preview files
@ -276,55 +91,15 @@ return [
'delete_any' => null, // Bulk delete
'download' => null, // Download files
],
// The policy class to use (can be overridden with custom implementation)
'policy' => \MWGuerra\FileManager\Policies\FileSystemItemPolicy::class,
],
/*
|--------------------------------------------------------------------------
| Panel Sidebar Settings
|--------------------------------------------------------------------------
|
| Configure the file manager folder tree that can be rendered in the
| Filament panel sidebar using render hooks.
|
| - enabled: Enable/disable the sidebar folder tree
| - root_label: Label for the root folder (e.g., "Root", "/", "Home")
| - heading: Heading text shown above the folder tree
| - show_in_file_manager: Show the sidebar within the file manager page
|
*/
'sidebar' => [
'enabled' => true,
'root_label' => env('FILEMANAGER_SIDEBAR_ROOT_LABEL', 'Root'),
'heading' => env('FILEMANAGER_SIDEBAR_HEADING', 'Folders'),
'show_in_file_manager' => true,
],
/*
|--------------------------------------------------------------------------
| File Types
|--------------------------------------------------------------------------
|
| Configure which file types are enabled and register custom file types.
|
| Built-in types can be disabled by setting their value to false.
| Custom types can be added by listing their fully-qualified class names.
|
| Each custom type class must implement FileTypeContract or extend
| AbstractFileType from MWGuerra\FileManager\FileTypes.
|
| Example of registering custom types:
|
| 'custom' => [
| \App\FileTypes\ThreeDModelFileType::class,
| \App\FileTypes\EbookFileType::class,
| ],
|
*/
'file_types' => [
// Built-in types (set to false to disable)
'video' => true,
'image' => true,
'audio' => true,
@ -332,10 +107,7 @@ return [
'text' => true,
'document' => true,
'archive' => true,
// Custom file types (fully-qualified class names)
'custom' => [
// \App\FileTypes\ThreeDModelFileType::class,
],
],
];

View File

@ -1,33 +1,7 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', env('MEDIA_DISK', 's3')),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
@ -62,18 +36,6 @@ return [
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],

View File

@ -6,50 +6,11 @@ use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [

View File

@ -1,40 +1,7 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
@ -55,10 +22,6 @@ return [
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
@ -98,18 +61,6 @@ return [
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', 'Example'),

View File

@ -4,25 +4,7 @@ use Nwidart\Modules\Activators\FileActivator;
use Nwidart\Modules\Providers\ConsoleServiceProvider;
return [
/*
|--------------------------------------------------------------------------
| Module Namespace
|--------------------------------------------------------------------------
|
| Default module namespace.
|
*/
'namespace' => 'Modules',
/*
|--------------------------------------------------------------------------
| Module Stubs
|--------------------------------------------------------------------------
|
| Default module stubs.
|
*/
'stubs' => [
'enabled' => false,
'path' => base_path('vendor/nwidart/laravel-modules/src/Commands/stubs'),
@ -60,57 +42,11 @@ return [
'gitkeep' => true,
],
'paths' => [
/*
|--------------------------------------------------------------------------
| Modules path
|--------------------------------------------------------------------------
|
| This path is used to save the generated module.
| This path will also be added automatically to the list of scanned folders.
|
*/
'modules' => base_path('Modules'),
/*
|--------------------------------------------------------------------------
| Modules assets path
|--------------------------------------------------------------------------
|
| Here you may update the modules' assets path.
|
*/
'assets' => public_path('modules'),
/*
|--------------------------------------------------------------------------
| The migrations' path
|--------------------------------------------------------------------------
|
| Where you run the 'module:publish-migration' command, where do you publish the
| the migration files?
|
*/
'migration' => base_path('database/migrations'),
/*
|--------------------------------------------------------------------------
| The app path
|--------------------------------------------------------------------------
|
| app folder name
| for example can change it to 'src' or 'App'
*/
'app_folder' => '',
/*
|--------------------------------------------------------------------------
| Generator path
|--------------------------------------------------------------------------
| Customise the paths where the folders will be generated.
| Setting the generate key to false will not generate that folder
*/
'generator' => [
// app/
'actions' => ['path' => 'app/Actions', 'generate' => false],
'casts' => ['path' => 'app/Casts', 'generate' => false],
'channels' => ['path' => 'app/Broadcasting', 'generate' => false],
@ -137,107 +73,36 @@ return [
'services' => ['path' => 'app/Services', 'generate' => false],
'scopes' => ['path' => 'app/Models/Scopes', 'generate' => false],
'traits' => ['path' => 'app/Traits', 'generate' => false],
// app/Http/
'controller' => ['path' => 'app/Http/Controllers', 'generate' => true],
'filter' => ['path' => 'app/Http/Middleware', 'generate' => false],
'request' => ['path' => 'app/Http/Requests', 'generate' => false],
// config/
'config' => ['path' => 'config', 'generate' => true],
// Database/
'factory' => ['path' => 'Database/Factories', 'generate' => true],
'migration' => ['path' => 'Database/migrations', 'generate' => true],
'seeder' => ['path' => 'Database/Seeders', 'generate' => true],
// lang/
'lang' => ['path' => 'lang', 'generate' => false],
// resource/
'assets' => ['path' => 'resources/assets', 'generate' => true],
'component-view' => ['path' => 'resources/views/components', 'generate' => false],
'views' => ['path' => 'resources/views', 'generate' => true],
// routes/
'routes' => ['path' => 'routes', 'generate' => true],
// tests/
'test-feature' => ['path' => 'tests/Feature', 'generate' => true],
'test-unit' => ['path' => 'tests/Unit', 'generate' => true],
],
],
/*
|--------------------------------------------------------------------------
| Auto Discover of Modules
|--------------------------------------------------------------------------
|
| Here you configure auto discover of module
| This is useful for simplify module providers.
|
*/
'auto-discover' => [
/*
|--------------------------------------------------------------------------
| Migrations
|--------------------------------------------------------------------------
|
| This option for register migration automatically.
|
*/
'migrations' => true,
/*
|--------------------------------------------------------------------------
| Translations
|--------------------------------------------------------------------------
|
| This option for register lang file automatically.
|
*/
'translations' => false,
],
/*
|--------------------------------------------------------------------------
| Package commands
|--------------------------------------------------------------------------
|
| Here you can define which commands will be visible and used in your
| application. You can add your own commands to merge section.
|
*/
'commands' => ConsoleServiceProvider::defaultCommands()
->merge([
// New commands go here
])->toArray(),
/*
|--------------------------------------------------------------------------
| Scan Path
|--------------------------------------------------------------------------
|
| Here you define which folder will be scanned. By default will scan vendor
| directory. This is useful if you host the package in packagist website.
|
*/
'scan' => [
'enabled' => false,
'paths' => [
base_path('vendor/*/*'),
],
],
/*
|--------------------------------------------------------------------------
| Composer File Template
|--------------------------------------------------------------------------
|
| Here is the config for the composer.json file, generated by this package
|
*/
'composer' => [
'vendor' => env('MODULE_VENDOR', 'nwidart'),
'author' => [
@ -246,31 +111,10 @@ return [
],
'composer-output' => false,
],
/*
|--------------------------------------------------------------------------
| Choose what laravel-modules will register as custom namespaces.
| Setting one to false will require you to register that part
| in your own Service Provider class.
|--------------------------------------------------------------------------
*/
'register' => [
'translations' => true,
/**
* load files on boot or register method
*/
'files' => 'register',
],
/*
|--------------------------------------------------------------------------
| Activators
|--------------------------------------------------------------------------
|
| You can define new types of activators here, file, database, etc. The only
| required parameter is 'class'.
| The file activator will store the activation status in storage/installed_modules
*/
'activators' => [
'file' => [
'class' => FileActivator::class,

File diff suppressed because it is too large Load Diff

View File

@ -3,200 +3,37 @@
return [
'models' => [
/*
* When using the "HasPermissions" trait from this package, we need to know which
* Eloquent model should be used to retrieve your permissions. Of course, it
* is often just the "Permission" model but you may use whatever you like.
*
* The model you want to use as a Permission model needs to implement the
* `Spatie\Permission\Contracts\Permission` contract.
*/
'permission' => Spatie\Permission\Models\Permission::class,
/*
* When using the "HasRoles" trait from this package, we need to know which
* Eloquent model should be used to retrieve your roles. Of course, it
* is often just the "Role" model but you may use whatever you like.
*
* The model you want to use as a Role model needs to implement the
* `Spatie\Permission\Contracts\Role` contract.
*/
'role' => Spatie\Permission\Models\Role::class,
],
'table_names' => [
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your roles. We have chosen a basic
* default value but you may easily change it to any table you like.
*/
'roles' => 'roles',
/*
* When using the "HasPermissions" trait from this package, we need to know which
* table should be used to retrieve your permissions. We have chosen a basic
* default value but you may easily change it to any table you like.
*/
'permissions' => 'permissions',
/*
* When using the "HasPermissions" trait from this package, we need to know which
* table should be used to retrieve your models permissions. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'model_has_permissions' => 'model_has_permissions',
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your models roles. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'model_has_roles' => 'model_has_roles',
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your roles permissions. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'role_has_permissions' => 'role_has_permissions',
],
'column_names' => [
/*
* Change this if you want to name the related pivots other than defaults
*/
'role_pivot_key' => null, // default 'role_id',
'permission_pivot_key' => null, // default 'permission_id',
/*
* Change this if you want to name the related model primary key other than
* `model_id`.
*
* For example, this would be nice if your primary keys are all UUIDs. In
* that case, name this `model_uuid`.
*/
'model_morph_key' => 'model_id',
/*
* Change this if you want to use the teams feature and your related model's
* foreign key is other than `team_id`.
*/
'team_foreign_key' => 'team_id',
],
/*
* When set to true, the method for checking permissions will be registered on the gate.
* Set this to false if you want to implement custom logic for checking permissions.
*/
'register_permission_check_method' => true,
/*
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
* this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated
* NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it.
*/
'register_octane_reset_listener' => false,
/*
* Events will fire when a role or permission is assigned/unassigned:
* \Spatie\Permission\Events\RoleAttached
* \Spatie\Permission\Events\RoleDetached
* \Spatie\Permission\Events\PermissionAttached
* \Spatie\Permission\Events\PermissionDetached
*
* To enable, set to true, and then create listeners to watch these events.
*/
'events_enabled' => false,
/*
* Teams Feature.
* When set to true the package implements teams using the 'team_foreign_key'.
* If you want the migrations to register the 'team_foreign_key', you must
* set this to true before doing the migration.
* If you already did the migration then you must make a new migration to also
* add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions'
* (view the latest version of this package's migration file)
*/
'teams' => false,
/*
* The class to use to resolve the permissions team id
*/
'team_resolver' => \Spatie\Permission\DefaultTeamResolver::class,
/*
* Passport Client Credentials Grant
* When set to true the package will use Passports Client to check permissions
*/
'use_passport_client_credentials' => false,
/*
* When set to true, the required permission names are added to exception messages.
* This could be considered an information leak in some contexts, so the default
* setting is false here for optimum safety.
*/
'display_permission_in_exception' => false,
/*
* When set to true, the required role names are added to exception messages.
* This could be considered an information leak in some contexts, so the default
* setting is false here for optimum safety.
*/
'display_role_in_exception' => false,
/*
* By default wildcard permission lookups are disabled.
* See documentation to understand supported syntax.
*/
'enable_wildcard_permission' => false,
/*
* The class to use for interpreting wildcard permissions.
* If you need to modify delimiters, override the class and specify its name here.
*/
// 'wildcard_permission' => Spatie\Permission\WildcardPermission::class,
/* Cache-specific settings */
'cache' => [
/*
* By default all permissions are cached for 24 hours to speed up performance.
* When permissions or roles are updated the cache is flushed automatically.
*/
'expiration_time' => \DateInterval::createFromDateString('24 hours'),
/*
* The cache key used to store all permissions.
*/
'key' => 'spatie.permission.cache',
/*
* You may optionally indicate a specific cache driver to use for permission and
* role caching using any of the `store` drivers listed in the cache.php config
* file. Using 'default' here means to use the `default` set in cache.php.
*/
'store' => 'default',
],
];

View File

@ -1,34 +1,7 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
@ -90,36 +63,10 @@ return [
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_BATCHING_CONNECTION', env('DEMO', false) ? 'pgsql_public' : env('DB_CONNECTION', 'sqlite')),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_FAILED_CONNECTION', env('DEMO', false) ? 'pgsql_public' : env('DB_CONNECTION', 'sqlite')),

View File

@ -1,31 +1,7 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
|
| This option controls the default server used by Reverb to handle
| incoming messages as well as broadcasting message to all your
| connected clients. At this time only "reverb" is supported.
|
*/
'default' => env('REVERB_SERVER', 'reverb'),
/*
|--------------------------------------------------------------------------
| Reverb Servers
|--------------------------------------------------------------------------
|
| Here you may define details for each of the supported Reverb servers.
| Each server has its own configuration options that are defined in
| the array below. You should ensure all the options are present.
|
*/
'servers' => [
'reverb' => [
@ -55,18 +31,6 @@ return [
],
],
/*
|--------------------------------------------------------------------------
| Reverb Applications
|--------------------------------------------------------------------------
|
| Here you may define how Reverb applications are managed. If you choose
| to use the "config" provider, you may define an array of apps which
| your server will support, including their connection credentials.
|
*/
'apps' => [
'provider' => 'config',

View File

@ -1,19 +1,6 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
@ -57,4 +44,8 @@ return [
'enabled' => env('ENABLE_APPLE_LOGIN', false),
],
'google_maps' => [
'api_key' => env('GOOGLE_MAPS_API_KEY'),
],
];

View File

@ -3,215 +3,25 @@
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION', env('DEMO', false) ? 'pgsql_public' : null),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
];

View File

@ -6,9 +6,6 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
@ -24,9 +21,6 @@ return new class extends Migration
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');

View File

@ -6,9 +6,6 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
@ -45,9 +42,6 @@ return new class extends Migration
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');

View File

@ -6,9 +6,6 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$teams = config('permission.teams');
@ -19,10 +16,6 @@ return new class extends Migration
throw_if(empty($tableNames), 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
/**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
$table->id(); // permission id
$table->string('name');
@ -31,10 +24,6 @@ return new class extends Migration
$table->unique(['name', 'guard_name']);
});
/**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
$table->id(); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
@ -119,9 +108,6 @@ return new class extends Migration
->forget(config('permission.cache.key'));
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$tableNames = config('permission.table_names');

View File

@ -6,9 +6,6 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
@ -23,9 +20,6 @@ return new class extends Migration
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');

View File

@ -10,7 +10,6 @@ class DatabaseSeeder extends Seeder
{
$this->call([
\Modules\User\Database\Seeders\AuthUserSeeder::class,
HomeSliderSettingsSeeder::class,
\Modules\Location\Database\Seeders\LocationSeeder::class,
\Modules\Category\Database\Seeders\CategorySeeder::class,
\Modules\Listing\Database\Seeders\ListingCustomFieldSeeder::class,

View File

@ -1,18 +0,0 @@
<?php
namespace Database\Seeders;
use App\Support\HomeSlideDefaults;
use App\Settings\GeneralSettings;
use Illuminate\Database\Seeder;
class HomeSliderSettingsSeeder extends Seeder
{
public function run(): void
{
$settings = app(GeneralSettings::class);
$settings->home_slides = HomeSlideDefaults::defaults();
$settings->save();
}
}

View File

@ -8,13 +8,28 @@ return new class extends SettingsMigration
{
$this->migrator->add('general.site_name', 'OpenClassify');
$this->migrator->add('general.site_description', 'The marketplace for buying and selling everything.');
$this->migrator->add('general.media_disk', \Modules\S3\Support\MediaStorage::defaultDriver());
$this->migrator->add('general.site_logo', null);
$this->migrator->add('general.site_logo_disk', null);
$this->migrator->add('general.default_language', 'en');
$this->migrator->add('general.default_country_code', '+90');
$this->migrator->add('general.currencies', ['USD']);
$this->migrator->add('general.sender_email', 'hello@example.com');
$this->migrator->add('general.sender_name', 'OpenClassify');
$this->migrator->add('general.linkedin_url', null);
$this->migrator->add('general.instagram_url', null);
$this->migrator->add('general.whatsapp', null);
$this->migrator->add('general.enable_google_maps', false);
$this->migrator->add('general.google_maps_api_key', null);
$this->migrator->add('general.enable_google_login', false);
$this->migrator->add('general.google_client_id', null);
$this->migrator->add('general.google_client_secret', null);
$this->migrator->add('general.enable_facebook_login', false);
$this->migrator->add('general.facebook_client_id', null);
$this->migrator->add('general.facebook_client_secret', null);
$this->migrator->add('general.enable_apple_login', false);
$this->migrator->add('general.apple_client_id', null);
$this->migrator->add('general.apple_client_secret', null);
$this->migrator->add('general.home_slides', \Modules\Site\App\Support\HomeSlideDefaults::defaults());
}
};

Some files were not shown because too many files have changed in this diff Show More