diff --git a/app/Filament/Resources/ShowcaseResource.php b/app/Filament/Resources/ShowcaseResource.php index f8416a1d..1c530cc9 100644 --- a/app/Filament/Resources/ShowcaseResource.php +++ b/app/Filament/Resources/ShowcaseResource.php @@ -109,6 +109,28 @@ public static function form(Schema $schema): Schema ->url() ->maxLength(255), ]), + + Schemas\Components\Fieldset::make('Platform-specific Screenshots') + ->visible(fn (Schemas\Components\Utilities\Get $get) => $get('has_mobile') && $get('has_desktop')) + ->schema([ + Forms\Components\FileUpload::make('mobile_screenshots') + ->label('Mobile Screenshots (optional, up to 5)') + ->image() + ->multiple() + ->maxFiles(5) + ->disk('public') + ->directory('showcase-screenshots') + ->reorderable(), + + Forms\Components\FileUpload::make('desktop_screenshots') + ->label('Desktop Screenshots (optional, up to 5)') + ->image() + ->multiple() + ->maxFiles(5) + ->disk('public') + ->directory('showcase-screenshots') + ->reorderable(), + ]), ]), Schemas\Components\Section::make('Certification') diff --git a/app/Http/Controllers/ShowcaseController.php b/app/Http/Controllers/ShowcaseController.php index 3bc767b4..0d0070b6 100644 --- a/app/Http/Controllers/ShowcaseController.php +++ b/app/Http/Controllers/ShowcaseController.php @@ -16,6 +16,8 @@ public function index(?string $platform = null): View $query->withMobile(); } elseif ($platform === 'desktop') { $query->withDesktop(); + } elseif ($platform === 'both') { + $query->withMobile()->withDesktop(); } $showcases = $query->paginate(10); diff --git a/app/Livewire/ShowcaseSubmissionForm.php b/app/Livewire/ShowcaseSubmissionForm.php index 454da2c7..5e2ef6c5 100644 --- a/app/Livewire/ShowcaseSubmissionForm.php +++ b/app/Livewire/ShowcaseSubmissionForm.php @@ -34,6 +34,16 @@ class ShowcaseSubmissionForm extends Component public array $existingScreenshots = []; + #[Validate('nullable|array|max:5')] + public array $mobileScreenshots = []; + + public array $existingMobileScreenshots = []; + + #[Validate('nullable|array|max:5')] + public array $desktopScreenshots = []; + + public array $existingDesktopScreenshots = []; + #[Validate('boolean')] public bool $hasMobile = false; @@ -67,6 +77,8 @@ public function mount(?Showcase $showcase = null): void $this->description = $showcase->description; $this->existingImage = $showcase->image; $this->existingScreenshots = $showcase->screenshots ?? []; + $this->existingMobileScreenshots = $showcase->mobile_screenshots ?? []; + $this->existingDesktopScreenshots = $showcase->desktop_screenshots ?? []; $this->hasMobile = $showcase->has_mobile; $this->hasDesktop = $showcase->has_desktop; $this->playStoreUrl = $showcase->play_store_url ?? ''; @@ -85,6 +97,8 @@ public function rules(): array 'description' => 'required|string|max:2000', 'image' => 'nullable|image|max:2048', 'screenshots.*' => 'nullable|image|max:2048', + 'mobileScreenshots.*' => 'nullable|image|max:2048', + 'desktopScreenshots.*' => 'nullable|image|max:2048', 'hasMobile' => 'boolean', 'hasDesktop' => 'boolean', 'playStoreUrl' => 'nullable|url|max:255', @@ -115,11 +129,46 @@ public function removeExistingScreenshot(int $index): void } } + public function removeExistingMobileScreenshot(int $index): void + { + if (isset($this->existingMobileScreenshots[$index])) { + unset($this->existingMobileScreenshots[$index]); + $this->existingMobileScreenshots = array_values($this->existingMobileScreenshots); + } + } + + public function removeExistingDesktopScreenshot(int $index): void + { + if (isset($this->existingDesktopScreenshots[$index])) { + unset($this->existingDesktopScreenshots[$index]); + $this->existingDesktopScreenshots = array_values($this->existingDesktopScreenshots); + } + } + public function removeExistingImage(): void { $this->existingImage = null; } + /** + * @param array $existing + * @param array $uploads + * @return array + */ + protected function storeScreenshots(array $existing, array $uploads): array + { + $paths = $existing; + + foreach ($uploads as $upload) { + if (count($paths) >= 5) { + break; + } + $paths[] = $upload->store('showcase-screenshots', 'public'); + } + + return $paths; + } + public function submit(): mixed { $this->validate(); @@ -135,19 +184,19 @@ public function submit(): mixed $imagePath = $this->image->store('showcase-images', 'public'); } - $screenshotPaths = $this->existingScreenshots; - foreach ($this->screenshots as $screenshot) { - if (count($screenshotPaths) >= 5) { - break; - } - $screenshotPaths[] = $screenshot->store('showcase-screenshots', 'public'); - } + $bothPlatforms = $this->hasMobile && $this->hasDesktop; + + $screenshotPaths = $this->storeScreenshots($this->existingScreenshots, $this->screenshots); + $mobileScreenshotPaths = $bothPlatforms ? $this->storeScreenshots($this->existingMobileScreenshots, $this->mobileScreenshots) : []; + $desktopScreenshotPaths = $bothPlatforms ? $this->storeScreenshots($this->existingDesktopScreenshots, $this->desktopScreenshots) : []; $data = [ 'title' => $this->title, 'description' => $this->description, 'image' => $imagePath, 'screenshots' => $screenshotPaths ?: null, + 'mobile_screenshots' => $mobileScreenshotPaths ?: null, + 'desktop_screenshots' => $desktopScreenshotPaths ?: null, 'has_mobile' => $this->hasMobile, 'has_desktop' => $this->hasDesktop, 'play_store_url' => $this->hasMobile ? ($this->playStoreUrl ?: null) : null, @@ -199,8 +248,8 @@ public function delete(): mixed Storage::disk('public')->delete($this->showcase->image); } - if ($this->showcase->screenshots) { - foreach ($this->showcase->screenshots as $screenshot) { + foreach ([$this->showcase->screenshots, $this->showcase->mobile_screenshots, $this->showcase->desktop_screenshots] as $screenshots) { + foreach ($screenshots ?? [] as $screenshot) { Storage::disk('public')->delete($screenshot); } } diff --git a/app/Models/Showcase.php b/app/Models/Showcase.php index bc6c4e4d..cd8400b1 100644 --- a/app/Models/Showcase.php +++ b/app/Models/Showcase.php @@ -18,6 +18,8 @@ class Showcase extends Model 'description', 'image', 'screenshots', + 'mobile_screenshots', + 'desktop_screenshots', 'has_mobile', 'has_desktop', 'play_store_url', @@ -60,6 +62,20 @@ public function needsReReview(): bool return $this->approved_at !== null && $this->updated_at->isAfter($this->approved_at); } + /** + * @return array + */ + public function screenshotsFor(?string $platform): array + { + $override = match ($platform) { + 'mobile' => $this->mobile_screenshots, + 'desktop' => $this->desktop_screenshots, + default => null, + }; + + return ! empty($override) ? $override : ($this->screenshots ?? []); + } + #[Scope] protected function approved(Builder $query): Builder { @@ -88,6 +104,8 @@ protected function casts(): array { return [ 'screenshots' => 'array', + 'mobile_screenshots' => 'array', + 'desktop_screenshots' => 'array', 'has_mobile' => 'boolean', 'has_desktop' => 'boolean', 'certified_nativephp' => 'boolean', diff --git a/database/factories/ShowcaseFactory.php b/database/factories/ShowcaseFactory.php index e21a6254..60cbfd19 100644 --- a/database/factories/ShowcaseFactory.php +++ b/database/factories/ShowcaseFactory.php @@ -33,6 +33,8 @@ public function definition(): array 'description' => fake()->paragraph(3), 'image' => null, 'screenshots' => null, + 'mobile_screenshots' => null, + 'desktop_screenshots' => null, 'has_mobile' => $hasMobile, 'has_desktop' => $hasDesktop, 'play_store_url' => $hasMobile ? fake()->optional(0.7)->url() : null, @@ -132,6 +134,20 @@ public function withWideScreenshots(int $count = 3): static return $this->withScreenshots($count, tall: false); } + public function withMobileScreenshots(int $count = 3): static + { + return $this->state(fn (array $attributes) => [ + 'mobile_screenshots' => array_map(fn () => $this->generatePlaceholderScreenshot(tall: true), range(1, $count)), + ]); + } + + public function withDesktopScreenshots(int $count = 3): static + { + return $this->state(fn (array $attributes) => [ + 'desktop_screenshots' => array_map(fn () => $this->generatePlaceholderScreenshot(tall: false), range(1, $count)), + ]); + } + protected function generatePlaceholderScreenshot(bool $tall = false): string { $width = $tall ? 390 : 1280; diff --git a/database/migrations/2026_08_18_120000_add_platform_screenshots_to_showcases_table.php b/database/migrations/2026_08_18_120000_add_platform_screenshots_to_showcases_table.php new file mode 100644 index 00000000..cce33b51 --- /dev/null +++ b/database/migrations/2026_08_18_120000_add_platform_screenshots_to_showcases_table.php @@ -0,0 +1,29 @@ +json('mobile_screenshots')->nullable()->after('screenshots'); + $table->json('desktop_screenshots')->nullable()->after('mobile_screenshots'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('showcases', function (Blueprint $table) { + $table->dropColumn(['mobile_screenshots', 'desktop_screenshots']); + }); + } +}; diff --git a/database/seeders/ShowcaseSeeder.php b/database/seeders/ShowcaseSeeder.php index 50d187d6..f9ddb589 100644 --- a/database/seeders/ShowcaseSeeder.php +++ b/database/seeders/ShowcaseSeeder.php @@ -18,6 +18,7 @@ public function run(): void Showcase::factory(2)->approved()->desktop()->withWideScreenshots(3)->create(); Showcase::factory(2)->approved()->desktop()->create(); Showcase::factory(2)->approved()->both()->withTallScreenshots(2)->create(); + Showcase::factory(1)->approved()->both()->withMobileScreenshots(2)->withDesktopScreenshots(2)->create(); // Recently approved (will show as "new") with screenshots Showcase::factory(2)->recentlyApproved()->mobile()->withTallScreenshots(4)->create(); diff --git a/resources/views/components/showcase-card.blade.php b/resources/views/components/showcase-card.blade.php index 27490e9d..315fe52c 100644 --- a/resources/views/components/showcase-card.blade.php +++ b/resources/views/components/showcase-card.blade.php @@ -1,12 +1,16 @@ -@props(['showcase']) +@props(['showcase', 'platform' => null]) + +@php + $displayScreenshots = $showcase->screenshotsFor($platform); +@endphp
- @if($showcase->screenshots && count($showcase->screenshots) > 0) + @if(count($displayScreenshots) > 0)
- @foreach($showcase->screenshots as $index => $screenshot) + @foreach($displayScreenshots as $index => $screenshot) +
+ @endforeach + + @endif + @if (count($existingMobileScreenshots) < 5) + + @endif + @error('mobileScreenshots.*') {{ $message }} @enderror + + + + Desktop Screenshots (optional, up to 5) + @if (count($existingDesktopScreenshots) > 0) +
+ @foreach ($existingDesktopScreenshots as $index => $screenshot) +
+ Desktop screenshot {{ $index + 1 }} + +
+ @endforeach +
+ @endif + @if (count($existingDesktopScreenshots) < 5) + + @endif + @error('desktopScreenshots.*') {{ $message }} @enderror +
+ @endif diff --git a/resources/views/showcase.blade.php b/resources/views/showcase.blade.php index 19a59e58..25f6099c 100644 --- a/resources/views/showcase.blade.php +++ b/resources/views/showcase.blade.php @@ -1,4 +1,4 @@ - + {{-- Hero Section --}}
- Discover amazing {{ $platform ?? '' }} apps built by the NativePHP community. From productivity tools to creative applications, see what's possible with NativePHP. + Discover amazing {{ $platform === 'both' ? 'cross-platform' : ($platform ?? '') }} apps built by the NativePHP community. From productivity tools to creative applications, see what's possible with NativePHP.

{{-- Platform Filter --}} @@ -106,6 +108,16 @@ class="mx-auto mt-5 max-w-2xl text-center text-base/relaxed text-gray-600 sm:tex > Desktop + $platform === 'both', + 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700' => $platform !== 'both', + ]) + > + Cross-Platform +
@@ -115,7 +127,7 @@ class="mx-auto mt-5 max-w-2xl text-center text-base/relaxed text-gray-600 sm:tex @if ($showcases->count() > 0)
@foreach ($showcases as $showcase) - + @endforeach
@@ -138,7 +150,7 @@ class="mb-2 text-xl font-semibold text-gray-900 dark:text-white"

@if($platform) - No {{ $platform }} apps have been showcased yet. Be the first to submit yours! + No {{ $platform === 'both' ? 'cross-platform' : $platform }} apps have been showcased yet. Be the first to submit yours! @else The showcase is empty. Be the first to submit your NativePHP app! @endif diff --git a/routes/web.php b/routes/web.php index 390f62cd..be345430 100644 --- a/routes/web.php +++ b/routes/web.php @@ -216,7 +216,7 @@ Route::view('wall-of-love', 'wall-of-love')->name('wall-of-love'); Route::view('brand', 'brand')->name('brand'); Route::get('showcase/{platform?}', [ShowcaseController::class, 'index']) - ->where('platform', 'mobile|desktop') + ->where('platform', 'mobile|desktop|both') ->name('showcase'); Route::view('laracon-us-2025-giveaway', 'laracon-us-2025-giveaway')->name('laracon-us-2025-giveaway'); Route::view('privacy-policy', 'privacy-policy')->name('privacy-policy'); diff --git a/tests/Feature/ShowcasePageTest.php b/tests/Feature/ShowcasePageTest.php new file mode 100644 index 00000000..a06cda5c --- /dev/null +++ b/tests/Feature/ShowcasePageTest.php @@ -0,0 +1,111 @@ +approved()->mobile()->create(['title' => 'Mobile App']); + Showcase::factory()->approved()->desktop()->create(['title' => 'Desktop App']); + Showcase::factory()->pending()->create(['title' => 'Pending App']); + + $response = $this->get('/showcase'); + + $response->assertStatus(200); + $response->assertSee('Mobile App'); + $response->assertSee('Desktop App'); + $response->assertDontSee('Pending App'); + } + + public function test_mobile_filter_only_shows_apps_with_mobile_support(): void + { + Showcase::factory()->approved()->mobile()->create(['title' => 'Mobile Only App']); + Showcase::factory()->approved()->desktop()->create(['title' => 'Desktop Only App']); + Showcase::factory()->approved()->both()->create(['title' => 'Cross-Platform App']); + + $response = $this->get('/showcase/mobile'); + + $response->assertStatus(200); + $response->assertSee('Mobile Only App'); + $response->assertSee('Cross-Platform App'); + $response->assertDontSee('Desktop Only App'); + } + + public function test_desktop_filter_only_shows_apps_with_desktop_support(): void + { + Showcase::factory()->approved()->mobile()->create(['title' => 'Mobile Only App']); + Showcase::factory()->approved()->desktop()->create(['title' => 'Desktop Only App']); + Showcase::factory()->approved()->both()->create(['title' => 'Cross-Platform App']); + + $response = $this->get('/showcase/desktop'); + + $response->assertStatus(200); + $response->assertSee('Desktop Only App'); + $response->assertSee('Cross-Platform App'); + $response->assertDontSee('Mobile Only App'); + } + + public function test_both_filter_only_shows_apps_supporting_both_platforms(): void + { + Showcase::factory()->approved()->mobile()->create(['title' => 'Mobile Only App']); + Showcase::factory()->approved()->desktop()->create(['title' => 'Desktop Only App']); + Showcase::factory()->approved()->both()->create(['title' => 'Cross-Platform App']); + + $response = $this->get('/showcase/both'); + + $response->assertStatus(200); + $response->assertSee('Cross-Platform App'); + $response->assertDontSee('Mobile Only App'); + $response->assertDontSee('Desktop Only App'); + } + + public function test_invalid_platform_returns_404(): void + { + $response = $this->get('/showcase/tablet'); + + $response->assertStatus(404); + } + + public function test_mobile_and_desktop_filters_show_platform_specific_screenshots(): void + { + Storage::fake('public'); + + $showcase = Showcase::factory()->approved()->both() + ->withMobileScreenshots(1) + ->withDesktopScreenshots(1) + ->create(['title' => 'Split Screenshots App']); + + $mobileScreenshot = $showcase->mobile_screenshots[0]; + $desktopScreenshot = $showcase->desktop_screenshots[0]; + + $mobileResponse = $this->get('/showcase/mobile'); + $mobileResponse->assertSee($mobileScreenshot, false); + $mobileResponse->assertDontSee($desktopScreenshot, false); + + $desktopResponse = $this->get('/showcase/desktop'); + $desktopResponse->assertSee($desktopScreenshot, false); + $desktopResponse->assertDontSee($mobileScreenshot, false); + } + + public function test_falls_back_to_shared_screenshots_without_platform_override(): void + { + Storage::fake('public'); + + $showcase = Showcase::factory()->approved()->both()->withScreenshots(1)->create([ + 'title' => 'Shared Screenshots App', + ]); + + $sharedScreenshot = $showcase->screenshots[0]; + + $this->get('/showcase/mobile')->assertSee($sharedScreenshot, false); + $this->get('/showcase/desktop')->assertSee($sharedScreenshot, false); + } +} diff --git a/tests/Feature/ShowcaseSubmissionTest.php b/tests/Feature/ShowcaseSubmissionTest.php index 45ddc208..752d7093 100644 --- a/tests/Feature/ShowcaseSubmissionTest.php +++ b/tests/Feature/ShowcaseSubmissionTest.php @@ -7,8 +7,10 @@ use App\Models\User; use App\Notifications\ShowcaseSubmitted; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; use Illuminate\Notifications\AnonymousNotifiable; use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; use Tests\TestCase; @@ -123,4 +125,69 @@ public function test_showcase_notification_email_includes_submission_details(): $this->assertStringContainsString('sent back for review', $resubmitted); } + + public function test_submitting_both_platforms_stores_platform_specific_screenshots(): void + { + Storage::fake('public'); + + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(ShowcaseSubmissionForm::class) + ->set('title', 'Cross-Platform App') + ->set('description', 'Works everywhere.') + ->set('hasMobile', true) + ->set('hasDesktop', true) + ->set('mobileScreenshots', [UploadedFile::fake()->image('mobile.png')]) + ->set('desktopScreenshots', [UploadedFile::fake()->image('desktop.png')]) + ->set('certifiedNativephp', true) + ->call('submit') + ->assertHasNoErrors(); + + $showcase = Showcase::where('title', 'Cross-Platform App')->firstOrFail(); + $this->assertCount(1, $showcase->mobile_screenshots); + $this->assertCount(1, $showcase->desktop_screenshots); + Storage::disk('public')->assertExists($showcase->mobile_screenshots[0]); + Storage::disk('public')->assertExists($showcase->desktop_screenshots[0]); + } + + public function test_submitting_a_single_platform_never_stores_platform_specific_screenshots(): void + { + Storage::fake('public'); + + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(ShowcaseSubmissionForm::class) + ->set('title', 'Mobile Only App') + ->set('description', 'Just mobile.') + ->set('hasMobile', true) + ->set('certifiedNativephp', true) + ->call('submit') + ->assertHasNoErrors(); + + $showcase = Showcase::where('title', 'Mobile Only App')->firstOrFail(); + $this->assertNull($showcase->mobile_screenshots); + $this->assertNull($showcase->desktop_screenshots); + } + + public function test_unchecking_a_platform_clears_its_platform_specific_screenshots(): void + { + Storage::fake('public'); + + $user = User::factory()->create(); + $showcase = Showcase::factory()->both()->withMobileScreenshots(2)->withDesktopScreenshots(2)->create([ + 'user_id' => $user->id, + ]); + + Livewire::actingAs($user) + ->test(ShowcaseSubmissionForm::class, ['showcase' => $showcase]) + ->set('hasDesktop', false) + ->call('submit') + ->assertHasNoErrors(); + + $showcase->refresh(); + $this->assertNull($showcase->mobile_screenshots); + $this->assertNull($showcase->desktop_screenshots); + } } diff --git a/tests/Unit/ShowcaseTest.php b/tests/Unit/ShowcaseTest.php new file mode 100644 index 00000000..8dc8776e --- /dev/null +++ b/tests/Unit/ShowcaseTest.php @@ -0,0 +1,68 @@ + ['shared-1.png', 'shared-2.png']]); + + $this->assertSame(['shared-1.png', 'shared-2.png'], $showcase->screenshotsFor(null)); + } + + #[Test] + public function screenshots_for_falls_back_to_shared_screenshots_when_no_override_exists(): void + { + $showcase = new Showcase(['screenshots' => ['shared-1.png']]); + + $this->assertSame(['shared-1.png'], $showcase->screenshotsFor('mobile')); + $this->assertSame(['shared-1.png'], $showcase->screenshotsFor('desktop')); + } + + #[Test] + public function screenshots_for_falls_back_to_shared_screenshots_when_override_is_empty(): void + { + $showcase = new Showcase(['screenshots' => ['shared-1.png'], 'mobile_screenshots' => []]); + + $this->assertSame(['shared-1.png'], $showcase->screenshotsFor('mobile')); + } + + #[Test] + public function screenshots_for_returns_the_mobile_override_when_present(): void + { + $showcase = new Showcase([ + 'screenshots' => ['shared-1.png'], + 'mobile_screenshots' => ['mobile-1.png'], + 'desktop_screenshots' => ['desktop-1.png'], + ]); + + $this->assertSame(['mobile-1.png'], $showcase->screenshotsFor('mobile')); + } + + #[Test] + public function screenshots_for_returns_the_desktop_override_when_present(): void + { + $showcase = new Showcase([ + 'screenshots' => ['shared-1.png'], + 'mobile_screenshots' => ['mobile-1.png'], + 'desktop_screenshots' => ['desktop-1.png'], + ]); + + $this->assertSame(['desktop-1.png'], $showcase->screenshotsFor('desktop')); + } + + #[Test] + public function screenshots_for_returns_an_empty_array_when_nothing_is_set(): void + { + $showcase = new Showcase; + + $this->assertSame([], $showcase->screenshotsFor(null)); + $this->assertSame([], $showcase->screenshotsFor('mobile')); + } +}