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:
parent
6aae6f86b6
commit
73527f3781
@ -254,6 +254,23 @@ Stored value: IANA timezone string (e.g. `"Asia/Bahrain"`).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## `<x-share-modal>` & `<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:
|
||||||
|
|||||||
14
CLAUDE.md
14
CLAUDE.md
@ -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.
|
||||||
|
|||||||
6
TODO.md
6
TODO.md
@ -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.
|
|
||||||
@ -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**
|
|
||||||
@ -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.
|
|
||||||
@ -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
|
|
||||||
|
|
||||||
@ -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
|
|
||||||
|
|
||||||
41
TODO_new.md
41
TODO_new.md
@ -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
|
|
||||||
@ -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
|
|
||||||
|
|
||||||
@ -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
|
|
||||||
|
|
||||||
@ -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.
|
|
||||||
@ -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
|
|
||||||
|
|
||||||
@ -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
|
|
||||||
|
|
||||||
@ -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)
|
|
||||||
@ -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
|
||||||
|
|||||||
@ -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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
307
app/Http/Controllers/SportsMatchController.php
Normal file
307
app/Http/Controllers/SportsMatchController.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,11 +2667,21 @@ 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) {
|
||||||
|
if ($r->user_id) {
|
||||||
|
$key = 'u:' . $r->user_id;
|
||||||
|
} else {
|
||||||
|
$guestKey = $r->device_hash ?: ($r->device_id ?: ($r->ip_address ?: 'unknown'));
|
||||||
|
$key = 'g:' . $guestKey;
|
||||||
|
}
|
||||||
|
if (! isset($groups[$key])) {
|
||||||
|
$ua = $this->parseUserAgent($r->user_agent);
|
||||||
|
$groups[$key] = [
|
||||||
|
'key' => $key,
|
||||||
'user_id' => $r->user_id,
|
'user_id' => $r->user_id,
|
||||||
'user_channel' => $r->user_channel,
|
'user_channel' => $r->user_channel,
|
||||||
'user_name' => $r->user_name ?? 'Guest',
|
'user_name' => $r->user_name ?? 'Guest',
|
||||||
@ -2533,7 +2690,17 @@ class VideoController extends Controller
|
|||||||
? route('media.avatar', $r->user_avatar)
|
? route('media.avatar', $r->user_avatar)
|
||||||
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
|
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
|
||||||
: null,
|
: 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,35 +2746,44 @@ 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])) {
|
||||||
|
$ua = $this->parseUserAgent($r->user_agent);
|
||||||
|
$dlGroups[$key] = [
|
||||||
|
'key' => $key,
|
||||||
'user_id' => $r->user_id,
|
'user_id' => $r->user_id,
|
||||||
'user_name' => $r->user_name ?? 'Guest',
|
'user_name' => $r->user_name ?? 'Guest',
|
||||||
'user_avatar'=> $r->user_id
|
'user_avatar'=> $r->user_id
|
||||||
@ -2599,7 +2791,17 @@ class VideoController extends Controller
|
|||||||
? route('media.avatar', $r->user_avatar)
|
? route('media.avatar', $r->user_avatar)
|
||||||
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
|
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
|
||||||
: null,
|
: 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, '13–17' => 0, '18–24' => 0, '25–34' => 0, '35–44' => 0, '45–54' => 0, '55–64' => 0, '65+' => 0];
|
$blank = ['count' => 0, 'male' => 0, 'female' => 0, 'other' => 0];
|
||||||
|
$ageBuckets = [
|
||||||
|
'Under 13' => $blank, '13–17' => $blank, '18–24' => $blank, '25–34' => $blank,
|
||||||
|
'35–44' => $blank, '45–54' => $blank, '55–64' => $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 => '55–64',
|
$age <= 64 => '55–64',
|
||||||
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
43
app/Models/ProfileVisit.php
Normal file
43
app/Models/ProfileVisit.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Models/SportsMatch.php
Normal file
51
app/Models/SportsMatch.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
58
app/Notifications/VideoSharedWithUser.php
Normal file
58
app/Notifications/VideoSharedWithUser.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Support/EmailThumbnail.php
Normal file
59
app/Support/EmailThumbnail.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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
281
public/fp.js
Normal 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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
41
resources/views/components/share-button.blade.php
Normal file
41
resources/views/components/share-button.blade.php
Normal 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
|
||||||
@ -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>
|
||||||
@ -91,9 +139,10 @@ 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 _shareMembersUrl = '';
|
||||||
var _shareTrack = '';
|
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;
|
||||||
|
|
||||||
@ -102,6 +151,7 @@ async function openShareModal(videoUrl, videoTitle, recordUrl, emailUrl) {
|
|||||||
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 || '';
|
||||||
|
_shareMembersUrl = membersUrl || '';
|
||||||
_shareTrack = trackParam;
|
_shareTrack = trackParam;
|
||||||
|
|
||||||
// Obtain a unique tracked share link from the server
|
// Obtain a unique tracked share link from the server
|
||||||
@ -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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||||
|
|
||||||
12
resources/views/components/sports-image.blade.php
Normal file
12
resources/views/components/sports-image.blade.php
Normal 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>
|
||||||
20
resources/views/components/sports-section.blade.php
Normal file
20
resources/views/components/sports-section.blade.php
Normal 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>
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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() {});
|
||||||
|
|||||||
@ -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
|
||||||
|
const hasTopViewers = !!topViewerRows;
|
||||||
|
const hasRecentViewers = !!recentViewerRows;
|
||||||
|
let viewersHtml = '';
|
||||||
|
if (hasTopViewers || hasRecentViewers) {
|
||||||
|
const topCol = hasTopViewers ? `
|
||||||
|
<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}
|
||||||
|
</div>` : '';
|
||||||
|
const recentCol = hasRecentViewers ? `
|
||||||
|
<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.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 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-people-fill"></i> Viewers — ${_fmt(d.unique_viewers)} registered · ${_fmt(d.guest_views||0)} guest</div>
|
<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 class="ins-two-col">
|
<div class="${wrap}">${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 Viewers</div>
|
|
||||||
${topViewerRows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No registered viewers yet.</p>'}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">Recent Activity</div>
|
|
||||||
${recentViewerRows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No views yet.</p>'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Who Liked section ───────────────────────────────
|
// ── Who Liked section ───────────────────────────────
|
||||||
let likersHtml = '';
|
let likersHtml = '';
|
||||||
@ -429,122 +527,113 @@ 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>`;
|
||||||
<div class="ins-section-title"><i class="bi bi-gender-ambiguous"></i> Viewers by Gender</div>
|
|
||||||
${genderHtml}
|
// Demographics — only when at least one bucket has data
|
||||||
</div>
|
const demographicsHtml = ageHtml ? `
|
||||||
<div>
|
<div style="margin-top:22px;">
|
||||||
<div class="ins-section-title"><i class="bi bi-people-fill"></i> Viewers by Age Group</div>
|
<div class="ins-section-title"><i class="bi bi-people-fill"></i> Viewers by Age Group</div>
|
||||||
${ageHtml}
|
${ageHtml}
|
||||||
</div>
|
${ageLegend}
|
||||||
</div>
|
</div>` : '';
|
||||||
</div>`;
|
|
||||||
|
|
||||||
// ── Share links breakdown ───────────────────────────
|
// ── Share links breakdown ───────────────────────────
|
||||||
let shareHtml = '';
|
let shareHtml = '';
|
||||||
@ -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 & 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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 '🎉 ' + actor + ' just joined TAKEONE!';
|
case 'new_user': return '🎉 ' + actor + ' just joined TAKEONE!';
|
||||||
case 'new_subscriber': return '🔔 ' + actor + ' subscribed to your channel';
|
case 'new_subscriber': return '🔔 ' + actor + ' subscribed to your channel';
|
||||||
case 'video_like': return '❤️ ' + actor + ' liked your video ' + title;
|
case 'video_like': return '❤️ ' + actor + ' liked your video ' + title;
|
||||||
|
case 'video_shared': return '📤 ' + actor + ' shared a video with you: ' + title + (d.message ? ' <em class="yt-notif-preview">"' + escHtml(d.message) + '"</em>' : '');
|
||||||
case 'new_post': return '📝 ' + actor + ' posted something new';
|
case 'new_post': return '📝 ' + 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>
|
||||||
|
|||||||
887
resources/views/layouts/partials/sports-match-modal.blade.php
Normal file
887
resources/views/layouts/partials/sports-match-modal.blade.php
Normal 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 & 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 & 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 & 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>
|
||||||
@ -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 & 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>
|
||||||
|
|||||||
@ -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>
|
||||||
/* ══════════════════════════════════════════════
|
/* ══════════════════════════════════════════════
|
||||||
|
|||||||
@ -243,7 +243,7 @@
|
|||||||
|
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@include('layouts.partials.share-modal')
|
<x-share-modal />
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@section('scripts')
|
@section('scripts')
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'))
|
||||||
|
|||||||
@ -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 -->
|
||||||
|
|||||||
@ -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'))
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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) {
|
||||||
|
// Scroll only inside the sidebar list container — never the page —
|
||||||
|
// so focus stays on the player when the track changes.
|
||||||
var active = document.querySelector('.sidebar-video-card.current-video');
|
var active = document.querySelector('.sidebar-video-card.current-video');
|
||||||
if(active) active.scrollIntoView({behavior:'smooth',block:'nearest'});
|
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' } });
|
||||||
|
|||||||
@ -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');
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user