diff --git a/resources/views/components/partials/card-styles.blade.php b/resources/views/components/partials/card-styles.blade.php index 86f1167..b7143f1 100644 --- a/resources/views/components/partials/card-styles.blade.php +++ b/resources/views/components/partials/card-styles.blade.php @@ -446,305 +446,5 @@ height: 24px; } -/* Cute Edit Modal Styles */ -.cute-edit-modal { - max-width: 380px; - margin: auto; -} - -.cute-edit-content { - background: linear-gradient(145deg, #1f1f1f 0%, #2a2a2a 100%); - border: 1px solid #3a3a3a; - border-radius: 20px; - overflow: hidden; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); -} - -.cute-edit-header { - background: linear-gradient(135deg, #ff6b8a 0%, #ff8fa3 100%); - padding: 16px 20px; - display: flex; - align-items: center; - gap: 10px; - position: relative; -} - -.cute-edit-icon { - font-size: 20px; -} - -.cute-edit-header h5 { - margin: 0; - color: white; - font-size: 16px; - font-weight: 600; -} - -.btn-close-cute { - position: absolute; - right: 12px; - background: rgba(255,255,255,0.2); - border: none; - color: white; - width: 26px; - height: 26px; - border-radius: 50%; - font-size: 18px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: background 0.2s; -} - -.btn-close-cute:hover { - background: rgba(255,255,255,0.3); -} - -.cute-edit-body { - padding: 20px; -} - -.cute-form-group { - margin-bottom: 16px; -} - -.cute-form-group label { - display: flex; - align-items: center; - gap: 6px; - color: #ccc; - font-size: 13px; - margin-bottom: 8px; - font-weight: 500; -} - -.cute-form-group label i { - color: #ff6b8a; - font-size: 14px; -} - -.cute-input, .cute-textarea { - width: 100%; - background: #151515; - border: 1px solid #333; - border-radius: 10px; - padding: 10px 12px; - color: #fff; - font-size: 13px; - transition: all 0.2s; -} - -.cute-input:focus, .cute-textarea:focus { - outline: none; - border-color: #ff6b8a; - box-shadow: 0 0 0 3px rgba(255, 107, 138, 0.15); -} - -.cute-input::placeholder, .cute-textarea::placeholder { - color: #555; -} - -/* Type Options */ -.cute-type-options, .cute-privacy-options { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.cute-type-option, .cute-privacy-option { - flex: 1; - min-width: 80px; - cursor: pointer; -} - -.cute-type-option input, .cute-privacy-option input { - display: none; -} - -.cute-type-option span, .cute-privacy-option span { - display: block; - padding: 8px 10px; - background: #151515; - border: 1px solid #333; - border-radius: 8px; - font-size: 12px; - color: #aaa; - text-align: center; - transition: all 0.2s; -} - -.cute-type-option:hover span, .cute-privacy-option:hover span { - border-color: #555; - background: #1a1a1a; -} - -.cute-type-option.active span, .cute-privacy-option.active span { - background: rgba(255, 107, 138, 0.15); - border-color: #ff6b8a; - color: #ff6b8a; -} - -/* Shorts Toggle in Edit Modal */ -.cute-shorts-toggle { - position: relative; - display: inline-flex; - align-items: center; - cursor: pointer; - margin-top: 8px; -} - -.cute-shorts-toggle input { - opacity: 0; - width: 0; - height: 0; -} - -.cute-shorts-slider { - position: relative; - width: 44px; - height: 24px; - background-color: #333; - border-radius: 12px; - transition: 0.3s; - margin-right: 10px; -} - -.cute-shorts-slider:before { - position: absolute; - content: ""; - height: 18px; - width: 18px; - left: 3px; - bottom: 3px; - background-color: white; - border-radius: 50%; - transition: 0.3s; -} - -.cute-shorts-toggle input:checked + .cute-shorts-slider { - background-color: #e63030; -} - -.cute-shorts-toggle input:checked + .cute-shorts-slider:before { - transform: translateX(20px); -} - -.cute-shorts-label { - color: #aaa; - font-size: 12px; -} - -/* Thumbnail Upload */ -.cute-thumbnail-upload { - border: 2px dashed #444; - border-radius: 10px; - padding: 16px; - text-align: center; - cursor: pointer; - transition: all 0.2s; - background: #151515; -} - -.cute-thumbnail-upload:hover { - border-color: #ff6b8a; - background: rgba(255, 107, 138, 0.05); -} - -.cute-thumbnail-preview { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - color: #666; -} - -.cute-thumbnail-preview i { - font-size: 24px; - color: #ff6b8a; -} - -.cute-thumbnail-preview span { - font-size: 12px; -} - -/* Status */ -.cute-status { - padding: 10px; - border-radius: 8px; - font-size: 13px; - margin-bottom: 16px; - display: none; -} - -.cute-status.success { - display: block; - background: rgba(34, 197, 94, 0.15); - color: #4ade80; - border: 1px solid rgba(34, 197, 94, 0.3); -} - -.cute-status.error { - display: block; - background: rgba(239, 68, 68, 0.15); - color: #f87171; - border: 1px solid rgba(239, 68, 68, 0.3); -} - -/* Actions */ -.cute-edit-actions { - display: flex; - gap: 10px; - justify-content: flex-end; -} - -.cute-btn-cancel, .cute-btn-save { - padding: 10px 20px; - border-radius: 10px; - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - border: none; -} - -.cute-btn-cancel { - background: #333; - color: #aaa; -} - -.cute-btn-cancel:hover { - background: #444; - color: #fff; -} - -.cute-btn-save { - background: linear-gradient(135deg, #ff6b8a 0%, #ff8fa3 100%); - color: white; - box-shadow: 0 4px 15px rgba(255, 107, 138, 0.3); -} - -.cute-btn-save:hover { - transform: translateY(-1px); - box-shadow: 0 6px 20px rgba(255, 107, 138, 0.4); -} - -.cute-btn-save:disabled { - background: #444; - cursor: not-allowed; - transform: none; - box-shadow: none; -} - -@media (max-width: 420px) { - .cute-edit-modal { - max-width: 320px; - margin: 10px; - } - - .cute-type-options, .cute-privacy-options { - flex-direction: column; - } -} @endonce diff --git a/resources/views/components/video-card.blade.php b/resources/views/components/video-card.blade.php index 5491243..cab3c38 100644 --- a/resources/views/components/video-card.blade.php +++ b/resources/views/components/video-card.blade.php @@ -312,9 +312,8 @@ function stopVideo(element) { element.classList.remove('audio-playing'); } -// openEditVideoModal + the type/privacy/thumbnail/submit handlers that used -// to live here were removed with the per-card modal — the singleton at -// layouts/partials/edit-video-modal.blade.php owns the global function and -// handles its own event bindings. +// Edit-modal open/close + its form handlers live in the singleton at +// layouts/partials/edit-video-modal.blade.php (not here) — see the +// removed-markup note above the dropdown for why. @endonce diff --git a/tests/Feature/Phase2PerfTest.php b/tests/Feature/Phase2PerfTest.php new file mode 100644 index 0000000..b36ad53 --- /dev/null +++ b/tests/Feature/Phase2PerfTest.php @@ -0,0 +1,175 @@ +withoutMiddleware([ + VerifyCsrfToken::class, + \Illuminate\Session\Middleware\AuthenticateSession::class, + ]); + } + + // ─── Shared edit modal: per-card copy is gone ───── + + public function test_video_card_does_not_render_a_per_card_edit_modal(): void + { + [$user, $video] = $this->makeUserAndVideo(); + $this->actingAs($user); + + $html = view('components.video-card', ['video' => $video])->render(); + + // The dead per-card modal used id="editVideoModal{slug}" and the class + // "cute-edit-modal". If either reappears, we're back to shipping 100 + // extra lines per card. + $this->assertStringNotContainsString('editVideoModal' . $video->getRouteKey(), $html); + $this->assertStringNotContainsString('cute-edit-modal', $html); + } + + // ─── Owner-only menu items ──────────────────────── + + public function test_owner_sees_edit_delete_items_in_card_menu(): void + { + [$owner, $video] = $this->makeUserAndVideo(); + $this->actingAs($owner); + + $html = view('components.video-card', ['video' => $video])->render(); + + $this->assertStringContainsString('openEditVideoModal', $html); + $this->assertStringContainsString('showDeleteModal', $html); + $this->assertStringNotContainsString('notInterested', $html); + $this->assertStringNotContainsString('dontRecommendChannel', $html); + $this->assertStringNotContainsString('reportVideo', $html); + } + + public function test_non_owner_sees_report_items_but_not_edit(): void + { + [$owner, $video] = $this->makeUserAndVideo(); + $viewer = User::factory()->create([ + 'username' => 'v'.substr(bin2hex(random_bytes(4)), 0, 8), + ]); + $this->actingAs($viewer); + + $html = view('components.video-card', ['video' => $video])->render(); + + $this->assertStringNotContainsString('openEditVideoModal', $html); + $this->assertStringNotContainsString('showDeleteModal', $html); + $this->assertStringContainsString('notInterested', $html); + $this->assertStringContainsString('reportVideo', $html); + } + + // ─── Thumbnail layout-shift attributes ──────────── + + public function test_thumbnail_image_has_width_and_height(): void + { + [$user, $video] = $this->makeUserAndVideo(); + $this->actingAs($user); + + $html = view('components.video-card', ['video' => $video])->render(); + + $this->assertMatchesRegularExpression( + '/]*width="320"[^>]*height="180"/i', + $html, + 'Thumbnail img must carry explicit width/height so the browser reserves layout space.' + ); + $this->assertStringContainsString('loading="lazy"', $html); + } + + // ─── Cursor-paginated feed endpoint ─────────────── + + public function test_feed_more_returns_json_with_cards_and_next_cursor(): void + { + $user = User::factory()->create([ + 'username' => 'f'.substr(bin2hex(random_bytes(4)), 0, 8), + ]); + // Twice the page size so the cursor must advance. + for ($i = 0; $i < 30; $i++) { + Video::create([ + 'user_id' => $user->id, + 'title' => 'Vid ' . $i, + 'description' => '', + 'filename' => "users/x/videos/y{$i}/video.mp4", + 'path' => "users/x/videos/y{$i}/video.mp4", + 'type' => 'generic', + 'visibility' => 'public', + 'status' => 'ready', + 'share_token' => bin2hex(random_bytes(8)), + ]); + } + + $r = $this->getJson(route('videos.feed.more', ['filter' => 'all'])); + $r->assertOk(); + $r->assertJsonStructure(['items', 'next_cursor', 'has_more']); + $this->assertTrue($r->json('has_more'), 'With 30 videos and a 24-page size, has_more must be true.'); + $this->assertNotEmpty($r->json('next_cursor')); + $this->assertStringContainsString('yt-video-card', (string) $r->json('items')); + } + + public function test_feed_more_unknown_filter_returns_empty_slice(): void + { + $this->getJson(route('videos.feed.more', ['filter' => 'nonsense'])) + ->assertOk() + ->assertJson(['items' => '', 'has_more' => false]); + } + + // ─── Lazy reply-form template present ───────────── + + public function test_comments_component_ships_the_reply_template_not_inline_forms(): void + { + [$user, $video] = $this->makeUserAndVideo(); + \App\Models\Comment::create([ + 'user_id' => $user->id, + 'video_id' => $video->id, + 'body' => 'top-level comment', + ]); + $this->actingAs($user); + + $html = view('components.video-comments', ['video' => $video])->render(); + + $this->assertStringContainsString('id="ytc-reply-form-tpl"', $html); + $this->assertStringContainsString('ytc-reply-slot', $html); + } + + // ─── Helpers ────────────────────────────────────── + + private function makeUserAndVideo(): array + { + $user = User::factory()->create([ + 'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8), + ]); + $video = Video::create([ + 'user_id' => $user->id, + 'title' => 'Test video', + 'description'=> 'Hello', + 'filename' => 'users/x/videos/y/video.mp4', + 'path' => 'users/x/videos/y/video.mp4', + 'type' => 'generic', + 'visibility' => 'public', + 'status' => 'ready', + 'share_token' => bin2hex(random_bytes(8)), + ]); + return [$user, $video]; + } +}