Add sports-match type, device tracking, profile visits, and share refactor

- New SportsMatch model/controller and sports UI components/modal
- Move share-modal to a reusable x-share-modal/x-share-button component
- Add VideoSharedWithUser notification and share-to-members flow
- Device/user-agent tracking on views, downloads, share accesses
- ProfileVisit model + migration; subscription source tracking
- Email thumbnail support; remove stale TODO files
This commit is contained in:
ghassan committed 2026-05-29 01:50:28 +03:00
1 parent 6aae6f86b6
commit 73527f3781
61 files changed
+4014 -636

No files matched your search

@@ -77,14 +77,11 @@
<svg class="ytp-svg-pause" viewBox="0 0 24 24" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
@if(isset($previousVideo) && $prevUrl)
<button class="ytp-button" title="Previous" onclick="window.location.href='{{ $prevUrl }}'">
@if(isset($playlist) && $playlist)
<button class="ytp-button ytp-prev-btn" title="Previous" onclick="if(window.plPrev)window.plPrev();">
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg>
</button>
@endif
@if(isset($nextVideo) && $nextUrl)
<button class="ytp-button" title="Next" onclick="window.location.href='{{ $nextUrl }}'">
<button class="ytp-button ytp-next-btn" title="Next" onclick="if(window.plNext)window.plNext();">
<svg viewBox="0 0 24 24"><path d="m6 18 8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button>
@endif
@@ -224,12 +221,16 @@
/* ══ Full ytp styles ══ */
.ytp-wrap {
position: relative; width: 100%; background: #000;
position: relative; width: 100% !important; background: #000;
border-radius: 12px; overflow: hidden;
aspect-ratio: 16/9;
aspect-ratio: 16/9 !important;
height: auto !important;
max-height: none !important;
min-height: 0 !important;
}
.ytp {
.ytp, .audio-ytp {
position: relative; width: 100%; height: 100%;
aspect-ratio: 16/9;
background: #000; outline: none;
user-select: none; overflow: hidden;
font-family: Roboto, Arial, sans-serif;
@@ -1063,6 +1064,9 @@ window._audioPlayerUpdate = function(d) {
var langPopup = document.getElementById('ytpLangPopup');
if (!langWrap || !langPopup || !langBtn) return;
// New song loaded → back to its primary track, so shares don't carry a stale track id.
window._ytpTrackId = 0;
function flagHtml(flag, size) {
var s = size === 'sm' ? 'width:22px;height:16px;border-radius:2px;display:inline-block;flex-shrink:0;'
: 'width:22px;height:16px;border-radius:2px;display:inline-block;';
@@ -1143,6 +1147,8 @@ window._audioPlayerUpdate = function(d) {
var url = opt.dataset.langUrl;
var flag = opt.dataset.langFlag;
var curTime = audio.currentTime;
// Track the selected version so "Share" links to this exact language (0 = primary).
window._ytpTrackId = parseInt(opt.dataset.langId, 10) || 0;
langPopup.querySelectorAll('.ytp-lang-option').forEach(function(o) { o.classList.remove('active'); });
opt.classList.add('active');
langPopup.classList.remove('open');
@@ -32,7 +32,10 @@
.vdb-desc-text a.action-btn { display:inline-flex; margin:4px 6px 4px 0; color:inherit; text-decoration:none; vertical-align:middle; }
.vdb-desc-text.vdb-clamp { max-height:130px; overflow:hidden; -webkit-mask-image:linear-gradient(180deg,#000 70%,transparent); mask-image:linear-gradient(180deg,#000 70%,transparent); }
.vdb-desc-text.vdb-clamp.vdb-expanded { max-height:none; -webkit-mask-image:none; mask-image:none; }
.vdb-show-more { background:none; border:none; color:var(--text-primary); font-weight:700; font-size:13px; cursor:pointer; padding:6px 0 0; }
.vdb-show-more { display:flex; align-items:center; justify-content:center; gap:6px; margin:12px auto 0; background:var(--bg-secondary); border:1px solid var(--border-color); color:var(--text-primary); font-weight:700; font-size:13px; cursor:pointer; padding:7px 16px; border-radius:18px; transition:background .15s ease, border-color .15s ease; }
.vdb-show-more:hover { background:var(--bg-hover, rgba(127,127,127,.12)); }
.vdb-show-more i { font-size:14px; transition:transform .2s ease; }
.vdb-show-more.expanded i { transform:rotate(180deg); }
</style>
<div class="vdb-wrap" id="vdbWrap">
@@ -58,7 +61,7 @@
{!! $descriptionSlot !!}
@elseif($renderedDescription !== '')
<div id="vdbDescShort" class="vdb-desc-text vdb-clamp">{!! $renderedDescription !!}</div>
<button id="vdbShowMore" class="vdb-show-more" style="display:none;" onclick="toggleVdbDesc(this)">Show more</button>
<button id="vdbShowMore" class="vdb-show-more" style="display:none;" onclick="toggleVdbDesc(this)"><span>Show more</span> <i class="bi bi-chevron-down"></i></button>
@else
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
@endif
@@ -70,8 +73,24 @@
</div>
<script>
// Remember which tab the user opened so SPA navigation between videos keeps it active.
window._vdbActiveTab = window._vdbActiveTab || 'vdb-about';
// Scroll back up to the video player on every SPA video-to-video swap.
// On mobile the window is locked (see CLAUDE.md mobile scroll model) and `.yt-main`
// (id="main") is the real scroll container, so we scroll that instead.
window._spaScrollToVideo = window._spaScrollToVideo || function () {
const main = document.getElementById('main');
if (window.innerWidth <= 768 && main) {
main.scrollTo({ top: 0, behavior: 'smooth' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
// ── Tab switching ──────────────────────────────────────
function switchVdbTab(panelId, btn) {
window._vdbActiveTab = panelId;
document.querySelectorAll('.vdb-tab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.vdb-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
@@ -82,11 +101,44 @@ function switchVdbTab(panelId, btn) {
if (currentUrl && currentUrl !== window._insLoadedUrl) loadInsights();
}
}
// Re-apply the remembered tab after an SPA swap replaces #vdbWrap's contents.
// If the remembered tab doesn't exist on the new video (e.g. Insights when the
// viewer isn't the owner), fall back to About so nothing is left blank.
function _vdbApplyActiveTab() {
const wrap = document.getElementById('vdbWrap');
if (!wrap) return;
let target = window._vdbActiveTab || 'vdb-about';
let btn = wrap.querySelector('.vdb-tab[data-panel="' + target + '"]');
if (!btn) { target = 'vdb-about'; btn = wrap.querySelector('.vdb-tab[data-panel="vdb-about"]'); }
if (!btn) return;
wrap.querySelectorAll('.vdb-tab').forEach(b => b.classList.remove('active'));
wrap.querySelectorAll('.vdb-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
const panel = document.getElementById(target);
if (panel) panel.classList.add('active');
if (target === 'vdb-insights') {
const ip = document.getElementById('vdb-insights');
const currentUrl = ip && ip.dataset.insightsBase;
if (currentUrl && currentUrl !== window._insLoadedUrl && typeof loadInsights === 'function') loadInsights();
}
}
// Observe #vdbWrap so the active tab is re-applied whenever the SPA layer
// rewrites its innerHTML during a video-to-video transition.
(function _vdbWatchSwaps() {
const wrap = document.getElementById('vdbWrap');
if (!wrap || wrap._vdbTabObserver) return;
const obs = new MutationObserver(() => _vdbApplyActiveTab());
obs.observe(wrap, { childList: true, subtree: false });
wrap._vdbTabObserver = obs;
})();
function toggleVdbDesc(btn) {
const d = document.getElementById('vdbDescShort');
if (!d) return;
const expanded = d.classList.toggle('vdb-expanded');
btn.textContent = expanded ? 'Show less' : 'Show more';
btn.classList.toggle('expanded', expanded);
const label = btn.querySelector('span');
if (label) label.textContent = expanded ? 'Show less' : 'Show more';
}
// Reveal "Show more" only when the description overflows the clamp. Compare the
// natural content height to the clamp limit (130px) rather than clientHeight,
@@ -94,8 +146,8 @@ function toggleVdbDesc(btn) {
function _vdbCheckOverflow() {
const d = document.getElementById('vdbDescShort'), b = document.getElementById('vdbShowMore');
if (!d || !b) return;
if (d.classList.contains('vdb-expanded')) { b.style.display = 'block'; return; }
b.style.display = (d.scrollHeight > 138) ? 'block' : 'none';
if (d.classList.contains('vdb-expanded')) { b.style.display = 'flex'; return; }
b.style.display = (d.scrollHeight > 138) ? 'flex' : 'none';
}
document.addEventListener('DOMContentLoaded', _vdbCheckOverflow);
window.addEventListener('load', _vdbCheckOverflow);
@@ -17,7 +17,9 @@
<div class="channel-row" style="display: flex; align-items: center; justify-content: space-between; padding: 6px 0; flex-wrap: wrap; gap: 12px;">
{{-- Left: Channel Info --}}
<div style="display: flex; align-items: center; gap: 12px;">
<a href="{{ $video->user ? route('channel', $video->user->channel) : '#' }}" class="channel-info text-decoration-none" style="color: inherit; display: flex; align-items: center; gap: 12px;">
<a href="{{ $video->user ? route('channel', $video->user->channel) : '#' }}" class="channel-info text-decoration-none"
@if($video->user) data-profile-visit-url="{{ route('profile.visit', $video->user) }}" data-source-video-id="{{ $video->id }}" @endif
style="color: inherit; display: flex; align-items: center; gap: 12px;">
@if($video->user)
<img src="{{ $video->user->avatar_url }}" class="channel-avatar" style="width: 36px; height: 36px;" alt="{{ $video->user->name }}">
@else
@@ -69,9 +71,9 @@
{{-- Share Button --}}
@if($video->isShareable())
<button class="action-btn" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
<x-share-button :video="$video" class="action-btn">
<i class="bi bi-share"></i> <span>Share</span>
</button>
</x-share-button>
@endif
</div>
</div>