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 <noreply@anthropic.com>
This commit is contained in:
parent
a4384113c2
commit
d9959c4452
@ -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();
|
||||
|
||||
46
app/Mail/VideoShared.php
Normal file
46
app/Mail/VideoShared.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Address;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class VideoShared extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public string $shareTitle;
|
||||
|
||||
public function __construct(
|
||||
public Video $video,
|
||||
public string $shareUrl,
|
||||
public User $sender,
|
||||
public ?string $personalMessage = null,
|
||||
?string $shareTitle = null,
|
||||
) {
|
||||
// Version-aware title (e.g. the English track's title) with a primary fallback.
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@ -168,7 +168,7 @@
|
||||
|
||||
@if ($video->isShareable())
|
||||
<button class="action-btn desktop-action"
|
||||
onclick="shareCurrent('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')">
|
||||
onclick="shareCurrent('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}', '{{ Auth::check() ? route('videos.shareEmail', $video) : '' }}')">
|
||||
<i class="bi bi-share"></i><span>Share</span>
|
||||
</button>
|
||||
@endif
|
||||
@ -271,7 +271,7 @@
|
||||
|
||||
@if ($video->isShareable())
|
||||
<button class="dropdown-item"
|
||||
onclick="shareCurrent('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')">
|
||||
onclick="shareCurrent('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}', '{{ Auth::check() ? route('videos.shareEmail', $video) : '' }}')">
|
||||
<i class="bi bi-share"></i> Share
|
||||
</button>
|
||||
@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) {
|
||||
|
||||
69
resources/views/emails/video-shared.blade.php
Normal file
69
resources/views/emails/video-shared.blade.php
Normal file
@ -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
|
||||
<x-emails.layout subject="{{ $sender->name }} shared a {{ $noun }} with you">
|
||||
|
||||
{{-- Icon --}}
|
||||
<div style="width:64px;height:64px;border-radius:50%;background:rgba(230,30,30,.12);border:1px solid rgba(230,30,30,.25);margin:0 auto 24px;text-align:center;line-height:64px;font-size:28px;">{!! $icon !!}</div>
|
||||
|
||||
<h1 class="email-title">{{ $sender->name }} shared a {{ $noun }} with you</h1>
|
||||
<p class="email-subtitle">They thought you'd enjoy this on {{ config('app.name') }}.</p>
|
||||
|
||||
@if($personalMessage)
|
||||
{{-- Personal note --}}
|
||||
<div class="email-infobox" style="border-left:3px solid #e61e1e;">
|
||||
<div class="email-infobox-label">Message from {{ $sender->name }}</div>
|
||||
<div style="color:#ddd;font-size:14px;line-height:1.6;white-space:pre-wrap;">{{ $personalMessage }}</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Cover --}}
|
||||
<div class="email-thumb-wrap">
|
||||
@if($video->thumbnail)
|
||||
<a href="{{ $shareUrl }}">
|
||||
<img src="{{ route('media.thumbnail', $video->thumbnail) }}" alt="{{ $shareTitle }}">
|
||||
</a>
|
||||
@else
|
||||
<div class="email-thumb-placeholder">
|
||||
<span style="font-size:32px;">{!! $icon !!}</span>
|
||||
<span>{{ Str::limit($shareTitle, 40) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Details --}}
|
||||
<div class="email-infobox">
|
||||
<div class="email-infobox-label">Details</div>
|
||||
<div class="email-inforow">
|
||||
<span class="email-inforow-key">Title</span>
|
||||
<span class="email-inforow-val">{{ $shareTitle }}</span>
|
||||
</div>
|
||||
@if($video->formatted_duration)
|
||||
<div class="email-inforow">
|
||||
<span class="email-inforow-key">Duration</span>
|
||||
<span class="email-inforow-val">{{ $video->formatted_duration }}</span>
|
||||
</div>
|
||||
@endif
|
||||
<div class="email-inforow">
|
||||
<span class="email-inforow-key">{{ $byLabel }}</span>
|
||||
<span class="email-inforow-val">{{ $video->user->name ?? config('app.name') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- CTA --}}
|
||||
<div class="email-btn-wrap">
|
||||
<a href="{{ $shareUrl }}" class="email-btn">{!! $isSong ? '▶ ' : '▶ ' !!}{{ $cta }}</a>
|
||||
</div>
|
||||
|
||||
<hr class="email-divider">
|
||||
|
||||
<p class="email-note">
|
||||
{{ $sender->name }} sent you this link from {{ config('app.name') }}. If you weren't expecting it, you can safely ignore this email.
|
||||
</p>
|
||||
|
||||
</x-emails.layout>
|
||||
@ -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;">
|
||||
<i class="bi bi-telegram"></i>
|
||||
</a>
|
||||
<a href="#" id="shareEmailBtn" class="social-share-btn" role="button"
|
||||
style="display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #6b7280; color: white; text-decoration: none;">
|
||||
<i class="bi bi-envelope-fill"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Send by email --}}
|
||||
<div id="shareEmailSection" style="display:none; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-color);">
|
||||
<p style="color: var(--text-secondary); font-size: 13px; margin-bottom: 12px;">
|
||||
<i class="bi bi-envelope me-1"></i> Send to a friend by email:
|
||||
</p>
|
||||
<input type="email" id="shareEmailTo" class="form-control" placeholder="friend@example.com" autocomplete="off"
|
||||
style="background: var(--bg-primary); border: 1px solid var(--border-color); color: var(--text-primary); padding: 10px 14px; border-radius: 8px;">
|
||||
<textarea id="shareEmailMsg" class="form-control" rows="2" maxlength="500" placeholder="Add a short message (optional)"
|
||||
style="background: var(--bg-primary); border: 1px solid var(--border-color); color: var(--text-primary); padding: 10px 14px; border-radius: 8px; margin-top: 8px; resize: vertical;"></textarea>
|
||||
<button type="button" id="shareEmailSend" class="action-btn action-btn-primary" style="width:100%; justify-content:center; margin-top:10px;">
|
||||
<i class="bi bi-send"></i> <span>Send email</span>
|
||||
</button>
|
||||
<div id="shareEmailStatus" style="display:none; margin-top:10px; font-size:13px; text-align:center;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -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 = '<i class="bi bi-clipboard"></i> <span>Copy</span>';
|
||||
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 = '<i class="bi bi-hourglass-split"></i> <span>Sending…</span>';
|
||||
_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');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user