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:
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>© {{ date('Y') }} <strong style="color:#666;">TAKEONE</strong> — 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>
|
||||
·
|
||||
{{ 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>
|
||||
@@ -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>
|
||||
·
|
||||
@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
Reference in new issue
Block a user