diff --git a/app/Concerns/HasSlug.php b/app/Concerns/HasSlug.php new file mode 100644 index 00000000..fbe4a862 --- /dev/null +++ b/app/Concerns/HasSlug.php @@ -0,0 +1,67 @@ +fillable[] = $this->getSlugColumnName(); + } + + public static function bootHasSlug(): void + { + static::saving(function (Model $model) { + $model->slug = Str::slug($model->slug); + + if (! $model->slug || ! $model->slugAlreadyUsed($model->slug)) { + $model->slug = $model->generateSlug(); + } + }); + } + + protected function getSlugColumnName(): string + { + return 'slug'; + } + + protected function getSlugSource(): string + { + return $this->title; + } + + public function scopeWhereSlug(Builder $query, string $slug): Builder + { + return $query->where($this->getSlugColumnName(), $slug); + } + + public function generateSlug(): string + { + $base = $slug = Str::slug($this->getSlugSource()); + $suffix = 1; + + while ($this->slugAlreadyUsed($slug)) { + $slug = Str::slug($base . '_' . $suffix++); + } + + return $slug; + } + + protected function slugAlreadyUsed(string $slug): bool + { + $query = static::whereSlug($slug) + ->withoutGlobalScopes(); + + if ($this->exists) { + $query->where($this->getKeyName(), '!=', $this->getKey()); + } + + return $query->exists(); + } +} diff --git a/app/Filament/MediaLibrary/TenantPathGenerator.php b/app/Filament/MediaLibrary/TenantPathGenerator.php index b603d615..bad8a1a3 100644 --- a/app/Filament/MediaLibrary/TenantPathGenerator.php +++ b/app/Filament/MediaLibrary/TenantPathGenerator.php @@ -12,7 +12,8 @@ class TenantPathGenerator extends DefaultPathGenerator protected function getBasePath(Media $media): string { return collect([ - filament()->getTenant()?->ulid, + 'org', + filament()->getTenant()->ulid, $media->getKey(), ]) ->filter() diff --git a/app/Filament/MediaLibrary/UserPathGenerator.php b/app/Filament/MediaLibrary/UserPathGenerator.php new file mode 100644 index 00000000..3a9ec369 --- /dev/null +++ b/app/Filament/MediaLibrary/UserPathGenerator.php @@ -0,0 +1,22 @@ +model->ulid, + $media->getKey(), + ]) + ->filter() + ->join(\DIRECTORY_SEPARATOR); + } +} diff --git a/app/Filament/Pages/Profile/UserPersonalInfo.php b/app/Filament/Pages/Profile/UserPersonalInfo.php index 707459ba..a864f9ee 100644 --- a/app/Filament/Pages/Profile/UserPersonalInfo.php +++ b/app/Filament/Pages/Profile/UserPersonalInfo.php @@ -4,9 +4,11 @@ namespace App\Filament\Pages\Profile; +use Filament\Facades\Filament; use Filament\Forms\Components\Group; use Filament\Forms\Components\SpatieMediaLibraryFileUpload; use Filament\Forms\Components\TextInput; +use Filament\Forms\Form; use Jeffgreco13\FilamentBreezy\Livewire\PersonalInfo as BasePersonalInfo; class UserPersonalInfo extends BasePersonalInfo @@ -17,29 +19,43 @@ class UserPersonalInfo extends BasePersonalInfo 'email', ]; - protected function getProfileFormSchema(): array + public function mount(): void { - $groupFields = []; - - if ($this->hasAvatars) { - $groupFields[] = SpatieMediaLibraryFileUpload::make('avatar') - ->label(__('filament-breezy::default.fields.avatar')) - ->avatar() - ->collection('avatars') - ->conversion('large'); - } - - $groupFields[] = Group::make() - ->columnSpan(2) - ->columns(2) - ->schema([ - TextInput::make('first_name'), - TextInput::make('last_name'), + $this->user = Filament::getCurrentPanel()->auth()->user(); - $this->getEmailComponent() - ->columnSpanFull(), - ]); + $this->form->fill($this->user->only($this->only)); + } - return $groupFields; + public function form(Form $form): Form + { + return $form + ->columns(3) + ->statePath('data') + ->model($this->user) + ->schema([ + SpatieMediaLibraryFileUpload::make('avatar') + ->label(__('filament-breezy::default.fields.avatar')) + ->avatar() + ->collection('avatar') + ->conversion('large'), + + Group::make() + ->columnSpan(2) + ->columns(2) + ->schema([ + TextInput::make('first_name') + ->label(__('field.first_name')), + + TextInput::make('last_name') + ->label(__('field.last_name')), + + TextInput::make('email') + ->label(__('field.last_name')) + ->unique(ignoreRecord:true) + ->required() + ->email() + ->columnSpanFull(), + ]), + ]); } } diff --git a/app/Models/Organization.php b/app/Models/Organization.php index a7267584..98cca2d3 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Concerns\HasLocation; +use App\Concerns\HasSlug; use App\Concerns\HasUlid; use Filament\Models\Contracts\HasAvatar; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -19,6 +20,7 @@ class Organization extends Model implements HasAvatar, HasMedia use HasFactory; use HasLocation; use HasUlid; + use HasSlug; use InteractsWithMedia; protected $fillable = [ @@ -59,4 +61,9 @@ public function getFilamentAvatarUrl(): ?string { return $this->getFirstMediaUrl('logo', 'thumb'); } + + protected function getSlugSource(): string + { + return $this->name; + } } diff --git a/app/Models/User.php b/app/Models/User.php index 5d7e8f2a..5774eb11 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,6 +6,7 @@ // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Concerns\HasUlid; use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\HasAvatar; use Filament\Models\Contracts\HasName; @@ -17,14 +18,18 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Storage; use Jeffgreco13\FilamentBreezy\Traits\TwoFactorAuthenticatable; use Laravel\Sanctum\HasApiTokens; +use Spatie\Image\Manipulations; +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; -class User extends Authenticatable implements FilamentUser, HasAvatar, HasName, HasTenants +class User extends Authenticatable implements FilamentUser, HasAvatar, HasName, HasMedia, HasTenants { use HasApiTokens; use HasFactory; + use HasUlid; + use InteractsWithMedia; use Notifiable; use TwoFactorAuthenticatable; @@ -38,7 +43,6 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasName, 'last_name', 'email', 'password', - 'avatar_url', ]; /** @@ -66,6 +70,21 @@ public function organizations(): MorphToMany return $this->morphToMany(Organization::class, 'model', 'model_has_organizations', 'model_id'); } + public function registerMediaCollections(): void + { + $this->addMediaCollection('avatar') + ->singleFile() + ->registerMediaConversions(function () { + $this->addMediaConversion('thumb') + ->fit(Manipulations::FIT_CONTAIN, 64, 64) + ->optimize(); + + $this->addMediaConversion('large') + ->fit(Manipulations::FIT_CONTAIN, 256, 256) + ->optimize(); + }); + } + public function canAccessPanel(Panel $panel): bool { return $this->hasVerifiedEmail(); @@ -73,7 +92,7 @@ public function canAccessPanel(Panel $panel): bool public function getFilamentAvatarUrl(): ?string { - return $this->avatar_url ? Storage::url($this->avatar_url) : null; + return $this->getFirstMediaUrl('avatar', 'thumb'); } public function getFilamentName(): string diff --git a/app/Providers/Filament/OrganizationPanelProvider.php b/app/Providers/Filament/OrganizationPanelProvider.php index 8958f665..3de51c66 100644 --- a/app/Providers/Filament/OrganizationPanelProvider.php +++ b/app/Providers/Filament/OrganizationPanelProvider.php @@ -13,9 +13,11 @@ use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DispatchServingFilamentEvent; +use Filament\Pages\Page; use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; +use Filament\Support\Enums\Alignment; use Filament\Widgets; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\EncryptCookies; @@ -60,6 +62,9 @@ public function panel(Panel $panel): Panel ->widgets([ // Widgets\AccountWidget::class, ]) + ->bootUsing(function () { + Page::formActionsAlignment(Alignment::End); + }) ->databaseNotifications() ->plugins([ BreezyCore::make() @@ -86,7 +91,7 @@ public function panel(Panel $panel): Panel ->authMiddleware([ Authenticate::class, ]) - ->tenant(Organization::class) + ->tenant(Organization::class, 'slug') ->tenantProfile(EditOrganizationProfile::class) ->tenantMiddleware([ // ApplyTenantScopes::class, diff --git a/config/media-library.php b/config/media-library.php index 6577438d..131839dd 100644 --- a/config/media-library.php +++ b/config/media-library.php @@ -87,6 +87,7 @@ // Model::class => PathGenerator::class // or // 'model_morph_alias' => PathGenerator::class + App\Models\User::class => App\Filament\MediaLibrary\UserPathGenerator::class, ], /* diff --git a/database/migrations/2014_10_12_000000_create_users_table.php b/database/migrations/2014_10_12_000000_create_users_table.php index f91ba9c5..95c6bcfb 100644 --- a/database/migrations/2014_10_12_000000_create_users_table.php +++ b/database/migrations/2014_10_12_000000_create_users_table.php @@ -12,6 +12,7 @@ public function up(): void { Schema::create('users', function (Blueprint $table) { $table->id(); + $table->ulid()->unique(); $table->string('first_name'); $table->string('last_name'); $table->string('email')->unique(); diff --git a/tests/Feature/LoginTest.php b/tests/Feature/LoginTest.php index c0c8f1c8..275f5fd2 100644 --- a/tests/Feature/LoginTest.php +++ b/tests/Feature/LoginTest.php @@ -47,7 +47,7 @@ }); it('can redirect unauthenticated app requests', function () { - $this->get(route('filament.admin.pages.dashboard')) + $this->get(route('filament.organization.pages.dashboard', ['tenant' => 'test'])) ->assertRedirect(Filament::getLoginUrl()); });