Add NAS file manager integration and all pending platform changes

- Installed p7h/nas-file-manager package via private VCS repo
- Published config/nas-file-manager.php with super_admin middleware restriction
- Added NAS env vars to .env.example
- Created admin/nas-storage page with connection info panel and file browser widget
- Added NAS Storage link to admin sidebar (super_admin only)
- Added SuperAdminController@nasStorage method and admin.nas-storage route
- Includes all accumulated branch changes: profile wall, 2FA, audit logs,
  settings panel, country/phone/timezone components, posts, slideshow,
  playlist shares, video downloads/shares, comment likes, notifications,
  social links, and more

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
ghassanandClaude Sonnet 4.6 committed 2026-05-13 13:24:32 +03:00
1 parent d44490dfe0
commit 0b2e95ea65
161 files changed
+27496 -7250

No files matched your search

@@ -1,6 +1,6 @@
<div class="channel-row">
<div style="display: flex; align-items: center; gap: 12px;">
<a href="{{ route('channel', $video->user_id) }}" class="channel-info text-decoration-none"
<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;">
@if ($video->user)
<img src="{{ $video->user->avatar_url }}" class="channel-avatar" alt="{{ $video->user->name }}">
@@ -9,7 +9,9 @@
@endif
<div>
<div class="channel-name">{{ $video->user->name ?? 'Unknown' }}</div>
<div class="channel-subs">{{ number_format($video->user->subscriber_count ?? 0) }} subscribers</div>
<div class="channel-subs"
data-channel-id="{{ $video->user_id }}"
data-count="{{ $video->user->subscriber_count ?? 0 }}">{{ number_format($video->user->subscriber_count ?? 0) }} subscribers</div>
</div>
</a>
</div>
@@ -52,13 +54,30 @@
/* Responsive */
@media (max-width: 576px) {
.channel-row {
flex-direction: column;
align-items: flex-start !important;
gap: 12px;
flex-direction: row;
align-items: center;
flex-wrap: nowrap;
gap: 8px;
}
.channel-row > div:first-child {
flex: 1;
min-width: 0;
}
.channel-info {
width: 100%;
min-width: 0;
}
.channel-name {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.channel-subs {
white-space: nowrap;
}
}
</style>
@@ -0,0 +1,234 @@
@props([
'name' => '',
'id' => null,
'value' => null,
'label' => null,
'placeholder' => 'Select country',
'required' => false,
'class' => '',
'style' => '',
'autoGeo' => true,
])
@php
use App\Data\Countries;
$options = Countries::forCountry();
$uid = 'csd_' . ($id ?? $name) . '_' . substr(md5(uniqid()), 0, 8);
$inputId = $id ?? $name;
$selFlag = '🌐';
$selLabel = $placeholder;
if ($value) {
foreach ($options as $opt) {
if ($opt['value'] === $value) {
$selFlag = $opt['flag'];
$selLabel = $opt['label'];
break;
}
}
}
$isPlaceholder = !$value;
// Embed geo map only when no value is pre-set and auto-detection is enabled
$geoMapJson = ($autoGeo && !$value) ? json_encode(Countries::forGeoTimezoneMap()) : null;
@endphp
@once('csd-styles')
<style>
.csd-wrap { position: relative; }
.csd-lbl { display: block; margin-bottom: 6px; font-weight: 500; font-size: 14px; color: var(--text-primary, #f1f1f1); }
.csd-lbl .req { color: var(--brand-red, #e61e1e); margin-left: 2px; }
.csd-btn {
display: flex; align-items: center; gap: 8px; width: 100%;
background: var(--bg-dark, #0f0f0f); border: 1px solid var(--border-color, #303030);
border-radius: 8px; padding: 10px 14px; color: var(--text-primary, #f1f1f1);
font-size: 14px; font-family: inherit; line-height: 1.4; cursor: pointer;
text-align: left; transition: border-color .2s; outline: none; min-height: 46px; box-sizing: border-box;
}
.csd-btn:hover, .csd-btn:focus-visible { border-color: var(--brand-red, #e61e1e); }
.csd-btn[aria-expanded="true"] { border-color: var(--brand-red, #e61e1e); }
.csd-ico { font-size: 18px; line-height: 1; flex-shrink: 0; user-select: none; }
.csd-val { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.csd-val.ph { color: var(--text-secondary, #aaa); }
.csd-sub { font-size: 12px; color: var(--text-secondary, #aaa); white-space: nowrap; flex-shrink: 0; }
.csd-arr { flex-shrink: 0; font-size: 11px; color: var(--text-secondary, #aaa); margin-left: 4px; transition: transform .2s; }
.csd-btn[aria-expanded="true"] .csd-arr { transform: rotate(180deg); }
.csd-panel {
position: fixed; z-index: 1060; min-width: 220px;
background: var(--bg-secondary, #1e1e1e); border: 1px solid var(--border-color, #303030);
border-radius: 10px; box-shadow: 0 12px 32px rgba(0,0,0,.55); overflow: hidden;
}
.csd-srch { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border-color, #303030); }
.csd-srch i { font-size: 13px; color: var(--text-secondary, #aaa); flex-shrink: 0; }
.csd-sinput { flex: 1; background: none; border: none; outline: none; color: var(--text-primary, #f1f1f1); font-size: 13px; font-family: inherit; }
.csd-sinput::placeholder { color: var(--text-secondary, #aaa); }
.csd-list { list-style: none; margin: 0; padding: 4px 0; max-height: 240px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--border-color, #303030) transparent; }
.csd-list::-webkit-scrollbar { width: 4px; }
.csd-list::-webkit-scrollbar-thumb { background: var(--border-color, #303030); border-radius: 2px; }
.csd-opt { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; font-size: 13px; color: var(--text-primary, #f1f1f1); transition: background .1s; outline: none; }
.csd-opt:hover, .csd-opt:focus { background: rgba(255,255,255,.07); }
.csd-opt[aria-selected="true"] { background: rgba(255,255,255,.05); }
.csd-opt-ico { font-size: 17px; line-height: 1; flex-shrink: 0; user-select: none; }
.csd-opt-main { font-weight: 500; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.csd-opt-sub { font-size: 12px; color: var(--text-secondary, #aaa); white-space: nowrap; flex-shrink: 0; max-width: 45%; overflow: hidden; text-overflow: ellipsis; }
.csd-empty { padding: 12px; font-size: 13px; color: var(--text-secondary, #aaa); text-align: center; }
</style>
@endonce
@once('csd-script')
<script>
(function () {
if (window.CSD) return;
window.CSD = class {
constructor(id) {
this.root = document.getElementById(id);
this.btn = this.root.querySelector('.csd-btn');
this.panel = this.root.querySelector('.csd-panel');
this.sinput = this.root.querySelector('.csd-sinput');
this.list = this.root.querySelector('.csd-list');
this.hidden = this.root.querySelector('input[type=hidden]');
this.icoEl = this.btn.querySelector('.csd-ico');
this.valEl = this.btn.querySelector('.csd-val');
this.subEl = this.btn.querySelector('.csd-sub');
this.emptyEl= this.root.querySelector('.csd-empty');
this._bind();
if (this.root.dataset.geoMap && !this.hidden.value) {
this._autoGeo();
}
}
_bind() {
this.btn.addEventListener('click', e => { e.stopPropagation(); this.toggle(); });
this.sinput.addEventListener('input', () => this._filter());
this.list.addEventListener('click', e => { const o = e.target.closest('.csd-opt'); if (o) this._pick(o); });
document.addEventListener('click', e => { if (!this.root.contains(e.target)) this.close(); }, true);
this.btn.addEventListener('keydown', e => { if (e.key==='Enter'||e.key===' ') { e.preventDefault(); this.toggle(); } });
this.sinput.addEventListener('keydown', e => {
if (e.key==='Escape') { this.close(); this.btn.focus(); }
if (e.key==='ArrowDown') { e.preventDefault(); this._move(0); }
if (e.key==='Enter') { e.preventDefault(); }
});
this.list.addEventListener('keydown', e => {
const all = [...this.list.querySelectorAll('.csd-opt:not([hidden])')];
const i = all.indexOf(document.activeElement.closest('.csd-opt'));
if (e.key==='ArrowDown') { e.preventDefault(); this._move(i+1); }
if (e.key==='ArrowUp') { e.preventDefault(); i>0 ? this._move(i-1) : this.sinput.focus(); }
if (e.key==='Enter') { e.preventDefault(); if (all[i]) this._pick(all[i]); }
if (e.key==='Escape') { this.close(); this.btn.focus(); }
});
}
_autoGeo() {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const map = JSON.parse(this.root.dataset.geoMap);
const iso2 = map[tz];
if (iso2) {
const opt = this.list.querySelector('[data-v="' + iso2 + '"]');
if (opt) this._pick(opt);
}
} catch (_) {}
}
toggle() { this.panel.hidden ? this.open() : this.close(); }
open() {
document.querySelectorAll('.csd-panel:not([hidden])').forEach(p => { p.hidden = true; });
document.querySelectorAll('.csd-btn[aria-expanded=true]').forEach(b => b.setAttribute('aria-expanded','false'));
this.panel.hidden = false;
this.btn.setAttribute('aria-expanded','true');
this.sinput.value = '';
this._filter();
requestAnimationFrame(() => this.sinput.focus());
const r = this.btn.getBoundingClientRect();
const goUp = window.innerHeight - r.bottom < 280 && r.top > 280;
this.panel.style.left = r.left + 'px';
this.panel.style.width = Math.max(r.width, 220) + 'px';
if (goUp) {
this.panel.style.top = '';
this.panel.style.bottom = (window.innerHeight - r.top + 4) + 'px';
} else {
this.panel.style.top = (r.bottom + 4) + 'px';
this.panel.style.bottom = '';
}
}
close() { this.panel.hidden=true; this.btn.setAttribute('aria-expanded','false'); }
_filter() {
const q = this.sinput.value.toLowerCase(); let n=0;
this.list.querySelectorAll('.csd-opt').forEach(o => {
const show = o.dataset.s.includes(q);
o.style.display = show ? '' : 'none';
if (show) n++;
});
if (this.emptyEl) this.emptyEl.style.display = n > 0 ? 'none' : '';
}
_move(i) { const all=[...this.list.querySelectorAll('.csd-opt:not([hidden])')]; if(all[i]){all[i].tabIndex=0;all[i].focus();} }
_pick(opt) {
this.hidden.value = opt.dataset.v;
this.icoEl.textContent = opt.querySelector('.csd-opt-ico').textContent;
this.valEl.textContent = opt.querySelector('.csd-opt-main').textContent;
this.valEl.classList.remove('ph');
if (this.subEl) { const s=opt.querySelector('.csd-opt-sub'); this.subEl.textContent=s?s.textContent:''; }
this.list.querySelectorAll('.csd-opt').forEach(o => o.setAttribute('aria-selected', o===opt?'true':'false'));
this.close(); this.btn.focus();
this.hidden.dispatchEvent(new Event('change',{bubbles:true}));
}
};
}());
</script>
@endonce
<div class="csd-wrap {{ $class }}" id="{{ $uid }}"
@if($style) style="{{ $style }}" @endif
@if($geoMapJson) data-geo-map="{{ $geoMapJson }}" @endif>
@if($label)
<label class="csd-lbl" for="{{ $inputId }}">
{{ $label }}@if($required)<span class="req">*</span>@endif
</label>
@endif
<button type="button"
class="csd-btn"
aria-haspopup="listbox"
aria-expanded="false"
aria-label="{{ $label ?? 'Select country' }}">
<span class="csd-ico">{{ $selFlag }}</span>
<span class="csd-val{{ $isPlaceholder ? ' ph' : '' }}">{{ $selLabel }}</span>
<i class="bi bi-chevron-down csd-arr"></i>
</button>
<div class="csd-panel" hidden role="listbox">
<div class="csd-srch">
<i class="bi bi-search"></i>
<input class="csd-sinput" type="text" placeholder="Search country…" autocomplete="off" spellcheck="false">
</div>
<ul class="csd-list">
@foreach($options as $opt)
<li class="csd-opt"
role="option"
tabindex="-1"
data-v="{{ $opt['value'] }}"
data-s="{{ $opt['search'] }}"
aria-selected="{{ $value === $opt['value'] ? 'true' : 'false' }}">
<span class="csd-opt-ico">{{ $opt['flag'] }}</span>
<span class="csd-opt-main">{{ $opt['label'] }}</span>
</li>
@endforeach
</ul>
<p class="csd-empty" hidden>No results</p>
</div>
<input type="hidden"
name="{{ $name }}"
id="{{ $inputId }}"
value="{{ $value }}"
@if($required) required @endif>
</div>
<script>
(function () {
function boot() { new CSD('{{ $uid }}'); }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}());
</script>
@@ -0,0 +1,416 @@
@props([
'name' => '',
'id' => null,
'value' => null,
'label' => null,
'required' => false,
'class' => '',
'style' => '',
'minYear' => 1900,
'maxYear' => null,
])
@php
$maxYear = (int) ($maxYear ?? date('Y'));
$minYear = (int) $minYear;
$uid = 'dp_' . ($id ?? $name) . '_' . substr(md5(uniqid()), 0, 8);
$inputId = $id ?? $name;
// Parse YYYY-MM-DD initial value
$initDay = $initMonth = $initYear = null;
if ($value && preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $m)) {
[, $initYear, $initMonth, $initDay] = array_map('intval', $m);
}
$months = ['January','February','March','April','May','June',
'July','August','September','October','November','December'];
@endphp
{{-- ── Shared CSS ────────────────────────────────────────────────────── --}}
@once('dp-styles')
<style>
.dp-wrap { position: relative; }
.dp-lbl { display: block; margin-bottom: 6px; font-weight: 500; font-size: 14px; color: var(--text-primary, #f1f1f1); }
.dp-lbl .req { color: var(--brand-red, #e61e1e); margin-left: 2px; }
.dp-row { display: flex; gap: 8px; }
/* Each column */
.dp-field { position: relative; flex: 1; min-width: 0; }
.dp-field.dp-day { flex: 0 0 72px; }
.dp-field.dp-year { flex: 0 0 98px; }
/* Trigger button — matches .form-input style */
.dp-btn {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
width: 100%;
background: var(--bg-dark, #0f0f0f);
border: 1px solid var(--border-color, #303030);
border-radius: 8px;
padding: 10px 10px;
color: var(--text-primary, #f1f1f1);
font-size: 14px;
font-family: inherit;
line-height: 1.4;
cursor: pointer;
text-align: left;
transition: border-color .2s;
outline: none;
min-height: 46px;
box-sizing: border-box;
white-space: nowrap;
overflow: hidden;
}
.dp-btn:hover,
.dp-btn:focus-visible { border-color: var(--brand-red, #e61e1e); }
.dp-btn[aria-expanded="true"] { border-color: var(--brand-red, #e61e1e); }
.dp-val { flex: 1; overflow: hidden; text-overflow: ellipsis; }
.dp-val.ph { color: var(--text-secondary, #aaa); }
.dp-arr { flex-shrink: 0; font-size: 10px; color: var(--text-secondary, #aaa); transition: transform .2s; }
.dp-btn[aria-expanded="true"] .dp-arr { transform: rotate(180deg); }
/* Dropdown panel */
.dp-panel {
position: fixed;
z-index: 1060;
background: var(--bg-secondary, #1e1e1e);
border: 1px solid var(--border-color, #303030);
border-radius: 10px;
box-shadow: 0 12px 32px rgba(0,0,0,.55);
overflow: hidden;
}
/* Year search bar */
.dp-srch {
display: flex;
align-items: center;
gap: 7px;
padding: 7px 10px;
border-bottom: 1px solid var(--border-color, #303030);
}
.dp-srch i { font-size: 12px; color: var(--text-secondary, #aaa); flex-shrink: 0; }
.dp-sinput {
flex: 1; width: 100%;
background: none; border: none; outline: none;
color: var(--text-primary, #f1f1f1);
font-size: 13px; font-family: inherit;
}
.dp-sinput::placeholder { color: var(--text-secondary, #aaa); }
/* Shared list */
.dp-list {
list-style: none;
margin: 0; padding: 4px 0;
max-height: 220px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-color, #303030) transparent;
}
.dp-list::-webkit-scrollbar { width: 4px; }
.dp-list::-webkit-scrollbar-thumb { background: var(--border-color, #303030); border-radius: 2px; }
/* Month / Year options (list style) */
.dp-list-opt {
display: flex;
align-items: center;
padding: 8px 13px;
cursor: pointer;
font-size: 13px;
color: var(--text-primary, #f1f1f1);
transition: background .1s;
outline: none;
}
.dp-list-opt:hover { background: rgba(255,255,255,.07); }
.dp-list-opt[aria-selected="true"] {
background: rgba(230,30,30,.12);
color: var(--brand-red, #e61e1e);
font-weight: 600;
}
/* Day grid */
.dp-day-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 3px;
padding: 8px;
max-height: none;
overflow: visible;
}
.dp-day-opt {
display: flex;
align-items: center;
justify-content: center;
padding: 7px 2px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: var(--text-primary, #f1f1f1);
transition: background .1s;
outline: none;
user-select: none;
}
.dp-day-opt:hover { background: rgba(255,255,255,.1); }
.dp-day-opt[aria-selected="true"] {
background: var(--brand-red, #e61e1e);
color: #fff;
font-weight: 700;
}
.dp-day-opt[hidden] { display: none; }
.dp-list-opt[hidden] { display: none; }
</style>
@endonce
{{-- ── Shared JS ─────────────────────────────────────────────────────── --}}
@once('dp-script')
<script>
(function () {
if (window.DatePicker) return;
window.DatePicker = class {
constructor(id) {
this.root = document.getElementById(id);
this.hidden = this.root.querySelector('input[type=hidden]');
this.f = {
day: this.root.querySelector('.dp-day'),
month: this.root.querySelector('.dp-month'),
year: this.root.querySelector('.dp-year'),
};
this.v = { day: null, month: null, year: null };
// Restore from hidden value
const raw = this.hidden.value;
if (raw && /^\d{4}-\d{2}-\d{2}$/.test(raw)) {
const [y, m, d] = raw.split('-').map(Number);
this.v = { year: y, month: m, day: d };
}
this._bind('day');
this._bind('month');
this._bind('year');
// Apply initial day constraints
if (this.v.month) this._constrainDays();
document.addEventListener('click', e => {
if (!this.root.contains(e.target)) this._closeAll();
}, true);
}
_bind(type) {
const field = this.f[type];
const btn = field.querySelector('.dp-btn');
const panel = field.querySelector('.dp-panel');
const list = field.querySelector('[data-dp-list]');
const sinput = field.querySelector('.dp-sinput');
btn.addEventListener('click', e => {
e.stopPropagation();
const wasOpen = !panel.hidden;
this._closeAll();
if (wasOpen) return;
panel.hidden = false;
btn.setAttribute('aria-expanded', 'true');
if (sinput) {
sinput.value = '';
this._filterYear(list, '');
requestAnimationFrame(() => sinput.focus());
}
// Scroll selected option into view
requestAnimationFrame(() => {
const sel = list.querySelector('[aria-selected="true"]');
if (sel) sel.scrollIntoView({ block: 'nearest' });
});
// Position panel using fixed coords to escape any overflow container
const r = btn.getBoundingClientRect();
const minW = type === 'day' ? 196 : r.width;
const goUp = window.innerHeight - r.bottom < 270 && r.top > 270;
panel.style.left = r.left + 'px';
panel.style.width = Math.max(r.width, minW) + 'px';
if (goUp) {
panel.style.top = '';
panel.style.bottom = (window.innerHeight - r.top + 4) + 'px';
} else {
panel.style.top = (r.bottom + 4) + 'px';
panel.style.bottom = '';
}
});
list.addEventListener('click', e => {
const opt = e.target.closest('[data-v]');
if (opt && !opt.hidden) this._pick(type, parseInt(opt.dataset.v));
});
if (sinput) {
sinput.addEventListener('input', () => this._filterYear(list, sinput.value));
sinput.addEventListener('keydown', e => {
if (e.key === 'Escape') this._closeAll();
});
}
}
_pick(type, val) {
const field = this.f[type];
const valEl = field.querySelector('.dp-val');
const list = field.querySelector('[data-dp-list]');
const MONTHS = ['January','February','March','April','May','June',
'July','August','September','October','November','December'];
this.v[type] = val;
valEl.textContent = type === 'month' ? MONTHS[val - 1] : String(val);
valEl.classList.remove('ph');
list.querySelectorAll('[data-v]').forEach(o =>
o.setAttribute('aria-selected', parseInt(o.dataset.v) === val ? 'true' : 'false')
);
this._closeAll();
if (type === 'month' || type === 'year') this._constrainDays();
this._flush();
}
_constrainDays() {
const m = this.v.month || 1;
const y = this.v.year || 2000;
const max = new Date(y, m, 0).getDate(); // last day of month
const list = this.f.day.querySelector('[data-dp-list]');
list.querySelectorAll('[data-v]').forEach(o => {
const d = parseInt(o.dataset.v);
o.hidden = d > max;
if (d > max && this.v.day === d) {
this.v.day = null;
const valEl = this.f.day.querySelector('.dp-val');
valEl.textContent = 'Day';
valEl.classList.add('ph');
o.setAttribute('aria-selected', 'false');
}
});
}
_filterYear(list, q) {
list.querySelectorAll('[data-v]').forEach(o => {
o.style.display = (q.length > 0 && !o.dataset.v.startsWith(q)) ? 'none' : '';
});
}
_closeAll() {
this.root.querySelectorAll('.dp-panel').forEach(p => { p.hidden = true; });
this.root.querySelectorAll('.dp-btn').forEach(b => b.setAttribute('aria-expanded', 'false'));
}
_flush() {
const { day, month, year } = this.v;
if (day && month && year) {
this.hidden.value =
`${year}-${String(month).padStart(2,'0')}-${String(day).padStart(2,'0')}`;
} else {
this.hidden.value = '';
}
this.hidden.dispatchEvent(new Event('change', { bubbles: true }));
}
};
}());
</script>
@endonce
{{-- ── Component HTML ─────────────────────────────────────────────────── --}}
<div class="dp-wrap {{ $class }}" id="{{ $uid }}" @if($style) style="{{ $style }}" @endif>
@if($label)
<label class="dp-lbl" for="{{ $inputId }}">
{{ $label }}@if($required)<span class="req">*</span>@endif
</label>
@endif
<div class="dp-row">
{{-- ── Day ──────────────────────────────────────────────────────── --}}
<div class="dp-field dp-day">
<button type="button" class="dp-btn" aria-expanded="false" aria-label="Day">
<span class="dp-val{{ !$initDay ? ' ph' : '' }}">{{ $initDay ?? 'Day' }}</span>
<i class="bi bi-chevron-down dp-arr"></i>
</button>
<div class="dp-panel" hidden>
<ul class="dp-list dp-day-grid" data-dp-list>
@for($d = 1; $d <= 31; $d++)
<li class="dp-day-opt"
data-v="{{ $d }}"
aria-selected="{{ $initDay === $d ? 'true' : 'false' }}"
role="option">{{ $d }}</li>
@endfor
</ul>
</div>
</div>
{{-- ── Month ─────────────────────────────────────────────────────── --}}
<div class="dp-field dp-month">
<button type="button" class="dp-btn" aria-expanded="false" aria-label="Month">
<span class="dp-val{{ !$initMonth ? ' ph' : '' }}">
{{ $initMonth ? $months[$initMonth - 1] : 'Month' }}
</span>
<i class="bi bi-chevron-down dp-arr"></i>
</button>
<div class="dp-panel" hidden>
<ul class="dp-list" data-dp-list>
@foreach($months as $mi => $mname)
<li class="dp-list-opt"
data-v="{{ $mi + 1 }}"
aria-selected="{{ $initMonth === ($mi + 1) ? 'true' : 'false' }}"
role="option">{{ $mname }}</li>
@endforeach
</ul>
</div>
</div>
{{-- ── Year ──────────────────────────────────────────────────────── --}}
<div class="dp-field dp-year">
<button type="button" class="dp-btn" aria-expanded="false" aria-label="Year">
<span class="dp-val{{ !$initYear ? ' ph' : '' }}">{{ $initYear ?? 'Year' }}</span>
<i class="bi bi-chevron-down dp-arr"></i>
</button>
<div class="dp-panel" hidden>
<div class="dp-srch">
<i class="bi bi-search"></i>
<input type="text" class="dp-sinput" placeholder="Year…" autocomplete="off" spellcheck="false">
</div>
<ul class="dp-list" data-dp-list>
@for($y = $maxYear; $y >= $minYear; $y--)
<li class="dp-list-opt"
data-v="{{ $y }}"
aria-selected="{{ $initYear === $y ? 'true' : 'false' }}"
role="option">{{ $y }}</li>
@endfor
</ul>
</div>
</div>
</div>{{-- .dp-row --}}
<input type="hidden"
name="{{ $name }}"
id="{{ $inputId }}"
value="{{ $value }}"
@if($required) required @endif>
</div>{{-- .dp-wrap --}}
<script>
(function () {
function boot() { new DatePicker('{{ $uid }}'); }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}());
</script>
@@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{ $subject ?? config('app.name') }}</title>
<style>
/* Reset */
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
table, td { mso-table-lspace: 0; mso-table-rspace: 0; }
img { -ms-interpolation-mode: bicubic; border: 0; outline: none; text-decoration: none; }
body { margin: 0; padding: 0; background-color: #0f0f0f; width: 100% !important; }
.wrapper { background-color: #0f0f0f; padding: 40px 16px; }
.email-card {
max-width: 600px;
margin: 0 auto;
background: #181818;
border-radius: 16px;
overflow: hidden;
border: 1px solid #2a2a2a;
box-shadow: 0 24px 64px rgba(0,0,0,0.6);
}
/* Header */
.email-header {
background: #0f0f0f;
border-bottom: 1px solid #2a2a2a;
padding: 28px 40px;
text-align: center;
}
.email-header img { height: 36px; display: block; margin: 0 auto; }
/* Body */
.email-body { padding: 40px; }
.email-icon {
width: 64px;
height: 64px;
border-radius: 50%;
margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
}
h1.email-title {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 22px;
font-weight: 700;
color: #f1f1f1;
margin: 0 0 8px;
text-align: center;
line-height: 1.3;
}
p.email-subtitle {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
color: #888;
text-align: center;
margin: 0 0 32px;
line-height: 1.6;
}
p.email-text {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
color: #c8c8c8;
margin: 0 0 20px;
line-height: 1.7;
}
/* CTA Button */
.email-btn-wrap { text-align: center; margin: 32px 0; }
.email-btn {
display: inline-block;
background: #e61e1e;
color: #ffffff !important;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
font-weight: 700;
text-decoration: none;
padding: 14px 36px;
border-radius: 50px;
letter-spacing: 0.3px;
box-shadow: 0 8px 24px rgba(230,30,30,0.35);
}
.email-btn:hover { background: #c91a1a; }
/* Info box */
.email-infobox {
background: #222;
border: 1px solid #2d2d2d;
border-radius: 12px;
padding: 20px 24px;
margin: 24px 0;
}
.email-infobox-label {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #666;
margin: 0 0 12px;
}
.email-inforow {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 10px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.email-inforow:last-child { margin-bottom: 0; }
.email-inforow-key {
font-size: 13px;
color: #666;
min-width: 90px;
flex-shrink: 0;
}
.email-inforow-val {
font-size: 13px;
color: #c8c8c8;
font-weight: 500;
word-break: break-all;
}
/* Divider */
.email-divider {
border: none;
border-top: 1px solid #2a2a2a;
margin: 32px 0;
}
/* Small note */
.email-note {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
color: #555;
text-align: center;
line-height: 1.7;
margin: 0;
}
.email-note a { color: #888; text-decoration: underline; }
/* Footer */
.email-footer {
background: #0f0f0f;
border-top: 1px solid #2a2a2a;
padding: 24px 40px;
text-align: center;
}
.email-footer p {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
color: #444;
margin: 4px 0;
line-height: 1.6;
}
.email-footer a { color: #666; text-decoration: none; }
.email-footer a:hover { color: #e61e1e; }
/* Thumbnail */
.email-thumb-wrap { margin: 0 0 28px; text-align: center; }
.email-thumb-wrap a { display: block; }
.email-thumb-wrap img {
width: 100%;
max-width: 520px;
height: auto;
border-radius: 12px;
display: block;
margin: 0 auto;
border: 1px solid #2a2a2a;
}
.email-thumb-placeholder {
width: 100%;
max-width: 520px;
height: 190px;
background: linear-gradient(135deg, #1a1a1a 0%, #2a0808 100%);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
border: 1px solid #2a2a2a;
flex-direction: column;
gap: 10px;
}
.email-thumb-placeholder span {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
font-weight: 600;
color: #888;
}
@media only screen and (max-width: 600px) {
.wrapper { padding: 20px 10px; }
.email-body { padding: 28px 24px; }
.email-header { padding: 22px 24px; }
.email-footer { padding: 20px 24px; }
h1.email-title { font-size: 20px; }
}
</style>
</head>
<body>
<div class="wrapper">
<div class="email-card">
{{-- Header --}}
<div class="email-header">
<img src="{{ config('app.url') }}/storage/images/fullLogo.png" alt="{{ config('app.name') }}">
</div>
{{-- Body --}}
<div class="email-body">
{{ $slot }}
</div>
{{-- Footer --}}
<div class="email-footer">
<p>&copy; {{ date('Y') }} <strong style="color:#666;">TAKEONE</strong> &mdash; Video Platform</p>
<p><a href="{{ config('app.url') }}">{{ config('app.url') }}</a></p>
<p style="margin-top:8px;">This email was sent to you because you have an account on TAKEONE.</p>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,172 @@
@props([
'name' => 'gender',
'id' => null,
'value' => null,
'label' => null,
'required' => false,
'class' => '',
'style' => '',
])
@php
$uid = 'gsd_' . ($id ?? $name) . '_' . substr(md5(uniqid()), 0, 8);
$inputId = $id ?? $name;
$options = [
['value' => 'male', 'symbol' => '♂', 'label' => 'Male', 'color' => '#4a9eff'],
['value' => 'female', 'symbol' => '♀', 'label' => 'Female', 'color' => '#ff6eb0'],
];
$selected = null;
$placeholder = 'Select gender';
foreach ($options as $opt) {
if ($opt['value'] === $value) { $selected = $opt; break; }
}
$isPlaceholder = !$selected;
@endphp
@once('gsd-styles')
<style>
.gsd-wrap { position: relative; }
.gsd-lbl { display: block; margin-bottom: 6px; font-weight: 500; font-size: 14px; color: var(--text-primary, #f1f1f1); }
.gsd-lbl .req { color: var(--brand-red, #e61e1e); margin-left: 2px; }
.gsd-btn {
display: flex; align-items: center; gap: 10px; width: 100%;
background: var(--bg-dark, #0f0f0f); border: 1px solid var(--border-color, #303030);
border-radius: 8px; padding: 10px 14px; color: var(--text-primary, #f1f1f1);
font-size: 14px; font-family: inherit; cursor: pointer; text-align: left;
transition: border-color .2s; outline: none; min-height: 46px; box-sizing: border-box;
}
.gsd-btn:hover, .gsd-btn:focus-visible { border-color: var(--brand-red, #e61e1e); }
.gsd-btn[aria-expanded="true"] { border-color: var(--brand-red, #e61e1e); }
.gsd-sym { font-size: 20px; line-height: 1; flex-shrink: 0; user-select: none; }
.gsd-val { flex: 1; }
.gsd-val.ph { color: var(--text-secondary, #aaa); }
.gsd-arr { flex-shrink: 0; font-size: 11px; color: var(--text-secondary, #aaa); margin-left: auto; transition: transform .2s; }
.gsd-btn[aria-expanded="true"] .gsd-arr { transform: rotate(180deg); }
.gsd-panel {
position: fixed; z-index: 1060; min-width: 180px;
background: var(--bg-secondary, #1e1e1e); border: 1px solid var(--border-color, #303030);
border-radius: 10px; box-shadow: 0 12px 32px rgba(0,0,0,.55); overflow: hidden;
padding: 4px 0;
}
.gsd-opt {
display: flex; align-items: center; gap: 12px; padding: 10px 14px;
cursor: pointer; font-size: 14px; color: var(--text-primary, #f1f1f1);
transition: background .1s; outline: none; border: none; background: none;
width: 100%; text-align: left; font-family: inherit;
}
.gsd-opt:hover, .gsd-opt:focus { background: rgba(255,255,255,.07); }
.gsd-opt[aria-selected="true"] { background: rgba(255,255,255,.05); }
.gsd-opt-sym { font-size: 22px; line-height: 1; user-select: none; }
.gsd-opt-lbl { font-weight: 500; }
</style>
@endonce
@if($label)
<div class="gsd-wrap {{ $class }}" id="{{ $uid }}" @if($style) style="{{ $style }}" @endif>
<label class="gsd-lbl" for="{{ $inputId }}">
{{ $label }}@if($required)<span class="req">*</span>@endif
</label>
@else
<div class="gsd-wrap {{ $class }}" id="{{ $uid }}" @if($style) style="{{ $style }}" @endif>
@endif
<button type="button"
class="gsd-btn"
aria-haspopup="listbox"
aria-expanded="false"
aria-label="{{ $label ?? 'Select gender' }}">
@if($selected)
<span class="gsd-sym" style="color:{{ $selected['color'] }}">{{ $selected['symbol'] }}</span>
<span class="gsd-val">{{ $selected['label'] }}</span>
@else
<span class="gsd-sym" style="color:var(--text-secondary,#aaa)">⚧</span>
<span class="gsd-val ph">{{ $placeholder }}</span>
@endif
<i class="bi bi-chevron-down gsd-arr"></i>
</button>
<div class="gsd-panel" hidden role="listbox">
@foreach($options as $opt)
<button type="button"
class="gsd-opt"
role="option"
data-v="{{ $opt['value'] }}"
aria-selected="{{ $value === $opt['value'] ? 'true' : 'false' }}">
<span class="gsd-opt-sym" style="color:{{ $opt['color'] }}">{{ $opt['symbol'] }}</span>
<span class="gsd-opt-lbl">{{ $opt['label'] }}</span>
</button>
@endforeach
</div>
<input type="hidden"
name="{{ $name }}"
id="{{ $inputId }}"
value="{{ $value }}"
@if($required) required @endif>
</div>
<script>
(function () {
var uid = '{{ $uid }}';
function boot() {
var root = document.getElementById(uid);
var btn = root.querySelector('.gsd-btn');
var panel = root.querySelector('.gsd-panel');
var hidden = root.querySelector('input[type=hidden]');
var symEl = btn.querySelector('.gsd-sym');
var valEl = btn.querySelector('.gsd-val');
function open() {
document.querySelectorAll('.gsd-panel:not([hidden])').forEach(function(p) { p.hidden = true; });
document.querySelectorAll('.gsd-btn[aria-expanded=true]').forEach(function(b) { b.setAttribute('aria-expanded','false'); });
panel.hidden = false;
btn.setAttribute('aria-expanded', 'true');
var r = btn.getBoundingClientRect();
var goUp = window.innerHeight - r.bottom < 120 && r.top > 120;
panel.style.left = r.left + 'px';
panel.style.width = Math.max(r.width, 180) + 'px';
if (goUp) {
panel.style.top = '';
panel.style.bottom = (window.innerHeight - r.top + 4) + 'px';
} else {
panel.style.top = (r.bottom + 4) + 'px';
panel.style.bottom = '';
}
}
function close() { panel.hidden = true; btn.setAttribute('aria-expanded', 'false'); }
function pick(opt) {
hidden.value = opt.dataset.v;
var sym = opt.querySelector('.gsd-opt-sym');
var lbl = opt.querySelector('.gsd-opt-lbl');
symEl.textContent = sym.textContent;
symEl.style.color = sym.style.color;
valEl.textContent = lbl.textContent;
valEl.classList.remove('ph');
panel.querySelectorAll('.gsd-opt').forEach(function(o) {
o.setAttribute('aria-selected', o === opt ? 'true' : 'false');
});
close(); btn.focus();
hidden.dispatchEvent(new Event('change', { bubbles: true }));
}
btn.addEventListener('click', function(e) { e.stopPropagation(); panel.hidden ? open() : close(); });
btn.addEventListener('keydown', function(e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); panel.hidden ? open() : close(); } });
panel.addEventListener('mousedown', function(e) { e.stopPropagation(); });
panel.querySelectorAll('.gsd-opt').forEach(function(opt) {
opt.addEventListener('click', function() { pick(opt); });
opt.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(opt); }
if (e.key === 'Escape') { close(); btn.focus(); }
});
});
document.addEventListener('mousedown', function(e) { if (!root.contains(e.target)) close(); });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}());
</script>
@@ -0,0 +1,406 @@
@php
$id = $attributes->get('id', 'cropper_' . Str::random(6));
$width = (int) $attributes->get('width', 300);
$height = (int) $attributes->get('height', 300);
$shape = $attributes->get('shape', 'circle'); // 'circle' or 'square'
$folder = $attributes->get('folder', 'uploads');
$filename = $attributes->get('filename', 'image_' . time());
$callback = $attributes->get('callback', ''); // JS function called with (url) after server upload
$updateUrl = $attributes->get('update-url', ''); // POST {path} here after server upload
$title = $attributes->get('title', $shape === 'circle' ? 'Change Photo' : 'Crop Image');
$outputWidth = (int) $attributes->get('output-width', 0); // final output px width (0 = viewport size)
$targetInput = $attributes->get('target-input', ''); // form mode: ID of file input to set result on
$previewImg = $attributes->get('preview-img', ''); // ID of img to update with preview
@endphp
@once
<link rel="stylesheet" href="{{ asset('css/cropme.min.css') }}">
<script src="{{ asset('js/cropme.min.js') }}"></script>
<style>
/* ── TakeOne Cropper Modal ─────────────────────────── */
.tc-overlay {
display: none; position: fixed; inset: 0; z-index: 10100;
background: rgba(0,0,0,.82); backdrop-filter: blur(4px);
align-items: center; justify-content: center;
}
.tc-overlay.open { display: flex; animation: tcFadeIn .18s ease; }
@keyframes tcFadeIn { from { opacity: 0; } to { opacity: 1; } }
.tc-modal {
background: #141414; border: 1px solid rgba(255,255,255,.12);
border-radius: 18px; width: min(540px, 95vw);
box-shadow: 0 24px 80px rgba(0,0,0,.75);
overflow: hidden; animation: tcSlideUp .2s cubic-bezier(.34,1.3,.64,1);
}
@keyframes tcSlideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.tc-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px 14px;
border-bottom: 1px solid rgba(255,255,255,.07);
}
.tc-modal-title {
font-size: 15px; font-weight: 700; color: #fff;
display: flex; align-items: center; gap: 8px;
}
.tc-modal-title i { color: #ef4444; }
.tc-modal-close {
background: none; border: none; color: rgba(255,255,255,.45);
font-size: 20px; cursor: pointer; line-height: 1; padding: 4px 6px;
border-radius: 6px; transition: color .15s, background .15s;
}
.tc-modal-close:hover { color: #fff; background: rgba(255,255,255,.08); }
.tc-modal-body { padding: 16px 20px; }
.tc-file-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.tc-file-label {
display: inline-flex; align-items: center; gap: 7px; flex-shrink: 0;
height: 36px; padding: 0 14px; border-radius: 8px;
background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.12);
color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
transition: background .15s;
}
.tc-file-label:hover { background: rgba(255,255,255,.13); }
.tc-file-name {
font-size: 12px; color: rgba(255,255,255,.38); flex: 1;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tc-canvas {
width: 100%; height: 320px; background: #0d0d0d;
border-radius: 10px; border: 1px solid rgba(255,255,255,.07);
overflow: hidden; position: relative;
}
.tc-placeholder {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center;
color: rgba(255,255,255,.2); gap: 10px; pointer-events: none;
}
.tc-placeholder i { font-size: 42px; }
.tc-placeholder span { font-size: 13px; }
.tc-controls { display: flex; gap: 14px; margin-top: 14px; }
.tc-control { flex: 1; }
.tc-control-label {
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
color: rgba(255,255,255,.35); margin-bottom: 6px; display: flex; align-items: center; gap: 5px;
}
.tc-range {
-webkit-appearance: none; appearance: none; width: 100%; height: 3px;
background: rgba(255,255,255,.12); border-radius: 3px; outline: none; cursor: pointer;
}
.tc-range::-webkit-slider-thumb {
-webkit-appearance: none; width: 15px; height: 15px;
border-radius: 50%; background: #ef4444; cursor: pointer;
box-shadow: 0 0 0 3px rgba(239,68,68,.2); transition: box-shadow .15s;
}
.tc-range::-webkit-slider-thumb:hover { box-shadow: 0 0 0 5px rgba(239,68,68,.3); }
.tc-range::-moz-range-thumb {
width: 15px; height: 15px; border: none;
border-radius: 50%; background: #ef4444; cursor: pointer;
}
.tc-modal-footer {
padding: 12px 20px 18px; display: flex; gap: 8px; justify-content: flex-end;
border-top: 1px solid rgba(255,255,255,.07); flex-wrap: wrap;
}
.tc-btn {
display: inline-flex; align-items: center; gap: 7px;
height: 38px; padding: 0 18px; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer; border: none; font-family: inherit;
transition: background .15s, transform .1s, opacity .15s;
}
.tc-btn-ghost {
background: rgba(255,255,255,.07); color: rgba(255,255,255,.75);
border: 1px solid rgba(255,255,255,.12);
}
.tc-btn-ghost:hover { background: rgba(255,255,255,.13); color: #fff; }
.tc-btn-as-is {
background: rgba(255,255,255,.05); color: rgba(255,255,255,.55);
border: 1px solid rgba(255,255,255,.09); margin-right: auto;
}
.tc-btn-as-is:hover { background: rgba(255,255,255,.1); color: rgba(255,255,255,.85); }
.tc-btn-as-is:disabled { opacity: .3; cursor: not-allowed; }
.tc-btn-primary { background: #ef4444; color: #fff; }
.tc-btn-primary:hover:not(:disabled) { background: #dc2626; transform: translateY(-1px); }
.tc-btn-primary:disabled { opacity: .45; cursor: not-allowed; }
</style>
@endonce
{{-- Modal --}}
<div class="tc-overlay" id="tcOverlay_{{ $id }}" role="dialog" aria-modal="true">
<div class="tc-modal">
<div class="tc-modal-header">
<span class="tc-modal-title">
<i class="bi bi-crop"></i>
{{ $title }}
</span>
<button class="tc-modal-close" onclick="closeCropperModal('{{ $id }}')" aria-label="Close">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="tc-modal-body">
<div class="tc-file-row">
<label class="tc-file-label" for="tcInput_{{ $id }}">
<i class="bi bi-upload"></i>
Choose image
</label>
<span class="tc-file-name" id="tcFileName_{{ $id }}">No file chosen</span>
<input type="file" id="tcInput_{{ $id }}" accept="image/*" style="display:none" aria-label="Select image file">
</div>
<div class="tc-canvas" id="tcCanvas_{{ $id }}">
<div class="tc-placeholder" id="tcPlaceholder_{{ $id }}">
<i class="bi bi-image"></i>
<span>Upload an image to start cropping</span>
</div>
</div>
<div class="tc-controls">
<div class="tc-control">
<label class="tc-control-label" for="tcZoom_{{ $id }}">
<i class="bi bi-zoom-in"></i> Zoom
</label>
<input type="range" class="tc-range" id="tcZoom_{{ $id }}" min="0" max="100" step="1" value="0">
</div>
<div class="tc-control">
<label class="tc-control-label" for="tcRot_{{ $id }}">
<i class="bi bi-arrow-clockwise"></i> Rotate
</label>
<input type="range" class="tc-range" id="tcRot_{{ $id }}" min="-180" max="180" step="1" value="0">
</div>
</div>
</div>
<div class="tc-modal-footer">
<button class="tc-btn tc-btn-as-is" id="tcAsIsBtn_{{ $id }}" disabled
onclick="tcUploadAsIs_{{ $id }}()">
<i class="bi bi-image"></i>
Upload as-is
</button>
<button class="tc-btn tc-btn-ghost" onclick="closeCropperModal('{{ $id }}')">
Cancel
</button>
<button class="tc-btn tc-btn-primary" id="tcSaveBtn_{{ $id }}" disabled
onclick="tcSave_{{ $id }}()">
<i class="bi bi-check-lg"></i>
<span id="tcSaveBtnText_{{ $id }}">Crop & Save</span>
</button>
</div>
</div>
</div>
<script>
(function () {
var cropperInst = null;
var originalFile = null;
var _intercept = false; // flag: we are programmatically setting the file, skip interceptor
var zoomMin = 0.01, zoomMax = 3;
var id = '{{ $id }}';
var vw = {{ $width }};
var vh = {{ $height }};
var shape = '{{ $shape }}';
var folder = '{{ $folder }}';
var filename = '{{ $filename }}';
var uploadUrl = '{{ route('image.upload') }}';
var updateUrl = '{{ $updateUrl }}';
var callbackFn = '{{ $callback }}';
var outputWidth = {{ $outputWidth > 0 ? $outputWidth : 0 }};
var targetInputId = '{{ $targetInput }}'; // form mode: ID of the file input to intercept
var previewImgId = '{{ $previewImg }}';
var isFormMode = targetInputId !== '';
function getCsrf() {
var m = document.querySelector('meta[name="csrf-token"]');
return m ? m.content : '';
}
/* ── Open / close ── */
function openModal() {
document.getElementById('tcOverlay_' + id).classList.add('open');
document.body.style.overflow = 'hidden';
}
window['openCropperModal_' + id] = openModal;
window.closeCropperModal = window.closeCropperModal || function (i) {
var el = document.getElementById('tcOverlay_' + i);
if (el) { el.classList.remove('open'); document.body.style.overflow = ''; }
};
document.getElementById('tcOverlay_' + id).addEventListener('click', function (e) {
if (e.target === this) window.closeCropperModal(id);
});
/* ── Preload a File into the cropper ── */
function preloadFile(file) {
if (!file) return;
originalFile = file;
document.getElementById('tcFileName_' + id).textContent = file.name;
var reader = new FileReader();
reader.onload = function (e) { initCropper(e.target.result); };
reader.readAsDataURL(file);
}
window['tcPreload_' + id] = preloadFile;
function initCropper(dataUrl) {
document.getElementById('tcPlaceholder_' + id).style.display = 'none';
document.getElementById('tcSaveBtn_' + id).disabled = false;
document.getElementById('tcAsIsBtn_' + id).disabled = false;
var canvas = document.getElementById('tcCanvas_' + id);
if (cropperInst) { cropperInst.destroy(); cropperInst = null; }
cropperInst = new Cropme(canvas, {
container: { width: '100%', height: 320 },
viewport: {
width: vw, height: vh,
type: shape,
border: { enable: true, width: 2, color: '#ef4444' }
},
transformOrigin: 'viewport',
zoom: { min: zoomMin, max: zoomMax, enable: true, mouseWheel: true, slider: false },
rotation: { enable: true, slider: false }
});
cropperInst.bind({ url: dataUrl }).then(function () {
document.getElementById('tcZoom_' + id).value = 0;
document.getElementById('tcRot_' + id).value = 0;
});
}
/* ── Internal file input (the "Choose image" button inside the modal) ── */
document.getElementById('tcInput_' + id).addEventListener('change', function () {
if (this.files && this.files[0]) preloadFile(this.files[0]);
});
/* ── Form-mode: expose open helper for external callers ── */
// Callers should invoke window['openCropperModal_' + id]() directly from their
// dropzone click/drop handlers rather than relying on event interception.
/* ── Zoom / rotate sliders ── */
document.getElementById('tcZoom_' + id).addEventListener('input', function () {
if (!cropperInst || !cropperInst.properties.image) return;
var p = parseFloat(this.value) / 100;
cropperInst.properties.scale = zoomMin + (zoomMax - zoomMin) * p;
var s = cropperInst.properties;
s.image.style.transform = 'translate3d(' + s.x + 'px,' + s.y + 'px,0) scale(' + s.scale + ') rotate(' + s.deg + 'deg)';
});
document.getElementById('tcRot_' + id).addEventListener('input', function () {
if (cropperInst) cropperInst.rotate(parseInt(this.value, 10));
});
/* ── Helpers ── */
function base64ToFile(base64, name) {
var arr = base64.split(',');
var mime = (arr[0].match(/:(.*?);/) || [])[1] || 'image/png';
var bin = atob(arr[1]);
var u8 = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
return new File([u8], name || 'cropped.png', { type: mime });
}
function setOnTargetInput(file) {
var el = document.getElementById(targetInputId);
if (!el) return;
try {
var dt = new DataTransfer();
dt.items.add(file);
_intercept = true;
el.files = dt.files;
el.dispatchEvent(new Event('change', { bubbles: true }));
} finally {
_intercept = false;
}
if (previewImgId) {
var prev = document.getElementById(previewImgId);
if (prev) prev.src = URL.createObjectURL(file);
}
}
function uploadToServer(base64, onDone, onFail) {
fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': getCsrf() },
body: JSON.stringify({ image: base64, folder: folder, filename: filename })
})
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res.success) throw new Error(res.message || 'Upload failed');
if (updateUrl) {
return fetch(updateUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': getCsrf() },
body: JSON.stringify({ path: res.path })
}).then(function () { return res; });
}
return res;
})
.then(onDone)
.catch(onFail);
}
/* ── Crop & Save ── */
window['tcSave_' + id] = function () {
if (!cropperInst) return;
var btn = document.getElementById('tcSaveBtn_' + id);
var txt = document.getElementById('tcSaveBtnText_' + id);
btn.disabled = true;
txt.textContent = 'Saving…';
var cropOpts = outputWidth > 0 ? { type: 'base64', width: outputWidth } : { type: 'base64' };
cropperInst.crop(cropOpts).then(function (base64) {
if (isFormMode) {
var fname = originalFile ? originalFile.name : 'cropped.png';
setOnTargetInput(base64ToFile(base64, fname));
window.closeCropperModal(id);
if (typeof window.showToast === 'function') window.showToast('Image ready!', 'success');
btn.disabled = false;
txt.textContent = 'Crop & Save';
} else {
uploadToServer(base64, function (res) {
window.closeCropperModal(id);
if (typeof window.showToast === 'function') window.showToast('Saved!', 'success');
if (callbackFn && typeof window[callbackFn] === 'function') window[callbackFn](res.url);
btn.disabled = false;
txt.textContent = 'Crop & Save';
}, function (err) {
if (typeof window.showToast === 'function') window.showToast(err.message || 'Upload failed', 'error');
btn.disabled = false;
txt.textContent = 'Crop & Save';
});
}
});
};
/* ── Upload as-is ── */
window['tcUploadAsIs_' + id] = function () {
if (!originalFile) { window.closeCropperModal(id); return; }
if (isFormMode) {
// Put the original (un-cropped) file on the target input so the form sees it.
setOnTargetInput(originalFile);
window.closeCropperModal(id);
} else {
// Server mode: read original file as base64 and upload unchanged
var btn = document.getElementById('tcSaveBtn_' + id);
var txt = document.getElementById('tcSaveBtnText_' + id);
var aisBtn = document.getElementById('tcAsIsBtn_' + id);
btn.disabled = true;
aisBtn.disabled = true;
txt.textContent = 'Uploading…';
var reader = new FileReader();
reader.onload = function (e) {
uploadToServer(e.target.result, function (res) {
window.closeCropperModal(id);
if (typeof window.showToast === 'function') window.showToast('Saved!', 'success');
if (callbackFn && typeof window[callbackFn] === 'function') window[callbackFn](res.url);
btn.disabled = false;
aisBtn.disabled = false;
txt.textContent = 'Crop & Save';
}, function (err) {
if (typeof window.showToast === 'function') window.showToast(err.message || 'Upload failed', 'error');
btn.disabled = false;
aisBtn.disabled = false;
txt.textContent = 'Crop & Save';
});
};
reader.readAsDataURL(originalFile);
}
};
})();
</script>
@@ -0,0 +1,307 @@
@props([
'name' => '',
'id' => null,
'value' => null,
'label' => null,
'placeholder' => 'Code',
'required' => false,
'class' => '',
'style' => '',
])
@php
use App\Data\Countries;
$options = Countries::forPhoneCode();
$uid = 'csd_' . ($id ?? $name) . '_' . substr(md5(uniqid()), 0, 8);
$inputId = $id ?? $name;
$selFlag = '🌐';
$selLabel = $placeholder;
$selSub = '';
if ($value) {
foreach ($options as $opt) {
if ($opt['value'] === $value || str_starts_with($opt['value'], $value . '|')) {
$selFlag = $opt['flag'];
$selLabel = $opt['label'];
$selSub = $opt['secondary'];
break;
}
}
}
$isPlaceholder = !$value;
@endphp
{{-- ── Shared CSS (output once across all three CSD components) ──────── --}}
@once('csd-styles')
<style>
.csd-wrap { position: relative; }
.csd-lbl { display: block; margin-bottom: 6px; font-weight: 500; font-size: 14px; color: var(--text-primary, #f1f1f1); }
.csd-lbl .req { color: var(--brand-red, #e61e1e); margin-left: 2px; }
.csd-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
background: var(--bg-dark, #0f0f0f);
border: 1px solid var(--border-color, #303030);
border-radius: 8px;
padding: 10px 14px;
color: var(--text-primary, #f1f1f1);
font-size: 14px;
font-family: inherit;
line-height: 1.4;
cursor: pointer;
text-align: left;
transition: border-color .2s;
outline: none;
min-height: 46px;
box-sizing: border-box;
}
.csd-btn:hover,
.csd-btn:focus-visible { border-color: var(--brand-red, #e61e1e); }
.csd-btn[aria-expanded="true"] { border-color: var(--brand-red, #e61e1e); }
.csd-ico { font-size: 18px; line-height: 1; flex-shrink: 0; user-select: none; }
.csd-val { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.csd-val.ph { color: var(--text-secondary, #aaa); }
.csd-sub { font-size: 12px; color: var(--text-secondary, #aaa); white-space: nowrap; flex-shrink: 0; }
.csd-arr { flex-shrink: 0; font-size: 11px; color: var(--text-secondary, #aaa); margin-left: 4px; transition: transform .2s; }
.csd-btn[aria-expanded="true"] .csd-arr { transform: rotate(180deg); }
.csd-panel {
position: fixed;
z-index: 1060;
min-width: 220px;
background: var(--bg-secondary, #1e1e1e);
border: 1px solid var(--border-color, #303030);
border-radius: 10px;
box-shadow: 0 12px 32px rgba(0,0,0,.55);
overflow: hidden;
}
.csd-srch {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color, #303030);
}
.csd-srch i { font-size: 13px; color: var(--text-secondary, #aaa); flex-shrink: 0; }
.csd-sinput {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--text-primary, #f1f1f1);
font-size: 13px;
font-family: inherit;
}
.csd-sinput::placeholder { color: var(--text-secondary, #aaa); }
.csd-list {
list-style: none;
margin: 0;
padding: 4px 0;
max-height: 240px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-color, #303030) transparent;
}
.csd-list::-webkit-scrollbar { width: 4px; }
.csd-list::-webkit-scrollbar-thumb { background: var(--border-color, #303030); border-radius: 2px; }
.csd-opt {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
color: var(--text-primary, #f1f1f1);
transition: background .1s;
outline: none;
}
.csd-opt:hover,
.csd-opt:focus { background: rgba(255,255,255,.07); }
.csd-opt[aria-selected="true"] { background: rgba(255,255,255,.05); }
.csd-opt-ico { font-size: 17px; line-height: 1; flex-shrink: 0; user-select: none; }
.csd-opt-main { font-weight: 500; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.csd-opt-sub { font-size: 12px; color: var(--text-secondary, #aaa); white-space: nowrap; flex-shrink: 0; max-width: 45%; overflow: hidden; text-overflow: ellipsis; }
.csd-empty { padding: 12px; font-size: 13px; color: var(--text-secondary, #aaa); text-align: center; }
</style>
@endonce
{{-- ── Shared JS (output once across all three CSD components) ───────── --}}
@once('csd-script')
<script>
(function () {
if (window.CSD) return;
window.CSD = class {
constructor(id) {
this.root = document.getElementById(id);
this.btn = this.root.querySelector('.csd-btn');
this.panel = this.root.querySelector('.csd-panel');
this.sinput = this.root.querySelector('.csd-sinput');
this.list = this.root.querySelector('.csd-list');
this.hidden = this.root.querySelector('input[type=hidden]');
this.icoEl = this.btn.querySelector('.csd-ico');
this.valEl = this.btn.querySelector('.csd-val');
this.subEl = this.btn.querySelector('.csd-sub');
this.emptyEl= this.root.querySelector('.csd-empty');
this._bind();
}
_bind() {
this.btn.addEventListener('click', e => { e.stopPropagation(); this.toggle(); });
this.sinput.addEventListener('input', () => this._filter());
this.list.addEventListener('click', e => {
const o = e.target.closest('.csd-opt');
if (o) this._pick(o);
});
document.addEventListener('click', e => { if (!this.root.contains(e.target)) this.close(); }, true);
this.btn.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.toggle(); }
});
this.sinput.addEventListener('keydown', e => {
if (e.key === 'Escape') { this.close(); this.btn.focus(); }
if (e.key === 'ArrowDown') { e.preventDefault(); this._move(0); }
if (e.key === 'Enter') { e.preventDefault(); }
});
this.list.addEventListener('keydown', e => {
const all = [...this.list.querySelectorAll('.csd-opt:not([hidden])')];
const i = all.indexOf(document.activeElement.closest('.csd-opt'));
if (e.key === 'ArrowDown') { e.preventDefault(); this._move(i + 1); }
if (e.key === 'ArrowUp') { e.preventDefault(); i > 0 ? this._move(i - 1) : this.sinput.focus(); }
if (e.key === 'Enter') { e.preventDefault(); if (all[i]) this._pick(all[i]); }
if (e.key === 'Escape') { this.close(); this.btn.focus(); }
});
}
toggle() { this.panel.hidden ? this.open() : this.close(); }
open() {
document.querySelectorAll('.csd-panel:not([hidden])').forEach(p => { p.hidden = true; });
document.querySelectorAll('.csd-btn[aria-expanded=true]').forEach(b => b.setAttribute('aria-expanded','false'));
this.panel.hidden = false;
this.btn.setAttribute('aria-expanded', 'true');
this.sinput.value = '';
this._filter();
requestAnimationFrame(() => this.sinput.focus());
const r = this.btn.getBoundingClientRect();
const goUp = window.innerHeight - r.bottom < 280 && r.top > 280;
this.panel.style.left = r.left + 'px';
this.panel.style.width = Math.max(r.width, 220) + 'px';
if (goUp) {
this.panel.style.top = '';
this.panel.style.bottom = (window.innerHeight - r.top + 4) + 'px';
} else {
this.panel.style.top = (r.bottom + 4) + 'px';
this.panel.style.bottom = '';
}
}
close() {
this.panel.hidden = true;
this.btn.setAttribute('aria-expanded', 'false');
}
_filter() {
const q = this.sinput.value.toLowerCase();
let n = 0;
this.list.querySelectorAll('.csd-opt').forEach(o => {
const show = o.dataset.s.includes(q);
o.style.display = show ? '' : 'none';
if (show) n++;
});
if (this.emptyEl) this.emptyEl.style.display = n > 0 ? 'none' : '';
}
_move(i) {
const all = [...this.list.querySelectorAll('.csd-opt:not([hidden])')];
if (all[i]) { all[i].tabIndex = 0; all[i].focus(); }
}
_pick(opt) {
this.hidden.value = opt.dataset.v;
this.icoEl.textContent = opt.querySelector('.csd-opt-ico').textContent;
this.valEl.textContent = opt.querySelector('.csd-opt-main').textContent;
this.valEl.classList.remove('ph');
if (this.subEl) {
const s = opt.querySelector('.csd-opt-sub');
this.subEl.textContent = s ? s.textContent : '';
}
this.list.querySelectorAll('.csd-opt').forEach(o =>
o.setAttribute('aria-selected', o === opt ? 'true' : 'false')
);
this.close();
this.btn.focus();
this.hidden.dispatchEvent(new Event('change', { bubbles: true }));
}
};
}());
</script>
@endonce
{{-- ── Component HTML ─────────────────────────────────────────────────── --}}
<div class="csd-wrap {{ $class }}" id="{{ $uid }}" @if($style) style="{{ $style }}" @endif>
@if($label)
<label class="csd-lbl" for="{{ $inputId }}">
{{ $label }}@if($required)<span class="req">*</span>@endif
</label>
@endif
<button type="button"
class="csd-btn"
aria-haspopup="listbox"
aria-expanded="false"
aria-label="{{ $label ?? 'Phone code' }}">
<span class="csd-ico">{{ $selFlag }}</span>
<span class="csd-val{{ $isPlaceholder ? ' ph' : '' }}">{{ $selLabel }}</span>
@if(!$isPlaceholder && $selSub)
<span class="csd-sub">{{ $selSub }}</span>
@endif
<i class="bi bi-chevron-down csd-arr"></i>
</button>
<div class="csd-panel" hidden role="listbox">
<div class="csd-srch">
<i class="bi bi-search"></i>
<input class="csd-sinput" type="text" placeholder="Search country or code…" autocomplete="off" spellcheck="false">
</div>
<ul class="csd-list">
@foreach($options as $opt)
<li class="csd-opt"
role="option"
tabindex="-1"
data-v="{{ $opt['value'] }}"
data-s="{{ $opt['search'] }}"
aria-selected="{{ $value && str_starts_with($opt['value'], $value.'|') ? 'true' : 'false' }}">
<span class="csd-opt-ico">{{ $opt['flag'] }}</span>
<span class="csd-opt-main">{{ $opt['label'] }}</span>
<span class="csd-opt-sub">{{ $opt['secondary'] }}</span>
</li>
@endforeach
</ul>
<p class="csd-empty" hidden>No results</p>
</div>
<input type="hidden"
name="{{ $name }}"
id="{{ $inputId }}"
value="{{ $value }}"
@if($required) required @endif>
</div>
<script>
(function () {
function boot() { new CSD('{{ $uid }}'); }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}());
</script>
@@ -0,0 +1,78 @@
@props(['playlist', 'showTypeBadge' => false])
@once
<style>
.pl-visibility-badge {
position: absolute;
bottom: 8px;
left: 8px;
background: rgba(0,0,0,.75);
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 3px 8px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 4px;
text-transform: uppercase;
letter-spacing: .4px;
pointer-events: none;
}
</style>
@endonce
@php $pl = $playlist; @endphp
<div class="yt-video-card">
<a href="{{ route('playlists.show', $pl->id) }}">
<div class="yt-video-thumb">
<img src="{{ $pl->thumbnail_url }}" alt="{{ $pl->name }}" loading="lazy">
<div class="pl-count-badge">
<i class="bi bi-collection-play-fill"></i>
{{ $pl->videos_count }}
</div>
@if($showTypeBadge)
<span class="feed-pl-type-badge">
<i class="bi bi-collection-play-fill"></i> PLAYLIST
</span>
@endif
@if($pl->visibility === 'private')
<span class="pl-visibility-badge"><i class="bi bi-lock-fill"></i> Private</span>
@elseif($pl->visibility === 'unlisted')
<span class="pl-visibility-badge"><i class="bi bi-link-45deg"></i> Unlisted</span>
@endif
</div>
</a>
<div class="yt-video-info">
<div class="yt-channel-icon"
style="cursor:pointer;"
onclick="window.location.href='{{ $pl->user ? route('channel', $pl->user->channel) : '#' }}'">
@if($pl->user?->avatar_url)
<img src="{{ $pl->user->avatar_url }}" alt="{{ $pl->user->name }}"
style="width:100%;height:100%;object-fit:cover;border-radius:50%;">
@elseif($pl->user)
<span style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700;color:#fff;">
{{ mb_strtoupper(mb_substr($pl->user->name, 0, 1)) }}
</span>
@endif
</div>
<div class="yt-video-details">
<h3 class="yt-video-title">
<a href="{{ route('playlists.show', $pl->id) }}">{{ $pl->name }}</a>
</h3>
@if($pl->user)
<a href="{{ route('channel', $pl->user->channel) }}" class="yt-channel-name"
onclick="event.stopPropagation()">{{ $pl->user->name }}</a>
@endif
<div class="yt-video-meta">
<span class="yt-type-label" style="color:#a78bfa;">
<i class="bi bi-collection-play-fill"></i>
Playlist
</span>
&nbsp;·&nbsp;
{{ number_format($pl->view_count) }} {{ Str::plural('view', $pl->view_count) }} · {{ $pl->created_at->diffForHumans() }}
</div>
</div>
</div>
</div>
@@ -0,0 +1,324 @@
@props([
'existing' => [], // [ ['platform'=>'twitter','value'=>'handle','visibility'=>'public'], ... ]
])
@php
$uid = 'sle_' . substr(md5(uniqid()), 0, 8);
@endphp
@once('sle-styles')
<style>
.sle-entry { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.sle-entry-input { flex: 1; min-width: 0; }
.sle-icon-btn {
flex-shrink: 0; display: flex; align-items: center; justify-content: center;
width: 38px; height: 38px; border-radius: 8px; background: transparent;
border: 1px solid var(--border-color, #303030);
font-size: 15px; cursor: pointer; transition: border-color .15s, color .15s, background .15s;
}
.sle-remove-btn { color: var(--text-secondary, #aaa); }
.sle-remove-btn:hover { border-color: #e61e1e; color: #e61e1e; background: rgba(230,30,30,.08); }
/* Visibility button colour per state */
.sle-vis-btn[data-vis="public"] { color: #22c55e; border-color: rgba(34,197,94,.35); }
.sle-vis-btn[data-vis="registered"] { color: #3b82f6; border-color: rgba(59,130,246,.35); }
.sle-vis-btn[data-vis="subscribers"] { color: #f59e0b; border-color: rgba(245,158,11,.35); }
.sle-vis-btn[data-vis="only_me"] { color: #6b7280; border-color: rgba(107,114,128,.35); }
.sle-empty {
text-align: center; padding: 28px 16px;
color: var(--text-secondary, #aaa); font-size: 14px;
}
.sle-empty i { font-size: 30px; display: block; margin-bottom: 8px; opacity: .45; }
/* Shared popup panel */
.sle-popup-wrap { position: relative; }
.sle-popup {
position: fixed; z-index: 2100;
background: var(--bg-secondary, #1e1e1e); border: 1px solid var(--border-color, #303030);
border-radius: 10px; box-shadow: 0 12px 32px rgba(0,0,0,.6); padding: 4px 0;
}
.sle-pop-opt {
display: flex; align-items: center; gap: 9px; padding: 8px 13px;
cursor: pointer; font-size: 13px; color: var(--text-primary, #f1f1f1);
transition: background .1s; white-space: nowrap;
}
.sle-pop-opt:hover { background: rgba(255,255,255,.07); }
.sle-pop-opt.active { font-weight: 600; }
.sle-pop-opt i { font-size: 14px; flex-shrink: 0; width: 16px; text-align: center; }
/* Platform picker panel is scrollable */
.sle-popup.plat-panel {
overflow-y: auto; max-height: 260px;
scrollbar-width: thin; scrollbar-color: var(--border-color, #303030) transparent;
}
.sle-popup.plat-panel::-webkit-scrollbar { width: 4px; }
.sle-popup.plat-panel::-webkit-scrollbar-thumb { background: var(--border-color, #303030); border-radius: 2px; }
/* Platform picker button */
.sle-sp-wrap { position: relative; flex: 0 0 160px; }
.sle-sp-btn {
display: flex; align-items: center; gap: 8px; width: 100%;
background: var(--bg-dark, #0f0f0f); border: 1px solid var(--border-color, #303030);
border-radius: 8px; padding: 9px 10px; color: var(--text-primary, #f1f1f1);
font-size: 13px; font-family: inherit; cursor: pointer; text-align: left;
transition: border-color .2s; outline: none; min-height: 38px; box-sizing: border-box;
}
.sle-sp-btn:hover, .sle-sp-btn:focus-visible { border-color: var(--brand-red, #e61e1e); }
.sle-sp-btn[aria-expanded="true"] { border-color: var(--brand-red, #e61e1e); }
.sle-sp-ico { font-size: 15px; flex-shrink: 0; }
.sle-sp-val { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sle-sp-val.ph { color: var(--text-secondary, #aaa); }
.sle-sp-arr { font-size: 10px; color: var(--text-secondary, #aaa); flex-shrink: 0; transition: transform .2s; }
.sle-sp-btn[aria-expanded="true"] .sle-sp-arr { transform: rotate(180deg); }
</style>
@endonce
@once('sle-script')
<script>
(function () {
if (window.SocialLinksEditor) return;
// Close all popups when clicking outside
document.addEventListener('click', e => {
if (!e.target.closest('.sle-popup-wrap') && !e.target.closest('.sle-sp-wrap')) {
document.querySelectorAll('.sle-popup').forEach(p => { p.hidden = true; });
document.querySelectorAll('[aria-expanded="true"].sle-sp-btn').forEach(b => b.setAttribute('aria-expanded','false'));
}
});
window.SocialLinksEditor = class {
constructor(uid, existing) {
this.root = document.getElementById(uid);
this.entriesEl = this.root.querySelector('[data-sle-entries]');
this.addBtn = this.root.querySelector('[data-sle-add]');
this._idx = 0;
this.PLATFORMS = [
{ key: 'twitter', label: 'Twitter / X', icon: 'bi-twitter-x', placeholder: 'username', type: 'text', defaultVis: 'public' },
{ key: 'instagram', label: 'Instagram', icon: 'bi-instagram', placeholder: 'username', type: 'text', defaultVis: 'public' },
{ key: 'facebook', label: 'Facebook', icon: 'bi-facebook', placeholder: 'username', type: 'text', defaultVis: 'public' },
{ key: 'youtube', label: 'YouTube', icon: 'bi-youtube', placeholder: 'channel', type: 'text', defaultVis: 'public' },
{ key: 'linkedin', label: 'LinkedIn', icon: 'bi-linkedin', placeholder: 'username', type: 'text', defaultVis: 'public' },
{ key: 'tiktok', label: 'TikTok', icon: 'bi-tiktok', placeholder: 'username', type: 'text', defaultVis: 'public' },
{ key: 'whatsapp', label: 'WhatsApp', icon: 'bi-whatsapp', placeholder: '97312345678 (no + or spaces)', type: 'text', defaultVis: 'subscribers' },
{ key: 'website', label: 'Website', icon: 'bi-globe', placeholder: 'https://example.com', type: 'url', defaultVis: 'public' },
{ key: 'google_location', label: 'Google Maps', icon: 'bi-geo-alt-fill', placeholder: 'https://maps.google.com/...', type: 'url', defaultVis: 'public' },
{ key: 'social_phone', label: 'Phone', icon: 'bi-telephone-fill', placeholder: '+97312345678', type: 'text', defaultVis: 'subscribers' },
{ key: 'social_email', label: 'Email', icon: 'bi-envelope-fill', placeholder: '[email protected]', type: 'email', defaultVis: 'subscribers' },
];
this.VISIBILITIES = [
{ key: 'public', label: 'Public', icon: 'bi-globe2', color: '#22c55e', hint: 'Everyone including guests' },
{ key: 'registered', label: 'Registered', icon: 'bi-person-check-fill', color: '#3b82f6', hint: 'Logged-in users only' },
{ key: 'subscribers', label: 'Subscribers', icon: 'bi-star-fill', color: '#f59e0b', hint: 'Your subscribers only' },
{ key: 'only_me', label: 'Only me', icon: 'bi-lock-fill', color: '#6b7280', hint: 'Hidden from everyone else' },
];
existing.forEach(({ platform, value, visibility }) => {
if (platform && value) {
this.entriesEl.appendChild(this._buildEntry(platform, value, visibility || 'public'));
}
});
this._renderEmpty();
this.addBtn.addEventListener('click', () => {
const row = this._buildEntry('', '', 'public');
this.entriesEl.appendChild(row);
this._renderEmpty();
row.querySelector('.sle-sp-btn').click();
});
}
_platform(key) { return this.PLATFORMS.find(p => p.key === key) || null; }
_visibility(key) { return this.VISIBILITIES.find(v => v.key === key) || this.VISIBILITIES[0]; }
_renderEmpty() {
let empty = this.entriesEl.querySelector('.sle-empty');
if (this.entriesEl.querySelectorAll('.sle-entry').length === 0) {
if (!empty) {
empty = document.createElement('div');
empty.className = 'sle-empty';
empty.innerHTML = '<i class="bi bi-share"></i>No social accounts added yet.';
this.entriesEl.appendChild(empty);
}
} else {
if (empty) empty.remove();
}
}
_closeAllPopups() {
document.querySelectorAll('.sle-popup').forEach(p => { p.hidden = true; });
document.querySelectorAll('[aria-expanded="true"].sle-sp-btn').forEach(b => b.setAttribute('aria-expanded','false'));
}
_togglePopup(trigger, panel) {
const wasOpen = !panel.hidden;
this._closeAllPopups();
if (!wasOpen) {
panel.hidden = false;
if (trigger.classList.contains('sle-sp-btn')) trigger.setAttribute('aria-expanded','true');
const r = trigger.getBoundingClientRect();
const isRight = panel.classList.contains('pop-right');
const goUp = window.innerHeight - r.bottom < 270 && r.top > 270;
panel.style.minWidth = r.width + 'px';
if (goUp) {
panel.style.top = '';
panel.style.bottom = (window.innerHeight - r.top + 4) + 'px';
} else {
panel.style.top = (r.bottom + 4) + 'px';
panel.style.bottom = '';
}
if (isRight) {
panel.style.left = '';
panel.style.right = (window.innerWidth - r.right) + 'px';
} else {
panel.style.left = r.left + 'px';
panel.style.right = '';
}
}
}
_buildEntry(key, value, visibility) {
const idx = this._idx++;
const p = this._platform(key);
const vis = this._visibility(visibility || (p ? p.defaultVis : 'public'));
const row = document.createElement('div');
row.className = 'sle-entry';
// ── 1. Platform picker ───────────────────────────────────────
const spWrap = document.createElement('div');
spWrap.className = 'sle-sp-wrap';
const spBtn = document.createElement('button');
spBtn.type = 'button'; spBtn.className = 'sle-sp-btn'; spBtn.setAttribute('aria-expanded','false');
const spIco = document.createElement('i');
spIco.className = p ? `sle-sp-ico bi ${p.icon}` : 'sle-sp-ico bi bi-plus-circle';
const spVal = document.createElement('span');
spVal.className = p ? 'sle-sp-val' : 'sle-sp-val ph';
spVal.textContent = p ? p.label : 'Platform';
const spArr = document.createElement('i');
spArr.className = 'bi bi-chevron-down sle-sp-arr';
spBtn.append(spIco, spVal, spArr);
const platInput = document.createElement('input');
platInput.type = 'hidden'; platInput.name = `slink[${idx}][platform]`; platInput.value = key || '';
const spPanel = document.createElement('div');
spPanel.className = 'sle-popup plat-panel'; spPanel.hidden = true;
this.PLATFORMS.forEach(pl => {
const opt = document.createElement('div');
opt.className = `sle-pop-opt${pl.key === key ? ' active' : ''}`;
opt.innerHTML = `<i class="bi ${pl.icon}"></i>${pl.label}`;
opt.addEventListener('click', () => {
spIco.className = `sle-sp-ico bi ${pl.icon}`;
spVal.textContent = pl.label; spVal.classList.remove('ph');
spPanel.querySelectorAll('.sle-pop-opt').forEach(o => o.classList.remove('active'));
opt.classList.add('active');
platInput.value = pl.key;
inp.type = pl.type;
inp.placeholder = pl.placeholder;
inp.disabled = false;
inp.value = '';
// Apply default visibility for this platform
const defVis = this._visibility(pl.defaultVis);
visInput.value = defVis.key;
visBtn.dataset.vis = defVis.key;
visBtn.innerHTML = `<i class="bi ${defVis.icon}"></i>`;
visBtn.title = defVis.label + ' — ' + defVis.hint;
this._closeAllPopups();
inp.focus();
});
spPanel.appendChild(opt);
});
spBtn.addEventListener('click', e => { e.stopPropagation(); this._togglePopup(spBtn, spPanel); });
spWrap.append(spBtn, platInput, spPanel);
// ── 2. Value input ───────────────────────────────────────────
const inp = document.createElement('input');
inp.className = 'form-input sle-entry-input';
inp.type = p ? p.type : 'text';
inp.name = `slink[${idx}][value]`;
inp.placeholder = p ? p.placeholder : 'select platform first';
inp.disabled = !key;
inp.value = value || '';
// ── 3. Visibility picker ─────────────────────────────────────
const visWrap = document.createElement('div');
visWrap.className = 'sle-popup-wrap';
const visInput = document.createElement('input');
visInput.type = 'hidden'; visInput.name = `slink[${idx}][visibility]`; visInput.value = vis.key;
const visBtn = document.createElement('button');
visBtn.type = 'button';
visBtn.className = 'sle-icon-btn sle-vis-btn';
visBtn.dataset.vis = vis.key;
visBtn.title = vis.label + ' — ' + vis.hint;
visBtn.innerHTML = `<i class="bi ${vis.icon}"></i>`;
const visPanel = document.createElement('div');
visPanel.className = 'sle-popup pop-right'; visPanel.hidden = true;
this.VISIBILITIES.forEach(v => {
const opt = document.createElement('div');
opt.className = `sle-pop-opt${v.key === vis.key ? ' active' : ''}`;
opt.style.color = v.key === vis.key ? v.color : '';
opt.innerHTML = `<i class="bi ${v.icon}" style="color:${v.color}"></i>`
+ `<span><strong>${v.label}</strong><br><small style="color:var(--text-secondary,#aaa)">${v.hint}</small></span>`;
opt.addEventListener('click', () => {
visInput.value = v.key;
visBtn.dataset.vis = v.key;
visBtn.innerHTML = `<i class="bi ${v.icon}"></i>`;
visBtn.title = v.label + ' — ' + v.hint;
visPanel.querySelectorAll('.sle-pop-opt').forEach(o => {
o.classList.remove('active'); o.style.color = '';
});
opt.classList.add('active'); opt.style.color = v.color;
visPanel.hidden = true;
});
visPanel.appendChild(opt);
});
visBtn.addEventListener('click', e => { e.stopPropagation(); this._togglePopup(visBtn, visPanel); });
visWrap.append(visInput, visBtn, visPanel);
// ── 4. Remove button ─────────────────────────────────────────
const rm = document.createElement('button');
rm.type = 'button'; rm.className = 'sle-icon-btn sle-remove-btn'; rm.title = 'Remove';
rm.innerHTML = '<i class="bi bi-trash"></i>';
rm.addEventListener('click', () => { row.remove(); this._renderEmpty(); });
row.append(spWrap, inp, visWrap, rm);
return row;
}
};
}());
</script>
@endonce
<div id="{{ $uid }}">
<div data-sle-entries></div>
<div style="margin-top:4px;">
<button type="button" data-sle-add class="action-btn">
<i class="bi bi-plus-lg"></i> <span>Add Account</span>
</button>
</div>
</div>
<script>
(function () {
function boot() { new SocialLinksEditor('{{ $uid }}', @json($existing)); }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}());
</script>
@@ -0,0 +1,234 @@
@props([
'name' => '',
'id' => null,
'value' => null,
'label' => null,
'placeholder' => 'Select timezone',
'required' => false,
'class' => '',
'style' => '',
])
@php
use App\Data\Countries;
$options = Countries::forTimezone();
$uid = 'csd_' . ($id ?? $name) . '_' . substr(md5(uniqid()), 0, 8);
$inputId = $id ?? $name;
$selFlag = '🌐';
$selLabel = $placeholder;
$selSub = '';
if ($value) {
foreach ($options as $opt) {
if ($opt['value'] === $value) {
$selFlag = $opt['flag'];
$selLabel = $opt['label'];
$selSub = $opt['utc'];
break;
}
}
}
$isPlaceholder = !$value;
@endphp
@once('csd-styles')
<style>
.csd-wrap { position: relative; }
.csd-lbl { display: block; margin-bottom: 6px; font-weight: 500; font-size: 14px; color: var(--text-primary, #f1f1f1); }
.csd-lbl .req { color: var(--brand-red, #e61e1e); margin-left: 2px; }
.csd-btn {
display: flex; align-items: center; gap: 8px; width: 100%;
background: var(--bg-dark, #0f0f0f); border: 1px solid var(--border-color, #303030);
border-radius: 8px; padding: 10px 14px; color: var(--text-primary, #f1f1f1);
font-size: 14px; font-family: inherit; line-height: 1.4; cursor: pointer;
text-align: left; transition: border-color .2s; outline: none; min-height: 46px; box-sizing: border-box;
}
.csd-btn:hover, .csd-btn:focus-visible { border-color: var(--brand-red, #e61e1e); }
.csd-btn[aria-expanded="true"] { border-color: var(--brand-red, #e61e1e); }
.csd-ico { font-size: 18px; line-height: 1; flex-shrink: 0; user-select: none; }
.csd-val { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.csd-val.ph { color: var(--text-secondary, #aaa); }
.csd-sub { font-size: 12px; color: var(--text-secondary, #aaa); white-space: nowrap; flex-shrink: 0; }
.csd-arr { flex-shrink: 0; font-size: 11px; color: var(--text-secondary, #aaa); margin-left: 4px; transition: transform .2s; }
.csd-btn[aria-expanded="true"] .csd-arr { transform: rotate(180deg); }
.csd-panel {
position: fixed; z-index: 1060; min-width: 220px;
background: var(--bg-secondary, #1e1e1e); border: 1px solid var(--border-color, #303030);
border-radius: 10px; box-shadow: 0 12px 32px rgba(0,0,0,.55); overflow: hidden;
}
.csd-srch { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border-color, #303030); }
.csd-srch i { font-size: 13px; color: var(--text-secondary, #aaa); flex-shrink: 0; }
.csd-sinput { flex: 1; background: none; border: none; outline: none; color: var(--text-primary, #f1f1f1); font-size: 13px; font-family: inherit; }
.csd-sinput::placeholder { color: var(--text-secondary, #aaa); }
.csd-list { list-style: none; margin: 0; padding: 4px 0; max-height: 240px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--border-color, #303030) transparent; }
.csd-list::-webkit-scrollbar { width: 4px; }
.csd-list::-webkit-scrollbar-thumb { background: var(--border-color, #303030); border-radius: 2px; }
.csd-opt { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; font-size: 13px; color: var(--text-primary, #f1f1f1); transition: background .1s; outline: none; }
.csd-opt:hover, .csd-opt:focus { background: rgba(255,255,255,.07); }
.csd-opt[aria-selected="true"] { background: rgba(255,255,255,.05); }
.csd-opt-ico { font-size: 17px; line-height: 1; flex-shrink: 0; user-select: none; }
.csd-opt-main { font-weight: 500; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.csd-opt-sub { font-size: 12px; color: var(--text-secondary, #aaa); white-space: nowrap; flex-shrink: 0; max-width: 45%; overflow: hidden; text-overflow: ellipsis; }
.csd-empty { padding: 12px; font-size: 13px; color: var(--text-secondary, #aaa); text-align: center; }
</style>
@endonce
@once('csd-script')
<script>
(function () {
if (window.CSD) return;
window.CSD = class {
constructor(id) {
this.root = document.getElementById(id);
this.btn = this.root.querySelector('.csd-btn');
this.panel = this.root.querySelector('.csd-panel');
this.sinput = this.root.querySelector('.csd-sinput');
this.list = this.root.querySelector('.csd-list');
this.hidden = this.root.querySelector('input[type=hidden]');
this.icoEl = this.btn.querySelector('.csd-ico');
this.valEl = this.btn.querySelector('.csd-val');
this.subEl = this.btn.querySelector('.csd-sub');
this.emptyEl= this.root.querySelector('.csd-empty');
this._bind();
if (this.root.dataset.geoMap && !this.hidden.value) {
this._autoGeo();
}
}
_bind() {
this.btn.addEventListener('click', e => { e.stopPropagation(); this.toggle(); });
this.sinput.addEventListener('input', () => this._filter());
this.list.addEventListener('click', e => { const o = e.target.closest('.csd-opt'); if (o) this._pick(o); });
document.addEventListener('click', e => { if (!this.root.contains(e.target)) this.close(); }, true);
this.btn.addEventListener('keydown', e => { if (e.key==='Enter'||e.key===' ') { e.preventDefault(); this.toggle(); } });
this.sinput.addEventListener('keydown', e => {
if (e.key==='Escape') { this.close(); this.btn.focus(); }
if (e.key==='ArrowDown') { e.preventDefault(); this._move(0); }
if (e.key==='Enter') { e.preventDefault(); }
});
this.list.addEventListener('keydown', e => {
const all = [...this.list.querySelectorAll('.csd-opt:not([hidden])')];
const i = all.indexOf(document.activeElement.closest('.csd-opt'));
if (e.key==='ArrowDown') { e.preventDefault(); this._move(i+1); }
if (e.key==='ArrowUp') { e.preventDefault(); i>0 ? this._move(i-1) : this.sinput.focus(); }
if (e.key==='Enter') { e.preventDefault(); if (all[i]) this._pick(all[i]); }
if (e.key==='Escape') { this.close(); this.btn.focus(); }
});
}
toggle() { this.panel.hidden ? this.open() : this.close(); }
open() {
document.querySelectorAll('.csd-panel:not([hidden])').forEach(p => { p.hidden = true; });
document.querySelectorAll('.csd-btn[aria-expanded=true]').forEach(b => b.setAttribute('aria-expanded','false'));
this.panel.hidden = false;
this.btn.setAttribute('aria-expanded','true');
this.sinput.value = '';
this._filter();
requestAnimationFrame(() => this.sinput.focus());
const r = this.btn.getBoundingClientRect();
const goUp = window.innerHeight - r.bottom < 280 && r.top > 280;
this.panel.style.left = r.left + 'px';
this.panel.style.width = Math.max(r.width, 220) + 'px';
if (goUp) {
this.panel.style.top = '';
this.panel.style.bottom = (window.innerHeight - r.top + 4) + 'px';
} else {
this.panel.style.top = (r.bottom + 4) + 'px';
this.panel.style.bottom = '';
}
}
close() { this.panel.hidden=true; this.btn.setAttribute('aria-expanded','false'); }
_filter() {
const q = this.sinput.value.toLowerCase(); let n=0;
this.list.querySelectorAll('.csd-opt').forEach(o => {
const show = o.dataset.s.includes(q);
o.style.display = show ? '' : 'none';
if (show) n++;
});
if (this.emptyEl) this.emptyEl.style.display = n > 0 ? 'none' : '';
}
_autoGeo() {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const map = JSON.parse(this.root.dataset.geoMap);
const iso2 = map[tz];
if (iso2) {
const opt = this.list.querySelector('[data-v="' + iso2 + '"]');
if (opt) this._pick(opt);
}
} catch (_) {}
}
_move(i) { const all=[...this.list.querySelectorAll('.csd-opt:not([hidden])')]; if(all[i]){all[i].tabIndex=0;all[i].focus();} }
_pick(opt) {
this.hidden.value = opt.dataset.v;
this.icoEl.textContent = opt.querySelector('.csd-opt-ico').textContent;
this.valEl.textContent = opt.querySelector('.csd-opt-main').textContent;
this.valEl.classList.remove('ph');
if (this.subEl) { const s=opt.querySelector('.csd-opt-sub'); this.subEl.textContent=s?s.textContent:''; }
this.list.querySelectorAll('.csd-opt').forEach(o => o.setAttribute('aria-selected', o===opt?'true':'false'));
this.close(); this.btn.focus();
this.hidden.dispatchEvent(new Event('change',{bubbles:true}));
}
};
}());
</script>
@endonce
<div class="csd-wrap {{ $class }}" id="{{ $uid }}" @if($style) style="{{ $style }}" @endif>
@if($label)
<label class="csd-lbl" for="{{ $inputId }}">
{{ $label }}@if($required)<span class="req">*</span>@endif
</label>
@endif
<button type="button"
class="csd-btn"
aria-haspopup="listbox"
aria-expanded="false"
aria-label="{{ $label ?? 'Select timezone' }}">
<span class="csd-ico">{{ $selFlag }}</span>
<span class="csd-val{{ $isPlaceholder ? ' ph' : '' }}">{{ $selLabel }}</span>
@if(!$isPlaceholder && $selSub)
<span class="csd-sub">{{ $selSub }}</span>
@endif
<i class="bi bi-chevron-down csd-arr"></i>
</button>
<div class="csd-panel" hidden role="listbox">
<div class="csd-srch">
<i class="bi bi-search"></i>
<input class="csd-sinput" type="text" placeholder="Search timezone or country…" autocomplete="off" spellcheck="false">
</div>
<ul class="csd-list">
@foreach($options as $opt)
<li class="csd-opt"
role="option"
tabindex="-1"
data-v="{{ $opt['value'] }}"
data-s="{{ $opt['search'] }}"
aria-selected="{{ $value === $opt['value'] ? 'true' : 'false' }}">
<span class="csd-opt-ico">{{ $opt['flag'] }}</span>
<span class="csd-opt-main">{{ $opt['label'] }}</span>
<span class="csd-opt-sub">{{ $opt['utc'] }}</span>
</li>
@endforeach
</ul>
<p class="csd-empty" hidden>No results</p>
</div>
<input type="hidden"
name="{{ $name }}"
id="{{ $inputId }}"
value="{{ $value }}"
@if($required) required @endif>
</div>
<script>
(function () {
function boot() { new CSD('{{ $uid }}'); }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}());
</script>
@@ -46,6 +46,16 @@
color: var(--brand-red) !important;
}
.action-btn.subscribed {
background: var(--brand-red);
border-color: var(--brand-red);
color: #fff !important;
}
.mobile-action-dropdown .dropdown-item.subscribed {
color: var(--brand-red) !important;
}
.mobile-action-dropdown {
display: none;
position: relative;
@@ -99,28 +109,36 @@
<div class="video-actions" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap; overflow: visible;">
@auth
@if (Auth::id() === $video->user_id)
<button class="action-btn desktop-action" onclick="openEditVideoModal({{ $video->id }})">
<button class="action-btn desktop-action" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
<i class="bi bi-pencil"></i>
<span>Edit</span>
</button>
@elseif (Auth::id() !== $video->user_id)
<button class="action-btn desktop-action"><i class="bi bi-bell"></i><span>Subscribe</span></button>
@php $isSubscribed = Auth::user()->isSubscribedTo($video->user); @endphp
<button type="button"
class="action-btn desktop-action subscribe-toggle-btn {{ $isSubscribed ? 'subscribed' : '' }}"
data-channel-id="{{ $video->user_id }}"
data-subscribe-url="{{ route('channel.subscribe', $video->user_id) }}"
data-subscribed="{{ $isSubscribed ? 'true' : 'false' }}">
<i class="bi {{ $isSubscribed ? 'bi-bell-fill' : 'bi-bell' }}"></i>
<span class="subscribe-label">{{ $isSubscribed ? 'Subscribed' : 'Subscribe' }}</span>
</button>
@endif
@else
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action"><i
class="bi bi-bell"></i><span>Subscribe</span></button>
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action">
<i class="bi bi-bell"></i><span>Subscribe</span>
</button>
@endauth
@auth
<form method="POST"
action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}"
class="d-inline desktop-action">
@csrf
<button type="submit" class="action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}">
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
</button>
</form>
<button type="button"
class="action-btn desktop-action like-toggle-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}"
data-video-id="{{ $video->id }}"
data-toggle-url="{{ route('videos.toggleLike', $video) }}"
data-liked="{{ $video->isLikedBy(Auth::user()) ? 'true' : 'false' }}">
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
<span class="like-count-label">{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}</span>
</button>
@else
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action">
<i class="bi bi-hand-thumbs-up"></i>
@@ -130,7 +148,7 @@
@if ($video->isShareable())
<button class="action-btn desktop-action"
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')">
<i class="bi bi-share"></i> Share
</button>
@endif
@@ -144,6 +162,51 @@
<span>Save</span>
</button>
@php
$dlAccess = $video->download_access ?? 'disabled';
$dlUser = Auth::user();
$showDl = match($dlAccess) {
'everyone' => true,
'registered' => (bool) $dlUser,
'subscribers' => $dlUser && ($dlUser->id === $video->user_id || $dlUser->isSubscribedTo($video->user)),
default => false,
};
@endphp
@if($showDl)
@php $isAudioDl = $video->isAudioOnly(); @endphp
<!-- Download Dropdown -->
<div class="dropdown desktop-action" style="display:inline-flex;">
<button class="action-btn dropdown-toggle" type="button" id="downloadDropdown{{ $video->id }}"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-download"></i>
<span>Download</span>
</button>
<ul class="dropdown-menu" aria-labelledby="downloadDropdown{{ $video->id }}"
style="background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:10px;padding:6px 0;min-width:160px;">
<li>
@if($isAudioDl)
<a class="dropdown-item" href="#"
onclick="startSlideshowDownload('{{ $video->getRouteKey() }}'); return false;"
style="color:var(--text-primary);display:flex;align-items:center;gap:8px;font-size:14px;padding:8px 14px;text-decoration:none;">
<i class="bi bi-film"></i> Download Video
</a>
@else
<a class="dropdown-item" href="{{ route('videos.download', $video) }}"
style="color:var(--text-primary);display:flex;align-items:center;gap:8px;font-size:14px;padding:8px 14px;text-decoration:none;">
<i class="bi bi-film"></i> Download Video
</a>
@endif
</li>
<li>
<a class="dropdown-item" href="{{ route('videos.downloadMp3', $video) }}"
style="color:var(--text-primary);display:flex;align-items:center;gap:8px;font-size:14px;padding:8px 14px;text-decoration:none;">
<i class="bi bi-music-note-beamed"></i> Download MP3
</a>
</li>
</ul>
</div>
@endif
<div class="dropdown mobile-action-dropdown">
<button class="action-btn dropdown-toggle" type="button" id="dropdownMenuLinkMusic{{ $video->id }}"
data-bs-toggle="dropdown" aria-expanded="false">
@@ -153,11 +216,17 @@
<div class="dropdown-menu" aria-labelledby="dropdownMenuLinkMusic{{ $video->id }}">
@auth
@if (Auth::id() !== $video->user_id)
<button type="button" class="dropdown-item">
<i class="bi bi-bell"></i> Subscribe
@php $isSubscribed = Auth::user()->isSubscribedTo($video->user); @endphp
<button type="button"
class="dropdown-item subscribe-toggle-btn {{ $isSubscribed ? 'subscribed' : '' }}"
data-channel-id="{{ $video->user_id }}"
data-subscribe-url="{{ route('channel.subscribe', $video->user_id) }}"
data-subscribed="{{ $isSubscribed ? 'true' : 'false' }}">
<i class="bi {{ $isSubscribed ? 'bi-bell-fill' : 'bi-bell' }}"></i>
<span class="subscribe-label">{{ $isSubscribed ? 'Subscribed' : 'Subscribe' }}</span>
</button>
@else
<button type="button" class="dropdown-item" onclick="openEditVideoModal({{ $video->id }})">
<button type="button" class="dropdown-item" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
<i class="bi bi-pencil"></i> Edit
</button>
@endif
@@ -168,16 +237,14 @@
@endauth
@auth
<form method="POST"
action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}"
class="d-inline w-100">
@csrf
<button type="submit" class="dropdown-item {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}">
<i
class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
</button>
</form>
<button type="button"
class="dropdown-item like-toggle-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}"
data-video-id="{{ $video->id }}"
data-toggle-url="{{ route('videos.toggleLike', $video) }}"
data-liked="{{ $video->isLikedBy(Auth::user()) ? 'true' : 'false' }}">
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
<span class="like-count-label">{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}</span>
</button>
@else
<button class="dropdown-item" onclick="window.location.href='{{ route('login') }}'">
<i class="bi bi-hand-thumbs-up"></i>
@@ -187,7 +254,7 @@
@if ($video->isShareable())
<button class="dropdown-item"
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')">
<i class="bi bi-share"></i> Share
</button>
@endif
@@ -195,11 +262,332 @@
<button class="dropdown-item" onclick="openAddToPlaylistModal({{ $video->id }})">
<i class="bi bi-bookmark"></i> Save
</button>
@if($showDl)
@if($isAudioDl)
<a class="dropdown-item" href="#"
onclick="startSlideshowDownload('{{ $video->getRouteKey() }}'); return false;"
style="color:var(--text-primary);display:flex;align-items:center;gap:8px;">
<i class="bi bi-film"></i> Download Video
</a>
@else
<a class="dropdown-item" href="{{ route('videos.download', $video) }}"
style="color:var(--text-primary);display:flex;align-items:center;gap:8px;">
<i class="bi bi-film"></i> Download Video
</a>
@endif
<a class="dropdown-item" href="{{ route('videos.downloadMp3', $video) }}"
style="color:var(--text-primary);display:flex;align-items:center;gap:8px;">
<i class="bi bi-music-note-beamed"></i> Download MP3
</a>
@endif
@if(Auth::check() && Auth::id() === $video->user_id)
<button type="button" class="dropdown-item text-danger" onclick="showDeleteModal({{ $video->id }}, '{{ addslashes($video->title) }}')">
<button type="button" class="dropdown-item text-danger" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})">
<i class="bi bi-trash"></i> Delete
</button>
@endif
</div>
</div>
</div>
<script>
(function () {
var csrfToken = '{{ csrf_token() }}';
// ── Subscribe toggle ─────────────────────────────────────────
document.addEventListener('click', function (e) {
var btn = e.target.closest('.subscribe-toggle-btn');
if (!btn) return;
var url = btn.dataset.subscribeUrl;
var subscribed = btn.dataset.subscribed === 'true';
var channelId = btn.dataset.channelId;
var nowSub = !subscribed;
// Optimistic update on all matching buttons (desktop + mobile)
document.querySelectorAll('.subscribe-toggle-btn[data-channel-id="' + channelId + '"]').forEach(function (b) {
b.dataset.subscribed = nowSub ? 'true' : 'false';
b.classList.toggle('subscribed', nowSub);
var icon = b.querySelector('i');
var label = b.querySelector('.subscribe-label');
if (icon) icon.className = 'bi ' + (nowSub ? 'bi-bell-fill' : 'bi-bell');
if (label) label.textContent = nowSub ? 'Subscribed' : 'Subscribe';
});
// Also update channel-row subscriber counts on the page
document.querySelectorAll('.channel-subs[data-channel-id="' + channelId + '"]').forEach(function (el) {
var n = parseInt(el.dataset.count || 0);
el.dataset.count = nowSub ? n + 1 : Math.max(0, n - 1);
el.textContent = Number(el.dataset.count).toLocaleString() + ' subscribers';
});
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' },
credentials: 'same-origin',
})
.then(function (r) {
if (!r.ok) throw new Error(r.status);
return r.json();
})
.then(function (data) {
document.querySelectorAll('.subscribe-toggle-btn[data-channel-id="' + channelId + '"]').forEach(function (b) {
b.dataset.subscribed = data.subscribed ? 'true' : 'false';
b.classList.toggle('subscribed', data.subscribed);
var icon = b.querySelector('i');
var label = b.querySelector('.subscribe-label');
if (icon) icon.className = 'bi ' + (data.subscribed ? 'bi-bell-fill' : 'bi-bell');
if (label) label.textContent = data.subscribed ? 'Subscribed' : 'Subscribe';
});
document.querySelectorAll('.channel-subs[data-channel-id="' + channelId + '"]').forEach(function (el) {
el.dataset.count = data.subscriber_count;
el.textContent = Number(data.subscriber_count).toLocaleString() + ' subscribers';
});
})
.catch(function (err) {
// Revert optimistic update
document.querySelectorAll('.subscribe-toggle-btn[data-channel-id="' + channelId + '"]').forEach(function (b) {
b.dataset.subscribed = subscribed ? 'true' : 'false';
b.classList.toggle('subscribed', subscribed);
var icon = b.querySelector('i');
var label = b.querySelector('.subscribe-label');
if (icon) icon.className = 'bi ' + (subscribed ? 'bi-bell-fill' : 'bi-bell');
if (label) label.textContent = subscribed ? 'Subscribed' : 'Subscribe';
});
if (typeof showToast === 'function') showToast('Could not update subscription. Please try again.', 'error');
});
});
document.addEventListener('click', function (e) {
var btn = e.target.closest('.like-toggle-btn');
if (!btn) return;
var url = btn.dataset.toggleUrl;
var liked = btn.dataset.liked === 'true';
// Optimistic UI update
var allBtns = document.querySelectorAll('.like-toggle-btn[data-video-id="' + btn.dataset.videoId + '"]');
allBtns.forEach(function (b) {
var icon = b.querySelector('i');
var label = b.querySelector('.like-count-label');
var nowLiked = !liked;
b.dataset.liked = nowLiked ? 'true' : 'false';
b.classList.toggle('liked', nowLiked);
if (icon) {
icon.className = 'bi ' + (nowLiked ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up');
}
if (label) {
var current = parseInt(label.textContent.replace(/,/g, '')) || 0;
var next = nowLiked ? current + 1 : Math.max(0, current - 1);
label.textContent = next > 0 ? next.toLocaleString() : 'Like';
}
});
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' },
})
.then(function (r) { return r.json(); })
.then(function (data) {
// Reconcile with server truth
allBtns.forEach(function (b) {
var icon = b.querySelector('i');
var label = b.querySelector('.like-count-label');
b.dataset.liked = data.liked ? 'true' : 'false';
b.classList.toggle('liked', data.liked);
if (icon) icon.className = 'bi ' + (data.liked ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up');
if (label) label.textContent = data.like_count > 0 ? data.like_count.toLocaleString() : 'Like';
});
})
.catch(function () {
// Revert optimistic update on failure
allBtns.forEach(function (b) {
var icon = b.querySelector('i');
b.dataset.liked = liked ? 'true' : 'false';
b.classList.toggle('liked', liked);
if (icon) icon.className = 'bi ' + (liked ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up');
});
if (typeof toast === 'function') toast('Could not update like. Try again.', 'error');
});
});
})();
// ── Slideshow video generation progress ──────────────────────────────
if (!window._slideshowDlInit) {
window._slideshowDlInit = true;
// Inject progress overlay once
var _overlay = document.createElement('div');
_overlay.id = 'sl-dl-overlay';
_overlay.innerHTML = `
<div id="sl-dl-box">
<div id="sl-dl-icon"><i class="bi bi-film"></i></div>
<h5 id="sl-dl-title">Generating Video</h5>
<p id="sl-dl-status">Starting...</p>
<div id="sl-dl-track">
<div id="sl-dl-bar"></div>
</div>
<div id="sl-dl-pct">0%</div>
<p id="sl-dl-hint">This may take a minute for long tracks</p>
<button id="sl-dl-cancel" onclick="window._slideshowDlCancel()">Cancel</button>
</div>`;
document.body.appendChild(_overlay);
// Styles
var _style = document.createElement('style');
_style.textContent = `
#sl-dl-overlay {
display: none; position: fixed; inset: 0; z-index: 99999;
background: rgba(0,0,0,.85); align-items: center; justify-content: center;
}
#sl-dl-overlay.active { display: flex; }
#sl-dl-box {
background: #1e1e1e; border: 1px solid #333; border-radius: 18px;
padding: 36px 32px; width: 90%; max-width: 400px; text-align: center;
box-shadow: 0 24px 80px rgba(0,0,0,.7);
}
#sl-dl-icon { font-size: 44px; color: #e61e1e; margin-bottom: 14px; }
#sl-dl-title { color: #fff; font-size: 18px; font-weight: 600; margin: 0 0 6px; }
#sl-dl-status { color: #aaa; font-size: 13px; margin: 0 0 20px; min-height: 18px; }
#sl-dl-track {
background: #2a2a2a; border-radius: 6px; height: 10px;
overflow: hidden; margin-bottom: 10px;
}
#sl-dl-bar {
background: linear-gradient(90deg, #e61e1e, #ff4757);
height: 100%; width: 0%; border-radius: 6px;
transition: width .6s ease;
}
#sl-dl-pct { color: #e61e1e; font-size: 22px; font-weight: 700; margin-bottom: 4px; }
#sl-dl-hint { color: #555; font-size: 11px; margin: 8px 0 16px; }
#sl-dl-cancel {
background: none; border: 1px solid #444; color: #888;
border-radius: 8px; padding: 6px 18px; font-size: 13px; cursor: pointer;
}
#sl-dl-cancel:hover { border-color: #e61e1e; color: #e61e1e; }
`;
document.head.appendChild(_style);
var _pollTimer = null;
var _currentKey = null;
var _maxPct = 0; // progress only ever moves forward
window._slideshowDlCancel = function () {
clearInterval(_pollTimer);
_pollTimer = null;
_currentKey = null;
_maxPct = 0;
document.getElementById('sl-dl-overlay').classList.remove('active');
};
function _setProgress(pct, status) {
// Never decrease the bar — only move forward (unless resetting to 0 on error/cancel)
pct = Math.max(pct, _maxPct);
_maxPct = pct;
document.getElementById('sl-dl-bar').style.width = pct + '%';
document.getElementById('sl-dl-pct').textContent = pct + '%';
document.getElementById('sl-dl-status').textContent = status;
}
function _setError(msg) {
_maxPct = 0;
document.getElementById('sl-dl-bar').style.width = '0%';
document.getElementById('sl-dl-bar').style.background = '#e61e1e';
document.getElementById('sl-dl-pct').textContent = '✕';
document.getElementById('sl-dl-status').textContent = msg || 'Generation failed. Please try again.';
document.getElementById('sl-dl-cancel').textContent = 'Close';
}
window.startSlideshowDownload = function (routeKey) {
if (_currentKey && _currentKey !== routeKey) {
// Different video — reset
clearInterval(_pollTimer);
_pollTimer = null;
}
_currentKey = routeKey;
_maxPct = 0;
document.getElementById('sl-dl-bar').style.background = ''; // restore gradient
document.getElementById('sl-dl-cancel').textContent = 'Cancel';
_setProgress(0, 'Starting...');
document.getElementById('sl-dl-overlay').classList.add('active');
document.getElementById('sl-dl-cancel').style.display = '';
// Use the global csrf variable set by the layout, with meta tag as fallback
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
var token = (typeof csrf !== 'undefined' ? csrf : '') || (csrfMeta ? csrfMeta.getAttribute('content') : '');
fetch('/videos/' + routeKey + '/slideshow/generate', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': token, 'Accept': 'application/json' }
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.status === 'ready') {
_maxPct = 100;
document.getElementById('sl-dl-bar').style.width = '100%';
document.getElementById('sl-dl-pct').textContent = '100%';
document.getElementById('sl-dl-status').textContent = 'Ready! Starting download...';
document.getElementById('sl-dl-cancel').style.display = 'none';
setTimeout(function () {
window._slideshowDlCancel();
window.location.href = '/videos/' + routeKey + '/download';
}, 600);
return;
}
if (data.error) {
_setError('Error: ' + data.error);
return;
}
var duration = data.duration || 0;
_setProgress(2, 'Generating video...');
_pollTimer = setInterval(function () {
fetch('/videos/' + routeKey + '/slideshow/progress?duration=' + duration, {
headers: { 'Accept': 'application/json' }
})
.then(function (r) { return r.json(); })
.then(function (prog) {
if (prog.status === 'error') {
clearInterval(_pollTimer);
_pollTimer = null;
_setError(prog.message || null);
return;
}
if (prog.status === 'ready') {
clearInterval(_pollTimer);
_pollTimer = null;
_maxPct = 100;
document.getElementById('sl-dl-bar').style.width = '100%';
document.getElementById('sl-dl-pct').textContent = '100%';
document.getElementById('sl-dl-status').textContent = 'Ready! Starting download...';
document.getElementById('sl-dl-cancel').style.display = 'none';
setTimeout(function () {
window._slideshowDlCancel();
window.location.href = '/videos/' + routeKey + '/download';
}, 600);
return;
}
// 'waiting' means the process just started — keep the current bar position
if (prog.status === 'waiting') return;
var pct = prog.percent || 0;
var remaining = '';
if (duration > 0 && pct > 2) {
var secs = Math.round(duration * (100 - pct) / pct);
remaining = secs > 60
? ' (~' + Math.ceil(secs / 60) + ' min left)'
: ' (~' + secs + 's left)';
}
_setProgress(pct, 'Generating video...' + remaining);
})
.catch(function () { /* ignore transient poll errors */ });
}, 1500);
})
.catch(function () {
_setProgress(0, 'Failed to start. Please try again.');
});
};
}
</script>
+178 -42
View File
@@ -26,14 +26,21 @@ $sizeClasses = match($size) {
@endphp
<div class="yt-video-card {{ $sizeClasses }}" data-video-url="{{ $videoUrl }}">
<a href="{{ $video ? route('videos.show', $video->id) : '#' }}">
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)">
<a href="{{ $video ? route('videos.show', $video) : '#' }}">
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}">
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}">
@if($videoUrl)
<video preload="none">
<source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}">
</video>
@endif
{{-- Equalizer overlay shown when audio-only track is previewing --}}
<div class="audio-preview-overlay">
<div class="audio-eq">
<span></span><span></span><span></span><span></span><span></span>
</div>
</div>
@if($video && $video->duration)
<span class="yt-video-duration">{{ gmdate('i:s', $video->duration) }}</span>
@endif
@@ -42,27 +49,44 @@ $sizeClasses = match($size) {
<i class="bi bi-collection-play-fill"></i> SHORTS
</span>
@endif
@if($isOwner && $video->visibility === 'private')
<span class="yt-visibility-badge yt-visibility-private">
<i class="bi bi-lock-fill"></i> Private
</span>
@elseif($isOwner && $video->visibility === 'unlisted')
<span class="yt-visibility-badge yt-visibility-unlisted">
<i class="bi bi-link-45deg"></i> Unlisted
</span>
@endif
</div>
</a>
<div class="yt-video-info">
<div class="yt-channel-icon">
<a href="{{ $video && $video->user ? route('channel', $video->user->channel) : '#' }}"
class="yt-channel-icon" onclick="event.stopPropagation();">
@if($video && $video->user && $video->user->avatar_url)
<img src="{{ $video->user->avatar_url }}" alt="{{ $video->user->name }}" style="width: 100%; height: 100%; object-fit: cover; border-radius: 50%;">
@endif
</div>
</a>
<div class="yt-video-details">
<h3 class="yt-video-title">
<a href="{{ $video ? route('videos.show', $video->id) : '#' }}">
<i class="bi {{ $typeIcon }}" style="color: #ef4444; margin-right: 6px;"></i>
<a href="{{ $video ? route('videos.show', $video) : '#' }}">
{{ $video->title ?? 'Untitled Video' }}
</a>
</h3>
@if($video && $video->user)
<div class="yt-channel-name">{{ $video->user->name }}</div>
<a href="{{ route('channel', $video->user->channel) }}"
class="yt-channel-name" onclick="event.stopPropagation();">{{ $video->user->name }}</a>
@endif
@if($video)
<div class="yt-video-meta">
{{ number_format($video->view_count) }} views • {{ $video->created_at->diffForHumans() }}
@if($video->type && $video->type !== 'generic')
<span class="yt-type-label yt-type-{{ $video->type }}">
<i class="bi {{ $typeIcon }}"></i>
{{ ucfirst($video->type === 'match' ? 'Sports' : $video->type) }}
</span>
&nbsp;·&nbsp;
@endif
{{ number_format($video->view_count) }} views · {{ $video->created_at->diffForHumans() }}
</div>
@endif
</div>
@@ -74,47 +98,49 @@ $sizeClasses = match($size) {
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
@if($isOwner)
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="openEditVideoModal('{{ $video->id }}')">
<a class="dropdown-item" href="javascript:void(0)" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
<i class="bi bi-pencil"></i> Edit
</a>
</li>
<li>
<button type="button" class="dropdown-item text-danger" onclick="showDeleteModal('{{ $video->id }}', '{{ addslashes($video->title) }}')">
<button type="button" class="dropdown-item text-danger" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})">
<i class="bi bi-trash"></i> Delete
</button>
</li>
<li><hr class="dropdown-divider"></li>
@endif
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="addToQueue('{{ $video->id }}')">
<a class="dropdown-item" href="javascript:void(0)" onclick="addToQueue('{{ $video->getRouteKey() }}')">
<i class="bi bi-list-nested"></i> Add to queue
</a>
</li>
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="saveToWatchLater('{{ $video->id }}')">
<a class="dropdown-item" href="javascript:void(0)" onclick="saveToWatchLater('{{ $video->getRouteKey() }}')">
<i class="bi bi-clock"></i> Save to Watch later
</a>
</li>
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="openPlaylistModal('{{ $video->id }}')">
<a class="dropdown-item" href="javascript:void(0)" onclick="openPlaylistModal('{{ $video->getRouteKey() }}')">
<i class="bi bi-bookmark"></i> Save to playlist
</a>
</li>
@if($video->allow_download)
<li>
<a class="dropdown-item" href="{{ route('videos.download', $video->id) }}">
<a class="dropdown-item" href="{{ route('videos.download', $video) }}">
<i class="bi bi-download"></i> Download
</a>
</li>
@endif
@if($video->isShareable())
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
<a class="dropdown-item" href="javascript:void(0)" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')">
<i class="bi bi-share"></i> Share
</a>
</li>
@endif
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="notInterested('{{ $video->id }}')">
<a class="dropdown-item" href="javascript:void(0)" onclick="notInterested('{{ $video->getRouteKey() }}')">
<i class="bi bi-dash-circle"></i> Not interested
</a>
</li>
@@ -124,7 +150,7 @@ $sizeClasses = match($size) {
</a>
</li>
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="reportVideo('{{ $video->id }}')">
<a class="dropdown-item" href="javascript:void(0)" onclick="reportVideo('{{ $video->getRouteKey() }}')">
<i class="bi bi-flag"></i> Report
</a>
</li>
@@ -134,30 +160,31 @@ $sizeClasses = match($size) {
</div>
</div>
@php $vk = $video->getRouteKey() ?? ''; @endphp
<!-- Cute Edit Video Modal -->
<div class="modal fade" id="editVideoModal{{ $video->id ?? '' }}" tabindex="-1" aria-labelledby="editVideoModalLabel{{ $video->id ?? '' }}" aria-hidden="true">
<div class="modal fade" id="editVideoModal{{ $vk }}" tabindex="-1" aria-labelledby="editVideoModalLabel{{ $vk }}" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered cute-edit-modal">
<div class="modal-content cute-edit-content">
<div class="cute-edit-header">
<span class="cute-edit-icon">✏️</span>
<h5>Edit Video</h5>
<button type="button" class="btn-close-cute" onclick="closeEditVideoModal('{{ $video->id ?? '' }}')">×</button>
<button type="button" class="btn-close-cute" onclick="closeEditVideoModal('{{ $vk }}')">×</button>
</div>
<div class="cute-edit-body">
<form id="edit-video-form-{{ $video->id ?? '' }}" enctype="multipart/form-data">
<form id="edit-video-form-{{ $vk }}" enctype="multipart/form-data">
@csrf
@method('PUT')
<!-- Title -->
<div class="cute-form-group">
<label><i class="bi bi-card-heading"></i> Title</label>
<input type="text" name="title" id="edit-title-{{ $video->id ?? '' }}" class="cute-input" placeholder="Video title">
<input type="text" name="title" id="edit-title-{{ $vk }}" class="cute-input" placeholder="Video title">
</div>
<!-- Description -->
<div class="cute-form-group">
<label><i class="bi bi-text-paragraph"></i> Description</label>
<textarea name="description" id="edit-description-{{ $video->id ?? '' }}" class="cute-textarea" rows="2" placeholder="Tell viewers about your video"></textarea>
<textarea name="description" id="edit-description-{{ $vk }}" class="cute-textarea" rows="2" placeholder="Tell viewers about your video"></textarea>
</div>
<!-- Video Type -->
@@ -183,7 +210,7 @@ $sizeClasses = match($size) {
<div class="cute-form-group">
<label><i class="bi bi-lightning-charge-fill"></i> Shorts</label>
<label class="cute-shorts-toggle">
<input type="checkbox" name="is_shorts" id="edit-is-shorts-{{ $video->id ?? '' }}" value="1">
<input type="checkbox" name="is_shorts" id="edit-is-shorts-{{ $vk }}" value="1">
<span class="cute-shorts-slider"></span>
<span class="cute-shorts-label">Mark as Short</span>
</label>
@@ -192,9 +219,9 @@ $sizeClasses = match($size) {
<!-- Thumbnail -->
<div class="cute-form-group">
<label><i class="bi bi-image"></i> Thumbnail</label>
<div class="cute-thumbnail-upload" onclick="document.getElementById('edit-thumbnail-{{ $video->id ?? '' }}').click()">
<input type="file" name="thumbnail" id="edit-thumbnail-{{ $video->id ?? '' }}" accept="image/*" hidden>
<div class="cute-thumbnail-preview" id="thumbnail-preview-{{ $video->id ?? '' }}">
<div class="cute-thumbnail-upload" onclick="document.getElementById('edit-thumbnail-{{ $vk }}').click()">
<input type="file" name="thumbnail" id="edit-thumbnail-{{ $vk }}" accept="image/*" hidden>
<div class="cute-thumbnail-preview" id="thumbnail-preview-{{ $vk }}">
<i class="bi bi-camera"></i>
<span>Click to change</span>
</div>
@@ -221,11 +248,11 @@ $sizeClasses = match($size) {
</div>
<!-- Status -->
<div class="cute-status" id="edit-status-{{ $video->id ?? '' }}"></div>
<div class="cute-status" id="edit-status-{{ $vk }}"></div>
<!-- Buttons -->
<div class="cute-edit-actions">
<button type="button" class="cute-btn-cancel" onclick="closeEditVideoModal('{{ $video->id ?? '' }}')">Cancel</button>
<button type="button" class="cute-btn-cancel" onclick="closeEditVideoModal('{{ $vk }}')">Cancel</button>
<button type="submit" class="cute-btn-save">Save</button>
</div>
</form>
@@ -255,6 +282,10 @@ $sizeClasses = match($size) {
position: absolute;
top: 0;
left: 0;
transition: transform 0.2s ease;
}
.yt-video-card:hover .yt-video-thumb img {
transform: scale(1.03);
}
.yt-video-card .yt-video-thumb video {
@@ -273,6 +304,52 @@ $sizeClasses = match($size) {
opacity: 1;
}
/* Audio preview overlay (equalizer) */
.audio-preview-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
justify-content: center;
padding-bottom: 14px;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.yt-video-thumb.audio-playing .audio-preview-overlay {
opacity: 1;
}
.audio-eq {
display: flex;
align-items: flex-end;
gap: 3px;
height: 24px;
background: rgba(0,0,0,0.55);
padding: 4px 8px;
border-radius: 20px;
}
.audio-eq span {
display: block;
width: 3px;
border-radius: 2px;
background: #e61e1e;
animation: eq-bar 0.8s ease-in-out infinite;
}
.audio-eq span:nth-child(1) { height: 6px; animation-delay: 0s; }
.audio-eq span:nth-child(2) { height: 14px; animation-delay: 0.15s; }
.audio-eq span:nth-child(3) { height: 20px; animation-delay: 0.05s; }
.audio-eq span:nth-child(4) { height: 12px; animation-delay: 0.2s; }
.audio-eq span:nth-child(5) { height: 8px; animation-delay: 0.1s; }
@keyframes eq-bar {
0%, 100% { transform: scaleY(0.4); }
50% { transform: scaleY(1); }
}
.yt-video-card .yt-video-duration {
position: absolute;
bottom: 8px;
@@ -306,6 +383,31 @@ $sizeClasses = match($size) {
font-size: 12px;
}
.yt-video-card .yt-visibility-badge {
position: absolute;
bottom: 8px;
left: 8px;
padding: 3px 7px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
gap: 4px;
pointer-events: none;
}
.yt-video-card .yt-visibility-private {
background: rgba(220, 38, 38, 0.88);
color: #fff;
}
.yt-video-card .yt-visibility-unlisted {
background: rgba(30, 30, 30, 0.82);
color: #facc15;
border: 1px solid rgba(250, 204, 21, 0.4);
}
.yt-video-card .yt-video-info {
display: flex;
margin-top: 12px;
@@ -343,6 +445,11 @@ $sizeClasses = match($size) {
text-decoration: none;
}
.yt-video-card .yt-channel-icon {
text-decoration: none;
display: block;
}
.yt-video-card .yt-channel-name,
.yt-video-card .yt-video-meta {
color: #aaa;
@@ -352,7 +459,23 @@ $sizeClasses = match($size) {
text-overflow: ellipsis;
}
/* More button */
.yt-video-card a.yt-channel-name {
display: block;
text-decoration: none;
}
.yt-video-card a.yt-channel-name:hover { color: #fff; }
/* Type label in meta row */
.yt-type-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.yt-type-music { color: #c084fc; }
.yt-type-match { color: #60a5fa; }
/* More button — visible only on hover (touch devices always show it) */
.yt-video-card .yt-more-btn {
background: transparent;
border: none;
@@ -365,10 +488,15 @@ $sizeClasses = match($size) {
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
transition: background 0.2s, opacity 0.15s;
flex-shrink: 0;
}
@media (hover: hover) {
.yt-video-card .yt-more-btn { opacity: 0; }
.yt-video-card:hover .yt-more-btn,
.yt-video-card .yt-more-btn:focus,
.yt-video-card .yt-more-btn[aria-expanded="true"] { opacity: 1; }
}
.yt-video-card .yt-more-btn:hover {
background: #3f3f3f;
}
@@ -746,6 +874,7 @@ $sizeClasses = match($size) {
}
</style>
@once
<script>
// Global function to save to Watch Later
function saveToWatchLater(videoId) {
@@ -760,7 +889,7 @@ function saveToWatchLater(videoId) {
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message || 'Added to Watch Later');
showToast(data.message || 'Added to Watch Later', 'success');
}
})
.catch(error => console.error('Error:', error));
@@ -801,26 +930,32 @@ function openPlaylistModal(videoId) {
// Global function to add to queue
function addToQueue(videoId) {
alert('Queue feature coming soon!');
showToast('Queue feature coming soon!', 'info');
}
function playVideo(element) {
const video = element.querySelector('video');
if (video) {
video.currentTime = 0;
video.volume = 0.10;
video.play().catch(function(e) {});
if (!video) return;
video.currentTime = 0;
const isAudio = element.dataset.audio === 'true';
video.volume = isAudio ? 0.4 : 0.10;
if (isAudio) {
// Keep thumbnail visible — just play audio, show equalizer
video.play().catch(function() {});
element.classList.add('audio-playing');
} else {
video.play().catch(function() {});
video.classList.add('active');
}
}
function stopVideo(element) {
const video = element.querySelector('video');
if (video) {
video.pause();
video.currentTime = 0;
video.classList.remove('active');
}
if (!video) return;
video.pause();
video.currentTime = 0;
video.classList.remove('active');
element.classList.remove('audio-playing');
}
// Edit Modal Functions
@@ -967,3 +1102,4 @@ document.addEventListener('submit', function(e) {
}
});
</script>
@endonce
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,785 @@
{{--
Video Insights component.
Expects: $video (Video model)
Renders the Insights tab panel + modal + all JS — only for the video owner.
--}}
@php
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
@endphp
@if($isVideoOwner)
<style>
/* ── Stat cards ──────────────────────────────────────── */
.ins-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin-bottom:18px; }
@media(max-width:680px){ .ins-grid { grid-template-columns:repeat(2,1fr); } }
.ins-card { background:var(--bg-dark); border:1px solid var(--border-color); border-radius:10px; padding:14px 14px 12px; display:flex; flex-direction:column; gap:4px; cursor:pointer; transition:border-color .15s, transform .12s, box-shadow .15s; user-select:none; }
.ins-card:hover { border-color:rgba(239,68,68,.5); transform:translateY(-2px); box-shadow:0 4px 16px rgba(0,0,0,.3); }
.ins-card:active { transform:translateY(0); }
.ins-card-icon { font-size:18px; margin-bottom:2px; }
.ins-card-val { font-size:26px; font-weight:900; line-height:1; color:var(--text-primary); }
.ins-card-label { font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.05em; color:var(--text-secondary); }
.ins-card-sub { font-size:12px; margin-top:3px; font-weight:600; }
.ins-card-sub.up { color:#4ade80; } .ins-card-sub.down { color:#f87171; } .ins-card-sub.neu { color:var(--text-secondary); }
/* ── Section titles / charts ────────────────────────── */
.ins-section-title { font-size:12px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--text-secondary); margin-bottom:12px; display:flex; align-items:center; gap:6px; }
.ins-chart { display:flex; align-items:flex-end; gap:4px; height:80px; margin-bottom:6px; }
.ins-bar-col { flex:1; display:flex; flex-direction:column; align-items:center; gap:4px; min-width:0; }
.ins-bar { width:100%; border-radius:4px 4px 0 0; background:#ef444466; transition:height .6s cubic-bezier(.22,.61,.36,1), background .15s; min-height:2px; }
.ins-bar.clickable { cursor:pointer; }
.ins-bar.clickable:hover { background:#ef4444cc !important; }
.ins-bar-today { background:#ef4444 !important; }
.ins-bar-label { font-size:9px; color:var(--text-secondary); text-align:center; overflow:hidden; white-space:nowrap; }
.ins-peak-badge { display:inline-flex; align-items:center; gap:6px; background:rgba(239,68,68,.12); border:1px solid rgba(239,68,68,.25); border-radius:20px; padding:4px 12px; font-size:12px; font-weight:600; color:#fca5a5; }
.ins-body { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
@media(max-width:600px){ .ins-body { grid-template-columns:1fr; } }
/* ── Country rows ───────────────────────────────────── */
.ins-country-row { display:flex; align-items:center; gap:10px; padding:7px 0; border-bottom:1px solid rgba(255,255,255,.04); cursor:pointer; border-radius:6px; transition:background .12s; }
.ins-country-row:last-child { border-bottom:none; }
.ins-country-row:hover { background:rgba(255,255,255,.04); padding-left:6px; }
.ins-country-flag { font-size:20px; flex-shrink:0; width:28px; text-align:center; line-height:1; }
.ins-country-name { flex:1; font-size:13px; font-weight:500; color:var(--text-primary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ins-country-bar-wrap { width:100px; height:5px; background:rgba(255,255,255,.07); border-radius:4px; overflow:hidden; flex-shrink:0; }
.ins-country-bar { height:100%; background:#ef4444; border-radius:4px; }
.ins-country-pct { font-size:12px; font-weight:700; color:var(--text-secondary); min-width:34px; text-align:right; }
.ins-country-cnt { font-size:11px; color:var(--text-secondary); min-width:40px; text-align:right; }
/* ── Download rows ───────────────────────────────────── */
.ins-dl-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; flex-wrap:wrap; gap:8px; }
.ins-dl-type-pills { display:flex; gap:6px; }
.ins-dl-pill { display:inline-flex; align-items:center; gap:5px; border-radius:20px; padding:3px 11px; font-size:12px; font-weight:600; }
.ins-dl-pill.video { border:1px solid rgba(239,68,68,.3); color:#fca5a5; background:rgba(239,68,68,.08); }
.ins-dl-pill.mp3 { border:1px solid rgba(96,165,250,.3); color:#93c5fd; background:rgba(96,165,250,.08); }
.ins-dl-pill.guest { border:1px solid rgba(255,255,255,.12); color:var(--text-secondary); background:rgba(255,255,255,.05); }
.ins-dl-user-row { display:flex; align-items:center; gap:10px; padding:8px 6px; border-bottom:1px solid rgba(255,255,255,.04); cursor:pointer; border-radius:8px; transition:background .12s; }
.ins-dl-user-row:last-child { border-bottom:none; }
.ins-dl-user-row:hover { background:rgba(255,255,255,.05); }
.ins-dl-avatar { width:34px; height:34px; border-radius:50%; object-fit:cover; flex-shrink:0; background:rgba(255,255,255,.08); }
.ins-dl-user-name { flex:1; font-size:13px; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ins-dl-user-meta { font-size:11px; color:var(--text-secondary); margin-top:1px; }
.ins-dl-count-badge { flex-shrink:0; background:rgba(239,68,68,.12); border:1px solid rgba(239,68,68,.25); color:#fca5a5; border-radius:20px; font-size:12px; font-weight:700; padding:2px 10px; }
.ins-recent-row { display:flex; align-items:center; gap:10px; padding:7px 0; border-bottom:1px solid rgba(255,255,255,.04); font-size:12px; }
.ins-recent-row:last-child { border-bottom:none; }
.ins-recent-avatar { width:28px; height:28px; border-radius:50%; object-fit:cover; flex-shrink:0; background:rgba(255,255,255,.08); display:flex; align-items:center; justify-content:center; font-size:14px; }
.ins-recent-name { flex:1; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ins-recent-type { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; padding:2px 7px; border-radius:6px; flex-shrink:0; }
.ins-recent-type.video { background:rgba(239,68,68,.12); color:#fca5a5; }
.ins-recent-type.mp3 { background:rgba(96,165,250,.1); color:#93c5fd; }
.ins-recent-time { color:var(--text-secondary); flex-shrink:0; min-width:70px; text-align:right; }
.ins-recent-flag { font-size:16px; flex-shrink:0; }
/* ── Demographics (gender / age) ─────────────────────── */
.ins-demo-row { display:flex; align-items:center; gap:8px; padding:5px 0; }
.ins-demo-sym { font-size:16px; flex-shrink:0; width:18px; text-align:center; }
.ins-demo-label { font-size:12px; font-weight:600; color:var(--text-primary); min-width:52px; }
.ins-age-label { min-width:68px; }
.ins-demo-bar-wrap { flex:1; height:6px; background:rgba(255,255,255,.07); border-radius:4px; overflow:hidden; }
.ins-demo-bar { height:100%; border-radius:4px; transition:width .6s cubic-bezier(.22,.61,.36,1); }
.ins-demo-pct { font-size:12px; font-weight:700; color:var(--text-secondary); min-width:34px; text-align:right; }
.ins-demo-cnt { font-size:11px; color:var(--text-secondary); min-width:30px; text-align:right; }
/* ── Skeleton ─────────────────────────────────────────── */
.ins-skeleton { display:flex; flex-direction:column; gap:12px; padding:4px 0; }
.ins-skel-row { height:14px; border-radius:6px; background:rgba(255,255,255,.06); animation:skelPulse 1.4s ease-in-out infinite; }
@keyframes skelPulse { 0%,100%{opacity:.5} 50%{opacity:1} }
/* ── Insight modal ───────────────────────────────────── */
.ins-modal-backdrop { position:fixed; inset:0; background:rgba(0,0,0,.72); z-index:9200; display:flex; align-items:center; justify-content:center; padding:16px; animation:insBackIn .15s ease; }
@keyframes insBackIn { from{opacity:0} to{opacity:1} }
.ins-modal { background:var(--bg-secondary); border:1px solid var(--border-color); border-radius:18px; width:100%; max-width:500px; max-height:84vh; display:flex; flex-direction:column; overflow:hidden; animation:insModalIn .22s cubic-bezier(.34,1.4,.64,1); }
@keyframes insModalIn { from{opacity:0;transform:scale(.88) translateY(20px)} to{opacity:1;transform:none} }
.ins-modal-head { display:flex; align-items:center; gap:12px; padding:16px 18px 13px; border-bottom:1px solid var(--border-color); flex-shrink:0; }
.ins-modal-icon { font-size:26px; line-height:1; }
.ins-modal-titles { flex:1; min-width:0; }
.ins-modal-title { font-size:15px; font-weight:700; color:var(--text-primary); }
.ins-modal-subtitle { font-size:12px; color:var(--text-secondary); margin-top:1px; }
.ins-modal-close { background:rgba(255,255,255,.06); border:none; color:var(--text-secondary); border-radius:50%; width:30px; height:30px; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:16px; transition:background .15s,color .15s; flex-shrink:0; }
.ins-modal-close:hover { background:rgba(239,68,68,.2); color:#fca5a5; }
.ins-modal-body { padding:18px 18px 22px; overflow-y:auto; flex:1; }
/* Modal content pieces */
.ins-modal-hero { text-align:center; padding:4px 0 20px; }
.ins-modal-hero-num { font-size:52px; font-weight:900; line-height:1; color:var(--text-primary); }
.ins-modal-hero-flag { font-size:56px; line-height:1; margin-bottom:4px; }
.ins-modal-hero-label { font-size:13px; color:var(--text-secondary); margin-top:6px; }
.ins-modal-stat { display:flex; justify-content:space-between; align-items:center; padding:9px 0; border-bottom:1px solid rgba(255,255,255,.05); font-size:13px; }
.ins-modal-stat:last-child { border-bottom:none; }
.ins-modal-stat-lbl { color:var(--text-secondary); font-weight:500; display:flex; align-items:center; gap:7px; }
.ins-modal-stat-val { font-weight:700; color:var(--text-primary); }
.ins-modal-section { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--text-secondary); margin:18px 0 10px; display:flex; align-items:center; gap:6px; }
.ins-modal-section:first-child { margin-top:0; }
/* Hourly chart (24 bars) */
.ins-hourly-chart { display:flex; align-items:flex-end; gap:2px; height:60px; margin-bottom:5px; }
.ins-hourly-col { flex:1; display:flex; flex-direction:column; align-items:center; gap:2px; min-width:0; }
.ins-hourly-bar { width:100%; border-radius:3px 3px 0 0; background:#ef444455; min-height:2px; transition:height .5s cubic-bezier(.22,.61,.36,1); }
.ins-hourly-bar.peak { background:#ef4444; }
.ins-hourly-lbl { font-size:7px; color:var(--text-secondary); text-align:center; overflow:hidden; white-space:nowrap; }
/* Person list */
.ins-person-row { display:flex; align-items:center; gap:10px; padding:9px 0; border-bottom:1px solid rgba(255,255,255,.04); cursor:pointer; border-radius:6px; transition:background .12s; }
.ins-person-row:last-child { border-bottom:none; }
.ins-person-row:hover { background:rgba(255,255,255,.04); padding-left:6px; }
.ins-person-avatar { width:36px; height:36px; border-radius:50%; object-fit:cover; flex-shrink:0; background:rgba(255,255,255,.08); }
.ins-person-name { flex:1; font-size:13px; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ins-person-meta { font-size:11px; color:var(--text-secondary); margin-top:1px; }
.ins-person-badge { flex-shrink:0; background:rgba(239,68,68,.1); border:1px solid rgba(239,68,68,.25); color:#fca5a5; border-radius:20px; font-size:11px; font-weight:700; padding:2px 9px; }
.ins-guest-row { display:flex; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid rgba(255,255,255,.04); font-size:12px; }
.ins-guest-row:last-child { border-bottom:none; }
.ins-guest-flag-big { font-size:22px; flex-shrink:0; }
/* Download history entries */
.ins-dl-hist-row { display:flex; align-items:center; gap:10px; padding:9px 12px; border-radius:8px; background:rgba(255,255,255,.03); margin-bottom:6px; font-size:12px; }
.ins-dl-hist-num { font-size:11px; font-weight:700; color:var(--text-secondary); min-width:18px; text-align:center; }
.ins-dl-hist-type { font-size:10px; font-weight:700; text-transform:uppercase; padding:2px 8px; border-radius:6px; flex-shrink:0; }
.ins-dl-hist-type.video { background:rgba(239,68,68,.12); color:#fca5a5; }
.ins-dl-hist-type.mp3 { background:rgba(96,165,250,.1); color:#93c5fd; }
.ins-dl-hist-time { flex:1; color:var(--text-secondary); }
.ins-dl-hist-flag { font-size:15px; flex-shrink:0; }
/* Loading state inside modal */
.ins-modal-loading { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:40px 0; gap:10px; color:var(--text-secondary); font-size:13px; }
.ins-modal-spin { width:28px; height:28px; border:3px solid rgba(255,255,255,.1); border-top-color:#ef4444; border-radius:50%; animation:spin .7s linear infinite; }
@keyframes spin { to{transform:rotate(360deg)} }
</style>
{{-- Panel: placed inside .vdb-wrap right after the About panel --}}
<div class="vdb-panel" id="vdb-insights">
<div id="insightsContent">
<div class="ins-skeleton">
<div class="ins-skel-row" style="width:60%;height:18px;"></div>
<div class="ins-skel-row" style="width:100%;height:80px;"></div>
<div class="ins-skel-row" style="width:80%;"></div>
<div class="ins-skel-row" style="width:90%;"></div>
<div class="ins-skel-row" style="width:70%;"></div>
</div>
</div>
</div>
{{-- Modal (position:fixed, nesting doesn't matter) --}}
<div id="insModalBackdrop" class="ins-modal-backdrop" style="display:none;" onclick="if(event.target===this)closeInsModal()">
<div class="ins-modal" onclick="event.stopPropagation()">
<div class="ins-modal-head">
<span class="ins-modal-icon" id="insModalIcon"></span>
<div class="ins-modal-titles">
<div class="ins-modal-title" id="insModalTitle"></div>
<div class="ins-modal-subtitle" id="insModalSubtitle"></div>
</div>
<button class="ins-modal-close" onclick="closeInsModal()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="ins-modal-body" id="insModalBody"></div>
</div>
</div>
<script>
window._insLoaded = false;
window._insData = null;
// ── Helpers ────────────────────────────────────────────
const _cflag = code => (!code||code.length!==2) ? '🌐' : code.toUpperCase().split('').map(c=>String.fromCodePoint(0x1F1E6+c.charCodeAt(0)-65)).join('');
const _fmt = n => { n=n??0; if(n>=1e6) return (n/1e6).toFixed(1).replace(/\.0$/,'')+'M'; if(n>=1e3) return (n/1e3).toFixed(1).replace(/\.0$/,'')+'K'; return String(n); };
const _fmtH = h => (h===null||h===undefined) ? '—' : (h%12||12)+':00 '+(h>=12?'PM':'AM');
const _ago = s => { const d=Math.floor((Date.now()-new Date(s))/1000); if(d<60) return d+'s ago'; if(d<3600) return Math.floor(d/60)+'m ago'; if(d<86400) return Math.floor(d/3600)+'h ago'; return Math.floor(d/86400)+'d ago'; };
const _fmtDt = s => new Date(s).toLocaleDateString('en-US',{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
// ── Modal helpers ──────────────────────────────────────
function _openModal(icon, title, subtitle, html) {
document.getElementById('insModalIcon').textContent = icon;
document.getElementById('insModalTitle').textContent = title;
document.getElementById('insModalSubtitle').textContent = subtitle || '';
document.getElementById('insModalBody').innerHTML = html;
document.getElementById('insModalBackdrop').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function _modalLoading(icon, title, subtitle) {
_openModal(icon, title, subtitle,
`<div class="ins-modal-loading"><div class="ins-modal-spin"></div>Loading details…</div>`);
}
function closeInsModal() {
document.getElementById('insModalBackdrop').style.display = 'none';
document.body.style.overflow = '';
}
document.addEventListener('keydown', e => { if(e.key==='Escape') closeInsModal(); });
// ── Person list HTML (shared) ──────────────────────────
function _personListHtml(users, emptyMsg) {
if (!users || !users.length) return `<p style="font-size:13px;color:var(--text-secondary);margin:0;">${emptyMsg}</p>`;
return users.map((u,i) => `
<div class="ins-person-row" onclick="window.location.href='/channel/${u.channel||u.id}'" title="View ${u.name}'s profile">
<div style="font-size:11px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<img src="${u.avatar}" alt="${u.name}" class="ins-person-avatar">
<div style="flex:1;min-width:0;">
<div class="ins-person-name">${u.name}</div>
<div class="ins-person-meta">Last seen ${_ago(u.last_at)}</div>
</div>
<div class="ins-person-badge">👁 ${u.count}×</div>
</div>`).join('');
}
// ── Mini bar chart HTML (shared) ───────────────────────
function _miniBarChart(daily) {
const max = Math.max(...daily.map(d=>d.count), 1);
const today = new Date().toLocaleDateString('en-US',{month:'short',day:'numeric'});
return daily.map(d => {
const h = Math.round((d.count/max)*56);
const isT = d.label === today;
return `<div class="ins-bar-col">
<div class="ins-bar${isT?' ins-bar-today':''}" style="height:${Math.max(h,2)}px;" title="${d.label}: ${d.count}"></div>
<div class="ins-bar-label">${d.short}</div>
</div>`;
}).join('');
}
// ── Hourly chart HTML ──────────────────────────────────
function _hourlyChart(hourly) {
const max = Math.max(...hourly.map(h=>h.count), 1);
const peak = hourly.reduce((a,b)=>b.count>a.count?b:a, hourly[0]);
const bars = hourly.map(h => {
const ht = Math.round((h.count/max)*54);
const isPk = h.hour === peak.hour && peak.count > 0;
return `<div class="ins-hourly-col">
<div class="ins-hourly-bar${isPk?' peak':''}" style="height:${Math.max(ht,2)}px;" title="${h.label}: ${h.count}"></div>
<div class="ins-hourly-lbl">${h.hour%6===0?h.label:''}</div>
</div>`;
}).join('');
const peakH = peak.count > 0
? `<div style="margin-top:8px;"><span class="ins-peak-badge"><i class="bi bi-clock-fill"></i> Peak ${_fmtH(peak.hour)} · ${peak.count} views</span></div>`
: '';
return `<div class="ins-hourly-chart">${bars}</div>${peakH}`;
}
// ══════════════════════════════════════════════════════
// LOAD MAIN INSIGHTS
// ══════════════════════════════════════════════════════
function loadInsights() {
fetch('{{ route('videos.insights', $video) }}', { headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'} })
.then(r=>{ if(!r.ok) throw r; return r.json(); })
.then(d => { window._insLoaded=true; window._insData=d; renderInsights(d); })
.catch(() => {
document.getElementById('insightsContent').innerHTML =
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load insights. Try again later.</p>';
});
}
function renderInsights(d) {
const weekBadge = d.week_change>0
? `<span class="ins-card-sub up">↑ ${d.week_change}% vs last week</span>`
: d.week_change<0
? `<span class="ins-card-sub down">↓ ${Math.abs(d.week_change)}% vs last week</span>`
: `<span class="ins-card-sub neu">— same as last week</span>`;
const dlSub = (d.dl_video||d.dl_mp3)
? `<span class="ins-card-sub neu">🎬 ${_fmt(d.dl_video)} · 🎵 ${_fmt(d.dl_mp3)}</span>`
: `<span class="ins-card-sub neu">no downloads yet</span>`;
// ── Stat cards (open top-level modal on click) ──────
const cards = `
<p style="font-size:11px;color:var(--text-secondary);margin:0 0 8px;display:flex;align-items:center;gap:5px;">
<i class="bi bi-cursor-fill"></i> Tap any card, chart bar, or country for a detailed breakdown
</p>
<div class="ins-grid">
<div class="ins-card" onclick="openInsModal_views()">
<div class="ins-card-icon">👁️</div><div class="ins-card-val">${_fmt(d.total_views)}</div>
<div class="ins-card-label">Total Views</div>${weekBadge}
</div>
<div class="ins-card" onclick="openInsModal_viewers()">
<div class="ins-card-icon">👤</div><div class="ins-card-val">${_fmt(d.unique_viewers)}</div>
<div class="ins-card-label">Unique Viewers</div>
<span class="ins-card-sub neu">${_fmt(d.views_today)} today</span>
</div>
<div class="ins-card" onclick="openInsModal_downloads()">
<div class="ins-card-icon">⬇️</div><div class="ins-card-val">${_fmt(d.downloads)}</div>
<div class="ins-card-label">Downloads</div>${dlSub}
</div>
<div class="ins-card" onclick="openInsModal_shares()">
<div class="ins-card-icon">🔗</div><div class="ins-card-val">${_fmt(d.shares)}</div>
<div class="ins-card-label">Share Reach</div>
<span class="ins-card-sub neu">${_fmt(d.share_links)} link${d.share_links===1?'':'s'} created</span>
</div>
</div>`;
// ── Bar chart (each bar clickable) ──────────────────
const maxV = Math.max(...d.daily.map(x=>x.count),1);
const todayL = new Date().toLocaleDateString('en-US',{month:'short',day:'numeric'});
const bars = d.daily.map(day => {
const h = Math.round((day.count/maxV)*72);
const isT = day.label===todayL;
const tip = `${day.label}: ${day.count} views`;
return `<div class="ins-bar-col">
<div class="ins-bar clickable${isT?' ins-bar-today':''}" style="height:${Math.max(h,2)}px;"
title="${tip}" onclick="openDayDetail('${day.date}','${day.label}',${day.count})"></div>
<div class="ins-bar-label">${day.short}</div>
</div>`;
}).join('');
const peakHtml = d.peak_hour!==null
? `<div style="margin-top:10px;"><span class="ins-peak-badge"><i class="bi bi-clock-fill"></i> Peak hour: ${_fmtH(d.peak_hour)}</span></div>` : '';
const chartSection = `
<div>
<div class="ins-section-title"><i class="bi bi-graph-up"></i> Views — last 14 days <span style="font-size:10px;opacity:.5;font-weight:400;text-transform:none;">(tap bar for day detail)</span></div>
<div class="ins-chart">${bars}</div>
<div style="font-size:11px;color:var(--text-secondary);text-align:right;">${d.views_this_week} views this week</div>
${peakHtml}
</div>`;
// ── Countries (each row clickable) ──────────────────
let countriesHtml = '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data yet.</p>';
if (d.countries && d.countries.length) {
countriesHtml = d.countries.map(c => `
<div class="ins-country-row" onclick="openCountryDetail('${c.code}','${c.name}',${c.count})" title="View ${c.name||c.code} details">
<div class="ins-country-flag">${_cflag(c.code)}</div>
<div class="ins-country-name">${c.name||c.code}</div>
<div class="ins-country-bar-wrap"><div class="ins-country-bar" style="width:${c.pct}%;"></div></div>
<div class="ins-country-pct">${c.pct}%</div>
<div class="ins-country-cnt">${_fmt(c.count)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;"></i>
</div>`).join('');
}
// ── Viewers section ─────────────────────────────────
const topViewerRows = (d.top_viewers || []).map((u, i) => `
<div class="ins-dl-user-row" onclick="window.location.href='/channel/${u.channel||u.id}'" title="View ${u.name}'s profile">
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<img src="${u.avatar}" alt="${u.name}" class="ins-dl-avatar">
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${u.name}</div>
<div class="ins-dl-user-meta">Last seen ${_ago(u.last_at)}</div>
</div>
<div class="ins-dl-count-badge">👁 ${u.count}×</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`).join('');
const recentViewerRows = (d.recent_viewers || []).map(r => {
const avatarSrc = r.user_avatar || `https://i.pravatar.cc/150?u=guest-${r.country||'unknown'}`;
const clickAttr = r.user_id ? `onclick="window.location.href='/channel/${r.user_channel||r.user_id}'" style="cursor:pointer;" title="View ${r.user_name}'s profile"` : '';
return `<div class="ins-recent-row" ${clickAttr}>
<img src="${avatarSrc}" class="ins-recent-avatar" alt="${r.user_name}">
<div class="ins-recent-name">${r.user_name}</div>
<span class="ins-recent-flag">${_cflag(r.country)}</span>
<span class="ins-recent-time">${_ago(r.at)}</span>
</div>`;
}).join('');
const viewersHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-dl-header">
<div class="ins-section-title" style="margin:0;"><i class="bi bi-people-fill"></i> Viewers — ${_fmt(d.unique_viewers)} registered · ${_fmt(d.guest_views||0)} guest</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">Top Viewers</div>
${topViewerRows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No registered viewers yet.</p>'}
</div>
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">Recent Activity</div>
${recentViewerRows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No views yet.</p>'}
</div>
</div>
</div>`;
// ── Downloads section ───────────────────────────────
let dlHtml = '';
if (d.downloads===0) {
dlHtml = `<div style="margin-top:20px;"><div class="ins-section-title"><i class="bi bi-download"></i> Downloads</div>
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No downloads yet.</p></div>`;
} else {
const pills = [
d.dl_video ? `<span class="ins-dl-pill video"><i class="bi bi-film"></i> ${_fmt(d.dl_video)} video</span>` : '',
d.dl_mp3 ? `<span class="ins-dl-pill mp3"><i class="bi bi-music-note"></i> ${_fmt(d.dl_mp3)} MP3</span>` : '',
d.dl_guests ? `<span class="ins-dl-pill guest"><i class="bi bi-person"></i> ${_fmt(d.dl_guests)} guest</span>` : '',
].filter(Boolean).join('');
const usersRows = d.dl_users && d.dl_users.length
? d.dl_users.map((u,i) => `
<div class="ins-dl-user-row" onclick="openDownloaderHistory(${u.id},'${u.name.replace(/'/g,"\\'")}','${u.avatar}')" title="See full download history">
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<img src="${u.avatar}" alt="${u.name}" class="ins-dl-avatar">
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${u.name}</div>
<div class="ins-dl-user-meta">Last: ${_ago(u.last_at)}</div>
</div>
<div class="ins-dl-count-badge">⬇️ ${u.count}×</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`).join('')
: '<p style="font-size:13px;color:var(--text-secondary);margin:0;">Guests only so far.</p>';
const recentRows = (d.dl_recent||[]).map(r => {
const avatarSrc = r.user_avatar || `https://i.pravatar.cc/150?u=guest-${r.country||'unknown'}`;
const av = `<img src="${avatarSrc}" class="ins-recent-avatar" alt="${r.user_name}">`;
return `<div class="ins-recent-row">${av}
<div class="ins-recent-name">${r.user_name}</div>
<span class="ins-recent-flag">${_cflag(r.country)}</span>
<span class="ins-recent-type ${r.type}">${r.type.toUpperCase()}</span>
<span class="ins-recent-time">${_ago(r.at)}</span>
</div>`;
}).join('');
dlHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-dl-header">
<div class="ins-section-title" style="margin:0;"><i class="bi bi-download"></i> Downloads — ${_fmt(d.downloads)} total</div>
<div class="ins-dl-type-pills">${pills}</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">
Top Downloaders <span style="font-weight:400;opacity:.5;">(tap to see history)</span>
</div>
${usersRows}
</div>
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">Recent Activity</div>
${recentRows}
</div>
</div>
</div>`;
}
// ── Gender breakdown ────────────────────────────────
let genderHtml;
if (d.genders && d.genders.length > 0) {
const gColors = { male: '#4a9eff', female: '#ff6eb0' };
const gSymbols = { male: '♂', female: '♀' };
genderHtml = d.genders.map(g => {
const color = gColors[g.gender] || '#aaa';
const sym = gSymbols[g.gender] || '⚧';
return `<div class="ins-demo-row">
<span class="ins-demo-sym" style="color:${color}">${sym}</span>
<span class="ins-demo-label">${g.gender.charAt(0).toUpperCase()+g.gender.slice(1)}</span>
<div class="ins-demo-bar-wrap"><div class="ins-demo-bar" style="width:${g.pct}%;background:${color}"></div></div>
<span class="ins-demo-pct">${g.pct}%</span>
<span class="ins-demo-cnt">${_fmt(g.count)}</span>
</div>`;
}).join('');
} else {
genderHtml = `<p style="font-size:13px;color:var(--text-secondary);margin:0;">No gender data yet — viewers need a profile to appear here.</p>`;
}
// ── Age group breakdown ──────────────────────────────
let ageHtml;
if (d.age_groups && d.age_groups.length > 0) {
const maxAge = Math.max(...d.age_groups.map(g => g.count), 1);
ageHtml = d.age_groups.map(g => {
const barW = Math.round(g.count / maxAge * 100);
return `<div class="ins-demo-row">
<span class="ins-demo-label ins-age-label">${g.label}</span>
<div class="ins-demo-bar-wrap"><div class="ins-demo-bar" style="width:${barW}%;background:var(--brand-red,#e61e1e)"></div></div>
<span class="ins-demo-pct">${g.pct}%</span>
<span class="ins-demo-cnt">${_fmt(g.count)}</span>
</div>`;
}).join('');
} else {
ageHtml = `<p style="font-size:13px;color:var(--text-secondary);margin:0;">No age data yet — viewers need a date of birth on their profile to appear here.</p>`;
}
const demographicsHtml = `
<div style="margin-top:18px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-body">
<div>
<div class="ins-section-title"><i class="bi bi-gender-ambiguous"></i> Viewers by Gender</div>
${genderHtml}
</div>
<div>
<div class="ins-section-title"><i class="bi bi-people-fill"></i> Viewers by Age Group</div>
${ageHtml}
</div>
</div>
</div>`;
// ── Share links breakdown ───────────────────────────
let shareHtml = '';
if (d.share_links > 0) {
const linkRows = (d.share_breakdown || []).map((s, i) => `
<div class="ins-dl-user-row" style="cursor:default;">
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${s.sharer}</div>
<div class="ins-dl-user-meta">${_ago(s.created_at)}</div>
</div>
<div class="ins-dl-count-badge" title="Unique devices that opened this link">👁️ ${_fmt(s.reach)}</div>
</div>`).join('');
shareHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;flex-wrap:wrap;gap:8px;">
<div class="ins-section-title" style="margin:0;"><i class="bi bi-share"></i> Share Links — ${_fmt(d.share_links)} created · ${_fmt(d.shares)} unique devices reached</div>
</div>
<p style="font-size:11px;color:var(--text-secondary);margin:0 0 10px;line-height:1.5;">Each sharer gets a unique link. The same device opening the same link is counted only once — VPN IP changes don't affect the count.</p>
${linkRows}
</div>`;
} else {
shareHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-section-title"><i class="bi bi-share"></i> Share Links</div>
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No share links created yet. Tap the Share button to generate a unique tracked link.</p>
</div>`;
}
document.getElementById('insightsContent').innerHTML =
cards +
`<div class="ins-body">${chartSection}
<div>
<div class="ins-section-title"><i class="bi bi-globe2"></i> Audience by Country <span style="font-size:10px;opacity:.5;font-weight:400;text-transform:none;">(tap for viewer breakdown)</span></div>
${countriesHtml}
</div></div>` + viewersHtml + dlHtml + shareHtml + demographicsHtml;
}
// ══════════════════════════════════════════════════════
// STAT CARD MODALS (top-level summaries)
// ══════════════════════════════════════════════════════
function openInsModal_views() {
const d = window._insData; if(!d) return;
const changeColor = d.week_change>0?'#4ade80':d.week_change<0?'#f87171':'var(--text-secondary)';
const changeLabel = d.week_change>0?`↑ ${d.week_change}%`:d.week_change<0?`↓ ${Math.abs(d.week_change)}%`:'— 0%';
_openModal('👁️','View Analytics','All-time stats',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.total_views)}</div>
<div class="ins-modal-hero-label">total views</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-calendar-day"></i> Today</span><span class="ins-modal-stat-val">${_fmt(d.views_today)}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-calendar-week"></i> This week</span><span class="ins-modal-stat-val">${_fmt(d.views_this_week)}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-calendar2-minus"></i> Last week</span><span class="ins-modal-stat-val">${_fmt(d.views_last_week)}</span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;">
<span class="ins-modal-stat-lbl"><i class="bi bi-graph-up-arrow"></i> Week over week</span>
<span class="ins-modal-stat-val" style="color:${changeColor};">${changeLabel}</span>
</div>
<div class="ins-modal-section"><i class="bi bi-graph-up"></i> Last 14 days — tap a bar to see that day</div>
<div class="ins-chart" style="height:72px;">${window._insData.daily.map(day=>{
const h=Math.round((day.count/Math.max(...window._insData.daily.map(x=>x.count),1))*68);
const isT=day.label===new Date().toLocaleDateString('en-US',{month:'short',day:'numeric'});
return `<div class="ins-bar-col"><div class="ins-bar clickable${isT?' ins-bar-today':''}" style="height:${Math.max(h,2)}px;" title="${day.label}: ${day.count}" onclick="closeInsModal();setTimeout(()=>openDayDetail('${day.date}','${day.label}',${day.count}),180)"></div><div class="ins-bar-label">${day.short}</div></div>`;
}).join('')}</div>
${d.peak_hour!==null?`<div style="margin-top:10px;"><span class="ins-peak-badge"><i class="bi bi-clock-fill"></i> Peak hour: ${_fmtH(d.peak_hour)}</span></div>`:''}`);
}
function openInsModal_viewers() {
const d = window._insData; if(!d) return;
const guestV = Math.max(0, d.total_views - d.unique_viewers);
const mPct = d.total_views>0 ? Math.round(d.unique_viewers/d.total_views*100) : 0;
_openModal('👤','Viewer Breakdown','Registered vs anonymous',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.unique_viewers)}</div>
<div class="ins-modal-hero-label">unique registered viewers</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-eye"></i> Total views</span><span class="ins-modal-stat-val">${_fmt(d.total_views)}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Registered</span><span class="ins-modal-stat-val">${mPct}% <span style="color:var(--text-secondary);font-weight:400;">(${_fmt(d.unique_viewers)} views)</span></span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;"><span class="ins-modal-stat-lbl"><i class="bi bi-person-dash"></i> Anonymous</span><span class="ins-modal-stat-val">${100-mPct}% <span style="color:var(--text-secondary);font-weight:400;">(${_fmt(guestV)} views)</span></span></div>
<div class="ins-modal-section"><i class="bi bi-globe2"></i> Top countries — tap to see viewers</div>
${(d.countries||[]).slice(0,5).map(c=>`
<div class="ins-country-row" onclick="closeInsModal();setTimeout(()=>openCountryDetail('${c.code}','${c.name}',${c.count}),180)" style="cursor:pointer;">
<div class="ins-country-flag">${_cflag(c.code)}</div>
<div class="ins-country-name">${c.name||c.code}</div>
<div class="ins-country-bar-wrap"><div class="ins-country-bar" style="width:${c.pct}%;"></div></div>
<div class="ins-country-pct">${c.pct}%</div>
<div class="ins-country-cnt">${_fmt(c.count)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);"></i>
</div>`).join('')||'<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data.</p>'}`);
}
function openInsModal_downloads() {
const d = window._insData; if(!d) return;
if (d.downloads===0) {
_openModal('⬇️','Downloads','No downloads yet',`<div style="text-align:center;padding:30px 0;"><div style="font-size:48px;margin-bottom:12px;">📭</div><p style="color:var(--text-secondary);font-size:14px;margin:0;">No one has downloaded this video yet.</p></div>`);
return;
}
const engRate = d.total_views>0 ? ((d.downloads/d.total_views)*100).toFixed(1) : '0.0';
_openModal('⬇️','Download Summary',`${_fmt(d.downloads)} total downloads`,`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.downloads)}</div>
<div class="ins-modal-hero-label">total downloads</div>
</div>
<div style="display:flex;gap:8px;margin-bottom:18px;flex-wrap:wrap;">
${d.dl_video ? `<span class="ins-dl-pill video"><i class="bi bi-film"></i> ${_fmt(d.dl_video)} video</span>` : ''}
${d.dl_mp3 ? `<span class="ins-dl-pill mp3"><i class="bi bi-music-note"></i> ${_fmt(d.dl_mp3)} MP3</span>` : ''}
${d.dl_guests ? `<span class="ins-dl-pill guest"><i class="bi bi-person"></i> ${_fmt(d.dl_guests)} guest</span>` : ''}
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-eye"></i> Download rate</span><span class="ins-modal-stat-val">${engRate}% of viewers</span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;"><span class="ins-modal-stat-lbl"><i class="bi bi-people"></i> Unique downloaders</span><span class="ins-modal-stat-val">${_fmt((d.dl_users||[]).length + (d.dl_guests>0?1:0))}</span></div>
${(d.dl_users||[]).length ? `
<div class="ins-modal-section"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Registered — tap for full history</div>
${d.dl_users.map((u,i)=>`
<div class="ins-person-row" style="cursor:pointer;" onclick="closeInsModal();setTimeout(()=>openDownloaderHistory(${u.id},'${u.name.replace(/'/g,"\\'")}','${u.avatar}'),180)">
<div style="font-size:11px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<img src="${u.avatar}" class="ins-person-avatar" alt="${u.name}">
<div style="flex:1;min-width:0;"><div class="ins-person-name">${u.name}</div><div class="ins-person-meta">Last: ${_ago(u.last_at)}</div></div>
<div class="ins-person-badge">⬇️ ${u.count}×</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);margin-left:6px;flex-shrink:0;"></i>
</div>`).join('')}` : ''}
`);
}
function openInsModal_shares() {
const d = window._insData; if(!d) return;
const engRate = d.total_views>0 ? (((d.shares)+d.likes)/d.total_views*100).toFixed(1) : '0.0';
const linkRows = (d.share_breakdown||[]).length
? (d.share_breakdown||[]).map((s,i) => `
<div class="ins-dl-user-row" style="cursor:default;">
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${s.sharer}</div>
<div class="ins-dl-user-meta">Created: ${_ago(s.created_at)}</div>
</div>
<div class="ins-dl-count-badge">👁️ ${_fmt(s.reach)}</div>
</div>`).join('')
: '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No share links created yet.</p>';
_openModal('🔗','Share Reach','Unique devices that opened your share links',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.shares)}</div>
<div class="ins-modal-hero-label">unique devices reached</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-link-45deg"></i> Links created</span><span class="ins-modal-stat-val">${_fmt(d.share_links)}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-hand-thumbs-up-fill" style="color:#ef4444;"></i> Likes</span><span class="ins-modal-stat-val">❤️ ${_fmt(d.likes)}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-activity"></i> Engagement rate</span><span class="ins-modal-stat-val">${engRate}%</span></div>
<div class="ins-modal-section" style="margin-top:16px;"><i class="bi bi-people-fill"></i> Share links — top by reach</div>
${linkRows}
<p style="font-size:11px;color:var(--text-secondary);margin:12px 0 0;line-height:1.5;">Each person who shares gets a unique link. Same device opening the same link counts only once.</p>`);
}
// ══════════════════════════════════════════════════════
// DRILL-DOWN: COUNTRY
// ══════════════════════════════════════════════════════
function openCountryDetail(code, name, totalCount) {
const flag = _cflag(code);
_modalLoading(flag, name||code, `${_fmt(totalCount)} views from this country`);
fetch(`{{ url('/videos/' . $video->getRouteKey() . '/insights/country') }}/${code}`, {
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
})
.then(r=>{ if(!r.ok) throw r; return r.json(); })
.then(d => {
const regHtml = _personListHtml(d.registered_users, 'No registered viewers yet from this country.');
const miniChart = _miniBarChart(d.daily);
document.getElementById('insModalBody').innerHTML = `
<div class="ins-modal-hero">
<div class="ins-modal-hero-flag">${flag}</div>
<div class="ins-modal-hero-num">${_fmt(d.total_views)}</div>
<div class="ins-modal-hero-label">total views from ${d.country_name||code}</div>
</div>
<div class="ins-modal-stat">
<span class="ins-modal-stat-lbl"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Registered viewers</span>
<span class="ins-modal-stat-val">${_fmt((d.registered_users||[]).length)}</span>
</div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;">
<span class="ins-modal-stat-lbl"><i class="bi bi-person-dash"></i> Anonymous views</span>
<span class="ins-modal-stat-val">${_fmt(d.guest_count)}</span>
</div>
<div class="ins-modal-section"><i class="bi bi-graph-up"></i> 14-day trend from ${d.country_name||code}</div>
<div class="ins-chart" style="height:60px;">${miniChart}</div>
${(d.registered_users||[]).length ? `
<div class="ins-modal-section"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Registered viewers</div>
${regHtml}` : `
<div class="ins-modal-section"><i class="bi bi-people"></i> Viewers</div>
<p style="font-size:13px;color:var(--text-secondary);margin:0;">
All ${_fmt(d.total_views)} views from ${d.country_name||code} were anonymous.
</p>`}`;
document.getElementById('insModalTitle').textContent = d.country_name || code;
document.getElementById('insModalSubtitle').textContent = `${_fmt(d.total_views)} views from this country`;
})
.catch(() => {
document.getElementById('insModalBody').innerHTML =
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load country details.</p>';
});
}
// ══════════════════════════════════════════════════════
// DRILL-DOWN: SPECIFIC DAY
// ══════════════════════════════════════════════════════
function openDayDetail(date, label, totalCount) {
_modalLoading('📅', label, `${_fmt(totalCount)} views on this day`);
fetch(`{{ url('/videos/' . $video->getRouteKey() . '/insights/day') }}/${date}`, {
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
})
.then(r=>{ if(!r.ok) throw r; return r.json(); })
.then(d => {
const hourlyHtml = _hourlyChart(d.hourly);
const regHtml = _personListHtml(d.registered_users, 'No registered viewers on this day.');
const topCountries = (d.countries||[]).map(c=>`
<div class="ins-country-row" style="cursor:pointer;" onclick="closeInsModal();setTimeout(()=>openCountryDetail('${c.code}','${c.name}',${c.count}),180)">
<div class="ins-country-flag">${_cflag(c.code)}</div>
<div class="ins-country-name">${c.name||c.code}</div>
<div class="ins-country-cnt" style="margin-left:auto;">${_fmt(c.count)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);"></i>
</div>`).join('') || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data.</p>';
document.getElementById('insModalIcon').textContent = '📅';
document.getElementById('insModalTitle').textContent = `${d.day_of_week}, ${d.date}`;
document.getElementById('insModalSubtitle').textContent = `${_fmt(d.total_views)} views on this day`;
document.getElementById('insModalBody').innerHTML = `
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.total_views)}</div>
<div class="ins-modal-hero-label">views on ${d.day_of_week}</div>
</div>
<div class="ins-modal-stat">
<span class="ins-modal-stat-lbl"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Registered</span>
<span class="ins-modal-stat-val">${_fmt((d.registered_users||[]).length)} viewers (${_fmt(d.total_views - d.guest_count)} views)</span>
</div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;">
<span class="ins-modal-stat-lbl"><i class="bi bi-person-dash"></i> Anonymous</span>
<span class="ins-modal-stat-val">${_fmt(d.guest_count)} views</span>
</div>
<div class="ins-modal-section"><i class="bi bi-clock"></i> Views by Hour</div>
${hourlyHtml}
${(d.registered_users||[]).length ? `
<div class="ins-modal-section" style="margin-top:18px;"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Who watched</div>
${regHtml}` : ''}
<div class="ins-modal-section" style="margin-top:18px;"><i class="bi bi-globe2"></i> Top countries — tap to explore</div>
${topCountries}`;
})
.catch(() => {
document.getElementById('insModalBody').innerHTML =
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load day details.</p>';
});
}
// ══════════════════════════════════════════════════════
// DRILL-DOWN: DOWNLOADER HISTORY
// ══════════════════════════════════════════════════════
function openDownloaderHistory(userId, userName, userAvatar) {
_modalLoading('⬇️', userName, 'Loading download history…');
document.getElementById('insModalIcon').innerHTML = `<img src="${userAvatar}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;">`;
fetch(`{{ url('/videos/' . $video->getRouteKey() . '/insights/downloader') }}/${userId}`, {
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
})
.then(r=>{ if(!r.ok) throw r; return r.json(); })
.then(d => {
document.getElementById('insModalIcon').innerHTML = `<img src="${d.user.avatar}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;">`;
document.getElementById('insModalTitle').textContent = d.user.name;
document.getElementById('insModalSubtitle').textContent = `${d.total} download${d.total!==1?'s':''} of this video`;
const rows = d.records.map((r,i) => `
<div class="ins-dl-hist-row">
<div class="ins-dl-hist-num">${i+1}</div>
<span class="ins-dl-hist-type ${r.type}">${r.type.toUpperCase()}</span>
<div class="ins-dl-hist-time">${_fmtDt(r.at)}</div>
<div class="ins-dl-hist-flag" title="${r.country_name||r.country||'Unknown'}">${_cflag(r.country)}</div>
</div>`).join('');
document.getElementById('insModalBody').innerHTML = `
<div style="display:flex;align-items:center;gap:12px;padding:4px 0 20px;">
<img src="${d.user.avatar}" style="width:52px;height:52px;border-radius:50%;object-fit:cover;border:2px solid var(--border-color);">
<div>
<div style="font-size:16px;font-weight:700;color:var(--text-primary);">${d.user.name}</div>
<div style="font-size:13px;color:var(--text-secondary);margin-top:2px;">
${d.total} download${d.total!==1?'s':''} of this video
</div>
</div>
</div>
<div class="ins-modal-section"><i class="bi bi-clock-history"></i> Download History</div>
${rows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No records found.</p>'}`;
})
.catch(() => {
document.getElementById('insModalBody').innerHTML =
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load download history.</p>';
});
}
</script>
@endif
File diff suppressed because it is too large. Load diff