From d9959c445224a66a8506e03742ab5ffce86d3d67 Mon Sep 17 00:00:00 2001 From: ghassan Date: Sun, 24 May 2026 14:12:01 +0300 Subject: [PATCH] Add share-video-by-email feature New POST /videos/{video}/share/email route (auth + throttled) handled by VideoController@shareByEmail, sending the VideoShared mailable rendered from emails/video-shared.blade.php. Wired into the share modal and video-actions. Co-Authored-By: Claude Opus 4.7 --- app/Http/Controllers/VideoController.php | 41 +++++++ app/Mail/VideoShared.php | 46 +++++++ .../views/components/video-actions.blade.php | 8 +- resources/views/emails/video-shared.blade.php | 69 +++++++++++ .../layouts/partials/share-modal.blade.php | 114 +++++++++++++++++- routes/web.php | 1 + 6 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 app/Mail/VideoShared.php create mode 100644 resources/views/emails/video-shared.blade.php diff --git a/app/Http/Controllers/VideoController.php b/app/Http/Controllers/VideoController.php index 6e6ff39..161135c 100644 --- a/app/Http/Controllers/VideoController.php +++ b/app/Http/Controllers/VideoController.php @@ -2176,6 +2176,47 @@ class VideoController extends Controller return response()->json(['url' => route('share.access', $token)]); } + /** + * Email a friend a properly-formatted share email, version-aware: the link and the + * email's title reflect the language track the sender chose. The URL is built + * server-side so outgoing mail can never carry an attacker-supplied link. + */ + public function shareByEmail(Request $request, Video $video) + { + if (! $video->isShareable() || ! $video->canView(Auth::user())) { + return response()->json(['error' => 'This video cannot be shared.'], 403); + } + + $data = $request->validate([ + 'email' => 'required|email|max:255', + 'message' => 'nullable|string|max:500', + 'track' => 'nullable|integer', + ]); + + $trackId = (int) ($data['track'] ?? 0); + $shareTitle = $video->title; + $shareUrl = $video->share_url; + if ($trackId && ($track = $video->audioTracks->firstWhere('id', $trackId))) { + if (! empty($track->title)) $shareTitle = $track->title; + $shareUrl .= (str_contains($shareUrl, '?') ? '&' : '?') . 'track=' . $trackId; + } + + try { + \Mail::to($data['email'])->send(new \App\Mail\VideoShared( + $video, + $shareUrl, + Auth::user(), + $data['message'] ?? null, + $shareTitle, + )); + } catch (\Throwable $e) { + \Log::error('Share-by-email failed: ' . $e->getMessage(), ['video_id' => $video->id]); + return response()->json(['error' => 'Could not send the email right now. Please try again.'], 500); + } + + return response()->json(['success' => true]); + } + public function accessShare(Request $request, string $token) { $share = \DB::table('video_shares')->where('token', $token)->first(); diff --git a/app/Mail/VideoShared.php b/app/Mail/VideoShared.php new file mode 100644 index 0000000..d290028 --- /dev/null +++ b/app/Mail/VideoShared.php @@ -0,0 +1,46 @@ +shareTitle = $shareTitle ?: $video->title; + } + + public function envelope(): Envelope + { + return new Envelope( + subject: $this->sender->name . ' shared "' . $this->shareTitle . '" with you on ' . config('app.name'), + // Let the recipient reply straight to the friend who sent it. + replyTo: $this->sender->email + ? [new Address($this->sender->email, $this->sender->name)] + : [], + ); + } + + public function content(): Content + { + return new Content(view: 'emails.video-shared'); + } +} diff --git a/resources/views/components/video-actions.blade.php b/resources/views/components/video-actions.blade.php index b57cbe2..e90b5d0 100644 --- a/resources/views/components/video-actions.blade.php +++ b/resources/views/components/video-actions.blade.php @@ -168,7 +168,7 @@ @if ($video->isShareable()) @endif @@ -271,7 +271,7 @@ @if ($video->isShareable()) @endif @@ -516,10 +516,10 @@ if (!window._slideshowDlInit) { // Share the version the viewer is on: append ?track={id} so the recipient's player // opens directly on that language (window._ytpTrackId; 0 = primary β†’ plain link). - window.shareCurrent = function (baseUrl, title, recordUrl) { + window.shareCurrent = function (baseUrl, title, recordUrl, emailUrl) { var t = window._ytpTrackId || 0; var url = t ? baseUrl + (baseUrl.indexOf('?') === -1 ? '?' : '&') + 'track=' + t : baseUrl; - if (typeof window.openShareModal === 'function') window.openShareModal(url, title, recordUrl); + if (typeof window.openShareModal === 'function') window.openShareModal(url, title, recordUrl, emailUrl); }; window.startSlideshowDownload = function (routeKey) { diff --git a/resources/views/emails/video-shared.blade.php b/resources/views/emails/video-shared.blade.php new file mode 100644 index 0000000..394decc --- /dev/null +++ b/resources/views/emails/video-shared.blade.php @@ -0,0 +1,69 @@ +@php + // Type-aware wording β€” a sport/match or regular video is NOT a song. + $isSong = $video->isAudioOnly() || $video->type === 'music'; + $noun = $isSong ? 'song' : ($video->type === 'match' ? 'match' : 'video'); + $cta = $isSong ? 'Listen now' : 'Watch now'; + $icon = $isSong ? '🎵' : '▶'; // 🎡 vs β–Ά + $byLabel = $isSong ? 'Artist' : ($video->type === 'match' ? 'Posted by' : 'Channel'); +@endphp + + + {{-- Icon --}} +
{!! $icon !!}
+ +

{{ $sender->name }} shared a {{ $noun }} with you

+ + + @if($personalMessage) + {{-- Personal note --}} + + @endif + + {{-- Cover --}} + + + {{-- Details --}} + + + {{-- CTA --}} + + + + + + +
diff --git a/resources/views/layouts/partials/share-modal.blade.php b/resources/views/layouts/partials/share-modal.blade.php index 6666649..b761d8d 100644 --- a/resources/views/layouts/partials/share-modal.blade.php +++ b/resources/views/layouts/partials/share-modal.blade.php @@ -42,8 +42,27 @@ style="display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #0088cc; color: white; text-decoration: none;"> + + + {{-- Send by email --}} + @@ -70,7 +89,11 @@ function _getLatestCsrf() { return (typeof csrf !== 'undefined') ? csrf : ''; } -async function openShareModal(videoUrl, videoTitle, recordUrl) { +// Set per-open so the "Send by email" form knows the endpoint + which version to send. +var _shareEmailUrl = ''; +var _shareTrack = ''; + +async function openShareModal(videoUrl, videoTitle, recordUrl, emailUrl) { var csrfToken = _getLatestCsrf(); var shareUrl = videoUrl; @@ -78,6 +101,8 @@ async function openShareModal(videoUrl, videoTitle, recordUrl) { // share link replaces shareUrl below, so we re-attach it afterwards. var trackParam = ''; try { trackParam = (new URL(videoUrl, window.location.origin)).searchParams.get('track') || ''; } catch (e) {} + _shareEmailUrl = emailUrl || ''; + _shareTrack = trackParam; // Obtain a unique tracked share link from the server if (recordUrl) { @@ -130,6 +155,17 @@ function _populateShareModal(shareUrl, videoTitle) { copyBtn.innerHTML = ' Copy'; copyBtn.classList.remove('copied'); document.getElementById('copySuccess').style.display = 'none'; + + // Reset the email form; the envelope button only works when an endpoint was provided. + var emailBtn = document.getElementById('shareEmailBtn'); + var emailSec = document.getElementById('shareEmailSection'); + if (emailBtn) emailBtn.style.display = _shareEmailUrl ? 'flex' : 'none'; + if (emailSec) { + emailSec.style.display = 'none'; + var to = document.getElementById('shareEmailTo'); if (to) to.value = ''; + var msg = document.getElementById('shareEmailMsg'); if (msg) msg.value = ''; + var st = document.getElementById('shareEmailStatus'); if (st) st.style.display = 'none'; + } } function _copyToClipboard(text) { @@ -174,6 +210,82 @@ document.addEventListener('DOMContentLoaded', function() { showToast('Could not copy β€” please copy the link manually.', 'error'); }); }); + + // ── Send by email ────────────────────────────────────────────── + var emailBtn = document.getElementById('shareEmailBtn'); + var emailSec = document.getElementById('shareEmailSection'); + var emailTo = document.getElementById('shareEmailTo'); + var emailMsg = document.getElementById('shareEmailMsg'); + var emailSend = document.getElementById('shareEmailSend'); + var emailStat = document.getElementById('shareEmailStatus'); + + if (emailBtn && emailSec) { + emailBtn.addEventListener('click', function(e) { + e.preventDefault(); + emailSec.style.display = (emailSec.style.display === 'none' || !emailSec.style.display) ? 'block' : 'none'; + if (emailSec.style.display === 'block' && emailTo) emailTo.focus(); + }); + } + + function _showEmailStatus(msg, color) { + if (!emailStat) return; + emailStat.textContent = msg; + emailStat.style.color = color; + emailStat.style.display = 'block'; + } + + if (emailSend) { + emailSend.addEventListener('click', function() { + var to = (emailTo && emailTo.value || '').trim(); + if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(to)) { + _showEmailStatus('Please enter a valid email address.', '#ef4444'); + if (emailTo) emailTo.focus(); + return; + } + if (!_shareEmailUrl) { _showEmailStatus('Email sharing is unavailable here.', '#ef4444'); return; } + + emailSend.disabled = true; + var _orig = emailSend.innerHTML; + emailSend.innerHTML = ' Sending…'; + _showEmailStatus('Sending…', 'var(--text-secondary)'); + + var body = new URLSearchParams({ + _token: '{{ csrf_token() }}', + email: to, + message: (emailMsg && emailMsg.value || ''), + track: _shareTrack || '0', + }); + + fetch(_shareEmailUrl, { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': _getLatestCsrf(), + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: body.toString(), + }) + .then(function(r) { return r.json().then(function(d){ return { ok: r.ok, d: d }; }); }) + .then(function(res) { + emailSend.disabled = false; + emailSend.innerHTML = _orig; + if (res.ok && res.d.success) { + _showEmailStatus('Sent! Your friend will get the email shortly.', '#4caf50'); + if (emailTo) emailTo.value = ''; + if (emailMsg) emailMsg.value = ''; + setTimeout(function(){ if (emailSec) emailSec.style.display = 'none'; }, 1800); + } else { + _showEmailStatus((res.d && res.d.error) || 'Could not send the email. Please try again.', '#ef4444'); + } + }) + .catch(function() { + emailSend.disabled = false; + emailSend.innerHTML = _orig; + _showEmailStatus('Network error β€” please try again.', '#ef4444'); + }); + }); + } }); diff --git a/routes/web.php b/routes/web.php index a495360..1672a9b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -48,6 +48,7 @@ Route::get('/videos/{video}/slideshow/progress', [VideoController::class, 'slide Route::get('/videos/{video}/recommendations', [VideoController::class, 'recommendations'])->name('videos.recommendations'); Route::get('/videos/{video}/player-data', [VideoController::class, 'playerData'])->name('videos.playerData'); Route::post('/videos/{video}/share', [VideoController::class, 'recordShare'])->name('videos.recordShare'); +Route::post('/videos/{video}/share/email', [VideoController::class, 'shareByEmail'])->name('videos.shareEmail')->middleware(['auth', 'throttle:10,1']); Route::get('/videos/{video}/og-image', [VideoController::class, 'ogImage'])->name('videos.ogImage'); Route::get('/s/{token}', [VideoController::class, 'accessShare'])->name('share.access'); Route::get('/videos/{video}/insights', [VideoController::class, 'insights'])->name('videos.insights')->middleware('auth');