Removes the 12-chip cap on the language filter so every language that appears in public content is listed (video 175 alone contributes 12). The bar wrapper is now sticky and horizontally scrollable with chevron buttons that appear only when the bar overflows in that direction, a gradient fade behind each button, vertical-wheel → horizontal scroll that hands off to the page at the edges, and sessionStorage-persisted scroll position so navigation doesn't snap it back to the start. Chip clicks are intercepted (only for /videos URLs; Trending/Shorts still render different templates via a normal navigation) and swap only the #filter-content region via fetch + DOMParser, updating history.pushState, document.title, and active-chip state without a full page reload. Modifier-clicks and middle-clicks fall through to native "open in new tab" behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2650 lines
112 KiB
PHP
2650 lines
112 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
<title>@yield('title', config('app.name'))</title>
|
|
|
|
<!-- Open Graph meta tags default - will be overridden by page-specific tags -->
|
|
@stack('head')
|
|
|
|
<!-- Favicon -->
|
|
<link rel="icon" type="image/png" href="{{ asset('storage/images/logo.png') }}">
|
|
<link rel="apple-touch-icon" href="{{ asset('storage/images/logo.png') }}">
|
|
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
<link rel="stylesheet" href="{{ asset('vendor/flag-icons/css/flag-icons.min.css') }}">
|
|
{{-- Image cropper assets — must be in the layout head (not inside the
|
|
x-image-cropper component) because page-level uses of the cropper
|
|
render those styles inside #main, which the SPA navigation later
|
|
wipes via innerHTML swap. The layout-level modals (upload,
|
|
sports-match) would then render their cropper overlays unstyled. --}}
|
|
<link rel="stylesheet" href="{{ asset('css/cropme.min.css') }}">
|
|
<script src="{{ asset('js/cropme.min.js') }}"></script>
|
|
<style>
|
|
/* TakeOne Cropper Modal — must be in the head, not in the component,
|
|
because page-level uses render those styles inside #main and SPA
|
|
navigation later wipes that scope. */
|
|
.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>
|
|
<style>
|
|
:root {
|
|
--brand-red: #e61e1e;
|
|
--bg-dark: #0f0f0f;
|
|
--bg-secondary: #1e1e1e;
|
|
--border-color: #303030;
|
|
--text-primary: #f1f1f1;
|
|
--text-secondary: #aaaaaa;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
html {
|
|
overflow-x: hidden;
|
|
max-width: 100%;
|
|
}
|
|
|
|
body {
|
|
background-color: var(--bg-dark);
|
|
color: var(--text-primary);
|
|
font-family: "Roboto", "Arial", sans-serif;
|
|
margin: 0;
|
|
padding: 0;
|
|
overflow-x: hidden;
|
|
max-width: 100%;
|
|
}
|
|
|
|
/* Header */
|
|
.yt-header {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 56px;
|
|
background: var(--bg-dark);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0 16px;
|
|
z-index: 1000;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.yt-header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
|
|
.yt-menu-btn {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.yt-menu-btn:hover { background: var(--border-color); }
|
|
|
|
.yt-logo {
|
|
display: flex;
|
|
align-items: center;
|
|
text-decoration: none;
|
|
gap: 4px;
|
|
}
|
|
|
|
.yt-logo-text {
|
|
font-size: 1.4rem;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
letter-spacing: -1px;
|
|
}
|
|
|
|
/* Search */
|
|
.yt-header-center {
|
|
flex: 1;
|
|
max-width: 640px;
|
|
margin: 0 40px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.yt-search {
|
|
flex: 1;
|
|
display: flex;
|
|
height: 40px;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.yt-search-form {
|
|
flex: 1;
|
|
display: flex;
|
|
height: 40px;
|
|
}
|
|
|
|
.yt-search-input {
|
|
flex: 1;
|
|
background: #121212;
|
|
border: 1px solid var(--border-color);
|
|
border-right: none;
|
|
border-radius: 20px 0 0 20px;
|
|
padding: 0 16px;
|
|
color: var(--text-primary);
|
|
font-size: 16px;
|
|
}
|
|
|
|
.yt-search-input:focus {
|
|
outline: none;
|
|
border-color: #1c62b9;
|
|
}
|
|
|
|
.yt-search-btn {
|
|
width: 64px;
|
|
background: #222;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 0 20px 20px 0;
|
|
color: var(--text-primary);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.yt-search-btn:hover { background: #303030; }
|
|
|
|
.yt-search-mic {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background: #3f3f3f;
|
|
border: none;
|
|
color: var(--text-primary);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 1.1rem;
|
|
flex-shrink: 0;
|
|
transition: background 0.2s;
|
|
}
|
|
.yt-search-mic:hover { background: #555; }
|
|
|
|
/* Header Right */
|
|
.yt-header-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.yt-icon-btn {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-primary);
|
|
font-size: 1.2rem;
|
|
}
|
|
|
|
.yt-icon-btn:hover { background: var(--border-color); }
|
|
|
|
.yt-user-avatar {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 50%;
|
|
background: #555;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Sidebar */
|
|
.yt-sidebar {
|
|
position: fixed;
|
|
top: 56px;
|
|
left: 0;
|
|
bottom: 0;
|
|
width: 240px;
|
|
background: var(--bg-dark);
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
padding: 8px 0;
|
|
transition: transform 0.25s ease, width 0.25s ease;
|
|
z-index: 999;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: var(--border-color) transparent;
|
|
}
|
|
|
|
.yt-sidebar-section {
|
|
padding: 0 8px 4px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
margin-bottom: 4px;
|
|
}
|
|
.yt-sidebar-section:last-child { border-bottom: none; }
|
|
|
|
.yt-sidebar-link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 18px;
|
|
padding: 0 12px;
|
|
height: 40px;
|
|
border-radius: 10px;
|
|
color: var(--text-primary);
|
|
text-decoration: none;
|
|
cursor: pointer;
|
|
transition: background 0.15s, color 0.15s;
|
|
font-size: 14px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.yt-sidebar-link i {
|
|
font-size: 18px;
|
|
flex-shrink: 0;
|
|
width: 22px;
|
|
text-align: center;
|
|
}
|
|
|
|
.yt-sidebar-link:hover {
|
|
background: var(--bg-secondary);
|
|
color: var(--text-primary);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.yt-sidebar-link.active {
|
|
background: rgba(230, 30, 30, 0.12);
|
|
color: var(--brand-red);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.yt-sidebar-link.active i { color: var(--brand-red); }
|
|
|
|
/* Sidebar section label */
|
|
.yt-sidebar-section-header {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
padding: 12px 12px 4px;
|
|
}
|
|
|
|
/* ══════════════════════════════════════════
|
|
GLOBAL BUTTON SYSTEM
|
|
All UI buttons must use one of these classes.
|
|
border-radius: 8px, padding: 8px 14px, flex icon+text.
|
|
══════════════════════════════════════════ */
|
|
.action-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 8px 14px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border-color);
|
|
background: var(--bg-secondary);
|
|
color: var(--text-primary);
|
|
font-size: 0.82rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
white-space: nowrap;
|
|
transition: background .15s, border-color .15s, color .15s;
|
|
font-family: inherit;
|
|
line-height: 1;
|
|
}
|
|
.action-btn:hover {
|
|
background: var(--border-color);
|
|
color: var(--text-primary);
|
|
text-decoration: none;
|
|
}
|
|
.action-btn:focus { outline: none; box-shadow: 0 0 0 2px rgba(255,255,255,.15); }
|
|
.action-btn:disabled, .action-btn[disabled] { opacity: .5; cursor: not-allowed; }
|
|
|
|
/* Primary variant — brand red */
|
|
.action-btn-primary, .action-btn.primary {
|
|
background: var(--brand-red);
|
|
border-color: var(--brand-red);
|
|
color: #fff;
|
|
}
|
|
.action-btn-primary:hover, .action-btn.primary:hover {
|
|
background: #cc1a1a;
|
|
border-color: #cc1a1a;
|
|
color: #fff;
|
|
}
|
|
|
|
/* Danger variant — destructive actions */
|
|
.action-btn-danger, .action-btn.danger {
|
|
border-color: #c53030;
|
|
color: #fc8181;
|
|
background: transparent;
|
|
}
|
|
.action-btn-danger:hover, .action-btn.danger:hover {
|
|
background: rgba(197,48,48,.15);
|
|
color: #fc8181;
|
|
}
|
|
|
|
/* Icon-only variant (no text label) */
|
|
.action-btn.icon-only { padding: 8px; }
|
|
|
|
/* Filter chip bar */
|
|
.yt-filter-bar-wrap {
|
|
position: sticky;
|
|
top: 56px;
|
|
z-index: 90;
|
|
margin: -24px -24px 20px;
|
|
background: var(--bg-dark);
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
.yt-filter-bar {
|
|
display: flex;
|
|
gap: 12px;
|
|
overflow-x: auto;
|
|
scrollbar-width: none;
|
|
-ms-overflow-style: none;
|
|
padding: 12px 24px;
|
|
scroll-behavior: smooth;
|
|
}
|
|
.yt-filter-bar::-webkit-scrollbar { display: none; }
|
|
|
|
/* Left / right scroll buttons — visible only when the bar overflows */
|
|
.yt-filter-nav {
|
|
position: absolute; top: 0; bottom: 0;
|
|
display: none; align-items: center;
|
|
width: 48px;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-primary);
|
|
cursor: pointer;
|
|
z-index: 2;
|
|
padding: 0;
|
|
font-size: 20px;
|
|
justify-content: center;
|
|
}
|
|
.yt-filter-nav.visible { display: flex; }
|
|
.yt-filter-nav.left { left: 0; background: linear-gradient(to right, var(--bg-dark) 40%, transparent); justify-content: flex-start; padding-left: 6px; }
|
|
.yt-filter-nav.right { right: 0; background: linear-gradient(to left, var(--bg-dark) 40%, transparent); justify-content: flex-end; padding-right: 6px; }
|
|
.yt-filter-nav i {
|
|
width: 32px; height: 32px; border-radius: 50%;
|
|
background: var(--bg-secondary);
|
|
display: flex; align-items: center; justify-content: center;
|
|
box-shadow: 0 2px 6px rgba(0,0,0,0.35);
|
|
}
|
|
.yt-filter-nav:hover i { background: #555; }
|
|
|
|
.yt-chip {
|
|
flex-shrink: 0;
|
|
background: #3f3f3f;
|
|
color: var(--text-primary);
|
|
border: none;
|
|
padding: 6px 12px;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
display: inline-block;
|
|
transition: background 0.15s;
|
|
white-space: nowrap;
|
|
}
|
|
.yt-chip:hover {
|
|
background: #555;
|
|
color: var(--text-primary);
|
|
text-decoration: none;
|
|
}
|
|
.yt-chip.active {
|
|
background: var(--text-primary);
|
|
color: #0f0f0f;
|
|
}
|
|
|
|
/* Mobile sidebar overlay */
|
|
.yt-sidebar-overlay {
|
|
position: fixed;
|
|
top: 56px;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0,0,0,0.5);
|
|
z-index: 998;
|
|
display: none;
|
|
}
|
|
|
|
.yt-sidebar-overlay.show { display: block; }
|
|
|
|
/* Impersonation banner */
|
|
.impersonate-bar {
|
|
position: fixed;
|
|
top: 56px;
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 999;
|
|
height: 40px;
|
|
background: #7a4f00;
|
|
border-bottom: 1px solid #c47f00;
|
|
color: #ffd166;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 10px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
padding: 0 16px;
|
|
}
|
|
.impersonate-bar i { font-size: 15px; }
|
|
.impersonate-exit-btn {
|
|
margin-left: 16px;
|
|
background: rgba(255,255,255,.12);
|
|
border: 1px solid rgba(255,209,102,.5);
|
|
color: #ffd166;
|
|
border-radius: 6px;
|
|
padding: 4px 12px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
transition: background .15s;
|
|
}
|
|
.impersonate-exit-btn:hover { background: rgba(255,255,255,.2); }
|
|
body.has-impersonate-bar .yt-main { margin-top: calc(56px + 40px); }
|
|
|
|
/* Main Content */
|
|
.yt-main {
|
|
margin-top: 56px;
|
|
margin-left: 240px;
|
|
padding: 24px;
|
|
min-height: calc(100vh - 56px);
|
|
transition: margin-left 0.3s;
|
|
}
|
|
|
|
/* Upload Button */
|
|
.yt-upload-btn {
|
|
background: var(--brand-red);
|
|
color: white;
|
|
border: none;
|
|
padding: 8px 16px;
|
|
border-radius: 20px;
|
|
font-weight: 500;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.yt-upload-btn:hover { background: #cc1a1a; }
|
|
|
|
/* ── Header user dropdown (custom, no Bootstrap) ───────────────── */
|
|
/* ── Notification bell ── */
|
|
@keyframes bell-ring {
|
|
0% { transform: rotate(0) scale(1); }
|
|
8% { transform: rotate(-22deg) scale(1.15); }
|
|
20% { transform: rotate(20deg) scale(1.15); }
|
|
32% { transform: rotate(-16deg) scale(1.1); }
|
|
44% { transform: rotate(12deg) scale(1.05); }
|
|
56% { transform: rotate(-8deg) scale(1); }
|
|
68% { transform: rotate(5deg) scale(1); }
|
|
80% { transform: rotate(-3deg) scale(1); }
|
|
100% { transform: rotate(0) scale(1); }
|
|
}
|
|
@keyframes bell-sway {
|
|
0%, 100% { transform: rotate(0); }
|
|
30% { transform: rotate(-10deg); }
|
|
70% { transform: rotate(10deg); }
|
|
}
|
|
#notifBtn {
|
|
transform-origin: center top;
|
|
transition: transform 0.1s;
|
|
}
|
|
#notifBtn.bell-ring {
|
|
animation: bell-ring 0.85s cubic-bezier(.36,.07,.19,.97) both;
|
|
}
|
|
#notifBtn.bell-has-unread:not(.bell-ring) {
|
|
animation: bell-sway 2s ease-in-out infinite;
|
|
}
|
|
.yt-notif-wrap { position: relative; }
|
|
.yt-notif-badge {
|
|
position: absolute; top: 4px; right: 4px;
|
|
min-width: 16px; height: 16px; border-radius: 8px;
|
|
background: var(--brand-red); color: #fff;
|
|
font-size: 10px; font-weight: 700; line-height: 16px;
|
|
text-align: center; padding: 0 3px;
|
|
pointer-events: none;
|
|
}
|
|
.yt-notif-panel {
|
|
position: fixed;
|
|
width: 420px;
|
|
max-height: 600px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 14px;
|
|
box-shadow: 0 12px 40px rgba(0,0,0,.6);
|
|
z-index: 10001;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.yt-notif-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 16px 18px 14px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
flex-shrink: 0;
|
|
}
|
|
.yt-notif-title { font-size: 16px; font-weight: 700; color: var(--text-primary); }
|
|
.yt-notif-mark-all {
|
|
font-size: 12px; color: var(--brand-red); background: none;
|
|
border: none; cursor: pointer; padding: 0; font-weight: 500;
|
|
}
|
|
.yt-notif-mark-all:hover { text-decoration: underline; }
|
|
.yt-notif-list { overflow-y: auto; flex: 1; min-height: 0; }
|
|
.yt-notif-item {
|
|
display: flex; align-items: flex-start; gap: 12px;
|
|
padding: 12px 16px; cursor: pointer; text-decoration: none;
|
|
border-bottom: 1px solid rgba(255,255,255,.04);
|
|
transition: background .12s;
|
|
}
|
|
.yt-notif-item:hover { background: rgba(255,255,255,.05); }
|
|
.yt-notif-item.unread { background: rgba(230,30,30,.06); }
|
|
.yt-notif-item.unread:hover { background: rgba(230,30,30,.1); }
|
|
.yt-notif-thumb {
|
|
width: 96px; height: 54px; border-radius: 6px; object-fit: cover;
|
|
flex-shrink: 0; background: var(--bg-dark); display: block;
|
|
}
|
|
.yt-notif-thumb-placeholder {
|
|
width: 96px; height: 54px; border-radius: 6px; flex-shrink: 0;
|
|
background: var(--border-color); display: flex; align-items: center;
|
|
justify-content: center; color: var(--text-secondary); font-size: 20px;
|
|
}
|
|
.yt-notif-body { flex: 1; min-width: 0; }
|
|
.yt-notif-text {
|
|
font-size: 13px; color: var(--text-primary); line-height: 1.45;
|
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
.yt-notif-text strong { font-weight: 600; }
|
|
.yt-notif-preview { font-style: italic; opacity: .75; }
|
|
.yt-notif-time { font-size: 11px; color: var(--text-secondary); margin-top: 4px; }
|
|
.yt-notif-dot {
|
|
width: 8px; height: 8px; border-radius: 50%;
|
|
background: var(--brand-red); flex-shrink: 0; margin-top: 8px;
|
|
}
|
|
.yt-notif-empty {
|
|
padding: 48px 16px; text-align: center;
|
|
font-size: 13px; color: var(--text-secondary);
|
|
}
|
|
@media (max-width: 480px) {
|
|
.yt-notif-panel { width: calc(100vw - 16px); }
|
|
}
|
|
|
|
.yt-user-dropdown { position: relative; }
|
|
|
|
.yt-user-panel {
|
|
display: none;
|
|
position: fixed;
|
|
width: 224px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 12px;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,.55);
|
|
z-index: 10001;
|
|
overflow: hidden;
|
|
}
|
|
.yt-user-panel.open { display: block; }
|
|
|
|
.yt-panel-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 14px 16px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
.yt-panel-info img {
|
|
width: 36px; height: 36px;
|
|
border-radius: 50%; flex-shrink: 0;
|
|
object-fit: cover;
|
|
}
|
|
.yt-panel-name { font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
|
.yt-panel-email {
|
|
font-size: 11px; color: var(--text-secondary); margin-top: 1px;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 145px;
|
|
}
|
|
|
|
.yt-panel-links { padding: 6px 0; }
|
|
|
|
.yt-drop-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 9px 16px;
|
|
color: var(--text-primary);
|
|
text-decoration: none;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
transition: background 0.12s;
|
|
width: 100%;
|
|
background: transparent;
|
|
border: none;
|
|
text-align: left;
|
|
font-family: inherit;
|
|
line-height: 1;
|
|
}
|
|
.yt-drop-item:hover {
|
|
background: var(--border-color);
|
|
color: var(--text-primary);
|
|
text-decoration: none;
|
|
}
|
|
.yt-drop-item i { font-size: 15px; width: 18px; text-align: center; flex-shrink: 0; }
|
|
.yt-drop-item.danger { color: #fc8181; }
|
|
.yt-drop-item.danger:hover { background: rgba(197,48,48,.15); color: #fc8181; }
|
|
|
|
.yt-panel-divider { height: 1px; background: var(--border-color); margin: 4px 0; }
|
|
|
|
/* Mobile circle button */
|
|
@media (max-width: 576px) {
|
|
.yt-upload-btn {
|
|
width: 40px;
|
|
height: 40px;
|
|
padding: 0;
|
|
border-radius: 50%;
|
|
justify-content: center;
|
|
}
|
|
.yt-upload-btn span {
|
|
display: none;
|
|
}
|
|
|
|
/* Show text on larger mobile */
|
|
@media (min-width: 400px) {
|
|
.yt-upload-btn {
|
|
width: auto;
|
|
border-radius: 20px;
|
|
padding: 8px 16px;
|
|
}
|
|
.yt-upload-btn span {
|
|
display: inline;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Responsive */
|
|
/* ─── Desktop: mini-guide (collapsed = 72 px stacked icon+label) ─── */
|
|
@media (min-width: 992px) {
|
|
.yt-sidebar.collapsed {
|
|
width: 72px;
|
|
padding: 8px 0;
|
|
overflow: hidden;
|
|
}
|
|
.yt-sidebar.collapsed .yt-sidebar-link {
|
|
flex-direction: column;
|
|
height: auto;
|
|
padding: 10px 4px;
|
|
gap: 4px;
|
|
border-radius: 12px;
|
|
align-items: center;
|
|
text-align: center;
|
|
margin: 0 4px;
|
|
}
|
|
.yt-sidebar.collapsed .yt-sidebar-link i {
|
|
font-size: 20px;
|
|
width: auto;
|
|
}
|
|
.yt-sidebar.collapsed .yt-sidebar-link span {
|
|
font-size: 10px;
|
|
line-height: 1.2;
|
|
white-space: nowrap;
|
|
}
|
|
.yt-sidebar.collapsed .yt-sidebar-section {
|
|
border-bottom: none;
|
|
padding-bottom: 4px;
|
|
margin-bottom: 0;
|
|
}
|
|
.yt-sidebar.collapsed .yt-sidebar-section-header { display: none; }
|
|
.yt-main.collapsed { margin-left: 72px; }
|
|
}
|
|
|
|
@media (max-width: 991px) {
|
|
.yt-sidebar {
|
|
transform: translateX(-100%);
|
|
}
|
|
|
|
.yt-sidebar.open {
|
|
transform: translateX(0);
|
|
}
|
|
|
|
.yt-main {
|
|
margin-left: 0;
|
|
}
|
|
|
|
.yt-search-mic { display: none; }
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.yt-header-center { display: none; }
|
|
.yt-main { padding: 16px; }
|
|
.yt-main.video-view-page { padding: 0 !important; }
|
|
.video-view-page .yt-sidebar-container { padding: 0 16px; }
|
|
}
|
|
|
|
/* Mobile Search Toggle Button */
|
|
.yt-mobile-search-toggle {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-primary);
|
|
font-size: 1.2rem;
|
|
}
|
|
|
|
.yt-mobile-search-toggle:hover {
|
|
background: var(--border-color);
|
|
}
|
|
|
|
/* Mobile Search Overlay */
|
|
.mobile-search-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: var(--bg-dark);
|
|
z-index: 1001;
|
|
padding: 60px 16px 16px;
|
|
justify-content: center;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.mobile-search-overlay.active {
|
|
display: flex;
|
|
}
|
|
|
|
.mobile-search-form {
|
|
display: flex;
|
|
width: 100%;
|
|
max-width: 600px;
|
|
height: 44px;
|
|
}
|
|
|
|
.mobile-search-input {
|
|
flex: 1;
|
|
background: #121212;
|
|
border: 1px solid var(--border-color);
|
|
border-right: none;
|
|
border-radius: 20px 0 0 20px;
|
|
padding: 0 16px;
|
|
color: var(--text-primary);
|
|
font-size: 16px;
|
|
}
|
|
|
|
.mobile-search-input:focus {
|
|
outline: none;
|
|
border-color: #1c62b9;
|
|
}
|
|
|
|
.mobile-search-submit {
|
|
width: 50px;
|
|
background: #222;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 0 20px 20px 0;
|
|
color: var(--text-primary);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.mobile-search-submit:hover {
|
|
background: #303030;
|
|
}
|
|
|
|
/* Dropdown */
|
|
.dropdown-menu-dark {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.dropdown-item {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.dropdown-item:hover {
|
|
background: var(--border-color);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* Modal input focus */
|
|
#deleteVideoInput:focus {
|
|
outline: none;
|
|
border-color: #ef4444 !important;
|
|
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2);
|
|
}
|
|
|
|
.delete-otp-input {
|
|
width: 100%;
|
|
background: #1a1a1a;
|
|
border: 1px solid rgba(239, 68, 68, 0.5);
|
|
border-radius: 8px;
|
|
color: #fff;
|
|
padding: 12px 16px;
|
|
font-size: 22px;
|
|
letter-spacing: 0.4em;
|
|
text-align: center;
|
|
text-indent: 0.4em;
|
|
font-family: 'SFMono-Regular', Menlo, Monaco, Consolas, monospace;
|
|
box-sizing: border-box;
|
|
transition: border-color .15s ease, box-shadow .15s ease;
|
|
}
|
|
.delete-otp-input::placeholder {
|
|
color: rgba(255, 255, 255, 0.35);
|
|
letter-spacing: 0.4em;
|
|
}
|
|
.delete-otp-input:focus {
|
|
outline: none;
|
|
border-color: #ef4444;
|
|
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.18);
|
|
}
|
|
|
|
/* ── Playlist controls bar (sidebar, all video types) ── */
|
|
.pl-controls-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
margin-bottom: 10px;
|
|
padding: 6px 8px;
|
|
background: var(--bg-secondary);
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border-color);
|
|
flex-shrink: 0;
|
|
}
|
|
.pl-ctrl-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
padding: 5px 8px;
|
|
border-radius: 6px;
|
|
font-size: 15px;
|
|
transition: color .15s, background .15s;
|
|
white-space: nowrap;
|
|
}
|
|
.pl-ctrl-btn:hover:not(:disabled) { color: var(--text-primary); background: rgba(255,255,255,.07); }
|
|
.pl-ctrl-btn:disabled { opacity: .35; cursor: default; }
|
|
.pl-ctrl-btn.pl-ctrl-active { color: var(--brand-red); }
|
|
.pl-ctrl-divider { width: 1px; height: 18px; background: var(--border-color); margin: 0 2px; flex-shrink: 0; }
|
|
.pl-ctrl-autoplay { margin-left: auto; }
|
|
.pl-autoplay-label { font-size: 12px; font-weight: 500; }
|
|
|
|
/* ── Mini-player ─────────────────────────────────────── */
|
|
#ytpMini {
|
|
position: fixed;
|
|
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
|
|
right: 12px;
|
|
width: 300px;
|
|
background: #1a1a1a;
|
|
border: 1px solid #333;
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
z-index: 1999;
|
|
box-shadow: 0 4px 24px rgba(0,0,0,.6);
|
|
}
|
|
#ytpMiniVideo {
|
|
position: relative;
|
|
width: 100%;
|
|
aspect-ratio: 16/9;
|
|
background: #000;
|
|
overflow: hidden;
|
|
cursor: move; /* drag handle */
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
}
|
|
#ytpMini.dragging { cursor: grabbing; opacity: .92; }
|
|
#ytpMini.dragging #ytpMiniVideo { cursor: grabbing; }
|
|
#ytpMiniVideo video { width:100%; height:100%; object-fit:contain; display:block; }
|
|
#ytpMiniVideo .ytp-chrome-bottom,
|
|
#ytpMiniVideo .ytp-gradient-bottom,
|
|
#ytpMiniVideo .ytp-large-play-btn,
|
|
#ytpMiniVideo .ytp-spinner,
|
|
#ytpMiniVideo .ytp-dbl-left,
|
|
#ytpMiniVideo .ytp-dbl-right { display:none !important; }
|
|
#ytpMiniBar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 6px 8px;
|
|
gap: 6px;
|
|
background: #1a1a1a;
|
|
}
|
|
#ytpMiniInfo {
|
|
flex:1; min-width:0;
|
|
cursor: move; /* secondary drag handle on the title area */
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
}
|
|
#ytpMiniTitle {
|
|
font-size: 12px;
|
|
color: #eee;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
display: block;
|
|
}
|
|
#ytpMiniControls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
#ytpMiniControls button,
|
|
#ytpMiniControls a {
|
|
background: none;
|
|
border: none;
|
|
color: #ccc;
|
|
cursor: pointer;
|
|
padding: 4px 6px;
|
|
font-size: 16px;
|
|
border-radius: 4px;
|
|
text-decoration: none;
|
|
line-height: 1;
|
|
}
|
|
#ytpMiniControls button:hover,
|
|
#ytpMiniControls a:hover { color:#fff; background:rgba(255,255,255,.1); }
|
|
@media (max-width: 480px) {
|
|
#ytpMini { width: calc(100vw - 24px); right: 12px; }
|
|
}
|
|
|
|
/* ── Mobile responsive ───────────────────────────────── */
|
|
@media (max-width: 480px) {
|
|
.yt-menu-btn, .yt-icon-btn, .yt-mobile-search-toggle {
|
|
min-width: 44px;
|
|
min-height: 44px;
|
|
}
|
|
.yt-main { padding: 12px 8px !important; }
|
|
.yt-video-title, .video-title { font-size: 14px !important; }
|
|
.channel-info { gap: 8px !important; }
|
|
.channel-avatar { width: 32px !important; height: 32px !important; }
|
|
.channel-name { font-size: 14px !important; }
|
|
.channel-subs { font-size: 12px !important; }
|
|
.yt-channel-name, .yt-video-meta { font-size: 12px !important; }
|
|
.video-actions {
|
|
overflow-x: auto;
|
|
-webkit-overflow-scrolling: touch;
|
|
padding-bottom: 8px;
|
|
width: 100%;
|
|
}
|
|
.yt-action-btn { flex-shrink: 0; padding: 8px 12px; font-size: 13px; }
|
|
.comment-item { flex-direction: column; }
|
|
.comment-item > img { width: 32px !important; height: 32px !important; }
|
|
.yt-search-input { font-size: 14px; padding: 0 12px; }
|
|
.yt-header { padding: 0 8px; }
|
|
.dropdown-menu { width: 100%; min-width: 200px; }
|
|
}
|
|
@media (max-width: 360px) {
|
|
.yt-main { padding: 8px 4px !important; }
|
|
.yt-main.video-view-page { padding: 0 !important; }
|
|
.yt-header-right .yt-icon-btn:not(:first-child) { display: none; }
|
|
}
|
|
/* Base grid — kept in the layout (not the per-page extra_styles block)
|
|
so SPA navigations from a video page back to a gallery still get it.
|
|
No !important: pages with their own .yt-video-grid rules (e.g. the
|
|
channel page) override these via normal cascade since their <style>
|
|
comes from @section('extra_styles') in <body>. */
|
|
.yt-video-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 20px;
|
|
}
|
|
@media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } }
|
|
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: 1fr; gap: 14px; } }
|
|
@media (max-height: 500px) and (orientation: landscape) {
|
|
.yt-sidebar { width: 200px; }
|
|
.yt-video-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
|
|
}
|
|
@media (min-width: 1440px) {
|
|
.yt-video-grid { grid-template-columns: repeat(4, 1fr); }
|
|
}
|
|
@media (max-width: 768px) {
|
|
.video-container {
|
|
border-radius: 0 !important;
|
|
margin: 0 !important;
|
|
max-width: 100% !important;
|
|
width: 100% !important;
|
|
}
|
|
}
|
|
@media (hover: none) {
|
|
.yt-sidebar-link { padding: 0 16px; }
|
|
.yt-sidebar-link:hover { background: transparent; }
|
|
.yt-sidebar-link:active { background: var(--border-color); }
|
|
}
|
|
|
|
/* ── Bottom nav + mobile scroll model ───────────────── */
|
|
.yt-bottom-nav {
|
|
display: none;
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: calc(56px + env(safe-area-inset-bottom, 0px));
|
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
padding-left: 8px;
|
|
padding-right: 8px;
|
|
background: var(--bg-dark);
|
|
border-top: 1px solid var(--border-color);
|
|
z-index: 1000;
|
|
justify-content: space-around;
|
|
align-items: center;
|
|
-webkit-tap-highlight-color: transparent;
|
|
will-change: transform;
|
|
}
|
|
.yt-bottom-nav-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex: 1;
|
|
height: 100%;
|
|
color: var(--text-secondary);
|
|
text-decoration: none;
|
|
font-size: 12px;
|
|
gap: 4px;
|
|
transition: color 0.2s;
|
|
cursor: pointer;
|
|
background: transparent;
|
|
border: none;
|
|
min-width: 56px;
|
|
}
|
|
.yt-bottom-nav-item:hover { color: var(--text-primary); }
|
|
.yt-bottom-nav-item.active { color: var(--text-primary); }
|
|
.yt-bottom-nav-item i { font-size: 24px; }
|
|
.yt-bottom-nav-item span { font-size: 10px; font-weight: 500; }
|
|
@media (max-width: 768px) {
|
|
html { overflow: hidden; height: 100%; max-width: 100%; }
|
|
body {
|
|
overflow: hidden;
|
|
height: 100%;
|
|
max-width: 100%;
|
|
position: fixed;
|
|
width: 100%;
|
|
}
|
|
.yt-bottom-nav { display: flex; transform: none !important; }
|
|
.yt-main {
|
|
position: fixed !important;
|
|
top: 56px !important;
|
|
left: 0 !important;
|
|
right: 0 !important;
|
|
bottom: calc(56px + env(safe-area-inset-bottom, 0px)) !important;
|
|
margin: 0 !important;
|
|
padding: 16px !important;
|
|
padding-bottom: 16px !important;
|
|
min-height: unset !important;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
-webkit-overflow-scrolling: touch;
|
|
overscroll-behavior-y: contain;
|
|
}
|
|
body.has-impersonate-bar .yt-main { top: calc(56px + 40px) !important; }
|
|
.yt-main.video-view-page { padding: 0 !important; }
|
|
.yt-filter-bar {
|
|
position: relative !important;
|
|
top: auto !important;
|
|
z-index: auto !important;
|
|
margin: -16px -16px 16px !important;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
@yield('extra_styles')
|
|
|
|
{{-- Device fingerprint: computes a stable per-device hash on first visit,
|
|
caches it in localStorage + the `_fp` cookie so the server can dedupe guests
|
|
across IP/VPN/country changes. Loaded async — never blocks paint. --}}
|
|
<script src="{{ asset('fp.js') }}" async></script>
|
|
</head>
|
|
<body class="{{ $bodyClass ?? '' }} {{ session('impersonator_id') ? 'has-impersonate-bar' : '' }}">
|
|
<!-- Header -->
|
|
@include('layouts.partials.header')
|
|
|
|
<!-- Impersonation Banner -->
|
|
@if(session('impersonator_id'))
|
|
<div class="impersonate-bar">
|
|
<i class="bi bi-person-fill-gear"></i>
|
|
<span>Impersonating <strong>{{ Auth::user()->name }}</strong> — you are acting as this user</span>
|
|
<form method="POST" action="{{ route('impersonate.exit') }}" style="margin:0;">
|
|
@csrf
|
|
<button type="submit" class="impersonate-exit-btn">
|
|
<i class="bi bi-box-arrow-right"></i> Exit Impersonation
|
|
</button>
|
|
</form>
|
|
</div>
|
|
@endif
|
|
|
|
<!-- Mobile Search Overlay -->
|
|
<div class="mobile-search-overlay" id="mobileSearchOverlay">
|
|
<form action="{{ route('videos.search') }}" method="GET" class="mobile-search-form">
|
|
<input type="text" name="q" class="mobile-search-input" placeholder="Search" value="{{ request('q') }}">
|
|
<button type="submit" class="mobile-search-submit">
|
|
<i class="bi bi-search"></i>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Sidebar Overlay (Mobile) -->
|
|
<div class="yt-sidebar-overlay" onclick="toggleSidebar()"></div>
|
|
|
|
<!-- Sidebar -->
|
|
@include('layouts.partials.sidebar')
|
|
|
|
<!-- Main Content -->
|
|
<main class="yt-main @yield('main_class')" id="main">
|
|
@yield('content')
|
|
</main>
|
|
|
|
<!-- Upload Modal -->
|
|
@auth
|
|
@include('layouts.partials.upload-modal')
|
|
@include('layouts.partials.edit-video-modal')
|
|
@include('layouts.partials.sports-match-modal')
|
|
@include('layouts.partials.merge-video-modal')
|
|
@endauth
|
|
|
|
<!-- Add to Playlist Modal - Available for all users (shows login prompt if not authenticated) -->
|
|
@include('layouts.partials.add-to-playlist-modal')
|
|
|
|
<!-- Share Modal - Available on all pages -->
|
|
<x-share-modal />
|
|
|
|
<!-- Delete Video Modal -->
|
|
@auth
|
|
<div class="modal fade" id="deleteVideoModal" tabindex="-1" aria-labelledby="deleteVideoModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content" style="background: #1a1a1a; border: 1px solid #3f3f3f; border-radius: 12px;">
|
|
<div class="modal-header" style="border-bottom: 1px solid #3f3f3f; padding: 20px 24px;">
|
|
<h5 class="modal-title" id="deleteVideoModalLabel" style="color: #fff; font-weight: 600; display: flex; align-items: center; gap: 10px;">
|
|
<i class="bi bi-exclamation-triangle-fill" style="color: #ef4444;"></i>
|
|
Delete Video
|
|
</h5>
|
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body" style="padding: 24px;">
|
|
<div style="background: #282828; border-radius: 8px; padding: 16px; margin-bottom: 20px;">
|
|
<p style="color: #fff; margin: 0; font-size: 14px; line-height: 1.6;">
|
|
<strong style="color: #ef4444;">Warning:</strong> This action is permanent and cannot be undone.
|
|
All data associated with this video will be lost, including:
|
|
</p>
|
|
<ul style="color: #aaa; margin: 12px 0 0; padding-left: 20px; font-size: 14px; line-height: 1.8;">
|
|
<li>View count</li>
|
|
<li>Comments</li>
|
|
<li>Likes</li>
|
|
<li>Thumbnail</li>
|
|
<li>Video file</li>
|
|
</ul>
|
|
</div>
|
|
<div style="margin-bottom: 16px;">
|
|
<label for="deleteVideoInput" style="color: #aaa; font-size: 14px; margin-bottom: 8px; display: block;">
|
|
To confirm deletion, type <strong style="color: #fff;">"<span id="deleteVideoName"></span>"</strong> below:
|
|
</label>
|
|
<input type="text"
|
|
id="deleteVideoInput"
|
|
class="form-control"
|
|
style="background: #282828; border: 1px solid #3f3f3f; color: #fff; padding: 12px 16px; border-radius: 8px; font-size: 14px;"
|
|
placeholder="Enter video name">
|
|
</div>
|
|
@auth
|
|
@if(Auth::user()->two_factor_enabled)
|
|
<div id="deleteOtpWrap" style="margin-top: 4px;">
|
|
<label for="deleteOtpInput" style="color: #aaa; font-size: 14px; margin-bottom: 8px; display: flex; align-items: center; gap: 7px;">
|
|
<i class="bi bi-shield-lock-fill" style="color: #a78bfa;"></i>
|
|
<span>Enter your <strong style="color:#fff;">2FA code</strong> to confirm:</span>
|
|
</label>
|
|
<input type="text" id="deleteOtpInput" inputmode="numeric" pattern="[0-9]*"
|
|
maxlength="6" autocomplete="one-time-code"
|
|
class="delete-otp-input"
|
|
placeholder="000000">
|
|
</div>
|
|
@endif
|
|
@endauth
|
|
</div>
|
|
<div class="modal-footer" style="border-top: 1px solid #3f3f3f; padding: 16px 24px; gap: 12px;">
|
|
<button type="button" class="btn" style="background: #3f3f3f; color: #fff; padding: 10px 20px; border-radius: 8px; font-weight: 500; border: none;" data-bs-dismiss="modal">
|
|
Cancel
|
|
</button>
|
|
<button type="button" id="confirmDeleteBtn" class="btn" style="background: #ef4444; color: #fff; padding: 10px 20px; border-radius: 8px; font-weight: 500; border: none; opacity: 0.5; cursor: not-allowed;" disabled onclick="confirmDeleteVideo()">
|
|
Delete Video
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endauth
|
|
|
|
<!-- Toast Container -->
|
|
<div id="toast-container" style="position:fixed;bottom:24px;right:24px;z-index:99999;display:flex;flex-direction:column;gap:10px;pointer-events:none;"></div>
|
|
|
|
<!-- Generic Confirm Modal -->
|
|
<div class="modal fade" id="confirmModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered modal-sm">
|
|
<div class="modal-content" style="background:#1a1a1a;border:1px solid #3f3f3f;border-radius:12px;">
|
|
<div class="modal-body" style="padding:24px;">
|
|
<p id="confirmModalMessage" style="color:#fff;margin:0 0 20px;font-size:15px;line-height:1.5;"></p>
|
|
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
|
<button type="button" class="btn" data-bs-dismiss="modal" style="background:#3f3f3f;color:#fff;padding:8px 18px;border-radius:8px;border:none;font-size:14px;">Cancel</button>
|
|
<button type="button" id="confirmModalOkBtn" class="btn" style="background:#ef4444;color:#fff;padding:8px 18px;border-radius:8px;border:none;font-size:14px;">Confirm</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- YouTube-style Bottom Navigation Bar (Mobile) -->
|
|
<nav class="yt-bottom-nav">
|
|
<a href="{{ route('videos.index') }}" class="yt-bottom-nav-item {{ request()->routeIs('videos.index') ? 'active' : '' }}">
|
|
<i class="bi bi-house-door-fill"></i>
|
|
<span>Home</span>
|
|
</a>
|
|
<a href="{{ route('videos.trending') }}" class="yt-bottom-nav-item {{ request()->routeIs('videos.trending') ? 'active' : '' }}">
|
|
<i class="bi bi-fire"></i>
|
|
<span>Trending</span>
|
|
</a>
|
|
<a href="{{ auth()->check() ? route('videos.create') : route('login') }}" class="yt-bottom-nav-item {{ request()->routeIs('videos.create') ? 'active' : '' }}">
|
|
<i class="bi bi-plus-circle-fill"></i>
|
|
<span>Upload</span>
|
|
</a>
|
|
<a href="{{ route('history') }}" class="yt-bottom-nav-item {{ request()->routeIs('history') ? 'active' : '' }}">
|
|
<i class="bi bi-collection-play-fill"></i>
|
|
<span>History</span>
|
|
</a>
|
|
<a href="{{ auth()->check() ? route('channel', auth()->user()->channel) : route('login') }}" class="yt-bottom-nav-item">
|
|
<i class="bi bi-person-fill"></i>
|
|
<span>Profile</span>
|
|
</a>
|
|
</nav>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
<script>
|
|
// Global toast notification — replaces alert()
|
|
function showToast(message, type) {
|
|
type = type || 'info';
|
|
const colors = { success: '#22c55e', error: '#ef4444', warning: '#f59e0b', info: '#3b82f6' };
|
|
const icons = { success: 'bi-check-circle-fill', error: 'bi-x-circle-fill', warning: 'bi-exclamation-triangle-fill', info: 'bi-info-circle-fill' };
|
|
const color = colors[type] || colors.info;
|
|
const icon = icons[type] || icons.info;
|
|
const container = document.getElementById('toast-container');
|
|
const toast = document.createElement('div');
|
|
toast.style.cssText = 'background:#1a1a1a;border:1px solid #3f3f3f;border-left:4px solid ' + color + ';color:#fff;padding:14px 16px;border-radius:8px;font-size:14px;display:flex;align-items:center;gap:10px;min-width:260px;max-width:380px;box-shadow:0 4px 20px rgba(0,0,0,.6);pointer-events:all;opacity:0;transition:opacity .25s ease;';
|
|
toast.innerHTML = '<i class="bi ' + icon + '" style="color:' + color + ';font-size:16px;flex-shrink:0;"></i><span style="flex:1;">' + message + '</span><button onclick="this.parentElement.remove()" style="background:none;border:none;color:#888;cursor:pointer;padding:0;font-size:18px;line-height:1;">×</button>';
|
|
container.appendChild(toast);
|
|
requestAnimationFrame(function() { toast.style.opacity = '1'; });
|
|
setTimeout(function() {
|
|
toast.style.opacity = '0';
|
|
setTimeout(function() { toast.remove(); }, 280);
|
|
}, 4000);
|
|
}
|
|
|
|
// Flash session toasts
|
|
@if(session('toast_error'))
|
|
showToast(@json(session('toast_error')), 'error');
|
|
@endif
|
|
@if(session('toast_success'))
|
|
showToast(@json(session('toast_success')), 'success');
|
|
@endif
|
|
@if(session('toast_warning'))
|
|
showToast(@json(session('toast_warning')), 'warning');
|
|
@endif
|
|
@if(session('toast_info'))
|
|
showToast(@json(session('toast_info')), 'info');
|
|
@endif
|
|
|
|
// Global confirm modal — replaces confirm()
|
|
function showConfirm(message, onConfirm, confirmLabel) {
|
|
document.getElementById('confirmModalMessage').textContent = message;
|
|
const okBtn = document.getElementById('confirmModalOkBtn');
|
|
okBtn.textContent = confirmLabel || 'Confirm';
|
|
const modalEl = document.getElementById('confirmModal');
|
|
const modal = new bootstrap.Modal(modalEl);
|
|
const handler = function() {
|
|
okBtn.removeEventListener('click', handler);
|
|
modal.hide();
|
|
onConfirm();
|
|
};
|
|
okBtn.addEventListener('click', handler);
|
|
modalEl.addEventListener('hidden.bs.modal', function cleanup() {
|
|
okBtn.removeEventListener('click', handler);
|
|
modalEl.removeEventListener('hidden.bs.modal', cleanup);
|
|
});
|
|
modal.show();
|
|
}
|
|
|
|
// Mobile search toggle function
|
|
function toggleMobileSearch() {
|
|
const overlay = document.getElementById('mobileSearchOverlay');
|
|
overlay.classList.toggle('active');
|
|
|
|
// Focus input when overlay opens
|
|
if (overlay.classList.contains('active')) {
|
|
setTimeout(function() {
|
|
document.querySelector('.mobile-search-input').focus();
|
|
}, 100);
|
|
}
|
|
}
|
|
|
|
// Close mobile search on escape key
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') {
|
|
const overlay = document.getElementById('mobileSearchOverlay');
|
|
if (overlay.classList.contains('active')) {
|
|
overlay.classList.remove('active');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Sidebar toggle function
|
|
function toggleSidebar() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const overlay = document.querySelector('.yt-sidebar-overlay');
|
|
const main = document.getElementById('main');
|
|
|
|
// Check if we're on mobile or desktop
|
|
const isMobile = window.innerWidth < 992;
|
|
|
|
if (isMobile) {
|
|
// Mobile behavior - use 'open' class
|
|
sidebar.classList.toggle('open');
|
|
overlay.classList.toggle('show');
|
|
} else {
|
|
// Desktop behavior - use 'collapsed' class
|
|
sidebar.classList.toggle('collapsed');
|
|
main.classList.toggle('collapsed');
|
|
|
|
// Save state to localStorage
|
|
const isCollapsed = sidebar.classList.contains('collapsed');
|
|
localStorage.setItem('sidebarCollapsed', isCollapsed);
|
|
}
|
|
}
|
|
|
|
// Header user dropdown (custom, replaces Bootstrap)
|
|
function toggleHeaderDropdown() {
|
|
const btn = document.getElementById('headerUserBtn');
|
|
const panel = document.getElementById('headerUserPanel');
|
|
const open = panel.classList.contains('open');
|
|
closeHeaderDropdown();
|
|
if (!open) {
|
|
const r = btn.getBoundingClientRect();
|
|
panel.style.top = (r.bottom + 6) + 'px';
|
|
panel.style.right = Math.max(0, window.innerWidth - r.right) + 'px';
|
|
panel.style.left = '';
|
|
panel.classList.add('open');
|
|
}
|
|
}
|
|
function closeHeaderDropdown() {
|
|
const panel = document.getElementById('headerUserPanel');
|
|
if (panel) panel.classList.remove('open');
|
|
}
|
|
document.addEventListener('click', function(e) {
|
|
if (!e.target.closest('.yt-user-dropdown')) closeHeaderDropdown();
|
|
if (!e.target.closest('.yt-notif-wrap')) closeNotifPanel();
|
|
});
|
|
|
|
/* ── Notification panel ── */
|
|
@auth
|
|
(function () {
|
|
var fetchUrl = '{{ route("notifications.fetch") }}';
|
|
var countUrl = '{{ route("notifications.count") }}';
|
|
var readAllUrl = '{{ route("notifications.read-all") }}';
|
|
var csrf = '{{ csrf_token() }}';
|
|
var loaded = false;
|
|
var prevUnread = 0;
|
|
var panel = document.getElementById('notifPanel');
|
|
var badge = document.getElementById('notifBadge');
|
|
var markAllBtn = document.getElementById('notifMarkAll');
|
|
var list = document.getElementById('notifList');
|
|
|
|
// ── Chime (Web Audio API — no file, no autoplay warm-up needed) ──
|
|
var _actx = null;
|
|
var _chimeQueued = false;
|
|
|
|
function _ensureCtx() {
|
|
if (!_actx) {
|
|
try { _actx = new (window.AudioContext || window.webkitAudioContext)(); }
|
|
catch(e) {}
|
|
}
|
|
return _actx;
|
|
}
|
|
|
|
function _doChimeNow() {
|
|
var ctx = _actx;
|
|
if (!ctx) return;
|
|
try {
|
|
var t = ctx.currentTime;
|
|
[[880,0],[1108,0.13],[1320,0.26]].forEach(function(n) {
|
|
var osc = ctx.createOscillator();
|
|
var g = ctx.createGain();
|
|
osc.type = 'sine';
|
|
osc.frequency.value = n[0];
|
|
g.gain.setValueAtTime(0, t + n[1]);
|
|
g.gain.linearRampToValueAtTime(0.20, t + n[1] + 0.015);
|
|
g.gain.exponentialRampToValueAtTime(0.0001, t + n[1] + 0.75);
|
|
osc.connect(g);
|
|
g.connect(ctx.destination);
|
|
osc.start(t + n[1]);
|
|
osc.stop(t + n[1] + 0.8);
|
|
});
|
|
} catch(e) {}
|
|
}
|
|
|
|
// On every user gesture: resume context and flush any queued chime
|
|
function _onGesture() {
|
|
var ctx = _ensureCtx();
|
|
if (!ctx) return;
|
|
if (ctx.state === 'suspended') {
|
|
ctx.resume().then(function() {
|
|
if (_chimeQueued) { _chimeQueued = false; _doChimeNow(); }
|
|
}).catch(function(){});
|
|
} else if (_chimeQueued) {
|
|
_chimeQueued = false;
|
|
_doChimeNow();
|
|
}
|
|
}
|
|
['click','keydown','touchstart','scroll'].forEach(function(ev) {
|
|
document.addEventListener(ev, _onGesture, { passive: true });
|
|
});
|
|
|
|
function playChime() {
|
|
_ensureCtx();
|
|
if (!_actx) return;
|
|
if (_actx.state === 'running') {
|
|
_doChimeNow();
|
|
} else {
|
|
// Context suspended — queue it, also try resume immediately
|
|
_chimeQueued = true;
|
|
_actx.resume().then(function() {
|
|
if (_chimeQueued) { _chimeQueued = false; _doChimeNow(); }
|
|
}).catch(function(){});
|
|
}
|
|
}
|
|
|
|
// ── Bell ring animation ────────────────────────────────
|
|
var _bellTimer = null;
|
|
function ringBell() {
|
|
var btn = document.getElementById('notifBtn');
|
|
if (!btn) return;
|
|
btn.classList.remove('bell-ring');
|
|
// Force reflow so removing+adding the class restarts the animation
|
|
void btn.getBoundingClientRect();
|
|
btn.classList.add('bell-ring');
|
|
clearTimeout(_bellTimer);
|
|
_bellTimer = setTimeout(function () { btn.classList.remove('bell-ring'); }, 900);
|
|
}
|
|
|
|
function setBellUnread(hasUnread) {
|
|
var btn = document.getElementById('notifBtn');
|
|
if (!btn) return;
|
|
btn.classList.toggle('bell-has-unread', !!hasUnread);
|
|
if (!hasUnread) btn.classList.remove('bell-ring');
|
|
}
|
|
|
|
function post(url) {
|
|
return fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
|
|
credentials: 'same-origin',
|
|
});
|
|
}
|
|
|
|
function escHtml(s) {
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
function notifText(d) {
|
|
var actor = '<strong>' + escHtml(d.actor_name || d.uploader_name || d.user_name || '') + '</strong>';
|
|
var title = '"' + escHtml(d.video_title || '') + '"';
|
|
var preview = d.comment_preview ? ' <em class="yt-notif-preview">"' + escHtml(d.comment_preview) + '"</em>' : '';
|
|
switch (d.type) {
|
|
case 'new_comment': return actor + ' commented on your video ' + title + ':' + preview;
|
|
case 'new_reply': return actor + ' replied to your comment:' + preview;
|
|
case 'comment_like':return actor + ' liked your comment on ' + title;
|
|
case 'new_user': return '🎉 ' + actor + ' just joined TAKEONE!';
|
|
case 'new_subscriber': return '🔔 ' + actor + ' subscribed to your channel';
|
|
case 'video_like': return '❤️ ' + actor + ' liked your video ' + title;
|
|
case 'video_shared': return '📤 ' + actor + ' shared a video with you: ' + title + (d.message ? ' <em class="yt-notif-preview">"' + escHtml(d.message) + '"</em>' : '');
|
|
case 'new_post': return '📝 ' + actor + ' posted something new';
|
|
default: return actor + ' uploaded a new video: ' + title;
|
|
}
|
|
}
|
|
|
|
function notifHref(d) {
|
|
if (d.type === 'new_user') return '/channel/' + encodeURIComponent(d.user_channel || '');
|
|
if (d.type === 'new_subscriber') return '/channel/' + encodeURIComponent(d.actor_channel || '');
|
|
if (d.type === 'new_post') return '/channel/' + encodeURIComponent(d.author_channel || '');
|
|
return '/videos/' + escHtml(d.video_route_key || '');
|
|
}
|
|
|
|
function notifThumb(d) {
|
|
var avatarTypes = ['new_user', 'new_subscriber', 'new_post'];
|
|
if (avatarTypes.indexOf(d.type) !== -1) {
|
|
var av = d.user_avatar || d.actor_avatar || d.author_avatar || '';
|
|
return av
|
|
? '<img class="yt-notif-thumb" src="' + escHtml(av) + '" alt="" loading="lazy" onerror="notifThumbFallback(this)" style="border-radius:50%">'
|
|
: '<div class="yt-notif-thumb-placeholder"><i class="bi bi-person-circle"></i></div>';
|
|
}
|
|
return d.video_thumbnail
|
|
? '<img class="yt-notif-thumb" src="/media/thumbnails/' + escHtml(d.video_thumbnail) + '" alt="" loading="lazy" onerror="notifThumbFallback(this)">'
|
|
: '<div class="yt-notif-thumb-placeholder"><i class="bi bi-play-circle"></i></div>';
|
|
}
|
|
|
|
function renderNotifications(data) {
|
|
var unread = data.unread_count;
|
|
if (unread > 0) {
|
|
badge.textContent = unread > 99 ? '99+' : unread;
|
|
badge.style.display = '';
|
|
if (markAllBtn) markAllBtn.style.display = '';
|
|
} else {
|
|
badge.style.display = 'none';
|
|
if (markAllBtn) markAllBtn.style.display = 'none';
|
|
}
|
|
|
|
if (!data.notifications || data.notifications.length === 0) {
|
|
list.innerHTML = '<div class="yt-notif-empty">You\'re all caught up!</div>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = data.notifications.map(function (n) {
|
|
var d = n.data;
|
|
var dot = !n.read ? '<div class="yt-notif-dot"></div>' : '';
|
|
return '<a class="yt-notif-item' + (!n.read ? ' unread' : '') + '" '
|
|
+ 'href="' + notifHref(d) + '" '
|
|
+ 'data-notif-id="' + escHtml(n.id) + '" '
|
|
+ 'onclick="handleNotifClick(event, this)">'
|
|
+ notifThumb(d)
|
|
+ '<div class="yt-notif-body">'
|
|
+ '<div class="yt-notif-text">' + notifText(d) + '</div>'
|
|
+ '<div class="yt-notif-time">' + escHtml(n.time) + '</div>'
|
|
+ '</div>'
|
|
+ dot
|
|
+ '</a>';
|
|
}).join('');
|
|
}
|
|
|
|
window.notifThumbFallback = function (img) {
|
|
var ph = document.createElement('div');
|
|
ph.className = 'yt-notif-thumb-placeholder';
|
|
ph.innerHTML = '<i class="bi bi-play-circle"></i>';
|
|
img.parentNode.replaceChild(ph, img);
|
|
};
|
|
|
|
function loadNotifications() {
|
|
fetch(fetchUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) { loaded = true; renderNotifications(data); })
|
|
.catch(function () { list.innerHTML = '<div class="yt-notif-empty">Could not load notifications.</div>'; });
|
|
}
|
|
|
|
window.toggleNotifPanel = function () {
|
|
var btn = document.getElementById('notifBtn');
|
|
if (panel.style.display === 'none' || !panel.style.display) {
|
|
closeHeaderDropdown();
|
|
var r = btn.getBoundingClientRect();
|
|
var maxH = Math.min(600, window.innerHeight - r.bottom - 16);
|
|
panel.style.top = (r.bottom + 6) + 'px';
|
|
panel.style.right = Math.max(8, window.innerWidth - r.right) + 'px';
|
|
panel.style.left = '';
|
|
panel.style.maxHeight = maxH + 'px';
|
|
panel.style.display = 'flex';
|
|
btn.setAttribute('aria-expanded', 'true');
|
|
if (!loaded) loadNotifications();
|
|
} else {
|
|
closeNotifPanel();
|
|
}
|
|
};
|
|
|
|
window.closeNotifPanel = function () {
|
|
if (panel) { panel.style.display = 'none'; }
|
|
var btn = document.getElementById('notifBtn');
|
|
if (btn) btn.setAttribute('aria-expanded', 'false');
|
|
};
|
|
|
|
window.handleNotifClick = function (e, el) {
|
|
var id = el.dataset.notifId;
|
|
el.classList.remove('unread');
|
|
el.querySelector('.yt-notif-dot')?.remove();
|
|
post('{{ url("/notifications") }}/' + id + '/read')
|
|
.then(function () {
|
|
var remaining = list.querySelectorAll('.yt-notif-item.unread').length;
|
|
if (remaining === 0) {
|
|
badge.style.display = 'none';
|
|
if (markAllBtn) markAllBtn.style.display = 'none';
|
|
setBellUnread(false);
|
|
} else {
|
|
badge.textContent = remaining > 99 ? '99+' : remaining;
|
|
}
|
|
prevUnread = remaining;
|
|
});
|
|
closeNotifPanel();
|
|
};
|
|
|
|
window.markAllRead = function () {
|
|
list.querySelectorAll('.yt-notif-item.unread').forEach(function (el) {
|
|
el.classList.remove('unread');
|
|
el.querySelector('.yt-notif-dot')?.remove();
|
|
});
|
|
badge.style.display = 'none';
|
|
if (markAllBtn) markAllBtn.style.display = 'none';
|
|
prevUnread = 0;
|
|
setBellUnread(false);
|
|
post(readAllUrl);
|
|
};
|
|
|
|
// ── Poll every 60 s — count-only (1 lightweight DB query) ──
|
|
function poll() {
|
|
fetch(countUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
var newCount = data.unread_count || 0;
|
|
if (newCount > prevUnread) {
|
|
playChime();
|
|
ringBell();
|
|
badge.textContent = newCount > 99 ? '99+' : newCount;
|
|
badge.style.display = '';
|
|
if (markAllBtn) markAllBtn.style.display = '';
|
|
setBellUnread(true);
|
|
loaded = false; // force re-fetch when panel opens
|
|
} else if (newCount === 0 && prevUnread > 0) {
|
|
badge.style.display = 'none';
|
|
if (markAllBtn) markAllBtn.style.display = 'none';
|
|
setBellUnread(false);
|
|
}
|
|
prevUnread = newCount;
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
setInterval(poll, 60000);
|
|
|
|
// ── Initial badge load — deferred so it doesn't block page render ──
|
|
(window.requestIdleCallback || function(cb){ setTimeout(cb, 500); })(function () {
|
|
fetch(countUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
prevUnread = data.unread_count || 0;
|
|
if (prevUnread > 0) {
|
|
badge.textContent = prevUnread > 99 ? '99+' : prevUnread;
|
|
badge.style.display = '';
|
|
if (markAllBtn) markAllBtn.style.display = '';
|
|
setBellUnread(true);
|
|
}
|
|
})
|
|
.catch(function () {});
|
|
});
|
|
})();
|
|
@endauth
|
|
|
|
// Restore sidebar state from localStorage on page load
|
|
function restoreSidebarState() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const main = document.getElementById('main');
|
|
const isMobile = window.innerWidth < 992;
|
|
|
|
if (!isMobile) {
|
|
const savedState = localStorage.getItem('sidebarCollapsed');
|
|
// Default to mini (collapsed) if no preference saved — matches YouTube
|
|
if (savedState !== 'false') {
|
|
sidebar.classList.add('collapsed');
|
|
main.classList.add('collapsed');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set active sidebar link based on current route
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Restore sidebar state
|
|
restoreSidebarState();
|
|
|
|
const currentPath = window.location.pathname;
|
|
const sidebarLinks = document.querySelectorAll('.yt-sidebar-link');
|
|
|
|
sidebarLinks.forEach(link => {
|
|
const href = link.getAttribute('href');
|
|
if (href === currentPath || (currentPath.startsWith(href) && href !== '/')) {
|
|
link.classList.add('active');
|
|
} else if (href === '/' && currentPath === '/') {
|
|
link.classList.add('active');
|
|
}
|
|
});
|
|
});
|
|
|
|
// Handle window resize to reset state for mobile
|
|
window.addEventListener('resize', function() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const main = document.getElementById('main');
|
|
const isMobile = window.innerWidth < 992;
|
|
|
|
if (isMobile) {
|
|
// On mobile, remove collapsed state
|
|
sidebar.classList.remove('collapsed');
|
|
main.classList.remove('collapsed');
|
|
} else {
|
|
// On desktop, restore from localStorage
|
|
restoreSidebarState();
|
|
}
|
|
});
|
|
|
|
// Delete video modal functions
|
|
let currentDeleteVideoId = null;
|
|
let currentDeleteVideoTitle = '';
|
|
|
|
const _2faEnabled = {{ Auth::check() && Auth::user()->two_factor_enabled ? 'true' : 'false' }};
|
|
|
|
function showDeleteModal(videoId, videoTitle) {
|
|
currentDeleteVideoId = videoId;
|
|
currentDeleteVideoTitle = videoTitle.replace(/\s+/g, ' ').trim();
|
|
|
|
document.getElementById('deleteVideoName').textContent = currentDeleteVideoTitle;
|
|
document.getElementById('deleteVideoInput').value = '';
|
|
const otpInput = document.getElementById('deleteOtpInput');
|
|
if (otpInput) otpInput.value = '';
|
|
|
|
const deleteBtn = document.getElementById('confirmDeleteBtn');
|
|
deleteBtn.disabled = true;
|
|
deleteBtn.style.opacity = '0.5';
|
|
deleteBtn.style.cursor = 'not-allowed';
|
|
|
|
const dropdown = document.querySelector('.dropdown-menu.show');
|
|
if (dropdown) dropdown.classList.remove('show');
|
|
|
|
const modalElement = document.getElementById('deleteVideoModal');
|
|
const modal = new bootstrap.Modal(modalElement);
|
|
modal.show();
|
|
}
|
|
|
|
function _updateDeleteBtn() {
|
|
const titleOk = document.getElementById('deleteVideoInput').value.replace(/\s+/g, ' ').trim() === currentDeleteVideoTitle;
|
|
const otpInput = document.getElementById('deleteOtpInput');
|
|
const otpOk = !_2faEnabled || (otpInput && otpInput.value.replace(/\s/g,'').length === 6);
|
|
const deleteBtn = document.getElementById('confirmDeleteBtn');
|
|
const ok = titleOk && otpOk;
|
|
deleteBtn.disabled = !ok;
|
|
deleteBtn.style.opacity = ok ? '1' : '0.5';
|
|
deleteBtn.style.cursor = ok ? 'pointer' : 'not-allowed';
|
|
}
|
|
|
|
document.getElementById('deleteVideoInput').addEventListener('input', _updateDeleteBtn);
|
|
const _otpInput = document.getElementById('deleteOtpInput');
|
|
if (_otpInput) _otpInput.addEventListener('input', _updateDeleteBtn);
|
|
|
|
function confirmDeleteVideo() {
|
|
if (!currentDeleteVideoId || !currentDeleteVideoTitle) return;
|
|
|
|
const inputValue = document.getElementById('deleteVideoInput').value;
|
|
if (inputValue.replace(/\s+/g,' ').trim() !== currentDeleteVideoTitle) {
|
|
showToast('Video name does not match. Please try again.', 'error');
|
|
return;
|
|
}
|
|
|
|
const otpInput = document.getElementById('deleteOtpInput');
|
|
const otpCode = otpInput ? otpInput.value.replace(/\s/g, '') : '';
|
|
if (_2faEnabled && otpCode.length !== 6) {
|
|
showToast('Please enter your 6-digit 2FA code.', 'error');
|
|
if (otpInput) otpInput.focus();
|
|
return;
|
|
}
|
|
|
|
const headers = {
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
'Accept': 'application/json',
|
|
};
|
|
if (_2faEnabled) headers['X-2FA-Code'] = otpCode;
|
|
|
|
fetch(`/videos/${currentDeleteVideoId}`, { method: 'DELETE', headers })
|
|
.then(response => {
|
|
if (response.status === 200 || response.status === 302 || response.redirected) {
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteVideoModal'));
|
|
if (modal) modal.hide();
|
|
window.location.href = "{{ route('videos.index') }}";
|
|
} else if (response.status === 403) {
|
|
showToast('You do not have permission to delete this video.', 'error');
|
|
} else if (response.status === 404) {
|
|
showToast('Video not found.', 'error');
|
|
} else if (response.status === 422) {
|
|
response.json().then(data => {
|
|
showToast(data.message || 'Invalid 2FA code. Please try again.', 'error');
|
|
if (otpInput) { otpInput.value = ''; otpInput.focus(); }
|
|
_updateDeleteBtn();
|
|
});
|
|
} else {
|
|
response.json().then(data => {
|
|
showToast(data.message || 'Failed to delete video. Please try again.', 'error');
|
|
}).catch(() => {
|
|
showToast('Failed to delete video. Please try again.', 'error');
|
|
});
|
|
}
|
|
})
|
|
.catch(() => showToast('An error occurred while deleting the video.', 'error'));
|
|
}
|
|
</script>
|
|
|
|
{{-- Page-specific scripts come from each view's @section('scripts').
|
|
Wrapped in a marker element so the SPA navigator can swap & re-run
|
|
them — without this, SPA nav into a page whose JS defines functions
|
|
like switchTab() leaves those functions undefined, and any onclick
|
|
handler that calls them silently fails. --}}
|
|
<div id="page-scripts">
|
|
@yield('scripts')
|
|
</div>
|
|
|
|
{{-- ═══════════════════════════════════════════════════════════
|
|
ADMIN ERROR CATCHER — only rendered for super_admins
|
|
═══════════════════════════════════════════════════════════ --}}
|
|
@auth
|
|
@if(Auth::user()->isSuperAdmin())
|
|
<div id="errCatcher" style="position:fixed;bottom:80px;right:16px;z-index:99999;font-family:monospace;">
|
|
<!-- Badge -->
|
|
<button id="errBadge" onclick="toggleErrPanel()"
|
|
style="display:none;background:#e61e1e;color:#fff;border:none;border-radius:50%;width:40px;height:40px;font-size:14px;font-weight:700;cursor:pointer;box-shadow:0 2px 12px rgba(0,0,0,.5);position:relative;">
|
|
<span id="errCount">0</span>
|
|
<span style="position:absolute;top:-2px;right:-2px;width:10px;height:10px;background:#ff0;border-radius:50%;"></span>
|
|
</button>
|
|
<!-- Panel -->
|
|
<div id="errPanel" style="display:none;width:min(520px,90vw);max-height:70vh;overflow-y:auto;background:#111;border:1px solid #e61e1e;border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.7);">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid #333;background:#1a1a1a;border-radius:10px 10px 0 0;">
|
|
<span style="color:#e61e1e;font-weight:700;font-size:13px;"><i class="bi bi-bug-fill"></i> Error Catcher</span>
|
|
<div style="display:flex;gap:8px;">
|
|
<button onclick="clearErrors()" style="background:#333;border:none;color:#aaa;padding:3px 10px;border-radius:4px;font-size:11px;cursor:pointer;">Clear</button>
|
|
<button onclick="toggleErrPanel()" style="background:#333;border:none;color:#aaa;padding:3px 10px;border-radius:4px;font-size:11px;cursor:pointer;">Close</button>
|
|
</div>
|
|
</div>
|
|
<div id="errList" style="padding:10px 14px;display:flex;flex-direction:column;gap:10px;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
let errors = [];
|
|
|
|
function renderErrors() {
|
|
const list = document.getElementById('errList');
|
|
const badge = document.getElementById('errBadge');
|
|
const count = document.getElementById('errCount');
|
|
count.textContent = errors.length;
|
|
badge.style.display = errors.length ? 'block' : 'none';
|
|
list.innerHTML = errors.map((e, i) => `
|
|
<div style="background:#1a1a1a;border:1px solid #333;border-radius:6px;padding:10px;font-size:11px;">
|
|
<div style="display:flex;justify-content:space-between;margin-bottom:6px;">
|
|
<span style="color:#e61e1e;font-weight:700;">${e.status} ${e.method} ${e.url}</span>
|
|
<span style="color:#666;">${e.time}</span>
|
|
</div>
|
|
<pre style="color:#f8f8f2;white-space:pre-wrap;word-break:break-all;margin:0;max-height:200px;overflow-y:auto;background:#0a0a0a;padding:8px;border-radius:4px;">${escHtml(e.body)}</pre>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function escHtml(s) {
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
function addError(status, method, url, body) {
|
|
errors.unshift({ status, method, url, body: tryFormat(body), time: new Date().toLocaleTimeString() });
|
|
if (errors.length > 20) errors.pop();
|
|
renderErrors();
|
|
// Auto-open panel on first error
|
|
if (errors.length === 1) document.getElementById('errPanel').style.display = 'block';
|
|
}
|
|
|
|
function tryFormat(raw) {
|
|
try { return JSON.stringify(JSON.parse(raw), null, 2); } catch { return raw || '(empty)'; }
|
|
}
|
|
|
|
window.toggleErrPanel = function () {
|
|
const p = document.getElementById('errPanel');
|
|
p.style.display = p.style.display === 'none' ? 'block' : 'none';
|
|
};
|
|
window.clearErrors = function () {
|
|
errors = [];
|
|
renderErrors();
|
|
document.getElementById('errPanel').style.display = 'none';
|
|
};
|
|
|
|
// ── Intercept XHR ──────────────────────────────────────────
|
|
const OrigXHR = window.XMLHttpRequest;
|
|
function PatchedXHR() {
|
|
const xhr = new OrigXHR();
|
|
let _method, _url;
|
|
const origOpen = xhr.open.bind(xhr);
|
|
xhr.open = function (method, url, ...rest) {
|
|
_method = method; _url = url;
|
|
return origOpen(method, url, ...rest);
|
|
};
|
|
xhr.addEventListener('loadend', function () {
|
|
if (xhr.status !== 0 && (xhr.status < 200 || xhr.status >= 300)) {
|
|
addError(xhr.status, _method, _url, xhr.responseText);
|
|
}
|
|
});
|
|
return xhr;
|
|
}
|
|
PatchedXHR.prototype = OrigXHR.prototype;
|
|
window.XMLHttpRequest = PatchedXHR;
|
|
|
|
// ── Intercept fetch ────────────────────────────────────────
|
|
const origFetch = window.fetch;
|
|
window.fetch = async function (...args) {
|
|
const res = await origFetch(...args);
|
|
if (!res.ok) {
|
|
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? '?';
|
|
const meth = args[1]?.method ?? 'GET';
|
|
res.clone().text().then(body => addError(res.status, meth, url, body));
|
|
}
|
|
return res;
|
|
};
|
|
})();
|
|
</script>
|
|
@endif
|
|
@endauth
|
|
|
|
<!-- ── Persistent Mini-Player ──────────────────────────────────────────
|
|
#videoPlayer is teleported into #ytpMiniVideo via DOM adoption so
|
|
HLS.js keeps streaming without any reconnection or discontinuity.
|
|
Activated by scroll (video leaves viewport) or by SPA navigation.
|
|
──────────────────────────────────────────────────────────────────── -->
|
|
<div id="ytpMini" style="display:none;" aria-label="Mini player">
|
|
<div id="ytpMiniVideo"></div>
|
|
<div id="ytpMiniBar">
|
|
<div id="ytpMiniInfo">
|
|
<span id="ytpMiniTitle"></span>
|
|
</div>
|
|
<div id="ytpMiniControls">
|
|
<button id="ytpMiniPlay" title="Play / Pause"><i class="bi bi-play-fill"></i></button>
|
|
<a id="ytpMiniExpand" title="Back to video" href="#"><i class="bi bi-box-arrow-up-right"></i></a>
|
|
<button id="ytpMiniClose" title="Close"><i class="bi bi-x-lg"></i></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
/* ── Mini-player controller (teleportation-based) ─────────────────────
|
|
Moves the actual #videoPlayer element into the mini slot so HLS.js
|
|
never drops the stream. Two modes:
|
|
'scroll' — player scrolled out of viewport on the video page
|
|
'nav' — user navigated to a non-video page via SPA
|
|
───────────────────────────────────────────────────────────────────── */
|
|
/* Global on/off for the floating mini player. Persisted in localStorage so
|
|
the user's choice survives reloads and applies across video AND music
|
|
players. Default ON. The gear-menu toggles in each player flip this. */
|
|
window._ytpMiniEnabled = function () {
|
|
try { return localStorage.getItem('ytpMiniEnabled') !== '0'; }
|
|
catch (e) { return true; }
|
|
};
|
|
window._ytpMiniSetEnabled = function (on) {
|
|
try { localStorage.setItem('ytpMiniEnabled', on ? '1' : '0'); } catch (e) {}
|
|
/* Closing the mini cleanly if the user disabled it while it was active. */
|
|
if (!on && window._miniPlayer && window._miniPlayer.isActive()) {
|
|
window._miniPlayer.deactivate();
|
|
}
|
|
};
|
|
|
|
window._miniPlayer = (function () {
|
|
var wrap = document.getElementById('ytpMini');
|
|
var slot = document.getElementById('ytpMiniVideo');
|
|
var titleEl = document.getElementById('ytpMiniTitle');
|
|
var playBtn = document.getElementById('ytpMiniPlay');
|
|
var expandBtn = document.getElementById('ytpMiniExpand');
|
|
var closeBtn = document.getElementById('ytpMiniClose');
|
|
|
|
var _mode = null; /* 'scroll' | 'nav' | null */
|
|
var _kind = null; /* 'video' | 'audio' | null */
|
|
var _origParent = null;
|
|
var _origNext = null;
|
|
|
|
function getVid() { return document.getElementById('videoPlayer'); }
|
|
function getAudio() { return document.getElementById('audioEl'); }
|
|
/* The element the mini player drives — video element if present, else the
|
|
page's <audio>. Returned as a generic HTMLMediaElement either way. */
|
|
function getMedia() { return getVid() || getAudio(); }
|
|
|
|
function syncBtn() {
|
|
var m = getMedia();
|
|
if (!playBtn) return;
|
|
playBtn.querySelector('i').className = (!m || m.paused)
|
|
? 'bi bi-play-fill' : 'bi bi-pause-fill';
|
|
}
|
|
|
|
function activate(title, url, mode) {
|
|
if (_mode !== null) return false; /* already active — prevent re-entry */
|
|
if (!slot) return false;
|
|
|
|
var v = getVid();
|
|
if (v) {
|
|
/* VIDEO MODE — teleport the <video> element so HLS.js stays attached */
|
|
_kind = 'video';
|
|
_origParent = v.parentNode;
|
|
_origNext = v.nextSibling;
|
|
slot.appendChild(v);
|
|
} else {
|
|
/* AUDIO MODE — teleport the <audio> element OUT of #main so SPA
|
|
navigation (which replaces #main.innerHTML) cannot destroy it,
|
|
and playback continues uninterrupted. Show the current cover
|
|
art (or active slide) inside the visible slot. */
|
|
var a = getAudio();
|
|
if (!a) return false;
|
|
_kind = 'audio';
|
|
var coverSrc = '';
|
|
var slideA = document.getElementById('slideA');
|
|
if (slideA && slideA.offsetParent !== null && slideA.src) coverSrc = slideA.src;
|
|
if (!coverSrc) {
|
|
var cover = document.getElementById('audioCoverImg');
|
|
if (cover && cover.src) coverSrc = cover.src;
|
|
}
|
|
slot.innerHTML = coverSrc
|
|
? '<img id="ytpMiniCover" src="' + coverSrc + '" alt="" style="width:100%;height:100%;object-fit:cover;display:block;">'
|
|
: '<div style="width:100%;height:100%;background:#1a1a1a;display:flex;align-items:center;justify-content:center;color:#666;"><i class="bi bi-music-note-beamed" style="font-size:32px;"></i></div>';
|
|
|
|
/* Teleport the audio element to the mini wrap. <audio> is
|
|
invisible, so visual layout is unaffected. */
|
|
_origParent = a.parentNode;
|
|
_origNext = a.nextSibling;
|
|
wrap.appendChild(a);
|
|
}
|
|
|
|
titleEl.textContent = title || 'Now playing';
|
|
expandBtn.href = url || '#';
|
|
_mode = mode;
|
|
wrap.style.display = 'block';
|
|
syncBtn();
|
|
var m = getMedia();
|
|
if (m) {
|
|
m.addEventListener('play', syncBtn);
|
|
m.addEventListener('pause', syncBtn);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function restore() {
|
|
if (_kind === 'video') {
|
|
var v = getVid();
|
|
if (!v || !_origParent) return;
|
|
if (_origNext && _origNext.isConnected && _origNext.parentNode === _origParent) {
|
|
_origParent.insertBefore(v, _origNext);
|
|
} else {
|
|
_origParent.appendChild(v);
|
|
}
|
|
} else if (_kind === 'audio') {
|
|
/* Move <audio> back to its original parent if it still exists
|
|
(e.g. scroll-mode → user scrolled back to the player). If the
|
|
parent was wiped by an SPA nav, leave the audio in the mini. */
|
|
var a = getAudio();
|
|
if (a && _origParent && _origParent.isConnected) {
|
|
if (_origNext && _origNext.isConnected && _origNext.parentNode === _origParent) {
|
|
_origParent.insertBefore(a, _origNext);
|
|
} else {
|
|
_origParent.appendChild(a);
|
|
}
|
|
}
|
|
if (slot) slot.innerHTML = '';
|
|
}
|
|
_origParent = null;
|
|
_origNext = null;
|
|
_kind = null;
|
|
}
|
|
|
|
function deactivate() {
|
|
restore();
|
|
wrap.style.display = 'none';
|
|
_mode = null;
|
|
}
|
|
|
|
if (playBtn) {
|
|
playBtn.addEventListener('click', function () {
|
|
var m = getMedia();
|
|
if (!m) return;
|
|
if (m.paused) m.play().catch(function(){});
|
|
else m.pause();
|
|
});
|
|
}
|
|
|
|
if (expandBtn) {
|
|
expandBtn.addEventListener('click', function (e) {
|
|
if (_mode === 'scroll') {
|
|
e.preventDefault();
|
|
/* Scroll back to the player and restore it */
|
|
var main = document.getElementById('main');
|
|
if (main) main.scrollTo({ top: 0, behavior: 'smooth' });
|
|
deactivate();
|
|
return;
|
|
}
|
|
/* nav mode — hand off the playhead so the destination page
|
|
resumes playback from where the mini left off. The expand
|
|
target is the original player URL; we append:
|
|
resume=1 — tells the player to auto-start
|
|
t=<sec> — current playhead position
|
|
The video/audio player reads these query params on init. */
|
|
var m = getMedia();
|
|
if (!m) return; /* fall through to default nav */
|
|
var href = expandBtn.getAttribute('href') || '';
|
|
if (!href || href === '#') return;
|
|
try {
|
|
var u = new URL(href, location.href);
|
|
u.searchParams.set('resume', '1');
|
|
if (!isNaN(m.currentTime) && m.currentTime > 0) {
|
|
u.searchParams.set('t', Math.floor(m.currentTime).toString());
|
|
}
|
|
expandBtn.setAttribute('href', u.toString());
|
|
} catch (err) { /* leave href as-is */ }
|
|
});
|
|
}
|
|
|
|
if (closeBtn) {
|
|
closeBtn.addEventListener('click', function () {
|
|
if (_mode === 'scroll') {
|
|
/* User is still on the player's own page. Close the mini
|
|
and put the media element back in its original box so it
|
|
keeps playing like a background tab — no pause, no scroll
|
|
back up. We dispatch a custom event so the per-page
|
|
IntersectionObservers can reset their local "is the mini
|
|
on?" flag — otherwise scrolling away again wouldn't
|
|
re-trigger the mini until the user scrolls back over the
|
|
player first. */
|
|
restore();
|
|
wrap.style.display = 'none';
|
|
_mode = null;
|
|
window.dispatchEvent(new CustomEvent('miniplayer:scroll-closed'));
|
|
return;
|
|
}
|
|
/* nav mode — user has navigated away from the player's page.
|
|
Pause and fully tear down; the original player no longer
|
|
exists in the DOM to receive the media element. */
|
|
var m = getMedia();
|
|
if (m) m.pause();
|
|
wrap.style.display = 'none';
|
|
_mode = null;
|
|
_kind = null;
|
|
});
|
|
}
|
|
|
|
/* ── Drag-to-reposition ────────────────────────────────────────────
|
|
Desktop-only (mobile mini is already disabled). Persists the chosen
|
|
position in localStorage so the next session keeps it. Buttons /
|
|
anchors inside the bar are NOT drag handles — pointerdown on those
|
|
goes through to their click handler. */
|
|
var _drag = null;
|
|
var POS_KEY = 'ytpMiniPos';
|
|
|
|
function clampToViewport(left, top) {
|
|
var r = wrap.getBoundingClientRect();
|
|
var maxL = window.innerWidth - r.width - 4;
|
|
var maxT = window.innerHeight - r.height - 4;
|
|
return {
|
|
left: Math.max(4, Math.min(left, maxL)),
|
|
top: Math.max(4, Math.min(top, maxT)),
|
|
};
|
|
}
|
|
|
|
function applyPos(left, top) {
|
|
var c = clampToViewport(left, top);
|
|
wrap.style.left = c.left + 'px';
|
|
wrap.style.top = c.top + 'px';
|
|
wrap.style.right = 'auto';
|
|
wrap.style.bottom = 'auto';
|
|
}
|
|
|
|
function loadSavedPos() {
|
|
try {
|
|
var raw = localStorage.getItem(POS_KEY);
|
|
if (!raw) return;
|
|
var p = JSON.parse(raw);
|
|
if (typeof p.left === 'number' && typeof p.top === 'number') {
|
|
applyPos(p.left, p.top);
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
function startDrag(e) {
|
|
/* Ignore drag attempts on interactive children — buttons/anchors
|
|
keep their click semantics. */
|
|
if (e.target.closest('button, a')) return;
|
|
if (e.button !== undefined && e.button !== 0) return;
|
|
var r = wrap.getBoundingClientRect();
|
|
_drag = { dx: e.clientX - r.left, dy: e.clientY - r.top };
|
|
wrap.classList.add('dragging');
|
|
/* Lock in pixel coords for the first move so the wrap stops
|
|
relying on right/bottom anchoring. */
|
|
applyPos(r.left, r.top);
|
|
try { wrap.setPointerCapture(e.pointerId); } catch (er) {}
|
|
e.preventDefault();
|
|
}
|
|
|
|
function moveDrag(e) {
|
|
if (!_drag) return;
|
|
applyPos(e.clientX - _drag.dx, e.clientY - _drag.dy);
|
|
}
|
|
|
|
function endDrag(e) {
|
|
if (!_drag) return;
|
|
_drag = null;
|
|
wrap.classList.remove('dragging');
|
|
try { wrap.releasePointerCapture(e.pointerId); } catch (er) {}
|
|
try {
|
|
var r = wrap.getBoundingClientRect();
|
|
localStorage.setItem(POS_KEY, JSON.stringify({ left: r.left, top: r.top }));
|
|
} catch (er) {}
|
|
}
|
|
|
|
wrap.addEventListener('pointerdown', startDrag);
|
|
wrap.addEventListener('pointermove', moveDrag);
|
|
wrap.addEventListener('pointerup', endDrag);
|
|
wrap.addEventListener('pointercancel', endDrag);
|
|
|
|
/* Re-clamp on resize so the mini doesn't get stranded off-screen. */
|
|
window.addEventListener('resize', function () {
|
|
if (wrap.style.display === 'none') return;
|
|
var r = wrap.getBoundingClientRect();
|
|
applyPos(r.left, r.top);
|
|
});
|
|
|
|
/* Apply saved position when the mini activates (no point reading it
|
|
while the wrap is display:none — getBoundingClientRect would be 0). */
|
|
function _activateAndPosition(title, url, mode) {
|
|
var ok = activate(title, url, mode);
|
|
if (ok) loadSavedPos();
|
|
return ok;
|
|
}
|
|
|
|
return {
|
|
activate: function (t, u) { return _activateAndPosition(t, u, 'nav'); },
|
|
activateScroll: function (t, u) { return _activateAndPosition(t, u, 'scroll'); },
|
|
deactivate: deactivate,
|
|
deactivateScroll: function () { if (_mode === 'scroll') deactivate(); },
|
|
isActive: function () { return _mode !== null; },
|
|
isScrollMode: function () { return _mode === 'scroll'; },
|
|
isNavMode: function () { return _mode === 'nav'; },
|
|
setUrl: function (u) { if (expandBtn) expandBtn.href = u; },
|
|
setTitle: function (t) { if (titleEl) titleEl.textContent = t || 'Video'; },
|
|
/* Called when the user SPA-navigates away while the mini is in
|
|
scroll mode — converts it to nav mode so the expand button
|
|
returns to the player's original URL instead of scrolling. */
|
|
convertToNav: function (u) {
|
|
if (_mode !== 'scroll') return;
|
|
_mode = 'nav';
|
|
if (u && expandBtn) expandBtn.href = u;
|
|
/* Audio mode: the original parent is about to be wiped by the
|
|
SPA innerHTML swap, so forget it — restore() will then leave
|
|
the audio in the mini wrap on deactivate. */
|
|
_origParent = null;
|
|
_origNext = null;
|
|
},
|
|
syncBtn: syncBtn
|
|
};
|
|
})();
|
|
|
|
/* ── SPA navigation — swaps <main> content so the mini player video
|
|
element lives on across page changes without any HLS reconnection ── */
|
|
(function () {
|
|
|
|
function isVideoShowPage(url) {
|
|
/* /videos/{key} — two path segments, second not a static word */
|
|
var parts = new URL(url, location.href).pathname
|
|
.replace(/\/$/, '').split('/').filter(Boolean);
|
|
if (parts.length !== 2 || parts[0] !== 'videos') return false;
|
|
var STATIC = { search:1, trending:1, create:1, shorts:1 };
|
|
return !STATIC[parts[1]];
|
|
}
|
|
|
|
function isInternal(href) {
|
|
try { return new URL(href, location.href).origin === location.origin; }
|
|
catch(e) { return false; }
|
|
}
|
|
|
|
function reExecScripts(container) {
|
|
Array.from(container.querySelectorAll('script')).forEach(function (old) {
|
|
var n = document.createElement('script');
|
|
Array.from(old.attributes).forEach(function (a) {
|
|
n.setAttribute(a.name, a.value);
|
|
});
|
|
n.textContent = old.textContent;
|
|
old.parentNode.replaceChild(n, old);
|
|
});
|
|
}
|
|
|
|
function updateNavStates(url) {
|
|
var path = new URL(url, location.href).pathname;
|
|
document.querySelectorAll('.yt-bottom-nav-item, .yt-sidebar-item[href]')
|
|
.forEach(function (el) {
|
|
try {
|
|
var ep = new URL(el.getAttribute('href') || '', location.href).pathname;
|
|
el.classList.toggle('active', ep !== '/' && path.startsWith(ep) || ep === path);
|
|
} catch(e) {}
|
|
});
|
|
}
|
|
|
|
/* Import <style> blocks from the destination doc's <head> that
|
|
aren't already present in the current head. Idempotent: identical
|
|
textContent is only added once across the SPA session, so navigating
|
|
between pages doesn't keep growing the head. */
|
|
function importHeadStyles(doc) {
|
|
try {
|
|
var have = {};
|
|
document.head.querySelectorAll('style[data-spa-style]').forEach(function (s) {
|
|
have[s.dataset.spaStyle] = true;
|
|
});
|
|
var srcStyles = doc.head ? doc.head.querySelectorAll('style') : [];
|
|
Array.prototype.forEach.call(srcStyles, function (s) {
|
|
var txt = s.textContent || '';
|
|
if (!txt.trim()) return;
|
|
/* Hash via length + first/last bytes — cheap dedupe key */
|
|
var key = txt.length + ':' + txt.slice(0, 80) + ':' + txt.slice(-40);
|
|
if (have[key]) return;
|
|
have[key] = true;
|
|
var n = document.createElement('style');
|
|
n.dataset.spaStyle = key;
|
|
n.textContent = txt;
|
|
document.head.appendChild(n);
|
|
});
|
|
} catch (e) { /* non-fatal */ }
|
|
}
|
|
|
|
/* Top progress bar — gives the user immediate visual feedback that
|
|
the SPA navigation is in flight. The actual DOM swap can take
|
|
a noticeable beat on big pages; without this the click feels dead. */
|
|
var _spaBar = null;
|
|
function spaBarStart() {
|
|
if (!_spaBar) {
|
|
_spaBar = document.createElement('div');
|
|
_spaBar.style.cssText = 'position:fixed;top:0;left:0;height:2px;background:var(--brand-red,#e61e1e);z-index:99999;width:0%;transition:width .2s ease,opacity .25s ease;pointer-events:none;box-shadow:0 0 8px rgba(230,30,30,.6);';
|
|
document.body.appendChild(_spaBar);
|
|
}
|
|
_spaBar.style.opacity = '1';
|
|
_spaBar.style.width = '0%';
|
|
/* Two-stage trickle: jump to 25% immediately, creep to 70% while
|
|
waiting on network. spaBarDone() finishes the run. */
|
|
requestAnimationFrame(function () { _spaBar.style.width = '25%'; });
|
|
setTimeout(function () { if (_spaBar) _spaBar.style.width = '70%'; }, 300);
|
|
}
|
|
function spaBarDone() {
|
|
if (!_spaBar) return;
|
|
_spaBar.style.width = '100%';
|
|
setTimeout(function () {
|
|
if (!_spaBar) return;
|
|
_spaBar.style.opacity = '0';
|
|
setTimeout(function () { if (_spaBar) _spaBar.style.width = '0%'; }, 250);
|
|
}, 150);
|
|
}
|
|
|
|
function spaGo(url) {
|
|
spaBarStart();
|
|
/* Update the URL immediately so the address bar reflects the click.
|
|
If the load fails, popstate-like recovery isn't needed: the catch
|
|
below falls back to a hard nav which corrects the URL again. */
|
|
try { history.pushState({ spa: true, url: url, pending: true }, '', url); } catch (e) {}
|
|
updateNavStates(url);
|
|
|
|
fetch(url, { headers: { 'X-SPA-Nav': '1' }, credentials: 'same-origin' })
|
|
.then(function (r) { return r.text(); })
|
|
.then(function (html) {
|
|
var doc = new DOMParser().parseFromString(html, 'text/html');
|
|
var newM = doc.getElementById('main');
|
|
var curM = document.getElementById('main');
|
|
if (!newM || !curM) { location.href = url; return; }
|
|
|
|
/* Safety: if destination turned out to be a video page, hard-navigate */
|
|
if (doc.getElementById('ytpWrap') || doc.getElementById('videoPlayer')) {
|
|
location.href = url;
|
|
return;
|
|
}
|
|
|
|
document.title = doc.title;
|
|
curM.className = newM.className;
|
|
curM.innerHTML = newM.innerHTML;
|
|
reExecScripts(curM);
|
|
/* Pages render @section('extra_styles') into <head>, so a
|
|
plain #main swap loses the destination's styles. Copy
|
|
any <style> blocks from the new doc's <head> that we
|
|
don't already have. Identified by data-spa-style (set
|
|
below on first import) or by content hash. */
|
|
importHeadStyles(doc);
|
|
/* Page-level scripts live in #page-scripts (the wrapper
|
|
around the per-page scripts section) — swap and re-
|
|
execute so per-page helpers like channel's switchTab()
|
|
get defined again on this navigation. */
|
|
var srcPS = doc.getElementById('page-scripts');
|
|
var curPS = document.getElementById('page-scripts');
|
|
if (srcPS && curPS) {
|
|
curPS.innerHTML = srcPS.innerHTML;
|
|
reExecScripts(curPS);
|
|
}
|
|
curM.scrollTop = 0;
|
|
/* URL was already pushed at the top; replace state to drop
|
|
the `pending` flag now that the load succeeded. */
|
|
history.replaceState({ spa: true, url: url }, doc.title, url);
|
|
spaBarDone();
|
|
})
|
|
.catch(function () { location.href = url; });
|
|
}
|
|
|
|
function stopMiniAndNavigate(url) {
|
|
var v = document.getElementById('videoPlayer');
|
|
var a = document.getElementById('audioEl');
|
|
if (v) v.pause();
|
|
if (a) a.pause();
|
|
document.getElementById('ytpMini').style.display = 'none';
|
|
/* Allow browser to do a normal full-page load */
|
|
}
|
|
|
|
/* ── Intercept link clicks ── */
|
|
document.addEventListener('click', function (e) {
|
|
var a = e.target.closest('a[href]');
|
|
if (!a) return;
|
|
var href = a.getAttribute('href');
|
|
if (!href || href === '#' || /^(javascript:|mailto:|tel:)/.test(href)) return;
|
|
if (a.target === '_blank') return;
|
|
if (!isInternal(href)) return;
|
|
/* SPA-managed video cards handle their own transitions */
|
|
if (a.hasAttribute('data-rec-url') || a.hasAttribute('data-pl-id')) return;
|
|
|
|
var destUrl = new URL(href, location.href).href;
|
|
var v = document.getElementById('videoPlayer');
|
|
var aEl = document.getElementById('audioEl');
|
|
var playing = (v && (window._ytpWasPlaying || !v.paused))
|
|
|| (aEl && !aEl.paused);
|
|
var miniOn = window._miniPlayer && window._miniPlayer.isActive();
|
|
|
|
if (!playing && !miniOn) return;
|
|
|
|
/* Mobile: no floating mini player. Let the browser do a normal
|
|
full-page navigation; playback stops with the page like any
|
|
other site. The desktop-only mini is the only place where a
|
|
persistent floating bar makes sense. */
|
|
if (window.innerWidth <= 768) {
|
|
if (miniOn) stopMiniAndNavigate(destUrl);
|
|
return;
|
|
}
|
|
|
|
/* Going to a video page: stop mini, let browser do a full load */
|
|
if (isVideoShowPage(destUrl)) {
|
|
if (miniOn) stopMiniAndNavigate(destUrl);
|
|
return;
|
|
}
|
|
|
|
/* Mini disabled in the user's gear preference: treat as a normal
|
|
full navigation — pause and let the page change cleanly. Must
|
|
come BEFORE preventDefault(), otherwise we cancel the browser
|
|
click without running spaGo() and the link goes nowhere. */
|
|
if (!window._ytpMiniEnabled()) {
|
|
if (miniOn) stopMiniAndNavigate(destUrl);
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
|
|
/* Activate mini if the video is live in the page (not already in mini) */
|
|
if (playing && !miniOn) {
|
|
var title = document.title.replace(/\s*\|.*$/, '').trim();
|
|
window._miniPlayer.activate(title, location.href);
|
|
} else if (miniOn && window._miniPlayer.isScrollMode()) {
|
|
/* Already in scroll mode — convert to nav mode so the expand
|
|
button returns to the original player URL after SPA nav. */
|
|
window._miniPlayer.convertToNav(location.href);
|
|
}
|
|
|
|
spaGo(destUrl);
|
|
}, true);
|
|
|
|
/* ── Back / forward button ── */
|
|
window.addEventListener('popstate', function (e) {
|
|
var url = location.href;
|
|
var miniOn = window._miniPlayer && window._miniPlayer.isActive();
|
|
|
|
if (isVideoShowPage(url)) {
|
|
if (miniOn) stopMiniAndNavigate(url);
|
|
location.reload();
|
|
return;
|
|
}
|
|
|
|
if (miniOn || (e.state && e.state.spa)) {
|
|
spaGo(url);
|
|
}
|
|
});
|
|
|
|
})();
|
|
</script>
|
|
|
|
{{-- Profile-visit tracking: any link with data-profile-visit-url fires a beacon on click --}}
|
|
<script>
|
|
(function () {
|
|
var _csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
document.addEventListener('click', function (e) {
|
|
var a = e.target.closest('[data-profile-visit-url]');
|
|
if (!a) return;
|
|
var url = a.getAttribute('data-profile-visit-url');
|
|
var src = a.getAttribute('data-source-video-id') || '';
|
|
if (!url) return;
|
|
try {
|
|
var body = new URLSearchParams({ source_video_id: src }).toString();
|
|
if (navigator.sendBeacon) {
|
|
var blob = new Blob([body + '&_token=' + encodeURIComponent(_csrf)], { type: 'application/x-www-form-urlencoded' });
|
|
navigator.sendBeacon(url, blob);
|
|
} else {
|
|
fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-TOKEN': _csrf, 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
|
|
body: body,
|
|
credentials: 'same-origin',
|
|
keepalive: true,
|
|
}).catch(function(){});
|
|
}
|
|
} catch (err) {}
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|