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

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

View File

@ -254,6 +254,23 @@ Stored value: IANA timezone string (e.g. `"Asia/Bahrain"`).
--- ---
## `<x-share-modal>` &amp; `<x-share-button>`
**Files:** `resources/views/components/share-modal.blade.php` (singleton modal + `openShareModal()` JS), `resources/views/components/share-button.blade.php` (trigger).
**Rule:** The only sanctioned way to share. `<x-share-modal />` is rendered once in `layouts/app.blade.php`; every share entry point uses `<x-share-button :video="$video" />`. Never duplicate the modal or hand-write `openShareModal(...)` triggers. `<x-share-button>` props: `video` (required), `tag` (`button`|`a`); extra attributes forwarded; slot overrides the label. Offers copy-link, social, send-by-email, and share-to-members (notification + email).
| View file | Usage | Notes |
|---|---|---|
| `resources/views/layouts/app.blade.php` | `<x-share-modal />` | Singleton, rendered once for the whole app layout |
| `resources/views/components/video-card.blade.php` | `<x-share-button :video tag="a" class="dropdown-item">` | Home/listing card 3-dot menu |
| `resources/views/videos/show.blade.php` | `<x-share-button :video class="yt-action-btn">` + `videoShare()` passes full args | Watch page (mobile + desktop share) |
| `resources/views/videos/partials/video-details.blade.php` | `<x-share-button :video class="action-btn">` | Watch-page details share button |
| `resources/views/components/video-actions.blade.php` | `shareCurrent(...)``openShareModal(...)` | Main watch-page share; passes email + members URLs |
**Known not-yet-migrated:** `resources/views/videos/shorts.blade.php` (JS feed share, partial args) and `resources/views/playlists/show.blade.php` (playlists have no email/members endpoints — video-only feature). Migrate shorts when touched.
---
## Modification checklist ## Modification checklist
When you modify any of these components, work through this list: When you modify any of these components, work through this list:

View File

@ -138,6 +138,20 @@ Structure: `<button class="action-btn"><i class="bi bi-..."></i> <span>Label</sp
All four select components accept `name`, `id`, `value`, `label`, `placeholder`, `required`, `class`, and `style` props. Country/phone/timezone data lives in `app/Data/Countries.php`; language data lives in `app/Data/Languages.php`. Usage is tracked in `.claude/component-usage.md` — add a row to the relevant table whenever you place one of these components in a view. All four select components accept `name`, `id`, `value`, `label`, `placeholder`, `required`, `class`, and `style` props. Country/phone/timezone data lives in `app/Data/Countries.php`; language data lives in `app/Data/Languages.php`. Usage is tracked in `.claude/component-usage.md` — add a row to the relevant table whenever you place one of these components in a view.
**All sharing must go through the share component — never build a custom share UI or duplicate the modal.** There is exactly one share modal, and one way to trigger it:
| Need | Use | Notes |
|---|---|---|
| The share modal itself | `<x-share-modal />` | Singleton — already rendered once in `layouts/app.blade.php`. Never copy its markup or `@include` it again on a page that uses the app layout. Lives in `resources/views/components/share-modal.blade.php`. |
| A share button / menu item | `<x-share-button :video="$video" />` | Pass `tag="a"` for dropdown items (e.g. video-card menu), default `tag="button"` for `.action-btn`. Extra classes/attributes are forwarded; a slot overrides the default "Share" label. |
The button calls the global `openShareModal(shareUrl, title, recordUrl, emailUrl, membersUrl)`, which provides copy-link, social, **send-by-email**, and **share-to-members** (in-app notification + email) in one place. Rules that must never be violated:
1. **Never render a raw share `<a onclick="openShareModal(...)">`** — use `<x-share-button>` so every entry point passes the full argument set.
2. **If you must call `openShareModal()` from JS, pass all five arguments** — including `route('videos.shareEmail', $video)` and `route('videos.shareMembers', $video)` (empty string for guests). Calling it with partial args silently drops the email/members options.
3. **Never build a second share modal, dropdown, or sheet** anywhere. New share entry points reuse `<x-share-button>`.
4. **Share-to-members** is powered by `VideoController@shareWithMembers` + the `VideoSharedWithUser` notification (database + mail) and the `users.search` typeahead — extend these rather than adding a parallel path.
**Component usage tracker is mandatory and must always be kept current** — the tracker lives at `.claude/component-usage.md`. These rules apply without exception: **Component usage tracker is mandatory and must always be kept current** — the tracker lives at `.claude/component-usage.md`. These rules apply without exception:
1. **Creating a new reusable component** → add a new section to the tracker listing the component file path, its props, and an empty usage table. 1. **Creating a new reusable component** → add a new section to the tracker listing the component file path, its props, and an empty usage table.

View File

@ -1,6 +0,0 @@
# Mobile Upload Icon Change to +
## Steps:
1. [x] Edit resources/views/layouts/app.blade.php: Replace `bi bi-play-circle-fill` with `bi bi-plus-circle-fill` in the bottom nav Upload <i> tag.
2. [x] Verify in browser mobile view (refresh page, resize to <768px).
3. [x] Task complete - icon updated successfully.

View File

@ -1,10 +0,0 @@
# Admin Dashboard Enhancements - Progress
## Steps:
- [x] Step 1: Add cleanup method to SuperAdminController
- [x] Step 2: Add route for cleanup
- [ ] Step 3: Add dashboard UI (button + storage gauge for videos/)
- [x] Step 4: Test *(Manual: Visit /admin/dashboard, use button; route added)*
- [x] Complete
✅ **Admin Dashboard Enhancement Complete**

View File

@ -1,7 +0,0 @@
# Add Delete Dropdown to Video Show Page ✅
## Steps:
1. [x] Edit resources/views/components/video-actions.blade.php: Added conditional red Delete button in mobile dropdown for owners (after Save, onclick="showDeleteModal(...)").
2. [x] Edit resources/views/layouts/app.blade.php: Updated confirmDeleteVideo() success → redirect to {{ route('videos.index') }} instead of reload.
3. [x] Verify: Video show → owner mobile dropdown Delete → modal → confirm → redirects to videos index page.
4. [x] Task complete - delete now redirects to home videos list.

View File

@ -1,35 +0,0 @@
# Drag and Drop Playlist Reordering Implementation
## Task
Add drag-and-drop functionality to reorder videos in a playlist when editing
## Steps Completed
1. [x] Add SortableJS library to the project (via CDN in the view)
2. [x] Update playlist show.blade.php to add drag-and-drop functionality
3. [x] Add CSS styles for drag-and-drop visual feedback
4. [x] Implement playlist sidebar when playing from playlist (no up-next recommendations)
## Implementation Complete ✅
### Backend (Already existed - no changes needed)
- Route: `PUT /playlists/{playlist}/reorder` - accepts `video_ids` array
- Controller: `PlaylistController::reorder()` - calls `playlist->reorderVideos()`
- Model: `Playlist::reorderVideos()` - updates position for each video
### Frontend Changes Made (Playlist Show Page)
- Added SortableJS via CDN in `show.blade.php`
- Added drag handles (grip icon) to each video item for users who can edit
- Added CSS styles for drag-and-drop visual feedback (ghost, chosen, drag classes)
- Added JavaScript to initialize Sortable on the video list container
- On `onEnd` event, collects new order and sends AJAX to reorder endpoint
- Position numbers update visually after reorder
### Video Player Page Changes (Sidebar)
- Updated VideoController to detect playlist context from `?playlist=` parameter
- Updated all video type views (generic, music, match, show) to show playlist videos in sidebar when viewing from playlist
- Shows playlist name and video count in sidebar header
- Shows position numbers on each video thumbnail
- Links preserve the playlist parameter for continuous playback
- Shows "Edit Playlist" button for playlist owners

View File

@ -1,41 +0,0 @@
# GPU Acceleration Implementation Steps
## Status: In Progress ✅ Started
**Hardware Confirmed:**
- 2x NVIDIA RTX 3060 (12GB each)
- NVIDIA Driver 580.76.05, CUDA 13.0
- FFmpeg 4.4.2 with NVENC support (h264_nvenc, hevc_nvenc)
- hwaccels: cuda ✅
## Completed Steps
- [x] Verified GPU/FFmpeg setup
- [x] Created config/ffmpeg.php ✅
- [x] Updated CompressVideoJob.php with NVENC ✅
- [x] Updated VideoController.php queue dispatch ✅
## Next Steps (Approved Plan)
1. ~~Verify GPU/FFmpeg readiness~~
2. ~~Create config/ffmpeg.php for global NVENC settings~~
3. ~~Update app/Jobs/CompressVideoJob.php: Switch to h264_nvenc (CRF 23, preset p4)~~
4. ~~Update app/Http/Controllers/VideoController.php: Queue dispatch tweaks~~
5. ~~Setup queue: php artisan queue:table && migrate && QUEUE_CONNECTION=database~~ ✅ (tables exist)
6. ~~Test encoding: Upload video, monitor logs/GPU util~~ → Now implementing HLS GPU playback
7. ~~Optional~~ Create GenerateHlsJob + frontend HLS.js player ✅ Planning
8. Update model/controller/views for HLS playback
## Commands to Run After Code Changes
```
php artisan config:clear
php artisan queue:table
php artisan migrate
# Edit .env: QUEUE_CONNECTION=database
php artisan queue:work --queue=video-processing --tries=3
# Test upload, tail -f storage/logs/laravel.log && watch nvidia-smi
```
## Testing
- Upload test video
- Check encoding speed (should be 5-10x faster)
- Verify quality/size

View File

@ -1,41 +0,0 @@
# TODO - Topbar Standardization - COMPLETED
## Task: Use same topbar across all pages
### Summary:
- Topbar is in a separate file: `resources/views/layouts/partials/header.blade.php`
- All layouts now include this header partial
### Layouts and their pages:
1. **layouts/app.blade.php** (includes header + sidebar)
- videos/index.blade.php
- videos/trending.blade.php
- videos/show.blade.php
- videos/create.blade.php
- videos/edit.blade.php
- videos/types/*.blade.php
- user/profile.blade.php
- user/channel.blade.php
- user/history.blade.php
- user/liked.blade.php
- user/settings.blade.php
- welcome.blade.php
2. **layouts/plain.blade.php** (includes header, no sidebar)
- auth/login.blade.php
- auth/register.blade.php
3. **admin/layout.blade.php** (includes header, admin sidebar)
- admin/dashboard.blade.php
- admin/users.blade.php
- admin/videos.blade.php
- admin/edit-user.blade.php
- admin/edit-video.blade.php
### Changes Made:
- [x] 1. Analyzed current structure
- [x] 2. Updated welcome.blade.php to use layouts.app
- [x] 3. Verified plain.blade.php includes header (already had it)
- [x] 4. Verified admin layout uses header (already had it)
- [x] 5. Fixed videos/create.blade.php - hide duplicate header on mobile

View File

@ -1,37 +0,0 @@
# TODO: Next/Previous Video Controls for Playlist
## Task
Add next and previous video controls to the video player when viewing from a playlist context, plus autoplay toggle.
## Implementation Steps
### Step 1: Modify VideoController.php
- [x] Add nextVideo and previousVideo variables based on current video position in playlist
- [x] Add autoplayNext variable support
### Step 2: Modify generic.blade.php
- [x] Add Next/Previous control buttons overlay on video player
- [x] Add autoplay toggle switch
- [x] Add keyboard shortcuts (Left/Right arrows)
- [x] Style controls to match YouTube-style
### Step 3: Modify music.blade.php
- [x] Add Next/Previous control buttons overlay on video player
- [x] Add autoplay toggle switch
- [x] Add keyboard shortcuts (Left/Right arrows)
- [x] Style controls to match YouTube-style
### Step 4: Modify match.blade.php
- [x] Add Next/Previous control buttons overlay on video player
- [x] Add autoplay toggle switch
- [x] Add keyboard shortcuts (Left/Right arrows)
- [x] Style controls to match YouTube-style
## Files Edited
1. app/Http/Controllers/VideoController.php
2. resources/views/videos/types/generic.blade.php
3. resources/views/videos/types/music.blade.php
4. resources/views/videos/types/match.blade.php
## COMPLETED

View File

@ -1,31 +0,0 @@
# Open Graph Implementation Plan
## Task: Video sharing preview (thumbnail + info) on all platforms
### Steps to complete:
1. [x] 1. Update Video Model - Add methods for proper thumbnail handling and image dimensions
2. [x] 2. Update videos/show.blade.php - Add comprehensive Open Graph meta tags
3. [x] 3. Add video-specific Open Graph tags (og:video, og:video:url, etc.)
4. [x] 4. Add enhanced Twitter Card meta tags
5. [x] 5. Add Schema.org VideoObject markup
6. [x] 6. Ensure thumbnail is publicly accessible
### Platform Support:
- ✅ WhatsApp
- ✅ Facebook
- ✅ Twitter/X
- ✅ LinkedIn
- ✅ Telegram
- ✅ Pinterest
- ✅ All other social platforms
### Meta Tags Implemented:
- Basic: og:title, og:description, og:image, og:url, og:type, og:site_name
- Image: og:image:width, og:image:height, og:image:alt
- Video-specific: og:video, og:video:url, og:video:secure_url, og:video:type, og:video:width, og:video:height, video:duration, video:release_date
- Twitter: twitter:card, twitter:site, twitter:creator, twitter:player, twitter:player:stream
- LinkedIn: linkedin:owner
- Pinterest: pinterest-rich-pin
- Schema.org: VideoObject with full video metadata

View File

@ -1,13 +0,0 @@
# Orphaned Videos Cleanup - Progress Tracker
## Steps (Approved Plan):
- [ ] **Step 1**: Add `CLEANUP_INTERVAL_MINUTES=30` to `.env`
- [ ] **Step 2**: Create Artisan command `app/Console/Commands/CleanupOrphanedVideos.php`
- [x] **Step 3**: Register command in `app/Console/Kernel.php` (commands()) *(autoloaded)*
- [x] **Step 4**: Add schedule to `app/Console/Kernel.php` using env interval
- [x] **Step 5**: Test: `php artisan cleanup:orphaned-videos --dry-run` *(tested via tool)*
- [x] **Step 6**: Verify schedule: `php artisan schedule:run` *(verified; next due in ~19min)*
- [x] **Step 7**: Production cron setup reminder *(Add to crontab: `* * * * * cd /var/www/videoplatform && php artisan schedule:run >> /dev/null 2>&1`)*
- [ ] **Complete**: attempt_completion
**TASK COMPLETE** - Cron job implemented. See README in file for usage.

View File

@ -1,37 +0,0 @@
# Playlist Implementation TODO
## Phase 1: Database & Models - COMPLETED
- [x] Create playlists migration
- [x] Create playlist_videos pivot table migration
- [x] Create Playlist model
- [x] Update User model with playlists relationship
- [x] Update Video model with playlists relationship
## Phase 2: Controller & Routes - COMPLETED
- [x] Create PlaylistController
- [x] Add RESTful routes for playlists
- [x] Add routes for adding/removing/reordering videos
## Phase 3: Views - Playlist Pages - COMPLETED
- [x] Create playlists index page (user's playlists)
- [x] Create playlist show page (view videos in playlist)
- [x] Create playlist create/edit modal
- [x] Add playlist management in user channel
## Phase 4: Views - Integration - COMPLETED
- [x] Add "Add to Playlist" button on video page
- [x] Add "Add to Playlist" modal
- [x] Add playlist dropdown on video cards
- [x] Add continuous play functionality
## Phase 5: Extra Features - COMPLETED
- [x] Auto-create "Watch Later" playlist for new users
- [x] Watch progress tracking
- [x] Playlist sharing
- [x] Playlist statistics
## Phase 6: Testing & Polish - COMPLETED
- [x] Add Playlists link to sidebar (FIXED)
- [x] Fix "Please log in" alert for authenticated users (FIXED)
- [x] Add responsive styles

View File

@ -1,51 +0,0 @@
# Shorts Feature Implementation Plan
## Overview
Add "Shorts" as a separate attribute (boolean flag) to identify short-form vertical videos, independent of the video content type (generic/music/match).
## ✅ Completed Tasks
### 1. ✅ Database Migration
- [x] Created migration to add `is_shorts` boolean column to videos table
### 2. ✅ Video Model (app/Models/Video.php)
- [x] Added `is_shorts` to fillable array
- [x] Added `is_shorts` to casts (boolean)
- [x] Added helper methods: `isShorts()`, `scopeShorts()`, `scopeNotShorts()`
- [x] Added `qualifiesAsShorts()` for auto-detection
- [x] Added `getFormattedDurationAttribute()`
- [x] Added `getShortsBadgeAttribute()`
### 3. ✅ Video Controller (app/Http/Controllers/VideoController.php)
- [x] Updated validation to include `is_shorts`
- [x] Added auto-detection of shorts based on:
- Duration ≤ 60 seconds
- Portrait orientation (height > width)
- [x] Updated store method to include duration and is_shorts
- [x] Updated edit method to include is_shorts in JSON response
- [x] Updated update method to support is_shorts
- [x] Added shorts() method for shorts page
### 4. ✅ Views
- [x] Added Shorts toggle in upload form (create.blade.php)
- [x] Added Shorts toggle CSS styles
- [x] Added Shorts badge in video cards
- [x] Added Shorts toggle in edit modal
- [x] Created shorts.blade.php page
- [x] Updated sidebar to link to Shorts page
### 5. ✅ Routes
- [x] Added /shorts route
### 6. ✅ Admin
- [x] Updated SuperAdminController to support is_shorts
## Usage
1. Users can mark videos as Shorts during upload
2. Shorts are automatically detected if:
- Duration ≤ 60 seconds AND
- Portrait orientation
3. Shorts have a red badge on video cards
4. Dedicated /shorts page shows all Shorts videos
5. Sidebar has a link to Shorts

View File

@ -1,15 +0,0 @@
# TODO: Implement YouTube-style "Up Next" Recommendations
## Tasks:
- [x] 1. Analyze codebase and understand the current implementation
- [x] 2. Add recommendations method in VideoController
- [x] 3. Add route for recommendations endpoint
- [x] 4. Update show.blade.php to display recommended videos
- [x] 5. Fix "Undefined variable $currentVideo" error
## Progress:
- Step 1: COMPLETED - Analyzed VideoController, Video model, and show.blade.php
- Step 2: COMPLETED - Added getRecommendedVideos() and recommendations() methods in VideoController
- Step 3: COMPLETED - Added route in web.php for /videos/{video}/recommendations
- Step 4: COMPLETED - Updated show.blade.php sidebar with Up Next recommendations
- Step 5: COMPLETED - Fixed missing $currentVideo variable in closure (line 258)

View File

@ -190,6 +190,25 @@ class MediaController extends Controller
return $this->fileResponse($local); return $this->fileResponse($local);
} }
public function sportsImage(string $filename, NasSyncService $nas): Response
{
// Format: "users/{slug}/sports/{matchId}/{key}.{ext}"
if (str_starts_with($filename, 'users/')) {
$local = storage_path('app/' . $filename);
if (! file_exists($local)) {
@mkdir(dirname($local), 0755, true);
// NAS path is identical to the relative path stored in DB
$nas->ensureLocalAsset($local, $filename);
if (! file_exists($local)) abort(404);
}
return $this->fileResponse($local);
}
abort(404);
}
// ── Helper ──────────────────────────────────────────────────────────────── // ── Helper ────────────────────────────────────────────────────────────────
private function fileResponse(string $path): Response private function fileResponse(string $path): Response

View File

@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Playlist; use App\Models\Playlist;
use App\Models\Video; use App\Models\Video;
use App\Services\GeoIpService; use App\Services\GeoIpService;
use App\Services\NasSyncService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -14,7 +15,7 @@ class PlaylistController extends Controller
{ {
public function __construct() public function __construct()
{ {
$this->middleware('auth')->except(['index', 'show', 'showByToken', 'publicPlaylists', 'userPlaylists', 'recordShare', 'accessShare']); $this->middleware('auth')->except(['index', 'show', 'showByToken', 'publicPlaylists', 'userPlaylists', 'recordShare', 'accessShare', 'ogImage']);
} }
// List user's playlists // List user's playlists
@ -119,6 +120,23 @@ class PlaylistController extends Controller
]); ]);
} }
// Serve the playlist's own OG metadata to social-media crawlers so previews
// show the playlist's picture and name — not the first video's. Humans still
// get redirected to the first track for one-tap playback.
$ua = (string) $request->userAgent();
$isCrawler = (bool) preg_match(
'/facebookexternalhit|facebookcatalog|Facebot|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|TelegramBot|Pinterest|redditbot|Googlebot|bingbot|DuckDuckBot|YandexBot|Applebot|Embedly|vkShare|W3C_Validator|SkypeUriPreview/i',
$ua
);
if ($isCrawler) {
$playlist->loadMissing('user');
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
return response()
->view('playlists.show', compact('playlist', 'videos'))
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
}
$firstVideo = $playlist->videos()->orderBy('position')->first(); $firstVideo = $playlist->videos()->orderBy('position')->first();
$destination = $firstVideo $destination = $firstVideo
? route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token ? route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token
@ -495,4 +513,109 @@ class PlaylistController extends Controller
app(\App\Services\NasSyncService::class)->deleteFile($nasPath); app(\App\Services\NasSyncService::class)->deleteFile($nasPath);
} catch (\Throwable) {} } catch (\Throwable) {}
} }
// Social-preview image: resize the playlist's own thumbnail to a 1200x630 JPEG
// (small enough for WhatsApp/Telegram/Discord previews). Falls back to a branded card.
public function ogImage(Playlist $playlist, NasSyncService $nas)
{
if (! $playlist->canViewViaToken(Auth::user())) {
abort(404);
}
if ($playlist->thumbnail) {
$local = storage_path('app/' . $playlist->thumbnail);
if (! file_exists($local)) {
@mkdir(dirname($local), 0755, true);
$nas->ensureLocalAsset($local, $playlist->thumbnail);
}
if (file_exists($local)) {
$ext = strtolower(pathinfo($local, PATHINFO_EXTENSION));
$src = match ($ext) {
'png' => @imagecreatefrompng($local),
'webp' => @imagecreatefromwebp($local),
'gif' => @imagecreatefromgif($local),
default => @imagecreatefromjpeg($local),
};
if ($src) {
$ow = imagesx($src); $oh = imagesy($src);
// Always output an exact 1200x630 canvas (cover-crop, no letterbox)
// so the served image matches the og:image:width/height we declare —
// a mismatch makes WhatsApp/Telegram fall back to the tiny thumbnail.
$cw = 1200; $ch = 630;
$dst = imagecreatetruecolor($cw, $ch);
// Cover: scale so the image fills the whole canvas, center-crop overflow
$scale = max($cw / $ow, $ch / $oh);
$sw = (int) round($cw / $scale);
$sh = (int) round($ch / $scale);
$sx = (int) round(($ow - $sw) / 2);
$sy = (int) round(($oh - $sh) / 2);
imagecopyresampled($dst, $src, 0, 0, $sx, $sy, $cw, $ch, $sw, $sh);
imagedestroy($src);
ob_start();
imagejpeg($dst, null, 82);
imagedestroy($dst);
$jpeg = ob_get_clean();
return response($jpeg, 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'public, max-age=86400',
]);
}
}
}
// Branded fallback card
$w = 1200; $h = 630;
$img = imagecreatetruecolor($w, $h);
$cBg = imagecolorallocate($img, 10, 10, 10);
$cRed = imagecolorallocate($img, 230, 30, 30);
$cWhite = imagecolorallocate($img, 240, 240, 240);
$cGray = imagecolorallocate($img, 120, 120, 120);
imagefill($img, 0, 0, $cBg);
imagefilledrectangle($img, 0, 0, $w, 8, $cRed);
imagefilledrectangle($img, 0, $h - 8, $w, $h, $cRed);
$cx = (int)($w / 2); $cy = (int)($h / 2) - 30; $r = 72;
imagefilledellipse($img, $cx, $cy, $r * 2, $r * 2, $cRed);
$tri = [$cx - 22, $cy - 30, $cx - 22, $cy + 30, $cx + 34, $cy];
imagefilledpolygon($img, $tri, $cWhite);
$fontBold = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
$fontNormal = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';
imagettftext($img, 22, 0, 40, 56, $cRed, $fontBold, strtoupper(config('app.name')));
$title = $playlist->name ?: 'Playlist';
$maxChars = 42;
$lines = [];
if (mb_strlen($title) > $maxChars) {
$words = explode(' ', $title); $line = '';
foreach ($words as $word) {
if (mb_strlen($line . ' ' . $word) > $maxChars) { $lines[] = trim($line); $line = $word; }
else { $line .= ($line ? ' ' : '') . $word; }
}
if ($line) $lines[] = trim($line);
} else { $lines = [$title]; }
$lines = array_slice($lines, 0, 2);
$titleY = $cy + $r + 60;
foreach ($lines as $i => $line) {
$bbox = imagettfbbox(28, 0, $fontBold, $line);
$tw = $bbox[2] - $bbox[0];
$tx = (int)(($w - $tw) / 2);
imagettftext($img, 28, 0, $tx, $titleY + $i * 44, $cWhite, $fontBold, $line);
}
$meta = $playlist->video_count . ' videos' . ($playlist->user ? ' • by ' . $playlist->user->name : '');
$bbox = imagettfbbox(16, 0, $fontNormal, $meta);
$tw = $bbox[2] - $bbox[0];
$tx = (int)(($w - $tw) / 2);
imagettftext($img, 16, 0, $tx, $titleY + count($lines) * 44 + 20, $cGray, $fontNormal, $meta);
ob_start();
imagejpeg($img, null, 85);
imagedestroy($img);
$jpeg = ob_get_clean();
return response($jpeg, 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'public, max-age=86400',
]);
}
} }

View File

@ -0,0 +1,307 @@
<?php
namespace App\Http\Controllers;
use App\Models\SportsMatch;
use App\Services\NasSyncService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
class SportsMatchController extends Controller
{
/** Single-image fields stored under the `media` JSON group. */
private const IMAGE_FIELDS = [
'media_participant1_photo' => 'participant1_photo',
'media_participant2_photo' => 'participant2_photo',
'media_referee_photo' => 'referee_photo',
'media_club1_logo' => 'club1_logo',
'media_club2_logo' => 'club2_logo',
'media_event_poster' => 'event_poster',
];
public function store(Request $request, NasSyncService $nas): JsonResponse
{
$data = $request->validate($this->rules());
$match = new SportsMatch();
$match->user_id = Auth::id();
$this->fillFromRequest($match, $request, $data);
$match->save();
$this->handleImages($match, $request, $nas);
$match->save();
return response()->json([
'ok' => true,
'message' => 'Match saved as draft.',
'match' => $this->toEditPayload($match),
]);
}
public function update(Request $request, SportsMatch $sportsMatch, NasSyncService $nas): JsonResponse
{
abort_unless($sportsMatch->user_id === Auth::id(), 403);
$data = $request->validate($this->rules($sportsMatch));
$this->fillFromRequest($sportsMatch, $request, $data);
$this->handleImages($sportsMatch, $request, $nas);
$sportsMatch->save();
return response()->json([
'ok' => true,
'message' => 'Match updated.',
'match' => $this->toEditPayload($sportsMatch),
]);
}
/** Return the record as JSON so the modal can be re-opened for later editing. */
public function edit(SportsMatch $sportsMatch): JsonResponse
{
abort_unless($sportsMatch->user_id === Auth::id(), 403);
return response()->json(['match' => $this->toEditPayload($sportsMatch)]);
}
// ── Validation ──────────────────────────────────────────────────────────
private function rules(?SportsMatch $existing = null): array
{
$img = ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'];
return [
// Basic — only these are required for a first (draft) save
'video_id' => ['required', 'integer', Rule::exists('videos', 'id')->where('user_id', Auth::id())],
'status' => ['nullable', Rule::in(['draft', 'published'])],
'title' => ['required', 'string', 'max:255'],
'event_name' => ['nullable', 'string', 'max:255'],
'match_date' => ['nullable', 'date'],
'match_time' => ['nullable', 'date_format:H:i'],
'participant1_name' => ['nullable', 'string', 'max:255'],
'participant2_name' => ['nullable', 'string', 'max:255'],
'referee_name' => ['nullable', 'string', 'max:255'],
// revealed later in edit
'sport' => ['nullable', 'string', 'max:80'],
'match_type' => ['nullable', 'string', 'max:80'],
'venue_name' => ['nullable', 'string', 'max:255'],
// Optional grouped scalars (free-form, kept generic)
'competition' => ['nullable', 'array'],
'participants' => ['nullable', 'array'],
'extra_participants'=> ['nullable', 'array'],
'venue' => ['nullable', 'array'],
'result' => ['nullable', 'array'],
'reviews' => ['nullable', 'array'],
'media' => ['nullable', 'array'],
// Repeatable groups
'officials' => ['nullable', 'array'],
'officials.*.role' => ['nullable', 'string', 'max:80'],
'officials.*.name' => ['nullable', 'string', 'max:255'],
'officials.*.photo' => $img,
'segments' => ['nullable', 'array'],
'segments.*.type' => ['nullable', 'string', 'max:80'],
'segments.*.number' => ['nullable', 'string', 'max:50'],
'segments.*.score' => ['nullable', 'string', 'max:255'],
'segments.*.winner' => ['nullable', 'string', 'max:255'],
'segments.*.notes' => ['nullable', 'string', 'max:2000'],
'statistics' => ['nullable', 'array'],
'statistics.*.name' => ['nullable', 'string', 'max:120'],
'statistics.*.value' => ['nullable', 'string', 'max:120'],
'statistics.*.owner' => ['nullable', 'string', 'max:255'],
'statistics.*.notes' => ['nullable', 'string', 'max:2000'],
// Single image fields
'media_participant1_photo' => $img,
'media_participant2_photo' => $img,
'media_referee_photo' => $img,
'media_club1_logo' => $img,
'media_club2_logo' => $img,
'media_event_poster' => $img,
];
}
// ── Mapping helpers ─────────────────────────────────────────────────────
private function fillFromRequest(SportsMatch $match, Request $request, array $data): void
{
$match->video_id = $data['video_id'];
$match->status = $data['status'] ?? 'draft';
$match->title = $data['title'];
$match->event_name = $data['event_name'] ?? null;
$match->match_date = $data['match_date'] ?? null;
$match->match_time = $data['match_time'] ?? null;
$match->participant1_name = $data['participant1_name'] ?? null;
$match->participant2_name = $data['participant2_name'] ?? null;
$match->referee_name = $data['referee_name'] ?? null;
$match->sport = $data['sport'] ?? null;
$match->match_type = $data['match_type'] ?? null;
$match->venue_name = $data['venue_name'] ?? null;
// Optional scalar groups — keep only non-empty values, drop the JSON if empty.
$match->competition = $this->clean($request->input('competition', []));
$match->venue = $this->clean($request->input('venue', []));
$match->result = $this->clean($request->input('result', []));
$match->reviews = $this->clean($request->input('reviews', []));
// Participants details + any extra participants in one generic structure.
$participants = $this->clean($request->input('participants', []));
$extra = collect($request->input('extra_participants', []))
->map(fn ($p) => $this->clean($p))
->filter()
->values()
->all();
if (! empty($extra)) $participants['extra'] = $extra;
$match->participants = empty($participants) ? null : $participants;
// Repeatable text groups (officials are built in handleImages() since they carry photos).
$match->segments = $this->cleanRows($request->input('segments', []));
$match->statistics = $this->cleanRows($request->input('statistics', []));
// Media text fields (caption/alt/credit/public). Preserve existing image
// paths already on the record; handleImages() overwrites any replaced ones.
$existingMedia = $match->media ?? [];
$mediaText = $this->clean($request->input('media', []));
if (isset($mediaText['public'])) {
$mediaText['public'] = filter_var($mediaText['public'], FILTER_VALIDATE_BOOLEAN);
}
$imagePaths = array_intersect_key($existingMedia, array_flip(self::IMAGE_FIELDS));
$merged = array_merge($imagePaths, $mediaText);
$match->media = empty($merged) ? null : $merged;
}
/** Strip empty values from a flat associative array; return null if nothing left. */
private function clean(?array $arr): ?array
{
if (! is_array($arr)) return null;
$out = array_filter($arr, fn ($v) => $v !== null && $v !== '' && $v !== []);
return empty($out) ? null : $out;
}
/** Clean a list of repeatable rows, dropping rows that are entirely empty. */
private function cleanRows(?array $rows): ?array
{
if (! is_array($rows)) return null;
$out = [];
foreach ($rows as $row) {
if (! is_array($row)) continue;
// photo (file) is handled separately; drop transient hidden keys here
unset($row['photo']);
$clean = $this->clean($row);
if ($clean) $out[] = $clean;
}
return empty($out) ? null : $out;
}
// ── Image handling ──────────────────────────────────────────────────────
private function handleImages(SportsMatch $match, Request $request, NasSyncService $nas): void
{
$slug = $nas->userSlug($match->user ?: Auth::user());
$media = $match->media ?? [];
// Named single images
foreach (self::IMAGE_FIELDS as $input => $key) {
if ($request->hasFile($input)) {
$media[$key] = $this->storeImage($request->file($input), $slug, $match->id, $key, $nas);
}
}
$match->media = empty($media) ? null : $media;
// Officials — built from raw input so file inputs align by index. The
// modal renumbers rows to contiguous indices before submit. A new photo
// file replaces the existing one; otherwise the existing path is kept.
$officials = [];
foreach (array_values($request->input('officials', [])) as $i => $row) {
if (! is_array($row)) continue;
$entry = [];
if (! empty($row['role'])) $entry['role'] = $row['role'];
if (! empty($row['name'])) $entry['name'] = $row['name'];
if ($request->hasFile("officials.$i.photo")) {
$entry['photo'] = $this->storeImage(
$request->file("officials.$i.photo"), $slug, $match->id, "official-$i", $nas
);
} elseif (! empty($row['photo_existing'])) {
$entry['photo'] = $row['photo_existing'];
}
if (! empty($entry)) $officials[] = $entry;
}
$match->officials = empty($officials) ? null : $officials;
}
/**
* Write one uploaded image to the canonical NAS path
* (users/{slug}/sports/{matchId}/{key}.{ext}) and return that relative path.
*/
private function storeImage(UploadedFile $file, string $slug, int $matchId, string $key, NasSyncService $nas): string
{
$ext = strtolower($file->getClientOriginalExtension() ?: 'jpg');
$rel = "users/{$slug}/sports/{$matchId}/{$key}.{$ext}";
$localAbs = storage_path('app/' . $rel);
@mkdir(dirname($localAbs), 0755, true);
$file->move(dirname($localAbs), basename($localAbs));
// Push to NAS and drop the local copy when the NAS is reachable.
if ($nas->isEnabled()) {
$nas->mkdirp(dirname($rel));
if ($nas->putFile($localAbs, $rel)) {
@unlink($localAbs);
}
}
return $rel;
}
/** Shape the record for the edit modal (resolves image paths to URLs). */
private function toEditPayload(SportsMatch $match): array
{
$media = $match->media ?? [];
$mediaUrls = [];
// Key the URLs by the form input name (e.g. media_participant1_photo) so the
// modal can match each preview to its field via [data-img].
foreach (self::IMAGE_FIELDS as $input => $key) {
if (! empty($media[$key])) {
$mediaUrls[$input] = route('media.sports-image', $media[$key]);
}
}
$officials = $match->officials ?? [];
foreach ($officials as &$o) {
if (! empty($o['photo'])) $o['photo_url'] = route('media.sports-image', $o['photo']);
}
unset($o);
return [
'id' => $match->id,
'video_id' => $match->video_id,
'video_title' => optional($match->video)->title,
'status' => $match->status,
'sport' => $match->sport,
'title' => $match->title,
'event_name' => $match->event_name,
'match_type' => $match->match_type,
'match_date' => optional($match->match_date)->format('Y-m-d'),
'match_time' => $match->match_time ? substr($match->match_time, 0, 5) : null,
'participant1_name' => $match->participant1_name,
'participant2_name' => $match->participant2_name,
'referee_name' => $match->referee_name,
'venue_name' => $match->venue_name,
'competition' => $match->competition,
'participants' => $match->participants,
'venue' => $match->venue,
'result' => $match->result,
'reviews' => $match->reviews,
'media' => $media,
'media_urls' => $mediaUrls,
'officials' => $officials,
'segments' => $match->segments,
'statistics' => $match->statistics,
];
}
}

View File

@ -21,6 +21,33 @@ class UserController extends Controller
$this->middleware('auth')->except(['channel']); $this->middleware('auth')->except(['channel']);
} }
// Typeahead search for members (used by the "Share to member" picker)
public function searchUsers(Request $request)
{
$q = trim((string) $request->query('q', ''));
if (mb_strlen($q) < 1) {
return response()->json(['users' => []]);
}
$users = User::where('id', '!=', Auth::id())
->where(function ($w) use ($q) {
$w->where('name', 'like', "%{$q}%")
->orWhere('username', 'like', "%{$q}%");
})
->orderBy('name')
->limit(8)
->get(['id', 'name', 'username', 'avatar']);
return response()->json([
'users' => $users->map(fn ($u) => [
'id' => $u->id,
'name' => $u->name,
'channel' => $u->channel,
'avatar' => $u->avatar_url,
]),
]);
}
// Profile page - personal overview for the authenticated user // Profile page - personal overview for the authenticated user
public function profile() public function profile()
{ {
@ -378,7 +405,49 @@ class UserController extends Controller
]); ]);
} }
public function toggleSubscribe(User $user) public function recordProfileVisit(Request $request, User $user)
{
// Don't record self-visits or repeated visits from the same person in the last 30 minutes
$visitorId = Auth::id();
$deviceId = $request->cookie('_did');
if ($visitorId && $visitorId === $user->id) {
return response()->json(['ok' => true, 'skipped' => 'self']);
}
$sourceVideoId = $request->integer('source_video_id') ?: null;
if ($sourceVideoId && ! Video::whereKey($sourceVideoId)->exists()) {
$sourceVideoId = null;
}
$dedup = \App\Models\ProfileVisit::where('profile_user_id', $user->id)
->where('created_at', '>=', now()->subMinutes(30))
->when($visitorId, fn ($q) => $q->where('visitor_user_id', $visitorId))
->when(! $visitorId && $deviceId, fn ($q) => $q->whereNull('visitor_user_id')->where('device_id', $deviceId))
->when($sourceVideoId, fn ($q) => $q->where('source_video_id', $sourceVideoId))
->exists();
if ($dedup) {
return response()->json(['ok' => true, 'skipped' => 'dedup']);
}
$ip = $request->header('CF-Connecting-IP') ?? $request->header('X-Real-IP') ?? $request->ip();
$geo = \App\Services\GeoIpService::lookup($ip);
\App\Models\ProfileVisit::create([
'profile_user_id' => $user->id,
'visitor_user_id' => $visitorId,
'device_id' => $deviceId,
'source_video_id' => $sourceVideoId,
'ip_address' => $ip,
'country' => $geo['country'] ?? null,
'created_at' => now(),
]);
return response()->json(['ok' => true]);
}
public function toggleSubscribe(Request $request, User $user)
{ {
$me = Auth::user(); $me = Auth::user();
@ -390,7 +459,11 @@ class UserController extends Controller
$me->subscriptions()->detach($user->id); $me->subscriptions()->detach($user->id);
$subscribed = false; $subscribed = false;
} else { } else {
$me->subscriptions()->attach($user->id); $sourceVideoId = $request->integer('source_video_id') ?: null;
if ($sourceVideoId && ! \App\Models\Video::whereKey($sourceVideoId)->exists()) {
$sourceVideoId = null;
}
$me->subscriptions()->attach($user->id, ['source_video_id' => $sourceVideoId]);
$subscribed = true; $subscribed = true;
try { $user->notify(new NewSubscriberNotification($me)); } catch (\Throwable) {} try { $user->notify(new NewSubscriberNotification($me)); } catch (\Throwable) {}
} }

View File

@ -428,6 +428,7 @@ class VideoController extends Controller
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'video_id' => $video->id,
'redirect' => route('videos.show', $video), 'redirect' => route('videos.show', $video),
]); ]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
@ -462,6 +463,16 @@ class VideoController extends Controller
?? $request->ip(); ?? $request->ip();
$geo = GeoIpService::lookup($ip); $geo = GeoIpService::lookup($ip);
// Persistent client-side device ID (set in the response cookie below).
// Survives IP/country changes so a guest on a VPN doesn't look like several different guests.
$viewDid = $request->cookie('_did') ?: (string) Str::uuid();
// Device fingerprint hash (set client-side by /fp.js after first paint).
// Stronger than the cookie alone — survives cookie clears, incognito, browser swaps.
// Null on the very first visit; the JS will call /identify to backfill it.
$viewFp = $request->cookie('_fp');
$viewFp = ($viewFp && preg_match('/^[a-f0-9]{64}$/', $viewFp)) ? $viewFp : null;
if (Auth::check()) { if (Auth::check()) {
$exists = \DB::table('video_views') $exists = \DB::table('video_views')
->where('user_id', Auth::id()) ->where('user_id', Auth::id())
@ -476,16 +487,22 @@ class VideoController extends Controller
'ip_address' => $ip, 'ip_address' => $ip,
'country' => $geo['country'], 'country' => $geo['country'],
'country_name' => $geo['country_name'], 'country_name' => $geo['country_name'],
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'device_id' => $viewDid,
'device_hash' => $viewFp,
'watched_at' => now(), 'watched_at' => now(),
]); ]);
} }
} else { } else {
// Guest: deduplicate by IP within the last hour // Guest: prefer the fingerprint hash for dedup (strongest signal); fall back to device_id cookie
$exists = \DB::table('video_views') $exists = \DB::table('video_views')
->whereNull('user_id') ->whereNull('user_id')
->where('video_id', $video->id) ->where('video_id', $video->id)
->where('ip_address', $ip)
->where('watched_at', '>', now()->subHour()) ->where('watched_at', '>', now()->subHour())
->where(function ($q) use ($viewFp, $viewDid) {
if ($viewFp) $q->where('device_hash', $viewFp);
else $q->where('device_id', $viewDid);
})
->exists(); ->exists();
if (! $exists) { if (! $exists) {
@ -495,6 +512,9 @@ class VideoController extends Controller
'ip_address' => $ip, 'ip_address' => $ip,
'country' => $geo['country'], 'country' => $geo['country'],
'country_name' => $geo['country_name'], 'country_name' => $geo['country_name'],
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'device_id' => $viewDid,
'device_hash' => $viewFp,
'watched_at' => now(), 'watched_at' => now(),
]); ]);
} }
@ -542,13 +562,11 @@ class VideoController extends Controller
default => 'videos.types.generic', default => 'videos.types.generic',
}; };
// Set persistent device-ID cookie used for share-link dedup // Refresh the persistent device-ID cookie (5-year window) — same value used above for video_views dedup
$did = $request->cookie('_did') ?: (string) Str::uuid();
return response() return response()
->view($view, compact('video', 'playlist', 'nextVideo', 'previousVideo', 'recommendedVideos', 'playlistVideos', 'shareTitle', 'shareDescription', 'shareTrackId')) ->view($view, compact('video', 'playlist', 'nextVideo', 'previousVideo', 'recommendedVideos', 'playlistVideos', 'shareTitle', 'shareDescription', 'shareTrackId'))
->header('Cache-Control', 'no-store, no-cache, must-revalidate') ->header('Cache-Control', 'no-store, no-cache, must-revalidate')
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5)); ->withCookie(cookie('_did', $viewDid, 60 * 24 * 365 * 5));
} }
public function playerData(Video $video, Request $request) public function playerData(Video $video, Request $request)
@ -2133,12 +2151,17 @@ class VideoController extends Controller
$geo = GeoIpService::lookup($ip); $geo = GeoIpService::lookup($ip);
$fp = $request->cookie('_fp');
$fp = ($fp && preg_match('/^[a-f0-9]{64}$/', $fp)) ? $fp : null;
\DB::table('video_downloads')->insert([ \DB::table('video_downloads')->insert([
'video_id' => $video->id, 'video_id' => $video->id,
'user_id' => $userId, 'user_id' => $userId,
'ip_address' => $ip, 'ip_address' => $ip,
'country' => $geo['country'], 'country' => $geo['country'],
'country_name' => $geo['country_name'], 'country_name' => $geo['country_name'],
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'device_hash' => $fp,
'type' => $type, 'type' => $type,
'downloaded_at' => now(), 'downloaded_at' => now(),
]); ]);
@ -2217,6 +2240,44 @@ class VideoController extends Controller
return response()->json(['success' => true]); return response()->json(['success' => true]);
} }
/**
* Share a video directly with selected members. Each recipient gets an in-app
* notification (clicking it opens the video) and an email.
*/
public function shareWithMembers(Request $request, Video $video)
{
if (! $video->isShareable() || ! $video->canView(Auth::user())) {
return response()->json(['error' => 'This video cannot be shared.'], 403);
}
$data = $request->validate([
'user_ids' => 'required|array|min:1|max:30',
'user_ids.*' => 'integer|exists:users,id',
'message' => 'nullable|string|max:500',
]);
$sharer = Auth::user();
$message = $data['message'] ?? null;
$recipients = \App\Models\User::whereIn('id', $data['user_ids'])
->where('id', '!=', $sharer->id)
->get();
foreach ($recipients as $member) {
try {
$member->notify(new \App\Notifications\VideoSharedWithUser(
$video, $sharer, $message, $video->share_url
));
} catch (\Throwable $e) {
\Log::error('Share-to-member failed: ' . $e->getMessage(), [
'video_id' => $video->id, 'member_id' => $member->id,
]);
}
}
return response()->json(['success' => true, 'count' => $recipients->count()]);
}
public function accessShare(Request $request, string $token) public function accessShare(Request $request, string $token)
{ {
$share = \DB::table('video_shares')->where('token', $token)->first(); $share = \DB::table('video_shares')->where('token', $token)->first();
@ -2244,12 +2305,17 @@ class VideoController extends Controller
?? $request->ip(); ?? $request->ip();
$geo = GeoIpService::lookup($ip); $geo = GeoIpService::lookup($ip);
$fp = $request->cookie('_fp');
$fp = ($fp && preg_match('/^[a-f0-9]{64}$/', $fp)) ? $fp : null;
\DB::table('share_accesses')->insert([ \DB::table('share_accesses')->insert([
'share_id' => $share->id, 'share_id' => $share->id,
'device_id' => $did, 'device_id' => $did,
'ip_address' => $ip, 'ip_address' => $ip,
'country' => $geo['country'] ?? null, 'country' => $geo['country'] ?? null,
'country_name'=> $geo['country_name'] ?? null, 'country_name'=> $geo['country_name'] ?? null,
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'device_hash' => $fp,
'accessed_at' => now(), 'accessed_at' => now(),
]); ]);
} }
@ -2393,6 +2459,48 @@ class VideoController extends Controller
]); ]);
} }
public function trackProgress(Request $request, Video $video)
{
$seconds = max(0, (int) $request->input('watched_seconds', 0));
$completed = (bool) $request->boolean('completed');
$viewDid = $request->cookie('_did');
$query = \DB::table('video_views')
->where('video_id', $video->id)
->where('watched_at', '>=', now()->subDay())
->orderByDesc('id')
->limit(1);
if (Auth::check()) {
$query->where('user_id', Auth::id());
} else {
$query->whereNull('user_id');
if ($viewDid) {
$query->where('device_id', $viewDid);
} else {
return response()->json(['ok' => false], 204);
}
}
$view = $query->first();
if (! $view) {
return response()->json(['ok' => false], 204);
}
$updates = [];
if ($seconds > (int) $view->watched_seconds) {
$updates['watched_seconds'] = $seconds;
}
if ($completed && ! $view->completed) {
$updates['completed'] = true;
}
if ($updates) {
\DB::table('video_views')->where('id', $view->id)->update($updates);
}
return response()->json(['ok' => true]);
}
public function insights(Video $video) public function insights(Video $video)
{ {
if (Auth::id() !== $video->user_id) { if (Auth::id() !== $video->user_id) {
@ -2432,23 +2540,34 @@ class VideoController extends Controller
? round(($viewsThisWeek - $viewsLastWeek) / $viewsLastWeek * 100) ? round(($viewsThisWeek - $viewsLastWeek) / $viewsLastWeek * 100)
: ($viewsThisWeek > 0 ? 100 : 0); : ($viewsThisWeek > 0 ? 100 : 0);
// 14-day daily breakdown // 14-day daily breakdown — segmented by viewer category (male / female / other-or-guest)
$rawDaily = \DB::table('video_views') $rawDaily = \DB::table('video_views')
->selectRaw("date(watched_at) as day, count(*) as cnt") ->leftJoin('users', 'users.id', '=', 'video_views.user_id')
->where('video_id', $id) ->selectRaw("date(video_views.watched_at) as day,
->where('watched_at', '>=', $now->copy()->subDays(13)->startOfDay()) sum(case when users.gender = 'male' then 1 else 0 end) as male_cnt,
->groupByRaw("date(watched_at)") sum(case when users.gender = 'female' then 1 else 0 end) as female_cnt,
sum(case when video_views.user_id is null or (users.gender is null or users.gender not in ('male','female')) then 1 else 0 end) as other_cnt,
count(*) as cnt")
->where('video_views.video_id', $id)
->where('video_views.watched_at', '>=', $now->copy()->subDays(13)->startOfDay())
->groupByRaw("date(video_views.watched_at)")
->orderBy('day') ->orderBy('day')
->pluck('cnt', 'day'); ->get()
->keyBy('day');
$daily = []; $daily = [];
for ($i = 13; $i >= 0; $i--) { for ($i = 13; $i >= 0; $i--) {
$d = $now->copy()->subDays($i); $d = $now->copy()->subDays($i);
$key = $d->format('Y-m-d');
$row = $rawDaily->get($key);
$daily[] = [ $daily[] = [
'date' => $d->format('Y-m-d'), 'date' => $key,
'label' => $d->format('M d'), 'label' => $d->format('M d'),
'short' => $d->format('D'), 'short' => $d->format('D'),
'count' => (int) ($rawDaily->get($d->format('Y-m-d')) ?? 0), 'count' => $row ? (int) $row->cnt : 0,
'male' => $row ? (int) $row->male_cnt : 0,
'female' => $row ? (int) $row->female_cnt : 0,
'other' => $row ? (int) $row->other_cnt : 0,
]; ];
} }
@ -2481,8 +2600,8 @@ class VideoController extends Controller
->first(); ->first();
$peakHour = $peakRow ? (int) $peakRow->hr : null; $peakHour = $peakRow ? (int) $peakRow->hr : null;
// Top registered viewers (by view count) // Top registered viewers (by view count) — also surface device/browser of their latest visit
$topViewers = \DB::table('video_views') $topViewersRaw = \DB::table('video_views')
->join('users', 'users.id', '=', 'video_views.user_id') ->join('users', 'users.id', '=', 'video_views.user_id')
->selectRaw('users.id, users.name, users.avatar, users.username, count(*) as cnt, max(video_views.watched_at) as last_at') ->selectRaw('users.id, users.name, users.avatar, users.username, count(*) as cnt, max(video_views.watched_at) as last_at')
->where('video_views.video_id', $id) ->where('video_views.video_id', $id)
@ -2490,8 +2609,25 @@ class VideoController extends Controller
->groupBy('users.id', 'users.name', 'users.avatar', 'users.username') ->groupBy('users.id', 'users.name', 'users.avatar', 'users.username')
->orderByDesc('cnt') ->orderByDesc('cnt')
->limit(20) ->limit(20)
->get() ->get();
->map(fn ($u) => [
// One extra query: most-recent user_agent per top viewer (small, indexed lookup)
$topViewerIds = $topViewersRaw->pluck('id')->all();
$latestUaByUid = [];
if ($topViewerIds) {
$latestRows = \DB::table('video_views as v1')
->whereIn('v1.user_id', $topViewerIds)
->where('v1.video_id', $id)
->whereRaw('v1.watched_at = (select max(v2.watched_at) from video_views v2 where v2.user_id = v1.user_id and v2.video_id = v1.video_id)')
->get(['v1.user_id', 'v1.user_agent']);
foreach ($latestRows as $row) {
$latestUaByUid[$row->user_id] = $row->user_agent;
}
}
$topViewers = $topViewersRaw->map(function ($u) use ($latestUaByUid) {
$ua = $this->parseUserAgent($latestUaByUid[$u->id] ?? null);
return [
'id' => $u->id, 'id' => $u->id,
'channel' => $u->username, 'channel' => $u->username,
'name' => $u->name, 'name' => $u->name,
@ -2500,19 +2636,30 @@ class VideoController extends Controller
: 'https://i.pravatar.cc/150?u=' . $u->id, : 'https://i.pravatar.cc/150?u=' . $u->id,
'count' => (int) $u->cnt, 'count' => (int) $u->cnt,
'last_at' => $u->last_at, 'last_at' => $u->last_at,
]); 'device' => $ua['device'],
'browser' => $ua['browser'],
];
});
$guestViews = \DB::table('video_views') $guestViews = \DB::table('video_views')
->where('video_id', $id) ->where('video_id', $id)
->whereNull('user_id') ->whereNull('user_id')
->count(); ->count();
// 10 most recent views // Recent activity — grouped by viewer.
$recentViewers = \DB::table('video_views') // Registered users group by user_id. For guests we layer three signals,
// strongest first: device_hash (multi-signal browser fingerprint) → device_id
// cookie → ip_address (legacy / cookie-less). This survives cookie clears,
// incognito mode, browser swaps, and VPN / country hops.
$recentRaw = \DB::table('video_views')
->leftJoin('users', 'users.id', '=', 'video_views.user_id') ->leftJoin('users', 'users.id', '=', 'video_views.user_id')
->select( ->select(
'video_views.watched_at', 'video_views.watched_at',
'video_views.country', 'video_views.country',
'video_views.ip_address',
'video_views.device_id',
'video_views.device_hash',
'video_views.user_agent',
'users.id as user_id', 'users.id as user_id',
'users.name as user_name', 'users.name as user_name',
'users.avatar as user_avatar', 'users.avatar as user_avatar',
@ -2520,20 +2667,40 @@ class VideoController extends Controller
) )
->where('video_views.video_id', $id) ->where('video_views.video_id', $id)
->orderByDesc('video_views.watched_at') ->orderByDesc('video_views.watched_at')
->limit(10) ->limit(500)
->get() ->get();
->map(fn ($r) => [
'at' => $r->watched_at, $groups = [];
'country' => $r->country, foreach ($recentRaw as $r) {
'user_id' => $r->user_id, if ($r->user_id) {
'user_channel' => $r->user_channel, $key = 'u:' . $r->user_id;
'user_name' => $r->user_name ?? 'Guest', } else {
'user_avatar' => $r->user_id $guestKey = $r->device_hash ?: ($r->device_id ?: ($r->ip_address ?: 'unknown'));
? ($r->user_avatar $key = 'g:' . $guestKey;
? route('media.avatar', $r->user_avatar) }
: 'https://i.pravatar.cc/150?u=' . $r->user_id) if (! isset($groups[$key])) {
: null, $ua = $this->parseUserAgent($r->user_agent);
]); $groups[$key] = [
'key' => $key,
'user_id' => $r->user_id,
'user_channel' => $r->user_channel,
'user_name' => $r->user_name ?? 'Guest',
'user_avatar' => $r->user_id
? ($r->user_avatar
? route('media.avatar', $r->user_avatar)
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
: null,
'country' => $r->country,
'device' => $ua['device'],
'browser' => $ua['browser'],
'count' => 0,
'last_at' => $r->watched_at,
];
}
$groups[$key]['count']++;
// first row is the most recent (orderByDesc) so device/browser reflect their latest visit
}
$recentViewers = array_values($groups);
// Download details // Download details
$totalDownloads = \DB::table('video_downloads')->where('video_id', $id)->count(); $totalDownloads = \DB::table('video_downloads')->where('video_id', $id)->count();
@ -2545,8 +2712,8 @@ class VideoController extends Controller
->groupBy('type') ->groupBy('type')
->pluck('cnt', 'type'); ->pluck('cnt', 'type');
// Per-user download counts (top 20 logged-in users) // Per-user download counts (top 20 logged-in users) + device/browser of their latest download
$dlUsers = \DB::table('video_downloads') $dlUsersRaw = \DB::table('video_downloads')
->join('users', 'users.id', '=', 'video_downloads.user_id') ->join('users', 'users.id', '=', 'video_downloads.user_id')
->selectRaw('users.id, users.name, users.avatar, count(*) as cnt, max(video_downloads.downloaded_at) as last_at') ->selectRaw('users.id, users.name, users.avatar, count(*) as cnt, max(video_downloads.downloaded_at) as last_at')
->where('video_downloads.video_id', $id) ->where('video_downloads.video_id', $id)
@ -2554,8 +2721,24 @@ class VideoController extends Controller
->groupBy('users.id', 'users.name', 'users.avatar') ->groupBy('users.id', 'users.name', 'users.avatar')
->orderByDesc('cnt') ->orderByDesc('cnt')
->limit(20) ->limit(20)
->get() ->get();
->map(fn ($u) => [
$dlUserIds = $dlUsersRaw->pluck('id')->all();
$dlLatestUaByUid = [];
if ($dlUserIds) {
$latestDl = \DB::table('video_downloads as d1')
->whereIn('d1.user_id', $dlUserIds)
->where('d1.video_id', $id)
->whereRaw('d1.downloaded_at = (select max(d2.downloaded_at) from video_downloads d2 where d2.user_id = d1.user_id and d2.video_id = d1.video_id)')
->get(['d1.user_id', 'd1.user_agent']);
foreach ($latestDl as $row) {
$dlLatestUaByUid[$row->user_id] = $row->user_agent;
}
}
$dlUsers = $dlUsersRaw->map(function ($u) use ($dlLatestUaByUid) {
$ua = $this->parseUserAgent($dlLatestUaByUid[$u->id] ?? null);
return [
'id' => $u->id, 'id' => $u->id,
'name' => $u->name, 'name' => $u->name,
'avatar' => $u->avatar 'avatar' => $u->avatar
@ -2563,43 +2746,62 @@ class VideoController extends Controller
: 'https://i.pravatar.cc/150?u=' . $u->id, : 'https://i.pravatar.cc/150?u=' . $u->id,
'count' => (int) $u->cnt, 'count' => (int) $u->cnt,
'last_at' => $u->last_at, 'last_at' => $u->last_at,
]); 'device' => $ua['device'],
'browser' => $ua['browser'],
];
});
$dlGuests = \DB::table('video_downloads') $dlGuests = \DB::table('video_downloads')
->where('video_id', $id) ->where('video_id', $id)
->whereNull('user_id') ->whereNull('user_id')
->count(); ->count();
// 10 most recent downloads // Recent download activity — grouped by downloader.
$dlRecent = \DB::table('video_downloads') // Guests are keyed by ip_address (video_downloads doesn't track device_id);
// device/browser comes from the most-recent download's user_agent.
$dlRecentRaw = \DB::table('video_downloads')
->leftJoin('users', 'users.id', '=', 'video_downloads.user_id') ->leftJoin('users', 'users.id', '=', 'video_downloads.user_id')
->select( ->select(
'video_downloads.id',
'video_downloads.type', 'video_downloads.type',
'video_downloads.downloaded_at', 'video_downloads.downloaded_at',
'video_downloads.country', 'video_downloads.country',
'video_downloads.country_name', 'video_downloads.country_name',
'video_downloads.ip_address',
'video_downloads.user_agent',
'users.id as user_id', 'users.id as user_id',
'users.name as user_name', 'users.name as user_name',
'users.avatar as user_avatar' 'users.avatar as user_avatar'
) )
->where('video_downloads.video_id', $id) ->where('video_downloads.video_id', $id)
->orderByDesc('video_downloads.downloaded_at') ->orderByDesc('video_downloads.downloaded_at')
->limit(10) ->limit(500)
->get() ->get();
->map(fn ($r) => [
'type' => $r->type, $dlGroups = [];
'at' => $r->downloaded_at, foreach ($dlRecentRaw as $r) {
'country' => $r->country, $key = $r->user_id ? 'u:' . $r->user_id : 'g:' . ($r->ip_address ?: 'unknown');
'country_name'=> $r->country_name, if (! isset($dlGroups[$key])) {
'user_id' => $r->user_id, $ua = $this->parseUserAgent($r->user_agent);
'user_name' => $r->user_name ?? 'Guest', $dlGroups[$key] = [
'user_avatar'=> $r->user_id 'key' => $key,
? ($r->user_avatar 'user_id' => $r->user_id,
? route('media.avatar', $r->user_avatar) 'user_name' => $r->user_name ?? 'Guest',
: 'https://i.pravatar.cc/150?u=' . $r->user_id) 'user_avatar'=> $r->user_id
: null, ? ($r->user_avatar
]); ? route('media.avatar', $r->user_avatar)
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
: null,
'country' => $r->country,
'type' => $r->type,
'device' => $ua['device'],
'browser' => $ua['browser'],
'count' => 0,
'last_at' => $r->downloaded_at,
];
}
$dlGroups[$key]['count']++;
}
$dlRecent = array_values($dlGroups);
// Gender breakdown (authenticated viewers only) // Gender breakdown (authenticated viewers only)
$genderRows = \DB::table('video_views') $genderRows = \DB::table('video_views')
@ -2625,13 +2827,17 @@ class VideoController extends Controller
$rawAges = \DB::table('video_views') $rawAges = \DB::table('video_views')
->join('users', 'users.id', '=', 'video_views.user_id') ->join('users', 'users.id', '=', 'video_views.user_id')
->selectRaw("{$ageExpr} as age") ->selectRaw("{$ageExpr} as age, users.gender as gender")
->where('video_views.video_id', $id) ->where('video_views.video_id', $id)
->whereNotNull('video_views.user_id') ->whereNotNull('video_views.user_id')
->whereNotNull('users.birthday') ->whereNotNull('users.birthday')
->get(); ->get();
$ageBuckets = ['Under 13' => 0, '1317' => 0, '1824' => 0, '2534' => 0, '3544' => 0, '4554' => 0, '5564' => 0, '65+' => 0]; $blank = ['count' => 0, 'male' => 0, 'female' => 0, 'other' => 0];
$ageBuckets = [
'Under 13' => $blank, '1317' => $blank, '1824' => $blank, '2534' => $blank,
'3544' => $blank, '4554' => $blank, '5564' => $blank, '65+' => $blank,
];
foreach ($rawAges as $row) { foreach ($rawAges as $row) {
$age = (int) $row->age; $age = (int) $row->age;
$bucket = match (true) { $bucket = match (true) {
@ -2644,16 +2850,22 @@ class VideoController extends Controller
$age <= 64 => '5564', $age <= 64 => '5564',
default => '65+', default => '65+',
}; };
$ageBuckets[$bucket]++; $ageBuckets[$bucket]['count']++;
if ($row->gender === 'male') $ageBuckets[$bucket]['male']++;
elseif ($row->gender === 'female') $ageBuckets[$bucket]['female']++;
else $ageBuckets[$bucket]['other']++;
} }
$totalAge = array_sum($ageBuckets); $totalAge = array_sum(array_column($ageBuckets, 'count'));
$ageGroups = collect($ageBuckets) $ageGroups = collect($ageBuckets)
->filter(fn ($cnt) => $cnt > 0) ->filter(fn ($b) => $b['count'] > 0)
->map(fn ($cnt, $label) => [ ->map(fn ($b, $label) => [
'label' => $label, 'label' => $label,
'count' => $cnt, 'count' => $b['count'],
'pct' => $totalAge > 0 ? round($cnt / $totalAge * 100) : 0, 'male' => $b['male'],
'female' => $b['female'],
'other' => $b['other'],
'pct' => $totalAge > 0 ? round($b['count'] / $totalAge * 100) : 0,
])->values(); ])->values();
// Who liked this video // Who liked this video
@ -2717,9 +2929,70 @@ class VideoController extends Controller
]; ];
})->sortByDesc('reach')->values()->take(10); })->sortByDesc('reach')->values()->take(10);
// ── Skip rate ──────────────────────────────────────────────
// Skipped = watched_seconds < max(10, 10% of duration)
$duration = (int) ($video->duration ?? 0);
$skipThreshold = max(10, (int) floor($duration * 0.10));
$skippedViews = \DB::table('video_views')
->where('video_id', $id)
->where('watched_seconds', '<', $skipThreshold)
->count();
$skipRate = $totalViews > 0 ? round($skippedViews / $totalViews * 100) : 0;
// ── Save rate ──────────────────────────────────────────────
// Distinct users who added this video to a playlist (excluding the uploader's own playlists)
$saveCount = \DB::table('playlist_videos')
->join('playlists', 'playlists.id', '=', 'playlist_videos.playlist_id')
->where('playlist_videos.video_id', $id)
->where('playlists.user_id', '!=', $video->user_id)
->distinct('playlists.user_id')
->count('playlists.user_id');
$uniqueViewersAll = \DB::table('video_views')
->where('video_id', $id)
->whereNotNull('user_id')
->distinct('user_id')
->count('user_id');
$saveRate = $uniqueViewersAll > 0 ? round($saveCount / $uniqueViewersAll * 100) : 0;
// ── Profile (wall) visits originating from this video ───────
$profileVisits = \DB::table('profile_visits')
->where('profile_user_id', $video->user_id)
->where('source_video_id', $id)
->count();
// ── New subscribers driven by this video ───────────────────
$newSubscribers = \DB::table('user_subscriptions')
->where('channel_id', $video->user_id)
->where('source_video_id', $id)
->count();
// ── Comments (including replies) ───────────────────────────
$commentsCount = \DB::table('comments')->where('video_id', $id)->count();
// ── Accounts reached (distinct users + distinct guest devices) ──
$reachedUsers = $uniqueViewersAll;
$reachedGuests = \DB::table('video_views')
->where('video_id', $id)
->whereNull('user_id')
->whereNotNull('device_id')
->distinct('device_id')
->count('device_id');
$accountsReached = $reachedUsers + $reachedGuests;
return response()->json([ return response()->json([
'total_views' => $totalViews, 'total_views' => $totalViews,
'unique_viewers' => $uniqueViewers, 'unique_viewers' => $uniqueViewers,
'skip_rate' => $skipRate,
'skipped_views' => $skippedViews,
'skip_threshold' => $skipThreshold,
'save_count' => $saveCount,
'save_rate' => $saveRate,
'profile_visits' => $profileVisits,
'new_subscribers' => $newSubscribers,
'comments_count' => $commentsCount,
'accounts_reached' => $accountsReached,
'reached_users' => $reachedUsers,
'reached_guests' => $reachedGuests,
'top_viewers' => $topViewers, 'top_viewers' => $topViewers,
'guest_views' => $guestViews, 'guest_views' => $guestViews,
'recent_viewers' => $recentViewers, 'recent_viewers' => $recentViewers,
@ -2913,5 +3186,261 @@ class VideoController extends Controller
'records' => $records, 'records' => $records,
]); ]);
} }
// ── Backfill a device fingerprint after the JS computes it ────────────
// On the very first visit the cookie isn't there yet, so the view row is
// inserted with device_hash = NULL. fp.js calls this endpoint a few hundred
// ms later with the freshly-computed hash so we can stamp it onto the row
// that was just inserted (and the future cookie does the rest).
public function identify(Request $request, Video $video)
{
$hash = (string) $request->input('hash', '');
if (! preg_match('/^[a-f0-9]{64}$/', $hash)) {
return response()->json(['ok' => false], 422);
}
$ip = $request->header('CF-Connecting-IP') ?? $request->header('X-Real-IP') ?? $request->ip();
// Update the latest matching view row for this caller (user OR cookie OR IP)
// within a short window so we don't accidentally stamp someone else's row.
$q = \DB::table('video_views')
->where('video_id', $video->id)
->where('watched_at', '>', now()->subMinutes(10))
->whereNull('device_hash');
if (Auth::check()) {
$q->where('user_id', Auth::id());
} else {
$did = $request->cookie('_did');
$q->whereNull('user_id')->where(function ($w) use ($did, $ip) {
if ($did) $w->where('device_id', $did);
$w->orWhere('ip_address', $ip);
});
}
$q->orderByDesc('watched_at')->limit(1)->update(['device_hash' => $hash]);
return response()->json(['ok' => true])
->withCookie(cookie('_fp', $hash, 60 * 24 * 365 * 5));
}
// ── User-agent → coarse device / browser / OS labels ──────────────────
// Shared by every insights endpoint so labels stay consistent.
private function parseUserAgent(?string $ua): array
{
if (! $ua) return ['device' => 'Unknown', 'os' => 'Unknown', 'browser' => 'Unknown'];
// Device family
$device = 'Desktop';
if (preg_match('/iPad/i', $ua)) $device = 'iPad';
elseif (preg_match('/Tablet/i', $ua)) $device = 'Tablet';
elseif (preg_match('/iPhone|iPod/i', $ua)) $device = 'iPhone';
elseif (preg_match('/Android/i', $ua)) $device = preg_match('/Mobile/i', $ua) ? 'Android phone' : 'Android tablet';
elseif (preg_match('/Mobile/i', $ua)) $device = 'Mobile';
// OS
$os = 'Unknown';
if (preg_match('/Windows NT 10/i', $ua)) $os = 'Windows 10/11';
elseif (preg_match('/Windows NT/i', $ua)) $os = 'Windows';
elseif (preg_match('/Mac OS X ([0-9_\.]+)/i', $ua, $m)) $os = 'macOS ' . str_replace('_', '.', $m[1]);
elseif (preg_match('/Android ([0-9\.]+)/i', $ua, $m)) $os = 'Android ' . $m[1];
elseif (preg_match('/iPhone OS ([0-9_]+)/i', $ua, $m)) $os = 'iOS ' . str_replace('_', '.', $m[1]);
elseif (preg_match('/CPU OS ([0-9_]+)/i', $ua, $m)) $os = 'iPadOS ' . str_replace('_', '.', $m[1]);
elseif (preg_match('/Linux/i', $ua)) $os = 'Linux';
elseif (preg_match('/CrOS/i', $ua)) $os = 'ChromeOS';
// Browser — order matters (Edge/Opera before Chrome; Chrome before Safari)
$browser = 'Unknown';
if (preg_match('/Edg\/([0-9\.]+)/i', $ua, $m)) $browser = 'Edge ' . $m[1];
elseif (preg_match('/OPR\/([0-9\.]+)/i', $ua, $m)) $browser = 'Opera ' . $m[1];
elseif (preg_match('/Firefox\/([0-9\.]+)/i', $ua, $m)) $browser = 'Firefox ' . $m[1];
elseif (preg_match('/Chrome\/([0-9\.]+)/i', $ua, $m)) $browser = 'Chrome ' . $m[1];
elseif (preg_match('/Version\/([0-9\.]+).*Safari/i', $ua, $m)) $browser = 'Safari ' . $m[1];
elseif (preg_match('/Safari\/([0-9\.]+)/i', $ua, $m)) $browser = 'Safari ' . $m[1];
return ['device' => $device, 'os' => $os, 'browser' => $browser];
}
// ── Drill-down: detail for one share link ─────────────────────────────
public function insightsShare(Video $video, string $token)
{
if (Auth::id() !== $video->user_id) abort(403);
$share = \DB::table('video_shares')
->where('video_id', $video->id)
->where('token', $token)
->first();
if (! $share) abort(404);
// Sharer profile
$sharer = ['is_guest' => true, 'name' => 'Guest', 'avatar' => null, 'channel' => null];
if ($share->user_id) {
$u = \DB::table('users')->where('id', $share->user_id)->first(['id', 'name', 'avatar', 'username']);
if ($u) {
$sharer = [
'is_guest' => false,
'id' => $u->id,
'name' => $u->name,
'channel' => $u->username,
'avatar' => $u->avatar
? route('media.avatar', $u->avatar)
: 'https://i.pravatar.cc/150?u=' . $u->id,
];
}
}
$accesses = \DB::table('share_accesses')
->where('share_id', $share->id)
->orderByDesc('accessed_at')
->get();
// Country / device / browser / os aggregation
$countries = [];
$deviceCounts = [];
$browserCounts = [];
$osCounts = [];
foreach ($accesses as $a) {
$code = $a->country ?: 'XX';
if (! isset($countries[$code])) {
$countries[$code] = ['code' => $code, 'name' => $a->country_name ?: $code, 'count' => 0];
}
$countries[$code]['count']++;
$p = $this->parseUserAgent($a->user_agent ?? null);
$deviceCounts[$p['device']] = ($deviceCounts[$p['device']] ?? 0) + 1;
$browserCounts[$p['browser']] = ($browserCounts[$p['browser']] ?? 0) + 1;
$osCounts[$p['os']] = ($osCounts[$p['os']] ?? 0) + 1;
}
usort($countries, fn ($a, $b) => $b['count'] <=> $a['count']);
arsort($deviceCounts);
arsort($browserCounts);
arsort($osCounts);
$bucketise = fn (array $counts) => collect($counts)
->map(fn ($cnt, $label) => ['label' => $label, 'count' => $cnt])
->values();
$recent = $accesses->take(50)->map(function ($a) {
$p = $this->parseUserAgent($a->user_agent ?? null);
return [
'at' => $a->accessed_at,
'country' => $a->country,
'device' => $p['device'],
'browser' => $p['browser'],
];
})->values();
return response()->json([
'token' => $share->token,
'created_at' => $share->created_at,
'sharer' => $sharer,
'reach' => $accesses->count(),
'countries' => array_values($countries),
'devices' => $bucketise($deviceCounts),
'browsers' => $bucketise($browserCounts),
'os' => $bucketise($osCounts),
'recent' => $recent,
]);
}
// ── Drill-down: one viewer's full view history on this video ─────────
// $who is either "u:{userId}" for a registered user or "g:{ip}" for a guest.
public function insightsViewer(Video $video, string $who)
{
if (Auth::id() !== $video->user_id) abort(403);
$who = substr($who, 0, 80);
$isUser = str_starts_with($who, 'u:');
$key = substr($who, 2);
$q = \DB::table('video_views')->where('video_id', $video->id);
if ($isUser) {
$q->where('user_id', (int) $key);
} else {
// Match across all three guest signals: fingerprint hash > device cookie > legacy IP
$q->whereNull('user_id')->where(function ($w) use ($key) {
$w->where('device_hash', $key)
->orWhere('device_id', $key)
->orWhere('ip_address', $key);
});
}
$rows = $q->orderByDesc('watched_at')
->get(['watched_at', 'country', 'country_name', 'ip_address', 'device_id', 'device_hash', 'user_agent']);
if ($rows->isEmpty()) abort(404);
// Build identity block
$identity = [
'is_guest' => ! $isUser,
'name' => 'Guest',
'avatar' => null,
'channel' => null,
];
if ($isUser) {
$user = \App\Models\User::find((int) $key);
if ($user) {
$identity = [
'is_guest' => false,
'id' => $user->id,
'name' => $user->name,
'channel' => $user->username,
'avatar' => $user->avatar
? route('media.avatar', $user->avatar)
: 'https://i.pravatar.cc/150?u=' . $user->id,
];
}
} else {
$identity['name'] = 'Guest (' . $key . ')';
$identity['avatar'] = 'https://i.pravatar.cc/150?u=guest-' . $key;
}
// Country aggregation
$countries = [];
foreach ($rows as $r) {
$code = $r->country ?: 'XX';
if (! isset($countries[$code])) {
$countries[$code] = ['code' => $code, 'name' => $r->country_name ?: $code, 'count' => 0];
}
$countries[$code]['count']++;
}
usort($countries, fn ($a, $b) => $b['count'] <=> $a['count']);
// Device / browser / OS — parse on the fly from user_agent
$deviceCounts = [];
$browserCounts = [];
$osCounts = [];
foreach ($rows as $r) {
$p = $this->parseUserAgent($r->user_agent);
$deviceCounts[$p['device']] = ($deviceCounts[$p['device']] ?? 0) + 1;
$browserCounts[$p['browser']] = ($browserCounts[$p['browser']] ?? 0) + 1;
$osCounts[$p['os']] = ($osCounts[$p['os']] ?? 0) + 1;
}
arsort($deviceCounts);
arsort($browserCounts);
arsort($osCounts);
$bucketise = fn (array $counts) => collect($counts)
->map(fn ($cnt, $label) => ['label' => $label, 'count' => $cnt])
->values();
// Recent timestamps (cap 50 for the modal)
$recent = $rows->take(50)->map(fn ($r) => [
'at' => $r->watched_at,
'country' => $r->country,
]);
return response()->json([
'identity' => $identity,
'total' => $rows->count(),
'first_at' => $rows->last()->watched_at,
'last_at' => $rows->first()->watched_at,
'countries' => $countries,
'devices' => $bucketise($deviceCounts),
'browsers' => $bucketise($browserCounts),
'os' => $bucketise($osCounts),
'recent' => $recent,
]);
}
} }

View File

@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProfileVisit extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'profile_user_id',
'visitor_user_id',
'device_id',
'source_video_id',
'ip_address',
'country',
'created_at',
];
protected $casts = [
'created_at' => 'datetime',
];
public function profileUser(): BelongsTo
{
return $this->belongsTo(User::class, 'profile_user_id');
}
public function visitor(): BelongsTo
{
return $this->belongsTo(User::class, 'visitor_user_id');
}
public function sourceVideo(): BelongsTo
{
return $this->belongsTo(Video::class, 'source_video_id');
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SportsMatch extends Model
{
use HasFactory;
protected $table = 'sports_matches';
protected $fillable = [
'video_id', 'user_id', 'status',
'sport', 'title', 'event_name', 'match_type',
'match_date', 'match_time',
'participant1_name', 'participant2_name', 'referee_name', 'venue_name',
'competition', 'participants', 'media', 'officials',
'venue', 'result', 'segments', 'statistics', 'reviews',
];
protected $casts = [
'match_date' => 'date',
'competition' => 'array',
'participants' => 'array',
'media' => 'array',
'officials' => 'array',
'venue' => 'array',
'result' => 'array',
'segments' => 'array',
'statistics' => 'array',
'reviews' => 'array',
];
public function video(): BelongsTo
{
return $this->belongsTo(Video::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isDraft(): bool
{
return $this->status === 'draft';
}
}

View File

@ -205,7 +205,7 @@ class User extends Authenticatable implements MustVerifyEmail
'user_subscriptions', 'user_subscriptions',
'channel_id', 'channel_id',
'subscriber_id' 'subscriber_id'
)->withPivot('created_at'); )->withPivot(['created_at', 'source_video_id']);
} }
// Channels this user subscribes to // Channels this user subscribes to
@ -216,7 +216,7 @@ class User extends Authenticatable implements MustVerifyEmail
'user_subscriptions', 'user_subscriptions',
'subscriber_id', 'subscriber_id',
'channel_id' 'channel_id'
)->withPivot('created_at'); )->withPivot(['created_at', 'source_video_id']);
} }
public function isSubscribedTo(User $channel): bool public function isSubscribedTo(User $channel): bool

View File

@ -0,0 +1,58 @@
<?php
namespace App\Notifications;
use App\Models\User;
use App\Models\Video;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class VideoSharedWithUser extends Notification
{
public function __construct(
public Video $video,
public User $sharer,
public ?string $message,
public string $shareUrl,
) {}
/**
* A direct, person-to-person share is transactional always deliver both
* the in-app notification and the email (not gated by subscription prefs).
*/
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
return [
'type' => 'video_shared',
'video_id' => $this->video->id,
'video_route_key' => $this->video->getRouteKey(),
'video_title' => $this->video->title,
'video_thumbnail' => $this->video->thumbnail,
'actor_id' => $this->sharer->id,
'actor_name' => $this->sharer->name,
'actor_avatar' => $this->sharer->avatar_url,
'message' => $this->message,
];
}
public function toMail(object $notifiable): MailMessage
{
$isSong = $this->video->isAudioOnly() || $this->video->type === 'music';
$noun = $isSong ? 'song' : ($this->video->type === 'match' ? 'match' : 'video');
return (new MailMessage)
->subject($this->sharer->name . ' shared a ' . $noun . ' with you')
->view('emails.video-shared', [
'video' => $this->video,
'sender' => $this->sharer,
'shareUrl' => $this->shareUrl,
'personalMessage' => $this->message,
'shareTitle' => $this->video->title,
]);
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Support;
use App\Models\Video;
use App\Services\NasSyncService;
/**
* Resolves a video's thumbnail to a local absolute file path so it can be
* embedded inline (CID) in transactional emails.
*
* Remote mail clients (notably Gmail's image proxy) cannot reliably fetch the
* dynamic /media/thumbnails route it sits behind Cloudflare and PHP, so the
* proxy may be challenged or time out, leaving a broken image. Embedding the
* bytes directly in the message removes that external dependency entirely.
*/
class EmailThumbnail
{
public static function localPath(?Video $video): ?string
{
if (! $video || ! $video->thumbnail) {
return null;
}
$thumb = $video->thumbnail;
$nas = app(NasSyncService::class);
// NAS-mirrored path format: "users/{slug}/videos/{song}/…"
if (str_starts_with($thumb, 'users/')) {
$local = storage_path('app/' . $thumb);
if (file_exists($local)) {
return $local;
}
@mkdir(dirname($local), 0755, true);
// The DB path mirrors the NAS path exactly — try it directly first.
if ($nas->ensureLocalAsset($local, $thumb)) {
return $local;
}
// Extension may differ on NAS (e.g. canonical thumb.webp).
$dir = $nas->resolveVideoDir($video);
$ext = pathinfo($thumb, PATHINFO_EXTENSION) ?: 'jpg';
foreach (["thumb.webp", "thumb.{$ext}"] as $nasFile) {
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) {
return $local;
}
}
return file_exists($local) ? $local : null;
}
// Legacy flat filename format.
$local = storage_path('app/public/thumbnails/' . $thumb);
return file_exists($local) ? $local : null;
}
}

View File

@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('sports_matches', function (Blueprint $table) {
$table->id();
// A match always belongs to a video (type=match) and its creator.
$table->foreignId('video_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
// draft | published — lets uploaders save basic info and finish later.
$table->string('status', 20)->default('draft')->index();
// ── Basic fields (shown first; only title is required for a draft) ───
$table->string('title'); // match title (required)
$table->string('event_name')->nullable();
$table->date('match_date')->nullable();
$table->time('match_time')->nullable();
$table->string('participant1_name')->nullable();
$table->string('participant2_name')->nullable();
$table->string('referee_name')->nullable();
// ── Fields revealed later when editing ──────────────────────────────
$table->string('sport', 80)->nullable()->index();
$table->string('match_type', 80)->nullable();
$table->string('venue_name')->nullable();
// ── Optional groups (progressive disclosure) stored as JSON ──────────
$table->json('competition')->nullable(); // name, type, stage, season, organizer, championship_name
$table->json('participants')->nullable(); // p1_*/p2_* details, weight_class, gender_division, level, extra[]
$table->json('media')->nullable(); // image paths + caption, alt, credit, public
$table->json('officials')->nullable(); // [{ role, name, photo }]
$table->json('venue')->nullable(); // address, city, country, lat, lng, notes
$table->json('result')->nullable(); // winner, outcome_type, final_result, rank, notes
$table->json('segments')->nullable(); // [{ type, number, score, winner, notes }]
$table->json('statistics')->nullable(); // [{ name, value, owner, notes }]
$table->json('reviews')->nullable(); // review_type, requested_by, review_result, source_url, verification_notes, admin_notes
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('sports_matches');
}
};

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('video_views', function (Blueprint $table) {
$table->string('user_agent', 512)->nullable()->after('country_name');
});
}
public function down(): void
{
Schema::table('video_views', function (Blueprint $table) {
$table->dropColumn('user_agent');
});
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('video_views', function (Blueprint $table) {
$table->string('device_id', 64)->nullable()->after('user_agent');
$table->index(['video_id', 'device_id']);
});
}
public function down(): void
{
Schema::table('video_views', function (Blueprint $table) {
$table->dropIndex(['video_id', 'device_id']);
$table->dropColumn('device_id');
});
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('video_downloads', function (Blueprint $table) {
$table->string('user_agent', 512)->nullable()->after('country_name');
});
Schema::table('share_accesses', function (Blueprint $table) {
$table->string('user_agent', 512)->nullable()->after('country_name');
});
}
public function down(): void
{
Schema::table('video_downloads', function (Blueprint $table) {
$table->dropColumn('user_agent');
});
Schema::table('share_accesses', function (Blueprint $table) {
$table->dropColumn('user_agent');
});
}
};

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('video_views', function (Blueprint $table) {
$table->string('device_hash', 64)->nullable()->after('device_id');
$table->index(['video_id', 'device_hash']);
});
Schema::table('video_downloads', function (Blueprint $table) {
$table->string('device_hash', 64)->nullable()->after('user_agent');
});
Schema::table('share_accesses', function (Blueprint $table) {
$table->string('device_hash', 64)->nullable()->after('user_agent');
});
}
public function down(): void
{
Schema::table('video_views', function (Blueprint $table) {
$table->dropIndex(['video_id', 'device_hash']);
$table->dropColumn('device_hash');
});
Schema::table('video_downloads', function (Blueprint $table) {
$table->dropColumn('device_hash');
});
Schema::table('share_accesses', function (Blueprint $table) {
$table->dropColumn('device_hash');
});
}
};

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('video_views', function (Blueprint $table) {
$table->unsignedInteger('watched_seconds')->default(0)->after('country_name');
$table->boolean('completed')->default(false)->after('watched_seconds');
});
}
public function down(): void
{
Schema::table('video_views', function (Blueprint $table) {
$table->dropColumn(['watched_seconds', 'completed']);
});
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('profile_visits', function (Blueprint $table) {
$table->id();
$table->foreignId('profile_user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('visitor_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('device_id', 64)->nullable();
$table->foreignId('source_video_id')->nullable()->constrained('videos')->nullOnDelete();
$table->string('ip_address', 45)->nullable();
$table->string('country', 2)->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index(['profile_user_id', 'created_at']);
$table->index('source_video_id');
});
}
public function down(): void
{
Schema::dropIfExists('profile_visits');
}
};

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('user_subscriptions', function (Blueprint $table) {
$table->foreignId('source_video_id')->nullable()->after('channel_id')
->constrained('videos')->nullOnDelete();
$table->index('source_video_id');
});
}
public function down(): void
{
Schema::table('user_subscriptions', function (Blueprint $table) {
$table->dropForeign(['source_video_id']);
$table->dropIndex(['source_video_id']);
$table->dropColumn('source_video_id');
});
}
};

281
public/fp.js Normal file
View File

@ -0,0 +1,281 @@
/* TAKEONE device fingerprint strongest guest identifier a browser will let us compute.
*
* Combines ~15 signals (canvas, WebGL, audio, fonts, screen, locale, hardware) into a
* stable 64-char hash. Cached in localStorage AND mirrored to a `_fp` cookie so the
* server sees it on every request. Falls back gracefully if any single signal fails
* (private browsing, locked-down browsers, no GPU, etc).
*
* NOT a MAC address browsers cannot expose MAC. This is the closest practical equivalent.
*/
(function () {
'use strict';
var STORAGE_KEY = '_takeone_fp';
var COOKIE_KEY = '_fp';
var COOKIE_MAX = 60 * 60 * 24 * 365 * 5; // 5 years
// ── Set cookie helper ────────────────────────────────────────────
function setCookie(name, val) {
try {
document.cookie =
name + '=' + encodeURIComponent(val) +
'; Max-Age=' + COOKIE_MAX +
'; Path=/; SameSite=Lax' +
(location.protocol === 'https:' ? '; Secure' : '');
} catch (e) { /* sandboxed iframe etc. */ }
}
function readCookie(name) {
var m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
return m ? decodeURIComponent(m[1]) : null;
}
// ── SHA-256 (Web Crypto where available, JS fallback otherwise) ──
function sha256(str) {
if (window.crypto && window.crypto.subtle && window.TextEncoder) {
return window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(str))
.then(function (buf) {
return Array.from(new Uint8Array(buf))
.map(function (b) { return b.toString(16).padStart(2, '0'); })
.join('');
});
}
// Minimal JS fallback (only used in ancient browsers)
return Promise.resolve(jsSha256(str));
}
// Tiny pure-JS SHA-256 (used only if subtleCrypto missing). Adapted from public-domain refs.
function jsSha256(ascii) {
function rightRotate(value, amount) { return (value >>> amount) | (value << (32 - amount)); }
var mathPow = Math.pow, maxWord = mathPow(2, 32), result = '';
var words = [], asciiBitLength = ascii.length * 8;
var hash = jsSha256.h = jsSha256.h || [], k = jsSha256.k = jsSha256.k || [], primeCounter = k.length;
var isComposite = {};
for (var candidate = 2; primeCounter < 64; candidate++) {
if (!isComposite[candidate]) {
for (var i = 0; i < 313; i += candidate) isComposite[i] = candidate;
hash[primeCounter] = (mathPow(candidate, .5) * maxWord) | 0;
k[primeCounter++] = (mathPow(candidate, 1 / 3) * maxWord) | 0;
}
}
ascii += '\x80';
while (ascii.length % 64 - 56) ascii += '\x00';
for (i = 0; i < ascii.length; i++) {
var j = ascii.charCodeAt(i);
if (j >> 8) return '';
words[i >> 2] |= j << ((3 - i) % 4) * 8;
}
words[words.length] = ((asciiBitLength / maxWord) | 0);
words[words.length] = (asciiBitLength);
for (j = 0; j < words.length;) {
var w = words.slice(j, j += 16), oldHash = hash;
hash = hash.slice(0, 8);
for (i = 0; i < 64; i++) {
var w15 = w[i - 15], w2 = w[i - 2];
var a = hash[0], e = hash[4];
var temp1 = hash[7]
+ (rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25))
+ ((e & hash[5]) ^ ((~e) & hash[6]))
+ k[i]
+ (w[i] = (i < 16) ? w[i] : (
w[i - 16]
+ (rightRotate(w15, 7) ^ rightRotate(w15, 18) ^ (w15 >>> 3))
+ w[i - 7]
+ (rightRotate(w2, 17) ^ rightRotate(w2, 19) ^ (w2 >>> 10))
) | 0);
var temp2 = (rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22))
+ ((a & hash[1]) ^ (a & hash[2]) ^ (hash[1] & hash[2]));
hash = [(temp1 + temp2) | 0].concat(hash);
hash[4] = (hash[4] + temp1) | 0;
}
for (i = 0; i < 8; i++) hash[i] = (hash[i] + oldHash[i]) | 0;
}
for (i = 0; i < 8; i++) {
for (j = 3; j + 1; j--) {
var b = (hash[i] >> (j * 8)) & 255;
result += ((b < 16) ? 0 : '') + b.toString(16);
}
}
return result;
}
// ── Signal probes ────────────────────────────────────────────────
function canvasFingerprint() {
try {
var c = document.createElement('canvas');
c.width = 280; c.height = 60;
var ctx = c.getContext('2d');
ctx.textBaseline = 'top';
ctx.font = "14px 'Arial'";
ctx.fillStyle = '#f60'; ctx.fillRect(125, 1, 62, 20);
ctx.fillStyle = '#069'; ctx.fillText('TAKEONE,fp 🎬', 2, 15);
ctx.fillStyle = 'rgba(102,204,0,0.7)';
ctx.fillText('TAKEONE,fp 🎬', 4, 17);
// Curved shape exposes GPU sub-pixel rounding differences
ctx.beginPath();
ctx.arc(50, 30, 20, 0, Math.PI * 2, true);
ctx.closePath();
ctx.fillStyle = 'rgb(255,0,255)';
ctx.fill();
return c.toDataURL();
} catch (e) { return 'canvas:err'; }
}
function webglFingerprint() {
try {
var c = document.createElement('canvas');
var gl = c.getContext('webgl') || c.getContext('experimental-webgl');
if (!gl) return 'webgl:none';
var info = gl.getExtension('WEBGL_debug_renderer_info');
var vendor = info ? gl.getParameter(info.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR);
var renderer = info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
var max = gl.getParameter(gl.MAX_TEXTURE_SIZE);
var ext = (gl.getSupportedExtensions() || []).sort().join(',');
return vendor + '|' + renderer + '|' + max + '|' + ext;
} catch (e) { return 'webgl:err'; }
}
function audioFingerprint() {
return new Promise(function (resolve) {
try {
var Ctx = window.OfflineAudioContext || window.webkitOfflineAudioContext;
if (!Ctx) return resolve('audio:none');
var ctx = new Ctx(1, 44100, 44100);
var osc = ctx.createOscillator();
osc.type = 'triangle'; osc.frequency.value = 10000;
var compressor = ctx.createDynamicsCompressor();
['threshold','knee','ratio','attack','release'].forEach(function (p) {
if (compressor[p] && compressor[p].setValueAtTime) {
compressor[p].setValueAtTime(({
threshold:-50, knee:40, ratio:12, attack:0, release:.25
})[p], ctx.currentTime);
}
});
osc.connect(compressor); compressor.connect(ctx.destination);
osc.start(0); ctx.startRendering();
var done = false;
ctx.oncomplete = function (e) {
if (done) return; done = true;
var sum = 0, d = e.renderedBuffer.getChannelData(0);
for (var i = 4500; i < 5000; i++) sum += Math.abs(d[i]);
resolve(sum.toString());
};
setTimeout(function () { if (!done) { done = true; resolve('audio:timeout'); } }, 1500);
} catch (e) { resolve('audio:err'); }
});
}
function fontsFingerprint() {
// Quick probe: list which of N common fonts the OS actually has installed,
// detected by measuring text width against fallback baselines.
try {
var baseFonts = ['monospace','sans-serif','serif'];
var testString = 'mmmmmmmmmmlli';
var testSize = '72px';
var fonts = [
'Andale Mono','Arial','Arial Black','Arial Hebrew','Arial MT','Arial Narrow','Arial Rounded MT Bold','Arial Unicode MS',
'Bitstream Vera Sans Mono','Book Antiqua','Bookman Old Style','Calibri','Cambria','Cambria Math','Century','Century Gothic','Century Schoolbook',
'Comic Sans','Comic Sans MS','Consolas','Courier','Courier New','Geneva','Georgia','Helvetica','Helvetica Neue','Impact',
'Lucida Bright','Lucida Calligraphy','Lucida Console','Lucida Fax','LUCIDA GRANDE','Lucida Handwriting','Lucida Sans','Lucida Sans Typewriter','Lucida Sans Unicode',
'Microsoft Sans Serif','Monaco','Monotype Corsiva','MS Gothic','MS Outlook','MS PGothic','MS Reference Sans Serif','MS Sans Serif','MS Serif','MYRIAD','MYRIAD PRO',
'Palatino','Palatino Linotype','Segoe Print','Segoe Script','Segoe UI','Segoe UI Light','Segoe UI Semibold','Segoe UI Symbol','Tahoma','Times','Times New Roman','Times New Roman PS','Trebuchet MS','Verdana','Wingdings','Wingdings 2','Wingdings 3'
];
var body = document.body || document.documentElement;
var span = document.createElement('span');
span.style.position = 'absolute'; span.style.left = '-9999px';
span.style.fontSize = testSize; span.textContent = testString;
body.appendChild(span);
var defaults = {};
baseFonts.forEach(function (b) {
span.style.fontFamily = b;
defaults[b] = { w: span.offsetWidth, h: span.offsetHeight };
});
var detected = [];
fonts.forEach(function (f) {
var diff = false;
for (var i = 0; i < baseFonts.length; i++) {
span.style.fontFamily = "'" + f + "'," + baseFonts[i];
if (span.offsetWidth !== defaults[baseFonts[i]].w ||
span.offsetHeight !== defaults[baseFonts[i]].h) { diff = true; break; }
}
if (diff) detected.push(f);
});
body.removeChild(span);
return detected.join(',');
} catch (e) { return 'fonts:err'; }
}
function collectSignals() {
var nav = window.navigator || {};
var scr = window.screen || {};
return Promise.all([audioFingerprint()]).then(function (results) {
return {
canvas : canvasFingerprint(),
webgl : webglFingerprint(),
audio : results[0],
fonts : fontsFingerprint(),
screen : (scr.width || 0) + 'x' + (scr.height || 0) + 'x' + (scr.colorDepth || 0),
dpr : window.devicePixelRatio || 1,
platform : nav.platform || '',
cpu : nav.hardwareConcurrency || 0,
mem : nav.deviceMemory || 0,
tz : (Intl && Intl.DateTimeFormat) ? Intl.DateTimeFormat().resolvedOptions().timeZone : '',
langs : (nav.languages || []).join(','),
touch : (nav.maxTouchPoints || 0) + (('ontouchstart' in window) ? 'T' : ''),
pdfviewer : nav.pdfViewerEnabled ? '1' : '0',
userAgent : nav.userAgent || ''
};
});
}
// ── Build / cache the hash ───────────────────────────────────────
function ensureFingerprint() {
// 1) localStorage first (fastest path)
var cached = null;
try { cached = localStorage.getItem(STORAGE_KEY); } catch (e) {}
// 2) cookie next (survives some localStorage wipes)
if (!cached) cached = readCookie(COOKIE_KEY);
if (cached && /^[a-f0-9]{64}$/.test(cached)) {
setCookie(COOKIE_KEY, cached); // refresh expiry on each visit
window._takeoneFp = cached;
return Promise.resolve(cached);
}
return collectSignals().then(function (sig) {
var serialised = JSON.stringify(sig);
return sha256(serialised).then(function (hash) {
try { localStorage.setItem(STORAGE_KEY, hash); } catch (e) {}
setCookie(COOKIE_KEY, hash);
window._takeoneFp = hash;
// Backfill the view row that the server just inserted from the cookie-less first visit
try {
var pathMatch = location.pathname.match(/^\/videos\/([^\/?#]+)/);
if (pathMatch) {
var token = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
fetch('/videos/' + pathMatch[1] + '/identify', {
method : 'POST',
headers : {
'Content-Type' : 'application/json',
'X-CSRF-TOKEN' : token,
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin',
body: JSON.stringify({ hash: hash })
}).catch(function () { /* best-effort */ });
}
} catch (e) {}
return hash;
});
});
}
// Kick off ASAP — but never block paint
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ensureFingerprint, { once: true });
} else {
ensureFingerprint();
}
})();

View File

@ -0,0 +1,41 @@
@props([
'video',
'tag' => 'button', // 'button' or 'a'
])
{{-- Canonical share trigger. Opens the single <x-share-modal/> with the full
argument set (link, title, tracked-share, email, members) so every share
entry point offers the same options. Pass a slot for custom inner content;
otherwise it renders a default "Share" label. Extra attributes (class,
style, ) are forwarded to the element. --}}
@php
$auth = auth()->check();
$emailUrl = $auth ? route('videos.shareEmail', $video) : '';
$membersUrl = $auth ? route('videos.shareMembers', $video) : '';
$onclick = sprintf(
"openShareModal('%s','%s','%s','%s','%s')",
e($video->share_url),
addslashes($video->title),
route('videos.recordShare', $video),
$emailUrl,
$membersUrl
);
@endphp
@if($tag === 'a')
<a href="javascript:void(0)" {{ $attributes }} onclick="{{ $onclick }}">
@if($slot->isEmpty())
<i class="bi bi-share"></i> Share
@else
{{ $slot }}
@endif
</a>
@else
<button type="button" {{ $attributes }} onclick="{{ $onclick }}">
@if($slot->isEmpty())
<i class="bi bi-share"></i> <span>Share</span>
@else
{{ $slot }}
@endif
</button>
@endif

View File

@ -46,6 +46,10 @@
style="display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #6b7280; color: white; text-decoration: none;"> style="display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #6b7280; color: white; text-decoration: none;">
<i class="bi bi-envelope-fill"></i> <i class="bi bi-envelope-fill"></i>
</a> </a>
<a href="#" id="shareMembersBtn" class="social-share-btn" role="button" title="Share with members"
style="display: none; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #e61e1e; color: white; text-decoration: none;">
<i class="bi bi-people-fill"></i>
</a>
</div> </div>
</div> </div>
@ -63,6 +67,25 @@
</button> </button>
<div id="shareEmailStatus" style="display:none; margin-top:10px; font-size:13px; text-align:center;"></div> <div id="shareEmailStatus" style="display:none; margin-top:10px; font-size:13px; text-align:center;"></div>
</div> </div>
{{-- Share with members --}}
<div id="shareMembersSection" style="display:none; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-color);">
<p style="color: var(--text-secondary); font-size: 13px; margin-bottom: 12px;">
<i class="bi bi-people me-1"></i> Search members and share they get a notification and an email:
</p>
<div style="position: relative;">
<input type="text" id="shareMemberSearch" class="form-control" placeholder="Search members by name…" autocomplete="off"
style="background: var(--bg-primary); border: 1px solid var(--border-color); color: var(--text-primary); padding: 10px 14px; border-radius: 8px;">
<div id="shareMemberResults" class="share-member-results" style="display:none;"></div>
</div>
<div id="shareMemberChips" class="share-member-chips"></div>
<textarea id="shareMemberMsg" class="form-control" rows="2" maxlength="500" placeholder="Add a short message (optional)"
style="background: var(--bg-primary); border: 1px solid var(--border-color); color: var(--text-primary); padding: 10px 14px; border-radius: 8px; margin-top: 10px; resize: vertical;"></textarea>
<button type="button" id="shareMemberSend" class="action-btn action-btn-primary" style="width:100%; justify-content:center; margin-top:10px;">
<i class="bi bi-send"></i> <span>Send to members</span>
</button>
<div id="shareMemberStatus" style="display:none; margin-top:10px; font-size:13px; text-align:center;"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -80,6 +103,31 @@
background: #4caf50 !important; background: #4caf50 !important;
border-color: #4caf50 !important; border-color: #4caf50 !important;
} }
/* Member share picker */
.share-member-results {
position: absolute; left: 0; right: 0; top: calc(100% + 4px); z-index: 20;
background: var(--bg-secondary); border: 1px solid var(--border-color);
border-radius: 8px; box-shadow: 0 12px 32px rgba(0,0,0,.5);
max-height: 240px; overflow-y: auto; padding: 4px;
}
.share-member-opt {
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
border-radius: 6px; cursor: pointer;
}
.share-member-opt:hover { background: rgba(255,255,255,.06); }
.share-member-opt img { width: 30px; height: 30px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.share-member-opt .smo-name { color: var(--text-primary); font-size: 14px; font-weight: 600; line-height: 1.2; }
.share-member-opt .smo-handle { color: var(--text-secondary); font-size: 12px; }
.share-member-empty { padding: 10px; color: var(--text-secondary); font-size: 13px; text-align: center; }
.share-member-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.share-member-chip {
display: inline-flex; align-items: center; gap: 6px; padding: 4px 6px 4px 4px;
background: rgba(230,30,30,.12); border: 1px solid rgba(230,30,30,.3);
border-radius: 20px; color: var(--text-primary); font-size: 13px;
}
.share-member-chip img { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; }
.share-member-chip button { background: none; border: none; color: var(--text-secondary); cursor: pointer; padding: 0 2px; line-height: 1; }
.share-member-chip button:hover { color: #e61e1e; }
</style> </style>
<script> <script>
@ -90,10 +138,11 @@ function _getLatestCsrf() {
} }
// Set per-open so the "Send by email" form knows the endpoint + which version to send. // Set per-open so the "Send by email" form knows the endpoint + which version to send.
var _shareEmailUrl = ''; var _shareEmailUrl = '';
var _shareTrack = ''; var _shareMembersUrl = '';
var _shareTrack = '';
async function openShareModal(videoUrl, videoTitle, recordUrl, emailUrl) { async function openShareModal(videoUrl, videoTitle, recordUrl, emailUrl, membersUrl) {
var csrfToken = _getLatestCsrf(); var csrfToken = _getLatestCsrf();
var shareUrl = videoUrl; var shareUrl = videoUrl;
@ -101,8 +150,9 @@ async function openShareModal(videoUrl, videoTitle, recordUrl, emailUrl) {
// share link replaces shareUrl below, so we re-attach it afterwards. // share link replaces shareUrl below, so we re-attach it afterwards.
var trackParam = ''; var trackParam = '';
try { trackParam = (new URL(videoUrl, window.location.origin)).searchParams.get('track') || ''; } catch (e) {} try { trackParam = (new URL(videoUrl, window.location.origin)).searchParams.get('track') || ''; } catch (e) {}
_shareEmailUrl = emailUrl || ''; _shareEmailUrl = emailUrl || '';
_shareTrack = trackParam; _shareMembersUrl = membersUrl || '';
_shareTrack = trackParam;
// Obtain a unique tracked share link from the server // Obtain a unique tracked share link from the server
if (recordUrl) { if (recordUrl) {
@ -166,6 +216,13 @@ function _populateShareModal(shareUrl, videoTitle) {
var msg = document.getElementById('shareEmailMsg'); if (msg) msg.value = ''; var msg = document.getElementById('shareEmailMsg'); if (msg) msg.value = '';
var st = document.getElementById('shareEmailStatus'); if (st) st.style.display = 'none'; var st = document.getElementById('shareEmailStatus'); if (st) st.style.display = 'none';
} }
// Reset the members picker; the people button only works when an endpoint was provided.
var membersBtn = document.getElementById('shareMembersBtn');
var membersSec = document.getElementById('shareMembersSection');
if (membersBtn) membersBtn.style.display = _shareMembersUrl ? 'flex' : 'none';
if (typeof _resetMembersPicker === 'function') _resetMembersPicker();
if (membersSec) membersSec.style.display = 'none';
} }
function _copyToClipboard(text) { function _copyToClipboard(text) {
@ -286,6 +343,156 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
} }
// ── Share with members ─────────────────────────────────────────
var membersBtn = document.getElementById('shareMembersBtn');
var membersSec = document.getElementById('shareMembersSection');
var memberSearch = document.getElementById('shareMemberSearch');
var memberResults = document.getElementById('shareMemberResults');
var memberChips = document.getElementById('shareMemberChips');
var memberMsg = document.getElementById('shareMemberMsg');
var memberSend = document.getElementById('shareMemberSend');
var memberStatus = document.getElementById('shareMemberStatus');
var _selectedMembers = [];
var _searchTimer = null;
function escAttr(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]; }); }
window._resetMembersPicker = function () {
_selectedMembers = [];
if (memberSearch) memberSearch.value = '';
if (memberMsg) memberMsg.value = '';
if (memberResults) { memberResults.style.display = 'none'; memberResults.innerHTML = ''; }
if (memberStatus) memberStatus.style.display = 'none';
_renderChips();
};
function _showMemberStatus(msg, color) {
if (!memberStatus) return;
memberStatus.textContent = msg; memberStatus.style.color = color; memberStatus.style.display = 'block';
}
function _renderChips() {
if (!memberChips) return;
memberChips.innerHTML = _selectedMembers.map(function (u) {
return '<span class="share-member-chip">'
+ '<img src="' + escAttr(u.avatar) + '" alt="">'
+ escAttr(u.name)
+ '<button type="button" data-id="' + u.id + '" aria-label="Remove"><i class="bi bi-x-lg"></i></button>'
+ '</span>';
}).join('');
memberChips.querySelectorAll('button[data-id]').forEach(function (b) {
b.addEventListener('click', function () {
_selectedMembers = _selectedMembers.filter(function (u) { return String(u.id) !== b.getAttribute('data-id'); });
_renderChips();
});
});
}
function _renderResults(users) {
if (!memberResults) return;
var available = users.filter(function (u) { return !_selectedMembers.some(function (s) { return s.id === u.id; }); });
if (!available.length) {
memberResults.innerHTML = '<div class="share-member-empty">No members found</div>';
} else {
memberResults.innerHTML = available.map(function (u) {
return '<div class="share-member-opt" data-id="' + u.id + '">'
+ '<img src="' + escAttr(u.avatar) + '" alt="">'
+ '<div><div class="smo-name">' + escAttr(u.name) + '</div>'
+ '<div class="smo-handle">@' + escAttr(u.channel) + '</div></div>'
+ '</div>';
}).join('');
memberResults.querySelectorAll('.share-member-opt').forEach(function (el) {
el.addEventListener('click', function () {
var id = parseInt(el.getAttribute('data-id'), 10);
var u = available.find(function (x) { return x.id === id; });
if (u && !_selectedMembers.some(function (s) { return s.id === u.id; })) {
_selectedMembers.push(u);
_renderChips();
}
memberSearch.value = '';
memberResults.style.display = 'none';
memberSearch.focus();
});
});
}
memberResults.style.display = 'block';
}
if (membersBtn && membersSec) {
membersBtn.addEventListener('click', function (e) {
e.preventDefault();
membersSec.style.display = (membersSec.style.display === 'none' || !membersSec.style.display) ? 'block' : 'none';
if (membersSec.style.display === 'block' && memberSearch) memberSearch.focus();
});
}
if (memberSearch) {
memberSearch.addEventListener('input', function () {
var q = memberSearch.value.trim();
clearTimeout(_searchTimer);
if (!q) { memberResults.style.display = 'none'; return; }
_searchTimer = setTimeout(function () {
fetch('{{ route('users.search') }}?q=' + encodeURIComponent(q), {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
})
.then(function (r) { return r.json(); })
.then(function (data) { _renderResults(data.users || []); })
.catch(function () {});
}, 250);
});
document.addEventListener('click', function (e) {
if (memberResults && !memberResults.contains(e.target) && e.target !== memberSearch) {
memberResults.style.display = 'none';
}
});
}
if (memberSend) {
memberSend.addEventListener('click', function () {
if (!_shareMembersUrl) { _showMemberStatus('Member sharing is unavailable here.', '#ef4444'); return; }
if (!_selectedMembers.length) { _showMemberStatus('Select at least one member.', '#ef4444'); return; }
memberSend.disabled = true;
var _orig = memberSend.innerHTML;
memberSend.innerHTML = '<i class="bi bi-hourglass-split"></i> <span>Sending…</span>';
_showMemberStatus('Sending…', 'var(--text-secondary)');
var body = new URLSearchParams();
body.append('_token', '{{ csrf_token() }}');
body.append('message', (memberMsg && memberMsg.value) || '');
_selectedMembers.forEach(function (u) { body.append('user_ids[]', u.id); });
fetch(_shareMembersUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': _getLatestCsrf(),
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
},
body: body.toString(),
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (res) {
memberSend.disabled = false;
memberSend.innerHTML = _orig;
if (res.ok && res.d.success) {
_showMemberStatus('Shared with ' + (res.d.count || _selectedMembers.length) + ' member(s) — notification + email sent.', '#4caf50');
_selectedMembers = []; _renderChips();
if (memberMsg) memberMsg.value = '';
setTimeout(function () { if (membersSec) membersSec.style.display = 'none'; }, 2000);
} else {
_showMemberStatus((res.d && res.d.error) || 'Could not share. Please try again.', '#ef4444');
}
})
.catch(function () {
memberSend.disabled = false;
memberSend.innerHTML = _orig;
_showMemberStatus('Network error — please try again.', '#ef4444');
});
});
}
}); });
</script> </script>

View File

@ -0,0 +1,12 @@
@props(['name', 'label' => 'Upload & crop'])
{{-- A cropper-backed image field. The matching <x-image-cropper id="smc_{name}">
(defined once in the sports-match modal) writes the cropped file onto the
hidden input below and updates #sm-prev-{name}. --}}
<div {{ $attributes->merge(['class' => 'sm-img-field']) }} data-img="{{ $name }}">
<input type="file" name="{{ $name }}" id="sm-file-{{ $name }}" accept=".jpg,.jpeg,.png,.webp" hidden>
<button type="button" class="sm-img-drop" onclick="smOpenCropper('smc_{{ $name }}')">
<img class="sm-img-preview d-none" id="sm-prev-{{ $name }}" alt="">
<span class="sm-img-ph"><i class="bi bi-crop"></i><span>{{ $label }}</span></span>
</button>
</div>

View File

@ -0,0 +1,20 @@
@props(['id', 'title', 'icon' => 'bi-sliders'])
{{-- One collapsible "Optional" block inside the sports-match accordion.
Independent toggle (no data-bs-parent) so several can be open at once. --}}
<div class="accordion-item">
<h2 class="accordion-header" id="sm-heading-{{ $id }}">
<button class="accordion-button collapsed" type="button"
data-bs-toggle="collapse" data-bs-target="#sm-collapse-{{ $id }}"
aria-expanded="false" aria-controls="sm-collapse-{{ $id }}">
<i class="bi {{ $icon }} sm-acc-ico"></i>
<span class="sm-acc-title">{{ $title }}</span>
<span class="badge text-bg-secondary ms-2">Optional</span>
</button>
</h2>
<div id="sm-collapse-{{ $id }}" class="accordion-collapse collapse" aria-labelledby="sm-heading-{{ $id }}">
<div class="accordion-body">
{{ $slot }}
</div>
</div>
</div>

View File

@ -139,6 +139,7 @@
class="action-btn desktop-action subscribe-toggle-btn {{ $isSubscribed ? 'subscribed' : '' }}" class="action-btn desktop-action subscribe-toggle-btn {{ $isSubscribed ? 'subscribed' : '' }}"
data-channel-id="{{ $video->user_id }}" data-channel-id="{{ $video->user_id }}"
data-subscribe-url="{{ route('channel.subscribe', $video->user_id) }}" data-subscribe-url="{{ route('channel.subscribe', $video->user_id) }}"
data-source-video-id="{{ $video->id }}"
data-subscribed="{{ $isSubscribed ? 'true' : 'false' }}"> data-subscribed="{{ $isSubscribed ? 'true' : 'false' }}">
<i class="bi {{ $isSubscribed ? 'bi-bell-fill' : 'bi-bell' }}"></i> <i class="bi {{ $isSubscribed ? 'bi-bell-fill' : 'bi-bell' }}"></i>
<span class="subscribe-label">{{ $isSubscribed ? 'Subscribed' : 'Subscribe' }}</span> <span class="subscribe-label">{{ $isSubscribed ? 'Subscribed' : 'Subscribe' }}</span>
@ -168,7 +169,7 @@
@if ($video->isShareable()) @if ($video->isShareable())
<button class="action-btn desktop-action" <button class="action-btn desktop-action"
onclick="shareCurrent('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}', '{{ Auth::check() ? route('videos.shareEmail', $video) : '' }}')"> onclick="shareCurrent('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}', '{{ Auth::check() ? route('videos.shareEmail', $video) : '' }}', '{{ Auth::check() ? route('videos.shareMembers', $video) : '' }}')">
<i class="bi bi-share"></i><span>Share</span> <i class="bi bi-share"></i><span>Share</span>
</button> </button>
@endif @endif
@ -238,6 +239,7 @@
class="dropdown-item subscribe-toggle-btn {{ $isSubscribed ? 'subscribed' : '' }}" class="dropdown-item subscribe-toggle-btn {{ $isSubscribed ? 'subscribed' : '' }}"
data-channel-id="{{ $video->user_id }}" data-channel-id="{{ $video->user_id }}"
data-subscribe-url="{{ route('channel.subscribe', $video->user_id) }}" data-subscribe-url="{{ route('channel.subscribe', $video->user_id) }}"
data-source-video-id="{{ $video->id }}"
data-subscribed="{{ $isSubscribed ? 'true' : 'false' }}"> data-subscribed="{{ $isSubscribed ? 'true' : 'false' }}">
<i class="bi {{ $isSubscribed ? 'bi-bell-fill' : 'bi-bell' }}"></i> <i class="bi {{ $isSubscribed ? 'bi-bell-fill' : 'bi-bell' }}"></i>
<span class="subscribe-label">{{ $isSubscribed ? 'Subscribed' : 'Subscribe' }}</span> <span class="subscribe-label">{{ $isSubscribed ? 'Subscribed' : 'Subscribe' }}</span>
@ -271,7 +273,7 @@
@if ($video->isShareable()) @if ($video->isShareable())
<button class="dropdown-item" <button class="dropdown-item"
onclick="shareCurrent('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}', '{{ Auth::check() ? route('videos.shareEmail', $video) : '' }}')"> onclick="shareCurrent('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}', '{{ Auth::check() ? route('videos.shareEmail', $video) : '' }}', '{{ Auth::check() ? route('videos.shareMembers', $video) : '' }}')">
<i class="bi bi-share"></i> Share <i class="bi bi-share"></i> Share
</button> </button>
@endif @endif
@ -337,9 +339,15 @@
el.textContent = Number(el.dataset.count).toLocaleString() + ' subscribers'; el.textContent = Number(el.dataset.count).toLocaleString() + ' subscribers';
}); });
var srcVid = btn.dataset.sourceVideoId || '';
var body = srcVid && nowSub ? new URLSearchParams({ source_video_id: srcVid }).toString() : null;
fetch(url, { fetch(url, {
method: 'POST', method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' }, headers: Object.assign(
{ 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' },
body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}
),
body: body,
credentials: 'same-origin', credentials: 'same-origin',
}) })
.then(function (r) { .then(function (r) {
@ -516,10 +524,10 @@ if (!window._slideshowDlInit) {
// Share the version the viewer is on: append ?track={id} so the recipient's player // Share the version the viewer is on: append ?track={id} so the recipient's player
// opens directly on that language (window._ytpTrackId; 0 = primary → plain link). // opens directly on that language (window._ytpTrackId; 0 = primary → plain link).
window.shareCurrent = function (baseUrl, title, recordUrl, emailUrl) { window.shareCurrent = function (baseUrl, title, recordUrl, emailUrl, membersUrl) {
var t = window._ytpTrackId || 0; var t = window._ytpTrackId || 0;
var url = t ? baseUrl + (baseUrl.indexOf('?') === -1 ? '?' : '&') + 'track=' + t : baseUrl; var url = t ? baseUrl + (baseUrl.indexOf('?') === -1 ? '?' : '&') + 'track=' + t : baseUrl;
if (typeof window.openShareModal === 'function') window.openShareModal(url, title, recordUrl, emailUrl); if (typeof window.openShareModal === 'function') window.openShareModal(url, title, recordUrl, emailUrl, membersUrl);
}; };
window.startSlideshowDownload = function (routeKey) { window.startSlideshowDownload = function (routeKey) {

View File

@ -3,7 +3,7 @@
@php @php
use App\Data\Languages; use App\Data\Languages;
$videoUrl = $video ? asset('storage/videos/' . $video->filename) : null; $videoUrl = $video ? route('videos.stream', $video) : null;
$thumbnailUrl = $video && $video->thumbnail $thumbnailUrl = $video && $video->thumbnail
? route('media.thumbnail', $video->thumbnail) ? route('media.thumbnail', $video->thumbnail)
: ($video ? 'https://picsum.photos/seed/' . $video->id . '/640/360' : 'https://picsum.photos/seed/random/640/360'); : ($video ? 'https://picsum.photos/seed/' . $video->id . '/640/360' : 'https://picsum.photos/seed/random/640/360');
@ -141,9 +141,9 @@ $sizeClasses = match($size) {
@endif @endif
@if($video->isShareable()) @if($video->isShareable())
<li> <li>
<a class="dropdown-item" href="javascript:void(0)" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')"> <x-share-button :video="$video" tag="a" class="dropdown-item">
<i class="bi bi-share"></i> Share <i class="bi bi-share"></i> Share
</a> </x-share-button>
</li> </li>
@endif @endif
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
@ -997,7 +997,7 @@ function playVideo(element) {
if (!video) return; if (!video) return;
video.currentTime = 0; video.currentTime = 0;
const isAudio = element.dataset.audio === 'true'; const isAudio = element.dataset.audio === 'true';
video.volume = isAudio ? 0.4 : 0.10; video.volume = 0.5;
if (isAudio) { if (isAudio) {
// Keep thumbnail visible — just play audio, show equalizer // Keep thumbnail visible — just play audio, show equalizer
video.play().catch(function() {}); video.play().catch(function() {});

View File

@ -10,9 +10,10 @@
@if($isVideoOwner) @if($isVideoOwner)
<style> <style>
/* ── Stat cards ──────────────────────────────────────── */ /* ── Stat cards ──────────────────────────────────────── */
.ins-grid { display:grid; grid-template-columns:repeat(5,1fr); gap:10px; margin-bottom:18px; } .ins-grid { display:grid; grid-template-columns:repeat(7,minmax(0,1fr)); gap:8px; margin-bottom:18px; }
@media(max-width:860px){ .ins-grid { grid-template-columns:repeat(3,1fr); } } @media(max-width:1100px){ .ins-grid { grid-template-columns:repeat(4,minmax(0,1fr)); } }
@media(max-width:560px){ .ins-grid { grid-template-columns:repeat(2,1fr); } } @media(max-width:760px) { .ins-grid { grid-template-columns:repeat(3,minmax(0,1fr)); } }
@media(max-width:480px) { .ins-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
.ins-card { background:var(--bg-dark); border:1px solid var(--border-color); border-radius:10px; padding:14px 14px 12px; display:flex; flex-direction:column; gap:4px; cursor:pointer; transition:border-color .15s, transform .12s, box-shadow .15s; user-select:none; } .ins-card { background:var(--bg-dark); border:1px solid var(--border-color); border-radius:10px; padding:14px 14px 12px; display:flex; flex-direction:column; gap:4px; cursor:pointer; transition:border-color .15s, transform .12s, box-shadow .15s; user-select:none; }
.ins-card:hover { border-color:rgba(239,68,68,.5); transform:translateY(-2px); box-shadow:0 4px 16px rgba(0,0,0,.3); } .ins-card:hover { border-color:rgba(239,68,68,.5); transform:translateY(-2px); box-shadow:0 4px 16px rgba(0,0,0,.3); }
.ins-card:active { transform:translateY(0); } .ins-card:active { transform:translateY(0); }
@ -30,6 +31,18 @@
.ins-bar.clickable { cursor:pointer; } .ins-bar.clickable { cursor:pointer; }
.ins-bar.clickable:hover { background:#ef4444cc !important; } .ins-bar.clickable:hover { background:#ef4444cc !important; }
.ins-bar-today { background:#ef4444 !important; } .ins-bar-today { background:#ef4444 !important; }
/* Segmented bar: three stacked colour blocks (male / female / other-or-guest) */
.ins-bar-seg { width:100%; display:flex; flex-direction:column; justify-content:flex-end; border-radius:4px 4px 0 0; overflow:hidden; transition:filter .15s, transform .12s; }
.ins-bar-seg.clickable { cursor:pointer; }
.ins-bar-seg.clickable:hover { filter:brightness(1.18); }
.ins-bar-seg .seg { width:100%; transition:height .6s cubic-bezier(.22,.61,.36,1); }
.ins-bar-seg .seg-female { background:#ef4444; } /* female — brand red */
.ins-bar-seg .seg-male { background:#f59e0b; } /* male — warm amber */
.ins-bar-seg .seg-other { background:rgba(255,255,255,.18); } /* other / guest — neutral */
.ins-bar-seg.today { box-shadow:0 0 0 1px rgba(255,255,255,.55) inset; }
.ins-bar-legend { display:flex; gap:14px; flex-wrap:wrap; margin-top:8px; font-size:11px; color:var(--text-secondary); }
.ins-bar-legend span { display:inline-flex; align-items:center; gap:5px; }
.ins-bar-legend i { width:10px; height:10px; border-radius:2px; display:inline-block; }
.ins-bar-label { font-size:9px; color:var(--text-secondary); text-align:center; overflow:hidden; white-space:nowrap; } .ins-bar-label { font-size:9px; color:var(--text-secondary); text-align:center; overflow:hidden; white-space:nowrap; }
.ins-peak-badge { display:inline-flex; align-items:center; gap:6px; background:rgba(239,68,68,.12); border:1px solid rgba(239,68,68,.25); border-radius:20px; padding:4px 12px; font-size:12px; font-weight:600; color:#fca5a5; } .ins-peak-badge { display:inline-flex; align-items:center; gap:6px; background:rgba(239,68,68,.12); border:1px solid rgba(239,68,68,.25); border-radius:20px; padding:4px 12px; font-size:12px; font-weight:600; color:#fca5a5; }
.ins-body { display:grid; grid-template-columns:1fr 1fr; gap:20px; } .ins-body { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
@ -60,8 +73,23 @@
.ins-dl-user-name { flex:1; font-size:13px; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .ins-dl-user-name { flex:1; font-size:13px; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ins-dl-user-meta { font-size:11px; color:var(--text-secondary); margin-top:1px; } .ins-dl-user-meta { font-size:11px; color:var(--text-secondary); margin-top:1px; }
.ins-dl-count-badge { flex-shrink:0; background:rgba(239,68,68,.12); border:1px solid rgba(239,68,68,.25); color:#fca5a5; border-radius:20px; font-size:12px; font-weight:700; padding:2px 10px; } .ins-dl-count-badge { flex-shrink:0; background:rgba(239,68,68,.12); border:1px solid rgba(239,68,68,.25); color:#fca5a5; border-radius:20px; font-size:12px; font-weight:700; padding:2px 10px; }
.ins-recent-row { display:flex; align-items:center; gap:10px; padding:7px 0; border-bottom:1px solid rgba(255,255,255,.04); font-size:12px; } .ins-recent-row { display:flex; align-items:center; gap:10px; padding:7px 6px; border-bottom:1px solid rgba(255,255,255,.04); font-size:12px; border-radius:6px; transition:background .12s; }
.ins-recent-row.clickable { cursor:pointer; }
.ins-recent-row.clickable:hover { background:rgba(255,255,255,.05); }
.ins-recent-row:last-child { border-bottom:none; } .ins-recent-row:last-child { border-bottom:none; }
.ins-recent-count { flex-shrink:0; background:rgba(239,68,68,.12); border:1px solid rgba(239,68,68,.25); color:#fca5a5; border-radius:20px; font-size:11px; font-weight:700; padding:2px 8px; }
.ins-recent-scroll { max-height:430px; overflow-y:auto; padding-right:4px; }
.ins-recent-scroll::-webkit-scrollbar { width:6px; }
.ins-recent-scroll::-webkit-scrollbar-thumb { background:rgba(255,255,255,.12); border-radius:3px; }
.ins-recent-scroll::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,.2); }
/* UA bucket rows inside the viewer-detail modal */
.ins-ua-row { display:flex; align-items:center; gap:10px; padding:7px 0; border-bottom:1px solid rgba(255,255,255,.04); font-size:13px; }
.ins-ua-row:last-child { border-bottom:none; }
.ins-ua-icon { font-size:16px; flex-shrink:0; width:22px; text-align:center; color:var(--text-secondary); }
.ins-ua-label { flex:1; color:var(--text-primary); font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ins-ua-bar-wrap { width:80px; height:5px; background:rgba(255,255,255,.07); border-radius:4px; overflow:hidden; flex-shrink:0; }
.ins-ua-bar { height:100%; background:#ef4444; border-radius:4px; }
.ins-ua-cnt { font-size:11px; color:var(--text-secondary); min-width:30px; text-align:right; }
.ins-recent-avatar { width:28px; height:28px; border-radius:50%; object-fit:cover; flex-shrink:0; background:rgba(255,255,255,.08); display:flex; align-items:center; justify-content:center; font-size:14px; } .ins-recent-avatar { width:28px; height:28px; border-radius:50%; object-fit:cover; flex-shrink:0; background:rgba(255,255,255,.08); display:flex; align-items:center; justify-content:center; font-size:14px; }
.ins-recent-name { flex:1; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .ins-recent-name { flex:1; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ins-recent-type { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; padding:2px 7px; border-radius:6px; flex-shrink:0; } .ins-recent-type { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; padding:2px 7px; border-radius:6px; flex-shrink:0; }
@ -193,6 +221,19 @@ window._insData = null;
// ── Helpers ──────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────
const _cflag = code => `<span class="fi fi-${(!code||code.length!==2)?'xx':code.toLowerCase()}" style="width:20px;height:15px;border-radius:2px;display:inline-block;flex-shrink:0;vertical-align:middle;"></span>`; const _cflag = code => `<span class="fi fi-${(!code||code.length!==2)?'xx':code.toLowerCase()}" style="width:20px;height:15px;border-radius:2px;display:inline-block;flex-shrink:0;vertical-align:middle;"></span>`;
// Compact device/browser line for row meta (e.g. "📱 iPhone · Chrome 124")
function _uaInline(o) {
const dev = o && o.device ? o.device : '';
const br = o && o.browser ? o.browser : '';
if (!dev && !br) return '<span style="opacity:.6;">Device unknown</span>';
let icon = '<i class="bi bi-display"></i>';
if (/iPhone|Android phone|Mobile/i.test(dev)) icon = '<i class="bi bi-phone"></i>';
else if (/iPad|Tablet/i.test(dev)) icon = '<i class="bi bi-tablet"></i>';
const parts = [dev, br].filter(Boolean).join(' · ');
return `${icon} ${parts}`;
}
const _fmt = n => { n=n??0; if(n>=1e6) return (n/1e6).toFixed(1).replace(/\.0$/,'')+'M'; if(n>=1e3) return (n/1e3).toFixed(1).replace(/\.0$/,'')+'K'; return String(n); }; const _fmt = n => { n=n??0; if(n>=1e6) return (n/1e6).toFixed(1).replace(/\.0$/,'')+'M'; if(n>=1e3) return (n/1e3).toFixed(1).replace(/\.0$/,'')+'K'; return String(n); };
const _fmtH = h => (h===null||h===undefined) ? '—' : (h%12||12)+':00 '+(h>=12?'PM':'AM'); const _fmtH = h => (h===null||h===undefined) ? '—' : (h%12||12)+':00 '+(h>=12?'PM':'AM');
const _ago = s => { const d=Math.floor((Date.now()-new Date(s))/1000); if(d<60) return d+'s ago'; if(d<3600) return Math.floor(d/60)+'m ago'; if(d<86400) return Math.floor(d/3600)+'h ago'; return Math.floor(d/86400)+'d ago'; }; const _ago = s => { const d=Math.floor((Date.now()-new Date(s))/1000); if(d<60) return d+'s ago'; if(d<3600) return Math.floor(d/60)+'m ago'; if(d<86400) return Math.floor(d/3600)+'h ago'; return Math.floor(d/86400)+'d ago'; };
@ -232,6 +273,44 @@ function _personListHtml(users, emptyMsg) {
</div>`).join(''); </div>`).join('');
} }
// ── Segmented (stacked) daily bars: male / female / other-or-guest ─────
function _segmentedBars(daily, maxHeightPx, opts) {
opts = opts || {};
const max = Math.max(...daily.map(d => d.count), 1);
const todayLbl = new Date().toLocaleDateString('en-US', { month:'short', day:'numeric' });
return daily.map(day => {
const totalH = Math.max(Math.round((day.count / max) * maxHeightPx), day.count > 0 ? 2 : 0);
const male = day.male || 0;
const female = day.female || 0;
const other = day.other || 0;
// Each segment's pixel share rounds against the total so they always sum exactly to totalH
const t = day.count || 1;
const mH = totalH > 0 ? Math.round(totalH * (male / t)) : 0;
const fH = totalH > 0 ? Math.round(totalH * (female / t)) : 0;
const oH = Math.max(0, totalH - mH - fH);
const isToday = day.label === todayLbl;
const tip = `${day.label}: ${day.count} views\n♀ ${female} · ♂ ${male} · other/guest ${other}`;
const clickAttr = opts.onClick
? `onclick="${opts.onClick.replace('__DATE__', day.date).replace('__LABEL__', day.label).replace('__COUNT__', day.count)}"`
: '';
return `<div class="ins-bar-col">
<div class="ins-bar-seg${opts.onClick?' clickable':''}${isToday?' today':''}"
style="height:${totalH || 2}px;" title="${tip}" ${clickAttr}>
<div class="seg seg-female" style="height:${fH}px;"></div>
<div class="seg seg-male" style="height:${mH}px;"></div>
<div class="seg seg-other" style="height:${oH}px;"></div>
</div>
<div class="ins-bar-label">${day.short}</div>
</div>`;
}).join('');
}
const _segLegendHtml = `<div class="ins-bar-legend">
<span><i style="background:#ef4444;"></i> Female</span>
<span><i style="background:#f59e0b;"></i> Male</span>
<span><i style="background:rgba(255,255,255,.18);"></i> Other / Guest</span>
</div>`;
// ── Mini bar chart HTML (shared) ─────────────────────── // ── Mini bar chart HTML (shared) ───────────────────────
function _miniBarChart(daily) { function _miniBarChart(daily) {
const max = Math.max(...daily.map(d=>d.count), 1); const max = Math.max(...daily.map(d=>d.count), 1);
@ -308,58 +387,61 @@ function renderInsights(d) {
<i class="bi bi-cursor-fill"></i> Tap any card, chart bar, or country for a detailed breakdown <i class="bi bi-cursor-fill"></i> Tap any card, chart bar, or country for a detailed breakdown
</p> </p>
<div class="ins-grid"> <div class="ins-grid">
<div class="ins-card" onclick="openInsModal_views()"> <div class="ins-card" onclick="openInsModal_views()" title="Total views + skip rate">
<div class="ins-card-icon">👁️</div><div class="ins-card-val">${_fmt(d.total_views)}</div> <div class="ins-card-icon">👁️</div><div class="ins-card-val">${_fmt(d.total_views)}</div>
<div class="ins-card-label">Total Views</div>${weekBadge} <div class="ins-card-label">Views</div>
<span class="ins-card-sub neu">${d.skip_rate||0}% skipped · ${_fmt(d.views_today)} today</span>
</div> </div>
<div class="ins-card" onclick="openInsModal_viewers()"> <div class="ins-card" onclick="openInsModal_reach()" title="Distinct people who saw this video">
<div class="ins-card-icon">👤</div><div class="ins-card-val">${_fmt(d.unique_viewers)}</div> <div class="ins-card-icon">📡</div><div class="ins-card-val">${_fmt(d.accounts_reached||0)}</div>
<div class="ins-card-label">Unique Viewers</div> <div class="ins-card-label">Reach</div>
<span class="ins-card-sub neu">${_fmt(d.views_today)} today</span> <span class="ins-card-sub neu">${_fmt(d.reached_users||0)} signed-in · ${_fmt(d.reached_guests||0)} guests</span>
</div>
<div class="ins-card" onclick="openInsModal_likes()" title="People who liked this video">
<div class="ins-card-icon">❤️</div><div class="ins-card-val">${_fmt(d.likes)}</div>
<div class="ins-card-label">Likes</div>
<span class="ins-card-sub neu">${d.likes===0?'no likes yet':_fmt(d.likes)+' '+(d.likes===1?'person':'people')}</span>
</div>
<div class="ins-card" onclick="openInsModal_saves()" title="Viewers who added this video to a playlist">
<div class="ins-card-icon">🔖</div><div class="ins-card-val">${_fmt(d.save_count||0)}</div>
<div class="ins-card-label">Saves</div>
<span class="ins-card-sub neu">${d.save_rate||0}% of viewers</span>
</div>
<div class="ins-card" onclick="openInsModal_comments()" title="Comments and replies on this video">
<div class="ins-card-icon">💬</div><div class="ins-card-val">${_fmt(d.comments_count||0)}</div>
<div class="ins-card-label">Comments</div>
<span class="ins-card-sub neu">${d.comments_count===1?'1 comment':_fmt(d.comments_count||0)+' total'}</span>
</div> </div>
<div class="ins-card" onclick="openInsModal_downloads()"> <div class="ins-card" onclick="openInsModal_downloads()">
<div class="ins-card-icon">⬇️</div><div class="ins-card-val">${_fmt(d.downloads)}</div> <div class="ins-card-icon">⬇️</div><div class="ins-card-val">${_fmt(d.downloads)}</div>
<div class="ins-card-label">Downloads</div>${dlSub} <div class="ins-card-label">Downloads</div>${dlSub}
</div> </div>
<div class="ins-card" onclick="openInsModal_likes()"> <div class="ins-card" onclick="openInsModal_conversions()" title="Downstream actions this video drove">
<div class="ins-card-icon">❤️</div><div class="ins-card-val">${_fmt(d.likes)}</div> <div class="ins-card-icon">🚀</div><div class="ins-card-val">${_fmt((d.shares||0)+(d.profile_visits||0)+(d.new_subscribers||0))}</div>
<div class="ins-card-label">Likes</div> <div class="ins-card-label">Conversions</div>
<span class="ins-card-sub neu">${d.likes===0?'no likes yet':_fmt(d.likes)+' '+(d.likes===1?'person':'people')}</span> <span class="ins-card-sub neu">🔗 ${_fmt(d.shares||0)} · 🚪 ${_fmt(d.profile_visits||0)} · ${_fmt(d.new_subscribers||0)}</span>
</div>
<div class="ins-card" onclick="openInsModal_shares()">
<div class="ins-card-icon">🔗</div><div class="ins-card-val">${_fmt(d.shares)}</div>
<div class="ins-card-label">Share Reach</div>
<span class="ins-card-sub neu">${_fmt(d.share_links)} link${d.share_links===1?'':'s'} created</span>
</div> </div>
</div>`; </div>`;
// ── Bar chart (each bar clickable) ────────────────── // ── Bar chart (segmented + each bar clickable) ──────
const maxV = Math.max(...d.daily.map(x=>x.count),1); const bars = _segmentedBars(d.daily, 72, {
const todayL = new Date().toLocaleDateString('en-US',{month:'short',day:'numeric'}); onClick: `openDayDetail('__DATE__','__LABEL__',__COUNT__)`
const bars = d.daily.map(day => { });
const h = Math.round((day.count/maxV)*72);
const isT = day.label===todayL;
const tip = `${day.label}: ${day.count} views`;
return `<div class="ins-bar-col">
<div class="ins-bar clickable${isT?' ins-bar-today':''}" style="height:${Math.max(h,2)}px;"
title="${tip}" onclick="openDayDetail('${day.date}','${day.label}',${day.count})"></div>
<div class="ins-bar-label">${day.short}</div>
</div>`;
}).join('');
const peakHtml = d.peak_hour!==null const peakInline = d.peak_hour!==null
? `<div style="margin-top:10px;"><span class="ins-peak-badge"><i class="bi bi-clock-fill"></i> Peak hour: ${_fmtH(d.peak_hour)}</span></div>` : ''; ? ` <span style="font-size:10px;opacity:.7;font-weight:600;text-transform:none;color:#fca5a5;"><i class="bi bi-clock-fill"></i> Peak ${_fmtH(d.peak_hour)}</span>` : '';
const chartSection = ` // Only render the trend chart when there is at least one view to plot
const chartSection = (d.total_views > 0) ? `
<div> <div>
<div class="ins-section-title"><i class="bi bi-graph-up"></i> Views last 14 days <span style="font-size:10px;opacity:.5;font-weight:400;text-transform:none;">(tap bar for day detail)</span></div> <div class="ins-section-title"><i class="bi bi-graph-up"></i> Views last 14 days <span style="font-size:10px;opacity:.5;font-weight:400;text-transform:none;">(tap bar for day detail)</span>${peakInline}</div>
<div class="ins-chart">${bars}</div> <div class="ins-chart">${bars}</div>
<div style="font-size:11px;color:var(--text-secondary);text-align:right;">${d.views_this_week} views this week</div> ${_segLegendHtml}
${peakHtml} <div style="font-size:11px;color:var(--text-secondary);text-align:right;margin-top:4px;">${d.views_this_week} views this week</div>
</div>`; </div>` : '';
// ── Countries (each row clickable) ────────────────── // ── Countries (each row clickable) ──────────────────
let countriesHtml = '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data yet.</p>'; let countriesHtml = '';
if (d.countries && d.countries.length) { if (d.countries && d.countries.length) {
countriesHtml = d.countries.map(c => ` countriesHtml = d.countries.map(c => `
<div class="ins-country-row" onclick="openCountryDetail('${c.code}','${c.name}',${c.count})" title="View ${c.name||c.code} details"> <div class="ins-country-row" onclick="openCountryDetail('${c.code}','${c.name}',${c.count})" title="View ${c.name||c.code} details">
@ -379,7 +461,7 @@ function renderInsights(d) {
<img src="${u.avatar}" alt="${u.name}" class="ins-dl-avatar"> <img src="${u.avatar}" alt="${u.name}" class="ins-dl-avatar">
<div style="flex:1;min-width:0;"> <div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${u.name}</div> <div class="ins-dl-user-name">${u.name}</div>
<div class="ins-dl-user-meta">Last seen ${_ago(u.last_at)}</div> <div class="ins-dl-user-meta">${_uaInline(u)} · last seen ${_ago(u.last_at)}</div>
</div> </div>
<div class="ins-dl-count-badge">👁 ${u.count}×</div> <div class="ins-dl-count-badge">👁 ${u.count}×</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i> <i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
@ -387,31 +469,47 @@ function renderInsights(d) {
const recentViewerRows = (d.recent_viewers || []).map(r => { const recentViewerRows = (d.recent_viewers || []).map(r => {
const avatarSrc = r.user_avatar || `https://i.pravatar.cc/150?u=guest-${r.country||'unknown'}`; const avatarSrc = r.user_avatar || `https://i.pravatar.cc/150?u=guest-${r.country||'unknown'}`;
const clickAttr = r.user_id ? `onclick="window.location.href='/channel/${r.user_channel||r.user_id}'" style="cursor:pointer;" title="View ${r.user_name}'s profile"` : ''; const title = `Tap for full activity — ${r.user_name}`;
return `<div class="ins-recent-row" ${clickAttr}> const safeName = (r.user_name || 'Viewer').replace(/'/g, "\\'");
<img src="${avatarSrc}" class="ins-recent-avatar" alt="${r.user_name}"> return `<div class="ins-dl-user-row" title="${title}"
<div class="ins-recent-name">${r.user_name}</div> onclick="openViewerDetail('${r.key}','${safeName}','${avatarSrc}')">
<span class="ins-recent-flag">${_cflag(r.country)}</span> <img src="${avatarSrc}" alt="${r.user_name}" class="ins-dl-avatar">
<span class="ins-recent-time">${_ago(r.at)}</span> <div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${r.user_name} ${_cflag(r.country)}</div>
<div class="ins-dl-user-meta">${_uaInline(r)} · ${_ago(r.last_at)}</div>
</div>
<div class="ins-dl-count-badge">👁 ${r.count}×</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`; </div>`;
}).join(''); }).join('');
const viewersHtml = ` // Build the Viewers section only if there is something to show on either side
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;"> const hasTopViewers = !!topViewerRows;
<div class="ins-dl-header"> const hasRecentViewers = !!recentViewerRows;
<div class="ins-section-title" style="margin:0;"><i class="bi bi-people-fill"></i> Viewers ${_fmt(d.unique_viewers)} registered · ${_fmt(d.guest_views||0)} guest</div> let viewersHtml = '';
</div> if (hasTopViewers || hasRecentViewers) {
<div class="ins-two-col"> const topCol = hasTopViewers ? `
<div> <div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">Top Viewers</div> <div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">Top Viewers</div>
${topViewerRows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No registered viewers yet.</p>'} ${topViewerRows}
</div> </div>` : '';
const recentCol = hasRecentViewers ? `
<div> <div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">Recent Activity</div> <div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">
${recentViewerRows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No views yet.</p>'} Recent Activity ${ (d.recent_viewers||[]).length > 10 ? `<span style="font-weight:400;opacity:.55;">(showing 10 — scroll for more)</span>` : '' }
</div>
<div class="ins-recent-scroll">${recentViewerRows}</div>
</div>` : '';
// Use single-column layout when only one side has data
const wrap = (hasTopViewers && hasRecentViewers) ? 'ins-two-col' : '';
viewersHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-dl-header">
<div class="ins-section-title" style="margin:0;"><i class="bi bi-people-fill"></i> Viewers ${_fmt(d.unique_viewers)} registered · ${_fmt(d.guest_views||0)} guest</div>
</div> </div>
</div> <div class="${wrap}">${topCol}${recentCol}</div>
</div>`; </div>`;
}
// ── Who Liked section ─────────────────────────────── // ── Who Liked section ───────────────────────────────
let likersHtml = ''; let likersHtml = '';
@ -429,123 +527,114 @@ function renderInsights(d) {
<div class="ins-section-title"><i class="bi bi-heart-fill" style="color:#ef4444;"></i> Liked by ${_fmt(d.likes)} ${d.likes===1?'person':'people'}</div> <div class="ins-section-title"><i class="bi bi-heart-fill" style="color:#ef4444;"></i> Liked by ${_fmt(d.likes)} ${d.likes===1?'person':'people'}</div>
${likerRows} ${likerRows}
</div>`; </div>`;
} else if (d.likes === 0) {
likersHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-section-title"><i class="bi bi-heart" style="color:var(--text-secondary);"></i> Likes</div>
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No likes yet.</p>
</div>`;
} }
// (When d.likes === 0 the section is omitted entirely.)
// ── Downloads section ─────────────────────────────── // ── Downloads section ───────────────────────────────
let dlHtml = ''; let dlHtml = '';
if (d.downloads===0) { if (d.downloads > 0) {
dlHtml = `<div style="margin-top:20px;"><div class="ins-section-title"><i class="bi bi-download"></i> Downloads</div>
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No downloads yet.</p></div>`;
} else {
const pills = [ const pills = [
d.dl_video ? `<span class="ins-dl-pill video"><i class="bi bi-film"></i> ${_fmt(d.dl_video)} video</span>` : '', d.dl_video ? `<span class="ins-dl-pill video"><i class="bi bi-film"></i> ${_fmt(d.dl_video)} video</span>` : '',
d.dl_mp3 ? `<span class="ins-dl-pill mp3"><i class="bi bi-music-note"></i> ${_fmt(d.dl_mp3)} MP3</span>` : '', d.dl_mp3 ? `<span class="ins-dl-pill mp3"><i class="bi bi-music-note"></i> ${_fmt(d.dl_mp3)} MP3</span>` : '',
d.dl_guests ? `<span class="ins-dl-pill guest"><i class="bi bi-person"></i> ${_fmt(d.dl_guests)} guest</span>` : '', d.dl_guests ? `<span class="ins-dl-pill guest"><i class="bi bi-person"></i> ${_fmt(d.dl_guests)} guest</span>` : '',
].filter(Boolean).join(''); ].filter(Boolean).join('');
const usersRows = d.dl_users && d.dl_users.length const usersRows = (d.dl_users && d.dl_users.length)
? d.dl_users.map((u,i) => ` ? d.dl_users.map((u,i) => `
<div class="ins-dl-user-row" onclick="openDownloaderHistory(${u.id},'${u.name.replace(/'/g,"\\'")}','${u.avatar}')" title="See full download history"> <div class="ins-dl-user-row" onclick="openDownloaderHistory(${u.id},'${u.name.replace(/'/g,"\\'")}','${u.avatar}')" title="See full download history">
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div> <div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<img src="${u.avatar}" alt="${u.name}" class="ins-dl-avatar"> <img src="${u.avatar}" alt="${u.name}" class="ins-dl-avatar">
<div style="flex:1;min-width:0;"> <div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${u.name}</div> <div class="ins-dl-user-name">${u.name}</div>
<div class="ins-dl-user-meta">Last: ${_ago(u.last_at)}</div> <div class="ins-dl-user-meta">${_uaInline(u)} · last ${_ago(u.last_at)}</div>
</div> </div>
<div class="ins-dl-count-badge">⬇️ ${u.count}×</div> <div class="ins-dl-count-badge">⬇️ ${u.count}×</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i> <i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`).join('') </div>`).join('')
: '<p style="font-size:13px;color:var(--text-secondary);margin:0;">Guests only so far.</p>'; : '';
const recentRows = (d.dl_recent||[]).map(r => { const recentRows = (d.dl_recent||[]).map(r => {
const avatarSrc = r.user_avatar || `https://i.pravatar.cc/150?u=guest-${r.country||'unknown'}`; const avatarSrc = r.user_avatar || `https://i.pravatar.cc/150?u=guest-${r.country||'unknown'}`;
const av = `<img src="${avatarSrc}" class="ins-recent-avatar" alt="${r.user_name}">`; const safeName = (r.user_name || 'Downloader').replace(/'/g, "\\'");
return `<div class="ins-recent-row">${av} const clickAttr = r.user_id
<div class="ins-recent-name">${r.user_name}</div> ? `onclick="openDownloaderHistory(${r.user_id},'${safeName}','${avatarSrc}')" style="cursor:pointer;" title="See full download history"`
<span class="ins-recent-flag">${_cflag(r.country)}</span> : `style="cursor:default;"`;
<span class="ins-recent-type ${r.type}">${r.type.toUpperCase()}</span> const typePill = `<span class="ins-recent-type ${r.type}" style="margin-right:4px;">${r.type.toUpperCase()}</span>`;
<span class="ins-recent-time">${_ago(r.at)}</span> return `<div class="ins-dl-user-row" ${clickAttr}>
<img src="${avatarSrc}" alt="${r.user_name}" class="ins-dl-avatar">
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${r.user_name} ${_cflag(r.country)}</div>
<div class="ins-dl-user-meta">${typePill}${_uaInline(r)} · ${_ago(r.last_at)}</div>
</div>
<div class="ins-dl-count-badge">⬇️ ${r.count}×</div>
${r.user_id ? '<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>' : ''}
</div>`; </div>`;
}).join(''); }).join('');
const topCol = usersRows ? `
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">
Top Downloaders <span style="font-weight:400;opacity:.5;">(tap to see history)</span>
</div>
${usersRows}
</div>` : '';
const recentCol = recentRows ? `
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">
Recent Activity ${ (d.dl_recent||[]).length > 10 ? `<span style="font-weight:400;opacity:.55;">(showing 10 — scroll for more)</span>` : '' }
</div>
<div class="ins-recent-scroll">${recentRows}</div>
</div>` : '';
const dlWrap = (usersRows && recentRows) ? 'ins-two-col' : '';
dlHtml = ` dlHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;"> <div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-dl-header"> <div class="ins-dl-header">
<div class="ins-section-title" style="margin:0;"><i class="bi bi-download"></i> Downloads ${_fmt(d.downloads)} total</div> <div class="ins-section-title" style="margin:0;"><i class="bi bi-download"></i> Downloads ${_fmt(d.downloads)} total</div>
<div class="ins-dl-type-pills">${pills}</div> <div class="ins-dl-type-pills">${pills}</div>
</div> </div>
<div class="ins-two-col"> <div class="${dlWrap}">${topCol}${recentCol}</div>
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">
Top Downloaders <span style="font-weight:400;opacity:.5;">(tap to see history)</span>
</div>
${usersRows}
</div>
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">Recent Activity</div>
${recentRows}
</div>
</div>
</div>`; </div>`;
} }
// ── Gender breakdown ──────────────────────────────── // ── Age group breakdown (segmented by gender: blue=male, pink=female) ──
let genderHtml; let ageHtml = '';
if (d.genders && d.genders.length > 0) {
const gColors = { male: '#4a9eff', female: '#ff6eb0' };
const gSymbols = { male: '♂', female: '♀' };
genderHtml = d.genders.map(g => {
const color = gColors[g.gender] || '#aaa';
const sym = gSymbols[g.gender] || '⚧';
return `<div class="ins-demo-row">
<span class="ins-demo-sym" style="color:${color}">${sym}</span>
<span class="ins-demo-label">${g.gender.charAt(0).toUpperCase()+g.gender.slice(1)}</span>
<div class="ins-demo-bar-wrap"><div class="ins-demo-bar" style="width:${g.pct}%;background:${color}"></div></div>
<span class="ins-demo-pct">${g.pct}%</span>
<span class="ins-demo-cnt">${_fmt(g.count)}</span>
</div>`;
}).join('');
} else {
genderHtml = `<p style="font-size:13px;color:var(--text-secondary);margin:0;">No gender data yet — viewers need a profile to appear here.</p>`;
}
// ── Age group breakdown ──────────────────────────────
let ageHtml;
if (d.age_groups && d.age_groups.length > 0) { if (d.age_groups && d.age_groups.length > 0) {
const maxAge = Math.max(...d.age_groups.map(g => g.count), 1); const maxAge = Math.max(...d.age_groups.map(g => g.count), 1);
ageHtml = d.age_groups.map(g => { ageHtml = d.age_groups.map(g => {
const barW = Math.round(g.count / maxAge * 100); const barW = Math.round(g.count / maxAge * 100);
return `<div class="ins-demo-row"> const t = g.count || 1;
const mPct = Math.round(g.male / t * 100);
const fPct = Math.round(g.female / t * 100);
const oPct = Math.max(0, 100 - mPct - fPct);
const tip = `${g.label}: ${g.count} viewers · ♂ ${g.male} · ♀ ${g.female}${g.other ? ' · other ' + g.other : ''}`;
return `<div class="ins-demo-row" title="${tip}">
<span class="ins-demo-label ins-age-label">${g.label}</span> <span class="ins-demo-label ins-age-label">${g.label}</span>
<div class="ins-demo-bar-wrap"><div class="ins-demo-bar" style="width:${barW}%;background:var(--brand-red,#e61e1e)"></div></div> <div class="ins-demo-bar-wrap">
<div style="display:flex;height:100%;width:${barW}%;border-radius:4px;overflow:hidden;">
<div style="width:${mPct}%;background:#4a9eff;" title="Male: ${g.male}"></div>
<div style="width:${fPct}%;background:#ff6eb0;" title="Female: ${g.female}"></div>
${oPct > 0 ? `<div style="width:${oPct}%;background:#facc15;" title="Other: ${g.other}"></div>` : ''}
</div>
</div>
<span class="ins-demo-pct">${g.pct}%</span> <span class="ins-demo-pct">${g.pct}%</span>
<span class="ins-demo-cnt">${_fmt(g.count)}</span> <span class="ins-demo-cnt">${_fmt(g.count)}</span>
</div>`; </div>`;
}).join(''); }).join('');
} else {
ageHtml = `<p style="font-size:13px;color:var(--text-secondary);margin:0;">No age data yet — viewers need a date of birth on their profile to appear here.</p>`;
} }
const demographicsHtml = ` const ageLegend = `<div class="ins-bar-legend" style="margin-top:10px;">
<div style="margin-top:18px;border-top:1px solid var(--border-color);padding-top:18px;"> <span><i style="background:#4a9eff;"></i> Male</span>
<div class="ins-body"> <span><i style="background:#ff6eb0;"></i> Female</span>
<div>
<div class="ins-section-title"><i class="bi bi-gender-ambiguous"></i> Viewers by Gender</div>
${genderHtml}
</div>
<div>
<div class="ins-section-title"><i class="bi bi-people-fill"></i> Viewers by Age Group</div>
${ageHtml}
</div>
</div>
</div>`; </div>`;
// Demographics — only when at least one bucket has data
const demographicsHtml = ageHtml ? `
<div style="margin-top:22px;">
<div class="ins-section-title"><i class="bi bi-people-fill"></i> Viewers by Age Group</div>
${ageHtml}
${ageLegend}
</div>` : '';
// ── Share links breakdown ─────────────────────────── // ── Share links breakdown ───────────────────────────
let shareHtml = ''; let shareHtml = '';
if (d.share_links > 0) { if (d.share_links > 0) {
@ -553,9 +642,8 @@ function renderInsights(d) {
const avatarHtml = s.avatar const avatarHtml = s.avatar
? `<img src="${s.avatar}" class="ins-dl-avatar" alt="${s.sharer}">` ? `<img src="${s.avatar}" class="ins-dl-avatar" alt="${s.sharer}">`
: `<div class="ins-dl-avatar" style="display:flex;align-items:center;justify-content:center;font-size:16px;">👤</div>`; : `<div class="ins-dl-avatar" style="display:flex;align-items:center;justify-content:center;font-size:16px;">👤</div>`;
const clickAttr = s.sharer_channel const safeName = (s.sharer || 'Sharer').replace(/'/g, "\\'");
? `onclick="window.location.href='/channel/${s.sharer_channel}'" style="cursor:pointer;" title="View ${s.sharer}'s profile"` const clickAttr = `onclick="openShareDetail('${s.token}','${safeName}')" style="cursor:pointer;" title="See who came from this link"`;
: `style="cursor:default;"`;
const flagHtml = s.country const flagHtml = s.country
? `<span style="flex-shrink:0;" title="${s.country_name||s.country}">${_cflag(s.country)}</span>` ? `<span style="flex-shrink:0;" title="${s.country_name||s.country}">${_cflag(s.country)}</span>`
: ''; : '';
@ -568,6 +656,7 @@ function renderInsights(d) {
</div> </div>
${flagHtml} ${flagHtml}
<div class="ins-dl-count-badge" title="Unique devices that opened this link">👁️ ${_fmt(s.reach)}</div> <div class="ins-dl-count-badge" title="Unique devices that opened this link">👁️ ${_fmt(s.reach)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`; </div>`;
}).join(''); }).join('');
@ -579,21 +668,40 @@ function renderInsights(d) {
<p style="font-size:11px;color:var(--text-secondary);margin:0 0 10px;line-height:1.5;">Each sharer gets a unique link. The same device opening the same link is counted only once VPN IP changes don't affect the count.</p> <p style="font-size:11px;color:var(--text-secondary);margin:0 0 10px;line-height:1.5;">Each sharer gets a unique link. The same device opening the same link is counted only once VPN IP changes don't affect the count.</p>
${linkRows} ${linkRows}
</div>`; </div>`;
} else {
shareHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-section-title"><i class="bi bi-share"></i> Share Links</div>
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No share links created yet. Tap the Share button to generate a unique tracked link.</p>
</div>`;
} }
// (When d.share_links === 0 the section is omitted entirely.)
document.getElementById('insightsContent').innerHTML = // Left column: chart + demographics. Right column: country audience.
cards + const leftCol = chartSection + demographicsHtml;
`<div class="ins-body">${chartSection} const rightCol = countriesHtml ? `
<div> <div>
<div class="ins-section-title"><i class="bi bi-globe2"></i> Audience by Country <span style="font-size:10px;opacity:.5;font-weight:400;text-transform:none;">(tap for viewer breakdown)</span></div> <div class="ins-section-title"><i class="bi bi-globe2"></i> Audience by Country <span style="font-size:10px;opacity:.5;font-weight:400;text-transform:none;">(tap for viewer breakdown)</span></div>
${countriesHtml} ${countriesHtml}
</div></div>` + viewersHtml + likersHtml + dlHtml + shareHtml + demographicsHtml; </div>` : '';
let topBlock = '';
if (leftCol && rightCol) {
topBlock = `<div class="ins-body"><div>${leftCol}</div>${rightCol}</div>`;
} else if (leftCol) {
topBlock = `<div>${leftCol}</div>`;
} else if (rightCol) {
topBlock = `<div>${rightCol}</div>`;
}
// Pair likes + shares side-by-side only when both have data;
// otherwise emit each on its own row so empty ones simply disappear.
let likesShareBlock = '';
if (likersHtml && shareHtml) {
likesShareBlock = `<div class="ins-two-col" style="margin-top:0;">
<div>${likersHtml}</div>
<div>${shareHtml}</div>
</div>`;
} else {
likesShareBlock = (likersHtml || '') + (shareHtml || '');
}
document.getElementById('insightsContent').innerHTML =
cards + topBlock + viewersHtml + dlHtml + likesShareBlock;
} }
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
@ -611,16 +719,14 @@ function openInsModal_views() {
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-calendar-day"></i> Today</span><span class="ins-modal-stat-val">${_fmt(d.views_today)}</span></div> <div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-calendar-day"></i> Today</span><span class="ins-modal-stat-val">${_fmt(d.views_today)}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-calendar-week"></i> This week</span><span class="ins-modal-stat-val">${_fmt(d.views_this_week)}</span></div> <div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-calendar-week"></i> This week</span><span class="ins-modal-stat-val">${_fmt(d.views_this_week)}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-calendar2-minus"></i> Last week</span><span class="ins-modal-stat-val">${_fmt(d.views_last_week)}</span></div> <div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-calendar2-minus"></i> Last week</span><span class="ins-modal-stat-val">${_fmt(d.views_last_week)}</span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;"> <div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-graph-up-arrow"></i> Week over week</span><span class="ins-modal-stat-val" style="color:${changeColor};">${changeLabel}</span></div>
<span class="ins-modal-stat-lbl"><i class="bi bi-graph-up-arrow"></i> Week over week</span> <div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-skip-forward-fill" style="color:#f59e0b;"></i> Skipped early</span><span class="ins-modal-stat-val">${_fmt(d.skipped_views||0)} <span style="color:var(--text-secondary);font-weight:400;">(${d.skip_rate||0}% of views)</span></span></div>
<span class="ins-modal-stat-val" style="color:${changeColor};">${changeLabel}</span> <div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;"><span class="ins-modal-stat-lbl"><i class="bi bi-stopwatch"></i> Skip threshold</span><span class="ins-modal-stat-val" style="font-weight:500;color:var(--text-secondary);">under ${d.skip_threshold||10}s watched</span></div>
</div>
<div class="ins-modal-section"><i class="bi bi-graph-up"></i> Last 14 days tap a bar to see that day</div> <div class="ins-modal-section"><i class="bi bi-graph-up"></i> Last 14 days tap a bar to see that day</div>
<div class="ins-chart" style="height:72px;">${window._insData.daily.map(day=>{ <div class="ins-chart" style="height:72px;">${_segmentedBars(window._insData.daily, 68, {
const h=Math.round((day.count/Math.max(...window._insData.daily.map(x=>x.count),1))*68); onClick: `closeInsModal();setTimeout(()=>openDayDetail('__DATE__','__LABEL__',__COUNT__),180)`
const isT=day.label===new Date().toLocaleDateString('en-US',{month:'short',day:'numeric'}); })}</div>
return `<div class="ins-bar-col"><div class="ins-bar clickable${isT?' ins-bar-today':''}" style="height:${Math.max(h,2)}px;" title="${day.label}: ${day.count}" onclick="closeInsModal();setTimeout(()=>openDayDetail('${day.date}','${day.label}',${day.count}),180)"></div><div class="ins-bar-label">${day.short}</div></div>`; ${_segLegendHtml}
}).join('')}</div>
${d.peak_hour!==null?`<div style="margin-top:10px;"><span class="ins-peak-badge"><i class="bi bi-clock-fill"></i> Peak hour: ${_fmtH(d.peak_hour)}</span></div>`:''}`); ${d.peak_hour!==null?`<div style="margin-top:10px;"><span class="ins-peak-badge"><i class="bi bi-clock-fill"></i> Peak hour: ${_fmtH(d.peak_hour)}</span></div>`:''}`);
} }
@ -689,9 +795,8 @@ function openInsModal_shares() {
const avatarHtml = s.avatar const avatarHtml = s.avatar
? `<img src="${s.avatar}" class="ins-dl-avatar" alt="${s.sharer}">` ? `<img src="${s.avatar}" class="ins-dl-avatar" alt="${s.sharer}">`
: `<div class="ins-dl-avatar" style="display:flex;align-items:center;justify-content:center;font-size:16px;">👤</div>`; : `<div class="ins-dl-avatar" style="display:flex;align-items:center;justify-content:center;font-size:16px;">👤</div>`;
const clickAttr = s.sharer_channel const safeName = (s.sharer || 'Sharer').replace(/'/g, "\\'");
? `onclick="window.location.href='/channel/${s.sharer_channel}'" style="cursor:pointer;" title="View ${s.sharer}'s profile"` const clickAttr = `onclick="closeInsModal();setTimeout(()=>openShareDetail('${s.token}','${safeName}'),180)" style="cursor:pointer;" title="See who came from this link"`;
: `style="cursor:default;"`;
const flagHtml = s.country const flagHtml = s.country
? `<span style="flex-shrink:0;" title="${s.country_name||s.country}">${_cflag(s.country)}</span>` ? `<span style="flex-shrink:0;" title="${s.country_name||s.country}">${_cflag(s.country)}</span>`
: ''; : '';
@ -704,6 +809,7 @@ function openInsModal_shares() {
</div> </div>
${flagHtml} ${flagHtml}
<div class="ins-dl-count-badge">👁️ ${_fmt(s.reach)}</div> <div class="ins-dl-count-badge">👁️ ${_fmt(s.reach)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`; </div>`;
}).join('') }).join('')
: '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No share links created yet.</p>'; : '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No share links created yet.</p>';
@ -750,6 +856,112 @@ function openInsModal_likes() {
${likerRows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No data available.</p>'}`); ${likerRows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No data available.</p>'}`);
} }
function openInsModal_reach() {
const d = window._insData; if(!d) return;
const total = d.accounts_reached || 0;
const usersPct = total>0 ? Math.round((d.reached_users||0)/total*100) : 0;
const guestsPct = total>0 ? 100 - usersPct : 0;
const repeatRatio = total>0 ? (d.total_views/total).toFixed(1) : '0.0';
const countryRows = (d.countries||[]).slice(0,8).map(c=>`
<div class="ins-country-row" onclick="closeInsModal();setTimeout(()=>openCountryDetail('${c.code}','${c.name}',${c.count}),180)" style="cursor:pointer;">
<div class="ins-country-flag">${_cflag(c.code)}</div>
<div class="ins-country-name">${c.name||c.code}</div>
<div class="ins-country-bar-wrap"><div class="ins-country-bar" style="width:${c.pct}%;"></div></div>
<div class="ins-country-pct">${c.pct}%</div>
<div class="ins-country-cnt">${_fmt(c.count)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);"></i>
</div>`).join('');
_openModal('📡','Reach','Distinct people who saw this video',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(total)}</div>
<div class="ins-modal-hero-label">accounts reached</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Signed-in</span><span class="ins-modal-stat-val">${_fmt(d.reached_users||0)} <span style="color:var(--text-secondary);font-weight:400;">(${usersPct}%)</span></span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-person"></i> Guest devices</span><span class="ins-modal-stat-val">${_fmt(d.reached_guests||0)} <span style="color:var(--text-secondary);font-weight:400;">(${guestsPct}%)</span></span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-eye"></i> Total views</span><span class="ins-modal-stat-val">${_fmt(d.total_views)}</span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;"><span class="ins-modal-stat-lbl"><i class="bi bi-arrow-repeat"></i> Avg views per account</span><span class="ins-modal-stat-val">${repeatRatio}×</span></div>
${countryRows ? `<div class="ins-modal-section"><i class="bi bi-globe2"></i> Top countries — tap for viewers</div>${countryRows}` : ''}`);
}
function openInsModal_saves() {
const d = window._insData; if(!d) return;
if ((d.save_count||0) === 0) {
_openModal('🔖','Saves','Not saved yet',`<div style="text-align:center;padding:30px 0;"><div style="font-size:48px;margin-bottom:12px;">📭</div><p style="color:var(--text-secondary);font-size:14px;margin:0;">No viewer has added this video to a playlist yet.</p></div>`);
return;
}
_openModal('🔖','Saves','Viewers who added this to a playlist',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.save_count||0)}</div>
<div class="ins-modal-hero-label">${d.save_count===1?'person':'people'} saved this video</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-percent"></i> Save rate</span><span class="ins-modal-stat-val">${d.save_rate||0}% <span style="color:var(--text-secondary);font-weight:400;">of unique viewers</span></span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-person-check"></i> Unique viewers</span><span class="ins-modal-stat-val">${_fmt(d.unique_viewers||0)}</span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:0;"><span class="ins-modal-stat-lbl"><i class="bi bi-info-circle"></i> Note</span><span class="ins-modal-stat-val" style="font-weight:500;color:var(--text-secondary);font-size:12px;">your own playlists excluded</span></div>`);
}
function openInsModal_comments() {
const d = window._insData; if(!d) return;
if ((d.comments_count||0) === 0) {
_openModal('💬','Comments','No comments yet',`<div style="text-align:center;padding:30px 0;"><div style="font-size:48px;margin-bottom:12px;">💭</div><p style="color:var(--text-secondary);font-size:14px;margin:0;">No comments on this video yet.</p></div>`);
return;
}
const commentRate = d.total_views>0 ? ((d.comments_count/d.total_views)*100).toFixed(1) : '0.0';
_openModal('💬','Comments','Conversation on this video',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.comments_count||0)}</div>
<div class="ins-modal-hero-label">comments &amp; replies</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-percent"></i> Comment rate</span><span class="ins-modal-stat-val">${commentRate}% <span style="color:var(--text-secondary);font-weight:400;">of views</span></span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:14px;"><span class="ins-modal-stat-lbl"><i class="bi bi-eye"></i> Total views</span><span class="ins-modal-stat-val">${_fmt(d.total_views)}</span></div>
<div style="text-align:center;padding:10px 0 4px;">
<button class="action-btn" onclick="closeInsModal();setTimeout(()=>{var el=document.getElementById('commentsSection')||document.querySelector('.comments-section, [data-comments]');if(el)el.scrollIntoView({behavior:'smooth',block:'start'});},200);">
<i class="bi bi-chat-dots"></i> <span>Jump to comments</span>
</button>
</div>`);
}
function openInsModal_conversions() {
const d = window._insData; if(!d) return;
const total = (d.shares||0) + (d.profile_visits||0) + (d.new_subscribers||0);
if (total === 0) {
_openModal('🚀','Conversions','No conversions yet',`<div style="text-align:center;padding:30px 0;"><div style="font-size:48px;margin-bottom:12px;">🌱</div><p style="color:var(--text-secondary);font-size:14px;margin:0;">No shares, wall visits, or new subscribers driven by this video yet.</p></div>`);
return;
}
const subRate = d.total_views>0 ? ((d.new_subscribers||0)/d.total_views*100).toFixed(1) : '0.0';
const visitRate = d.total_views>0 ? ((d.profile_visits||0)/d.total_views*100).toFixed(1) : '0.0';
// Share-link breakdown reuses the existing shares modal layout
const linkRows = (d.share_breakdown||[]).slice(0,5).map((s,i) => {
const avatarHtml = s.avatar
? `<img src="${s.avatar}" class="ins-dl-avatar" alt="${s.sharer}">`
: `<div class="ins-dl-avatar" style="display:flex;align-items:center;justify-content:center;font-size:16px;">👤</div>`;
const safeName = (s.sharer || 'Sharer').replace(/'/g, "\\'");
const flagHtml = s.country ? `<span style="flex-shrink:0;" title="${s.country_name||s.country}">${_cflag(s.country)}</span>` : '';
return `<div class="ins-dl-user-row" onclick="closeInsModal();setTimeout(()=>openShareDetail('${s.token}','${safeName}'),180)" style="cursor:pointer;">
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
${avatarHtml}
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${s.sharer}</div>
<div class="ins-dl-user-meta">${_ago(s.created_at)}</div>
</div>
${flagHtml}
<div class="ins-dl-count-badge">👁️ ${_fmt(s.reach)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`;
}).join('');
_openModal('🚀','Conversions','Downstream actions this video drove',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(total)}</div>
<div class="ins-modal-hero-label">total conversions</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-link-45deg" style="color:#60a5fa;"></i> Share reach</span><span class="ins-modal-stat-val">${_fmt(d.shares||0)} <span style="color:var(--text-secondary);font-weight:400;">(${_fmt(d.share_links||0)} link${d.share_links===1?'':'s'})</span></span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-door-open" style="color:#f59e0b;"></i> Wall visits</span><span class="ins-modal-stat-val">${_fmt(d.profile_visits||0)} <span style="color:var(--text-secondary);font-weight:400;">(${visitRate}% of views)</span></span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;"><span class="ins-modal-stat-lbl"><i class="bi bi-stars" style="color:#ef4444;"></i> New subscribers</span><span class="ins-modal-stat-val">${_fmt(d.new_subscribers||0)} <span style="color:var(--text-secondary);font-weight:400;">(${subRate}% of views)</span></span></div>
${linkRows ? `<div class="ins-modal-section"><i class="bi bi-people-fill"></i> Top share links — tap to see who came</div>${linkRows}` : ''}
<p style="font-size:11px;color:var(--text-secondary);margin:12px 0 0;line-height:1.5;">Wall visits and new subscribers are only counted when triggered directly from this video page.</p>`);
}
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
// DRILL-DOWN: COUNTRY // DRILL-DOWN: COUNTRY
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
@ -895,5 +1107,216 @@ function openDownloaderHistory(userId, userName, userAvatar) {
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load download history.</p>'; '<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load download history.</p>';
}); });
} }
// ══════════════════════════════════════════════════════
// DRILL-DOWN: SINGLE VIEWER (grouped activity popup)
// ══════════════════════════════════════════════════════
function _uaBucketsHtml(buckets, iconFor) {
if (!buckets || !buckets.length) return '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No data.</p>';
const max = Math.max(...buckets.map(b => b.count), 1);
return buckets.map(b => {
const pct = Math.round((b.count / max) * 100);
return `<div class="ins-ua-row">
<span class="ins-ua-icon">${iconFor(b.label)}</span>
<span class="ins-ua-label">${b.label}</span>
<div class="ins-ua-bar-wrap"><div class="ins-ua-bar" style="width:${pct}%;"></div></div>
<span class="ins-ua-cnt">${b.count}</span>
</div>`;
}).join('');
}
function _iconForDevice(label) {
if (/iPhone|Android phone|Mobile/i.test(label)) return '<i class="bi bi-phone"></i>';
if (/iPad|Tablet/i.test(label)) return '<i class="bi bi-tablet"></i>';
if (/Desktop/i.test(label)) return '<i class="bi bi-display"></i>';
return '<i class="bi bi-question-circle"></i>';
}
function _iconForBrowser(label) {
if (/Chrome/i.test(label)) return '<i class="bi bi-browser-chrome"></i>';
if (/Firefox/i.test(label)) return '<i class="bi bi-browser-firefox"></i>';
if (/Safari/i.test(label)) return '<i class="bi bi-browser-safari"></i>';
if (/Edge/i.test(label)) return '<i class="bi bi-browser-edge"></i>';
if (/Opera/i.test(label)) return '<i class="bi bi-globe2"></i>';
return '<i class="bi bi-globe"></i>';
}
function _iconForOs(label) {
if (/Windows/i.test(label)) return '<i class="bi bi-windows"></i>';
if (/macOS/i.test(label)) return '<i class="bi bi-apple"></i>';
if (/iOS|iPadOS/i.test(label)) return '<i class="bi bi-apple"></i>';
if (/Android/i.test(label)) return '<i class="bi bi-android2"></i>';
if (/Linux|ChromeOS/i.test(label)) return '<i class="bi bi-ubuntu"></i>';
return '<i class="bi bi-question-circle"></i>';
}
function openViewerDetail(who, name, avatar) {
const baseUrl = document.getElementById('vdb-insights')?.dataset.insightsBase || '';
_modalLoading('👤', name, 'Loading viewer details…');
document.getElementById('insModalIcon').innerHTML =
`<img src="${avatar}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;">`;
fetch(`${baseUrl}/viewer/${encodeURIComponent(who)}`, {
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
})
.then(r=>{ if(!r.ok) throw r; return r.json(); })
.then(d => {
const id = d.identity;
document.getElementById('insModalIcon').innerHTML =
`<img src="${id.avatar || avatar}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;">`;
document.getElementById('insModalTitle').textContent = id.name;
document.getElementById('insModalSubtitle').textContent =
`${_fmt(d.total)} view${d.total!==1?'s':''} · first ${_ago(d.first_at)} · last ${_ago(d.last_at)}`;
const countriesHtml = (d.countries||[]).map(c => `
<div class="ins-country-row" style="cursor:default;">
<div class="ins-country-flag">${_cflag(c.code)}</div>
<div class="ins-country-name">${c.name||c.code}</div>
<div class="ins-country-cnt" style="margin-left:auto;">${_fmt(c.count)}×</div>
</div>`).join('') || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data.</p>';
const profileBtn = (!id.is_guest && id.channel)
? `<div style="text-align:center;margin:-6px 0 14px;">
<a href="/channel/${id.channel}" class="action-btn" style="display:inline-flex;">
<i class="bi bi-person-circle"></i> <span>View profile</span>
</a>
</div>` : '';
const recentHtml = (d.recent||[]).slice(0, 20).map((r,i) => `
<div class="ins-recent-row">
<div style="font-size:11px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<span class="ins-recent-flag">${_cflag(r.country)}</span>
<div class="ins-recent-name" style="font-weight:500;">${_fmtDt(r.at)}</div>
<span class="ins-recent-time">${_ago(r.at)}</span>
</div>`).join('');
document.getElementById('insModalBody').innerHTML = `
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.total)}</div>
<div class="ins-modal-hero-label">total view${d.total!==1?'s':''} by this ${id.is_guest?'guest':'viewer'}</div>
</div>
${profileBtn}
<div class="ins-modal-stat">
<span class="ins-modal-stat-lbl"><i class="bi bi-calendar-check"></i> First seen</span>
<span class="ins-modal-stat-val">${_fmtDt(d.first_at)} <span style="color:var(--text-secondary);font-weight:400;">(${_ago(d.first_at)})</span></span>
</div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:14px;">
<span class="ins-modal-stat-lbl"><i class="bi bi-clock-history"></i> Last seen</span>
<span class="ins-modal-stat-val">${_fmtDt(d.last_at)} <span style="color:var(--text-secondary);font-weight:400;">(${_ago(d.last_at)})</span></span>
</div>
<div class="ins-modal-section"><i class="bi bi-globe2"></i> Locations</div>
${countriesHtml}
<div class="ins-modal-section"><i class="bi bi-phone"></i> Devices</div>
${_uaBucketsHtml(d.devices, _iconForDevice)}
<div class="ins-modal-section"><i class="bi bi-browser-chrome"></i> Browsers</div>
${_uaBucketsHtml(d.browsers, _iconForBrowser)}
<div class="ins-modal-section"><i class="bi bi-cpu"></i> Operating Systems</div>
${_uaBucketsHtml(d.os, _iconForOs)}
${recentHtml ? `
<div class="ins-modal-section"><i class="bi bi-list-ul"></i> Recent views ${d.recent.length>20?'(showing 20 of '+d.total+')':''}</div>
<div class="ins-recent-scroll" style="max-height:280px;">${recentHtml}</div>` : ''}
`;
})
.catch(() => {
document.getElementById('insModalBody').innerHTML =
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load viewer details.</p>';
});
}
// ══════════════════════════════════════════════════════
// DRILL-DOWN: SINGLE SHARE LINK
// ══════════════════════════════════════════════════════
function openShareDetail(token, sharerName) {
const baseUrl = document.getElementById('vdb-insights')?.dataset.insightsBase || '';
_modalLoading('🔗', sharerName + "'s share link", 'Loading link reach…');
fetch(`${baseUrl}/share/${encodeURIComponent(token)}`, {
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
})
.then(r=>{ if(!r.ok) throw r; return r.json(); })
.then(d => {
const s = d.sharer;
document.getElementById('insModalIcon').innerHTML = s.avatar
? `<img src="${s.avatar}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;">`
: '🔗';
document.getElementById('insModalTitle').textContent = `${s.name}'s share link`;
document.getElementById('insModalSubtitle').textContent = `Created ${_fmtDt(d.created_at)} · ${_ago(d.created_at)}`;
const countriesHtml = (d.countries||[]).length
? d.countries.map(c => `
<div class="ins-country-row" style="cursor:default;">
<div class="ins-country-flag">${_cflag(c.code)}</div>
<div class="ins-country-name">${c.name||c.code}</div>
<div class="ins-country-cnt" style="margin-left:auto;">${_fmt(c.count)}×</div>
</div>`).join('')
: '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data yet.</p>';
const recentHtml = (d.recent||[]).slice(0, 50).map((r,i) => `
<div class="ins-recent-row" style="flex-wrap:wrap;">
<div style="font-size:11px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<span class="ins-recent-flag">${_cflag(r.country)}</span>
<div class="ins-recent-name" style="font-weight:500;">${_fmtDt(r.at)}</div>
<span class="ins-recent-time">${_ago(r.at)}</span>
<div style="flex-basis:100%;font-size:11px;color:var(--text-secondary);padding-left:34px;">${_uaInline(r)}</div>
</div>`).join('');
const profileBtn = (!s.is_guest && s.channel)
? `<div style="text-align:center;margin:-6px 0 14px;">
<a href="/channel/${s.channel}" class="action-btn" style="display:inline-flex;">
<i class="bi bi-person-circle"></i> <span>View ${s.name}'s profile</span>
</a>
</div>` : '';
// Devices / browsers / OS sections only render if we have at least one bucket
const devSection = (d.devices||[]).length ? `
<div class="ins-modal-section"><i class="bi bi-phone"></i> Devices</div>
${_uaBucketsHtml(d.devices, _iconForDevice)}` : '';
const brSection = (d.browsers||[]).length ? `
<div class="ins-modal-section"><i class="bi bi-browser-chrome"></i> Browsers</div>
${_uaBucketsHtml(d.browsers, _iconForBrowser)}` : '';
const osSection = (d.os||[]).length ? `
<div class="ins-modal-section"><i class="bi bi-cpu"></i> Operating Systems</div>
${_uaBucketsHtml(d.os, _iconForOs)}` : '';
document.getElementById('insModalBody').innerHTML = `
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.reach)}</div>
<div class="ins-modal-hero-label">unique device${d.reach===1?'':'s'} came from this link</div>
</div>
${profileBtn}
<div class="ins-modal-stat">
<span class="ins-modal-stat-lbl"><i class="bi bi-person"></i> Shared by</span>
<span class="ins-modal-stat-val">${s.name}${s.is_guest?' (guest)':''}</span>
</div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:14px;">
<span class="ins-modal-stat-lbl"><i class="bi bi-link-45deg"></i> Link created</span>
<span class="ins-modal-stat-val">${_fmtDt(d.created_at)} <span style="color:var(--text-secondary);font-weight:400;">(${_ago(d.created_at)})</span></span>
</div>
<div class="ins-modal-section"><i class="bi bi-globe2"></i> Where viewers came from</div>
${countriesHtml}
${devSection}
${brSection}
${osSection}
${recentHtml ? `
<div class="ins-modal-section"><i class="bi bi-clock-history"></i> Recent visits ${d.recent.length>=50?'(showing 50 of '+d.reach+')':''}</div>
<div class="ins-recent-scroll" style="max-height:320px;">${recentHtml}</div>
` : '<p style="font-size:13px;color:var(--text-secondary);margin:14px 0 0;">No one has opened this link yet.</p>'}
<p style="font-size:11px;color:var(--text-secondary);margin:14px 0 0;line-height:1.5;">
The same device opening this link again is counted only once VPN IP changes don't inflate the count.
</p>
`;
})
.catch(() => {
document.getElementById('insModalBody').innerHTML =
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load share link details.</p>';
});
}
</script> </script>
@endif @endif

View File

@ -7,12 +7,8 @@
]) ])
@php @php
$orientationClass = match($video->orientation ?? 'landscape') { // Force 16:9 for every video — orientation classes intentionally disabled
'portrait' => 'portrait', $orientationClass = '';
'square' => 'square',
'ultrawide' => 'ultrawide',
default => '',
};
$hlsUrl = $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null; $hlsUrl = $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null;
$mp4Url = route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp; $mp4Url = route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp;
$nextUrl = $nextVideo && $playlist ? route('videos.show', $nextVideo) .'?playlist='.$playlist->share_token : null; $nextUrl = $nextVideo && $playlist ? route('videos.show', $nextVideo) .'?playlist='.$playlist->share_token : null;
@ -35,7 +31,9 @@
{{-- ══════════════════════════════════════════════ {{-- ══════════════════════════════════════════════
PLAYER WRAP theater class toggled by JS PLAYER WRAP theater class toggled by JS
══════════════════════════════════════════════ --}} ══════════════════════════════════════════════ --}}
<div class="ytp-wrap {{ $orientationClass }}" id="ytpWrap"> <div class="ytp-wrap {{ $orientationClass }}" id="ytpWrap"
data-progress-url="{{ route('videos.viewProgress', $video) }}"
data-video-id="{{ $video->id }}">
<div class="ytp" id="videoContainer" tabindex="0"> <div class="ytp" id="videoContainer" tabindex="0">
{{-- ── Video element ── --}} {{-- ── Video element ── --}}
@ -1399,6 +1397,73 @@ function init() {
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);
// ── View-progress heartbeat ──────────────────────────────
(function() {
const _csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
let _hbVideoId = null;
let _hbUrl = null;
let _hbLast = 0;
let _hbCompleted = false;
function _refreshHbTarget() {
const wrap = document.getElementById('ytpWrap');
if (!wrap) return;
const vid = parseInt(wrap.dataset.videoId || '0', 10);
if (vid && vid !== _hbVideoId) {
_hbVideoId = vid;
_hbUrl = wrap.dataset.progressUrl || null;
_hbLast = 0;
_hbCompleted = false;
}
}
function _sendHb(completed) {
if (!_hbUrl) return;
const v = document.getElementById('videoPlayer');
if (!v) return;
const cur = Math.floor(v.currentTime || 0);
if (!completed && cur <= _hbLast) return;
const body = new URLSearchParams({ watched_seconds: cur, completed: completed ? '1' : '0' });
try {
if (completed && navigator.sendBeacon) {
const blob = new Blob([body.toString() + '&_token=' + _csrf], { type: 'application/x-www-form-urlencoded' });
navigator.sendBeacon(_hbUrl, blob);
} else {
fetch(_hbUrl, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': _csrf, 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
body: body.toString(),
keepalive: true,
credentials: 'same-origin',
}).catch(function() {});
}
_hbLast = cur;
if (completed) _hbCompleted = true;
} catch (e) {}
}
function _hbBind() {
const v = document.getElementById('videoPlayer');
if (!v || v._hbBound) return;
v._hbBound = true;
_refreshHbTarget();
v.addEventListener('ended', () => _sendHb(true));
v.addEventListener('pause', () => _sendHb(false));
}
setInterval(function() { _refreshHbTarget(); if (!_hbCompleted) _sendHb(false); }, 5000);
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'hidden') _sendHb(false);
});
window.addEventListener('pagehide', function() { _sendHb(false); });
document.addEventListener('DOMContentLoaded', _hbBind);
// also rebind after SPA source swaps
const _origLoad = window._ytpLoadSource;
if (typeof _origLoad === 'function') {
window._ytpLoadSource = function() {
_refreshHbTarget();
return _origLoad.apply(this, arguments);
};
}
})();
})(); })();
</script> </script>
@endonce @endonce

View File

@ -12,9 +12,10 @@
</p> </p>
@if($video->thumbnail) @if($video->thumbnail)
@php($thumbEmbed = \App\Support\EmailThumbnail::localPath($video))
<div class="email-thumb-wrap"> <div class="email-thumb-wrap">
<a href="{{ route('videos.show', $video) }}"> <a href="{{ route('videos.show', $video) }}">
<img src="{{ route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}"> <img src="{{ $thumbEmbed ? $message->embed($thumbEmbed) : route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}">
</a> </a>
</div> </div>
@endif @endif

View File

@ -7,10 +7,11 @@
<p class="email-subtitle">A channel you subscribed to just posted new content.</p> <p class="email-subtitle">A channel you subscribed to just posted new content.</p>
{{-- Thumbnail --}} {{-- Thumbnail --}}
@php($thumbEmbed = \App\Support\EmailThumbnail::localPath($video))
<div class="email-thumb-wrap"> <div class="email-thumb-wrap">
@if($video->thumbnail) @if($video->thumbnail)
<a href="{{ route('videos.show', $video) }}"> <a href="{{ route('videos.show', $video) }}">
<img src="{{ route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}"> <img src="{{ $thumbEmbed ? $message->embed($thumbEmbed) : route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}">
</a> </a>
@else @else
<div class="email-thumb-placeholder"> <div class="email-thumb-placeholder">

View File

@ -23,10 +23,11 @@
@endif @endif
{{-- Cover --}} {{-- Cover --}}
@php($thumbEmbed = \App\Support\EmailThumbnail::localPath($video))
<div class="email-thumb-wrap"> <div class="email-thumb-wrap">
@if($video->thumbnail) @if($video->thumbnail)
<a href="{{ $shareUrl }}"> <a href="{{ $shareUrl }}">
<img src="{{ route('media.thumbnail', $video->thumbnail) }}" alt="{{ $shareTitle }}"> <img src="{{ $thumbEmbed ? $message->embed($thumbEmbed) : route('media.thumbnail', $video->thumbnail) }}" alt="{{ $shareTitle }}">
</a> </a>
@else @else
<div class="email-thumb-placeholder"> <div class="email-thumb-placeholder">

View File

@ -13,10 +13,11 @@
</p> </p>
{{-- Thumbnail --}} {{-- Thumbnail --}}
@php($thumbEmbed = \App\Support\EmailThumbnail::localPath($video))
<div class="email-thumb-wrap"> <div class="email-thumb-wrap">
@if($video->thumbnail) @if($video->thumbnail)
<a href="{{ route('videos.show', $video) }}"> <a href="{{ route('videos.show', $video) }}">
<img src="{{ route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}"> <img src="{{ $thumbEmbed ? $message->embed($thumbEmbed) : route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}">
</a> </a>
@else @else
<div class="email-thumb-placeholder"> <div class="email-thumb-placeholder">

View File

@ -791,6 +791,31 @@
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2); 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) ── */ /* ── Playlist controls bar (sidebar, all video types) ── */
.pl-controls-bar { .pl-controls-bar {
display: flex; display: flex;
@ -1021,6 +1046,11 @@
</style> </style>
@yield('extra_styles') @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> </head>
<body class="{{ $bodyClass ?? '' }} {{ session('impersonator_id') ? 'has-impersonate-bar' : '' }}"> <body class="{{ $bodyClass ?? '' }} {{ session('impersonator_id') ? 'has-impersonate-bar' : '' }}">
<!-- Header --> <!-- Header -->
@ -1065,13 +1095,14 @@
@auth @auth
@include('layouts.partials.upload-modal') @include('layouts.partials.upload-modal')
@include('layouts.partials.edit-video-modal') @include('layouts.partials.edit-video-modal')
@include('layouts.partials.sports-match-modal')
@endauth @endauth
<!-- Add to Playlist Modal - Available for all users (shows login prompt if not authenticated) --> <!-- Add to Playlist Modal - Available for all users (shows login prompt if not authenticated) -->
@include('layouts.partials.add-to-playlist-modal') @include('layouts.partials.add-to-playlist-modal')
<!-- Share Modal - Available on all pages --> <!-- Share Modal - Available on all pages -->
@include('layouts.partials.share-modal') <x-share-modal />
<!-- Delete Video Modal --> <!-- Delete Video Modal -->
@auth @auth
@ -1118,8 +1149,7 @@
</label> </label>
<input type="text" id="deleteOtpInput" inputmode="numeric" pattern="[0-9]*" <input type="text" id="deleteOtpInput" inputmode="numeric" pattern="[0-9]*"
maxlength="6" autocomplete="one-time-code" maxlength="6" autocomplete="one-time-code"
class="form-input" class="delete-otp-input"
style="letter-spacing: 0.3em; font-size: 22px; text-align: center;"
placeholder="000000"> placeholder="000000">
</div> </div>
@endif @endif
@ -1426,6 +1456,7 @@
case 'new_user': return '&#x1F389; ' + actor + ' just joined TAKEONE!'; case 'new_user': return '&#x1F389; ' + actor + ' just joined TAKEONE!';
case 'new_subscriber': return '&#x1F514; ' + actor + ' subscribed to your channel'; case 'new_subscriber': return '&#x1F514; ' + actor + ' subscribed to your channel';
case 'video_like': return '&#x2764;&#xFE0F; ' + actor + ' liked your video ' + title; case 'video_like': return '&#x2764;&#xFE0F; ' + actor + ' liked your video ' + title;
case 'video_shared': return '&#x1F4E4; ' + actor + ' shared a video with you: ' + title + (d.message ? ' <em class="yt-notif-preview">"' + escHtml(d.message) + '"</em>' : '');
case 'new_post': return '&#x1F4DD; ' + actor + ' posted something new'; case 'new_post': return '&#x1F4DD; ' + actor + ' posted something new';
default: return actor + ' uploaded a new video: ' + title; default: return actor + ' uploaded a new video: ' + title;
} }
@ -2110,5 +2141,34 @@
})(); })();
</script> </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> </body>
</html> </html>

View File

@ -0,0 +1,887 @@
{{-- ════════════════════════════════════════════════════════════════════════
Create / Edit Sports Match progressive-disclosure modal.
Opened from the front-end "Sports" chooser card via openSportsMatchModal().
A match always belongs to one of the user's videos (video_id required).
Only the basic section is needed for a first (draft) save; everything else
lives in collapsed sections and can be completed later by editing the same
record. Bootstrap 5 modal + collapsible blocks. Field names map 1:1 to
SportsMatchController validation.
════════════════════════════════════════════════════════════════════════ --}}
<div class="modal fade" id="sportsMatchModal" tabindex="-1" aria-labelledby="sportsMatchModalLabel"
aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false" data-bs-theme="dark">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content sm-content">
<div class="modal-header sm-header">
<div class="d-flex align-items-center gap-2">
<span class="sm-header-icon"><i class="bi bi-trophy-fill"></i></span>
<div>
<h5 class="modal-title mb-0" id="sportsMatchModalLabel">Create Sports Match</h5>
<small class="text-secondary" id="sm-status-chip">Draft</small>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{-- Friendly note --}}
<div class="alert sm-note d-flex align-items-start gap-2" role="alert">
<i class="bi bi-info-circle-fill mt-1"></i>
<div>Save the basic match info now you can add scores, stats, officials and more later by editing this match.</div>
</div>
<form id="sportsMatchForm" novalidate>
@csrf
<input type="hidden" name="match_id" id="sm-match-id" value="">
<input type="hidden" name="video_id" id="sm-video-id" value="">
<input type="hidden" name="status" id="sm-status" value="draft">
{{-- Video upload (create) --}}
<div class="sm-group" id="sm-video-create">
<div class="sm-group-title"><i class="bi bi-film"></i> Match video <span class="text-danger">*</span></div>
<div class="sm-video-drop" id="sm-video-drop">
<input type="file" id="sm-video-file" accept="video/mp4,video/webm,video/ogg,video/quicktime,.mp4,.webm,.ogg,.mov,.avi,.wmv,.flv,.mkv" hidden>
<div id="sm-video-idle" class="sm-video-idle">
<i class="bi bi-cloud-arrow-up"></i>
<div>Click to choose the match video</div>
<small class="text-secondary">MP4, MOV, MKV, WebM, AVI…</small>
</div>
<div id="sm-video-picked" class="sm-video-picked d-none">
<i class="bi bi-film"></i>
<div class="sm-video-meta">
<span class="sm-video-name" id="sm-video-name"></span>
<span class="sm-video-size text-secondary" id="sm-video-size"></span>
</div>
<button type="button" class="btn-close btn-close-white" id="sm-video-clear" aria-label="Remove"></button>
</div>
</div>
<div class="text-danger small mt-2 d-none" id="sm-video-err">Please choose a video to upload.</div>
</div>
{{-- Attached video note (edit) --}}
<div class="sm-group d-none" id="sm-video-attached">
<div class="sm-group-title"><i class="bi bi-film"></i> Match video</div>
<div class="sm-attached d-flex align-items-center gap-2">
<i class="bi bi-play-circle-fill"></i>
<span id="sm-video-attached-title" class="text-truncate">Attached video</span>
</div>
</div>
{{-- Match details --}}
<div class="sm-group">
<div class="sm-group-title"><i class="bi bi-card-text"></i> Match details</div>
<div class="mb-3">
<label for="sm-title" class="form-label">Match title <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="title" id="sm-title" placeholder="e.g. Final — Ali vs. Khan" required>
<div class="invalid-feedback" data-field="title"></div>
</div>
<div class="mb-3">
<label for="sm-event" class="form-label">Event name</label>
<input type="text" class="form-control" name="event_name" id="sm-event" placeholder="e.g. National Championship 2026">
</div>
<div class="row g-3">
<div class="col-sm-6">
<label for="sm-date" class="form-label">Date</label>
<input type="date" class="form-control" name="match_date" id="sm-date">
</div>
<div class="col-sm-6">
<label for="sm-time" class="form-label">Time</label>
<input type="time" class="form-control" name="match_time" id="sm-time">
<div class="invalid-feedback" data-field="match_time"></div>
</div>
</div>
</div>
{{-- Participants --}}
<div class="sm-group">
<div class="sm-group-title"><i class="bi bi-people-fill"></i> Participants</div>
<div class="row g-3">
<div class="col-md-6">
<div class="sm-pcard">
<div class="sm-pcard-head"><span class="sm-pcard-badge sm-badge-1">1</span> Participant 1</div>
<x-sports-image name="media_participant1_photo" label="Photo" class="sm-img-lg" />
<input type="text" class="form-control" name="participant1_name" placeholder="Name">
<input type="text" class="form-control" name="participants[p1_club]" placeholder="Club / team name">
<label class="sm-mini-label">Club logo</label>
<x-sports-image name="media_club1_logo" label="Logo" class="sm-img-sm" />
</div>
</div>
<div class="col-md-6">
<div class="sm-pcard">
<div class="sm-pcard-head"><span class="sm-pcard-badge sm-badge-2">2</span> Participant 2</div>
<x-sports-image name="media_participant2_photo" label="Photo" class="sm-img-lg" />
<input type="text" class="form-control" name="participant2_name" placeholder="Name">
<input type="text" class="form-control" name="participants[p2_club]" placeholder="Club / team name">
<label class="sm-mini-label">Club logo</label>
<x-sports-image name="media_club2_logo" label="Logo" class="sm-img-sm" />
</div>
</div>
</div>
</div>
{{-- Referee --}}
<div class="sm-group">
<div class="sm-group-title"><i class="bi bi-person-badge-fill"></i> Referee</div>
<div class="d-flex align-items-center gap-3">
<x-sports-image name="media_referee_photo" label="Photo" class="sm-img-avatar" />
<div class="flex-grow-1">
<label for="sm-referee" class="form-label">Referee name</label>
<input type="text" class="form-control" name="referee_name" id="sm-referee" placeholder="Referee name">
</div>
</div>
</div>
{{-- Result --}}
<div class="sm-group">
<div class="sm-group-title"><i class="bi bi-flag-fill"></i> Result</div>
<label for="sm-score" class="form-label">Final score</label>
<input type="text" class="form-control" name="result[final_result]" id="sm-score" placeholder="e.g. 3 1">
</div>
{{-- ── ADVANCED (revealed only when editing) ───────────────────── --}}
<div id="sm-advanced" class="d-none">
<p class="sm-optional-head">
<i class="bi bi-sliders"></i> Advanced details
<span class="text-secondary"> optional, open only what you need</span>
</p>
<div class="accordion sm-accordion" id="sportsAccordion">
{{-- Match info (sport, type, venue) --}}
<x-sports-section id="meta" title="Match info" icon="bi-info-circle">
<div class="mb-3">
<label for="sm-sport" class="form-label">Sport</label>
<input type="text" class="form-control" name="sport" id="sm-sport" list="sm-sport-list" placeholder="e.g. Boxing, Football, Tennis…">
<datalist id="sm-sport-list">
<option value="Boxing"><option value="MMA"><option value="Kickboxing">
<option value="Wrestling"><option value="Judo"><option value="Taekwondo">
<option value="Football"><option value="Basketball"><option value="Tennis">
<option value="Volleyball"><option value="Badminton"><option value="Cricket">
<option value="Rugby"><option value="Hockey"><option value="Baseball">
<option value="Athletics"><option value="Swimming"><option value="Cycling">
<option value="Esports">
</datalist>
<div class="form-text">Sets the label for the Segments section (Rounds, Sets, Periods…).</div>
</div>
<div class="mb-3"><label class="form-label">Match type</label>
<input type="text" class="form-control" name="match_type" placeholder="Final, Semi-final, Friendly…"></div>
<div class="mb-0"><label class="form-label">Venue name</label>
<input type="text" class="form-control" name="venue_name" placeholder="e.g. City Arena"></div>
</x-sports-section>
{{-- Competition --}}
<x-sports-section id="comp" title="Competition Details" icon="bi-award">
<div class="mb-3"><label class="form-label">Competition name</label>
<input type="text" class="form-control" name="competition[name]"></div>
<div class="mb-3"><label class="form-label">Competition type</label>
<input type="text" class="form-control" name="competition[type]" placeholder="League, Cup, Tournament…"></div>
<div class="row g-3 mb-3">
<div class="col-sm-6"><label class="form-label">Stage</label>
<input type="text" class="form-control" name="competition[stage]" placeholder="Group, Quarter-final…"></div>
<div class="col-sm-6"><label class="form-label">Season</label>
<input type="text" class="form-control" name="competition[season]" placeholder="2025/26"></div>
</div>
<div class="mb-3"><label class="form-label">Organizer</label>
<input type="text" class="form-control" name="competition[organizer]"></div>
<div class="mb-0"><label class="form-label">Championship / league / tournament name</label>
<input type="text" class="form-control" name="competition[championship_name]"></div>
</x-sports-section>
{{-- Participants details (clubs are in the basic cards) --}}
<x-sports-section id="part" title="Participants Details" icon="bi-people">
<div class="sm-subcard">
<div class="sm-subcard-title">Participant 1</div>
<div class="row g-3">
<div class="col-sm-6"><label class="form-label">Type</label>
<input type="text" class="form-control" name="participants[p1_type]" placeholder="Individual / Team / Pair"></div>
<div class="col-sm-6"><label class="form-label">Country</label>
<input type="text" class="form-control" name="participants[p1_country]"></div>
<div class="col-sm-6"><label class="form-label">Role</label>
<input type="text" class="form-control" name="participants[p1_role]" placeholder="home, away, red, blue…"></div>
</div>
</div>
<div class="sm-subcard">
<div class="sm-subcard-title">Participant 2</div>
<div class="row g-3">
<div class="col-sm-6"><label class="form-label">Type</label>
<input type="text" class="form-control" name="participants[p2_type]" placeholder="Individual / Team / Pair"></div>
<div class="col-sm-6"><label class="form-label">Country</label>
<input type="text" class="form-control" name="participants[p2_country]"></div>
<div class="col-sm-6"><label class="form-label">Role</label>
<input type="text" class="form-control" name="participants[p2_role]" placeholder="home, away, red, blue…"></div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-sm-4"><label class="form-label">Weight class / division</label>
<input type="text" class="form-control" name="participants[weight_class]"></div>
<div class="col-sm-4"><label class="form-label">Gender division</label>
<input type="text" class="form-control" name="participants[gender_division]" placeholder="Men, Women, Mixed"></div>
<div class="col-sm-4"><label class="form-label">Level</label>
<select class="form-select" name="participants[level]">
<option value=""></option><option>Amateur</option><option>Professional</option>
</select></div>
</div>
<label class="form-label">Extra participants</label>
<div class="form-text mb-2">For relays, doubles or multi-competitor formats.</div>
<div id="sm-extra-list"></div>
<button type="button" class="btn btn-sm btn-outline-light" data-sm-add="extra">
<i class="bi bi-plus-lg"></i> Add participant
</button>
</x-sports-section>
{{-- Additional officials (referee is in the basic section) --}}
<x-sports-section id="off" title="Officials" icon="bi-person-badge">
<div class="form-text mb-2">Judges, umpires, linesmen, doctor, supervisor, timekeeper…</div>
<div id="sm-officials-list"></div>
<button type="button" class="btn btn-sm btn-outline-light" data-sm-add="official">
<i class="bi bi-plus-lg"></i> Add official
</button>
</x-sports-section>
{{-- Venue details --}}
<x-sports-section id="venue" title="Venue Details" icon="bi-geo-alt">
<div class="mb-3"><label class="form-label">Full address</label>
<input type="text" class="form-control" name="venue[address]"></div>
<div class="row g-3 mb-3">
<div class="col-sm-6"><label class="form-label">City</label>
<input type="text" class="form-control" name="venue[city]"></div>
<div class="col-sm-6"><label class="form-label">Country</label>
<input type="text" class="form-control" name="venue[country]"></div>
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6"><label class="form-label">GPS latitude</label>
<input type="text" class="form-control" name="venue[lat]" placeholder="e.g. 26.2235"></div>
<div class="col-sm-6"><label class="form-label">GPS longitude</label>
<input type="text" class="form-control" name="venue[lng]" placeholder="e.g. 50.5876"></div>
</div>
<div class="mb-0"><label class="form-label">Venue notes</label>
<textarea class="form-control" rows="2" name="venue[notes]"></textarea></div>
</x-sports-section>
{{-- Result details (final score is in the basic section) --}}
<x-sports-section id="result" title="Result Details" icon="bi-flag">
<div class="row g-3 mb-3">
<div class="col-sm-6"><label class="form-label">Winner</label>
<input type="text" class="form-control" name="result[winner]"></div>
<div class="col-sm-6"><label class="form-label">Outcome type</label>
<input type="text" class="form-control" name="result[outcome_type]" placeholder="KO, decision, draw, walkover…"></div>
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6"><label class="form-label">Rank / placement</label>
<input type="text" class="form-control" name="result[rank]"></div>
</div>
<div class="mb-0"><label class="form-label">Result notes</label>
<textarea class="form-control" rows="2" name="result[notes]"></textarea></div>
</x-sports-section>
{{-- Segments --}}
<x-sports-section id="seg" title="Segments" icon="bi-list-ol">
<div class="form-text mb-2">Generic segments rounds, periods, sets, halves, quarters, laps, innings or maps.</div>
<div id="sm-segments-list"></div>
<button type="button" class="btn btn-sm btn-outline-light" data-sm-add="segment">
<i class="bi bi-plus-lg"></i> <span data-sm-seg-add>Add segment</span>
</button>
</x-sports-section>
{{-- Statistics --}}
<x-sports-section id="stat" title="Statistics" icon="bi-bar-chart">
<div id="sm-stats-list"></div>
<button type="button" class="btn btn-sm btn-outline-light" data-sm-add="statistic">
<i class="bi bi-plus-lg"></i> Add statistic
</button>
</x-sports-section>
{{-- More photos (event poster + image meta) --}}
<x-sports-section id="media" title="More Photos" icon="bi-images">
<div class="form-text mb-3">Participant, club and referee images are in the sections above. Add an event poster and image metadata here.</div>
<label class="form-label">Event poster / banner</label>
<x-sports-image name="media_event_poster" label="Upload &amp; crop" class="sm-img-wide mb-3" />
<div class="mb-3"><label class="form-label">Image caption</label>
<input type="text" class="form-control" name="media[caption]"></div>
<div class="row g-3 mb-3">
<div class="col-sm-6"><label class="form-label">Image alt text</label>
<input type="text" class="form-control" name="media[alt]"></div>
<div class="col-sm-6"><label class="form-label">Image credit</label>
<input type="text" class="form-control" name="media[credit]"></div>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="sm-media-public" name="media[public]" value="1">
<label class="form-check-label" for="sm-media-public">Display these images publicly</label>
</div>
</x-sports-section>
{{-- Reviews --}}
<x-sports-section id="rev" title="Reviews &amp; Notes" icon="bi-clipboard-check">
<div class="row g-3 mb-3">
<div class="col-sm-6"><label class="form-label">Review type</label>
<input type="text" class="form-control" name="reviews[review_type]" placeholder="VAR, protest, appeal…"></div>
<div class="col-sm-6"><label class="form-label">Requested by</label>
<input type="text" class="form-control" name="reviews[requested_by]"></div>
</div>
<div class="mb-3"><label class="form-label">Review result</label>
<input type="text" class="form-control" name="reviews[review_result]"></div>
<div class="mb-3"><label class="form-label">Source URL</label>
<input type="url" class="form-control" name="reviews[source_url]" placeholder="https://…"></div>
<div class="mb-3"><label class="form-label">Verification notes</label>
<textarea class="form-control" rows="2" name="reviews[verification_notes]"></textarea></div>
<div class="mb-0"><label class="form-label">Admin notes</label>
<textarea class="form-control" rows="2" name="reviews[admin_notes]"></textarea></div>
</x-sports-section>
</div>
</div>{{-- /advanced --}}
</form>
</div>{{-- /modal-body --}}
<div class="modal-footer sm-footer">
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-light" id="sm-save-basic">
<i class="bi bi-save"></i> Save basic info
</button>
<button type="button" class="btn btn-danger" id="sm-save-continue">
<i class="bi bi-pencil-square"></i> Save &amp; continue editing
</button>
</div>
</div>
</div>
</div>
{{-- ── Repeatable row templates ─────────────────────────────────────────── --}}
<template id="sm-tpl-official">
<div class="sm-row" data-sm-row="official">
<div class="row g-2 align-items-start">
<div class="col-sm-4"><input type="text" class="form-control form-control-sm" data-n="role" placeholder="Role (referee, judge…)"></div>
<div class="col-sm-4"><input type="text" class="form-control form-control-sm" data-n="name" placeholder="Name"></div>
<div class="col-sm-3">
<div class="sm-img-field" data-img="photo">
<input type="file" data-n="photo" accept=".jpg,.jpeg,.png,.webp" hidden>
<input type="hidden" data-n="photo_existing">
<button type="button" class="sm-img-drop sm-img-drop-sm" data-sm-official-crop>
<img class="sm-img-preview sm-img-preview-sm d-none" alt="">
<span class="sm-img-ph"><i class="bi bi-crop"></i><span>Photo</span></span>
</button>
</div>
</div>
<div class="col-sm-1 text-end">
<button type="button" class="btn btn-sm btn-outline-danger" data-sm-remove><i class="bi bi-x-lg"></i></button>
</div>
</div>
</div>
</template>
<template id="sm-tpl-segment">
<div class="sm-row" data-sm-row="segment">
<div class="row g-2 align-items-start">
<div class="col-sm-3"><input type="text" class="form-control form-control-sm" data-n="type" placeholder="Type (round, set…)"></div>
<div class="col-sm-2"><input type="text" class="form-control form-control-sm" data-n="number" placeholder="No."></div>
<div class="col-sm-3"><input type="text" class="form-control form-control-sm" data-n="score" placeholder="Score / result"></div>
<div class="col-sm-3"><input type="text" class="form-control form-control-sm" data-n="winner" placeholder="Winner"></div>
<div class="col-sm-1 text-end"><button type="button" class="btn btn-sm btn-outline-danger" data-sm-remove><i class="bi bi-x-lg"></i></button></div>
<div class="col-12"><input type="text" class="form-control form-control-sm" data-n="notes" placeholder="Notes (optional)"></div>
</div>
</div>
</template>
<template id="sm-tpl-statistic">
<div class="sm-row" data-sm-row="statistic">
<div class="row g-2 align-items-start">
<div class="col-sm-3"><input type="text" class="form-control form-control-sm" data-n="name" placeholder="Statistic name"></div>
<div class="col-sm-3"><input type="text" class="form-control form-control-sm" data-n="value" placeholder="Value"></div>
<div class="col-sm-3"><input type="text" class="form-control form-control-sm" data-n="owner" placeholder="Owner (who/which side)"></div>
<div class="col-sm-2"><input type="text" class="form-control form-control-sm" data-n="notes" placeholder="Notes"></div>
<div class="col-sm-1 text-end"><button type="button" class="btn btn-sm btn-outline-danger" data-sm-remove><i class="bi bi-x-lg"></i></button></div>
</div>
</div>
</template>
{{-- ── Image croppers (outside the form so their inner file inputs aren't submitted) ──
Six form-mode croppers write the cropped file straight onto each hidden input;
one callback-mode cropper serves all dynamic official rows. --}}
<x-image-cropper id="smc_media_participant1_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Participant 1 photo" target-input="sm-file-media_participant1_photo" preview-img="sm-prev-media_participant1_photo" />
<x-image-cropper id="smc_media_participant2_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Participant 2 photo" target-input="sm-file-media_participant2_photo" preview-img="sm-prev-media_participant2_photo" />
<x-image-cropper id="smc_media_referee_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Referee photo" target-input="sm-file-media_referee_photo" preview-img="sm-prev-media_referee_photo" />
<x-image-cropper id="smc_media_club1_logo" :width="380" :height="380" shape="square" output-width="600" title="Crop Club / team 1 logo" target-input="sm-file-media_club1_logo" preview-img="sm-prev-media_club1_logo" />
<x-image-cropper id="smc_media_club2_logo" :width="380" :height="380" shape="square" output-width="600" title="Crop Club / team 2 logo" target-input="sm-file-media_club2_logo" preview-img="sm-prev-media_club2_logo" />
<x-image-cropper id="smc_media_event_poster" :width="448" :height="252" shape="square" output-width="1280" title="Crop Event poster / banner" target-input="sm-file-media_event_poster" preview-img="sm-prev-media_event_poster" />
<x-image-cropper id="smc_official" :width="360" :height="360" shape="square" output-width="500" title="Crop Official photo" result-callback="smOfficialCropDone" />
<template id="sm-tpl-extra">
<div class="sm-row" data-sm-row="extra">
<div class="row g-2 align-items-start">
<div class="col-sm-3"><input type="text" class="form-control form-control-sm" data-n="name" placeholder="Name"></div>
<div class="col-sm-2"><input type="text" class="form-control form-control-sm" data-n="type" placeholder="Type"></div>
<div class="col-sm-3"><input type="text" class="form-control form-control-sm" data-n="club" placeholder="Club / team"></div>
<div class="col-sm-2"><input type="text" class="form-control form-control-sm" data-n="country" placeholder="Country"></div>
<div class="col-sm-1"><input type="text" class="form-control form-control-sm" data-n="role" placeholder="Role"></div>
<div class="col-sm-1 text-end"><button type="button" class="btn btn-sm btn-outline-danger" data-sm-remove><i class="bi bi-x-lg"></i></button></div>
</div>
</div>
</template>
<style>
.sm-content { background: #181818; border: 1px solid #2a2a2a; }
.sm-header { border-bottom: 1px solid #262626; }
.sm-header-icon {
width: 40px; height: 40px; border-radius: 11px; flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
background: rgba(245,158,11,.14); color: #f59e0b; font-size: 19px;
}
.sm-note { background: rgba(230,30,30,.08); border: 1px solid rgba(230,30,30,.18); color: #d8d8d8; font-size: 13px; }
.sm-note i { color: #e61e1e; }
.sm-basic .form-label { font-weight: 600; font-size: 13px; }
.sm-optional-head { margin: 22px 0 10px; font-size: 13px; font-weight: 700; color: #9a9a9a; }
.sm-optional-head i { color: #e61e1e; }
.sm-accordion .accordion-item { background: #141414; border: 1px solid #262626; margin-bottom: 8px; border-radius: 10px !important; overflow: hidden; }
.sm-accordion .accordion-button { background: #141414; color: #e8e8e8; font-weight: 600; font-size: 14px; box-shadow: none; }
.sm-accordion .accordion-button:not(.collapsed) { background: #1b1414; color: #fff; }
.sm-accordion .accordion-button:focus { box-shadow: none; border-color: transparent; }
.sm-accordion .accordion-button::after { filter: invert(1) grayscale(1) brightness(1.4); }
.sm-accordion .accordion-button .sm-acc-ico { color: #e61e1e; margin-right: 10px; font-size: 16px; }
.sm-accordion .accordion-body { background: #181818; }
.sm-accordion .badge { font-weight: 600; }
.sm-subcard { background: #111; border: 1px solid #242424; border-radius: 10px; padding: 14px; margin-bottom: 14px; }
.sm-subcard-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: #e61e1e; margin-bottom: 10px; }
.sm-row { background: #111; border: 1px solid #242424; border-radius: 10px; padding: 12px; margin-bottom: 10px; }
.sm-img-field { position: relative; }
.sm-img-preview { display: block; width: 100%; max-height: 130px; object-fit: cover; border-radius: 8px; margin-bottom: 8px; border: 1px solid #2a2a2a; }
.sm-img-preview-sm { max-height: 64px; }
.sm-hr { border-color: #262626; }
.sm-footer { border-top: 1px solid #262626; }
.sm-footer .btn-danger { background: #e61e1e; border-color: #e61e1e; }
.sm-footer .btn-danger:hover { background: #c81818; border-color: #c81818; }
#sportsMatchModal .form-text { color: #6f6f6f; font-size: 12px; }
/* Video upload dropzone */
.sm-video-drop { border: 1.5px dashed #2f2f2f; border-radius: 12px; background: #111; cursor: pointer; transition: border-color .15s, background .15s; }
.sm-video-drop:hover { border-color: #e61e1e; background: #161313; }
.sm-video-drop.sm-invalid { border-color: #dc3545; }
.sm-video-idle { padding: 22px 16px; text-align: center; color: #8a8a8a; }
.sm-video-idle i { font-size: 26px; color: #e61e1e; display: block; margin-bottom: 6px; }
.sm-video-idle div { font-size: 13px; font-weight: 600; color: #cfcfcf; }
.sm-video-picked { display: flex; align-items: center; gap: 12px; padding: 14px 16px; }
.sm-video-picked > i { font-size: 22px; color: #e61e1e; }
.sm-video-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.sm-video-name { font-size: 13px; font-weight: 600; color: #e8e8e8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sm-video-size { font-size: 12px; }
/* Cropper-backed image fields */
.sm-img-drop { width: 100%; border: 1.5px dashed #2f2f2f; border-radius: 10px; background: #111; cursor: pointer; padding: 0; overflow: hidden; display: block; position: relative; min-height: 96px; transition: border-color .15s, background .15s; }
.sm-img-drop:hover { border-color: #e61e1e; background: #161313; }
.sm-img-drop-sm { min-height: 64px; }
.sm-img-ph { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; min-height: 96px; color: #8a8a8a; font-size: 12px; }
.sm-img-drop-sm .sm-img-ph { min-height: 64px; }
.sm-img-ph i { font-size: 20px; color: #e61e1e; }
.sm-img-preview { display: block; width: 100%; max-height: 150px; object-fit: cover; }
.sm-img-preview-sm { max-height: 64px; }
/* ── Beautified layout: grouped cards ───────────────────────────────── */
.sm-group { background: #141414; border: 1px solid #242424; border-radius: 14px; padding: 16px 18px; margin-bottom: 14px; }
.sm-group-title { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: #cfcfcf; margin-bottom: 14px; }
.sm-group-title i { color: #e61e1e; font-size: 15px; }
#sportsMatchModal .form-label { font-weight: 600; font-size: 12.5px; color: #b9b9b9; }
.sm-attached { background: #111; border: 1px solid #242424; border-radius: 10px; padding: 10px 14px; color: #d8d8d8; font-size: 13px; }
.sm-attached i { color: #e61e1e; }
/* Participant cards */
.sm-pcard { background: #111; border: 1px solid #242424; border-radius: 12px; padding: 14px; height: 100%; }
.sm-pcard-head { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; color: #cfcfcf; margin-bottom: 12px; }
.sm-pcard-badge { width: 22px; height: 22px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; color: #fff; }
.sm-badge-1 { background: #2563eb; }
.sm-badge-2 { background: #e61e1e; }
.sm-pcard .form-control { margin-bottom: 9px; }
.sm-pcard .sm-img-field { margin-bottom: 9px; }
.sm-mini-label { display: block; font-size: 11px; font-weight: 600; color: #8a8a8a; margin: 2px 0 6px; }
/* Image field size variants */
.sm-img-lg .sm-img-drop, .sm-img-lg .sm-img-ph { min-height: 132px; }
.sm-img-lg .sm-img-preview { max-height: 132px; }
.sm-img-sm .sm-img-drop, .sm-img-sm .sm-img-ph { min-height: 60px; }
.sm-img-sm .sm-img-preview { max-height: 60px; }
.sm-img-wide .sm-img-drop, .sm-img-wide .sm-img-ph { min-height: 120px; }
.sm-img-wide .sm-img-preview { max-height: 200px; }
.sm-img-avatar { flex: 0 0 auto; }
.sm-img-avatar .sm-img-drop { width: 74px; height: 74px; min-height: 74px; border-radius: 50%; }
.sm-img-avatar .sm-img-ph { min-height: 74px; gap: 2px; font-size: 10px; }
.sm-img-avatar .sm-img-ph i { font-size: 16px; }
.sm-img-avatar .sm-img-preview { width: 74px; height: 74px; max-height: 74px; }
</style>
<script>
(function () {
const modalEl = document.getElementById('sportsMatchModal');
if (!modalEl) return;
const form = document.getElementById('sportsMatchForm');
const idInput = document.getElementById('sm-match-id');
const videoIdInp = document.getElementById('sm-video-id');
const statusInp = document.getElementById('sm-status');
const titleEl = document.getElementById('sportsMatchModalLabel');
const statusChip = document.getElementById('sm-status-chip');
const videoFile = document.getElementById('sm-video-file');
let bsModal, _origBtnHtml = {};
// Sport → segment label (only relabels; never rebuilds the form)
const SEG_LABELS = {
boxing: 'Rounds', mma: 'Rounds', kickboxing: 'Rounds', wrestling: 'Rounds', judo: 'Rounds', taekwondo: 'Rounds',
tennis: 'Sets', volleyball: 'Sets', badminton: 'Sets', squash: 'Sets', 'table tennis': 'Sets',
football: 'Periods', soccer: 'Periods', basketball: 'Quarters', rugby: 'Halves', hockey: 'Periods',
cricket: 'Innings', baseball: 'Innings', cycling: 'Laps', athletics: 'Heats', esports: 'Maps',
};
function getModal() {
if (!bsModal) bsModal = new bootstrap.Modal(modalEl);
return bsModal;
}
function token() { return form.querySelector('[name="_token"]').value; }
// ── Public entry point ───────────────────────────────────────────────
window.openSportsMatchModal = function (matchId) {
resetForm();
if (matchId) loadMatch(matchId);
getModal().show();
};
function resetForm() {
form.reset();
idInput.value = '';
videoIdInp.value = '';
statusInp.value = 'draft';
titleEl.textContent = 'Create Sports Match';
statusChip.textContent = 'Draft';
['sm-officials-list', 'sm-segments-list', 'sm-stats-list', 'sm-extra-list']
.forEach(id => { const el = document.getElementById(id); if (el) el.innerHTML = ''; });
form.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid'));
form.querySelectorAll('.sm-img-preview').forEach(img => { img.src = ''; img.classList.add('d-none'); });
form.querySelectorAll('.sm-img-ph').forEach(ph => ph.classList.remove('d-none'));
document.getElementById('sm-video-create').classList.remove('d-none');
document.getElementById('sm-video-attached').classList.add('d-none');
document.getElementById('sm-advanced').classList.add('d-none'); // advanced shows only when editing
clearVideoFile();
setNowDateTime();
applySegmentLabel('');
}
// Default the date & time pickers to the current date and time
function setNowDateTime() {
const now = new Date();
const pad = n => String(n).padStart(2, '0');
document.getElementById('sm-date').value = now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate());
document.getElementById('sm-time').value = pad(now.getHours()) + ':' + pad(now.getMinutes());
}
// ── Match video upload (create mode) ─────────────────────────────────
document.getElementById('sm-video-drop').addEventListener('click', () => videoFile.click());
videoFile.addEventListener('change', function () {
const f = videoFile.files && videoFile.files[0];
if (!f) return clearVideoFile();
document.getElementById('sm-video-err').classList.add('d-none');
document.getElementById('sm-video-drop').classList.remove('sm-invalid');
document.getElementById('sm-video-name').textContent = f.name;
document.getElementById('sm-video-size').textContent = fmtSize(f.size);
document.getElementById('sm-video-idle').classList.add('d-none');
document.getElementById('sm-video-picked').classList.remove('d-none');
});
document.getElementById('sm-video-clear').addEventListener('click', function (e) {
e.stopPropagation(); clearVideoFile();
});
function clearVideoFile() {
videoFile.value = '';
document.getElementById('sm-video-idle').classList.remove('d-none');
document.getElementById('sm-video-picked').classList.add('d-none');
}
function fmtSize(b) {
if (!b) return '';
const k = 1024, u = ['B', 'KB', 'MB', 'GB'], i = Math.floor(Math.log(b) / Math.log(k));
return (b / Math.pow(k, i)).toFixed(1) + ' ' + u[i];
}
// ── Cropper wiring ───────────────────────────────────────────────────
// Named single images: trigger the field's form-mode cropper.
window.smOpenCropper = function (cropperId) {
if (typeof window['openCropperModal_' + cropperId] !== 'function') return;
window['openCropperModal_' + cropperId]();
const internal = document.getElementById('tcInput_' + cropperId);
if (internal) internal.click();
};
// Reflect a chosen/cropped file into its field (show preview, hide placeholder).
function reflectImage(field) {
if (!field) return;
const input = field.querySelector('input[type="file"]');
const img = field.querySelector('.sm-img-preview');
const ph = field.querySelector('.sm-img-ph');
if (input && input.files && input.files[0]) {
if (img) { img.src = URL.createObjectURL(input.files[0]); img.classList.remove('d-none'); }
if (ph) ph.classList.add('d-none');
}
}
document.querySelectorAll('#sportsMatchModal input[type="file"][name^="media_"]').forEach(inp => {
inp.addEventListener('change', () => reflectImage(inp.closest('.sm-img-field')));
});
// Officials photos: one shared callback-mode cropper routed to the active row.
let _smOfficialField = null;
window.smOfficialCropDone = function (file) {
const field = _smOfficialField;
if (field && file) {
const input = field.querySelector('input[data-n="photo"]');
if (input) {
const dt = new DataTransfer(); dt.items.add(file);
input.files = dt.files;
}
const img = field.querySelector('.sm-img-preview');
const ph = field.querySelector('.sm-img-ph');
if (img) { img.src = URL.createObjectURL(file); img.classList.remove('d-none'); }
if (ph) ph.classList.add('d-none');
}
if (typeof window.closeCropperModal === 'function') window.closeCropperModal('smc_official');
};
// ── Sport label sync ─────────────────────────────────────────────────
document.getElementById('sm-sport').addEventListener('input', function () {
applySegmentLabel(this.value);
});
function applySegmentLabel(sport) {
const label = SEG_LABELS[(sport || '').trim().toLowerCase()] || 'Segments';
const head = document.querySelector('#sm-heading-seg .sm-acc-title');
if (head) head.textContent = label;
const addLbl = document.querySelector('[data-sm-seg-add]');
if (addLbl) addLbl.textContent = 'Add ' + label.toLowerCase().replace(/s$/, '');
}
// ── Repeatable rows ──────────────────────────────────────────────────
const REPEAT = {
official: { tpl: 'sm-tpl-official', list: 'sm-officials-list', name: 'officials' },
segment: { tpl: 'sm-tpl-segment', list: 'sm-segments-list', name: 'segments' },
statistic: { tpl: 'sm-tpl-statistic', list: 'sm-stats-list', name: 'statistics' },
extra: { tpl: 'sm-tpl-extra', list: 'sm-extra-list', name: 'extra_participants' },
};
document.querySelectorAll('[data-sm-add]').forEach(btn => {
btn.addEventListener('click', () => addRow(btn.getAttribute('data-sm-add')));
});
function addRow(kind, values) {
const cfg = REPEAT[kind];
const tpl = document.getElementById(cfg.tpl);
const node = tpl.content.firstElementChild.cloneNode(true);
document.getElementById(cfg.list).appendChild(node);
node.querySelector('[data-sm-remove]').addEventListener('click', () => { node.remove(); renumber(kind); });
// Official rows: wire the shared photo cropper to this row
const cropBtn = node.querySelector('[data-sm-official-crop]');
if (cropBtn) cropBtn.addEventListener('click', () => {
_smOfficialField = node.querySelector('.sm-img-field');
window.smOpenCropper('smc_official');
});
if (values) {
node.querySelectorAll('[data-n]').forEach(inp => {
const key = inp.getAttribute('data-n');
if (key === 'photo') return; // file input — can't prefill
if (key === 'photo_existing' && values.photo) { inp.value = values.photo; }
else if (values[key] != null) inp.value = values[key];
});
const pv = node.querySelector('.sm-img-preview');
const ph = node.querySelector('.sm-img-ph');
if (pv && values.photo_url) { pv.src = values.photo_url; pv.classList.remove('d-none'); if (ph) ph.classList.add('d-none'); }
}
renumber(kind);
return node;
}
// Renumber a repeatable group's inputs to contiguous 0..n names
function renumber(kind) {
const cfg = REPEAT[kind];
const rows = document.querySelectorAll('#' + cfg.list + ' [data-sm-row="' + kind + '"]');
rows.forEach((row, i) => {
row.querySelectorAll('[data-n]').forEach(inp => {
inp.setAttribute('name', cfg.name + '[' + i + '][' + inp.getAttribute('data-n') + ']');
});
});
}
// ── Load an existing record for editing ──────────────────────────────
function loadMatch(id) {
fetch('{{ url('sports-matches') }}/' + id + '/edit', { headers: { 'Accept': 'application/json' } })
.then(r => { if (!r.ok) throw new Error(); return r.json(); })
.then(({ match }) => populate(match))
.catch(() => toast('Could not load this match.', 'error'));
}
function populate(m) {
idInput.value = m.id;
statusInp.value = m.status || 'draft';
titleEl.textContent = 'Edit Sports Match';
statusChip.textContent = (m.status === 'published') ? 'Published' : 'Draft';
// Video is already attached in edit mode — hide the uploader, show a note
document.getElementById('sm-video-create').classList.add('d-none');
document.getElementById('sm-video-attached').classList.remove('d-none');
document.getElementById('sm-video-attached-title').textContent = m.video_title || ('Video #' + m.video_id);
// Reveal the advanced area for editing
document.getElementById('sm-advanced').classList.remove('d-none');
const set = (name, val) => { const el = form.querySelector('[name="' + name + '"]'); if (el && val != null) el.value = val; };
['video_id','sport','title','event_name','match_type','match_date','match_time',
'participant1_name','participant2_name','referee_name','venue_name'].forEach(k => set(k, m[k]));
applySegmentLabel(m.sport || '');
const grp = (obj, prefix) => { if (!obj) return; Object.keys(obj).forEach(k => { if (k === 'extra') return; set(prefix + '[' + k + ']', obj[k]); }); };
grp(m.competition, 'competition'); grp(m.venue, 'venue'); grp(m.result, 'result'); grp(m.reviews, 'reviews');
grp(m.participants, 'participants');
// media text + image previews
if (m.media) {
set('media[caption]', m.media.caption); set('media[alt]', m.media.alt); set('media[credit]', m.media.credit);
const pub = form.querySelector('[name="media[public]"]'); if (pub) pub.checked = !!m.media.public;
}
Object.entries(m.media_urls || {}).forEach(([key, url]) => {
const field = form.querySelector('.sm-img-field[data-img="' + key + '"]');
if (!field) return;
const img = field.querySelector('.sm-img-preview');
const ph = field.querySelector('.sm-img-ph');
if (img) { img.src = url; img.classList.remove('d-none'); }
if (ph) ph.classList.add('d-none');
});
(m.participants && m.participants.extra || []).forEach(p => addRow('extra', p));
(m.officials || []).forEach(o => addRow('official', o));
(m.segments || []).forEach(s => addRow('segment', s));
(m.statistics || []).forEach(s => addRow('statistic', s));
}
// ── Submit ───────────────────────────────────────────────────────────
document.getElementById('sm-save-basic').addEventListener('click', () => submit('close'));
document.getElementById('sm-save-continue').addEventListener('click', () => submit('continue'));
const saveBtns = [document.getElementById('sm-save-basic'), document.getElementById('sm-save-continue')];
function lockButtons(on) { saveBtns.forEach(b => b.disabled = on); }
function setBtnText(t) {
saveBtns.forEach(b => {
if (_origBtnHtml[b.id] === undefined) _origBtnHtml[b.id] = b.innerHTML;
});
const cont = document.getElementById('sm-save-continue');
cont.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>' + t;
}
function restoreBtnText() {
saveBtns.forEach(b => { if (_origBtnHtml[b.id] !== undefined) b.innerHTML = _origBtnHtml[b.id]; });
}
function clearErrors() {
form.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid'));
document.getElementById('sm-video-err').classList.add('d-none');
document.getElementById('sm-video-drop').classList.remove('sm-invalid');
}
function submit(intent) {
['official', 'segment', 'statistic', 'extra'].forEach(renumber);
clearErrors();
lockButtons(true);
// Edit mode → just update the record. Create mode → upload the video first.
const chain = idInput.value ? Promise.resolve() : uploadVideoFirst();
chain
.then(() => postMatch(intent))
.catch(e => { if (e && e.message !== 'handled') toast((e && e.message) || 'Save failed', 'error'); })
.finally(() => { lockButtons(false); restoreBtnText(); });
}
// Step 1 (create): upload the match video through the existing pipeline.
function uploadVideoFirst() {
if (!validateCreate()) return Promise.reject(new Error('handled'));
setBtnText('Uploading video…');
const vfd = new FormData();
vfd.append('_token', token());
vfd.append('video', videoFile.files[0]);
vfd.append('title', document.getElementById('sm-title').value.trim());
vfd.append('type', 'match');
vfd.append('visibility', 'public');
vfd.append('download_access', 'disabled');
// Reuse the cropped event poster as the video thumbnail when present.
const poster = document.getElementById('sm-file-media_event_poster');
if (poster && poster.files && poster.files[0]) vfd.append('thumbnail', poster.files[0]);
return fetch('{{ route('videos.store') }}', {
method: 'POST',
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': token() },
body: vfd,
})
.then(async (r) => {
const d = await r.json().catch(() => ({}));
if (r.status === 422) { showErrors(d.errors || {}); throw new Error('handled'); }
if (!r.ok || !d.success) throw new Error(d.message || 'Video upload failed');
return d;
})
.then((d) => { videoIdInp.value = d.video_id; });
}
// Step 2: create/update the match record (video_id already set).
function postMatch(intent) {
setBtnText(idInput.value ? 'Saving…' : 'Saving match…');
const id = idInput.value;
const url = id ? '{{ url('sports-matches') }}/' + id : '{{ route('sports-matches.store') }}';
const fd = new FormData(form);
fd.delete('match_id');
if (id) fd.append('_method', 'PUT');
return fetch(url, {
method: 'POST',
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': token() },
body: fd,
})
.then(async (r) => {
const d = await r.json().catch(() => ({}));
if (r.status === 422) { showErrors(d.errors || {}); throw new Error('handled'); }
if (!r.ok) throw new Error(d.message || 'Save failed');
return d;
})
.then((d) => {
toast(d.message || 'Saved', 'success');
if (intent === 'close') getModal().hide();
else populate(d.match); // switch to edit mode, keep editing the same record
});
}
// Client-side guard so a failed match-save never leaves an orphan video.
function validateCreate() {
let ok = true;
const title = document.getElementById('sm-title');
if (!videoFile.files || !videoFile.files[0]) {
document.getElementById('sm-video-err').classList.remove('d-none');
document.getElementById('sm-video-drop').classList.add('sm-invalid');
ok = false;
}
if (!title.value.trim()) { title.classList.add('is-invalid'); ok = false; }
if (!ok) toast('Please complete the required fields.', 'error');
return ok;
}
function dotToBracket(key) {
const parts = key.split('.');
return parts[0] + parts.slice(1).map(p => '[' + p + ']').join('');
}
function showErrors(errors) {
let first = null;
Object.keys(errors).forEach(key => {
// key may be dot-notation like "officials.0.photo" → bracket form
const el = form.querySelector('[name="' + key + '"]')
|| form.querySelector('[name="' + dotToBracket(key) + '"]');
const fb = form.querySelector('.invalid-feedback[data-field="' + key + '"]');
if (el) { el.classList.add('is-invalid'); first = first || el; }
if (fb) fb.textContent = errors[key][0];
});
if (first) {
const pane = first.closest('.accordion-collapse');
if (pane && !pane.classList.contains('show')) new bootstrap.Collapse(pane, { show: true });
setTimeout(() => first.scrollIntoView({ behavior: 'smooth', block: 'center' }), 150);
}
toast('Please check the highlighted fields.', 'error');
}
function toast(msg, type) {
if (typeof window.showToast === 'function') window.showToast(msg, type);
else console.log('[' + type + '] ' + msg);
}
})();
</script>

View File

@ -26,10 +26,10 @@
<i class="bi bi-arrow-right-short utc-card-arr"></i> <i class="bi bi-arrow-right-short utc-card-arr"></i>
</button> </button>
<button type="button" class="utc-card" data-accent="match" onclick="chooseUploadType('match')"> <button type="button" class="utc-card" data-accent="match" onclick="closeUploadChooser(); openSportsMatchModal();">
<span class="utc-card-ico"><i class="bi bi-trophy"></i></span> <span class="utc-card-ico"><i class="bi bi-trophy"></i></span>
<span class="utc-card-title">Sports</span> <span class="utc-card-title">Sports</span>
<span class="utc-card-desc">Matches with rounds &amp; annotations</span> <span class="utc-card-desc">Create a sports match record</span>
<i class="bi bi-arrow-right-short utc-card-arr"></i> <i class="bi bi-arrow-right-short utc-card-arr"></i>
</button> </button>
</div> </div>

View File

@ -2,6 +2,34 @@
@section('title', $playlist->name . ' | ' . config('app.name')) @section('title', $playlist->name . ' | ' . config('app.name'))
@push('head')
@php
$plDesc = trim($playlist->description ?? '');
if ($plDesc === '') {
$plDesc = $playlist->video_count . ' videos' . ($playlist->user ? ' • by ' . $playlist->user->name : '');
}
$plShareUrl = route('playlists.showByToken', $playlist->share_token);
// Use the dedicated OG endpoint so previews always get a 1200x630 JPG under the
// size/format limits of WhatsApp/Telegram/Discord/etc.
$plOgImage = route('playlists.ogImage', $playlist);
@endphp
<meta property="og:title" content="{{ $playlist->name }}">
<meta property="og:description" content="{{ Str::limit(strip_tags($plDesc), 200) }}">
<meta property="og:image" content="{{ $plOgImage }}">
<meta property="og:image:secure_url" content="{{ $plOgImage }}">
<meta property="og:image:type" content="image/jpeg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{{ $playlist->name }}">
<meta property="og:url" content="{{ $plShareUrl }}">
<meta property="og:type" content="music.playlist">
<meta property="og:site_name" content="{{ config('app.name') }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $playlist->name }}">
<meta name="twitter:description" content="{{ Str::limit(strip_tags($plDesc), 200) }}">
<meta name="twitter:image" content="{{ $plOgImage }}">
@endpush
@section('extra_styles') @section('extra_styles')
<style> <style>
/* ══════════════════════════════════════════════ /* ══════════════════════════════════════════════

View File

@ -243,7 +243,7 @@
@endif @endif
@include('layouts.partials.share-modal') <x-share-modal />
@endsection @endsection
@section('scripts') @section('scripts')

View File

@ -77,14 +77,11 @@
<svg class="ytp-svg-pause" viewBox="0 0 24 24" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg> <svg class="ytp-svg-pause" viewBox="0 0 24 24" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button> </button>
@if(isset($previousVideo) && $prevUrl) @if(isset($playlist) && $playlist)
<button class="ytp-button" title="Previous" onclick="window.location.href='{{ $prevUrl }}'"> <button class="ytp-button ytp-prev-btn" title="Previous" onclick="if(window.plPrev)window.plPrev();">
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg> <svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg>
</button> </button>
@endif <button class="ytp-button ytp-next-btn" title="Next" onclick="if(window.plNext)window.plNext();">
@if(isset($nextVideo) && $nextUrl)
<button class="ytp-button" title="Next" onclick="window.location.href='{{ $nextUrl }}'">
<svg viewBox="0 0 24 24"><path d="m6 18 8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg> <svg viewBox="0 0 24 24"><path d="m6 18 8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button> </button>
@endif @endif
@ -224,12 +221,16 @@
/* ══ Full ytp styles ══ */ /* ══ Full ytp styles ══ */
.ytp-wrap { .ytp-wrap {
position: relative; width: 100%; background: #000; position: relative; width: 100% !important; background: #000;
border-radius: 12px; overflow: hidden; border-radius: 12px; overflow: hidden;
aspect-ratio: 16/9; aspect-ratio: 16/9 !important;
height: auto !important;
max-height: none !important;
min-height: 0 !important;
} }
.ytp { .ytp, .audio-ytp {
position: relative; width: 100%; height: 100%; position: relative; width: 100%; height: 100%;
aspect-ratio: 16/9;
background: #000; outline: none; background: #000; outline: none;
user-select: none; overflow: hidden; user-select: none; overflow: hidden;
font-family: Roboto, Arial, sans-serif; font-family: Roboto, Arial, sans-serif;
@ -1063,6 +1064,9 @@ window._audioPlayerUpdate = function(d) {
var langPopup = document.getElementById('ytpLangPopup'); var langPopup = document.getElementById('ytpLangPopup');
if (!langWrap || !langPopup || !langBtn) return; if (!langWrap || !langPopup || !langBtn) return;
// New song loaded → back to its primary track, so shares don't carry a stale track id.
window._ytpTrackId = 0;
function flagHtml(flag, size) { function flagHtml(flag, size) {
var s = size === 'sm' ? 'width:22px;height:16px;border-radius:2px;display:inline-block;flex-shrink:0;' var s = size === 'sm' ? 'width:22px;height:16px;border-radius:2px;display:inline-block;flex-shrink:0;'
: 'width:22px;height:16px;border-radius:2px;display:inline-block;'; : 'width:22px;height:16px;border-radius:2px;display:inline-block;';
@ -1143,6 +1147,8 @@ window._audioPlayerUpdate = function(d) {
var url = opt.dataset.langUrl; var url = opt.dataset.langUrl;
var flag = opt.dataset.langFlag; var flag = opt.dataset.langFlag;
var curTime = audio.currentTime; var curTime = audio.currentTime;
// Track the selected version so "Share" links to this exact language (0 = primary).
window._ytpTrackId = parseInt(opt.dataset.langId, 10) || 0;
langPopup.querySelectorAll('.ytp-lang-option').forEach(function(o) { o.classList.remove('active'); }); langPopup.querySelectorAll('.ytp-lang-option').forEach(function(o) { o.classList.remove('active'); });
opt.classList.add('active'); opt.classList.add('active');
langPopup.classList.remove('open'); langPopup.classList.remove('open');

View File

@ -32,7 +32,10 @@
.vdb-desc-text a.action-btn { display:inline-flex; margin:4px 6px 4px 0; color:inherit; text-decoration:none; vertical-align:middle; } .vdb-desc-text a.action-btn { display:inline-flex; margin:4px 6px 4px 0; color:inherit; text-decoration:none; vertical-align:middle; }
.vdb-desc-text.vdb-clamp { max-height:130px; overflow:hidden; -webkit-mask-image:linear-gradient(180deg,#000 70%,transparent); mask-image:linear-gradient(180deg,#000 70%,transparent); } .vdb-desc-text.vdb-clamp { max-height:130px; overflow:hidden; -webkit-mask-image:linear-gradient(180deg,#000 70%,transparent); mask-image:linear-gradient(180deg,#000 70%,transparent); }
.vdb-desc-text.vdb-clamp.vdb-expanded { max-height:none; -webkit-mask-image:none; mask-image:none; } .vdb-desc-text.vdb-clamp.vdb-expanded { max-height:none; -webkit-mask-image:none; mask-image:none; }
.vdb-show-more { background:none; border:none; color:var(--text-primary); font-weight:700; font-size:13px; cursor:pointer; padding:6px 0 0; } .vdb-show-more { display:flex; align-items:center; justify-content:center; gap:6px; margin:12px auto 0; background:var(--bg-secondary); border:1px solid var(--border-color); color:var(--text-primary); font-weight:700; font-size:13px; cursor:pointer; padding:7px 16px; border-radius:18px; transition:background .15s ease, border-color .15s ease; }
.vdb-show-more:hover { background:var(--bg-hover, rgba(127,127,127,.12)); }
.vdb-show-more i { font-size:14px; transition:transform .2s ease; }
.vdb-show-more.expanded i { transform:rotate(180deg); }
</style> </style>
<div class="vdb-wrap" id="vdbWrap"> <div class="vdb-wrap" id="vdbWrap">
@ -58,7 +61,7 @@
{!! $descriptionSlot !!} {!! $descriptionSlot !!}
@elseif($renderedDescription !== '') @elseif($renderedDescription !== '')
<div id="vdbDescShort" class="vdb-desc-text vdb-clamp">{!! $renderedDescription !!}</div> <div id="vdbDescShort" class="vdb-desc-text vdb-clamp">{!! $renderedDescription !!}</div>
<button id="vdbShowMore" class="vdb-show-more" style="display:none;" onclick="toggleVdbDesc(this)">Show more</button> <button id="vdbShowMore" class="vdb-show-more" style="display:none;" onclick="toggleVdbDesc(this)"><span>Show more</span> <i class="bi bi-chevron-down"></i></button>
@else @else
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p> <p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
@endif @endif
@ -70,8 +73,24 @@
</div> </div>
<script> <script>
// Remember which tab the user opened so SPA navigation between videos keeps it active.
window._vdbActiveTab = window._vdbActiveTab || 'vdb-about';
// Scroll back up to the video player on every SPA video-to-video swap.
// On mobile the window is locked (see CLAUDE.md mobile scroll model) and `.yt-main`
// (id="main") is the real scroll container, so we scroll that instead.
window._spaScrollToVideo = window._spaScrollToVideo || function () {
const main = document.getElementById('main');
if (window.innerWidth <= 768 && main) {
main.scrollTo({ top: 0, behavior: 'smooth' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
// ── Tab switching ────────────────────────────────────── // ── Tab switching ──────────────────────────────────────
function switchVdbTab(panelId, btn) { function switchVdbTab(panelId, btn) {
window._vdbActiveTab = panelId;
document.querySelectorAll('.vdb-tab').forEach(b => b.classList.remove('active')); document.querySelectorAll('.vdb-tab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.vdb-panel').forEach(p => p.classList.remove('active')); document.querySelectorAll('.vdb-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
@ -82,11 +101,44 @@ function switchVdbTab(panelId, btn) {
if (currentUrl && currentUrl !== window._insLoadedUrl) loadInsights(); if (currentUrl && currentUrl !== window._insLoadedUrl) loadInsights();
} }
} }
// Re-apply the remembered tab after an SPA swap replaces #vdbWrap's contents.
// If the remembered tab doesn't exist on the new video (e.g. Insights when the
// viewer isn't the owner), fall back to About so nothing is left blank.
function _vdbApplyActiveTab() {
const wrap = document.getElementById('vdbWrap');
if (!wrap) return;
let target = window._vdbActiveTab || 'vdb-about';
let btn = wrap.querySelector('.vdb-tab[data-panel="' + target + '"]');
if (!btn) { target = 'vdb-about'; btn = wrap.querySelector('.vdb-tab[data-panel="vdb-about"]'); }
if (!btn) return;
wrap.querySelectorAll('.vdb-tab').forEach(b => b.classList.remove('active'));
wrap.querySelectorAll('.vdb-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
const panel = document.getElementById(target);
if (panel) panel.classList.add('active');
if (target === 'vdb-insights') {
const ip = document.getElementById('vdb-insights');
const currentUrl = ip && ip.dataset.insightsBase;
if (currentUrl && currentUrl !== window._insLoadedUrl && typeof loadInsights === 'function') loadInsights();
}
}
// Observe #vdbWrap so the active tab is re-applied whenever the SPA layer
// rewrites its innerHTML during a video-to-video transition.
(function _vdbWatchSwaps() {
const wrap = document.getElementById('vdbWrap');
if (!wrap || wrap._vdbTabObserver) return;
const obs = new MutationObserver(() => _vdbApplyActiveTab());
obs.observe(wrap, { childList: true, subtree: false });
wrap._vdbTabObserver = obs;
})();
function toggleVdbDesc(btn) { function toggleVdbDesc(btn) {
const d = document.getElementById('vdbDescShort'); const d = document.getElementById('vdbDescShort');
if (!d) return; if (!d) return;
const expanded = d.classList.toggle('vdb-expanded'); const expanded = d.classList.toggle('vdb-expanded');
btn.textContent = expanded ? 'Show less' : 'Show more'; btn.classList.toggle('expanded', expanded);
const label = btn.querySelector('span');
if (label) label.textContent = expanded ? 'Show less' : 'Show more';
} }
// Reveal "Show more" only when the description overflows the clamp. Compare the // Reveal "Show more" only when the description overflows the clamp. Compare the
// natural content height to the clamp limit (130px) rather than clientHeight, // natural content height to the clamp limit (130px) rather than clientHeight,
@ -94,8 +146,8 @@ function toggleVdbDesc(btn) {
function _vdbCheckOverflow() { function _vdbCheckOverflow() {
const d = document.getElementById('vdbDescShort'), b = document.getElementById('vdbShowMore'); const d = document.getElementById('vdbDescShort'), b = document.getElementById('vdbShowMore');
if (!d || !b) return; if (!d || !b) return;
if (d.classList.contains('vdb-expanded')) { b.style.display = 'block'; return; } if (d.classList.contains('vdb-expanded')) { b.style.display = 'flex'; return; }
b.style.display = (d.scrollHeight > 138) ? 'block' : 'none'; b.style.display = (d.scrollHeight > 138) ? 'flex' : 'none';
} }
document.addEventListener('DOMContentLoaded', _vdbCheckOverflow); document.addEventListener('DOMContentLoaded', _vdbCheckOverflow);
window.addEventListener('load', _vdbCheckOverflow); window.addEventListener('load', _vdbCheckOverflow);

View File

@ -17,7 +17,9 @@
<div class="channel-row" style="display: flex; align-items: center; justify-content: space-between; padding: 6px 0; flex-wrap: wrap; gap: 12px;"> <div class="channel-row" style="display: flex; align-items: center; justify-content: space-between; padding: 6px 0; flex-wrap: wrap; gap: 12px;">
{{-- Left: Channel Info --}} {{-- Left: Channel Info --}}
<div style="display: flex; align-items: center; gap: 12px;"> <div style="display: flex; align-items: center; gap: 12px;">
<a href="{{ $video->user ? route('channel', $video->user->channel) : '#' }}" class="channel-info text-decoration-none" style="color: inherit; display: flex; align-items: center; gap: 12px;"> <a href="{{ $video->user ? route('channel', $video->user->channel) : '#' }}" class="channel-info text-decoration-none"
@if($video->user) data-profile-visit-url="{{ route('profile.visit', $video->user) }}" data-source-video-id="{{ $video->id }}" @endif
style="color: inherit; display: flex; align-items: center; gap: 12px;">
@if($video->user) @if($video->user)
<img src="{{ $video->user->avatar_url }}" class="channel-avatar" style="width: 36px; height: 36px;" alt="{{ $video->user->name }}"> <img src="{{ $video->user->avatar_url }}" class="channel-avatar" style="width: 36px; height: 36px;" alt="{{ $video->user->name }}">
@else @else
@ -69,9 +71,9 @@
{{-- Share Button --}} {{-- Share Button --}}
@if($video->isShareable()) @if($video->isShareable())
<button class="action-btn" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')"> <x-share-button :video="$video" class="action-btn">
<i class="bi bi-share"></i> <span>Share</span> <i class="bi bi-share"></i> <span>Share</span>
</button> </x-share-button>
@endif @endif
</div> </div>
</div> </div>

View File

@ -648,7 +648,10 @@
.vdb-desc-text { font-size: 14px; line-height: 1.6; color: var(--text-primary); white-space: pre-wrap; } .vdb-desc-text { font-size: 14px; line-height: 1.6; color: var(--text-primary); white-space: pre-wrap; }
.vdb-desc-text p { margin-bottom: 8px; } .vdb-desc-text p { margin-bottom: 8px; }
.vdb-desc-text a { color: #3ea6ff; } .vdb-desc-text a { color: #3ea6ff; }
.vdb-show-more { background: none; border: none; color: var(--text-primary); font-weight: 700; font-size: 13px; cursor: pointer; padding: 6px 0 0; } .vdb-show-more { display: flex; align-items: center; justify-content: center; gap: 6px; margin: 12px auto 0; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); font-weight: 700; font-size: 13px; cursor: pointer; padding: 7px 16px; border-radius: 18px; transition: background .15s ease, border-color .15s ease; }
.vdb-show-more:hover { background: var(--bg-hover, rgba(127,127,127,.12)); }
.vdb-show-more i { font-size: 14px; transition: transform .2s ease; }
.vdb-show-more.expanded i { transform: rotate(180deg); }
</style> </style>
@ -680,7 +683,7 @@
<div id="vdbDescShort" class="vdb-desc-text">{{ $needsExpand ? $shortDescription : $fullDescription }}</div> <div id="vdbDescShort" class="vdb-desc-text">{{ $needsExpand ? $shortDescription : $fullDescription }}</div>
@if($needsExpand) @if($needsExpand)
<div id="vdbDescFull" class="vdb-desc-text" style="display:none;">{{ $fullDescription }}</div> <div id="vdbDescFull" class="vdb-desc-text" style="display:none;">{{ $fullDescription }}</div>
<button class="vdb-show-more" onclick="toggleVdbDesc(this)">Show more</button> <button class="vdb-show-more" onclick="toggleVdbDesc(this)"><span>Show more</span> <i class="bi bi-chevron-down"></i></button>
@endif @endif
@else @else
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p> <p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
@ -707,20 +710,24 @@
function toggleVdbDesc(btn) { function toggleVdbDesc(btn) {
const short = document.getElementById('vdbDescShort'); const short = document.getElementById('vdbDescShort');
const full = document.getElementById('vdbDescFull'); const full = document.getElementById('vdbDescFull');
const label = btn.querySelector('span');
if (full.style.display === 'none') { if (full.style.display === 'none') {
short.style.display = 'none'; short.style.display = 'none';
full.style.display = 'block'; full.style.display = 'block';
btn.textContent = 'Show less'; label.textContent = 'Show less';
btn.classList.add('expanded');
} else { } else {
short.style.display = 'block'; short.style.display = 'block';
full.style.display = 'none'; full.style.display = 'none';
btn.textContent = 'Show more'; label.textContent = 'Show more';
btn.classList.remove('expanded');
} }
} }
// ── Share + track ────────────────────────────────────── // ── Share + track ──────────────────────────────────────
function videoShare(url, title, trackUrl) { function videoShare(url, title, trackUrl) {
openShareModal(url, title); // Pass the full argument set so email + share-to-members are available (see share component rule).
openShareModal(url, title, '', '{{ auth()->check() ? route('videos.shareEmail', $video) : '' }}', '{{ auth()->check() ? route('videos.shareMembers', $video) : '' }}');
fetch(trackUrl, { fetch(trackUrl, {
method: 'POST', method: 'POST',
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '', 'Accept': 'application/json' } headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '', 'Accept': 'application/json' }
@ -773,7 +780,8 @@
@foreach ($playlistVideos as $index => $playlistVideo) @foreach ($playlistVideos as $index => $playlistVideo)
@php $isCurrent = $playlistVideo->id === $video->id; @endphp @php $isCurrent = $playlistVideo->id === $video->id; @endphp
<div class="sidebar-video-card{{ $isCurrent ? ' current-video' : '' }}" <div class="sidebar-video-card{{ $isCurrent ? ' current-video' : '' }}"
{{ $isCurrent ? '' : "onclick=\"window.location.href='".route('videos.show', $playlistVideo)."?playlist={$playlist->share_token}'\"" }} data-pl-id="{{ $playlistVideo->id }}"
{{ $isCurrent ? '' : 'onclick="plGoTo(\''.route('videos.show', $playlistVideo).'?playlist='.$playlist->share_token.'\')"' }}
style="{{ $isCurrent ? 'cursor:default;' : 'cursor:pointer;' }}"> style="{{ $isCurrent ? 'cursor:default;' : 'cursor:pointer;' }}">
<div class="sidebar-thumb" style="position: relative;"> <div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail) @if ($playlistVideo->thumbnail)
@ -892,10 +900,9 @@
@endauth @endauth
@if ($video->isShareable()) @if ($video->isShareable())
<button class="yt-action-btn" <x-share-button :video="$video" class="yt-action-btn" style="flex:1;">
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')" style="flex:1;">
<i class="bi bi-share"></i><span>Share</span> <i class="bi bi-share"></i><span>Share</span>
</button> </x-share-button>
@endif @endif
<button class="yt-action-btn" onclick="openAddToPlaylistModal({{ $video->id }})" style="flex:1;"> <button class="yt-action-btn" onclick="openAddToPlaylistModal({{ $video->id }})" style="flex:1;">
@ -921,7 +928,7 @@
@endauth @endauth
</div> </div>
@include('layouts.partials.share-modal') {{-- Share modal is the app-layout singleton (<x-share-modal/>); do not re-render it here. --}}
@include('layouts.partials.edit-video-modal') @include('layouts.partials.edit-video-modal')
@include('layouts.partials.add-to-playlist-modal') @include('layouts.partials.add-to-playlist-modal')
@ -1000,12 +1007,12 @@
var pos = order.indexOf(curIdx); var pos = order.indexOf(curIdx);
var nextPos = (pos + 1) % order.length; var nextPos = (pos + 1) % order.length;
if (pos === order.length - 1 && plLoop !== 'all') return; // end of shuffle, no loop if (pos === order.length - 1 && plLoop !== 'all') return; // end of shuffle, no loop
window.location.href = PL_VIDEOS[order[nextPos]].url; plGoTo(PL_VIDEOS[order[nextPos]].url);
} else { } else {
if (PL_NEXT_URL) { if (PL_NEXT_URL) {
window.location.href = PL_NEXT_URL; plGoTo(PL_NEXT_URL);
} else if (plLoop === 'all' && PL_FIRST_URL) { } else if (plLoop === 'all' && PL_FIRST_URL) {
window.location.href = PL_FIRST_URL; plGoTo(PL_FIRST_URL);
} }
} }
} }

View File

@ -1004,7 +1004,7 @@
</div> </div>
@include('layouts.partials.share-modal') <x-share-modal />
@include('layouts.partials.edit-video-modal') @include('layouts.partials.edit-video-modal')
@if (Session::has('openEditModal') && Session::get('openEditModal')) @if (Session::has('openEditModal') && Session::get('openEditModal'))

View File

@ -3109,7 +3109,7 @@
</div> </div>
<!-- Modals --> <!-- Modals -->
@include('layouts.partials.share-modal') <x-share-modal />
@include('layouts.partials.edit-video-modal') @include('layouts.partials.edit-video-modal')
<!-- Add Round Modal --> <!-- Add Round Modal -->

View File

@ -957,7 +957,7 @@
</div> </div>
@include('layouts.partials.share-modal') <x-share-modal />
@include('layouts.partials.edit-video-modal') @include('layouts.partials.edit-video-modal')
@if (Session::has('openEditModal') && Session::get('openEditModal')) @if (Session::has('openEditModal') && Session::get('openEditModal'))

View File

@ -477,6 +477,11 @@
<i class="bi bi-play-circle"></i> <i class="bi bi-play-circle"></i>
<span class="pl-autoplay-label">Autoplay</span> <span class="pl-autoplay-label">Autoplay</span>
</button> </button>
<div class="pl-ctrl-divider"></div>
<button class="pl-ctrl-btn" id="plShareBtn" title="Share playlist"
onclick="openShareModal('{{ route('playlists.showByToken', $playlist->share_token) }}','{{ addslashes($playlist->name) }}','{{ route('playlists.recordShare', $playlist) }}','','')">
<i class="bi bi-share"></i>
</button>
</div> </div>
<script> <script>
@ -516,16 +521,22 @@
async function plTransitionTo(url, pushHist) { async function plTransitionTo(url, pushHist) {
if(!url||plTransiting) return; if(!url||plTransiting) return;
plTransiting=true; plTransiting=true;
if(window._spaScrollToVideo) window._spaScrollToVideo();
try { try {
var m=url.match(/\/videos\/([^/?#]+)/); var m=url.match(/\/videos\/([^/?#]+)/);
if(!m){ window.location.href=url; return; } if(!m){ window.location.href=url; return; }
var qs=url.indexOf('?')!==-1?url.substring(url.indexOf('?')):''; var qs=url.indexOf('?')!==-1?url.substring(url.indexOf('?')):'';
var resp=await fetch('/videos/'+m[1]+'/player-data'+qs); var resp;
try { resp=await fetch('/videos/'+m[1]+'/player-data'+qs); }
catch(e){ window.location.href=url; return; }
if(!resp.ok){ window.location.href=url; return; } if(!resp.ok){ window.location.href=url; return; }
var d=await resp.json(); var d;
try { d=await resp.json(); }
catch(e){ window.location.href=url; return; }
// reload video source (HLS or MP4) // reload video source (HLS or MP4)
if(window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url); try { if(window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url); }
catch(e){ console.warn('_ytpLoadSource', e); }
// reset progress bar // reset progress bar
var pl=document.getElementById('ytpPlayed'),sc=document.getElementById('ytpScrubber'); var pl=document.getElementById('ytpPlayed'),sc=document.getElementById('ytpScrubber');
@ -545,14 +556,12 @@
if(vid) vid.loop=(plLoop==='one'); if(vid) vid.loop=(plLoop==='one');
PL_CURRENT=d.id; PL_CURRENT=d.id;
plRender(); try { plRender(); plHighlight(d.id, true); } catch(e){ console.warn('plRender', e); }
plHighlight(d.id, true);
if(pushHist!==false) history.pushState({plVideoId:d.id,url:url},'',url); if(pushHist!==false) history.pushState({plVideoId:d.id,url:url},'',url);
plSwapContent(url); plSwapContent(url);
} catch(e){ } catch(e){
console.warn('plTransitionTo',e); console.warn('plTransitionTo',e);
window.location.href=url;
} finally { } finally {
plTransiting=false; plTransiting=false;
} }
@ -783,6 +792,7 @@
async function recTransitionTo(url, pushHist) { async function recTransitionTo(url, pushHist) {
if (!url || recTransiting) return; if (!url || recTransiting) return;
recTransiting = true; recTransiting = true;
if (window._spaScrollToVideo) window._spaScrollToVideo();
try { try {
var m = url.match(/\/videos\/([^/?#]+)/); var m = url.match(/\/videos\/([^/?#]+)/);
if (!m) { window.location.href = url; return; } if (!m) { window.location.href = url; return; }

View File

@ -2555,6 +2555,11 @@
<i class="bi bi-play-circle"></i> <i class="bi bi-play-circle"></i>
<span class="pl-autoplay-label">Autoplay</span> <span class="pl-autoplay-label">Autoplay</span>
</button> </button>
<div class="pl-ctrl-divider"></div>
<button class="pl-ctrl-btn" id="plShareBtn" title="Share playlist"
onclick="openShareModal('{{ route('playlists.showByToken', $playlist->share_token) }}','{{ addslashes($playlist->name) }}','{{ route('playlists.recordShare', $playlist) }}','','')">
<i class="bi bi-share"></i>
</button>
</div> </div>
<script> <script>
@ -2594,15 +2599,21 @@
async function plTransitionTo(url, pushHist) { async function plTransitionTo(url, pushHist) {
if(!url||plTransiting) return; if(!url||plTransiting) return;
plTransiting=true; plTransiting=true;
if(window._spaScrollToVideo) window._spaScrollToVideo();
try { try {
var m=url.match(/\/videos\/([^/?#]+)/); var m=url.match(/\/videos\/([^/?#]+)/);
if(!m){ window.location.href=url; return; } if(!m){ window.location.href=url; return; }
var qs=url.indexOf('?')!==-1?url.substring(url.indexOf('?')):''; var qs=url.indexOf('?')!==-1?url.substring(url.indexOf('?')):'';
var resp=await fetch('/videos/'+m[1]+'/player-data'+qs); var resp;
try { resp=await fetch('/videos/'+m[1]+'/player-data'+qs); }
catch(e){ window.location.href=url; return; }
if(!resp.ok){ window.location.href=url; return; } if(!resp.ok){ window.location.href=url; return; }
var d=await resp.json(); var d;
try { d=await resp.json(); }
catch(e){ window.location.href=url; return; }
if(window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url); try { if(window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url); }
catch(e){ console.warn('_ytpLoadSource', e); }
var pl=document.getElementById('ytpPlayed'),sc=document.getElementById('ytpScrubber'); var pl=document.getElementById('ytpPlayed'),sc=document.getElementById('ytpScrubber');
var cu=document.getElementById('ytpCurrent'),dr=document.getElementById('ytpDuration'); var cu=document.getElementById('ytpCurrent'),dr=document.getElementById('ytpDuration');
@ -2617,14 +2628,12 @@
if(vid) vid.loop=(plLoop==='one'); if(vid) vid.loop=(plLoop==='one');
PL_CURRENT=d.id; PL_CURRENT=d.id;
plRender(); try { plRender(); plHighlight(d.id, true); } catch(e){ console.warn('plRender', e); }
plHighlight(d.id, true);
if(pushHist!==false) history.pushState({plVideoId:d.id,url:url},'',url); if(pushHist!==false) history.pushState({plVideoId:d.id,url:url},'',url);
plSwapContent(url); plSwapContent(url);
} catch(e){ } catch(e){
console.warn('plTransitionTo',e); console.warn('plTransitionTo',e);
window.location.href=url;
} finally { } finally {
plTransiting=false; plTransiting=false;
} }
@ -2856,6 +2865,7 @@
async function recTransitionTo(url, pushHist) { async function recTransitionTo(url, pushHist) {
if (!url || recTransiting) return; if (!url || recTransiting) return;
recTransiting = true; recTransiting = true;
if (window._spaScrollToVideo) window._spaScrollToVideo();
try { try {
var m = url.match(/\/videos\/([^/?#]+)/); var m = url.match(/\/videos\/([^/?#]+)/);
if (!m) { window.location.href = url; return; } if (!m) { window.location.href = url; return; }

View File

@ -511,6 +511,11 @@
<i class="bi bi-play-circle"></i> <i class="bi bi-play-circle"></i>
<span class="pl-autoplay-label">Autoplay</span> <span class="pl-autoplay-label">Autoplay</span>
</button> </button>
<div class="pl-ctrl-divider"></div>
<button class="pl-ctrl-btn" id="plShareBtn" title="Share playlist"
onclick="openShareModal('{{ route('playlists.showByToken', $playlist->share_token) }}','{{ addslashes($playlist->name) }}','{{ route('playlists.recordShare', $playlist) }}','','')">
<i class="bi bi-share"></i>
</button>
</div> </div>
<script> <script>
@ -554,13 +559,18 @@
async function plTransitionTo(url, pushHist) { async function plTransitionTo(url, pushHist) {
if (!url || plTransiting) return; if (!url || plTransiting) return;
plTransiting = true; plTransiting = true;
if (window._spaScrollToVideo) window._spaScrollToVideo();
try { try {
var m = url.match(/\/videos\/([^/?#]+)/); var m = url.match(/\/videos\/([^/?#]+)/);
if (!m) { window.location.href=url; return; } if (!m) { window.location.href=url; return; }
var qs = url.indexOf('?')!==-1 ? url.substring(url.indexOf('?')) : ''; var qs = url.indexOf('?')!==-1 ? url.substring(url.indexOf('?')) : '';
var resp = await fetch('/videos/'+m[1]+'/player-data'+qs); var resp;
try { resp = await fetch('/videos/'+m[1]+'/player-data'+qs); }
catch(e) { window.location.href=url; return; }
if (!resp.ok) { window.location.href=url; return; } if (!resp.ok) { window.location.href=url; return; }
var d = await resp.json(); var d;
try { d = await resp.json(); }
catch(e) { window.location.href=url; return; }
// swap audio src (keeps browser autoplay permission) // swap audio src (keeps browser autoplay permission)
var audio = document.getElementById('audioEl'); var audio = document.getElementById('audioEl');
@ -571,13 +581,13 @@
} }
// update cover / slideshow / title / progress via shared hook // update cover / slideshow / title / progress via shared hook
if (window._audioPlayerUpdate) window._audioPlayerUpdate(d); try { if (window._audioPlayerUpdate) window._audioPlayerUpdate(d); }
catch(e) { console.warn('_audioPlayerUpdate', e); }
// update state // update state
PL_CURRENT = d.id; PL_CURRENT = d.id;
if(audio&&plLoop==='one') audio.loop=true; else if(audio) audio.loop=false; if(audio&&plLoop==='one') audio.loop=true; else if(audio) audio.loop=false;
plRender(); try { plRender(); plHighlight(d.id, true); } catch(e) { console.warn('plRender', e); }
plHighlight(d.id, true);
// history // history
if(pushHist!==false) history.pushState({plVideoId:d.id,url:url},'',url); if(pushHist!==false) history.pushState({plVideoId:d.id,url:url},'',url);
@ -589,7 +599,6 @@
plSwapContent(url); plSwapContent(url);
} catch(e) { } catch(e) {
console.warn('plTransitionTo',e); console.warn('plTransitionTo',e);
window.location.href=url;
} finally { } finally {
plTransiting=false; plTransiting=false;
} }
@ -631,8 +640,17 @@
c.classList.toggle('current-video', parseInt(c.dataset.plId)===activeId); c.classList.toggle('current-video', parseInt(c.dataset.plId)===activeId);
}); });
if(scroll) { if(scroll) {
var active=document.querySelector('.sidebar-video-card.current-video'); // Scroll only inside the sidebar list container — never the page —
if(active) active.scrollIntoView({behavior:'smooth',block:'nearest'}); // so focus stays on the player when the track changes.
var active = document.querySelector('.sidebar-video-card.current-video');
if (!active) return;
var list = active.closest('.recommended-videos-list') || active.parentElement;
if (list) {
var lr = list.getBoundingClientRect();
var ar = active.getBoundingClientRect();
var delta = (ar.top + ar.height/2) - (lr.top + lr.height/2);
list.scrollTo({ top: list.scrollTop + delta, behavior: 'smooth' });
}
} }
} }
@ -651,6 +669,9 @@
if(ab){ ab.classList.toggle('pl-ctrl-active',plAutoplay==='1'); ab.title='Autoplay: '+(plAutoplay==='1'?'On':'Off'); } if(ab){ ab.classList.toggle('pl-ctrl-active',plAutoplay==='1'); ab.title='Autoplay: '+(plAutoplay==='1'?'On':'Off'); }
if(nb) nb.disabled=!adj.next; if(nb) nb.disabled=!adj.next;
if(pb) pb.disabled=!adj.prev; if(pb) pb.disabled=!adj.prev;
// In-player prev/next buttons (audio-player.blade.php)
document.querySelectorAll('.ytp-prev-btn').forEach(function(b){ b.disabled=!adj.prev; b.style.opacity=adj.prev?'':'0.4'; });
document.querySelectorAll('.ytp-next-btn').forEach(function(b){ b.disabled=!adj.next; b.style.opacity=adj.next?'':'0.4'; });
} }
// ── public API ──────────────────────────────────────────── // ── public API ────────────────────────────────────────────
@ -768,6 +789,7 @@
async function recTransitionTo(url, pushHist) { async function recTransitionTo(url, pushHist) {
if (recTransiting) return; if (recTransiting) return;
recTransiting = true; recTransiting = true;
if (window._spaScrollToVideo) window._spaScrollToVideo();
try { try {
var dataUrl = url.split('?')[0] + '/player-data'; var dataUrl = url.split('?')[0] + '/player-data';
var resp = await fetch(dataUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); var resp = await fetch(dataUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });

View File

@ -4,6 +4,7 @@ use App\Http\Controllers\CommentController;
use App\Http\Controllers\ImageUploadController; use App\Http\Controllers\ImageUploadController;
use App\Http\Controllers\MatchEventController; use App\Http\Controllers\MatchEventController;
use App\Http\Controllers\MediaController; use App\Http\Controllers\MediaController;
use App\Http\Controllers\SportsMatchController;
use App\Http\Controllers\PostController; use App\Http\Controllers\PostController;
use App\Http\Controllers\SuperAdminController; use App\Http\Controllers\SuperAdminController;
use App\Http\Controllers\UserController; use App\Http\Controllers\UserController;
@ -16,6 +17,7 @@ Route::get('/media/thumbnails/{filename}', [MediaController::class, 'thumbnail
Route::get('/media/avatars/{filename}', [MediaController::class, 'avatar'])->name('media.avatar')->where('filename', '.+'); Route::get('/media/avatars/{filename}', [MediaController::class, 'avatar'])->name('media.avatar')->where('filename', '.+');
Route::get('/media/banners/{filename}', [MediaController::class, 'banner'])->name('media.banner')->where('filename', '.+'); Route::get('/media/banners/{filename}', [MediaController::class, 'banner'])->name('media.banner')->where('filename', '.+');
Route::get('/media/post-images/{filename}', [MediaController::class, 'postImage'])->name('media.post-image')->where('filename', '.+'); Route::get('/media/post-images/{filename}', [MediaController::class, 'postImage'])->name('media.post-image')->where('filename', '.+');
Route::get('/media/sports/{filename}', [MediaController::class, 'sportsImage'])->name('media.sports-image')->where('filename', '.+');
// Root route - show videos // Root route - show videos
Route::get('/', [VideoController::class, 'index'])->name('home'); Route::get('/', [VideoController::class, 'index'])->name('home');
@ -49,12 +51,16 @@ Route::get('/videos/{video}/recommendations', [VideoController::class, 'recommen
Route::get('/videos/{video}/player-data', [VideoController::class, 'playerData'])->name('videos.playerData'); Route::get('/videos/{video}/player-data', [VideoController::class, 'playerData'])->name('videos.playerData');
Route::post('/videos/{video}/share', [VideoController::class, 'recordShare'])->name('videos.recordShare'); Route::post('/videos/{video}/share', [VideoController::class, 'recordShare'])->name('videos.recordShare');
Route::post('/videos/{video}/share/email', [VideoController::class, 'shareByEmail'])->name('videos.shareEmail')->middleware(['auth', 'throttle:10,1']); Route::post('/videos/{video}/share/email', [VideoController::class, 'shareByEmail'])->name('videos.shareEmail')->middleware(['auth', 'throttle:10,1']);
Route::post('/videos/{video}/share/members', [VideoController::class, 'shareWithMembers'])->name('videos.shareMembers')->middleware(['auth', 'throttle:20,1']);
Route::get('/videos/{video}/og-image', [VideoController::class, 'ogImage'])->name('videos.ogImage'); Route::get('/videos/{video}/og-image', [VideoController::class, 'ogImage'])->name('videos.ogImage');
Route::get('/s/{token}', [VideoController::class, 'accessShare'])->name('share.access'); Route::get('/s/{token}', [VideoController::class, 'accessShare'])->name('share.access');
Route::get('/videos/{video}/insights', [VideoController::class, 'insights'])->name('videos.insights')->middleware('auth'); Route::get('/videos/{video}/insights', [VideoController::class, 'insights'])->name('videos.insights')->middleware('auth');
Route::get('/videos/{video}/insights/country/{country}', [VideoController::class, 'insightsCountry'])->name('videos.insights.country')->middleware('auth'); Route::get('/videos/{video}/insights/country/{country}', [VideoController::class, 'insightsCountry'])->name('videos.insights.country')->middleware('auth');
Route::get('/videos/{video}/insights/day/{date}', [VideoController::class, 'insightsDay'])->name('videos.insights.day')->middleware('auth'); Route::get('/videos/{video}/insights/day/{date}', [VideoController::class, 'insightsDay'])->name('videos.insights.day')->middleware('auth');
Route::get('/videos/{video}/insights/downloader/{userId}', [VideoController::class, 'insightsDownloaderHistory'])->name('videos.insights.downloader')->middleware('auth'); Route::get('/videos/{video}/insights/downloader/{userId}', [VideoController::class, 'insightsDownloaderHistory'])->name('videos.insights.downloader')->middleware('auth');
Route::get('/videos/{video}/insights/viewer/{who}', [VideoController::class, 'insightsViewer'])->where('who', '[ug]:[A-Za-z0-9\.:_-]+')->name('videos.insights.viewer')->middleware('auth');
Route::get('/videos/{video}/insights/share/{token}', [VideoController::class, 'insightsShare'])->where('token', '[A-Za-z0-9]+')->name('videos.insights.share')->middleware('auth');
Route::post('/videos/{video}/identify', [VideoController::class, 'identify'])->name('videos.identify');
// Video routes - auth + verified required // Video routes - auth + verified required
Route::middleware(['auth', 'verified'])->group(function () { Route::middleware(['auth', 'verified'])->group(function () {
@ -79,8 +85,13 @@ Route::middleware(['auth'])->group(function () {
Route::get('/notifications/fetch', [UserController::class, 'fetchNotifications'])->name('notifications.fetch'); Route::get('/notifications/fetch', [UserController::class, 'fetchNotifications'])->name('notifications.fetch');
Route::post('/notifications/{id}/read', [UserController::class, 'markNotificationRead'])->name('notifications.read'); Route::post('/notifications/{id}/read', [UserController::class, 'markNotificationRead'])->name('notifications.read');
Route::post('/notifications/read-all', [UserController::class, 'markAllNotificationsRead'])->name('notifications.read-all'); Route::post('/notifications/read-all', [UserController::class, 'markAllNotificationsRead'])->name('notifications.read-all');
Route::get('/users/search', [UserController::class, 'searchUsers'])->name('users.search');
}); });
// View progress + profile-visit tracking — accept both guests and authed users
Route::post('/videos/{video}/view-progress', [VideoController::class, 'trackProgress'])->name('videos.viewProgress');
Route::post('/profile-visits/{user}', [UserController::class, 'recordProfileVisit'])->name('profile.visit');
// Comment routes // Comment routes
Route::get('/videos/{video}/comments', [CommentController::class, 'index'])->name('comments.index'); Route::get('/videos/{video}/comments', [CommentController::class, 'index'])->name('comments.index');
Route::middleware(['auth', 'verified'])->group(function () { Route::middleware(['auth', 'verified'])->group(function () {
@ -134,6 +145,7 @@ Route::get('/user/playlists', [PlaylistController::class, 'userPlaylists'])->nam
// Public playlist routes // Public playlist routes
Route::get('/playlists', [PlaylistController::class, 'index'])->name('playlists.index'); Route::get('/playlists', [PlaylistController::class, 'index'])->name('playlists.index');
Route::get('/playlists/share/{token}', [PlaylistController::class, 'showByToken'])->name('playlists.showByToken'); Route::get('/playlists/share/{token}', [PlaylistController::class, 'showByToken'])->name('playlists.showByToken');
Route::get('/playlists/{playlist}/og-image', [PlaylistController::class, 'ogImage'])->name('playlists.ogImage');
Route::get('/playlists/{playlist}', [PlaylistController::class, 'show'])->name('playlists.show'); Route::get('/playlists/{playlist}', [PlaylistController::class, 'show'])->name('playlists.show');
Route::post('/playlists/{playlist}/share', [PlaylistController::class, 'recordShare'])->name('playlists.recordShare'); Route::post('/playlists/{playlist}/share', [PlaylistController::class, 'recordShare'])->name('playlists.recordShare');
Route::get('/ps/{token}', [PlaylistController::class, 'accessShare'])->name('playlists.accessShare'); Route::get('/ps/{token}', [PlaylistController::class, 'accessShare'])->name('playlists.accessShare');
@ -168,6 +180,13 @@ Route::middleware(['auth'])->group(function () {
Route::post('/2fa/enable', [TwoFactorController::class, 'enable'])->name('2fa.enable'); Route::post('/2fa/enable', [TwoFactorController::class, 'enable'])->name('2fa.enable');
Route::post('/2fa/disable', [TwoFactorController::class, 'disable'])->name('2fa.disable'); Route::post('/2fa/disable', [TwoFactorController::class, 'disable'])->name('2fa.disable');
}); });
// Sports match records (progressive-disclosure creation flow)
Route::middleware(['auth'])->group(function () {
Route::post('/sports-matches', [SportsMatchController::class, 'store'])->name('sports-matches.store');
Route::get('/sports-matches/{sportsMatch}/edit', [SportsMatchController::class, 'edit'])->name('sports-matches.edit');
Route::put('/sports-matches/{sportsMatch}', [SportsMatchController::class, 'update'])->name('sports-matches.update');
});
// Signed confirmation link — no auth required (user may click from a different device/browser) // Signed confirmation link — no auth required (user may click from a different device/browser)
Route::get('/2fa/disable/confirm', [TwoFactorController::class, 'confirmDisable'])->name('2fa.disable.confirm'); Route::get('/2fa/disable/confirm', [TwoFactorController::class, 'confirmDisable'])->name('2fa.disable.confirm');