Add full notification preferences system

Users can now control which in-app and email notifications they receive
from their Settings tab on their channel page.

Bell (in-app) preferences:
- New comment on my video (default: on)
- Reply to my comment (default: on)
- Comment liked (default: on)
- Video liked (default: on)
- New subscriber (default: on)
- New video from channels I follow (default: on)
- New post from channels I follow (default: on)
- New user registration — super admins only (default: on)

Email preferences (same set plus):
- Comment liked (default: off — too noisy)
- Video liked (default: off — too noisy)
- New post from channels I follow (default: off)
- My video finished processing (default: on)
- Weekly activity digest every Monday (default: on)
- New user registration — super admins only (default: on)

Implementation:
- Migration: notification_preferences JSON column on users table
- User::notificationPref($key) helper with typed defaults
- All existing notification classes updated to check prefs in via()
- 4 new notification classes: NewSubscriberNotification,
  VideoLikedNotification, NewPostNotification, WeeklyDigestNotification
- 8 new email views matching existing dark theme
- SendWeeklyDigest artisan command, scheduled every Monday 09:00
- NewSubscriberNotification wired into UserController::toggleSubscribe
- VideoLikedNotification wired into UserController::toggleLike
- NewPostNotification wired into PostController::store (to all subscribers)
- Bell renderer updated for new_subscriber, video_like, new_post types
- Preferences saved via AJAX (POST /settings/notifications) — instant
  toggle with automatic revert on failure

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
ghassanandClaude Sonnet 4.6 committed 2026-05-16 23:47:28 +03:00
1 parent 3fe167e33f
commit f8d13457fa
26 files changed
+808 -18

No files matched your search

@@ -0,0 +1,27 @@
<x-emails.layout subject="{{ $liker->name }} liked your comment">
<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;">&#x1F44D;</div>
<h1 class="email-title">Your comment got a like!</h1>
<p class="email-subtitle">On the video <strong style="color:#f1f1f1;">{{ $video->title }}</strong>.</p>
<p class="email-text">Hi <strong style="color:#f1f1f1;">{{ $recipient->name }}</strong>,</p>
<p class="email-text">
<strong style="color:#f1f1f1;">{{ $liker->name }}</strong> liked your comment:
</p>
<div class="email-infobox">
<div class="email-infobox-label">Your Comment</div>
<div style="color:#e0e0e0;font-size:14px;line-height:1.6;padding:4px 0;">
"{{ \Illuminate\Support\Str::limit(strip_tags($comment->body), 200) }}"
</div>
</div>
<div class="email-btn-wrap">
<a href="{{ route('videos.show', $video) }}" class="email-btn">&#x25B6;&nbsp; View Video</a>
</div>
<hr class="email-divider">
<p class="email-note">You can manage your notification preferences in your <a href="{{ route('settings') }}">account settings</a>.</p>
</x-emails.layout>
@@ -0,0 +1,27 @@
<x-emails.layout subject="{{ $commenter->name }} commented on your video">
<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;">&#x1F4AC;</div>
<h1 class="email-title">New comment on your video</h1>
<p class="email-subtitle">Someone left a comment on <strong style="color:#f1f1f1;">{{ $video->title }}</strong>.</p>
<p class="email-text">Hi <strong style="color:#f1f1f1;">{{ $recipient->name }}</strong>,</p>
<p class="email-text">
<strong style="color:#f1f1f1;">{{ $commenter->name }}</strong> commented on your video:
</p>
<div class="email-infobox">
<div class="email-infobox-label">Comment</div>
<div style="color:#e0e0e0;font-size:14px;line-height:1.6;padding:4px 0;">
"{{ \Illuminate\Support\Str::limit(strip_tags($comment->body), 200) }}"
</div>
</div>
<div class="email-btn-wrap">
<a href="{{ route('videos.show', $video) }}" class="email-btn">&#x1F4AC;&nbsp; View Comment</a>
</div>
<hr class="email-divider">
<p class="email-note">You can manage your notification preferences in your <a href="{{ route('settings') }}">account settings</a>.</p>
</x-emails.layout>
@@ -0,0 +1,26 @@
<x-emails.layout subject="{{ $author->name }} posted something new">
<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;">&#x1F4DD;</div>
<h1 class="email-title">New post from {{ $author->name }}</h1>
<p class="email-subtitle">A channel you follow shared something.</p>
<p class="email-text">Hi <strong style="color:#f1f1f1;">{{ $recipient->name }}</strong>,</p>
@if($post->body)
<div class="email-infobox">
<div class="email-infobox-label">Post</div>
<div style="color:#e0e0e0;font-size:14px;line-height:1.6;padding:4px 0;">
"{{ \Illuminate\Support\Str::limit(strip_tags($post->body), 300) }}"
</div>
</div>
@endif
<div class="email-btn-wrap">
<a href="{{ route('channel', $author->channel) }}" class="email-btn">&#x1F4DD;&nbsp; View Post</a>
</div>
<hr class="email-divider">
<p class="email-note">You can manage your notification preferences in your <a href="{{ route('settings') }}">account settings</a>.</p>
</x-emails.layout>
@@ -0,0 +1,27 @@
<x-emails.layout subject="{{ $replier->name }} replied to your comment">
<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;">&#x21A9;&#xFE0F;</div>
<h1 class="email-title">Someone replied to your comment</h1>
<p class="email-subtitle">On the video <strong style="color:#f1f1f1;">{{ $video->title }}</strong>.</p>
<p class="email-text">Hi <strong style="color:#f1f1f1;">{{ $recipient->name }}</strong>,</p>
<p class="email-text">
<strong style="color:#f1f1f1;">{{ $replier->name }}</strong> replied to your comment:
</p>
<div class="email-infobox">
<div class="email-infobox-label">Reply</div>
<div style="color:#e0e0e0;font-size:14px;line-height:1.6;padding:4px 0;">
"{{ \Illuminate\Support\Str::limit(strip_tags($reply->body), 200) }}"
</div>
</div>
<div class="email-btn-wrap">
<a href="{{ route('videos.show', $video) }}" class="email-btn">&#x21A9;&#xFE0F;&nbsp; View Reply</a>
</div>
<hr class="email-divider">
<p class="email-note">You can manage your notification preferences in your <a href="{{ route('settings') }}">account settings</a>.</p>
</x-emails.layout>
@@ -0,0 +1,21 @@
<x-emails.layout subject="{{ $subscriber->name }} subscribed to your channel">
<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;">&#x1F514;</div>
<h1 class="email-title">You have a new subscriber!</h1>
<p class="email-subtitle">Your channel is growing.</p>
<p class="email-text">Hi <strong style="color:#f1f1f1;">{{ $recipient->name }}</strong>,</p>
<p class="email-text">
<strong style="color:#f1f1f1;">{{ $subscriber->name }}</strong> just subscribed to your channel.
Keep creating great content!
</p>
<div class="email-btn-wrap">
<a href="{{ route('channel', $subscriber->channel) }}" class="email-btn">&#x1F464;&nbsp; View Their Profile</a>
</div>
<hr class="email-divider">
<p class="email-note">You can manage your notification preferences in your <a href="{{ route('settings') }}">account settings</a>.</p>
</x-emails.layout>
@@ -0,0 +1,44 @@
<x-emails.layout subject="{{ $uploader->name }} uploaded a new video">
<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;">&#x1F3AC;</div>
<h1 class="email-title">New video from {{ $uploader->name }}</h1>
<p class="email-subtitle">A channel you follow just posted.</p>
<p class="email-text">Hi <strong style="color:#f1f1f1;">{{ $recipient->name }}</strong>,</p>
<p class="email-text">
<strong style="color:#f1f1f1;">{{ $uploader->name }}</strong> uploaded a new video:
<strong style="color:#f1f1f1;">"{{ $video->title }}"</strong>
</p>
@if($video->thumbnail)
<div class="email-thumb-wrap">
<a href="{{ route('videos.show', $video) }}">
<img src="{{ route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}">
</a>
</div>
@endif
<div class="email-infobox">
@if($video->description)
<div class="email-inforow">
<span class="email-inforow-key">Description</span>
<span class="email-inforow-val">{{ \Illuminate\Support\Str::limit($video->description, 100) }}</span>
</div>
@endif
@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>
<div class="email-btn-wrap">
<a href="{{ route('videos.show', $video) }}" class="email-btn">&#x25B6;&nbsp; Watch Now</a>
</div>
<hr class="email-divider">
<p class="email-note">You can manage your notification preferences in your <a href="{{ route('settings') }}">account settings</a>.</p>
</x-emails.layout>
@@ -0,0 +1,28 @@
<x-emails.layout subject="{{ $liker->name }} liked your video">
<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;">&#x2764;&#xFE0F;</div>
<h1 class="email-title">Someone liked your video!</h1>
<p class="email-text">Hi <strong style="color:#f1f1f1;">{{ $recipient->name }}</strong>,</p>
<p class="email-text">
<strong style="color:#f1f1f1;">{{ $liker->name }}</strong> liked your video
<strong style="color:#f1f1f1;">"{{ $video->title }}"</strong>.
</p>
@if($video->thumbnail)
<div class="email-thumb-wrap">
<a href="{{ route('videos.show', $video) }}">
<img src="{{ route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}">
</a>
</div>
@endif
<div class="email-btn-wrap">
<a href="{{ route('videos.show', $video) }}" class="email-btn">&#x25B6;&nbsp; Watch Video</a>
</div>
<hr class="email-divider">
<p class="email-note">You can manage your notification preferences in your <a href="{{ route('settings') }}">account settings</a>.</p>
</x-emails.layout>
@@ -0,0 +1,63 @@
<x-emails.layout subject="Your weekly TAKEONE summary 📊">
<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;">&#x1F4CA;</div>
<h1 class="email-title">Your week in numbers</h1>
<p class="email-subtitle">Here's how your channel did in the last 7 days.</p>
<p class="email-text">Hi <strong style="color:#f1f1f1;">{{ $creator->name }}</strong>,</p>
<div class="email-infobox">
<div class="email-infobox-label">This Week's Stats</div>
<div class="email-inforow">
<span class="email-inforow-key">&#x1F440; Views</span>
<span class="email-inforow-val">{{ number_format($stats['views']) }}</span>
</div>
<div class="email-inforow">
<span class="email-inforow-key">&#x2764;&#xFE0F; Likes</span>
<span class="email-inforow-val">{{ number_format($stats['likes']) }}</span>
</div>
<div class="email-inforow">
<span class="email-inforow-key">&#x1F514; New Subscribers</span>
<span class="email-inforow-val">{{ number_format($stats['new_subs']) }}</span>
</div>
</div>
@if($stats['top_video'])
<p class="email-text" style="margin-top:20px;">Your top video this week:</p>
@if($stats['top_video']->thumbnail)
<div class="email-thumb-wrap">
<a href="{{ route('videos.show', $stats['top_video']) }}">
<img src="{{ route('media.thumbnail', $stats['top_video']->thumbnail) }}" alt="{{ $stats['top_video']->title }}">
</a>
</div>
@endif
<div class="email-infobox">
<div class="email-inforow">
<span class="email-inforow-key">Title</span>
<span class="email-inforow-val">{{ $stats['top_video']->title }}</span>
</div>
<div class="email-inforow">
<span class="email-inforow-key">Views this week</span>
<span class="email-inforow-val">{{ number_format($stats['top_video']->week_views ?? 0) }}</span>
</div>
</div>
<div class="email-btn-wrap">
<a href="{{ route('videos.show', $stats['top_video']) }}" class="email-btn">&#x25B6;&nbsp; Watch Top Video</a>
</div>
@else
<p class="email-text" style="text-align:center;color:var(--text-secondary);">
Upload your first video to start tracking performance!
</p>
<div class="email-btn-wrap">
<a href="{{ route('home') }}" class="email-btn">&#x1F3AC;&nbsp; Go to TAKEONE</a>
</div>
@endif
<hr class="email-divider">
<p class="email-note">You can manage your notification preferences in your <a href="{{ route('settings') }}">account settings</a>.</p>
</x-emails.layout>
+13 -6
View File
@@ -1226,20 +1226,27 @@
case 'new_comment': return actor + ' commented on your video ' + title + ':' + preview;
case 'new_reply': return actor + ' replied to your comment:' + preview;
case 'comment_like':return actor + ' liked your comment on ' + title;
case 'new_user': return '&#x1F389; ' + actor + ' just joined TAKEONE!';
default: return actor + ' uploaded a new video: ' + title;
case 'new_user': return '&#x1F389; ' + actor + ' just joined TAKEONE!';
case 'new_subscriber': return '&#x1F514; ' + actor + ' subscribed to your channel';
case 'video_like': return '&#x2764;&#xFE0F; ' + actor + ' liked your video ' + title;
case 'new_post': return '&#x1F4DD; ' + actor + ' posted something new';
default: return actor + ' uploaded a new video: ' + title;
}
}
function notifHref(d) {
if (d.type === 'new_user') return '/channel/' + encodeURIComponent(d.user_channel || '');
if (d.type === 'new_user') return '/channel/' + encodeURIComponent(d.user_channel || '');
if (d.type === 'new_subscriber') return '/channel/' + encodeURIComponent(d.actor_channel || '');
if (d.type === 'new_post') return '/channel/' + encodeURIComponent(d.author_channel || '');
return '/videos/' + escHtml(d.video_route_key || '');
}
function notifThumb(d) {
if (d.type === 'new_user') {
return d.user_avatar
? '<img class="yt-notif-thumb" src="' + escHtml(d.user_avatar) + '" alt="" loading="lazy" onerror="notifThumbFallback(this)" style="border-radius:50%">'
var avatarTypes = ['new_user', 'new_subscriber', 'new_post'];
if (avatarTypes.indexOf(d.type) !== -1) {
var av = d.user_avatar || d.actor_avatar || d.author_avatar || '';
return av
? '<img class="yt-notif-thumb" src="' + escHtml(av) + '" alt="" loading="lazy" onerror="notifThumbFallback(this)" style="border-radius:50%">'
: '<div class="yt-notif-thumb-placeholder"><i class="bi bi-person-circle"></i></div>';
}
return d.video_thumbnail
+136
View File
@@ -1076,6 +1076,42 @@
margin-bottom: 20px;
}
.ch-settings-card:last-child { margin-bottom: 0; }
/* ── Notification preference rows ── */
.notif-section-label {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: .07em; color: var(--text-secondary);
margin: 0 0 12px; display: flex; align-items: center; gap: 6px;
}
.notif-pref-row {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,.04);
}
.notif-pref-row:last-of-type { border-bottom: none; }
.notif-pref-label { font-size: 14px; color: var(--text-primary); }
.notif-pref-note {
font-size: 12px; color: var(--text-secondary);
margin-top: 16px; line-height: 1.5;
}
.notif-toggle-wrap {
position: relative; width: 42px; height: 24px;
flex-shrink: 0; cursor: pointer;
}
.notif-toggle-input { opacity: 0; width: 0; height: 0; position: absolute; }
.notif-toggle-slider {
position: absolute; inset: 0;
background: rgba(255,255,255,.15); border-radius: 24px;
transition: background .2s;
}
.notif-toggle-slider::before {
content: ''; position: absolute;
width: 18px; height: 18px; border-radius: 50%;
background: #fff; top: 3px; left: 3px;
transition: transform .2s;
box-shadow: 0 1px 3px rgba(0,0,0,.4);
}
.notif-toggle-input:checked + .notif-toggle-slider { background: var(--brand-red); }
.notif-toggle-input:checked + .notif-toggle-slider::before { transform: translateX(18px); }
.ch-settings-card-title {
font-size: 13px; font-weight: 700;
text-transform: uppercase; letter-spacing: .06em;
@@ -2396,6 +2432,85 @@ if ($oldLinks) {
</div>
@endif
@if($isOwner)
@php
$notifPrefs = $user->notification_preferences ?? [];
$notifDefs = \App\Models\User::notifDefaults();
function notifVal(array $prefs, array $defs, string $key): bool {
return isset($prefs[$key]) ? (bool)$prefs[$key] : ($defs[$key] ?? true);
}
@endphp
<div class="ch-settings-card" id="notifPrefsCard">
<div class="ch-settings-card-title"><i class="bi bi-bell-fill"></i> Notification Preferences</div>
{{-- Bell (In-App) --}}
<div class="notif-section-label"><i class="bi bi-bell"></i> In-App Notifications</div>
@php
$bellPrefs = [
['notif_new_comment', 'New comment on my video'],
['notif_new_reply', 'Reply to my comment'],
['notif_comment_like', 'Someone liked my comment'],
['notif_video_like', 'Someone liked my video'],
['notif_new_subscriber', 'New subscriber'],
['notif_new_video_from_sub', 'New video from channels I follow'],
['notif_new_post_from_sub', 'New post from channels I follow'],
];
if ($user->isSuperAdmin()) {
$bellPrefs[] = ['notif_new_user_reg', 'New user registration'];
}
@endphp
@foreach($bellPrefs as [$key, $label])
<div class="notif-pref-row">
<span class="notif-pref-label">{{ $label }}</span>
<label class="notif-toggle-wrap">
<input type="checkbox" class="notif-toggle-input"
data-key="{{ $key }}"
{{ notifVal($notifPrefs, $notifDefs, $key) ? 'checked' : '' }}
onchange="saveNotifPref('{{ $key }}', this.checked)">
<span class="notif-toggle-slider"></span>
</label>
</div>
@endforeach
{{-- Email --}}
<div class="notif-section-label" style="margin-top:20px;"><i class="bi bi-envelope"></i> Email Notifications</div>
@php
$emailPrefs = [
['email_new_comment', 'New comment on my video'],
['email_new_reply', 'Reply to my comment'],
['email_comment_like', 'Someone liked my comment'],
['email_video_like', 'Someone liked my video'],
['email_new_subscriber', 'New subscriber'],
['email_new_video_from_sub', 'New video from channels I follow'],
['email_new_post_from_sub', 'New post from channels I follow'],
['email_video_processed', 'My video finished processing'],
['email_weekly_digest', 'Weekly activity digest (every Monday)'],
];
if ($user->isSuperAdmin()) {
$emailPrefs[] = ['email_new_user_reg', 'New user registration'];
}
@endphp
@foreach($emailPrefs as [$key, $label])
<div class="notif-pref-row">
<span class="notif-pref-label">{{ $label }}</span>
<label class="notif-toggle-wrap">
<input type="checkbox" class="notif-toggle-input"
data-key="{{ $key }}"
{{ notifVal($notifPrefs, $notifDefs, $key) ? 'checked' : '' }}
onchange="saveNotifPref('{{ $key }}', this.checked)">
<span class="notif-toggle-slider"></span>
</label>
</div>
@endforeach
<p class="notif-pref-note">Security emails (verification, password reset) are always sent and cannot be disabled.</p>
</div>
@endif
<div class="ch-settings-card">
<div class="ch-settings-card-title"><i class="bi bi-info-circle-fill"></i> Account</div>
<div class="ch-settings-field">
@@ -2957,6 +3072,27 @@ function onBannerSaved(url) {
if (banner) banner.insertBefore(newImg, banner.firstChild);
}
}
// ── Notification preference toggle ────────────────────────────────────────
function saveNotifPref(key, value) {
var csrf = document.querySelector('meta[name="csrf-token"]');
fetch('{{ route("settings.notifications") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrf ? csrf.getAttribute('content') : '',
'Accept': 'application/json',
},
body: JSON.stringify({ key: key, value: value }),
}).then(function(r) {
if (!r.ok) throw new Error('save failed');
}).catch(function() {
// Revert toggle on failure
var el = document.querySelector('[data-key="' + key + '"]');
if (el) el.checked = !value;
if (window.showToast) showToast('Could not save preference.', 'error');
});
}
</script>
@if($isOwner && !$preview)