ghassan f98e5415a3 Add lyrics pipeline, playlist views, admin toggles, and player polish
Lyrics pipeline (Whisper + Demucs + description alignment):
- New GenerateLyricsJob runs WhisperX with VAD filtering and forced word
  alignment, writes per-track JSON to NAS.
- New DecorateLyricsJob calls the active LLM provider to bake one to
  several emojis into each line (heavy decoration prompt).
- LyricsDescriptionParser strips heading content, section markers, and
  emoji-decoration from a song's description while preserving every
  language verbatim.
- correct_whisper_with_description aligner: strong-match anchors only,
  vocal-region-aware gap-fill so missing verses land on actual singing.
- Owner UI for generate/regenerate/edit/delete in the player gear.

Admin pages:
- /admin/lyrics    toggles for VAD, vocal gap-fill, Demucs, master
- /admin/gpu       extracted GPU section, encoder picker, FFmpeg path
- /admin/backup    extracted users-and-settings export/import
- /admin/settings  now AI/LLM only with provider list and Test button
- /admin/nas-storage hosts NAS settings, repair, disable flow, browser
- Shared partials/settings-styles for a uniform look across pages.

Playlist view tracking:
- Migration adds playlists.view_count and playlist_views dedup table.
- Playlist::bumpViewIfNew increments per device with a one-hour window.
- Tracked from /playlists/{id}, /playlists/share/{token}, /ps/{token},
  and /videos/{id}?playlist={token}.  Dispatched after-response so it
  never blocks the page render.
- Loading a playlist on the video page now runs one query instead of
  the four the old getNextVideo/getPreviousVideo path triggered.
- View counts shown on every playlist card and the playlist hero.

Player polish:
- Floating mini-player is draggable, persists its position in
  localStorage, clamps to viewport on resize.
- Mini disabled entirely on mobile (less than 768px).
- New gear-menu Mini Player toggle (persists in localStorage) lets the
  user disable both scroll-activation and SPA-nav-activation.
- Close button keeps media playing when used on the player's own page.
- SPA navigator now swaps a #page-scripts container so per-page JS
  (channel tabs, etc.) gets re-executed after content swaps.

Storage layout:
- Runtime data moved from /storage/* to /data/* and gitignored.
- /ml/venv, /ml/cache, /ml/__pycache__ excluded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 22:01:47 +03:00

2028 lines
106 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@extends('layouts.app')
@section('title', $user->name . ' — Channel | ' . config('app.name'))
@section('main_class', 'no-pad')
@section('extra_styles')
{{-- Styles are split by viewport so mobile and desktop can evolve
independently. Desktop is the base; mobile overrides via media
queries inside its own file. The $hue / $hue2 / $hue3 palette is
defined here at the parent level so both partials can reference
it each included partial runs in its own variable scope, so
hoisting the PHP block here makes the variables visible to both.
See partials/channel/styles/{desktop,mobile}.blade.php. --}}
@php
$hue = (crc32($user->name) % 360 + 360) % 360;
$hue2 = ($hue + 45) % 360;
$hue3 = ($hue + 200) % 360;
@endphp
@include('user.partials.channel.styles.desktop')
@include('user.partials.channel.styles.mobile')
@endsection
@section('content')
{{-- ─────────────── PREVIEW BANNER ─────────────────── --}}
@if($preview)
<div id="chPreviewBar" style="
position: sticky; top: 56px; z-index: 200;
background: #1a1200; border-bottom: 2px solid #f59e0b;
display: flex; align-items: center; justify-content: space-between;
padding: 10px 24px; gap: 12px;">
<div style="display:flex; align-items:center; gap:10px; color:#fbbf24; font-size:14px; font-weight:600;">
<i class="bi bi-eye-fill" style="font-size:16px;"></i>
You're viewing your profile as others see it
</div>
<a href="{{ route('channel', $user->channel) }}"
style="display:inline-flex; align-items:center; gap:6px; background:#f59e0b; color:#000;
font-size:13px; font-weight:700; padding:6px 14px; border-radius:6px; text-decoration:none;">
<i class="bi bi-arrow-left"></i> Back to my channel
</a>
</div>
@endif
{{-- ───────────────────── BANNER ───────────────────── --}}
<div class="ch-banner" id="chBanner">
@if($user->banner)
<img src="{{ $user->banner_url }}" id="chBannerImg" class="ch-banner-img" alt="">
@endif
<div class="ch-banner-noise"></div>
@if($isOwner && !$preview)
<button class="ch-banner-edit-btn" onclick="openCropperModal_banner()" title="Change banner">
<i class="bi bi-camera-fill"></i>
<span>Change banner</span>
</button>
@else
<div class="ch-banner-actions">
@auth
<button type="button"
class="ch-banner-subscribe-btn subscribe-toggle-btn {{ $isSubscriber ? 'subscribed-ch' : '' }}"
data-channel-id="{{ $user->id }}"
data-subscribe-url="{{ route('channel.subscribe', $user->id) }}"
data-subscribed="{{ $isSubscriber ? 'true' : 'false' }}">
<i class="bi {{ $isSubscriber ? 'bi-bell-fill' : 'bi-person-plus-fill' }}"></i>
<span class="subscribe-label">{{ $isSubscriber ? 'Subscribed' : 'Subscribe' }}</span>
</button>
@else
<a href="{{ route('login') }}" class="ch-banner-subscribe-btn">
<i class="bi bi-person-plus-fill"></i>
Subscribe
</a>
@endauth
<button class="ch-banner-share-btn" title="Share channel"
onclick="navigator.clipboard.writeText(window.location.href).then(()=>showToast('Channel link copied!','success'))">
<i class="bi bi-share"></i>
</button>
</div>
@endif
</div>
{{-- ─────────────────── CHANNEL HEADER ─────────────── --}}
@php
$headerSocialMap = [
'twitter' => ['icon'=>'bi-twitter-x', 'color'=>'#38bdf8', 'bg'=>'rgba(56,189,248,.1)', 'hover'=>'#38bdf8', 'href'=>fn($v)=>"https://twitter.com/{$v}"],
'instagram' => ['icon'=>'bi-instagram', 'color'=>'#818cf8', 'bg'=>'rgba(129,140,248,.1)', 'hover'=>'#818cf8', 'href'=>fn($v)=>"https://instagram.com/{$v}"],
'facebook' => ['icon'=>'bi-facebook', 'color'=>'#60a5fa', 'bg'=>'rgba(96,165,250,.1)', 'hover'=>'#60a5fa', 'href'=>fn($v)=>"https://facebook.com/{$v}"],
'youtube' => ['icon'=>'bi-youtube', 'color'=>'#94a3b8', 'bg'=>'rgba(148,163,184,.1)', 'hover'=>'#94a3b8', 'href'=>fn($v)=>"https://youtube.com/@{$v}"],
'tiktok' => ['icon'=>'bi-tiktok', 'color'=>'#67e8f9', 'bg'=>'rgba(103,232,249,.1)', 'hover'=>'#67e8f9', 'href'=>fn($v)=>"https://tiktok.com/@{$v}"],
'linkedin' => ['icon'=>'bi-linkedin', 'color'=>'#7dd3fc', 'bg'=>'rgba(125,211,252,.1)', 'hover'=>'#7dd3fc', 'href'=>fn($v)=>"https://linkedin.com/in/{$v}"],
'whatsapp' => ['icon'=>'bi-whatsapp', 'color'=>'#4ade80', 'bg'=>'rgba(74,222,128,.1)', 'hover'=>'#4ade80', 'href'=>fn($v)=>"https://wa.me/".preg_replace('/\D/','',$v)],
'website' => ['icon'=>'bi-globe2', 'color'=>'#a78bfa', 'bg'=>'rgba(167,139,250,.1)', 'hover'=>'#a78bfa', 'href'=>fn($v)=>preg_match('/^https?:\/\//',$v)?$v:"https://{$v}"],
'social_phone' => ['icon'=>'bi-telephone-fill', 'color'=>'#34d399', 'bg'=>'rgba(52,211,153,.1)', 'hover'=>'#34d399', 'href'=>fn($v)=>"tel:{$v}"],
'social_email' => ['icon'=>'bi-envelope-fill', 'color'=>'#fbbf24', 'bg'=>'rgba(251,191,36,.1)', 'hover'=>'#fbbf24', 'href'=>fn($v)=>"mailto:{$v}"],
'google_location'=> ['icon'=>'bi-geo-alt-fill', 'color'=>'#6ee7b7', 'bg'=>'rgba(110,231,183,.1)', 'hover'=>'#6ee7b7', 'href'=>fn($v)=>$v],
];
@endphp
<div class="ch-header">
<div class="ch-header-inner">
{{-- Avatar --}}
<div class="ch-avatar-wrap">
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="ch-avatar" id="chAvatarImg">
@if($isOwner && !$preview)
<button class="ch-avatar-edit-btn" onclick="openCropperModal_avatar()" title="Change photo">
<i class="bi bi-camera-fill"></i>
</button>
@endif
</div>
{{-- Info --}}
<div class="ch-info">
{{-- Name row --}}
<div class="ch-name-row">
<h1 class="ch-name">{{ $user->name }}</h1>
@if($user->email_verified_at)
<span class="ch-verified-badge" title="Verified"><i class="bi bi-check-lg"></i></span>
@endif
</div>
{{-- Flat stats row no pills --}}
<div class="ch-meta-row">
<span class="ch-meta-item">
<i class="bi bi-camera-video-fill"></i>
{{ number_format($videos->count() + $shorts->count()) }} videos
</span>
<span class="ch-meta-sep">·</span>
<span class="ch-meta-item">
<i class="bi bi-eye-fill"></i>
{{ number_format($totalViews) }} views
</span>
<span class="ch-meta-sep">·</span>
<span class="ch-meta-item channel-subs"
data-channel-id="{{ $user->id }}"
data-count="{{ $user->subscriber_count }}">
<i class="bi bi-people-fill"></i>
{{ number_format($user->subscriber_count) }} {{ Str::plural('subscriber', $user->subscriber_count) }}
</span>
@if($user->location)
<span class="ch-meta-sep">·</span>
<span class="ch-meta-item">
<i class="bi bi-geo-alt-fill"></i>
{{ $user->location }}
</span>
@endif
<span class="ch-meta-sep">·</span>
<span class="ch-meta-item">
<i class="bi bi-calendar3"></i>
Joined {{ $user->created_at->format('M Y') }}
</span>
@if($user->birthday)
@php $age = \Carbon\Carbon::parse($user->birthday)->age; @endphp
<span class="ch-meta-sep">·</span>
<span class="ch-meta-item">
<i class="bi bi-person-fill"></i>
{{ $age }} yrs
</span>
@endif
@if($user->gender === 'male')
<span class="ch-meta-sep">·</span>
<span class="ch-meta-item" style="color:#3b82f6;font-weight:700;">
<i class="bi bi-gender-male" style="font-size:14px;-webkit-text-stroke:.4px #3b82f6;filter:drop-shadow(0 0 6px #3b82f640);"></i>
Male
</span>
@elseif($user->gender === 'female')
<span class="ch-meta-sep">·</span>
<span class="ch-meta-item" style="color:#ec4899;font-weight:700;">
<i class="bi bi-gender-female" style="font-size:14px;-webkit-text-stroke:.4px #ec4899;filter:drop-shadow(0 0 6px #ec489940);"></i>
Female
</span>
@endif
</div>
@if($user->bio)
<div class="ch-bio" id="ch-bio">
<span class="ch-bio-short" id="ch-bio-short">{{ $user->bio }}</span>
<span class="ch-bio-full" id="ch-bio-full" style="display:none;">{{ $user->bio }}</span>
@if(strlen($user->bio) > 120)
<button class="ch-bio-toggle" id="ch-bio-btn" onclick="toggleBio()">…more</button>
@endif
</div>
@endif
{{-- Horoscope + Compatibility strip & Social buttons side by side --}}
@if($horoscope || $socialLinks->isNotEmpty())
@php
$hColor = $horoscope ? \App\Helpers\Horoscope::elementColor($horoscope['element']) : null;
$showCompat = $horoscope && ($compatibility !== null && $viewerSign && !$isOwner);
if ($showCompat) {
$compatColor = $compatibility >= 75 ? '#22c55e' : ($compatibility >= 55 ? '#eab308' : '#ef4444');
$compatLabel = $compatibility >= 85 ? 'Soulmates' : ($compatibility >= 70 ? 'Great match' : ($compatibility >= 55 ? 'Good vibes' : ($compatibility >= 40 ? 'Some friction' : 'Tough combo')));
}
@endphp
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:14px;">
@if($horoscope)
<div class="ch-horo-strip" style="margin-bottom:0; cursor:pointer;"
onclick="switchTab('about',document.querySelector('[data-tab=about]'))" title="Click to view horoscope">
<div class="ch-horo-sign-part">
<span class="ch-horo-emoji" style="filter:drop-shadow(0 0 8px {{ $hColor }});">{{ $horoscope['emoji'] }}</span>
<span class="ch-horo-name">{{ $horoscope['name'] }}</span>
<span class="ch-horo-el" style="color:{{ $hColor }};border-color:{{ $hColor }}20;background:{{ $hColor }}18;">{{ $horoscope['element'] }}</span>
</div>
@if($showCompat)
<span class="ch-horo-divider"></span>
<div class="ch-compat-mini">
<span style="font-size:13px;">❤️</span>
<span class="ch-compat-mini-pct" style="color:{{ $compatColor }};">{{ $compatibility }}%</span>
<div class="ch-compat-mini-track">
<div class="ch-compat-mini-fill" id="chCompatMiniBar" style="width:0%;background:{{ $compatColor }};"></div>
</div>
<span class="ch-compat-mini-label">{{ $compatLabel }}</span>
</div>
@endif
</div>
@endif
@if($isOwner && !$preview)
{{-- Owner manage dropdown: consolidates Edit channel / 2FA / Log Out All
so it can sit inline with the horoscope strip instead of taking up
a whole row beneath it. --}}
<div class="ch-manage-wrap" id="chManageWrap">
<button type="button" class="ch-manage-btn" id="chManageBtn"
onclick="event.stopPropagation(); window._chToggleManage && window._chToggleManage();">
<i class="bi bi-sliders"></i>
<span>Manage</span>
<i class="bi bi-chevron-down"></i>
</button>
<div class="ch-manage-menu" id="chManageMenu">
<button type="button" class="ch-manage-item" onclick="switchTab('settings', null); document.getElementById('chManageMenu').classList.remove('open');">
<i class="bi bi-pencil"></i>
<span>Edit channel</span>
</button>
<button type="button" class="ch-manage-item {{ $user->two_factor_enabled ? 'success' : 'warning' }}"
onclick="switchTab('settings', document.querySelector('[data-tab=settings]')); setTimeout(()=>document.getElementById('twoFactorCard')?.scrollIntoView({behavior:'smooth',block:'center'}),400); document.getElementById('chManageMenu').classList.remove('open');">
<i class="bi {{ $user->two_factor_enabled ? 'bi-shield-check-fill' : 'bi-shield-exclamation' }}"></i>
<span>{{ $user->two_factor_enabled ? '2FA is On' : 'Enable 2FA' }}</span>
</button>
<button type="button" class="ch-manage-item danger" onclick="openLogoutAllModal(); document.getElementById('chManageMenu').classList.remove('open');">
<i class="bi bi-box-arrow-right"></i>
<span>Log out all devices</span>
</button>
</div>
</div>
<script>
(function () {
var menu = document.getElementById('chManageMenu');
if (menu && menu.parentNode !== document.body) document.body.appendChild(menu);
window._chToggleManage = function () {
var b = document.getElementById('chManageBtn');
var m = document.getElementById('chManageMenu');
if (!b || !m) return;
var willOpen = !m.classList.contains('open');
m.classList.toggle('open');
if (willOpen) {
var r = b.getBoundingClientRect();
m.style.left = r.left + 'px';
m.style.top = (r.bottom + 6) + 'px';
m.style.minWidth = Math.max(220, r.width) + 'px';
}
};
document.addEventListener('click', function (e) {
var b = document.getElementById('chManageBtn');
var m = document.getElementById('chManageMenu');
if (m && b && !b.contains(e.target) && !m.contains(e.target)) m.classList.remove('open');
});
})();
</script>
{{-- Primary CTA + visitor-preview icon, inline alongside Manage. --}}
<button type="button" class="ch-btn-ghost" onclick="openUploadChooser()"
style="color:#fff;background:rgba(230,30,30,.18);border-color:rgba(230,30,30,.5);">
<i class="bi bi-camera-video"></i>
<span>Upload</span>
</button>
<a href="{{ route('channel', $user->channel) }}?preview=1" class="ch-btn-icon" title="Preview as visitor">
<i class="bi bi-eye"></i>
</a>
@endif
@if($socialLinks->isNotEmpty())
<div class="ch-social-row" style="margin-bottom:0;">
@foreach($socialLinks as $slink)
@if(isset($headerSocialMap[$slink->platform]))
@php $sm = $headerSocialMap[$slink->platform]; @endphp
<a href="{{ $sm['href']($slink->value) }}"
target="{{ in_array($slink->platform, ['social_phone','social_email']) ? '_self' : '_blank' }}"
rel="noopener"
class="ch-social-btn"
style="--sc:{{ $sm['color'] }};--sb:{{ $sm['bg'] }};--sh:{{ $sm['hover'] }};"
title="{{ ucfirst(str_replace(['social_','google_'],['',''],$slink->platform)) }}">
<i class="bi {{ $sm['icon'] }}"></i>
</a>
@endif
@endforeach
</div>
@endif
</div>
@endif
{{-- Owner action row moved up into the horoscope strip Edit/2FA/Logout
collapsed into Manage; Upload + Preview sit inline next to it. --}}
@if($isOwner && !$preview)
{{-- Logout All Devices Modal appended to body on open to escape stacking context --}}
<template id="logoutAllModalTpl">
<div id="logoutAllModal" style="position:fixed;inset:0;z-index:10100;background:rgba(0,0,0,.7);display:flex;align-items:center;justify-content:center;padding:20px 20px 80px;" onclick="if(event.target===this)closeLogoutAllModal()">
<div style="background:var(--bg-secondary,#1a1a1a);border:1px solid var(--border-color,#333);border-radius:16px;padding:28px;max-width:400px;width:100%;box-shadow:0 24px 64px rgba(0,0,0,.6);" onclick="event.stopPropagation()">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">
<div style="width:44px;height:44px;border-radius:50%;background:rgba(248,113,113,.15);display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i class="bi bi-box-arrow-right" style="color:#f87171;font-size:20px;"></i>
</div>
<div>
<div style="font-size:16px;font-weight:700;color:var(--text-primary,#fff);">Log Out All Other Devices</div>
<div style="font-size:12px;color:var(--text-secondary,#aaa);margin-top:2px;">Your current session will stay active</div>
</div>
</div>
<p style="font-size:13px;color:var(--text-secondary,#aaa);margin-bottom:20px;line-height:1.6;">
This will immediately end all other active sessions on phones, tablets, and other browsers. Enter your password to confirm.
</p>
<form method="POST" action="{{ route('settings.logoutAllDevices') }}">
@csrf
<div style="margin-bottom:16px;">
<label style="display:block;font-size:12px;font-weight:600;color:var(--text-secondary,#aaa);margin-bottom:7px;letter-spacing:.5px;text-transform:uppercase;">Password</label>
<input type="password" name="password" required placeholder="••••••••" id="logoutAllPwInput"
style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border-color,#333);border-radius:10px;padding:11px 14px;color:var(--text-primary,#fff);font-size:14px;">
@error('logout_password')
<div style="color:#f87171;font-size:12px;margin-top:5px;">{{ $message }}</div>
@enderror
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button type="button" class="action-btn" onclick="closeLogoutAllModal()">Cancel</button>
<button type="submit" class="action-btn action-btn-danger">
<i class="bi bi-box-arrow-right"></i> <span>Log Out All Devices</span>
</button>
</div>
</form>
</div>
</div>
</template>
@endif
</div>
</div>
</div>
{{-- ──────────────────────── TABS ───────────────────── --}}
<div class="ch-tabs-wrap">
<div class="ch-tabs" role="tablist">
<button class="ch-tab active" data-tab="wall" onclick="switchTab('wall', this)">
<i class="bi bi-layout-text-sidebar-reverse"></i>
Wall
@if($posts->count() > 0)
<span class="ch-tab-badge">{{ $posts->count() }}</span>
@endif
</button>
<button class="ch-tab" data-tab="videos" onclick="switchTab('videos', this)">
Videos
@if($videos->count() > 0)
<span class="ch-tab-badge">{{ $videos->count() }}</span>
@endif
</button>
@if($shorts->count() > 0)
<button class="ch-tab" data-tab="shorts" onclick="switchTab('shorts', this)">
Shorts
<span class="ch-tab-badge">{{ $shorts->count() }}</span>
</button>
@endif
@if(($playlists && $playlists->count() > 0) || $isOwner)
<button class="ch-tab" data-tab="playlists" onclick="switchTab('playlists', this)">
Playlists
@if($playlists && $playlists->count() > 0)
<span class="ch-tab-badge">{{ $playlists->count() }}</span>
@endif
</button>
@endif
<button class="ch-tab" data-tab="about" onclick="switchTab('about', this)">About</button>
@if($isOwner)
<button class="ch-tab" data-tab="settings" onclick="switchTab('settings', this)">
<i class="bi bi-gear" style="font-size:13px;"></i> Settings
</button>
@endif
</div>
</div>
{{-- spacer pushes content below the fixed tabs bar on mobile --}}
<div class="ch-tabs-spacer" style="display:none;"></div>
{{-- ─────────────────────── WALL TAB ────────────────── --}}
<div class="ch-tab-content active" id="tab-wall">
<div class="ch-wall-layout">
{{-- ── LEFT: composer + feed ── --}}
<div>
{{-- Composer (owner only) --}}
@auth
@if(Auth::id() === $user->id)
<div class="ch-composer">
<form action="{{ route('posts.store', $user->id) }}" method="POST" enctype="multipart/form-data" id="postForm">
@csrf
<div class="ch-composer-top">
<img src="{{ Auth::user()->avatar_url }}" alt="" class="ch-composer-avatar">
<div class="ch-composer-right">
<div class="ch-composer-name">{{ Auth::user()->name }}</div>
<textarea name="body" id="postBody"
placeholder="What's on your mind? Share with your community…"
maxlength="2000"
oninput="this.style.height='auto';this.style.height=this.scrollHeight+'px'"></textarea>
</div>
</div>
{{-- Multi-image preview grid --}}
<div id="imagePreviewGrid" class="ch-composer-img-grid" style="display:none;"></div>
<input type="file" name="images[]" id="postImages" accept="image/*" multiple style="display:none;" onchange="handlePostImages(this)">
{{-- Selected video chips --}}
<div id="postVideoChips" class="ch-composer-video-chips" style="display:none;"></div>
<div class="ch-composer-bar">
<div class="ch-composer-media">
<button type="button" class="ch-composer-media-btn photo" title="Add photos" onclick="document.getElementById('postImages').click()">
<i class="bi bi-image-fill"></i> Photo
</button>
@if($user->videos->count() > 0)
<button type="button" class="ch-composer-media-btn video" onclick="openVideoPicker()" title="Share videos">
<i class="bi bi-play-btn-fill"></i> Video
</button>
@endif
</div>
<button type="submit" class="ch-composer-post-btn">
<i class="bi bi-send-fill"></i> Post
</button>
</div>
</form>
</div>
@endif
@endauth
{{-- Posts feed --}}
@forelse($posts as $post)
@php
$postLiked = Auth::check() && $post->isLikedBy(Auth::user());
$bodyLen = strlen($post->body ?? '');
$isShort = $bodyLen > 0 && $bodyLen <= 80 && !$post->image && !$post->video_id && !$post->postImages->count() && !$post->postVideos->count();
@endphp
<div class="ch-post" id="post-{{ $post->id }}">
{{-- Header --}}
<div class="ch-post-header">
<a href="{{ $post->user ? route('channel', $post->user->channel) : '#' }}" class="ch-post-avatar-link">
<img src="{{ $post->user->avatar_url }}" alt="{{ $post->user->name }}" class="ch-post-avatar">
</a>
<div class="ch-post-meta">
<div class="ch-post-name">{{ $post->user->name }}</div>
<div class="ch-post-time">
<i class="bi bi-clock"></i>
<span title="{{ $post->created_at->format('D, M j Y — g:i A') }}">{{ $post->created_at->diffForHumans() }}</span>
</div>
</div>
@auth
@if(Auth::id() === $post->user_id || Auth::user()->isAdmin())
<div class="ch-post-menu-wrap">
<button class="ch-post-menu-btn" onclick="togglePostMenu(this)" title="More options">
<i class="bi bi-three-dots"></i>
</button>
<div class="ch-post-dropdown">
<form action="{{ route('posts.destroy', $post->id) }}" method="POST">
@csrf @method('DELETE')
<button type="submit" class="danger">
<i class="bi bi-trash3"></i> Delete post
</button>
</form>
</div>
</div>
@endif
@endauth
</div>
{{-- Body --}}
@if($post->body)
<div class="{{ $isShort ? 'ch-post-body-lg' : 'ch-post-body' }}">{{ $post->body }}</div>
@endif
{{-- Images (new multi-image) --}}
@php
$postImgs = $post->postImages;
$legacyImg = (!$postImgs->count() && $post->image) ? $post->image_url : null;
$imgCount = $postImgs->count() ?: ($legacyImg ? 1 : 0);
$countClass = $imgCount === 1 ? 'count-1' : ($imgCount === 2 ? 'count-2' : ($imgCount === 3 ? 'count-3' : ($imgCount === 4 ? 'count-4' : ($imgCount > 4 ? 'count-more' : ''))));
@endphp
@if($imgCount > 0)
<div class="ch-post-img-grid {{ $countClass }}">
@if($legacyImg)
<div class="ch-post-img-item" onclick="openLightbox('{{ $legacyImg }}')">
<img src="{{ $legacyImg }}" alt="" loading="lazy">
</div>
@else
@foreach($postImgs->take(4) as $idx => $img)
<div class="ch-post-img-item" onclick="openLightbox('{{ $img->image_url }}')">
<img src="{{ $img->image_url }}" alt="" loading="lazy">
@if($idx === 3 && $imgCount > 4)
<div class="ch-post-img-more">+{{ $imgCount - 4 }}</div>
@endif
</div>
@endforeach
@endif
</div>
@endif
{{-- Videos (new multi-video) --}}
@php
$postVids = $post->postVideos->filter(fn($pv) => $pv->video?->exists);
$legacyVid = (!$postVids->count() && $post->video_id && $post->video?->exists) ? $post->video : null;
$allVids = $legacyVid ? collect([$legacyVid]) : $postVids->map(fn($pv) => $pv->video);
@endphp
@if($allVids->count())
<div class="ch-post-video-wrap">
@foreach($allVids as $v)
<a href="{{ route('videos.show', $v) }}" class="ch-post-video-card" style="{{ !$loop->first ? 'margin-top:8px;' : '' }}">
<div class="ch-post-video-thumb-wrap">
<img src="{{ $v->thumbnail ? route('media.thumbnail', $v->thumbnail) : '' }}"
alt="" onerror="this.style.display='none'">
<div class="ch-post-video-play"><i class="bi bi-play-circle-fill"></i></div>
</div>
<div class="ch-post-video-info">
<div class="ch-post-video-title">{{ $v->title }}</div>
<div class="ch-post-video-meta">
<span class="ch-post-video-badge"><i class="bi bi-eye"></i> {{ \Illuminate\Support\Number::abbreviate($v->view_count, precision: 1) }}</span>
@if($v->duration)<span class="ch-post-video-badge"><i class="bi bi-clock"></i> {{ $v->formatted_duration }}</span>@endif
@if($v->is_shorts)<span class="ch-post-video-badge" style="color:#e33;"><i class="bi bi-phone"></i> Short</span>@endif
</div>
</div>
</a>
@endforeach
</div>
@endif
{{-- Actions --}}
<div class="ch-post-actions">
@auth
<button class="ch-post-action-btn {{ $postLiked ? 'liked' : '' }}"
data-post-id="{{ $post->id }}"
data-react-url="{{ route('posts.react', $post->id) }}"
onclick="reactPost(this)">
<span class="ch-post-like-anim">
<i class="bi {{ $postLiked ? 'bi-heart-fill' : 'bi-heart' }}"></i>
</span>
<span class="post-like-count">{{ $post->reaction_count > 0 ? $post->reaction_count : '' }}</span>
<span style="margin-left:2px;">{{ $postLiked ? 'Liked' : 'Like' }}</span>
</button>
@else
<span class="ch-post-action-btn" style="cursor:default;">
<i class="bi bi-heart"></i>
{{ $post->reaction_count > 0 ? $post->reaction_count : '' }}
<span style="margin-left:2px;">Like</span>
</span>
@endauth
<div class="ch-post-action-sep"></div>
<button class="ch-post-action-btn"
onclick="navigator.clipboard.writeText('{{ url()->current() }}').then(()=>showToast('Link copied!','success'))">
<i class="bi bi-share"></i> Share
</button>
</div>
</div>
@empty
{{-- Empty state --}}
<div class="ch-wall-empty">
<div class="ch-wall-empty-icon"></div>
<h3>Nothing here yet</h3>
@auth
@if(Auth::id() === $user->id)
<p>This is your wall. Share a thought, a photo, or one of your videos with your community!</p>
@else
<p>{{ $user->name }} hasn't shared anything yet — check back soon!</p>
@endif
@else
<p>{{ $user->name }} hasn't shared anything yet. Sign in to be notified when they do.</p>
@endauth
</div>
@endforelse
</div>
{{-- ── RIGHT: sidebar ── --}}
<div class="ch-wall-sidebar">
@if(!$horoscope && $isOwner)
<div class="ch-sw-card" style="text-align:center;padding:22px 16px;">
<div style="font-size:32px;margin-bottom:8px;">🔮</div>
<div style="font-size:14px;font-weight:700;color:var(--text-primary);margin-bottom:8px;">Your Horoscope Awaits</div>
<p style="font-size:12px;color:var(--text-secondary);line-height:1.6;margin-bottom:14px;">Add your birthday to unlock your zodiac sign, age, personality traits, and compatibility with visitors.</p>
<button type="button" class="action-btn action-btn-primary" style="width:100%;" onclick="switchTab('settings', document.querySelector('[data-tab=settings]')); setTimeout(()=>document.querySelector('[name=birthday]')?.closest('.ch-settings-field')?.scrollIntoView({behavior:'smooth',block:'center'}),400);">
<i class="bi bi-calendar-heart"></i> <span>Add Birthday</span>
</button>
</div>
@elseif($horoscope)
@php
$swHColor = \App\Helpers\Horoscope::elementColor($horoscope['element']);
$swPct = $compatibility;
$swCColor = $swPct !== null ? ($swPct >= 75 ? '#4ade80' : ($swPct >= 55 ? '#fbbf24' : '#f87171')) : null;
$swLabel = $swPct !== null ? ($swPct >= 85 ? 'Soulmates' : ($swPct >= 70 ? 'Great Match' : ($swPct >= 55 ? 'Good Vibes' : ($swPct >= 40 ? 'Some Friction' : 'Tough Combo')))) : null;
$swEmoji = $swPct !== null ? ($swPct >= 85 ? '✨' : ($swPct >= 70 ? '💛' : ($swPct >= 55 ? '🙂' : ($swPct >= 40 ? '🤔' : '⚡')))) : null;
@endphp
<div class="ch-sw-card" style="--sw-h:{{ $swHColor }};">
{{-- Sign row --}}
<div class="ch-sw-sign-row">
<span class="ch-sw-emoji" style="filter:drop-shadow(0 0 10px {{ $swHColor }});">{{ $horoscope['emoji'] }}</span>
<div class="ch-sw-sign-info">
<div class="ch-sw-sign-name">{{ $horoscope['name'] }} <span style="opacity:.5;font-weight:500;">{{ $horoscope['symbol'] }}</span></div>
<div class="ch-sw-element" style="color:{{ $swHColor }};">{{ $horoscope['element'] }} sign</div>
</div>
</div>
{{-- Traits --}}
<div class="ch-sw-traits">
@foreach($horoscope['traits'] as $trait)
<span class="ch-sw-trait">{{ $trait }}</span>
@endforeach
</div>
{{-- Compat section --}}
@if($swPct !== null && $viewerSign)
<div class="ch-sw-compat">
<div class="ch-sw-compat-signs">
<span class="ch-sw-compat-sign">{{ $viewerSign['emoji'] }}<small>You</small></span>
<span class="ch-sw-compat-heart">❤️</span>
<span class="ch-sw-compat-sign">{{ $horoscope['emoji'] }}<small>{{ explode(' ', $user->name)[0] }}</small></span>
<div style="flex:1;"></div>
<span class="ch-sw-compat-pct" style="color:{{ $swCColor }};">{{ $swPct }}%</span>
<span class="ch-sw-compat-lbl">{{ $swEmoji }} {{ $swLabel }}</span>
</div>
<div class="ch-sw-compat-bar">
<div class="ch-sw-compat-fill" id="compatBar" style="width:0%;background:{{ $swCColor }};"></div>
</div>
</div>
@elseif(auth()->check() && !auth()->user()->birthday && auth()->id() !== $user->id)
<div class="ch-sw-compat-prompt">
🔮 <a href="{{ route('channel') }}#settings">Add your birthday</a> to see your cosmic match with {{ explode(' ', $user->name)[0] }}
</div>
@endif
</div>
@endif
</div>{{-- .ch-wall-sidebar --}}
</div>
</div>
{{-- ─────────────────────── VIDEOS TAB ──────────────── --}}
<div class="ch-tab-content" id="tab-videos">
@if($videos->count() > 0)
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:20px;">
<div class="ch-vid-search-wrap" style="margin-bottom:0; flex:1; min-width:180px;">
<i class="bi bi-search"></i>
<input type="search" class="ch-vid-search" id="chVidSearch" placeholder="Search videos…" autocomplete="off">
</div>
<div class="ch-sort-bar" style="margin-bottom:0; flex-shrink:0;">
<span class="ch-sort-label">Sort</span>
<div class="ch-sort-pills">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'latest']) }}"
class="ch-sort-pill {{ $sort === 'latest' ? 'active' : '' }}">Latest</a>
<a href="{{ request()->fullUrlWithQuery(['sort' => 'popular']) }}"
class="ch-sort-pill {{ $sort === 'popular' ? 'active' : '' }}">Popular</a>
<a href="{{ request()->fullUrlWithQuery(['sort' => 'oldest']) }}"
class="ch-sort-pill {{ $sort === 'oldest' ? 'active' : '' }}">Oldest</a>
</div>
</div>
</div>
<div class="yt-video-grid" id="chVidGrid">
@foreach($videos as $video)
@include('components.video-card', ['video' => $video])
@endforeach
<div class="ch-vid-no-results" id="chVidNoResults">
<i class="bi bi-search"></i>
<span>No videos match your search</span>
</div>
</div>
@else
<div class="ch-empty">
<div class="ch-empty-icon"><i class="bi bi-camera-video"></i></div>
<h3>No videos yet</h3>
<p>This channel hasn't uploaded any videos.</p>
@auth
@if(Auth::id() === $user->id)
<button class="ch-btn-primary" onclick="openUploadChooser()">
<i class="bi bi-cloud-upload"></i> Upload your first video
</button>
@endif
@endauth
</div>
@endif
</div>
{{-- ─────────────────────── SHORTS TAB ──────────────── --}}
@if($shorts->count() > 0)
<div class="ch-tab-content" id="tab-shorts">
<div class="ch-shorts-grid">
@foreach($shorts as $short)
<a href="{{ route('videos.show', $short) }}" class="ch-short-card">
<div class="ch-short-thumb">
<img src="{{ $short->thumbnail ? route('media.thumbnail', $short->thumbnail) : 'https://picsum.photos/seed/' . $short->id . '/360/640' }}"
alt="{{ $short->title }}" loading="lazy">
@if($short->duration)
<span class="ch-short-duration">{{ gmdate('i:s', $short->duration) }}</span>
@endif
@if($isOwner && $short->visibility === 'private')
<span class="ch-short-visibility-badge ch-vis-private"><i class="bi bi-lock-fill"></i> Private</span>
@elseif($isOwner && $short->visibility === 'unlisted')
<span class="ch-short-visibility-badge ch-vis-unlisted"><i class="bi bi-link-45deg"></i> Unlisted</span>
@endif
<div class="ch-short-play"><i class="bi bi-play-fill"></i></div>
</div>
<div class="ch-short-title">{{ $short->title }}</div>
<div class="ch-short-meta">{{ number_format($short->view_count) }} views</div>
</a>
@endforeach
</div>
</div>
@endif
{{-- ─────────────────────── PLAYLISTS TAB ───────────── --}}
@if(($playlists && $playlists->count() > 0) || $isOwner)
<div class="ch-tab-content" id="tab-playlists">
@if($isOwner)
<div style="display:flex; justify-content:flex-end; margin-bottom:16px;">
<button class="action-btn action-btn-primary" onclick="openChannelCreatePlaylistModal()">
<i class="bi bi-plus-lg"></i> <span>New Playlist</span>
</button>
</div>
@endif
@if($playlists && $playlists->count() > 0)
<div class="ch-playlists-grid">
@foreach($playlists as $playlist)
@php
$plFirstVid = $playlist->videos->first();
$plCardUrl = $plFirstVid
? route('videos.show', $plFirstVid) . '?playlist=' . $playlist->share_token
: route('playlists.show', $playlist->id);
@endphp
<a href="{{ $plCardUrl }}" class="ch-playlist-card">
<div class="ch-playlist-thumb">
@if($playlist->thumbnail_url)
<img src="{{ $playlist->thumbnail_url }}" alt="{{ $playlist->name }}" loading="lazy">
@else
<div class="ch-pl-placeholder"><i class="bi bi-collection-play"></i></div>
@endif
<div class="ch-playlist-overlay"></div>
<div class="ch-playlist-count">
<i class="bi bi-play-fill"></i>
{{ $playlist->video_count }} videos
</div>
</div>
<div class="ch-playlist-name">{{ $playlist->name }}</div>
<div class="ch-playlist-meta">
@if($playlist->is_default)
Watch Later
@elseif($playlist->visibility === 'private')
<i class="bi bi-lock-fill"></i> Private
@elseif($playlist->visibility === 'unlisted')
<i class="bi bi-link-45deg"></i> Unlisted
@else
<i class="bi bi-globe"></i> Public
@endif
· <i class="bi bi-eye"></i> {{ \Illuminate\Support\Number::abbreviate($playlist->view_count, precision: 1) }}
</div>
</a>
@endforeach
</div>
@else
<div style="text-align:center; padding:60px 20px; color:var(--text-secondary);">
<i class="bi bi-collection-play" style="font-size:48px; margin-bottom:12px; display:block;"></i>
<p style="margin:0 0 16px;">No playlists yet. Create your first one!</p>
</div>
@endif
</div>
@endif
{{-- ─── Create Playlist Modal (channel page) ─────────── --}}
@if($isOwner)
<div id="chCreatePlaylistModal" style="display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.7); z-index:9999; align-items:center; justify-content:center; backdrop-filter:blur(2px);">
<div style="background:#282828; border-radius:12px; width:90%; max-width:480px; box-shadow:0 8px 32px rgba(0,0,0,.5); overflow:hidden; animation:chPlModalIn .2s ease;">
<div style="display:flex; justify-content:space-between; align-items:center; padding:20px 24px; border-bottom:1px solid #3f3f3f;">
<h2 style="font-size:18px; font-weight:600; margin:0; color:#fff;">Create new playlist</h2>
<button type="button" onclick="closeChannelCreatePlaylistModal()" style="background:transparent; border:none; color:#aaa; cursor:pointer; font-size:22px; line-height:1; border-radius:50%; width:32px; height:32px; display:flex; align-items:center; justify-content:center;">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div style="padding:24px;">
<form id="chCreatePlaylistForm" enctype="multipart/form-data">
@csrf
<!-- Thumbnail -->
<div style="margin-bottom:20px;">
<label style="display:block; margin-bottom:8px; font-weight:500; font-size:14px; color:#fff;">Thumbnail</label>
<div id="chPlThumbZone" style="border:2px dashed #3f3f3f; border-radius:12px; padding:20px; text-align:center; cursor:pointer; background:#1a1a1a; transition:all .2s;"
onmouseover="this.style.borderColor='#e61e1e'; this.style.background='rgba(230,30,30,0.05)'"
onmouseout="this.style.borderColor='#3f3f3f'; this.style.background='#1a1a1a'">
<input type="file" name="thumbnail" id="chPlThumbInput" accept="image/*" style="display:none;">
<div id="chPlThumbDefault">
<div style="font-size:36px; color:#666; margin-bottom:8px;"><i class="bi bi-card-image"></i></div>
<p style="color:#aaa; font-size:13px; margin:0 0 4px;">Click to upload thumbnail</p>
<p style="color:#555; font-size:11px; margin:0;">JPG, PNG, GIF, WebP (max 5MB)</p>
</div>
<div id="chPlThumbPreview" style="display:none; position:relative;">
<img id="chPlThumbImg" src="" alt="" style="max-width:100%; max-height:160px; border-radius:8px; object-fit:cover;">
<button type="button" onclick="chRemovePlThumb(event)" style="position:absolute; top:-8px; right:-8px; width:24px; height:24px; background:#e61e1e; color:#fff; border:none; border-radius:50%; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:14px;"><i class="bi bi-x"></i></button>
</div>
</div>
</div>
<!-- Name -->
<div style="margin-bottom:20px;">
<label style="display:block; margin-bottom:8px; font-weight:500; font-size:14px; color:#fff;">Name *</label>
<input type="text" name="name" required placeholder="Enter playlist name"
style="width:100%; padding:12px 14px; border:1px solid #3f3f3f; border-radius:8px; background:#121212; color:#fff; font-size:14px; outline:none; box-sizing:border-box;"
onfocus="this.style.borderColor='#e61e1e'" onblur="this.style.borderColor='#3f3f3f'">
</div>
<!-- Description -->
<div style="margin-bottom:20px;">
<label style="display:block; margin-bottom:8px; font-weight:500; font-size:14px; color:#fff;">Description</label>
<textarea name="description" rows="3" placeholder="Add a description (optional)"
style="width:100%; padding:12px 14px; border:1px solid #3f3f3f; border-radius:8px; background:#121212; color:#fff; font-size:14px; outline:none; resize:none; box-sizing:border-box;"
onfocus="this.style.borderColor='#e61e1e'" onblur="this.style.borderColor='#3f3f3f'"></textarea>
</div>
<!-- Visibility -->
<div style="margin-bottom:24px;">
<label style="display:block; margin-bottom:10px; font-weight:500; font-size:14px; color:#fff;">Visibility</label>
<div style="display:flex; flex-direction:column; gap:8px;">
<label style="display:flex; align-items:center; gap:12px; cursor:pointer; padding:10px 12px; border-radius:8px; border:1px solid #3f3f3f; background:#1a1a1a;">
<input type="radio" name="visibility" value="public" style="accent-color:#e61e1e; width:16px; height:16px;">
<div>
<div style="display:flex; align-items:center; gap:6px; color:#fff; font-size:14px; font-weight:500;"><i class="bi bi-globe"></i> Public</div>
<div style="color:#aaa; font-size:12px; margin-top:2px;">Anyone can search for and view this playlist</div>
</div>
</label>
<label style="display:flex; align-items:center; gap:12px; cursor:pointer; padding:10px 12px; border-radius:8px; border:1px solid #3f3f3f; background:#1a1a1a;">
<input type="radio" name="visibility" value="unlisted" style="accent-color:#e61e1e; width:16px; height:16px;">
<div>
<div style="display:flex; align-items:center; gap:6px; color:#fff; font-size:14px; font-weight:500;"><i class="bi bi-link-45deg"></i> Unlisted</div>
<div style="color:#aaa; font-size:12px; margin-top:2px;">Anyone with the link can view, but won't appear in search</div>
</div>
</label>
<label style="display:flex; align-items:center; gap:12px; cursor:pointer; padding:10px 12px; border-radius:8px; border:1px solid #3f3f3f; background:#1a1a1a;">
<input type="radio" name="visibility" value="private" checked style="accent-color:#e61e1e; width:16px; height:16px;">
<div>
<div style="display:flex; align-items:center; gap:6px; color:#fff; font-size:14px; font-weight:500;"><i class="bi bi-lock-fill"></i> Private</div>
<div style="color:#aaa; font-size:12px; margin-top:2px;">Only you can view this playlist</div>
</div>
</label>
</div>
</div>
<div style="display:flex; gap:12px; justify-content:flex-end;">
<button type="button" onclick="closeChannelCreatePlaylistModal()" class="action-btn">Cancel</button>
<button type="submit" class="action-btn action-btn-primary"><i class="bi bi-plus-lg"></i> <span>Create</span></button>
</div>
</form>
</div>
</div>
</div>
<style>
@keyframes chPlModalIn {
from { opacity:0; transform:translateY(-20px) scale(.95); }
to { opacity:1; transform:translateY(0) scale(1); }
}
</style>
<script>
(function() {
const modal = document.getElementById('chCreatePlaylistModal');
const form = document.getElementById('chCreatePlaylistForm');
const thumbZone = document.getElementById('chPlThumbZone');
const thumbInput = document.getElementById('chPlThumbInput');
const thumbDef = document.getElementById('chPlThumbDefault');
const thumbPrev = document.getElementById('chPlThumbPreview');
const thumbImg = document.getElementById('chPlThumbImg');
thumbZone.addEventListener('click', function(e) { if (!e.target.closest('button')) thumbInput.click(); });
thumbInput.addEventListener('change', function() { chHandleThumb(this); });
thumbZone.addEventListener('dragover', function(e) { e.preventDefault(); this.style.borderColor='#e61e1e'; });
thumbZone.addEventListener('dragleave', function(e) { e.preventDefault(); this.style.borderColor='#3f3f3f'; });
thumbZone.addEventListener('drop', function(e) {
e.preventDefault(); this.style.borderColor='#3f3f3f';
if (e.dataTransfer.files.length) { thumbInput.files = e.dataTransfer.files; chHandleThumb(thumbInput); }
});
function chHandleThumb(input) {
if (!input.files || !input.files[0]) return;
const file = input.files[0];
if (!['image/jpeg','image/png','image/gif','image/webp'].includes(file.type)) {
showToast('Invalid image format. Use JPG, PNG, GIF or WebP.', 'error'); return;
}
if (file.size > 5 * 1024 * 1024) { showToast('Image exceeds 5 MB limit.', 'error'); return; }
const reader = new FileReader();
reader.onload = function(e) {
thumbImg.src = e.target.result;
thumbDef.style.display = 'none';
thumbPrev.style.display = 'block';
};
reader.readAsDataURL(file);
}
window.chRemovePlThumb = function(e) {
e.preventDefault(); e.stopPropagation();
thumbInput.value = '';
thumbDef.style.display = 'block';
thumbPrev.style.display = 'none';
thumbImg.src = '';
};
window.openChannelCreatePlaylistModal = function() { modal.style.display = 'flex'; };
window.closeChannelCreatePlaylistModal = function() {
modal.style.display = 'none';
form.reset();
thumbDef.style.display = 'block';
thumbPrev.style.display = 'none';
thumbImg.src = '';
thumbInput.value = '';
};
modal.addEventListener('click', function(e) { if (e.target === modal) closeChannelCreatePlaylistModal(); });
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && modal.style.display === 'flex') closeChannelCreatePlaylistModal();
});
form.addEventListener('submit', function(e) {
e.preventDefault();
const btn = form.querySelector('[type=submit]');
btn.disabled = true;
fetch('{{ route("playlists.store") }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' },
body: new FormData(form)
})
.then(r => r.json())
.then(data => {
if (data.success) {
window.location.href = '{{ route("playlists.show", "") }}/' + data.playlist.id;
} else {
showToast(data.message || 'Failed to create playlist.', 'error');
btn.disabled = false;
}
})
.catch(() => { showToast('Failed to create playlist. Please try again.', 'error'); btn.disabled = false; });
});
})();
</script>
@endif
{{-- ─────────────────────── ABOUT TAB ───────────────── --}}
<div class="ch-tab-content" id="tab-about">
<div class="ch-about-layout">
{{-- Left column --}}
<div>
@if($user->bio)
<div class="ch-about-card">
<div class="ch-about-card-title"><i class="bi bi-person-lines-fill"></i> About</div>
<p class="ch-about-desc">{{ $user->bio }}</p>
</div>
@endif
@if(!$horoscope && $isOwner)
<div class="ch-about-card" style="text-align:center;padding:24px 20px;">
<div style="font-size:36px;margin-bottom:10px;">🔮</div>
<div style="font-size:15px;font-weight:700;color:var(--text-primary);margin-bottom:8px;">Unlock Your Horoscope</div>
<p style="font-size:13px;color:var(--text-secondary);line-height:1.6;margin-bottom:16px;">Add your birthday to see your zodiac sign, age, traits, and compatibility with visitors.</p>
<button type="button" class="action-btn action-btn-primary" onclick="switchTab('settings', document.querySelector('[data-tab=settings]')); setTimeout(()=>document.querySelector('[name=birthday]')?.closest('.ch-settings-field')?.scrollIntoView({behavior:'smooth',block:'center'}),400);">
<i class="bi bi-calendar-heart"></i> <span>Add Birthday</span>
</button>
</div>
@elseif($horoscope)
@php $hColor = \App\Helpers\Horoscope::elementColor($horoscope['element']); @endphp
<div class="ch-about-card" style="--horoscope-color:{{ $hColor }};">
<div class="ch-about-card-title"><i class="bi bi-stars"></i> Horoscope</div>
<div class="ch-horoscope-sign-row" style="margin-bottom:12px;">
<div class="ch-horoscope-symbol" style="font-size:36px;">{{ $horoscope['emoji'] }}</div>
<div>
<div class="ch-horoscope-name" style="font-size:17px;">{{ $horoscope['name'] }} {{ $horoscope['symbol'] }}</div>
<div class="ch-horoscope-element" style="color:{{ $hColor }};margin-top:4px;">
<i class="bi bi-gem"></i> {{ $horoscope['element'] }} sign
</div>
</div>
</div>
<div class="ch-horoscope-traits">
@foreach($horoscope['traits'] as $trait)
<span class="ch-horoscope-trait">{{ $trait }}</span>
@endforeach
</div>
</div>
@endif
@if($socialLinks->isNotEmpty())
@php
$chLinkMap = [
'twitter' => ['icon' => 'bi-twitter-x', 'color' => '#1d9bf0', 'bg' => 'rgba(29,155,240,.15)',
'href' => fn($v) => "https://twitter.com/{$v}",
'label' => fn($v) => "@{$v}"],
'instagram' => ['icon' => 'bi-instagram', 'color' => '#e1306c', 'bg' => 'rgba(225,48,108,.15)',
'href' => fn($v) => "https://instagram.com/{$v}",
'label' => fn($v) => "@{$v}"],
'facebook' => ['icon' => 'bi-facebook', 'color' => '#1877f2', 'bg' => 'rgba(24,119,242,.15)',
'href' => fn($v) => "https://facebook.com/{$v}",
'label' => fn($v) => $v],
'youtube' => ['icon' => 'bi-youtube', 'color' => '#ff0000', 'bg' => 'rgba(255,0,0,.15)',
'href' => fn($v) => "https://youtube.com/@{$v}",
'label' => fn($v) => "@{$v}"],
'linkedin' => ['icon' => 'bi-linkedin', 'color' => '#0a66c2', 'bg' => 'rgba(10,102,194,.15)',
'href' => fn($v) => "https://linkedin.com/in/{$v}",
'label' => fn($v) => $v],
'tiktok' => ['icon' => 'bi-tiktok', 'color' => '#ff0050', 'bg' => 'rgba(255,0,80,.15)',
'href' => fn($v) => "https://tiktok.com/@{$v}",
'label' => fn($v) => "@{$v}"],
'whatsapp' => ['icon' => 'bi-whatsapp', 'color' => '#25d366', 'bg' => 'rgba(37,211,102,.15)',
'href' => fn($v) => "https://wa.me/" . preg_replace('/\D/','',$v),
'label' => fn($v) => $v],
'website' => ['icon' => 'bi-globe2', 'color' => '#a78bfa', 'bg' => 'rgba(167,139,250,.15)',
'href' => fn($v) => preg_match('/^https?:\/\//', $v) ? $v : "https://{$v}",
'label' => fn($v) => parse_url((str_starts_with($v,'http') ? $v : "https://{$v}"), PHP_URL_HOST) ?: $v],
'google_location' => ['icon' => 'bi-geo-alt-fill', 'color' => '#ea4335', 'bg' => 'rgba(234,67,53,.15)',
'href' => fn($v) => $v,
'label' => fn($v) => 'View on Maps'],
'social_phone' => ['icon' => 'bi-telephone-fill', 'color' => '#34d399', 'bg' => 'rgba(52,211,153,.15)',
'href' => fn($v) => "tel:{$v}",
'label' => fn($v) => $v],
'social_email' => ['icon' => 'bi-envelope-fill', 'color' => '#fb923c', 'bg' => 'rgba(251,146,60,.15)',
'href' => fn($v) => "mailto:{$v}",
'label' => fn($v) => $v],
];
@endphp
<div class="ch-about-card">
<div class="ch-about-card-title"><i class="bi bi-link-45deg"></i> Links</div>
@foreach($socialLinks as $link)
@if(isset($chLinkMap[$link->platform]))
@php $m = $chLinkMap[$link->platform]; @endphp
<a href="{{ $m['href']($link->value) }}"
target="{{ in_array($link->platform, ['social_phone','social_email']) ? '_self' : '_blank' }}"
rel="noopener" class="ch-link-card">
<span class="ch-link-card-icon" style="background:{{ $m['bg'] }};color:{{ $m['color'] }};">
<i class="bi {{ $m['icon'] }}"></i>
</span>
<span class="ch-link-card-label">{{ $m['label']($link->value) }}</span>
<i class="bi bi-chevron-right"></i>
</a>
@endif
@endforeach
</div>
@endif
</div>
{{-- Right column: Stats + Compatibility --}}
<div>
<div class="ch-about-card">
<div class="ch-about-card-title"><i class="bi bi-bar-chart-fill"></i> Stats</div>
<div class="ch-stat-cards">
<div class="ch-stat-card">
<div class="ch-stat-card-val">{{ \Illuminate\Support\Number::abbreviate($totalViews, precision: 1) }}</div>
<div class="ch-stat-card-key">Total Views</div>
</div>
<div class="ch-stat-card">
<div class="ch-stat-card-val">{{ $videos->count() + $shorts->count() }}</div>
<div class="ch-stat-card-key">Videos</div>
</div>
</div>
<div class="ch-stat-item">
<div class="ch-stat-item-icon"><i class="bi bi-calendar3"></i></div>
<span>Joined {{ $user->created_at->format('F j, Y') }}</span>
</div>
@if($user->location)
<div class="ch-stat-item">
<div class="ch-stat-item-icon"><i class="bi bi-geo-alt-fill"></i></div>
<span>{{ $user->location }}</span>
</div>
@endif
@if($playlists && $playlists->count() > 0)
<div class="ch-stat-item">
<div class="ch-stat-item-icon"><i class="bi bi-collection-play-fill"></i></div>
<span>{{ $playlists->count() }} playlists</span>
</div>
@endif
@if($shorts->count() > 0)
<div class="ch-stat-item">
<div class="ch-stat-item-icon"><i class="bi bi-phone-fill"></i></div>
<span>{{ $shorts->count() }} shorts</span>
</div>
@endif
</div>
@if($compatibility !== null && $viewerSign)
@php
$pct = $compatibility;
$compatColor = $pct >= 75 ? '#22c55e' : ($pct >= 55 ? '#eab308' : '#ef4444');
$compatLabel = $pct >= 85 ? 'Soulmates' : ($pct >= 70 ? 'Great match' : ($pct >= 55 ? 'Good vibes' : ($pct >= 40 ? 'Some friction' : 'Tough combo')));
@endphp
<div class="ch-compat-card">
<div class="ch-compat-label"><i class="bi bi-hearts" style="color:#e33;"></i> Compatibility</div>
<div class="ch-compat-faces">
<div class="ch-compat-sign-block">
<div class="ch-compat-sign-sym">{{ $viewerSign['emoji'] }}</div>
<div class="ch-compat-sign-name">You</div>
</div>
<div class="ch-compat-heart">❤️</div>
<div class="ch-compat-sign-block">
<div class="ch-compat-sign-sym">{{ $horoscope['emoji'] }}</div>
<div class="ch-compat-sign-name">{{ explode(' ', $user->name)[0] }}</div>
</div>
</div>
<div class="ch-compat-pct" style="color:{{ $compatColor }};">{{ $pct }}%</div>
<div class="ch-compat-bar-wrap" style="margin:8px 0;">
<div class="ch-compat-bar" id="compatBarAbout" style="width:0%;background:{{ $compatColor }};"></div>
</div>
<div class="ch-compat-desc">{{ $compatLabel }}</div>
</div>
@endif
</div>
</div>
</div>
{{-- ─────────────────────── SETTINGS TAB ────────────── --}}
@if($isOwner)
@php
$oldLinks = old('slink');
if ($oldLinks) {
$socialExisting = collect($oldLinks)
->filter(fn($e) => !empty($e['platform']) && !empty($e['value']))
->map(fn($e) => ['platform' => $e['platform'], 'value' => $e['value'], 'visibility' => $e['visibility'] ?? 'public'])
->values()->all();
} else {
$socialExisting = $user->socialLinks()->orderBy('sort_order')->get()
->map(fn($l) => ['platform' => $l->platform, 'value' => $l->value, 'visibility' => $l->visibility])
->all();
}
@endphp
<div class="ch-tab-content" id="tab-settings">
<div class="ch-settings-layout">
{{-- Profile Edit Form --}}
<div>
<div class="ch-settings-card">
<div class="ch-settings-card-title"><i class="bi bi-person-fill"></i> Profile Info</div>
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data">
@csrf @method('PUT')
<input type="hidden" name="_edit_user_id" value="{{ $user->id }}">
{{-- Avatar --}}
<div class="ch-settings-avatar-row">
<div class="ch-settings-avatar-wrap">
<img src="{{ $user->avatar_url }}" alt="" class="ch-settings-avatar" id="chSettingsAvatarPreview">
<label class="ch-settings-avatar-btn" title="Change photo">
<i class="bi bi-camera-fill"></i>
<input type="file" name="avatar" accept="image/*" style="display:none;"
onchange="previewSettingsAvatar(this)">
</label>
</div>
<div style="font-size:13px;color:var(--text-secondary);line-height:1.7;">
<strong style="color:var(--text-primary);">Profile Photo</strong><br>
JPG, PNG or WebP · Max 5 MB<br>
Recommended: 200×200 px minimum
</div>
</div>
<div class="ch-settings-2col">
<div class="ch-settings-field">
<label class="ch-settings-label">Name</label>
<input type="text" name="name" class="ch-settings-input" value="{{ old('name', $user->name) }}" required>
</div>
<div class="ch-settings-field">
<label class="ch-settings-label">Gender</label>
<select name="gender" class="ch-settings-input" style="cursor:pointer;">
<option value="">Prefer not to say</option>
<option value="male" {{ old('gender', $user->gender) === 'male' ? 'selected' : '' }}>Male</option>
<option value="female" {{ old('gender', $user->gender) === 'female' ? 'selected' : '' }}>Female</option>
<option value="prefer_not_to_say" {{ old('gender', $user->gender) === 'prefer_not_to_say' ? 'selected' : '' }}>Prefer not to say</option>
</select>
</div>
</div>
<div class="ch-settings-field">
<label class="ch-settings-label">Bio</label>
<textarea name="bio" class="ch-settings-input ch-settings-textarea" placeholder="Tell the world about yourself…">{{ old('bio', $user->bio) }}</textarea>
</div>
<div class="ch-settings-2col">
<div class="ch-settings-field">
<x-date-picker
name="birthday"
label="Birthday"
value="{{ old('birthday', $user->birthday) }}"
:max-year="(int) date('Y')"
:min-year="1900"
/>
</div>
<div class="ch-settings-field">
<label class="ch-settings-label">Location</label>
<input type="text" name="location" class="ch-settings-input" value="{{ old('location', $user->location) }}" maxlength="100" placeholder="City, Country">
</div>
</div>
<div class="ch-settings-2col">
<div class="ch-settings-field">
<x-country-select
name="nationality"
label="Nationality"
placeholder="Select nationality"
value="{{ old('nationality', $user->nationality) }}"
/>
</div>
<div class="ch-settings-field">
<x-timezone-select
name="timezone"
label="Timezone"
placeholder="Select timezone"
value="{{ old('timezone', $user->timezone) }}"
/>
</div>
</div>
<div class="ch-settings-footer">
<button type="submit" class="action-btn action-btn-primary"><i class="bi bi-check-lg"></i> <span>Save Profile</span></button>
</div>
</form>
</div>
{{-- Social Links --}}
<div class="ch-settings-card">
<div class="ch-settings-card-title"><i class="bi bi-link-45deg"></i> Social Links</div>
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data">
@csrf @method('PUT')
<input type="hidden" name="_edit_user_id" value="{{ $user->id }}">
{{-- Must send name so the controller doesn't clear it --}}
<input type="hidden" name="name" value="{{ $user->name }}">
<x-social-links-editor :existing="$socialExisting" />
<div class="ch-settings-footer">
<button type="submit" class="action-btn action-btn-primary"><i class="bi bi-check-lg"></i> <span>Save Links</span></button>
</div>
</form>
</div>
</div>
{{-- Right: Security --}}
<div>
@if($isOwner)
<div class="ch-settings-card">
<div class="ch-settings-card-title"><i class="bi bi-shield-lock-fill"></i> Change Password</div>
<form method="POST" action="{{ route('settings.update') }}">
@csrf @method('PUT')
<div class="ch-settings-field">
<label class="ch-settings-label">Current Password</label>
<input type="password" name="current_password" class="ch-settings-input" required>
</div>
<div class="ch-settings-field">
<label class="ch-settings-label">New Password</label>
<input type="password" name="new_password" class="ch-settings-input" required minlength="8">
<div class="ch-settings-hint">Minimum 8 characters</div>
</div>
<div class="ch-settings-field">
<label class="ch-settings-label">Confirm New Password</label>
<input type="password" name="new_password_confirmation" class="ch-settings-input" required>
</div>
<div class="ch-settings-footer">
<button type="submit" class="action-btn action-btn-primary"><i class="bi bi-key-fill"></i> <span>Update Password</span></button>
</div>
</form>
</div>
@endif
@if($isOwner)
{{-- 2FA Card --}}
<div class="ch-settings-card" id="twoFactorCard">
<div class="ch-settings-card-title"><i class="bi bi-shield-check-fill"></i> Two-Factor Authentication</div>
@if($user->two_factor_enabled)
{{-- Enabled state --}}
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;">
<span style="background:rgba(34,197,94,.15);border:1px solid #22c55e;color:#22c55e;border-radius:20px;padding:3px 12px;font-size:12px;font-weight:600;">
<i class="bi bi-check-circle-fill"></i> Enabled
</span>
<span style="font-size:13px;color:var(--text-secondary);">Your account is protected.</span>
</div>
<p style="font-size:13px;color:var(--text-secondary);margin-bottom:16px;">
To disable 2FA, confirm your password below.
</p>
<form method="POST" action="{{ route('2fa.disable') }}">
@csrf
<div class="ch-settings-field">
<label class="ch-settings-label">Password</label>
<input type="password" name="password" class="ch-settings-input" required placeholder="••••••••">
</div>
<div class="ch-settings-footer">
<button type="submit" class="action-btn action-btn-danger"><i class="bi bi-shield-x"></i> <span>Disable 2FA</span></button>
</div>
</form>
@else
{{-- Disabled state --}}
<p style="font-size:13px;color:var(--text-secondary);margin-bottom:20px;">
Add an extra layer of security. Once enabled, you'll need your authenticator app each time you sign in.
</p>
{{-- Step 1: Show QR --}}
<div id="tfa-step1" style="display:none;">
<p style="font-size:13px;font-weight:600;margin-bottom:8px;">Step 1 Scan this QR code with your authenticator app</p>
<div id="tfa-qr" style="background:#fff;display:inline-block;padding:10px;border-radius:8px;margin-bottom:12px;">
<img id="tfa-qr-img" src="" alt="QR Code" style="display:block;width:180px;height:180px;">
</div>
<p style="font-size:12px;color:var(--text-secondary);margin-bottom:4px;">Or enter the key manually:</p>
<code id="tfa-secret" style="font-size:13px;letter-spacing:.1em;background:rgba(255,255,255,.06);padding:4px 10px;border-radius:6px;display:inline-block;margin-bottom:20px;word-break:break-all;"></code>
<p style="font-size:13px;font-weight:600;margin-bottom:8px;">Step 2 Enter the 6-digit code to confirm</p>
<form method="POST" action="{{ route('2fa.enable') }}" id="tfa-confirm-form">
@csrf
<input type="hidden" name="code" id="tfa-code-hidden">
<div style="display:flex;gap:10px;align-items:center;">
<input type="text" id="tfa-code-input" inputmode="numeric" pattern="[0-9]*" maxlength="6"
placeholder="000000" autocomplete="one-time-code"
style="width:140px;background:rgba(255,255,255,.04);border:1px solid var(--border-color);border-radius:8px;padding:10px 14px;color:var(--text-primary);font-size:18px;letter-spacing:.3em;text-align:center;">
<button type="submit" class="action-btn action-btn-primary"><i class="bi bi-check-lg"></i> <span>Enable 2FA</span></button>
</div>
@if($errors->has('code'))
<p style="color:#ef4444;font-size:12px;margin-top:6px;">{{ $errors->first('code') }}</p>
@endif
</form>
</div>
<div id="tfa-step0">
<button type="button" class="action-btn action-btn-primary" id="tfa-start-btn" onclick="tfaStart()">
<i class="bi bi-shield-plus"></i> <span>Set Up 2FA</span>
</button>
</div>
@endif
</div>
@endif
@if($isOwner)
@php
$notifPrefs = $user->notification_preferences ?? [];
$notifDefs = \App\Models\User::notifDefaults();
function notifVal(array $prefs, array $defs, string $key): bool {
return isset($prefs[$key]) ? (bool)$prefs[$key] : ($defs[$key] ?? true);
}
@endphp
<div class="ch-settings-card" id="notifPrefsCard">
<div class="ch-settings-card-title"><i class="bi bi-bell-fill"></i> Notification Preferences</div>
{{-- Bell (In-App) --}}
<div class="notif-section-label"><i class="bi bi-bell"></i> In-App Notifications</div>
@php
$bellPrefs = [
['notif_new_comment', 'New comment on my video'],
['notif_new_reply', 'Reply to my comment'],
['notif_comment_like', 'Someone liked my comment'],
['notif_video_like', 'Someone liked my video'],
['notif_new_subscriber', 'New subscriber'],
['notif_new_video_from_sub', 'New video from channels I follow'],
['notif_new_post_from_sub', 'New post from channels I follow'],
];
if ($user->isSuperAdmin()) {
$bellPrefs[] = ['notif_new_user_reg', 'New user registration'];
}
@endphp
@foreach($bellPrefs as [$key, $label])
<div class="notif-pref-row">
<span class="notif-pref-label">{{ $label }}</span>
<label class="notif-toggle-wrap">
<input type="checkbox" class="notif-toggle-input"
data-key="{{ $key }}"
{{ notifVal($notifPrefs, $notifDefs, $key) ? 'checked' : '' }}
onchange="saveNotifPref('{{ $key }}', this.checked)">
<span class="notif-toggle-slider"></span>
</label>
</div>
@endforeach
{{-- Email --}}
<div class="notif-section-label" style="margin-top:20px;"><i class="bi bi-envelope"></i> Email Notifications</div>
@php
$emailPrefs = [
['email_new_comment', 'New comment on my video'],
['email_new_reply', 'Reply to my comment'],
['email_comment_like', 'Someone liked my comment'],
['email_video_like', 'Someone liked my video'],
['email_new_subscriber', 'New subscriber'],
['email_new_video_from_sub', 'New video from channels I follow'],
['email_new_post_from_sub', 'New post from channels I follow'],
['email_video_processed', 'My video finished processing'],
['email_weekly_digest', 'Weekly activity digest (every Monday)'],
];
if ($user->isSuperAdmin()) {
$emailPrefs[] = ['email_new_user_reg', 'New user registration'];
}
@endphp
@foreach($emailPrefs as [$key, $label])
<div class="notif-pref-row">
<span class="notif-pref-label">{{ $label }}</span>
<label class="notif-toggle-wrap">
<input type="checkbox" class="notif-toggle-input"
data-key="{{ $key }}"
{{ notifVal($notifPrefs, $notifDefs, $key) ? 'checked' : '' }}
onchange="saveNotifPref('{{ $key }}', this.checked)">
<span class="notif-toggle-slider"></span>
</label>
</div>
@endforeach
<p class="notif-pref-note">Security emails (verification, password reset) are always sent and cannot be disabled.</p>
</div>
@endif
<div class="ch-settings-card">
<div class="ch-settings-card-title"><i class="bi bi-info-circle-fill"></i> Account</div>
<div class="ch-settings-field">
<label class="ch-settings-label">Email</label>
<input type="email" class="ch-settings-input" value="{{ $user->email }}" disabled>
<div class="ch-settings-hint">Email cannot be changed</div>
</div>
<div class="ch-settings-field" style="margin-bottom:0;">
<label class="ch-settings-label">Member Since</label>
<input type="text" class="ch-settings-input" value="{{ $user->created_at->format('F d, Y') }}" disabled>
</div>
</div>
@if($isOwner)
<div class="ch-settings-card">
<div class="ch-settings-card-title"><i class="bi bi-devices"></i> Active Sessions</div>
<p style="font-size:13px;color:var(--text-secondary);margin-bottom:16px;line-height:1.6;">
Sign out of all other browsers and devices where your account is currently logged in.
Your current session will remain active.
</p>
<form method="POST" action="{{ route('settings.logoutAllDevices') }}">
@csrf
<div class="ch-settings-field">
<label class="ch-settings-label">Confirm Password</label>
<input type="password" name="password" class="ch-settings-input" required placeholder="••••••••">
@error('logout_password')
<div style="color:#ef4444;font-size:12px;margin-top:5px;">{{ $message }}</div>
@enderror
</div>
<div class="ch-settings-footer">
<button type="submit" class="action-btn action-btn-danger"><i class="bi bi-box-arrow-right"></i> <span>Log Out All Other Devices</span></button>
</div>
</form>
</div>
@endif
</div>
</div>
</div>
@endif
{{-- Video picker modal (wall composer) --}}
@if(Auth::check() && Auth::id() === $user->id && $user->videos->count() > 0)
@php $pickerVideos = $user->videos()->latest()->get(); @endphp
<div id="videoPickerModal" class="ch-vp-backdrop" style="display:none;" onclick="if(event.target===this)closeVideoPicker()">
<div class="ch-vp-modal" onclick="event.stopPropagation()">
<div class="ch-vp-head">
<h3><i class="bi bi-collection-play-fill" style="color:#60a5fa;margin-right:8px;"></i>Select Videos</h3>
<button class="ch-vp-close" onclick="closeVideoPicker()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="ch-vp-search">
<input type="text" id="vpSearchInput" placeholder="Search your videos…" oninput="filterPickerVideos(this.value)">
</div>
<div class="ch-vp-grid" id="videoPickerGrid">
@foreach($pickerVideos as $v)
<div class="ch-vp-card"
data-id="{{ $v->id }}"
data-title="{{ $v->title }}"
data-thumb="{{ $v->thumbnail ? route('media.thumbnail', $v->thumbnail) : '' }}"
onclick="togglePickerCard(this)">
<div class="ch-vp-card-thumb">
@if($v->thumbnail)
<img src="{{ route('media.thumbnail', $v->thumbnail) }}" alt="" loading="lazy">
@else
<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,.2);font-size:22px;"><i class="bi bi-film"></i></div>
@endif
<div class="ch-vp-card-check"><i class="bi bi-check"></i></div>
</div>
<div class="ch-vp-card-title">{{ $v->title }}</div>
</div>
@endforeach
</div>
<div class="ch-vp-footer">
<span class="ch-vp-count" id="vpSelectedCount">0 videos selected</span>
<button class="ch-vp-confirm" id="vpConfirmBtn" onclick="confirmVideoPicker()" disabled>Add to Post</button>
</div>
</div>
</div>
@endif
@endsection
@section('scripts')
<script>
/* ── Logout All Devices modal ── */
function openLogoutAllModal() {
if (document.getElementById('logoutAllModal')) return;
const tpl = document.getElementById('logoutAllModalTpl');
const node = tpl.content.cloneNode(true);
document.body.appendChild(node);
setTimeout(() => document.getElementById('logoutAllPwInput')?.focus(), 50);
}
function closeLogoutAllModal() {
const el = document.getElementById('logoutAllModal');
if (el) el.remove();
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLogoutAllModal(); });
/* ── Tab switching ── */
function switchTab(tabId, btn) {
document.querySelectorAll('.ch-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.ch-tab-content').forEach(c => c.classList.remove('active'));
if (!btn) btn = document.querySelector('.ch-tab[data-tab="' + tabId + '"]');
if (btn) btn.classList.add('active');
const content = document.getElementById('tab-' + tabId);
if (content) content.classList.add('active');
history.replaceState(null, '', '#' + tabId);
}
window.addEventListener('hashchange', function () {
const h = location.hash.replace('#', '');
if (h) {
const btn = document.querySelector('.ch-tab[data-tab="' + h + '"]');
if (btn) switchTab(h, btn);
}
});
document.addEventListener('DOMContentLoaded', function () {
const hash = location.hash.replace('#', '');
const urlParams = new URLSearchParams(location.search);
if (hash) {
const btn = document.querySelector('.ch-tab[data-tab="' + hash + '"]');
if (btn) switchTab(hash, btn);
} else if (urlParams.has('sort')) {
const btn = document.querySelector('.ch-tab[data-tab="videos"]');
if (btn) switchTab('videos', btn);
}
@if($errors->any() && $isOwner)
switchTab('settings', null);
@elseif(session('_open_tab') === 'settings' && $isOwner)
switchTab('settings', null);
@endif
// Animate compat bars
setTimeout(function () {
@if($compatibility !== null)
var bar = document.getElementById('compatBar');
if (bar) bar.style.width = '{{ $compatibility }}%';
var bar2 = document.getElementById('compatBarAbout');
if (bar2) bar2.style.width = '{{ $compatibility }}%';
var mini = document.getElementById('chCompatMiniBar');
if (mini) mini.style.width = '{{ $compatibility }}%';
@endif
}, 300);
// Video search filter
var vidSearch = document.getElementById('chVidSearch');
var vidGrid = document.getElementById('chVidGrid');
var noResults = document.getElementById('chVidNoResults');
if (vidSearch && vidGrid) {
vidSearch.addEventListener('input', function () {
var q = this.value.trim().toLowerCase();
var cards = vidGrid.querySelectorAll('.yt-video-card');
var visible = 0;
cards.forEach(function (card) {
var titleEl = card.querySelector('.yt-video-title');
var title = titleEl ? titleEl.textContent.toLowerCase() : '';
var show = !q || title.includes(q);
card.style.display = show ? '' : 'none';
if (show) visible++;
});
if (noResults) noResults.style.display = visible === 0 ? 'block' : 'none';
});
}
});
/* ── 2FA setup ── */
function tfaStart() {
const btn = document.getElementById('tfa-start-btn');
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> <span>Loading…</span>'; }
fetch('{{ route('2fa.setup') }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
})
.then(r => r.json())
.then(data => {
document.getElementById('tfa-qr-img').src = 'data:image/svg+xml;base64,' + data.qr;
document.getElementById('tfa-secret').textContent = data.secret;
document.getElementById('tfa-step0').style.display = 'none';
document.getElementById('tfa-step1').style.display = '';
document.getElementById('tfa-code-input').focus();
})
.catch(() => {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-shield-plus"></i> <span>Set Up 2FA</span>'; }
});
}
document.addEventListener('DOMContentLoaded', function () {
const codeInput = document.getElementById('tfa-code-input');
const codeHidden = document.getElementById('tfa-code-hidden');
if (codeInput && codeHidden) {
document.getElementById('tfa-confirm-form').addEventListener('submit', function () {
codeHidden.value = codeInput.value;
});
}
});
/* ── Bio expand/collapse ── */
function toggleBio() {
const short = document.getElementById('ch-bio-short');
const full = document.getElementById('ch-bio-full');
const btn = document.getElementById('ch-bio-btn');
const isShort = full.style.display === 'none' || !full.style.display;
short.style.display = isShort ? 'none' : '';
full.style.display = isShort ? 'inline' : 'none';
btn.textContent = isShort ? ' Show less' : '…more';
}
/* ── Post composer — multi-image & video picker ── */
let _postImages = []; // array of {file, dataUrl}
function handlePostImages(input) {
const files = Array.from(input.files || []);
if (!files.length) return;
const remaining = 10 - _postImages.length;
files.slice(0, remaining).forEach(file => {
const reader = new FileReader();
reader.onload = e => {
_postImages.push({ file, dataUrl: e.target.result });
renderImageGrid();
};
reader.readAsDataURL(file);
});
input.value = '';
}
function renderImageGrid() {
const grid = document.getElementById('imagePreviewGrid');
if (!grid) return;
if (!_postImages.length) { grid.style.display = 'none'; grid.innerHTML = ''; return; }
const n = _postImages.length;
const cls = n === 1 ? 'count-1' : n === 2 ? 'count-2' : n === 3 ? 'count-3' : n === 4 ? 'count-4' : 'count-more';
grid.className = 'ch-composer-img-grid ' + cls;
grid.style.display = '';
grid.innerHTML = _postImages.map((img, i) => `
<div class="ch-composer-img-item">
<img src="${img.dataUrl}" alt="">
<button type="button" class="ch-composer-img-remove" onclick="removePostImage(${i})">
<i class="bi bi-x"></i>
</button>
</div>`).join('');
}
function removePostImage(idx) {
_postImages.splice(idx, 1);
renderImageGrid();
}
/* ── Video picker ── */
let _pickerSelected = {}; // {id: {id, title, thumb}}
function openVideoPicker() {
_pickerSelected = {};
document.querySelectorAll('.ch-vp-card').forEach(c => c.classList.remove('selected'));
document.getElementById('vpSelectedCount').textContent = '0 videos selected';
document.getElementById('vpConfirmBtn').disabled = true;
document.getElementById('vpSearchInput').value = '';
filterPickerVideos('');
document.getElementById('videoPickerModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
// restore already-selected chips
document.querySelectorAll('#postVideoChips [data-vid]').forEach(chip => {
const id = chip.dataset.vid;
const card = document.querySelector(`.ch-vp-card[data-id="${id}"]`);
if (card) {
card.classList.add('selected');
_pickerSelected[id] = { id, title: card.dataset.title, thumb: card.dataset.thumb };
}
});
updatePickerCount();
}
function closeVideoPicker() {
document.getElementById('videoPickerModal').style.display = 'none';
document.body.style.overflow = '';
}
function togglePickerCard(card) {
const id = card.dataset.id;
if (card.classList.contains('selected')) {
card.classList.remove('selected');
delete _pickerSelected[id];
} else {
card.classList.add('selected');
_pickerSelected[id] = { id, title: card.dataset.title, thumb: card.dataset.thumb };
}
updatePickerCount();
}
function updatePickerCount() {
const n = Object.keys(_pickerSelected).length;
document.getElementById('vpSelectedCount').textContent = n === 0 ? '0 videos selected' : `${n} video${n > 1 ? 's' : ''} selected`;
document.getElementById('vpConfirmBtn').disabled = n === 0;
}
function filterPickerVideos(q) {
const lower = q.toLowerCase();
document.querySelectorAll('.ch-vp-card').forEach(card => {
card.style.display = card.dataset.title.toLowerCase().includes(lower) ? '' : 'none';
});
}
function confirmVideoPicker() {
closeVideoPicker();
renderVideoChips();
}
function renderVideoChips() {
const wrap = document.getElementById('postVideoChips');
if (!wrap) return;
const ids = Object.values(_pickerSelected);
if (!ids.length) { wrap.style.display = 'none'; wrap.innerHTML = ''; return; }
wrap.style.display = '';
wrap.innerHTML = ids.map(v => `
<div class="ch-composer-video-chip" data-vid="${v.id}">
${v.thumb ? `<img class="ch-composer-video-chip-thumb" src="${v.thumb}" alt="">` : '<i class="bi bi-film" style="font-size:14px;flex-shrink:0;"></i>'}
<span>${v.title}</span>
<button type="button" class="ch-composer-video-chip-remove" onclick="removeVideoChip('${v.id}')">
<i class="bi bi-x"></i>
</button>
</div>`).join('');
}
function removeVideoChip(id) {
delete _pickerSelected[id];
renderVideoChips();
}
/* Intercept form submit — use fetch/FormData to send files + video ids */
(function () {
const form = document.getElementById('postForm');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
const fd = new FormData(this);
// Remove placeholder inputs we don't need
fd.delete('images[]');
fd.delete('video_ids[]');
// Add staged images
_postImages.forEach(img => fd.append('images[]', img.file));
// Add selected video ids
Object.keys(_pickerSelected).forEach(id => fd.append('video_ids[]', id));
const btn = this.querySelector('.ch-composer-post-btn');
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Posting…'; }
fetch(this.action, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: fd,
credentials: 'same-origin',
})
.then(r => {
if (r.redirected) { window.location.href = r.url; return; }
if (!r.ok) return r.text().then(t => { throw new Error(t); });
window.location.reload();
})
.catch(err => {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-send-fill"></i> Post'; }
if (typeof showToast === 'function') showToast('Failed to post. Please try again.', 'error');
console.error(err);
});
});
})();
/* Escape closes video picker */
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
const modal = document.getElementById('videoPickerModal');
if (modal && modal.style.display !== 'none') closeVideoPicker();
}
});
/* ── Post three-dot menu ── */
function togglePostMenu(btn) {
const dropdown = btn.nextElementSibling;
const isOpen = dropdown.classList.contains('open');
// close all
document.querySelectorAll('.ch-post-dropdown.open').forEach(d => d.classList.remove('open'));
if (!isOpen) dropdown.classList.add('open');
}
document.addEventListener('click', function(e) {
if (!e.target.closest('.ch-post-menu-wrap')) {
document.querySelectorAll('.ch-post-dropdown.open').forEach(d => d.classList.remove('open'));
}
});
/* ── Post like ── */
var csrfToken = '{{ csrf_token() }}';
function reactPost(btn) {
var url = btn.dataset.reactUrl;
var liked = btn.classList.contains('liked');
var anim = btn.querySelector('.ch-post-like-anim');
var icon = btn.querySelector('i');
var count = btn.querySelector('.post-like-count');
var label = btn.querySelector('span:last-child');
var newLiked = !liked;
// Optimistic update
btn.classList.toggle('liked', newLiked);
if (icon) icon.className = newLiked ? 'bi bi-heart-fill' : 'bi bi-heart';
if (label) label.textContent = newLiked ? 'Liked' : 'Like';
if (count) {
var n = parseInt(count.textContent) || 0;
count.textContent = (newLiked ? n + 1 : Math.max(0, n - 1)) || '';
}
if (anim && newLiked) { anim.classList.remove('pop'); void anim.offsetWidth; anim.classList.add('pop'); }
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' },
credentials: 'same-origin',
})
.then(r => r.json())
.then(data => {
btn.classList.toggle('liked', data.liked);
if (icon) icon.className = data.liked ? 'bi bi-heart-fill' : 'bi bi-heart';
if (label) label.textContent = data.liked ? 'Liked' : 'Like';
if (count) count.textContent = data.count || '';
})
.catch(() => {
btn.classList.toggle('liked', liked);
if (icon) icon.className = liked ? 'bi bi-heart-fill' : 'bi bi-heart';
if (label) label.textContent = liked ? 'Liked' : 'Like';
});
}
/* ── Image lightbox ── */
function openLightbox(src) {
var lb = document.getElementById('wallLightbox');
if (!lb) {
lb = document.createElement('div');
lb.id = 'wallLightbox';
lb.style.cssText = 'position:fixed;inset:0;z-index:9000;background:rgba(0,0,0,.92);display:flex;align-items:center;justify-content:center;cursor:zoom-out;backdrop-filter:blur(6px);';
lb.innerHTML = '<img style="max-width:92vw;max-height:92vh;border-radius:8px;box-shadow:0 32px 80px rgba(0,0,0,.7);object-fit:contain;" /><button style="position:absolute;top:20px;right:24px;background:rgba(255,255,255,.15);border:none;color:#fff;font-size:22px;width:44px;height:44px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;" onclick="this.parentElement.remove()"><i class="bi bi-x-lg"></i></button>';
lb.addEventListener('click', function(e) { if (e.target === lb) lb.remove(); });
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { var l = document.getElementById('wallLightbox'); if(l) l.remove(); } }, { once: true });
document.body.appendChild(lb);
}
lb.querySelector('img').src = src;
lb.style.display = 'flex';
}
/* ── Video hover-preview ── */
document.querySelectorAll('.yt-video-card').forEach(card => {
card.addEventListener('mouseenter', () => {
if ('ontouchstart' in window) return;
const v = card.querySelector('video');
if (v) { v.currentTime = 0; v.volume = 0.1; v.play().catch(()=>{}); v.classList.add('active'); }
});
card.addEventListener('mouseleave', () => {
const v = card.querySelector('video');
if (v) { v.pause(); v.currentTime = 0; v.classList.remove('active'); }
});
});
/* ── Subscribe toggle ── */
(function () {
document.addEventListener('click', function (e) {
var btn = e.target.closest('.subscribe-toggle-btn');
if (!btn) return;
var url = btn.dataset.subscribeUrl;
var subscribed = btn.dataset.subscribed === 'true';
var channelId = btn.dataset.channelId;
var nowSub = !subscribed;
btn.dataset.subscribed = nowSub ? 'true' : 'false';
btn.classList.toggle('subscribed-ch', nowSub);
var icon = btn.querySelector('i');
var label = btn.querySelector('.subscribe-label');
if (icon) icon.className = 'bi ' + (nowSub ? 'bi-bell-fill' : 'bi-person-plus-fill');
if (label) label.textContent = nowSub ? 'Subscribed' : 'Subscribe';
document.querySelectorAll('.channel-subs[data-channel-id="' + channelId + '"]').forEach(function (el) {
var n = parseInt(el.dataset.count || 0);
el.dataset.count = nowSub ? n + 1 : Math.max(0, n - 1);
el.innerHTML = '<i class="bi bi-people-fill"></i> ' + Number(el.dataset.count).toLocaleString() + ' ' + (el.dataset.count == 1 ? 'subscriber' : 'subscribers');
});
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' },
credentials: 'same-origin',
})
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
.then(function (data) {
btn.dataset.subscribed = data.subscribed ? 'true' : 'false';
btn.classList.toggle('subscribed-ch', data.subscribed);
if (icon) icon.className = 'bi ' + (data.subscribed ? 'bi-bell-fill' : 'bi-person-plus-fill');
if (label) label.textContent = data.subscribed ? 'Subscribed' : 'Subscribe';
document.querySelectorAll('.channel-subs[data-channel-id="' + channelId + '"]').forEach(function (el) {
el.dataset.count = data.subscriber_count;
el.innerHTML = '<i class="bi bi-people-fill"></i> ' + Number(data.subscriber_count).toLocaleString() + ' ' + (data.subscriber_count == 1 ? 'subscriber' : 'subscribers');
});
})
.catch(function () {
btn.dataset.subscribed = subscribed ? 'true' : 'false';
btn.classList.toggle('subscribed-ch', subscribed);
if (icon) icon.className = 'bi ' + (subscribed ? 'bi-bell-fill' : 'bi-person-plus-fill');
if (label) label.textContent = subscribed ? 'Subscribed' : 'Subscribe';
if (typeof showToast === 'function') showToast('Could not update subscription. Please try again.', 'error');
});
});
})();
/* ── Settings avatar preview ── */
function previewSettingsAvatar(input) {
if (!input.files || !input.files[0]) return;
var reader = new FileReader();
reader.onload = function(e) {
var prev = document.getElementById('chSettingsAvatarPreview');
if (prev) prev.src = e.target.result;
// Also update the channel header avatar
var chAvatar = document.querySelector('.ch-avatar');
if (chAvatar) chAvatar.src = e.target.result;
};
reader.readAsDataURL(input.files[0]);
}
/* ── Delete video (own channel) ── */
function deleteVideo(videoId, videoTitle) {
showConfirm('Delete "' + videoTitle + '"? This cannot be undone.', function () {
fetch('/videos/' + videoId, {
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' }
})
.then(r => r.json())
.then(data => {
if (data.success || data.redirect) window.location.reload();
else showToast('Failed to delete video.', 'error');
})
.catch(() => showToast('Failed to delete video.', 'error'));
}, 'Delete');
}
/* ── Cropper callbacks ── */
function onAvatarSaved(url) {
var img = document.getElementById('chAvatarImg');
if (img) img.src = url + '?t=' + Date.now();
// Also update header avatar in settings tab
var prev = document.getElementById('chSettingsAvatarPreview');
if (prev) prev.src = url + '?t=' + Date.now();
}
function onBannerSaved(url) {
var img = document.getElementById('chBannerImg');
if (img) {
img.src = url + '?t=' + Date.now();
} else {
var newImg = document.createElement('img');
newImg.id = 'chBannerImg';
newImg.className = 'ch-banner-img';
newImg.alt = '';
newImg.src = url + '?t=' + Date.now();
var banner = document.getElementById('chBanner');
if (banner) banner.insertBefore(newImg, banner.firstChild);
}
}
// ── Notification preference toggle ────────────────────────────────────────
function saveNotifPref(key, value) {
var csrf = document.querySelector('meta[name="csrf-token"]');
fetch('{{ route("settings.notifications") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrf ? csrf.getAttribute('content') : '',
'Accept': 'application/json',
},
body: JSON.stringify({ key: key, value: value }),
}).then(function(r) {
if (!r.ok) throw new Error('save failed');
}).catch(function() {
// Revert toggle on failure
var el = document.querySelector('[data-key="' + key + '"]');
if (el) el.checked = !value;
if (window.showToast) showToast('Could not save preference.', 'error');
});
}
</script>
@if($isOwner && !$preview)
<x-image-cropper
id="avatar"
:width="300"
:height="300"
shape="square"
folder="avatars"
:filename="'avatar_' . $user->id"
callback="onAvatarSaved"
update-url="{{ route('profile.updateAvatar') }}"
title="Change Profile Photo"
output-width="600"
/>
<x-image-cropper
id="banner"
:width="500"
:height="160"
shape="square"
folder="banners"
:filename="'banner_' . $user->id"
callback="onBannerSaved"
update-url="{{ route('profile.updateBanner') }}"
title="Change Banner Image"
output-width="1500"
/>
@endif
@endsection