Add NAS file manager integration and all pending platform changes

- Installed p7h/nas-file-manager package via private VCS repo
- Published config/nas-file-manager.php with super_admin middleware restriction
- Added NAS env vars to .env.example
- Created admin/nas-storage page with connection info panel and file browser widget
- Added NAS Storage link to admin sidebar (super_admin only)
- Added SuperAdminController@nasStorage method and admin.nas-storage route
- Includes all accumulated branch changes: profile wall, 2FA, audit logs,
  settings panel, country/phone/timezone components, posts, slideshow,
  playlist shares, video downloads/shares, comment likes, notifications,
  social links, and more

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ghassan 2026-05-13 13:24:32 +03:00
parent d44490dfe0
commit 0b2e95ea65
161 changed files with 27496 additions and 7250 deletions

186
.claude/component-usage.md Normal file
View File

@ -0,0 +1,186 @@
# Reusable Select Component Usage
This file tracks every page/partial that uses `<x-phone-code-select>`, `<x-country-select>`, or `<x-timezone-select>`.
**Update this file whenever you add or remove a component from a view.**
When modifying any component or its data source (`app/Data/Countries.php`), check all pages in the relevant section below and verify the change works correctly in each context.
---
## Data source
**`app/Data/Countries.php`** — `App\Data\Countries`
| Method | Used by component |
|---|---|
| `Countries::forPhoneCode()` | `<x-phone-code-select>` |
| `Countries::forCountry()` | `<x-country-select>` |
| `Countries::forTimezone()` | `<x-timezone-select>` |
| `Countries::all()` | All three (via the above methods) |
Adding or renaming a field in `Countries::all()` requires updating the corresponding `for*()` method too.
---
## Shared CSS / JS
The `.csd-*` CSS rules and the `window.CSD` class are duplicated across all three component files inside `@once` guards. If you change the look or behaviour of the dropdown, **update all three component files**:
- `resources/views/components/phone-code-select.blade.php`
- `resources/views/components/country-select.blade.php`
- `resources/views/components/timezone-select.blade.php`
The `@once` Blade directive ensures the browser only receives one copy of the CSS/JS even when multiple components are on the same page.
---
## `<x-video-insights>`
**File:** `resources/views/components/video-insights.blade.php`
**Props:** `:video``Video` model instance.
**Behaviour:** Renders the Insights tab panel (`<div class="vdb-panel" id="vdb-insights">`), the drill-down modal, all `.ins-*` CSS, and all insights JS (`loadInsights`, `renderInsights`, modal openers, country/day/downloader drill-downs). Only renders if `Auth::id() === $video->user_id`. Must be placed **inside `.vdb-wrap`**, after the About panel, so the tab-switch CSS applies. The parent view must call `loadInsights()` (global, defined by this component) when the Insights tab is activated.
**Data source:** `GET /videos/{video}/insights` (JSON) + drill-down routes `/insights/country/{code}`, `/insights/day/{date}`, `/insights/downloader/{userId}`.
| View file | Placement | Notes |
|---|---|---|
| `resources/views/videos/partials/description-box.blade.php` | Inside `.vdb-wrap`, after About panel | Used by all three video type views (generic, match, music) |
| `resources/views/videos/show.blade.php` | Inside `.vdb-wrap`, after About panel | Legacy view (not rendered by controller — kept in sync) |
---
## `<x-social-links-editor>`
**File:** `resources/views/components/social-links-editor.blade.php`
**Props:** `existing` — associative array keyed by platform name (e.g. `['twitter' => 'handle', 'whatsapp' => '97312345678']`).
**Behaviour:** Dynamic add/remove rows; each row has a custom icon dropdown to pick the platform and a text input for the value. Supported platforms: `twitter`, `instagram`, `facebook`, `youtube`, `linkedin`, `tiktok`, `whatsapp`, `website`, `google_location`, `social_phone`, `social_email`. Hidden clear inputs ensure removed entries are cleared on save. Must be placed **inside a `<form>`**.
**DB columns:** `twitter`, `instagram`, `facebook`, `youtube`, `linkedin`, `tiktok`, `website` (legacy), `whatsapp`, `google_location`, `social_phone`, `social_email`.
| View file | Placement | Notes |
|---|---|---|
| `resources/views/user/profile.blade.php` | Social tab of Edit Profile modal | `$socialExisting` array passed from `@php` block above `@section('scripts')` |
---
## `<x-date-picker>`
**File:** `resources/views/components/date-picker.blade.php`
**Stored value:** `YYYY-MM-DD` string in a hidden input (same format as `<input type="date">`).
**Props:** `name`, `id`, `value`, `label`, `required`, `class`, `style`, `minYear` (default 1900), `maxYear` (default current year).
**Behaviour:** Day grid (5 columns, 131), month list (JanuaryDecember), year searchable list (descending). Days auto-constrain on month/year change; invalid selected day resets automatically.
| View file | Field name | Notes |
|---|---|---|
| `resources/views/user/profile.blade.php` | `birthday` | Replaces `<input type="date">` |
| `resources/views/auth/register.blade.php` | `birthday` | Registration form — mandatory |
---
## `<x-image-cropper>`
**File:** `resources/views/components/image-cropper.blade.php`
**Props:** `id` (unique, required), `width` (px, default 300), `height` (px, default 300), `shape` (`circle`|`square`, default `circle`), `folder` (storage subfolder), `filename` (base name without extension), `callback` (JS function name called with URL on success), `update-url` (endpoint to POST `{path}` after crop to update DB), `title` (modal heading).
**Behaviour:** Renders a full-screen dark-themed modal with Cropme.js. Shows camera icon on avatar/banner hover (owner only). After crop: POSTs base64 to `/image-upload`, optionally POSTs the path to `update-url`, then calls `callback(url)`. Uses local assets (`public/js/cropme.min.js`, `public/css/cropme.min.css`). No jQuery required.
**Assets needed:** `public/js/cropme.min.js`, `public/css/cropme.min.css`.
**Routes needed:** `image.upload` (POST `/image-upload`).
| View file | id / use | Notes |
|---|---|---|
| `resources/views/user/channel.blade.php` | `avatar` — circle 300×300 | Owner only; `update-url = profile.updateAvatar`; callback `onAvatarSaved` |
| `resources/views/user/channel.blade.php` | `banner` — square 500×160 | Owner only; `update-url = profile.updateBanner`; callback `onBannerSaved` |
| `resources/views/layouts/partials/upload-modal.blade.php` | `thumb_upload` — square 448×252 | Form mode; `target-input=thumbnail-modal`; output 1280px |
| `resources/views/layouts/partials/edit-video-modal.blade.php` | `thumb_edit` — square 448×252 | Form mode; `target-input=edit-thumbnail-input`; output 1280px |
| `resources/views/videos/create.blade.php` | `thumb_create_mobile` — square 448×252 | Mobile; `target-input=thumbnail`; output 1280px |
| `resources/views/videos/edit.blade.php` | `thumb_edit_mobile` — square 448×252 | Mobile; `target-input=edit-thumbnail`; output 1280px |
| `resources/views/playlists/index.blade.php` | `thumb_pl_create` — square 448×252 | Form mode; `target-input=playlist-thumbnail-input`; output 1280px |
| `resources/views/playlists/show.blade.php` | `thumb_pl_edit` — square 448×252 | Form mode; `target-input=playlistThumbnailInput`; output 1280px |
---
## `<x-gender-select>`
**File:** `resources/views/components/gender-select.blade.php`
**Props:** `name`, `id`, `value` (ISO string: `"male"` or `"female"`), `label`, `required`, `class`, `style`.
**Behaviour:** Custom dropdown with blue ♂ (male) and pink ♀ (female) symbols. No search needed. Stores value as `"male"` or `"female"`.
| View file | Field name | Notes |
|---|---|---|
| `resources/views/auth/register.blade.php` | `gender` | Registration form — mandatory |
---
## `<x-phone-code-select>`
Stored value format: `"+973|BH"` (dial_code + pipe + ISO2).
To read only the dial code from a stored value: `explode('|', $value)[0]`.
| View file | Field name | Notes |
|---|---|---|
| `resources/views/user/profile.blade.php` | `phone_code` | Paired with `phone_number` text input |
---
## `<x-country-select>`
Stored value: ISO2 code (e.g. `"BH"`).
| View file | Field name | Notes |
|---|---|---|
| `resources/views/user/profile.blade.php` | `nationality` | Edit Profile form |
| `resources/views/auth/register.blade.php` | `nationality` | Registration form — mandatory |
---
## `<x-timezone-select>`
Stored value: IANA timezone string (e.g. `"Asia/Bahrain"`).
| View file | Field name | Notes |
|---|---|---|
| `resources/views/user/profile.blade.php` | `timezone` | Edit Profile form |
---
## Usage example
```blade
{{-- Phone code + number side by side --}}
<div style="display:flex; gap:8px;">
<x-phone-code-select
name="phone_code"
value="+973|BH"
label="Phone"
required
style="width:140px; flex-shrink:0;"
/>
<input type="tel" name="phone_number" class="form-control">
</div>
{{-- Country / nationality --}}
<x-country-select
name="nationality"
label="Nationality"
placeholder="Select nationality"
value="{{ old('nationality', $user->nationality) }}"
required
/>
{{-- Timezone --}}
<x-timezone-select
name="timezone"
label="Timezone"
value="{{ old('timezone', $user->timezone ?? 'Asia/Bahrain') }}"
required
/>
```
---
## Modification checklist
When you modify any of these components, work through this list:
- [ ] Update `app/Data/Countries.php` if the data structure changes
- [ ] Update all three `.blade.php` component files if shared CSS/JS changes
- [ ] Update the `for*()` method in `Countries.php` that feeds the changed component
- [ ] Re-test every page listed in the usage tables above
- [ ] Add/remove rows from the usage tables if views were added or removed

5
.claude/settings.json Normal file
View File

@ -0,0 +1,5 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}

View File

@ -57,3 +57,14 @@ VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
# NAS File Manager
NAS_PROTOCOL=smb
NAS_HOST=
NAS_PORT=445
NAS_USERNAME=
NAS_PASSWORD=
NAS_PATH=/media
NAS_SMB_SHARE=
NAS_SMB_DOMAIN=
NAS_FM_ROUTE_PREFIX=nas-file-manager

156
CLAUDE.md Normal file
View File

@ -0,0 +1,156 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**TAKEONE** (Play) is a Laravel 10 video-sharing platform with sports match annotation, HLS adaptive streaming, GPU-accelerated video processing, playlists, threaded comments, and a super-admin panel. Live at `https://video.takeone.bh`.
Tech stack: PHP 8.1+, Laravel 10, Blade templating, Vite 5, Axios, FFmpeg/FFProbe with NVIDIA NVENC, SQLite (dev) / MySQL (prod), Laravel Sanctum.
---
## Essential Commands
### Local Development
```bash
php artisan serve # Backend on http://localhost:8000
npm run dev # Vite dev server with HMR
npm run build # Production frontend build → public/build/
```
### Database
```bash
php artisan migrate
php artisan db:seed
php artisan tinker
```
### Background Workers
```bash
# Video processing (CompressVideoJob, GenerateHlsJob)
php artisan queue:work --queue=video-processing
# Orphaned file cleanup scheduler (every CLEANUP_INTERVAL_MINUTES, default 30)
php artisan schedule:run # run this every minute via cron
php artisan cleanup:orphaned-videos --dry-run # preview only
php artisan cleanup:orphaned-videos --force # delete orphans
```
### Testing
```bash
./vendor/bin/phpunit
./vendor/bin/phpunit --filter "TestName"
./vendor/bin/phpunit tests/Feature/ExampleTest.php
```
Tests use in-memory SQLite, array mail/cache/session drivers, sync queue, and BCRYPT_ROUNDS=4. Set `APP_ENV=testing`.
---
## Architecture
### Role System
Three roles stored on `users.role`: `super_admin`, `admin`, `user` (null = user). The `IsSuperAdmin` middleware guards all `/admin/*` routes. Helper methods on the User model: `isSuperAdmin()`, `isAdmin()`, `isUser()`.
### Video Lifecycle
1. `VideoController@store` validates upload, stores file, extracts metadata via FFProbe (duration, dimensions, orientation).
2. Status column transitions: `pending → processing → ready` (or `failed`).
3. `CompressVideoJob` re-encodes with NVIDIA NVENC (`h264_nvenc`, CRF 23), replaces original if smaller.
4. `GenerateHlsJob` produces 480p / 720p / 1080p HLS variants (`h264_nvenc`, preset p4) as `.m3u8` + `.ts` files.
5. Streaming served at `GET /videos/{video}/hls/{file?}` (master playlist → segments).
Queue connection is `sync` by default (runs inline); switch `QUEUE_CONNECTION=database` or `redis` for true async.
### Shorts Auto-Detection
A video is a Short when duration ≤ 60 s **and** portrait orientation. The `is_shorts` DB column allows manual override. Use model scopes `Video::shorts()` / `Video::notShorts()`.
### Trending Algorithm
`Video::scopeTrending($hours=48, $limit=50)` — weighted score: 70% recent views, 15% view velocity, 10% recency bonus, 5% likes. Only applies to videos < 10 days old with 5 recent views.
### Playlist System
- `playlist_videos` pivot carries `position`, `watched_seconds`, `watched`, `added_at`, `last_watched_at`.
- Each user has one `is_default = true` playlist ("Watch Later").
- `Playlist` model methods: `addVideo()`, `removeVideo()`, `reorder()`, `getNextVideo()`, `getPreviousVideo()`, `updateWatchProgress()`, `canView()`, `canEdit()`.
### Comment Threading & Mentions
Comments have a `parent_id` for one-level threading (replies). The `parsed_body` accessor converts `@username` syntax into clickable profile links.
### Comment Timestamp Badges
The `enhanceBody()` function in `components/video-comments.blade.php` converts timestamp syntax written in comments into clickable `._comment-time-badge` spans at render time (client-side). Supported formats:
- `@mm:ss` — single timestamp, jumps to that point (colon separator)
- `@mm.ss` — same, dot separator (legacy, still supported)
- `@mm:ss-mm:ss` or `@mm.ss-mm.ss` — time range; plays from start to end then pauses
Clicking a badge: scrolls to `#ytpWrap` (smooth), waits 500 ms, seeks `#videoPlayer` to `start`, calls `.play()`, and if a range is specified stops at `end` via a `timeupdate` listener. `@username` mention detection requires the first char to be a letter so it never collides with numeric timestamps.
### Sports Match Annotation
Videos of type `match` support three related models:
- `MatchRound` — round number, name, `start_time_seconds`
- `MatchPoint``timestamp_seconds`, action, competitor (blue/red), running score
- `CoachReview` — time-range segment with coach note and emoji
All managed via `MatchEventController` under authenticated routes.
### Key Model Scopes & Accessors
`Video` scopes: `public()`, `visibleTo($user)`, `shorts()`, `notShorts()`, `trending()`.
`Video` accessors: `url`, `thumbnail_url`, `like_count`, `view_count`, `formatted_duration`, `iso_duration`, `open_graph_image`.
`Playlist` accessors: `thumbnail_url`, `video_count`, `total_duration`, `formatted_duration`.
## Rules
**Database changes require confirmation** — if any task requires a migration, schema change, or new column, always ask before proceeding.
**Never use `alert()`, `confirm()`, or `prompt()`** — use toast notifications or inline UI feedback instead.
**All buttons must use the global `.action-btn` system** — never add custom button CSS. Use these classes:
- `.action-btn` — default (bordered, bg-secondary)
- `.action-btn.action-btn-primary` or `.action-btn.primary` — brand red, for primary/submit actions
- `.action-btn.action-btn-danger` or `.action-btn.danger` — red border/text, for destructive actions
- `.action-btn.icon-only` — square padding, for icon-only buttons
Structure: `<button class="action-btn"><i class="bi bi-..."></i> <span>Label</span></button>`. The global CSS lives in `layouts/app.blade.php`.
**Mobile layout uses a native-app scroll model** — on `max-width: 768px`, `html` and `body` are locked (`overflow: hidden; position: fixed`) so the window never scrolls. `.yt-main` is `position: fixed` spanning `top: 56px` to `bottom: calc(56px + env(safe-area-inset-bottom))` with `overflow-y: auto; -webkit-overflow-scrolling: touch`. This keeps the header and bottom nav truly fixed without any JavaScript. Consequences to remember:
- **Never use `position: sticky` inside `.yt-main` on mobile** — sticky elements float over content because the scroll container changed. Override with `position: relative !important` in the mobile media query.
- **Never rely on `window.scrollY` or `window.scroll` events on mobile** — the window doesn't scroll; listen on `document.getElementById('main')` instead.
- **Bottom nav needs no JS transform** — since the window is locked, browser chrome animation never shifts fixed elements.
**Upload modal and upload page must always stay in sync** — the desktop upload UI lives in `resources/views/layouts/partials/upload-modal.blade.php` and the mobile upload UI lives in `resources/views/videos/create.blade.php`. On mobile, `openUploadModal()` redirects to the create page instead of showing the modal. **Any change made to one must be applied to the other immediately in the same task.**
**Edit modal and edit page must always stay in sync** — the desktop edit UI lives in `resources/views/layouts/partials/edit-video-modal.blade.php` and the mobile edit UI lives in `resources/views/videos/edit.blade.php`. On mobile (< 992px), `openEditVideoModal(videoId)` redirects to `/videos/{id}/edit` instead of opening the modal. **Any change made to one must be applied to the other immediately in the same task.** the desktop upload UI lives in `resources/views/layouts/partials/upload-modal.blade.php` and the mobile upload UI lives in `resources/views/videos/create.blade.php`. On mobile, `openUploadModal()` redirects to the create page instead of showing the modal. **Any change made to one must be applied to the other immediately in the same task** this includes new fields, validation logic, file-type support, JS behaviour, labels, and error handling. Never update only one side.
**Never build custom dropdowns for country, nationality, phone code, timezone, or currency** — reusable Blade components already exist for these. Always use them; never roll a new `<select>`, inline list, or custom picker:
| Need | Component | Stored value |
|---|---|---|
| Country / nationality picker | `<x-country-select name="…" />` | ISO2 code e.g. `"BH"` |
| Phone / dial-code picker | `<x-phone-code-select name="…" />` | `"+973|BH"` — split on `|` to get code alone |
| Timezone picker | `<x-timezone-select name="…" />` | IANA string e.g. `"Asia/Bahrain"` |
| Currency | read from `App\Data\Countries::all()[$iso2]['currency']` | ISO 4217 code e.g. `"BHD"` |
All three components accept `name`, `id`, `value`, `label`, `placeholder`, `required`, `class`, and `style` props. The full dataset lives in `app/Data/Countries.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.
**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.
2. **Placing a component in any view** → immediately add a row to the relevant tracker table (view file path, field/slot name, any relevant notes). Do this in the same task, not later.
3. **Modifying a component** (props, markup, CSS, JS, behaviour) → open the tracker first, read every row in that component's usage table, then apply the necessary follow-up changes to every listed view before marking the task done. Never modify a component without checking its tracker entries.
4. **Removing a component from a view** → delete its row from the tracker table in the same task.
5. **Deleting a component entirely** → remove its full section from the tracker and clean up every view that was still referencing it.
The tracker is the source of truth for blast radius. If the tracker is out of date and a change breaks an unlisted page, that is a process failure — always keep it accurate.
**Match highlights sidebar must always match the video player height** — use a `ResizeObserver` on `#ytpWrap` to write `--sidebar-height` to `document.documentElement` and bind `.events-sidebar { height: var(--sidebar-height) }`. Never hardcode a pixel or viewport height for the sidebar. The pattern lives in `videos/types/match.blade.php` (`initSidebarHeightSync`).
### Infrastructure Notes
- **Cloudflare proxy**: `AppServiceProvider` forces HTTPS and trusts Cloudflare headers via `TrustProxies`.
- **FFmpeg config**: `/config/ffmpeg.php` — binaries at `/usr/bin/ffmpeg` and `/usr/bin/ffprobe`, GPU device 0, 3600 s timeout.
- **Broadcasting**: Pusher is configured but `BroadcastServiceProvider` is commented out — not active.
- **Timezone**: `Asia/Bahrain` (set in `config/app.php`).
- **App name constant**: `config('app.name')` returns `TAKEONE`.
### Route Structure Summary
- Public: `/`, `/videos`, `/trending`, `/shorts`, `/videos/search`, `/videos/{video}`, stream/hls/download
- Auth-required: video CRUD, likes, comments, profile, settings, history, playlists, match events
- Admin (`/admin/*`, `super_admin` middleware): dashboard, user CRUD, video CRUD, orphan cleanup
- API: `GET /api/user` (Sanctum token auth)

27
README_cleanup.md Normal file
View File

@ -0,0 +1,27 @@
# Video Platform Orphan Cleanup ✅
## Core Feature Complete
**Cron Job:**
- Command: `php artisan cleanup:orphaned-videos --force`
- Scheduled: Every 30min (`CLEANUP_INTERVAL_MINUTES=30` in .env)
- Dir: Deletes `storage/app/public/videos/*` w/o DB Video::filename match (inc compressed_)
- Logs: `storage/logs/orphaned-videos.log`
**Dashboard (/admin/dashboard):**
- Gauge: Videos size %
- Button: AJAX manual clean
**Production Setup:**
```
* * * * * cd /var/www/videoplatform && php artisan schedule:run >> /dev/null 2>&1
```
**Tested:**
- Dry-run: Found/deleted 6 orphans
- Cron: Scheduled
- Logs: Active
Button CSRF issue? Use CLI manual or cron.
Self-cleaning platform ready. 🎥🧹

View File

@ -0,0 +1,10 @@
# 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**

361
app/Data/Countries.php Normal file
View File

@ -0,0 +1,361 @@
<?php
namespace App\Data;
class Countries
{
/**
* All countries keyed by ISO2 code.
* Fields: name, iso2, iso3, flag, dial_code, timezone, currency
*/
public static function all(): array
{
// Generate flag emoji from ISO2 (two regional indicator letters)
$f = fn(string $c): string =>
mb_chr(0x1F1E6 + ord($c[0]) - 65) .
mb_chr(0x1F1E6 + ord($c[1]) - 65);
return [
// ── AFRICA ──────────────────────────────────────────────────────────
'DZ' => ['name'=>'Algeria', 'iso2'=>'DZ','iso3'=>'DZA','flag'=>$f('DZ'),'dial_code'=>'+213', 'timezone'=>'Africa/Algiers', 'currency'=>'DZD'],
'AO' => ['name'=>'Angola', 'iso2'=>'AO','iso3'=>'AGO','flag'=>$f('AO'),'dial_code'=>'+244', 'timezone'=>'Africa/Luanda', 'currency'=>'AOA'],
'BJ' => ['name'=>'Benin', 'iso2'=>'BJ','iso3'=>'BEN','flag'=>$f('BJ'),'dial_code'=>'+229', 'timezone'=>'Africa/Porto-Novo', 'currency'=>'XOF'],
'BW' => ['name'=>'Botswana', 'iso2'=>'BW','iso3'=>'BWA','flag'=>$f('BW'),'dial_code'=>'+267', 'timezone'=>'Africa/Gaborone', 'currency'=>'BWP'],
'BF' => ['name'=>'Burkina Faso', 'iso2'=>'BF','iso3'=>'BFA','flag'=>$f('BF'),'dial_code'=>'+226', 'timezone'=>'Africa/Ouagadougou', 'currency'=>'XOF'],
'BI' => ['name'=>'Burundi', 'iso2'=>'BI','iso3'=>'BDI','flag'=>$f('BI'),'dial_code'=>'+257', 'timezone'=>'Africa/Bujumbura', 'currency'=>'BIF'],
'CV' => ['name'=>'Cabo Verde', 'iso2'=>'CV','iso3'=>'CPV','flag'=>$f('CV'),'dial_code'=>'+238', 'timezone'=>'Atlantic/Cape_Verde', 'currency'=>'CVE'],
'CM' => ['name'=>'Cameroon', 'iso2'=>'CM','iso3'=>'CMR','flag'=>$f('CM'),'dial_code'=>'+237', 'timezone'=>'Africa/Douala', 'currency'=>'XAF'],
'CF' => ['name'=>'Central African Republic', 'iso2'=>'CF','iso3'=>'CAF','flag'=>$f('CF'),'dial_code'=>'+236', 'timezone'=>'Africa/Bangui', 'currency'=>'XAF'],
'TD' => ['name'=>'Chad', 'iso2'=>'TD','iso3'=>'TCD','flag'=>$f('TD'),'dial_code'=>'+235', 'timezone'=>'Africa/Ndjamena', 'currency'=>'XAF'],
'KM' => ['name'=>'Comoros', 'iso2'=>'KM','iso3'=>'COM','flag'=>$f('KM'),'dial_code'=>'+269', 'timezone'=>'Indian/Comoro', 'currency'=>'KMF'],
'CD' => ['name'=>'Congo (DRC)', 'iso2'=>'CD','iso3'=>'COD','flag'=>$f('CD'),'dial_code'=>'+243', 'timezone'=>'Africa/Kinshasa', 'currency'=>'CDF'],
'CG' => ['name'=>'Congo (Republic)', 'iso2'=>'CG','iso3'=>'COG','flag'=>$f('CG'),'dial_code'=>'+242', 'timezone'=>'Africa/Brazzaville', 'currency'=>'XAF'],
'DJ' => ['name'=>'Djibouti', 'iso2'=>'DJ','iso3'=>'DJI','flag'=>$f('DJ'),'dial_code'=>'+253', 'timezone'=>'Africa/Djibouti', 'currency'=>'DJF'],
'EG' => ['name'=>'Egypt', 'iso2'=>'EG','iso3'=>'EGY','flag'=>$f('EG'),'dial_code'=>'+20', 'timezone'=>'Africa/Cairo', 'currency'=>'EGP'],
'GQ' => ['name'=>'Equatorial Guinea', 'iso2'=>'GQ','iso3'=>'GNQ','flag'=>$f('GQ'),'dial_code'=>'+240', 'timezone'=>'Africa/Malabo', 'currency'=>'XAF'],
'ER' => ['name'=>'Eritrea', 'iso2'=>'ER','iso3'=>'ERI','flag'=>$f('ER'),'dial_code'=>'+291', 'timezone'=>'Africa/Asmara', 'currency'=>'ERN'],
'SZ' => ['name'=>'Eswatini', 'iso2'=>'SZ','iso3'=>'SWZ','flag'=>$f('SZ'),'dial_code'=>'+268', 'timezone'=>'Africa/Mbabane', 'currency'=>'SZL'],
'ET' => ['name'=>'Ethiopia', 'iso2'=>'ET','iso3'=>'ETH','flag'=>$f('ET'),'dial_code'=>'+251', 'timezone'=>'Africa/Addis_Ababa', 'currency'=>'ETB'],
'GA' => ['name'=>'Gabon', 'iso2'=>'GA','iso3'=>'GAB','flag'=>$f('GA'),'dial_code'=>'+241', 'timezone'=>'Africa/Libreville', 'currency'=>'XAF'],
'GM' => ['name'=>'Gambia', 'iso2'=>'GM','iso3'=>'GMB','flag'=>$f('GM'),'dial_code'=>'+220', 'timezone'=>'Africa/Banjul', 'currency'=>'GMD'],
'GH' => ['name'=>'Ghana', 'iso2'=>'GH','iso3'=>'GHA','flag'=>$f('GH'),'dial_code'=>'+233', 'timezone'=>'Africa/Accra', 'currency'=>'GHS'],
'GN' => ['name'=>'Guinea', 'iso2'=>'GN','iso3'=>'GIN','flag'=>$f('GN'),'dial_code'=>'+224', 'timezone'=>'Africa/Conakry', 'currency'=>'GNF'],
'GW' => ['name'=>'Guinea-Bissau', 'iso2'=>'GW','iso3'=>'GNB','flag'=>$f('GW'),'dial_code'=>'+245', 'timezone'=>'Africa/Bissau', 'currency'=>'XOF'],
'CI' => ['name'=>'Ivory Coast', 'iso2'=>'CI','iso3'=>'CIV','flag'=>$f('CI'),'dial_code'=>'+225', 'timezone'=>'Africa/Abidjan', 'currency'=>'XOF'],
'KE' => ['name'=>'Kenya', 'iso2'=>'KE','iso3'=>'KEN','flag'=>$f('KE'),'dial_code'=>'+254', 'timezone'=>'Africa/Nairobi', 'currency'=>'KES'],
'LS' => ['name'=>'Lesotho', 'iso2'=>'LS','iso3'=>'LSO','flag'=>$f('LS'),'dial_code'=>'+266', 'timezone'=>'Africa/Maseru', 'currency'=>'LSL'],
'LR' => ['name'=>'Liberia', 'iso2'=>'LR','iso3'=>'LBR','flag'=>$f('LR'),'dial_code'=>'+231', 'timezone'=>'Africa/Monrovia', 'currency'=>'LRD'],
'LY' => ['name'=>'Libya', 'iso2'=>'LY','iso3'=>'LBY','flag'=>$f('LY'),'dial_code'=>'+218', 'timezone'=>'Africa/Tripoli', 'currency'=>'LYD'],
'MG' => ['name'=>'Madagascar', 'iso2'=>'MG','iso3'=>'MDG','flag'=>$f('MG'),'dial_code'=>'+261', 'timezone'=>'Indian/Antananarivo', 'currency'=>'MGA'],
'MW' => ['name'=>'Malawi', 'iso2'=>'MW','iso3'=>'MWI','flag'=>$f('MW'),'dial_code'=>'+265', 'timezone'=>'Africa/Blantyre', 'currency'=>'MWK'],
'ML' => ['name'=>'Mali', 'iso2'=>'ML','iso3'=>'MLI','flag'=>$f('ML'),'dial_code'=>'+223', 'timezone'=>'Africa/Bamako', 'currency'=>'XOF'],
'MR' => ['name'=>'Mauritania', 'iso2'=>'MR','iso3'=>'MRT','flag'=>$f('MR'),'dial_code'=>'+222', 'timezone'=>'Africa/Nouakchott', 'currency'=>'MRU'],
'MU' => ['name'=>'Mauritius', 'iso2'=>'MU','iso3'=>'MUS','flag'=>$f('MU'),'dial_code'=>'+230', 'timezone'=>'Indian/Mauritius', 'currency'=>'MUR'],
'MA' => ['name'=>'Morocco', 'iso2'=>'MA','iso3'=>'MAR','flag'=>$f('MA'),'dial_code'=>'+212', 'timezone'=>'Africa/Casablanca', 'currency'=>'MAD'],
'MZ' => ['name'=>'Mozambique', 'iso2'=>'MZ','iso3'=>'MOZ','flag'=>$f('MZ'),'dial_code'=>'+258', 'timezone'=>'Africa/Maputo', 'currency'=>'MZN'],
'NA' => ['name'=>'Namibia', 'iso2'=>'NA','iso3'=>'NAM','flag'=>$f('NA'),'dial_code'=>'+264', 'timezone'=>'Africa/Windhoek', 'currency'=>'NAD'],
'NE' => ['name'=>'Niger', 'iso2'=>'NE','iso3'=>'NER','flag'=>$f('NE'),'dial_code'=>'+227', 'timezone'=>'Africa/Niamey', 'currency'=>'XOF'],
'NG' => ['name'=>'Nigeria', 'iso2'=>'NG','iso3'=>'NGA','flag'=>$f('NG'),'dial_code'=>'+234', 'timezone'=>'Africa/Lagos', 'currency'=>'NGN'],
'RW' => ['name'=>'Rwanda', 'iso2'=>'RW','iso3'=>'RWA','flag'=>$f('RW'),'dial_code'=>'+250', 'timezone'=>'Africa/Kigali', 'currency'=>'RWF'],
'ST' => ['name'=>'São Tomé & Príncipe', 'iso2'=>'ST','iso3'=>'STP','flag'=>$f('ST'),'dial_code'=>'+239', 'timezone'=>'Africa/Sao_Tome', 'currency'=>'STN'],
'SN' => ['name'=>'Senegal', 'iso2'=>'SN','iso3'=>'SEN','flag'=>$f('SN'),'dial_code'=>'+221', 'timezone'=>'Africa/Dakar', 'currency'=>'XOF'],
'SC' => ['name'=>'Seychelles', 'iso2'=>'SC','iso3'=>'SYC','flag'=>$f('SC'),'dial_code'=>'+248', 'timezone'=>'Indian/Mahe', 'currency'=>'SCR'],
'SL' => ['name'=>'Sierra Leone', 'iso2'=>'SL','iso3'=>'SLE','flag'=>$f('SL'),'dial_code'=>'+232', 'timezone'=>'Africa/Freetown', 'currency'=>'SLL'],
'SO' => ['name'=>'Somalia', 'iso2'=>'SO','iso3'=>'SOM','flag'=>$f('SO'),'dial_code'=>'+252', 'timezone'=>'Africa/Mogadishu', 'currency'=>'SOS'],
'ZA' => ['name'=>'South Africa', 'iso2'=>'ZA','iso3'=>'ZAF','flag'=>$f('ZA'),'dial_code'=>'+27', 'timezone'=>'Africa/Johannesburg', 'currency'=>'ZAR'],
'SS' => ['name'=>'South Sudan', 'iso2'=>'SS','iso3'=>'SSD','flag'=>$f('SS'),'dial_code'=>'+211', 'timezone'=>'Africa/Juba', 'currency'=>'SSP'],
'SD' => ['name'=>'Sudan', 'iso2'=>'SD','iso3'=>'SDN','flag'=>$f('SD'),'dial_code'=>'+249', 'timezone'=>'Africa/Khartoum', 'currency'=>'SDG'],
'TZ' => ['name'=>'Tanzania', 'iso2'=>'TZ','iso3'=>'TZA','flag'=>$f('TZ'),'dial_code'=>'+255', 'timezone'=>'Africa/Dar_es_Salaam', 'currency'=>'TZS'],
'TG' => ['name'=>'Togo', 'iso2'=>'TG','iso3'=>'TGO','flag'=>$f('TG'),'dial_code'=>'+228', 'timezone'=>'Africa/Lome', 'currency'=>'XOF'],
'TN' => ['name'=>'Tunisia', 'iso2'=>'TN','iso3'=>'TUN','flag'=>$f('TN'),'dial_code'=>'+216', 'timezone'=>'Africa/Tunis', 'currency'=>'TND'],
'UG' => ['name'=>'Uganda', 'iso2'=>'UG','iso3'=>'UGA','flag'=>$f('UG'),'dial_code'=>'+256', 'timezone'=>'Africa/Kampala', 'currency'=>'UGX'],
'ZM' => ['name'=>'Zambia', 'iso2'=>'ZM','iso3'=>'ZMB','flag'=>$f('ZM'),'dial_code'=>'+260', 'timezone'=>'Africa/Lusaka', 'currency'=>'ZMW'],
'ZW' => ['name'=>'Zimbabwe', 'iso2'=>'ZW','iso3'=>'ZWE','flag'=>$f('ZW'),'dial_code'=>'+263', 'timezone'=>'Africa/Harare', 'currency'=>'ZWL'],
// ── AMERICAS ─────────────────────────────────────────────────────────
'AG' => ['name'=>'Antigua & Barbuda', 'iso2'=>'AG','iso3'=>'ATG','flag'=>$f('AG'),'dial_code'=>'+1', 'timezone'=>'America/Antigua', 'currency'=>'XCD'],
'AR' => ['name'=>'Argentina', 'iso2'=>'AR','iso3'=>'ARG','flag'=>$f('AR'),'dial_code'=>'+54', 'timezone'=>'America/Argentina/Buenos_Aires','currency'=>'ARS'],
'BS' => ['name'=>'Bahamas', 'iso2'=>'BS','iso3'=>'BHS','flag'=>$f('BS'),'dial_code'=>'+1', 'timezone'=>'America/Nassau', 'currency'=>'BSD'],
'BB' => ['name'=>'Barbados', 'iso2'=>'BB','iso3'=>'BRB','flag'=>$f('BB'),'dial_code'=>'+1', 'timezone'=>'America/Barbados', 'currency'=>'BBD'],
'BZ' => ['name'=>'Belize', 'iso2'=>'BZ','iso3'=>'BLZ','flag'=>$f('BZ'),'dial_code'=>'+501', 'timezone'=>'America/Belize', 'currency'=>'BZD'],
'BO' => ['name'=>'Bolivia', 'iso2'=>'BO','iso3'=>'BOL','flag'=>$f('BO'),'dial_code'=>'+591', 'timezone'=>'America/La_Paz', 'currency'=>'BOB'],
'BR' => ['name'=>'Brazil', 'iso2'=>'BR','iso3'=>'BRA','flag'=>$f('BR'),'dial_code'=>'+55', 'timezone'=>'America/Sao_Paulo', 'currency'=>'BRL'],
'CA' => ['name'=>'Canada', 'iso2'=>'CA','iso3'=>'CAN','flag'=>$f('CA'),'dial_code'=>'+1', 'timezone'=>'America/Toronto', 'currency'=>'CAD'],
'CL' => ['name'=>'Chile', 'iso2'=>'CL','iso3'=>'CHL','flag'=>$f('CL'),'dial_code'=>'+56', 'timezone'=>'America/Santiago', 'currency'=>'CLP'],
'CO' => ['name'=>'Colombia', 'iso2'=>'CO','iso3'=>'COL','flag'=>$f('CO'),'dial_code'=>'+57', 'timezone'=>'America/Bogota', 'currency'=>'COP'],
'CR' => ['name'=>'Costa Rica', 'iso2'=>'CR','iso3'=>'CRI','flag'=>$f('CR'),'dial_code'=>'+506', 'timezone'=>'America/Costa_Rica', 'currency'=>'CRC'],
'CU' => ['name'=>'Cuba', 'iso2'=>'CU','iso3'=>'CUB','flag'=>$f('CU'),'dial_code'=>'+53', 'timezone'=>'America/Havana', 'currency'=>'CUP'],
'DM' => ['name'=>'Dominica', 'iso2'=>'DM','iso3'=>'DMA','flag'=>$f('DM'),'dial_code'=>'+1', 'timezone'=>'America/Dominica', 'currency'=>'XCD'],
'DO' => ['name'=>'Dominican Republic', 'iso2'=>'DO','iso3'=>'DOM','flag'=>$f('DO'),'dial_code'=>'+1', 'timezone'=>'America/Santo_Domingo', 'currency'=>'DOP'],
'EC' => ['name'=>'Ecuador', 'iso2'=>'EC','iso3'=>'ECU','flag'=>$f('EC'),'dial_code'=>'+593', 'timezone'=>'America/Guayaquil', 'currency'=>'USD'],
'SV' => ['name'=>'El Salvador', 'iso2'=>'SV','iso3'=>'SLV','flag'=>$f('SV'),'dial_code'=>'+503', 'timezone'=>'America/El_Salvador', 'currency'=>'USD'],
'GD' => ['name'=>'Grenada', 'iso2'=>'GD','iso3'=>'GRD','flag'=>$f('GD'),'dial_code'=>'+1', 'timezone'=>'America/Grenada', 'currency'=>'XCD'],
'GT' => ['name'=>'Guatemala', 'iso2'=>'GT','iso3'=>'GTM','flag'=>$f('GT'),'dial_code'=>'+502', 'timezone'=>'America/Guatemala', 'currency'=>'GTQ'],
'GY' => ['name'=>'Guyana', 'iso2'=>'GY','iso3'=>'GUY','flag'=>$f('GY'),'dial_code'=>'+592', 'timezone'=>'America/Guyana', 'currency'=>'GYD'],
'HT' => ['name'=>'Haiti', 'iso2'=>'HT','iso3'=>'HTI','flag'=>$f('HT'),'dial_code'=>'+509', 'timezone'=>'America/Port-au-Prince', 'currency'=>'HTG'],
'HN' => ['name'=>'Honduras', 'iso2'=>'HN','iso3'=>'HND','flag'=>$f('HN'),'dial_code'=>'+504', 'timezone'=>'America/Tegucigalpa', 'currency'=>'HNL'],
'JM' => ['name'=>'Jamaica', 'iso2'=>'JM','iso3'=>'JAM','flag'=>$f('JM'),'dial_code'=>'+1', 'timezone'=>'America/Jamaica', 'currency'=>'JMD'],
'MX' => ['name'=>'Mexico', 'iso2'=>'MX','iso3'=>'MEX','flag'=>$f('MX'),'dial_code'=>'+52', 'timezone'=>'America/Mexico_City', 'currency'=>'MXN'],
'NI' => ['name'=>'Nicaragua', 'iso2'=>'NI','iso3'=>'NIC','flag'=>$f('NI'),'dial_code'=>'+505', 'timezone'=>'America/Managua', 'currency'=>'NIO'],
'PA' => ['name'=>'Panama', 'iso2'=>'PA','iso3'=>'PAN','flag'=>$f('PA'),'dial_code'=>'+507', 'timezone'=>'America/Panama', 'currency'=>'PAB'],
'PY' => ['name'=>'Paraguay', 'iso2'=>'PY','iso3'=>'PRY','flag'=>$f('PY'),'dial_code'=>'+595', 'timezone'=>'America/Asuncion', 'currency'=>'PYG'],
'PE' => ['name'=>'Peru', 'iso2'=>'PE','iso3'=>'PER','flag'=>$f('PE'),'dial_code'=>'+51', 'timezone'=>'America/Lima', 'currency'=>'PEN'],
'KN' => ['name'=>'Saint Kitts & Nevis', 'iso2'=>'KN','iso3'=>'KNA','flag'=>$f('KN'),'dial_code'=>'+1', 'timezone'=>'America/St_Kitts', 'currency'=>'XCD'],
'LC' => ['name'=>'Saint Lucia', 'iso2'=>'LC','iso3'=>'LCA','flag'=>$f('LC'),'dial_code'=>'+1', 'timezone'=>'America/St_Lucia', 'currency'=>'XCD'],
'VC' => ['name'=>'Saint Vincent & Grenadines','iso2'=>'VC','iso3'=>'VCT','flag'=>$f('VC'),'dial_code'=>'+1', 'timezone'=>'America/St_Vincent', 'currency'=>'XCD'],
'SR' => ['name'=>'Suriname', 'iso2'=>'SR','iso3'=>'SUR','flag'=>$f('SR'),'dial_code'=>'+597', 'timezone'=>'America/Paramaribo', 'currency'=>'SRD'],
'TT' => ['name'=>'Trinidad & Tobago', 'iso2'=>'TT','iso3'=>'TTO','flag'=>$f('TT'),'dial_code'=>'+1', 'timezone'=>'America/Port_of_Spain', 'currency'=>'TTD'],
'US' => ['name'=>'United States', 'iso2'=>'US','iso3'=>'USA','flag'=>$f('US'),'dial_code'=>'+1', 'timezone'=>'America/New_York', 'currency'=>'USD'],
'UY' => ['name'=>'Uruguay', 'iso2'=>'UY','iso3'=>'URY','flag'=>$f('UY'),'dial_code'=>'+598', 'timezone'=>'America/Montevideo', 'currency'=>'UYU'],
'VE' => ['name'=>'Venezuela', 'iso2'=>'VE','iso3'=>'VEN','flag'=>$f('VE'),'dial_code'=>'+58', 'timezone'=>'America/Caracas', 'currency'=>'VES'],
// ── ASIA & MIDDLE EAST ────────────────────────────────────────────────
'AF' => ['name'=>'Afghanistan', 'iso2'=>'AF','iso3'=>'AFG','flag'=>$f('AF'),'dial_code'=>'+93', 'timezone'=>'Asia/Kabul', 'currency'=>'AFN'],
'AM' => ['name'=>'Armenia', 'iso2'=>'AM','iso3'=>'ARM','flag'=>$f('AM'),'dial_code'=>'+374', 'timezone'=>'Asia/Yerevan', 'currency'=>'AMD'],
'AZ' => ['name'=>'Azerbaijan', 'iso2'=>'AZ','iso3'=>'AZE','flag'=>$f('AZ'),'dial_code'=>'+994', 'timezone'=>'Asia/Baku', 'currency'=>'AZN'],
'BH' => ['name'=>'Bahrain', 'iso2'=>'BH','iso3'=>'BHR','flag'=>$f('BH'),'dial_code'=>'+973', 'timezone'=>'Asia/Bahrain', 'currency'=>'BHD'],
'BD' => ['name'=>'Bangladesh', 'iso2'=>'BD','iso3'=>'BGD','flag'=>$f('BD'),'dial_code'=>'+880', 'timezone'=>'Asia/Dhaka', 'currency'=>'BDT'],
'BT' => ['name'=>'Bhutan', 'iso2'=>'BT','iso3'=>'BTN','flag'=>$f('BT'),'dial_code'=>'+975', 'timezone'=>'Asia/Thimphu', 'currency'=>'BTN'],
'BN' => ['name'=>'Brunei', 'iso2'=>'BN','iso3'=>'BRN','flag'=>$f('BN'),'dial_code'=>'+673', 'timezone'=>'Asia/Brunei', 'currency'=>'BND'],
'KH' => ['name'=>'Cambodia', 'iso2'=>'KH','iso3'=>'KHM','flag'=>$f('KH'),'dial_code'=>'+855', 'timezone'=>'Asia/Phnom_Penh', 'currency'=>'KHR'],
'CN' => ['name'=>'China', 'iso2'=>'CN','iso3'=>'CHN','flag'=>$f('CN'),'dial_code'=>'+86', 'timezone'=>'Asia/Shanghai', 'currency'=>'CNY'],
'CY' => ['name'=>'Cyprus', 'iso2'=>'CY','iso3'=>'CYP','flag'=>$f('CY'),'dial_code'=>'+357', 'timezone'=>'Asia/Nicosia', 'currency'=>'EUR'],
'GE' => ['name'=>'Georgia', 'iso2'=>'GE','iso3'=>'GEO','flag'=>$f('GE'),'dial_code'=>'+995', 'timezone'=>'Asia/Tbilisi', 'currency'=>'GEL'],
'HK' => ['name'=>'Hong Kong', 'iso2'=>'HK','iso3'=>'HKG','flag'=>$f('HK'),'dial_code'=>'+852', 'timezone'=>'Asia/Hong_Kong', 'currency'=>'HKD'],
'IN' => ['name'=>'India', 'iso2'=>'IN','iso3'=>'IND','flag'=>$f('IN'),'dial_code'=>'+91', 'timezone'=>'Asia/Kolkata', 'currency'=>'INR'],
'ID' => ['name'=>'Indonesia', 'iso2'=>'ID','iso3'=>'IDN','flag'=>$f('ID'),'dial_code'=>'+62', 'timezone'=>'Asia/Jakarta', 'currency'=>'IDR'],
'IR' => ['name'=>'Iran', 'iso2'=>'IR','iso3'=>'IRN','flag'=>$f('IR'),'dial_code'=>'+98', 'timezone'=>'Asia/Tehran', 'currency'=>'IRR'],
'IQ' => ['name'=>'Iraq', 'iso2'=>'IQ','iso3'=>'IRQ','flag'=>$f('IQ'),'dial_code'=>'+964', 'timezone'=>'Asia/Baghdad', 'currency'=>'IQD'],
'IL' => ['name'=>'Israel', 'iso2'=>'IL','iso3'=>'ISR','flag'=>$f('IL'),'dial_code'=>'+972', 'timezone'=>'Asia/Jerusalem', 'currency'=>'ILS'],
'JP' => ['name'=>'Japan', 'iso2'=>'JP','iso3'=>'JPN','flag'=>$f('JP'),'dial_code'=>'+81', 'timezone'=>'Asia/Tokyo', 'currency'=>'JPY'],
'JO' => ['name'=>'Jordan', 'iso2'=>'JO','iso3'=>'JOR','flag'=>$f('JO'),'dial_code'=>'+962', 'timezone'=>'Asia/Amman', 'currency'=>'JOD'],
'KZ' => ['name'=>'Kazakhstan', 'iso2'=>'KZ','iso3'=>'KAZ','flag'=>$f('KZ'),'dial_code'=>'+7', 'timezone'=>'Asia/Almaty', 'currency'=>'KZT'],
'KW' => ['name'=>'Kuwait', 'iso2'=>'KW','iso3'=>'KWT','flag'=>$f('KW'),'dial_code'=>'+965', 'timezone'=>'Asia/Kuwait', 'currency'=>'KWD'],
'KG' => ['name'=>'Kyrgyzstan', 'iso2'=>'KG','iso3'=>'KGZ','flag'=>$f('KG'),'dial_code'=>'+996', 'timezone'=>'Asia/Bishkek', 'currency'=>'KGS'],
'LA' => ['name'=>'Laos', 'iso2'=>'LA','iso3'=>'LAO','flag'=>$f('LA'),'dial_code'=>'+856', 'timezone'=>'Asia/Vientiane', 'currency'=>'LAK'],
'LB' => ['name'=>'Lebanon', 'iso2'=>'LB','iso3'=>'LBN','flag'=>$f('LB'),'dial_code'=>'+961', 'timezone'=>'Asia/Beirut', 'currency'=>'LBP'],
'MO' => ['name'=>'Macau', 'iso2'=>'MO','iso3'=>'MAC','flag'=>$f('MO'),'dial_code'=>'+853', 'timezone'=>'Asia/Macau', 'currency'=>'MOP'],
'MY' => ['name'=>'Malaysia', 'iso2'=>'MY','iso3'=>'MYS','flag'=>$f('MY'),'dial_code'=>'+60', 'timezone'=>'Asia/Kuala_Lumpur', 'currency'=>'MYR'],
'MV' => ['name'=>'Maldives', 'iso2'=>'MV','iso3'=>'MDV','flag'=>$f('MV'),'dial_code'=>'+960', 'timezone'=>'Indian/Maldives', 'currency'=>'MVR'],
'MN' => ['name'=>'Mongolia', 'iso2'=>'MN','iso3'=>'MNG','flag'=>$f('MN'),'dial_code'=>'+976', 'timezone'=>'Asia/Ulaanbaatar', 'currency'=>'MNT'],
'MM' => ['name'=>'Myanmar', 'iso2'=>'MM','iso3'=>'MMR','flag'=>$f('MM'),'dial_code'=>'+95', 'timezone'=>'Asia/Yangon', 'currency'=>'MMK'],
'NP' => ['name'=>'Nepal', 'iso2'=>'NP','iso3'=>'NPL','flag'=>$f('NP'),'dial_code'=>'+977', 'timezone'=>'Asia/Kathmandu', 'currency'=>'NPR'],
'KP' => ['name'=>'North Korea', 'iso2'=>'KP','iso3'=>'PRK','flag'=>$f('KP'),'dial_code'=>'+850', 'timezone'=>'Asia/Pyongyang', 'currency'=>'KPW'],
'OM' => ['name'=>'Oman', 'iso2'=>'OM','iso3'=>'OMN','flag'=>$f('OM'),'dial_code'=>'+968', 'timezone'=>'Asia/Muscat', 'currency'=>'OMR'],
'PK' => ['name'=>'Pakistan', 'iso2'=>'PK','iso3'=>'PAK','flag'=>$f('PK'),'dial_code'=>'+92', 'timezone'=>'Asia/Karachi', 'currency'=>'PKR'],
'PS' => ['name'=>'Palestine', 'iso2'=>'PS','iso3'=>'PSE','flag'=>$f('PS'),'dial_code'=>'+970', 'timezone'=>'Asia/Hebron', 'currency'=>'ILS'],
'PH' => ['name'=>'Philippines', 'iso2'=>'PH','iso3'=>'PHL','flag'=>$f('PH'),'dial_code'=>'+63', 'timezone'=>'Asia/Manila', 'currency'=>'PHP'],
'QA' => ['name'=>'Qatar', 'iso2'=>'QA','iso3'=>'QAT','flag'=>$f('QA'),'dial_code'=>'+974', 'timezone'=>'Asia/Qatar', 'currency'=>'QAR'],
'SA' => ['name'=>'Saudi Arabia', 'iso2'=>'SA','iso3'=>'SAU','flag'=>$f('SA'),'dial_code'=>'+966', 'timezone'=>'Asia/Riyadh', 'currency'=>'SAR'],
'SG' => ['name'=>'Singapore', 'iso2'=>'SG','iso3'=>'SGP','flag'=>$f('SG'),'dial_code'=>'+65', 'timezone'=>'Asia/Singapore', 'currency'=>'SGD'],
'KR' => ['name'=>'South Korea', 'iso2'=>'KR','iso3'=>'KOR','flag'=>$f('KR'),'dial_code'=>'+82', 'timezone'=>'Asia/Seoul', 'currency'=>'KRW'],
'LK' => ['name'=>'Sri Lanka', 'iso2'=>'LK','iso3'=>'LKA','flag'=>$f('LK'),'dial_code'=>'+94', 'timezone'=>'Asia/Colombo', 'currency'=>'LKR'],
'SY' => ['name'=>'Syria', 'iso2'=>'SY','iso3'=>'SYR','flag'=>$f('SY'),'dial_code'=>'+963', 'timezone'=>'Asia/Damascus', 'currency'=>'SYP'],
'TW' => ['name'=>'Taiwan', 'iso2'=>'TW','iso3'=>'TWN','flag'=>$f('TW'),'dial_code'=>'+886', 'timezone'=>'Asia/Taipei', 'currency'=>'TWD'],
'TJ' => ['name'=>'Tajikistan', 'iso2'=>'TJ','iso3'=>'TJK','flag'=>$f('TJ'),'dial_code'=>'+992', 'timezone'=>'Asia/Dushanbe', 'currency'=>'TJS'],
'TH' => ['name'=>'Thailand', 'iso2'=>'TH','iso3'=>'THA','flag'=>$f('TH'),'dial_code'=>'+66', 'timezone'=>'Asia/Bangkok', 'currency'=>'THB'],
'TL' => ['name'=>'Timor-Leste', 'iso2'=>'TL','iso3'=>'TLS','flag'=>$f('TL'),'dial_code'=>'+670', 'timezone'=>'Asia/Dili', 'currency'=>'USD'],
'TR' => ['name'=>'Turkey', 'iso2'=>'TR','iso3'=>'TUR','flag'=>$f('TR'),'dial_code'=>'+90', 'timezone'=>'Europe/Istanbul', 'currency'=>'TRY'],
'TM' => ['name'=>'Turkmenistan', 'iso2'=>'TM','iso3'=>'TKM','flag'=>$f('TM'),'dial_code'=>'+993', 'timezone'=>'Asia/Ashgabat', 'currency'=>'TMT'],
'AE' => ['name'=>'United Arab Emirates', 'iso2'=>'AE','iso3'=>'ARE','flag'=>$f('AE'),'dial_code'=>'+971', 'timezone'=>'Asia/Dubai', 'currency'=>'AED'],
'UZ' => ['name'=>'Uzbekistan', 'iso2'=>'UZ','iso3'=>'UZB','flag'=>$f('UZ'),'dial_code'=>'+998', 'timezone'=>'Asia/Tashkent', 'currency'=>'UZS'],
'VN' => ['name'=>'Vietnam', 'iso2'=>'VN','iso3'=>'VNM','flag'=>$f('VN'),'dial_code'=>'+84', 'timezone'=>'Asia/Ho_Chi_Minh', 'currency'=>'VND'],
'YE' => ['name'=>'Yemen', 'iso2'=>'YE','iso3'=>'YEM','flag'=>$f('YE'),'dial_code'=>'+967', 'timezone'=>'Asia/Aden', 'currency'=>'YER'],
// ── EUROPE ────────────────────────────────────────────────────────────
'AL' => ['name'=>'Albania', 'iso2'=>'AL','iso3'=>'ALB','flag'=>$f('AL'),'dial_code'=>'+355', 'timezone'=>'Europe/Tirane', 'currency'=>'ALL'],
'AD' => ['name'=>'Andorra', 'iso2'=>'AD','iso3'=>'AND','flag'=>$f('AD'),'dial_code'=>'+376', 'timezone'=>'Europe/Andorra', 'currency'=>'EUR'],
'AT' => ['name'=>'Austria', 'iso2'=>'AT','iso3'=>'AUT','flag'=>$f('AT'),'dial_code'=>'+43', 'timezone'=>'Europe/Vienna', 'currency'=>'EUR'],
'BY' => ['name'=>'Belarus', 'iso2'=>'BY','iso3'=>'BLR','flag'=>$f('BY'),'dial_code'=>'+375', 'timezone'=>'Europe/Minsk', 'currency'=>'BYN'],
'BE' => ['name'=>'Belgium', 'iso2'=>'BE','iso3'=>'BEL','flag'=>$f('BE'),'dial_code'=>'+32', 'timezone'=>'Europe/Brussels', 'currency'=>'EUR'],
'BA' => ['name'=>'Bosnia & Herzegovina', 'iso2'=>'BA','iso3'=>'BIH','flag'=>$f('BA'),'dial_code'=>'+387', 'timezone'=>'Europe/Sarajevo', 'currency'=>'BAM'],
'BG' => ['name'=>'Bulgaria', 'iso2'=>'BG','iso3'=>'BGR','flag'=>$f('BG'),'dial_code'=>'+359', 'timezone'=>'Europe/Sofia', 'currency'=>'BGN'],
'HR' => ['name'=>'Croatia', 'iso2'=>'HR','iso3'=>'HRV','flag'=>$f('HR'),'dial_code'=>'+385', 'timezone'=>'Europe/Zagreb', 'currency'=>'EUR'],
'CZ' => ['name'=>'Czech Republic', 'iso2'=>'CZ','iso3'=>'CZE','flag'=>$f('CZ'),'dial_code'=>'+420', 'timezone'=>'Europe/Prague', 'currency'=>'CZK'],
'DK' => ['name'=>'Denmark', 'iso2'=>'DK','iso3'=>'DNK','flag'=>$f('DK'),'dial_code'=>'+45', 'timezone'=>'Europe/Copenhagen', 'currency'=>'DKK'],
'EE' => ['name'=>'Estonia', 'iso2'=>'EE','iso3'=>'EST','flag'=>$f('EE'),'dial_code'=>'+372', 'timezone'=>'Europe/Tallinn', 'currency'=>'EUR'],
'FI' => ['name'=>'Finland', 'iso2'=>'FI','iso3'=>'FIN','flag'=>$f('FI'),'dial_code'=>'+358', 'timezone'=>'Europe/Helsinki', 'currency'=>'EUR'],
'FR' => ['name'=>'France', 'iso2'=>'FR','iso3'=>'FRA','flag'=>$f('FR'),'dial_code'=>'+33', 'timezone'=>'Europe/Paris', 'currency'=>'EUR'],
'DE' => ['name'=>'Germany', 'iso2'=>'DE','iso3'=>'DEU','flag'=>$f('DE'),'dial_code'=>'+49', 'timezone'=>'Europe/Berlin', 'currency'=>'EUR'],
'GR' => ['name'=>'Greece', 'iso2'=>'GR','iso3'=>'GRC','flag'=>$f('GR'),'dial_code'=>'+30', 'timezone'=>'Europe/Athens', 'currency'=>'EUR'],
'HU' => ['name'=>'Hungary', 'iso2'=>'HU','iso3'=>'HUN','flag'=>$f('HU'),'dial_code'=>'+36', 'timezone'=>'Europe/Budapest', 'currency'=>'HUF'],
'IS' => ['name'=>'Iceland', 'iso2'=>'IS','iso3'=>'ISL','flag'=>$f('IS'),'dial_code'=>'+354', 'timezone'=>'Atlantic/Reykjavik', 'currency'=>'ISK'],
'IE' => ['name'=>'Ireland', 'iso2'=>'IE','iso3'=>'IRL','flag'=>$f('IE'),'dial_code'=>'+353', 'timezone'=>'Europe/Dublin', 'currency'=>'EUR'],
'IT' => ['name'=>'Italy', 'iso2'=>'IT','iso3'=>'ITA','flag'=>$f('IT'),'dial_code'=>'+39', 'timezone'=>'Europe/Rome', 'currency'=>'EUR'],
'XK' => ['name'=>'Kosovo', 'iso2'=>'XK','iso3'=>'XKX','flag'=>$f('XK'),'dial_code'=>'+383', 'timezone'=>'Europe/Belgrade', 'currency'=>'EUR'],
'LV' => ['name'=>'Latvia', 'iso2'=>'LV','iso3'=>'LVA','flag'=>$f('LV'),'dial_code'=>'+371', 'timezone'=>'Europe/Riga', 'currency'=>'EUR'],
'LI' => ['name'=>'Liechtenstein', 'iso2'=>'LI','iso3'=>'LIE','flag'=>$f('LI'),'dial_code'=>'+423', 'timezone'=>'Europe/Vaduz', 'currency'=>'CHF'],
'LT' => ['name'=>'Lithuania', 'iso2'=>'LT','iso3'=>'LTU','flag'=>$f('LT'),'dial_code'=>'+370', 'timezone'=>'Europe/Vilnius', 'currency'=>'EUR'],
'LU' => ['name'=>'Luxembourg', 'iso2'=>'LU','iso3'=>'LUX','flag'=>$f('LU'),'dial_code'=>'+352', 'timezone'=>'Europe/Luxembourg', 'currency'=>'EUR'],
'MT' => ['name'=>'Malta', 'iso2'=>'MT','iso3'=>'MLT','flag'=>$f('MT'),'dial_code'=>'+356', 'timezone'=>'Europe/Malta', 'currency'=>'EUR'],
'MD' => ['name'=>'Moldova', 'iso2'=>'MD','iso3'=>'MDA','flag'=>$f('MD'),'dial_code'=>'+373', 'timezone'=>'Europe/Chisinau', 'currency'=>'MDL'],
'MC' => ['name'=>'Monaco', 'iso2'=>'MC','iso3'=>'MCO','flag'=>$f('MC'),'dial_code'=>'+377', 'timezone'=>'Europe/Monaco', 'currency'=>'EUR'],
'ME' => ['name'=>'Montenegro', 'iso2'=>'ME','iso3'=>'MNE','flag'=>$f('ME'),'dial_code'=>'+382', 'timezone'=>'Europe/Podgorica', 'currency'=>'EUR'],
'NL' => ['name'=>'Netherlands', 'iso2'=>'NL','iso3'=>'NLD','flag'=>$f('NL'),'dial_code'=>'+31', 'timezone'=>'Europe/Amsterdam', 'currency'=>'EUR'],
'MK' => ['name'=>'North Macedonia', 'iso2'=>'MK','iso3'=>'MKD','flag'=>$f('MK'),'dial_code'=>'+389', 'timezone'=>'Europe/Skopje', 'currency'=>'MKD'],
'NO' => ['name'=>'Norway', 'iso2'=>'NO','iso3'=>'NOR','flag'=>$f('NO'),'dial_code'=>'+47', 'timezone'=>'Europe/Oslo', 'currency'=>'NOK'],
'PL' => ['name'=>'Poland', 'iso2'=>'PL','iso3'=>'POL','flag'=>$f('PL'),'dial_code'=>'+48', 'timezone'=>'Europe/Warsaw', 'currency'=>'PLN'],
'PT' => ['name'=>'Portugal', 'iso2'=>'PT','iso3'=>'PRT','flag'=>$f('PT'),'dial_code'=>'+351', 'timezone'=>'Europe/Lisbon', 'currency'=>'EUR'],
'RO' => ['name'=>'Romania', 'iso2'=>'RO','iso3'=>'ROU','flag'=>$f('RO'),'dial_code'=>'+40', 'timezone'=>'Europe/Bucharest', 'currency'=>'RON'],
'RU' => ['name'=>'Russia', 'iso2'=>'RU','iso3'=>'RUS','flag'=>$f('RU'),'dial_code'=>'+7', 'timezone'=>'Europe/Moscow', 'currency'=>'RUB'],
'SM' => ['name'=>'San Marino', 'iso2'=>'SM','iso3'=>'SMR','flag'=>$f('SM'),'dial_code'=>'+378', 'timezone'=>'Europe/San_Marino', 'currency'=>'EUR'],
'RS' => ['name'=>'Serbia', 'iso2'=>'RS','iso3'=>'SRB','flag'=>$f('RS'),'dial_code'=>'+381', 'timezone'=>'Europe/Belgrade', 'currency'=>'RSD'],
'SK' => ['name'=>'Slovakia', 'iso2'=>'SK','iso3'=>'SVK','flag'=>$f('SK'),'dial_code'=>'+421', 'timezone'=>'Europe/Bratislava', 'currency'=>'EUR'],
'SI' => ['name'=>'Slovenia', 'iso2'=>'SI','iso3'=>'SVN','flag'=>$f('SI'),'dial_code'=>'+386', 'timezone'=>'Europe/Ljubljana', 'currency'=>'EUR'],
'ES' => ['name'=>'Spain', 'iso2'=>'ES','iso3'=>'ESP','flag'=>$f('ES'),'dial_code'=>'+34', 'timezone'=>'Europe/Madrid', 'currency'=>'EUR'],
'SE' => ['name'=>'Sweden', 'iso2'=>'SE','iso3'=>'SWE','flag'=>$f('SE'),'dial_code'=>'+46', 'timezone'=>'Europe/Stockholm', 'currency'=>'SEK'],
'CH' => ['name'=>'Switzerland', 'iso2'=>'CH','iso3'=>'CHE','flag'=>$f('CH'),'dial_code'=>'+41', 'timezone'=>'Europe/Zurich', 'currency'=>'CHF'],
'UA' => ['name'=>'Ukraine', 'iso2'=>'UA','iso3'=>'UKR','flag'=>$f('UA'),'dial_code'=>'+380', 'timezone'=>'Europe/Kyiv', 'currency'=>'UAH'],
'GB' => ['name'=>'United Kingdom', 'iso2'=>'GB','iso3'=>'GBR','flag'=>$f('GB'),'dial_code'=>'+44', 'timezone'=>'Europe/London', 'currency'=>'GBP'],
'VA' => ['name'=>'Vatican City', 'iso2'=>'VA','iso3'=>'VAT','flag'=>$f('VA'),'dial_code'=>'+39', 'timezone'=>'Europe/Vatican', 'currency'=>'EUR'],
// ── OCEANIA ───────────────────────────────────────────────────────────
'AU' => ['name'=>'Australia', 'iso2'=>'AU','iso3'=>'AUS','flag'=>$f('AU'),'dial_code'=>'+61', 'timezone'=>'Australia/Sydney', 'currency'=>'AUD'],
'FJ' => ['name'=>'Fiji', 'iso2'=>'FJ','iso3'=>'FJI','flag'=>$f('FJ'),'dial_code'=>'+679', 'timezone'=>'Pacific/Fiji', 'currency'=>'FJD'],
'KI' => ['name'=>'Kiribati', 'iso2'=>'KI','iso3'=>'KIR','flag'=>$f('KI'),'dial_code'=>'+686', 'timezone'=>'Pacific/Tarawa', 'currency'=>'AUD'],
'MH' => ['name'=>'Marshall Islands', 'iso2'=>'MH','iso3'=>'MHL','flag'=>$f('MH'),'dial_code'=>'+692', 'timezone'=>'Pacific/Majuro', 'currency'=>'USD'],
'FM' => ['name'=>'Micronesia', 'iso2'=>'FM','iso3'=>'FSM','flag'=>$f('FM'),'dial_code'=>'+691', 'timezone'=>'Pacific/Pohnpei', 'currency'=>'USD'],
'NR' => ['name'=>'Nauru', 'iso2'=>'NR','iso3'=>'NRU','flag'=>$f('NR'),'dial_code'=>'+674', 'timezone'=>'Pacific/Nauru', 'currency'=>'AUD'],
'NZ' => ['name'=>'New Zealand', 'iso2'=>'NZ','iso3'=>'NZL','flag'=>$f('NZ'),'dial_code'=>'+64', 'timezone'=>'Pacific/Auckland', 'currency'=>'NZD'],
'PW' => ['name'=>'Palau', 'iso2'=>'PW','iso3'=>'PLW','flag'=>$f('PW'),'dial_code'=>'+680', 'timezone'=>'Pacific/Palau', 'currency'=>'USD'],
'PG' => ['name'=>'Papua New Guinea', 'iso2'=>'PG','iso3'=>'PNG','flag'=>$f('PG'),'dial_code'=>'+675', 'timezone'=>'Pacific/Port_Moresby', 'currency'=>'PGK'],
'WS' => ['name'=>'Samoa', 'iso2'=>'WS','iso3'=>'WSM','flag'=>$f('WS'),'dial_code'=>'+685', 'timezone'=>'Pacific/Apia', 'currency'=>'WST'],
'SB' => ['name'=>'Solomon Islands', 'iso2'=>'SB','iso3'=>'SLB','flag'=>$f('SB'),'dial_code'=>'+677', 'timezone'=>'Pacific/Guadalcanal', 'currency'=>'SBD'],
'TO' => ['name'=>'Tonga', 'iso2'=>'TO','iso3'=>'TON','flag'=>$f('TO'),'dial_code'=>'+676', 'timezone'=>'Pacific/Tongatapu', 'currency'=>'TOP'],
'TV' => ['name'=>'Tuvalu', 'iso2'=>'TV','iso3'=>'TUV','flag'=>$f('TV'),'dial_code'=>'+688', 'timezone'=>'Pacific/Funafuti', 'currency'=>'AUD'],
'VU' => ['name'=>'Vanuatu', 'iso2'=>'VU','iso3'=>'VUT','flag'=>$f('VU'),'dial_code'=>'+678', 'timezone'=>'Pacific/Efate', 'currency'=>'VUV'],
];
}
/**
* Options list for the phone-code component.
* Returns [value, flag, label, secondary, search] per entry, sorted by country name.
*/
public static function forPhoneCode(): array
{
$list = [];
foreach (self::all() as $country) {
$list[] = [
'value' => $country['dial_code'] . '|' . $country['iso2'],
'flag' => $country['flag'],
'label' => $country['dial_code'],
'secondary' => $country['name'],
'search' => strtolower($country['name'] . ' ' . $country['dial_code'] . ' ' . $country['iso2']),
];
}
usort($list, fn($a, $b) => strcmp($a['secondary'], $b['secondary']));
return $list;
}
/**
* Options list for the country/nationality component.
* value = iso2 code.
*/
public static function forCountry(): array
{
$list = [];
foreach (self::all() as $country) {
$list[] = [
'value' => $country['iso2'],
'flag' => $country['flag'],
'label' => $country['name'],
'search' => strtolower($country['name'] . ' ' . $country['iso2'] . ' ' . $country['iso3']),
];
}
usort($list, fn($a, $b) => strcmp($a['label'], $b['label']));
return $list;
}
/**
* Options list for the timezone component.
* Reads PHP's built-in timezone database and attaches country flags.
*/
public static function forTimezone(): array
{
$countries = self::all();
$list = [];
// Skip Etc/ offsets and legacy backward-compat entries; keep named timezones
$skip = ['Etc', 'SystemV', 'US', 'Canada', 'Brazil', 'Chile', 'Mexico', 'Cuba', 'Egypt', 'Eire',
'Factory', 'GB', 'GB-Eire', 'GMT', 'Hongkong', 'Iceland', 'Iran', 'Israel',
'Jamaica', 'Japan', 'Kwajalein', 'Libya', 'MET', 'MST', 'NZ', 'NZ-CHAT',
'Navajo', 'Poland', 'Portugal', 'ROC', 'ROK', 'Singapore', 'Turkey', 'UCT',
'UTC', 'Universal', 'W-SU', 'WET', 'Zulu'];
foreach (\DateTimeZone::listIdentifiers() as $tz) {
$region = explode('/', $tz)[0];
if (in_array($region, $skip, true)) {
continue;
}
if (!str_contains($tz, '/')) {
continue;
}
try {
$dtze = new \DateTimeZone($tz);
$dt = new \DateTime('now', $dtze);
$offset = $dt->getOffset();
$h = (int) floor(abs($offset) / 3600);
$m = (int) (abs($offset) % 3600 / 60);
$sign = $offset >= 0 ? '+' : '-';
$utc = 'UTC' . $sign . str_pad($h, 2, '0', STR_PAD_LEFT) . ':' . str_pad($m, 2, '0', STR_PAD_LEFT);
$location = $dtze->getLocation();
$countryCode = strtoupper($location['country_code'] ?? '');
$countryData = $countries[$countryCode] ?? null;
$flag = $countryData ? $countryData['flag'] : '🌐';
$countryName = $countryData ? $countryData['name'] : '';
$parts = explode('/', $tz);
$city = str_replace('_', ' ', end($parts));
$list[] = [
'value' => $tz,
'flag' => $flag,
'label' => str_replace('_', ' ', $tz),
'utc' => $utc,
'offset' => $offset,
'search' => strtolower($tz . ' ' . $city . ' ' . $utc . ' ' . $countryName),
];
} catch (\Exception) {
continue;
}
}
usort($list, fn($a, $b) => $a['offset'] <=> $b['offset'] ?: strcmp($a['value'], $b['value']));
return $list;
}
/**
* Returns a map of IANA timezone identifier ISO2 country code.
* Used by the country-select component to auto-detect the user's country from their device timezone.
*/
public static function forGeoTimezoneMap(): array
{
$countries = self::all();
$skip = ['Etc', 'SystemV', 'US', 'Canada', 'Brazil', 'Chile', 'Mexico', 'Cuba', 'Egypt', 'Eire',
'Factory', 'GB', 'GB-Eire', 'GMT', 'Hongkong', 'Iceland', 'Iran', 'Israel',
'Jamaica', 'Japan', 'Kwajalein', 'Libya', 'MET', 'MST', 'NZ', 'NZ-CHAT',
'Navajo', 'Poland', 'Portugal', 'ROC', 'ROK', 'Singapore', 'Turkey', 'UCT',
'UTC', 'Universal', 'W-SU', 'WET', 'Zulu'];
$map = [];
foreach (\DateTimeZone::listIdentifiers() as $tz) {
$region = explode('/', $tz)[0];
if (in_array($region, $skip, true) || !str_contains($tz, '/')) {
continue;
}
try {
$dtze = new \DateTimeZone($tz);
$loc = $dtze->getLocation();
$iso2 = strtoupper($loc['country_code'] ?? '');
if ($iso2 && isset($countries[$iso2])) {
$map[$tz] = $iso2;
}
} catch (\Exception) {}
}
return $map;
}
}

View File

@ -3,28 +3,32 @@
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* The list of the inputs that are never flashed to the session on validation exceptions.
*
* @var array<int, string>
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
/**
* Register the exception handling callbacks for the application.
*/
public function register(): void
{
$this->reportable(function (Throwable $e) {
//
});
// Redirect to home with a toast when a video URL can't be resolved
$this->renderable(function (NotFoundHttpException $e, $request) {
$prev = $e->getPrevious();
$isVideoRoute = str_starts_with($request->path(), 'videos/');
if ($isVideoRoute && ($prev instanceof ModelNotFoundException || $e->getMessage() === '')) {
return redirect('/')->with('toast_error', 'This video is no longer available.');
}
});
}
}

101
app/Helpers/Horoscope.php Normal file
View File

@ -0,0 +1,101 @@
<?php
namespace App\Helpers;
class Horoscope
{
private static array $signs = [
['name' => 'Capricorn', 'symbol' => '♑', 'element' => 'Earth', 'emoji' => '🐐', 'from' => [12, 22], 'to' => [1, 19],
'traits' => ['Ambitious', 'Disciplined', 'Patient', 'Practical']],
['name' => 'Aquarius', 'symbol' => '♒', 'element' => 'Air', 'emoji' => '🏺', 'from' => [1, 20], 'to' => [2, 18],
'traits' => ['Independent', 'Progressive', 'Humanitarian', 'Inventive']],
['name' => 'Pisces', 'symbol' => '♓', 'element' => 'Water', 'emoji' => '🐟', 'from' => [2, 19], 'to' => [3, 20],
'traits' => ['Empathetic', 'Creative', 'Intuitive', 'Gentle']],
['name' => 'Aries', 'symbol' => '♈', 'element' => 'Fire', 'emoji' => '🐏', 'from' => [3, 21], 'to' => [4, 19],
'traits' => ['Courageous', 'Energetic', 'Enthusiastic', 'Bold']],
['name' => 'Taurus', 'symbol' => '♉', 'element' => 'Earth', 'emoji' => '🐂', 'from' => [4, 20], 'to' => [5, 20],
'traits' => ['Reliable', 'Patient', 'Devoted', 'Sensual']],
['name' => 'Gemini', 'symbol' => '♊', 'element' => 'Air', 'emoji' => '👥', 'from' => [5, 21], 'to' => [6, 20],
'traits' => ['Adaptable', 'Curious', 'Witty', 'Expressive']],
['name' => 'Cancer', 'symbol' => '♋', 'element' => 'Water', 'emoji' => '🦀', 'from' => [6, 21], 'to' => [7, 22],
'traits' => ['Caring', 'Protective', 'Intuitive', 'Emotional']],
['name' => 'Leo', 'symbol' => '♌', 'element' => 'Fire', 'emoji' => '🦁', 'from' => [7, 23], 'to' => [8, 22],
'traits' => ['Confident', 'Dramatic', 'Creative', 'Generous']],
['name' => 'Virgo', 'symbol' => '♍', 'element' => 'Earth', 'emoji' => '🌾', 'from' => [8, 23], 'to' => [9, 22],
'traits' => ['Analytical', 'Loyal', 'Practical', 'Diligent']],
['name' => 'Libra', 'symbol' => '♎', 'element' => 'Air', 'emoji' => '⚖️', 'from' => [9, 23], 'to' => [10, 22],
'traits' => ['Diplomatic', 'Gracious', 'Fair-minded', 'Social']],
['name' => 'Scorpio', 'symbol' => '♏', 'element' => 'Water', 'emoji' => '🦂', 'from' => [10, 23], 'to' => [11, 21],
'traits' => ['Passionate', 'Stubborn', 'Resourceful', 'Brave']],
['name' => 'Sagittarius','symbol' => '♐', 'element' => 'Fire', 'emoji' => '🏹', 'from' => [11, 22], 'to' => [12, 21],
'traits' => ['Optimistic', 'Adventurous', 'Honest', 'Philosophical']],
];
private static array $elementCompatibility = [
'Fire' => ['Fire' => 85, 'Air' => 80, 'Earth' => 45, 'Water' => 40],
'Earth' => ['Earth' => 88, 'Water' => 82, 'Fire' => 45, 'Air' => 50],
'Air' => ['Air' => 84, 'Fire' => 80, 'Water' => 48, 'Earth' => 50],
'Water' => ['Water' => 86, 'Earth' => 82, 'Fire' => 40, 'Air' => 48],
];
public static function getSign(?string $birthday): ?array
{
if (! $birthday) return null;
try {
$date = new \DateTime($birthday);
} catch (\Exception) {
return null;
}
$month = (int) $date->format('n');
$day = (int) $date->format('j');
foreach (self::$signs as $sign) {
[$fm, $fd] = $sign['from'];
[$tm, $td] = $sign['to'];
if ($fm > $tm) {
// Capricorn wraps year
if (($month === $fm && $day >= $fd) || ($month === $tm && $day <= $td)) {
return $sign;
}
} else {
if (($month === $fm && $day >= $fd) || ($month > $fm && $month < $tm) || ($month === $tm && $day <= $td)) {
return $sign;
}
}
}
return null;
}
public static function compatibility(?array $sign1, ?array $sign2): ?int
{
if (! $sign1 || ! $sign2) return null;
$base = self::$elementCompatibility[$sign1['element']][$sign2['element']] ?? 50;
// Same sign bonus
if ($sign1['name'] === $sign2['name']) {
$base = min(98, $base + 8);
}
// Deterministic jitter so different sign pairs within same element feel unique
$seed = crc32($sign1['name'] . ':' . $sign2['name']);
$jitter = (abs($seed) % 11) - 5;
return max(20, min(99, $base + $jitter));
}
public static function elementColor(string $element): string
{
return match ($element) {
'Fire' => '#ff6b35',
'Earth' => '#6abf69',
'Air' => '#64b5f6',
'Water' => '#4fc3f7',
default => '#aaa',
};
}
}

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@ -23,10 +24,32 @@ class AuthenticatedSessionController extends Controller
$remember = $request->filled('remember');
if (Auth::attempt($credentials, $remember)) {
$user = Auth::user();
if ($user->two_factor_enabled && $user->two_factor_secret) {
Auth::logout();
$request->session()->put('2fa_user_id', $user->id);
$request->session()->put('2fa_remember', $remember);
return redirect()->route('2fa.challenge');
}
$request->session()->regenerate();
AuditLog::record('user.login', [
'user_id' => $user->id,
'user_name' => $user->name,
'details' => ['email' => $user->email],
]);
return redirect()->intended('/videos');
}
AuditLog::record('user.login.failed', [
'user_id' => null,
'user_name' => null,
'details' => ['email' => $credentials['email']],
]);
return back()->withErrors([
'email' => 'The provided credentials do not match our records.',
]);
@ -34,6 +57,13 @@ class AuthenticatedSessionController extends Controller
public function destroy(Request $request)
{
$user = Auth::user();
if ($user) {
AuditLog::record('user.logout', [
'user_id' => $user->id,
'user_name' => $user->name,
]);
}
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();

View File

@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Rules\NotDisposableEmail;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
@ -18,22 +19,34 @@ class RegisteredUserController extends Controller
public function store(Request $request)
{
// Honeypot — bots fill hidden fields; real users never do
if ($request->filled('_hp')) {
// Silently appear to succeed to confuse the bot
return redirect()->route('login');
}
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new NotDisposableEmail],
'password' => ['required', 'confirmed', Password::defaults()],
'birthday' => ['required', 'date', 'before:today'],
'gender' => ['required', 'in:male,female'],
'nationality' => ['required', 'string', 'size:2'],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'birthday' => $request->birthday,
'gender' => $request->gender,
'nationality' => $request->nationality,
]);
event(new Registered($user));
auth()->login($user);
return redirect('/videos');
return redirect()->route('verification.notice');
}
}

View File

@ -3,7 +3,11 @@
namespace App\Http\Controllers;
use App\Models\Comment;
use App\Models\CommentLike;
use App\Models\Video;
use App\Notifications\NewCommentLikeNotification;
use App\Notifications\NewCommentNotification;
use App\Notifications\NewReplyNotification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@ -28,24 +32,33 @@ class CommentController extends Controller
'parent_id' => 'nullable|exists:comments,id',
]);
$commenter = Auth::user();
$comment = $video->comments()->create([
'user_id' => Auth::id(),
'user_id' => $commenter->id,
'body' => $request->body,
'parent_id' => $request->parent_id,
]);
// $video->increment('comment_count'); // Disabled - was causing SQL error
$comment->load('user:id,name,avatar_url');
// Handle mentions
preg_match_all('/@(\w+)/', $request->body, $matches);
if (! empty($matches[1])) {
// Mentions found - in production, you would send notifications here
// For now, we just parse them
$comment->load('user:id,name,avatar');
// Fire notifications (never notify yourself)
if ($request->parent_id) {
// Reply — notify the parent comment's author
$parent = Comment::find($request->parent_id);
if ($parent && $parent->user_id !== $commenter->id) {
$parent->user->notify(new NewReplyNotification($video, $comment, $commenter));
}
} else {
// Top-level comment — notify the video owner
if ($video->user_id !== $commenter->id) {
$video->user->notify(new NewCommentNotification($video, $comment, $commenter));
}
}
return response()->json([
'success' => true,
'comment' => $comment->load('user:id,name,avatar_url'),
'comment' => $comment->load('user:id,name,avatar'),
]);
}
@ -59,13 +72,10 @@ class CommentController extends Controller
'body' => 'required|string|max:1000',
]);
$comment->update([
'body' => $request->body,
]);
$comment->update(['body' => $request->body]);
$comment->load('user:id,name,avatar');
$comment->load('user:id,name,avatar_url');
return response()->json($comment->load('user:id,name,avatar_url'));
return response()->json($comment->load('user:id,name,avatar'));
}
public function destroy(Comment $comment)
@ -78,4 +88,34 @@ class CommentController extends Controller
return response()->json(['success' => true]);
}
public function like(Comment $comment)
{
$userId = Auth::id();
$existing = CommentLike::where('comment_id', $comment->id)
->where('user_id', $userId)
->first();
if ($existing) {
$existing->delete();
$liked = false;
} else {
CommentLike::create(['comment_id' => $comment->id, 'user_id' => $userId]);
$liked = true;
// Notify the comment author (not self-likes)
if ($comment->user_id !== $userId) {
$video = $comment->video;
if ($video) {
$comment->user->notify(
new NewCommentLikeNotification($video, $comment, Auth::user())
);
}
}
}
$count = CommentLike::where('comment_id', $comment->id)->count();
return response()->json(['liked' => $liked, 'count' => $count]);
}
}

View File

@ -9,4 +9,29 @@ use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
use AuthorizesRequests, ValidatesRequests;
/**
* Generate a collision-safe filename using the authenticated user's name,
* a compact timestamp with millisecond precision, and the original extension.
*
* Format: {username}_{YYYYMMDD}_{HHmmss}_{ms}.{ext}
* Example: ghassanyusuf_20260301_120102_020.mp4
*/
protected static function generateFilename(string $extension, ?string $username = null): string
{
$user = $username ?? (auth()->user()?->name ?? 'user');
// Sanitize: lowercase, keep only alphanumeric, collapse to max 20 chars
$slug = preg_replace('/[^a-z0-9]/', '', strtolower($user));
$slug = substr($slug ?: 'user', 0, 20);
$now = now();
$ms = str_pad((int) ($now->microsecond / 1000), 3, '0', STR_PAD_LEFT);
$timestamp = $now->format('Ymd') . '_' . $now->format('His') . '_' . $ms;
$ext = ltrim(strtolower($extension), '.');
return "{$slug}_{$timestamp}.{$ext}";
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ImageUploadController extends Controller
{
public function upload(Request $request)
{
$request->validate([
'image' => 'required|string',
'folder' => 'required|string|max:80',
'filename' => 'required|string|max:200',
]);
$imageData = $request->image;
$parts = explode(';base64,', $imageData);
$typePart = explode('image/', $parts[0]);
$extension = $typePart[1] ?? 'png';
// Sanitize extension
$extension = preg_replace('/[^a-z0-9]/', '', strtolower($extension));
if (!in_array($extension, ['png', 'jpg', 'jpeg', 'webp', 'gif'])) {
$extension = 'png';
}
$imageBinary = base64_decode($parts[1] ?? '');
if (!$imageBinary) {
return response()->json(['success' => false, 'message' => 'Invalid image data'], 422);
}
$folder = trim($request->folder, '/');
$fileName = $request->filename . '.' . $extension;
$fullPath = $folder . '/' . $fileName;
Storage::disk('public')->put($fullPath, $imageBinary);
return response()->json([
'success' => true,
'path' => $fullPath,
'url' => asset('storage/' . $fullPath),
]);
}
}

View File

@ -4,14 +4,17 @@ namespace App\Http\Controllers;
use App\Models\Playlist;
use App\Models\Video;
use App\Services\GeoIpService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class PlaylistController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['index', 'show', 'publicPlaylists', 'userPlaylists']);
$this->middleware('auth')->except(['index', 'show', 'showByToken', 'publicPlaylists', 'userPlaylists', 'recordShare', 'accessShare']);
}
// List user's playlists
@ -31,11 +34,100 @@ class PlaylistController extends Controller
abort(404, 'Playlist not found');
}
$videos = $playlist->videos()->orderBy('position')->paginate(20);
$playlist->loadMissing('user');
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
return view('playlists.show', compact('playlist', 'videos'));
}
// View playlist via unguessable share token (unlisted playlists)
public function showByToken(string $token)
{
$playlist = Playlist::where('share_token', $token)->firstOrFail();
if (! $playlist->canViewViaToken(Auth::user())) {
abort(404, 'Playlist not found');
}
$playlist->loadMissing('user');
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
return view('playlists.show', compact('playlist', 'videos'));
}
// Generate (or reuse) a per-user share tracking token and return the tracking URL
public function recordShare(Playlist $playlist)
{
$userId = Auth::id();
if ($userId) {
$existing = DB::table('playlist_shares')
->where('playlist_id', $playlist->id)
->where('user_id', $userId)
->first();
if ($existing) {
return response()->json(['url' => route('playlists.accessShare', $existing->token)]);
}
}
do {
$token = Str::random(10);
} while (DB::table('playlist_shares')->where('token', $token)->exists());
DB::table('playlist_shares')->insert([
'playlist_id' => $playlist->id,
'user_id' => $userId,
'token' => $token,
'created_at' => now(),
]);
return response()->json(['url' => route('playlists.accessShare', $token)]);
}
// Handle a share link click: record the access, then redirect to the playlist
public function accessShare(Request $request, string $token)
{
$share = DB::table('playlist_shares')->where('token', $token)->first();
if (! $share) {
return redirect('/');
}
$playlist = Playlist::find($share->playlist_id);
if (! $playlist || ! $playlist->canViewViaToken(Auth::user())) {
return redirect('/');
}
$did = $request->cookie('_did') ?: (string) Str::uuid();
$seen = DB::table('playlist_share_accesses')
->where('share_id', $share->id)
->where('device_id', $did)
->exists();
if (! $seen) {
$ip = $request->header('CF-Connecting-IP')
?? $request->header('X-Real-IP')
?? $request->ip();
$geo = GeoIpService::lookup($ip);
DB::table('playlist_share_accesses')->insert([
'share_id' => $share->id,
'device_id' => $did,
'ip_address' => $ip,
'country' => $geo['country'] ?? null,
'country_name' => $geo['country_name'] ?? null,
'accessed_at' => now(),
]);
}
$firstVideo = $playlist->videos()->orderBy('position')->first();
$destination = $firstVideo
? route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token
: route('playlists.showByToken', $playlist->share_token);
return redirect($destination)
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
}
// Create new playlist form
public function create()
{
@ -48,8 +140,8 @@ class PlaylistController extends Controller
$request->validate([
'name' => 'required|string|max:100',
'description' => 'nullable|string|max:500',
'visibility' => 'nullable|in:public,private',
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:5120',
'visibility' => 'nullable|in:public,private,unlisted',
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:20480',
]);
$playlistData = [
@ -58,6 +150,7 @@ class PlaylistController extends Controller
'description' => $request->description,
'visibility' => $request->visibility ?? 'private',
'is_default' => false,
'share_token' => Str::random(32),
];
// Create playlist first to get ID for thumbnail naming
@ -66,7 +159,7 @@ class PlaylistController extends Controller
// Handle thumbnail upload
if ($request->hasFile('thumbnail')) {
$file = $request->file('thumbnail');
$filename = 'playlist_'.$playlist->id.'_'.time().'.'.$file->getClientOriginalExtension();
$filename = self::generateFilename($file->getClientOriginalExtension());
$file->storeAs('public/thumbnails', $filename);
$playlist->update(['thumbnail' => $filename]);
}
@ -123,8 +216,8 @@ class PlaylistController extends Controller
$request->validate([
'name' => 'required|string|max:100',
'description' => 'nullable|string|max:500',
'visibility' => 'nullable|in:public,private',
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:5120',
'visibility' => 'nullable|in:public,private,unlisted',
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:20480',
]);
$updateData = [
@ -145,7 +238,7 @@ class PlaylistController extends Controller
// Upload new thumbnail
$file = $request->file('thumbnail');
$filename = 'playlist_'.$playlist->id.'_'.time().'.'.$file->getClientOriginalExtension();
$filename = self::generateFilename($file->getClientOriginalExtension());
$file->storeAs('public/thumbnails', $filename);
$updateData['thumbnail'] = $filename;
}
@ -351,7 +444,7 @@ class PlaylistController extends Controller
// Play all videos in playlist (redirect to first video with playlist context)
public function playAll(Playlist $playlist)
{
if (! $playlist->canView(Auth::user())) {
if (! $playlist->canViewViaToken(Auth::user())) {
abort(404, 'Playlist not found');
}
@ -361,17 +454,13 @@ class PlaylistController extends Controller
return back()->with('error', 'Playlist is empty.');
}
// Redirect to first video with playlist parameter
return redirect()->route('videos.show', [
'video' => $firstVideo->id,
'playlist' => $playlist->id,
]);
return redirect(route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token);
}
// Shuffle play - redirect to random video
public function shuffle(Playlist $playlist)
{
if (! $playlist->canView(Auth::user())) {
if (! $playlist->canViewViaToken(Auth::user())) {
abort(404, 'Playlist not found');
}
@ -381,9 +470,6 @@ class PlaylistController extends Controller
return back()->with('error', 'Playlist is empty.');
}
return redirect()->route('videos.show', [
'video' => $randomVideo->id,
'playlist' => $playlist->id,
]);
return redirect(route('videos.show', $randomVideo) . '?playlist=' . $playlist->share_token);
}
}

View File

@ -0,0 +1,126 @@
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\PostImage;
use App\Models\PostVideo;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
class PostController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function store(Request $request, User $user)
{
if (Auth::id() !== $user->id) {
abort(403);
}
$request->validate([
'body' => 'nullable|string|max:2000',
'images' => 'nullable|array|max:10',
'images.*' => 'image|mimes:jpg,jpeg,png,webp,gif|max:8192',
'video_ids' => 'nullable|array|max:10',
'video_ids.*' => 'exists:videos,id',
// Legacy fields
'video_id' => 'nullable|exists:videos,id',
'image' => 'nullable|image|mimes:jpg,jpeg,png,webp,gif|max:8192',
]);
$hasImages = $request->hasFile('images');
$hasVideoIds = $request->filled('video_ids');
$hasLegacyImg = $request->hasFile('image');
$hasLegacyVid = $request->filled('video_id');
if (! $request->body && ! $hasImages && ! $hasLegacyImg && ! $hasVideoIds && ! $hasLegacyVid) {
return back()->withErrors(['body' => 'Post cannot be empty.']);
}
$data = [
'user_id' => $user->id,
'body' => $request->body,
'video_id' => $request->video_id ?? null,
];
// Legacy single image (backward compat)
if ($hasLegacyImg) {
$filename = uniqid('post_', true) . '.' . $request->file('image')->getClientOriginalExtension();
$request->file('image')->storeAs('public/post_images', $filename);
$data['image'] = $filename;
}
$post = Post::create($data);
// New multi-image
if ($hasImages) {
foreach ($request->file('images') as $idx => $file) {
$filename = uniqid('post_', true) . '.' . $file->getClientOriginalExtension();
$file->storeAs('public/post_images', $filename);
PostImage::create([
'post_id' => $post->id,
'filename' => $filename,
'sort_order' => $idx,
]);
}
}
// New multi-video
if ($hasVideoIds) {
foreach ($request->input('video_ids') as $idx => $videoId) {
PostVideo::create([
'post_id' => $post->id,
'video_id' => $videoId,
'sort_order' => $idx,
]);
}
}
return back()->with('toast_success', 'Post shared!');
}
public function destroy(Post $post)
{
if (Auth::id() !== $post->user_id && ! Auth::user()->isAdmin()) {
abort(403);
}
if ($post->image) {
Storage::delete('public/post_images/' . $post->image);
}
// Delete multi-image files
foreach ($post->postImages as $postImage) {
Storage::delete('public/post_images/' . $postImage->filename);
}
$post->delete();
return back()->with('toast_success', 'Post deleted.');
}
public function react(Post $post)
{
$user = Auth::user();
$existing = $post->reactions()->where('user_id', $user->id)->first();
if ($existing) {
$existing->delete();
$liked = false;
} else {
$post->reactions()->create(['user_id' => $user->id, 'type' => 'like']);
$liked = true;
}
return response()->json([
'liked' => $liked,
'count' => $post->reactions()->count(),
]);
}
}

View File

@ -2,47 +2,242 @@
namespace App\Http\Controllers;
use App\Models\AuditLog;
use App\Models\Setting;
use App\Models\User;
use App\Models\Video;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class SuperAdminController extends Controller
{
public function __construct()
{
$this->middleware('super_admin');
$this->middleware('super_admin')->except(['exitImpersonation']);
}
// Manually verify a user's email
public function verifyUser(User $user)
{
if ($user->email_verified_at) {
return back()->with('error', "{$user->name} is already verified.");
}
$user->email_verified_at = now();
$user->save();
return back()->with('success', "{$user->name}'s account has been verified.");
}
// Start impersonating a user
public function impersonate(User $user)
{
if ($user->isSuperAdmin()) {
return back()->with('error', 'You cannot impersonate another super admin.');
}
$admin = \Auth::user();
AuditLog::record('admin.impersonate', [
'user_id' => $admin->id,
'user_name' => $admin->name,
'subject_type' => 'User',
'subject_id' => (string) $user->id,
'subject_label' => $user->name,
]);
session(['impersonator_id' => \Auth::id()]);
\Auth::loginUsingId($user->id);
return redirect()->route('home')
->with('success', "You are now impersonating {$user->name}. Use the banner to exit.");
}
// Exit impersonation and return to original admin account
public function exitImpersonation()
{
$impersonatorId = session('impersonator_id');
if (! $impersonatorId) {
return redirect()->route('home');
}
$impersonatedUser = \Auth::user();
session()->forget('impersonator_id');
\Auth::loginUsingId($impersonatorId);
AuditLog::record('admin.impersonate.exit', [
'subject_type' => 'User',
'subject_id' => (string) $impersonatedUser->id,
'subject_label' => $impersonatedUser->name,
]);
return redirect()->route('admin.users')
->with('success', 'Impersonation ended. You are back as yourself.');
}
// Dashboard - Overview statistics
public function dashboard()
{
$stats = [
'total_users' => User::count(),
'total_videos' => Video::count(),
'total_views' => \DB::table('video_views')->count('id'),
'total_likes' => \DB::table('video_likes')->count('id'),
];
$now = now();
$w0 = $now->copy()->subDays(7); // start of this week window
$w1 = $now->copy()->subDays(14); // start of last week window
// Recent users
// ── Core totals ────────────────────────────────────────────
$totalUsers = User::count();
$totalVideos = Video::count();
$totalViews = \DB::table('video_views')->count();
$totalLikes = \DB::table('video_likes')->count();
$totalComments = \DB::table('comments')->count();
// ── Week-over-week growth ──────────────────────────────────
$usersThisWeek = User::where('created_at', '>=', $w0)->count();
$usersLastWeek = User::whereBetween('created_at', [$w1, $w0])->count();
$videosThisWeek = Video::where('created_at', '>=', $w0)->count();
$videosLastWeek = Video::whereBetween('created_at', [$w1, $w0])->count();
$viewsThisWeek = \DB::table('video_views')->where('watched_at', '>=', $w0)->count();
$viewsLastWeek = \DB::table('video_views')->whereBetween('watched_at', [$w1, $w0])->count();
$likesThisWeek = \DB::table('video_likes')->where('created_at', '>=', $w0)->count();
$likesLastWeek = \DB::table('video_likes')->whereBetween('created_at', [$w1, $w0])->count();
$commentsThisWeek = \DB::table('comments')->where('created_at', '>=', $w0)->count();
$growthUsers = $this->growthPct($usersLastWeek, $usersThisWeek);
$growthVideos = $this->growthPct($videosLastWeek, $videosThisWeek);
$growthViews = $this->growthPct($viewsLastWeek, $viewsThisWeek);
$growthLikes = $this->growthPct($likesLastWeek, $likesThisWeek);
$stats = compact(
'totalUsers','totalVideos','totalViews','totalLikes','totalComments',
'usersThisWeek','videosThisWeek','viewsThisWeek','likesThisWeek','commentsThisWeek',
'growthUsers','growthVideos','growthViews','growthLikes'
);
// ── 30-day daily activity (for charts) ────────────────────
$days30 = collect(range(29, 0))->map(fn($d) => $now->copy()->subDays($d)->format('Y-m-d'));
$rawUsers = User::selectRaw('DATE(created_at) as d, COUNT(*) as n')
->where('created_at', '>=', $now->copy()->subDays(30))
->groupBy('d')->pluck('n', 'd');
$rawVideos = Video::selectRaw('DATE(created_at) as d, COUNT(*) as n')
->where('created_at', '>=', $now->copy()->subDays(30))
->groupBy('d')->pluck('n', 'd');
$rawViews = \DB::table('video_views')
->selectRaw('DATE(watched_at) as d, COUNT(*) as n')
->where('watched_at', '>=', $now->copy()->subDays(30))
->groupBy('d')->pluck('n', 'd');
$chartLabels = $days30->map(fn($d) => date('M j', strtotime($d)))->values()->toJson();
$chartDatesRaw = $days30->values()->toJson();
$chartUsersData = $days30->map(fn($d) => $rawUsers->get($d, 0))->values()->toJson();
$chartVideosData = $days30->map(fn($d) => $rawVideos->get($d, 0))->values()->toJson();
$chartViewsData = $days30->map(fn($d) => $rawViews->get($d, 0))->values()->toJson();
// ── Video status & visibility ──────────────────────────────
$videosByStatus = Video::selectRaw('status, count(*) as n')->groupBy('status')->pluck('n', 'status');
$videosByVisibility = Video::selectRaw('visibility, count(*) as n')->groupBy('visibility')->pluck('n', 'visibility');
$videosByType = Video::selectRaw('type, count(*) as n')->groupBy('type')->pluck('n', 'type');
// ── Alerts ────────────────────────────────────────────────
$failedCount = $videosByStatus->get('failed', 0);
$processingCount = $videosByStatus->get('processing', 0);
$pendingCount = $videosByStatus->get('pending', 0);
// ── Top content ───────────────────────────────────────────
$topVideos = \DB::table('videos')
->join('users', 'videos.user_id', '=', 'users.id')
->select(
'videos.id', 'videos.title', 'videos.thumbnail', 'videos.type',
'users.name as username',
\DB::raw('(SELECT COUNT(*) FROM video_views WHERE video_views.video_id = videos.id) as view_count'),
\DB::raw('(SELECT COUNT(*) FROM video_likes WHERE video_likes.video_id = videos.id) as like_count')
)
->where('videos.status', 'ready')
->orderByDesc('view_count')
->take(5)->get();
$topUploaders = \DB::table('users')
->select('users.id','users.name','users.email','users.avatar',
\DB::raw('COUNT(videos.id) as video_count'),
\DB::raw('SUM((SELECT COUNT(*) FROM video_views WHERE video_views.video_id = videos.id)) as total_views')
)
->leftJoin('videos', 'users.id', '=', 'videos.user_id')
->groupBy('users.id','users.name','users.email','users.avatar')
->orderByDesc('video_count')
->take(5)->get();
// ── Engagement ────────────────────────────────────────────
$readyVideos = $videosByStatus->get('ready', 0);
$avgViewsPerVideo = $readyVideos > 0 ? round($totalViews / $readyVideos, 1) : 0;
$likeToViewRatio = $totalViews > 0 ? round(($totalLikes / $totalViews) * 100, 1) : 0;
$avgVideosPerUser = $totalUsers > 0 ? round($totalVideos / $totalUsers, 1) : 0;
// ── Viewers by country ────────────────────────────────────
$viewsByCountry = \DB::table('video_views')
->whereNotNull('country')
->selectRaw('country, country_name, COUNT(*) as total')
->groupBy('country', 'country_name')
->orderByDesc('total')
->take(20)
->get();
// ── Recent ────────────────────────────────────────────────
$recentUsers = User::latest()->take(5)->get();
// Recent videos
$recentVideos = Video::with('user')->latest()->take(5)->get();
// Videos by status
$videosByStatus = Video::select('status', \DB::raw('count(*) as count'))
->groupBy('status')
->pluck('count', 'status');
// ── Storage ───────────────────────────────────────────────
$disk = Storage::disk('public');
// Videos by visibility
$videosByVisibility = Video::select('visibility', \DB::raw('count(*) as count'))
->groupBy('visibility')
->pluck('count', 'visibility');
$sizeVideos = $this->dirSize($disk, 'videos');
$sizeThumbnails = $this->dirSize($disk, 'thumbnails');
$sizeAvatars = $this->dirSize($disk, 'avatars');
$sizeImages = $this->dirSize($disk, 'images');
$totalPublicSize = $sizeVideos + $sizeThumbnails + $sizeAvatars + $sizeImages;
$videosUsagePercent = $totalPublicSize > 0 ? round(($sizeVideos / $totalPublicSize) * 100, 1) : 0;
return view('admin.dashboard', compact('stats', 'recentUsers', 'recentVideos', 'videosByStatus', 'videosByVisibility'));
// Convert to MB
$toMb = fn($b) => round($b / 1024 / 1024, 1);
$storage = [
'videos' => $toMb($sizeVideos),
'thumbnails' => $toMb($sizeThumbnails),
'avatars' => $toMb($sizeAvatars),
'images' => $toMb($sizeImages),
'total' => $toMb($totalPublicSize),
];
$videosDirSize = $storage['videos'];
$totalPublicSizeMb = $storage['total'];
return view('admin.dashboard', compact(
'stats',
'chartLabels','chartDatesRaw','chartUsersData','chartVideosData','chartViewsData',
'videosByStatus','videosByVisibility','videosByType',
'failedCount','processingCount','pendingCount',
'topVideos','topUploaders',
'avgViewsPerVideo','likeToViewRatio','avgVideosPerUser',
'recentUsers','recentVideos',
'storage','videosDirSize','totalPublicSizeMb','videosUsagePercent',
'viewsByCountry'
));
}
private function growthPct(int $prev, int $curr): array
{
if ($prev === 0) {
return ['pct' => $curr > 0 ? 100 : 0, 'dir' => $curr > 0 ? 'up' : 'flat'];
}
$pct = round((($curr - $prev) / $prev) * 100, 1);
return ['pct' => abs($pct), 'dir' => $pct >= 0 ? 'up' : 'down'];
}
private function dirSize($disk, string $dir): int
{
$size = 0;
foreach ($disk->allFiles($dir) as $file) {
try { $size += $disk->size($file); } catch (\Exception $e) {}
}
return $size;
}
// List all users with search/filter
@ -126,6 +321,13 @@ class SuperAdminController extends Controller
return back()->with('error', 'You cannot delete your own account!');
}
AuditLog::record('admin.user.deleted', [
'subject_type' => 'User',
'subject_id' => (string) $user->id,
'subject_label' => $user->name,
'details' => ['email' => $user->email, 'video_count' => $user->videos->count()],
]);
// Delete user's videos and associated files
foreach ($user->videos as $video) {
Storage::delete('public/videos/' . $video->filename);
@ -202,6 +404,113 @@ class SuperAdminController extends Controller
return view('admin.videos', compact('videos'));
}
// Per-video analytics
public function videoAnalytics(Video $video)
{
// ── Total view events (every watch counts) ──────────────────────────
$totalViews = \DB::table('video_views')->where('video_id', $video->id)->count();
$totalLikes = \DB::table('video_likes')->where('video_id', $video->id)->count();
$totalComments = \DB::table('comments')->where('video_id', $video->id)->count();
// ── Views per day last 30 days (raw events) ───────────────────────
$rawDaily = \DB::table('video_views')
->where('video_id', $video->id)
->where('watched_at', '>=', now()->subDays(29)->startOfDay())
->selectRaw('DATE(watched_at) as date, COUNT(*) as total')
->groupBy('date')
->orderBy('date')
->get()
->keyBy('date');
$dailyLabels = [];
$dailyViews = [];
for ($i = 29; $i >= 0; $i--) {
$d = now()->subDays($i);
$dailyLabels[] = $d->format('M d');
$dailyViews[] = $rawDaily[$d->format('Y-m-d')]->total ?? 0;
}
// ── Fetch all view records once, join user profile data ─────────────
// Ordered newest-first so the first occurrence per viewer is their
// most recent record (country, etc. from their latest watch).
$allViewRecords = \DB::table('video_views')
->leftJoin('users', 'video_views.user_id', '=', 'users.id')
->where('video_views.video_id', $video->id)
->select(
'video_views.id',
'video_views.user_id',
'video_views.ip_address',
'video_views.country',
'video_views.country_name',
'video_views.watched_at',
'users.name as viewer_name',
'users.avatar as viewer_avatar',
'users.birthday',
'users.gender'
)
->orderByDesc('video_views.watched_at')
->get();
// ── Deduplicate to one row per unique viewer ─────────────────────────
// Auth users → keyed by user_id (u_123)
// Guest users → keyed by ip_address (i_1.2.3.4)
$seenKeys = [];
$uniqueViewers = [];
foreach ($allViewRecords as $row) {
$key = $row->user_id ? 'u_' . $row->user_id : 'i_' . $row->ip_address;
if (isset($seenKeys[$key])) continue;
$seenKeys[$key] = true;
$uniqueViewers[] = $row;
}
$totalUniqueViewers = count($uniqueViewers);
$authViewers = count(array_filter($uniqueViewers, fn($v) => $v->user_id !== null));
$guestViewers = $totalUniqueViewers - $authViewers;
// ── Countries one count per unique viewer ──────────────────────────
$countryMap = [];
foreach ($uniqueViewers as $viewer) {
if (!$viewer->country) continue;
if (!isset($countryMap[$viewer->country])) {
$countryMap[$viewer->country] = ['country' => $viewer->country, 'country_name' => $viewer->country_name, 'total' => 0];
}
$countryMap[$viewer->country]['total']++;
}
usort($countryMap, fn($a, $b) => $b['total'] - $a['total']);
$viewsByCountry = collect(array_slice($countryMap, 0, 20))->map(fn($r) => (object) $r);
// ── Age groups one count per unique viewer ─────────────────────────
$ageGroups = ['Under 18' => 0, '1824' => 0, '2534' => 0, '3544' => 0, '4554' => 0, '55+' => 0, 'Unknown' => 0];
$now = now();
foreach ($uniqueViewers as $viewer) {
if (!$viewer->birthday) { $ageGroups['Unknown']++; continue; }
$age = \Carbon\Carbon::parse($viewer->birthday)->diffInYears($now);
if ($age < 18) $ageGroups['Under 18']++;
elseif ($age < 25) $ageGroups['1824']++;
elseif ($age < 35) $ageGroups['2534']++;
elseif ($age < 45) $ageGroups['3544']++;
elseif ($age < 55) $ageGroups['4554']++;
else $ageGroups['55+']++;
}
// ── Gender one count per unique viewer ─────────────────────────────
$genderCounts = ['Male' => 0, 'Female' => 0, 'Prefer not to say' => 0];
foreach ($uniqueViewers as $viewer) {
if ($viewer->gender === 'male') $genderCounts['Male']++;
elseif ($viewer->gender === 'female') $genderCounts['Female']++;
elseif ($viewer->gender === 'prefer_not_to_say') $genderCounts['Prefer not to say']++;
}
// ── Recent 20 individual view events ────────────────────────────────
$recentViews = $allViewRecords->take(20);
return view('admin.video-analytics', compact(
'video', 'totalViews', 'totalUniqueViewers', 'authViewers', 'guestViewers',
'totalLikes', 'totalComments', 'dailyLabels', 'dailyViews',
'viewsByCountry', 'ageGroups', 'genderCounts', 'recentViews'
));
}
// Show edit video form
public function editVideo(Video $video)
{
@ -218,9 +527,10 @@ class SuperAdminController extends Controller
'type' => 'required|in:generic,music,match',
'status' => 'required|in:pending,processing,ready,failed',
'is_shorts' => 'nullable|boolean',
'download_access' => 'nullable|in:disabled,everyone,registered,subscribers',
]);
$data = $request->only(['title', 'description', 'visibility', 'type', 'status', 'is_shorts']);
$data = $request->only(['title', 'description', 'visibility', 'type', 'status', 'is_shorts', 'download_access']);
$video->update($data);
@ -232,6 +542,13 @@ class SuperAdminController extends Controller
{
$videoTitle = $video->title;
AuditLog::record('admin.video.deleted', [
'subject_type' => 'Video',
'subject_id' => (string) $video->id,
'subject_label' => $videoTitle,
'details' => ['owner_id' => $video->user_id, 'type' => $video->type],
]);
// Delete files
Storage::delete('public/videos/' . $video->filename);
if ($video->thumbnail) {
@ -246,4 +563,335 @@ class SuperAdminController extends Controller
return redirect()->route('admin.videos')->with('success', 'Video "' . $videoTitle . '" deleted successfully!');
}
/**
* Manual cleanup orphaned videos (admin instant trigger)
*/
public function cleanupOrphanedVideos(Request $request)
{
$output = [];
$returnCode = 0;
Artisan::call('cleanup:orphaned-videos --force', [], $output);
Log::channel('orphaned-videos')->info('Manual cleanup triggered by super admin');
$disk = Storage::disk('public');
$totalPublicSize = 0;
foreach ($disk->allFiles() as $file) {
$totalPublicSize += $disk->size($file);
}
$videosDirSize = 0;
foreach ($disk->allFiles('videos') as $file) {
$videosDirSize += $disk->size($file);
}
return response()->json([
'success' => true,
'message' => 'Cleanup completed! Check logs for details.',
'stats' => [
'return_code' => $returnCode,
'videos_dir_size_mb' => round($videosDirSize / 1024 / 1024, 1),
'total_public_size_mb' => round($totalPublicSize / 1024 / 1024, 1),
],
]);
}
public function modalData(Request $request)
{
$resource = $request->get('resource', '');
$limit = min((int) $request->get('limit', 30), 100);
switch ($resource) {
case 'users':
$query = User::latest();
if ($request->date) $query->whereDate('created_at', $request->date);
if ($request->filter === 'week') $query->where('created_at', '>=', now()->subDays(7));
$items = $query->take($limit)->get();
return response()->json(['type' => 'users', 'items' => $items->map(fn($u) => [
'avatar' => $u->avatar_url,
'name' => $u->name,
'email' => $u->email,
'role' => $u->role ?? 'user',
'joined' => $u->created_at->diffForHumans(),
'url' => route('channel', $u->channel),
])]);
case 'videos':
$query = Video::with('user')->latest();
if ($request->status) $query->where('status', $request->status);
if ($request->visibility) $query->where('visibility', $request->visibility);
if ($request->type) $query->where('type', $request->type);
if ($request->date) $query->whereDate('created_at', $request->date);
if ($request->uploader_id) $query->where('user_id', $request->uploader_id);
$items = $query->take($limit)->get();
return response()->json(['type' => 'videos', 'items' => $items->map(fn($v) => [
'thumbnail' => $v->thumbnail ? asset('storage/thumbnails/'.$v->thumbnail) : null,
'title' => $v->title,
'owner' => $v->user->name ?? 'Unknown',
'status' => $v->status,
'type' => $v->type,
'views' => \DB::table('video_views')->where('video_id', $v->id)->count(),
'uploaded' => $v->created_at->diffForHumans(),
'url' => route('videos.show', $v),
])]);
case 'top_videos':
$items = \DB::table('videos')
->join('users', 'videos.user_id', '=', 'users.id')
->select('videos.id', 'videos.title', 'videos.thumbnail', 'users.name as username',
\DB::raw('(SELECT COUNT(*) FROM video_views WHERE video_views.video_id = videos.id) as view_count'),
\DB::raw('(SELECT COUNT(*) FROM video_likes WHERE video_likes.video_id = videos.id) as like_count'))
->orderByDesc('view_count')->take($limit)->get();
return response()->json(['type' => 'top_videos', 'items' => $items->map(fn($v) => [
'thumbnail' => $v->thumbnail ? asset('storage/thumbnails/'.$v->thumbnail) : null,
'title' => $v->title,
'owner' => $v->username,
'views' => $v->view_count,
'likes' => $v->like_count,
'url' => route('videos.show', Video::encodeId($v->id)),
])]);
case 'views_day':
$items = \DB::table('video_views')
->join('videos', 'video_views.video_id', '=', 'videos.id')
->leftJoin('users', 'video_views.user_id', '=', 'users.id')
->select('videos.title', 'videos.id as video_id', 'users.name as viewer_name', 'video_views.country', 'video_views.watched_at')
->when($request->date, fn($q) => $q->whereDate('video_views.watched_at', $request->date))
->orderByDesc('video_views.watched_at')->take($limit)->get();
return response()->json(['type' => 'views', 'items' => $items->map(fn($v) => [
'video' => $v->title,
'viewer' => $v->viewer_name ?? 'Guest',
'country' => $v->country ?? '—',
'time' => \Carbon\Carbon::parse($v->watched_at)->diffForHumans(),
'url' => route('videos.show', Video::encodeId($v->video_id)),
])]);
case 'likes':
$items = \DB::table('videos')
->join('users', 'videos.user_id', '=', 'users.id')
->select('videos.id', 'videos.title', 'videos.thumbnail', 'users.name as username',
\DB::raw('(SELECT COUNT(*) FROM video_likes WHERE video_likes.video_id = videos.id) as like_count'))
->having('like_count', '>', 0)->orderByDesc('like_count')->take($limit)->get();
return response()->json(['type' => 'top_videos', 'items' => $items->map(fn($v) => [
'thumbnail' => $v->thumbnail ? asset('storage/thumbnails/'.$v->thumbnail) : null,
'title' => $v->title,
'owner' => $v->username,
'views' => 0,
'likes' => $v->like_count,
'url' => route('videos.show', Video::encodeId($v->id)),
])]);
case 'comments':
$items = \DB::table('comments')
->join('users', 'comments.user_id', '=', 'users.id')
->join('videos', 'comments.video_id', '=', 'videos.id')
->select('comments.body', 'users.name as user_name', 'videos.title as video_title', 'videos.id as video_id', 'comments.created_at')
->orderByDesc('comments.created_at')->take($limit)->get();
return response()->json(['type' => 'comments', 'items' => $items->map(fn($c) => [
'user' => $c->user_name,
'body' => \Str::limit($c->body, 120),
'video' => $c->video_title,
'time' => \Carbon\Carbon::parse($c->created_at)->diffForHumans(),
'url' => route('videos.show', Video::encodeId($c->video_id)),
])]);
case 'country_viewers':
$items = \DB::table('video_views')
->join('videos', 'video_views.video_id', '=', 'videos.id')
->leftJoin('users', 'video_views.user_id', '=', 'users.id')
->select('videos.title', 'videos.id as video_id', 'users.name as viewer_name', 'video_views.watched_at')
->where('video_views.country', $request->country)
->orderByDesc('video_views.watched_at')->take($limit)->get();
return response()->json(['type' => 'views', 'items' => $items->map(fn($v) => [
'video' => $v->title,
'viewer' => $v->viewer_name ?? 'Guest',
'country' => $request->country,
'time' => \Carbon\Carbon::parse($v->watched_at)->diffForHumans(),
'url' => route('videos.show', Video::encodeId($v->video_id)),
])]);
case 'uploaders':
$items = \DB::table('users')
->select('users.id', 'users.name', 'users.avatar',
\DB::raw('COUNT(videos.id) as video_count'),
\DB::raw('COALESCE(SUM((SELECT COUNT(*) FROM video_views WHERE video_views.video_id = videos.id)),0) as total_views'))
->leftJoin('videos', 'users.id', '=', 'videos.user_id')
->groupBy('users.id', 'users.name', 'users.avatar')
->orderByDesc('video_count')->take($limit)->get();
return response()->json(['type' => 'uploaders', 'items' => $items->map(fn($u) => [
'avatar' => $u->avatar ? asset('storage/avatars/'.$u->avatar) : 'https://i.pravatar.cc/40?u='.$u->id,
'name' => $u->name,
'videos' => $u->video_count,
'views' => $u->total_views,
'url' => route('channel', $u->channel),
])]);
}
return response()->json(['error' => 'Unknown resource'], 400);
}
public function logs(Request $request)
{
$logFile = storage_path('logs/laravel.log');
$lines = [];
$filter = $request->get('filter', '');
$level = $request->get('level', '');
$limit = (int) $request->get('limit', 200);
if (file_exists($logFile)) {
$all = array_reverse(file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES));
foreach ($all as $line) {
if ($filter && stripos($line, $filter) === false) continue;
if ($level && stripos($line, ".$level:") === false) continue;
$lines[] = $line;
if (count($lines) >= $limit) break;
}
}
return view('admin.logs', compact('lines', 'filter', 'level', 'limit'));
}
public function auditLogs(Request $request)
{
$query = AuditLog::query()->latest('created_at');
if ($request->filled('action')) {
$query->where('action', $request->action);
}
if ($request->filled('user')) {
$query->where(function ($q) use ($request) {
$q->where('user_name', 'like', '%'.$request->user.'%')
->orWhereHas('user', fn($u) => $u->where('email', 'like', '%'.$request->user.'%'));
});
}
if ($request->filled('ip')) {
$query->where('ip_address', 'like', '%'.$request->ip.'%');
}
if ($request->filled('subject')) {
$query->where('subject_label', 'like', '%'.$request->subject.'%');
}
if ($request->filled('date_from')) {
$query->whereDate('created_at', '>=', $request->date_from);
}
if ($request->filled('date_to')) {
$query->whereDate('created_at', '<=', $request->date_to);
}
$logs = $query->paginate(50)->withQueryString();
$actionTypes = AuditLog::query()
->select('action')
->distinct()
->orderBy('action')
->pluck('action');
return view('admin.audit-logs', compact('logs', 'actionTypes'));
}
// ── Settings ──────────────────────────────────────────────────────────
public function settings()
{
$settings = [
'gpu_enabled' => Setting::get('gpu_enabled', 'true'),
'gpu_device' => Setting::get('gpu_device', '0'),
'gpu_encoder' => Setting::get('gpu_encoder', 'h264_nvenc'),
'gpu_hwaccel' => Setting::get('gpu_hwaccel', 'cuda'),
'gpu_preset' => Setting::get('gpu_preset', 'p4'),
'ffmpeg_binary' => Setting::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg')),
];
$gpus = $this->probeGpus();
$nvencWorks = $this->probeNvenc();
return view('admin.settings', compact('settings', 'gpus', 'nvencWorks'));
}
public function updateSettings(Request $request)
{
$request->validate([
'gpu_enabled' => 'required|in:true,false',
'gpu_device' => 'required|integer|min:0|max:15',
'gpu_encoder' => 'required|in:h264_nvenc,hevc_nvenc,libx264',
'gpu_hwaccel' => 'required|in:cuda,none',
'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
'ffmpeg_binary' => 'required|string|max:255',
]);
$binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
if (! file_exists($binary) || ! is_executable($binary)) {
return back()->withErrors(['ffmpeg_binary' => "FFmpeg binary not found or not executable: {$binary}"]);
}
Setting::set('gpu_enabled', $request->gpu_enabled);
Setting::set('gpu_device', (string) $request->gpu_device);
Setting::set('gpu_encoder', $request->gpu_encoder);
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
Setting::set('gpu_preset', $request->gpu_preset);
Setting::set('ffmpeg_binary', $binary);
return back()->with('success', 'Settings saved.');
}
public function detectGpu()
{
return response()->json(['gpus' => $this->probeGpus()]);
}
private function probeGpus(): array
{
$gpus = [];
exec(
'nvidia-smi --query-gpu=index,name,memory.total,memory.free,utilization.gpu,temperature.gpu,driver_version'
. ' --format=csv,noheader,nounits 2>/dev/null',
$lines, $exit
);
if ($exit !== 0 || empty($lines)) return $gpus;
foreach ($lines as $line) {
$parts = array_map('trim', explode(',', $line));
if (count($parts) < 7) continue;
$gpus[] = [
'index' => (int) $parts[0],
'name' => $parts[1],
'mem_total' => (int) $parts[2],
'mem_free' => (int) $parts[3],
'util' => (int) $parts[4],
'temp' => (int) $parts[5],
'driver' => $parts[6],
];
}
return $gpus;
}
/**
* Quick smoke-test: encode one frame with h264_nvenc and return true if it succeeds.
* This catches CUDA compat / driver-version mismatches that nvidia-smi can't detect.
*/
private function probeNvenc(): bool
{
$ffmpeg = Setting::ffmpegBinary();
$tmp = sys_get_temp_dir() . '/nvenc_probe_' . getmypid() . '.mp4';
$device = Setting::gpuDevice();
exec(
escapeshellcmd($ffmpeg)
. ' -f lavfi -i nullsrc=s=128x72:r=1 -frames:v 1'
. " -c:v h264_nvenc -gpu {$device}"
. ' -y ' . escapeshellarg($tmp) . ' 2>/dev/null',
$out, $exit
);
$ok = ($exit === 0 && file_exists($tmp) && filesize($tmp) > 0);
@unlink($tmp);
return $ok;
}
public function nasStorage()
{
$nodes = config('nas-file-manager.schema', []);
return view('admin.nas-storage', compact('nodes'));
}
}

View File

@ -0,0 +1,171 @@
<?php
namespace App\Http\Controllers;
use App\Mail\TwoFactorDisableConfirmation;
use App\Models\AuditLog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
use PragmaRX\Google2FALaravel\Support\Authenticator;
class TwoFactorController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['showChallenge', 'verifyChallenge']);
}
// POST /2fa/setup — generate secret + return QR SVG
public function setup()
{
$user = Auth::user();
$google2fa = app('pragmarx.google2fa');
$secret = $google2fa->generateSecretKey();
session(['2fa_setup_secret' => $secret]);
$qrUrl = $google2fa->getQRCodeUrl(
config('app.name'),
$user->email,
$secret
);
$renderer = new \BaconQrCode\Renderer\ImageRenderer(
new \BaconQrCode\Renderer\RendererStyle\RendererStyle(200),
new \BaconQrCode\Renderer\Image\SvgImageBackEnd()
);
$writer = new \BaconQrCode\Writer($renderer);
$qrSvg = base64_encode($writer->writeString($qrUrl));
return response()->json([
'secret' => $secret,
'qr' => $qrSvg,
]);
}
// POST /2fa/enable — confirm OTP and save secret
public function enable(Request $request)
{
$request->validate(['code' => 'required|digits:6']);
$user = Auth::user();
$google2fa = app('pragmarx.google2fa');
$secret = session('2fa_setup_secret');
if (! $secret || ! $google2fa->verifyKey($secret, $request->code)) {
return back()->with('toast_error', 'Invalid code — please try again.');
}
$user->update([
'two_factor_secret' => encrypt($secret),
'two_factor_enabled' => true,
]);
session()->forget('2fa_setup_secret');
AuditLog::record('user.2fa.enabled');
return redirect()->route('channel')->with('toast_success', 'Two-factor authentication enabled!')->with('_open_tab', 'settings');
}
// POST /2fa/disable — verifies password then sends a confirmation email
public function disable(Request $request)
{
$request->validate(['password' => 'required']);
$user = Auth::user();
if (! \Hash::check($request->password, $user->password)) {
return back()->with('toast_error', 'Incorrect password.')->with('_open_tab', 'settings');
}
$confirmUrl = URL::temporarySignedRoute(
'2fa.disable.confirm',
now()->addMinutes(15),
['user' => $user->id]
);
Mail::to($user->email)->send(new TwoFactorDisableConfirmation($user, $confirmUrl));
AuditLog::record('user.2fa.disable_requested');
return redirect()->route('channel')
->with('toast_success', 'Check your email — a confirmation link has been sent to ' . $user->email . '.')
->with('_open_tab', 'settings');
}
// GET /2fa/disable/confirm?signature=...
public function confirmDisable(Request $request)
{
if (! $request->hasValidSignature()) {
abort(403, 'This confirmation link is invalid or has expired.');
}
$user = \App\Models\User::findOrFail($request->query('user'));
// Ensure the signed URL belongs to the currently authenticated user (or log them in if
// they arrive via email while not logged in on this device)
if (Auth::check() && Auth::id() !== $user->id) {
abort(403, 'This confirmation link belongs to a different account.');
}
if (! $user->two_factor_enabled) {
return redirect()->route('channel')
->with('toast_success', 'Two-factor authentication is already disabled.')
->with('_open_tab', 'settings');
}
$user->update([
'two_factor_secret' => null,
'two_factor_enabled' => false,
]);
AuditLog::record('user.2fa.disabled', ['user_id' => $user->id, 'user_name' => $user->name]);
if (! Auth::check()) {
return redirect()->route('login')
->with('toast_success', 'Two-factor authentication has been disabled. Please log in.');
}
return redirect()->route('channel')
->with('toast_success', 'Two-factor authentication has been disabled.')
->with('_open_tab', 'settings');
}
// GET /2fa/challenge
public function showChallenge()
{
if (! session('2fa_user_id')) {
return redirect()->route('login');
}
return view('auth.2fa-challenge');
}
// POST /2fa/challenge
public function verifyChallenge(Request $request)
{
$userId = session('2fa_user_id');
if (! $userId) {
return redirect()->route('login');
}
$request->validate(['code' => 'required|digits:6']);
$user = \App\Models\User::findOrFail($userId);
$google2fa = app('pragmarx.google2fa');
$secret = decrypt($user->two_factor_secret);
if (! $google2fa->verifyKey($secret, $request->code)) {
return back()->withErrors(['code' => 'Invalid code — please try again.']);
}
session()->forget('2fa_user_id');
Auth::login($user, session()->pull('2fa_remember', false));
return redirect()->intended('/videos');
}
}

View File

@ -2,13 +2,15 @@
namespace App\Http\Controllers;
use App\Helpers\Horoscope;
use App\Models\AuditLog;
use App\Models\Post;
use App\Models\User;
use App\Models\Video;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class UserController extends Controller
{
@ -17,7 +19,7 @@ class UserController extends Controller
$this->middleware('auth')->except(['channel']);
}
// Profile page - view own profile
// Profile page - personal overview for the authenticated user
public function profile()
{
$user = Auth::user();
@ -28,58 +30,83 @@ class UserController extends Controller
// Update profile
public function updateProfile(Request $request)
{
$user = Auth::user();
$authUser = Auth::user();
// Super admins may edit any user's profile by passing _edit_user_id
if ($authUser->isSuperAdmin() && $request->filled('_edit_user_id')) {
$user = User::findOrFail($request->input('_edit_user_id'));
} else {
$user = $authUser;
}
$request->validate([
'name' => 'required|string|max:255',
'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:5120',
'bio' => 'nullable|string|max:500',
'website' => 'nullable|string|max:255',
'twitter' => 'nullable|string|max:100',
'instagram' => 'nullable|string|max:100',
'facebook' => 'nullable|string|max:100',
'youtube' => 'nullable|string|max:100',
'linkedin' => 'nullable|string|max:100',
'tiktok' => 'nullable|string|max:100',
'birthday' => 'nullable|date',
'location' => 'nullable|string|max:100',
'gender' => 'nullable|in:male,female,prefer_not_to_say',
'nationality' => 'nullable|string|size:2',
'phone_code' => 'nullable|string|max:20',
'phone_number' => 'nullable|string|max:30',
'timezone' => 'nullable|timezone:all',
'slink' => 'nullable|array',
'slink.*.platform' => 'required_with:slink|string|max:30',
'slink.*.value' => 'required_with:slink|string|max:500',
'slink.*.visibility' => 'nullable|in:public,registered,subscribers,only_me',
]);
$data = [
'name' => $request->name,
'bio' => $request->bio,
'website' => $request->website,
'twitter' => $request->twitter,
'instagram' => $request->instagram,
'facebook' => $request->facebook,
'youtube' => $request->youtube,
'linkedin' => $request->linkedin,
'tiktok' => $request->tiktok,
'birthday' => $request->birthday,
'location' => $request->location,
'gender' => $request->gender ?: null,
'nationality' => $request->nationality ?: null,
'phone_code' => $request->phone_code ?: null,
'phone_number' => $request->phone_number ?: null,
'timezone' => $request->timezone ?: null,
];
if ($request->hasFile('avatar')) {
// Delete old avatar
if ($user->avatar) {
Storage::delete('public/avatars/'.$user->avatar);
}
$filename = Str::uuid().'.'.$request->file('avatar')->getClientOriginalExtension();
$filename = self::generateFilename($request->file('avatar')->getClientOriginalExtension());
$request->file('avatar')->storeAs('public/avatars', $filename);
$data['avatar'] = $filename;
}
$user->update($data);
return redirect()->route('profile')->with('success', 'Profile updated successfully!');
// Sync social links
$user->socialLinks()->delete();
$order = 0;
foreach ($request->input('slink', []) as $entry) {
$platform = trim($entry['platform'] ?? '');
$value = trim($entry['value'] ?? '');
if ($platform && $value) {
$user->socialLinks()->create([
'platform' => $platform,
'value' => $value,
'visibility' => $entry['visibility'] ?? 'public',
'sort_order' => $order++,
]);
}
}
// Settings page
// Redirect back to profile page, or channel settings if admin edited another user
if ($authUser->isSuperAdmin() && $authUser->id !== $user->id) {
return redirect()->route('channel', $user->channel)->with('toast_success', 'Profile updated!')->with('_open_tab', 'settings');
}
return redirect()->route('profile')->with('toast_success', 'Profile updated!');
}
// Settings page - redirects to channel settings tab
public function settings()
{
$user = Auth::user();
return view('user.settings', compact('user'));
return redirect()->route('channel')->with('_open_tab', 'settings');
}
// Update settings (password)
@ -100,36 +127,121 @@ class UserController extends Controller
'password' => Hash::make($request->new_password),
]);
return redirect()->route('settings')->with('success', 'Password updated successfully!');
AuditLog::record('user.password_changed');
return redirect()->route('channel')->with('toast_success', 'Password updated!')->with('_open_tab', 'settings');
}
// Logout all other devices
public function logoutAllDevices(Request $request)
{
$request->validate(['password' => 'required']);
if (! Hash::check($request->password, Auth::user()->password)) {
return back()->withErrors(['logout_password' => 'Incorrect password.'])->with('_open_tab', 'settings');
}
Auth::logoutOtherDevices($request->password);
// AuthenticateSession stores a hash of the password; logoutOtherDevices rehashes it,
// so we must update the session's copy or the current session gets invalidated too.
$request->session()->put(
'password_hash_' . Auth::getDefaultDriver(),
Auth::user()->getAuthPassword()
);
AuditLog::record('user.logout_all');
return redirect()->route('channel')->with('toast_success', 'All other sessions have been logged out.')->with('_open_tab', 'settings');
}
// User's channel page - view videos
public function channel($userId = null)
public function channel($username = null)
{
if ($userId) {
$user = User::findOrFail($userId);
if ($username) {
// Look up by username slug only — never by sequential ID
$user = User::where('username', $username)->firstOrFail();
} else {
$user = Auth::user();
if (! $user) {
return redirect()->route('login');
}
$user->channel; // triggers auto-generation if missing
}
// If viewing own channel, show all videos including private
// If viewing someone else's channel, show only public videos
if (Auth::check() && Auth::user()->id === $user->id) {
$videos = Video::where('user_id', $user->id)
->latest()
->paginate(12);
$sort = request('sort', 'latest');
$isOwner = Auth::check() && Auth::user()->id === $user->id;
$preview = $isOwner && request()->boolean('preview'); // owner viewing as visitor
$isOwner = $isOwner && !$preview;
// Also get user's playlists for their own channel
$playlists = $user->playlists()->orderBy('created_at', 'desc')->get();
} else {
$videos = Video::public()
->where('user_id', $user->id)
->latest()
->paginate(12);
$playlists = null;
$baseQuery = $isOwner
? Video::where('user_id', $user->id)
: Video::public()->where('user_id', $user->id);
$allQuery = clone $baseQuery;
switch ($sort) {
case 'popular':
$baseQuery->withCount('viewers')->orderByDesc('viewers_count');
break;
case 'oldest':
$baseQuery->oldest();
break;
default:
$baseQuery->latest();
}
return view('user.channel', compact('user', 'videos', 'playlists'));
$videos = $baseQuery->where('is_shorts', false)->get();
$shorts = (clone $allQuery)->where('is_shorts', true)->latest()->get();
$playlists = $isOwner
? $user->playlists()->orderBy('created_at', 'desc')->get()
: $user->playlists()->public()->where('is_default', false)->orderBy('created_at', 'desc')->get();
$totalViews = \DB::table('video_views')
->whereIn('video_id', $user->videos()->pluck('id'))
->count();
// Filter social links by visibility for the current viewer
$viewer = Auth::user();
$isOwner = $viewer && $viewer->id === $user->id;
$isSubscriber = $viewer && !$isOwner && $viewer->isSubscribedTo($user);
$socialLinks = $user->socialLinks()
->orderBy('sort_order')
->get()
->filter(function ($link) use ($isOwner, $viewer, $isSubscriber) {
if ($isOwner) return true;
return match ($link->visibility) {
'public' => true,
'registered' => (bool) $viewer,
'subscribers' => $viewer && $isSubscriber,
'only_me' => false,
default => false,
};
});
// Posts for the Wall tab
$posts = Post::where('user_id', $user->id)
->with(['user', 'video', 'reactions', 'postImages', 'postVideos.video'])
->latest()
->get();
// Horoscope
$horoscope = Horoscope::getSign($user->birthday);
$viewerSign = null;
$compatibility = null;
if ($viewer && ! $isOwner && $viewer->birthday) {
$viewerSign = Horoscope::getSign($viewer->birthday);
$compatibility = Horoscope::compatibility($horoscope, $viewerSign);
}
$canEdit = $isOwner || (Auth::check() && Auth::user()->isSuperAdmin());
return view('user.channel', compact(
'user', 'videos', 'shorts', 'playlists', 'totalViews', 'sort',
'socialLinks', 'isSubscriber', 'isOwner', 'canEdit', 'posts', 'horoscope', 'viewerSign', 'compatibility',
'preview'
));
}
// Watch history
@ -151,13 +263,23 @@ class UserController extends Controller
->orWhere('user_id', $user->id);
})
->get()
->sortByDesc(function ($video) use ($videoIds) {
->sortBy(function ($video) use ($videoIds) {
return $videoIds->search($video->id);
});
return view('user.history', compact('videos'));
}
// Clear watch history
public function clearHistory()
{
\DB::table('video_views')
->where('user_id', Auth::id())
->delete();
return redirect()->route('history')->with('toast_success', 'Watch history cleared.');
}
// Liked videos
public function liked()
{
@ -214,4 +336,96 @@ class UserController extends Controller
'like_count' => $video->like_count,
]);
}
public function toggleSubscribe(User $user)
{
$me = Auth::user();
if ($me->id === $user->id) {
return response()->json(['error' => 'Cannot subscribe to yourself'], 422);
}
if ($me->isSubscribedTo($user)) {
$me->subscriptions()->detach($user->id);
$subscribed = false;
} else {
$me->subscriptions()->attach($user->id);
$subscribed = true;
}
return response()->json([
'subscribed' => $subscribed,
'subscriber_count' => $user->fresh()->subscriber_count,
]);
}
public function fetchNotifications()
{
$user = Auth::user();
$rawNotifications = $user->notifications()->latest()->take(50)->get();
// Bulk-fetch current video state for all notification types
$videoIds = $rawNotifications
->pluck('data.video_id')
->filter()
->unique()
->values();
$videos = \App\Models\Video::whereIn('id', $videoIds)
->whereIn('visibility', ['public', 'unlisted'])
->get(['id', 'thumbnail', 'visibility'])
->keyBy('id');
$notifications = $rawNotifications
->filter(function ($n) use ($videos) {
$videoId = $n->data['video_id'] ?? null;
return $videoId && $videos->has($videoId);
})
->take(30)
->map(function ($n) use ($videos) {
$data = $n->data;
$video = $videos->get($data['video_id']);
$data['video_thumbnail'] = $video?->thumbnail ?? null;
return [
'id' => $n->id,
'read' => ! is_null($n->read_at),
'time' => $n->created_at->diffForHumans(),
'data' => $data,
];
})
->values();
return response()->json([
'notifications' => $notifications,
'unread_count' => $user->unreadNotifications()->count(),
]);
}
public function markNotificationRead(string $id)
{
$notification = Auth::user()->notifications()->findOrFail($id);
$notification->markAsRead();
return response()->json(['ok' => true]);
}
public function markAllNotificationsRead()
{
Auth::user()->unreadNotifications->markAsRead();
return response()->json(['ok' => true]);
}
public function updateAvatar(Request $request)
{
$request->validate(['path' => 'required|string|max:300']);
Auth::user()->update(['avatar' => basename($request->path)]);
return response()->json(['ok' => true]);
}
public function updateBanner(Request $request)
{
$request->validate(['path' => 'required|string|max:300']);
Auth::user()->update(['banner' => basename($request->path)]);
return response()->json(['ok' => true]);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,7 @@ class Kernel extends HttpKernel
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,

View File

@ -12,7 +12,7 @@ class TrustProxies extends Middleware
*
* @var array<int, string>|string|null
*/
protected $proxies;
protected $proxies = '*';
/**
* The headers that should be used to detect proxies.

View File

@ -12,6 +12,8 @@ class VerifyCsrfToken extends Middleware
* @var array<int, string>
*/
protected $except = [
//
'videos/*/share',
'playlists/*/share',
'videos/*/slideshow/generate',
];
}

View File

@ -2,6 +2,7 @@
namespace App\Jobs;
use App\Models\Setting;
use App\Models\Video;
use FFMpeg\FFMpeg;
use FFMpeg\Format\Video\X264;
@ -42,17 +43,41 @@ class CompressVideoJob implements ShouldQueue
$compressedPath = storage_path('app/public/videos/' . $compressedFilename);
try {
$ffmpeg = FFMpeg::create();
$ffmpegConfig = Config::get('ffmpeg');
$ffmpeg = FFMpeg::create([
'ffmpeg.binaries' => $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg',
'ffprobe.binaries' => $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe',
'timeout' => $ffmpegConfig['timeout'] ?? 3600,
]);
$ffmpegVideo = $ffmpeg->open($originalPath);
// Use CRF 18 for high quality (lower = better quality, 18-23 is good range)
// Use 'slow' preset for better compression efficiency
// GPU NVENC encoding via config
$ffmpegConfig = Config::get('ffmpeg');
$videoPasses = $ffmpegConfig['defaults']['video'] ?? [];
$audioPasses = $ffmpegConfig['defaults']['audio'] ?? [];
$gpuEnabled = Setting::gpuEnabled();
$encoder = Setting::gpuEncoder();
$preset = Setting::gpuPreset();
$device = Setting::gpuDevice();
$format = new X264('aac', 'h264_nvenc');
if ($gpuEnabled) {
$videoPasses = [
"-c:v {$encoder}",
"-preset {$preset}",
'-rc vbr',
'-cq 23',
'-profile:v high',
'-pix_fmt yuv420p',
"-gpu {$device}",
];
} else {
$videoPasses = [
'-c:v libx264',
'-preset fast',
'-crf 23',
'-profile:v high',
'-pix_fmt yuv420p',
];
}
$audioPasses = ['-c:a aac', '-b:a 192k'];
$format = new X264('aac', $encoder);
foreach ($videoPasses as $pass) {
$format->addLegacyOption($pass);
}
@ -79,12 +104,13 @@ class CompressVideoJob implements ShouldQueue
'mime_type' => 'video/mp4',
]);
Log::info('CompressVideoJob: Video compressed with GPU NVENC', [
Log::info('CompressVideoJob: Video compressed', [
'video_id' => $video->id,
'original_size' => $originalSize,
'compressed_size' => $compressedSize,
'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%',
'encoder' => 'h264_nvenc'
'encoder' => $encoder,
'gpu' => $gpuEnabled,
]);
} else {
// Compressed file is larger, delete it

View File

@ -2,17 +2,15 @@
namespace App\Jobs;
use App\Models\Setting;
use App\Models\Video;
use FFMpeg\FFMpeg;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class GenerateHlsJob implements ShouldQueue
{
@ -44,80 +42,111 @@ class GenerateHlsJob implements ShouldQueue
$hlsDir = 'public/hls/' . $video->id;
$hlsPath = storage_path('app/' . $hlsDir);
// Clean existing HLS
if (is_dir($hlsPath)) {
Storage::deleteDirectory($hlsDir);
}
Storage::makeDirectory($hlsDir);
try {
$ffmpegConfig = Config::get('ffmpeg');
$ffmpeg = FFMpeg::create([
'ffmpeg.binaries' => $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg',
'ffprobe.binaries' => $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe',
'timeout' => $ffmpegConfig['timeout'] ?? 3600,
]);
$videoMedia = $ffmpeg->open($sourcePath);
// HLS variants: 480p, 720p, 1080p
$variants = [
[
'height' => 480,
'name' => '480p',
'bitrate' => 1000,
],
[
'height' => 720,
'name' => '720p',
'bitrate' => 2500,
],
[
'height' => 1080,
'name' => '1080p',
'bitrate' => 5000,
],
['height' => 480, 'name' => '480p', 'bitrate' => '1000k'],
['height' => 720, 'name' => '720p', 'bitrate' => '2500k'],
['height' => 1080, 'name' => '1080p', 'bitrate' => '5000k'],
];
$hlsOptions = [
'-c:v h264_nvenc',
'-preset p4',
'-g 48', // GOP size 2s @25fps
'-sc_threshold 0',
'-c:a aac',
'-ar 48000',
'-f hls',
'-hls_time 6',
'-hls_list_size 0',
'-hls_segment_filename',
'%v/%03d.ts',
'-hls_flags',
'delete_segments+append_list',
'-master_pl_name',
'playlist.m3u8',
];
$videoMedia->save(new \FFMpeg\Format\Video\X264(), $hlsPath, function ($filters) use ($variants) {
foreach ($variants as $variant) {
$filters->custom('-map 0:v:0 -map 0:a:0?')
->size("trunc(oh*a/oh/{$variant['height']})*{$variant['height']}") // Scale
->resize(new \FFMpeg\Coordinate\Dimension($variant['height'] * 16 / 9, $variant['height']))
->videoCodec($variant['bitrate'] . 'k')
->addLegacyOption('-var_stream_map v:0,name:' . $variant['name'] . ' v:1,name:720p v:2,name:1080p');
foreach ($variants as $v) {
@mkdir($hlsPath . '/' . $v['name'], 0755, true);
}
});
// Mark HLS ready
$video->update([
'has_hls' => true,
'hls_path' => $hlsDir,
try {
$ffmpegBin = \App\Models\Setting::ffmpegBinary();
$gpuEnabled = Setting::gpuEnabled();
$encoder = Setting::gpuEncoder(); // h264_nvenc / hevc_nvenc / libx264
$preset = Setting::gpuPreset(); // p1p7 for NVENC, fast/medium/slow for x264
$device = Setting::gpuDevice(); // GPU index (0, 1, …)
$hwaccel = Setting::gpuHwaccel(); // cuda / none
$cmd = [escapeshellcmd($ffmpegBin)];
// Hardware-accelerated decode when GPU is in use
if ($gpuEnabled && $hwaccel !== 'none') {
$cmd[] = "-hwaccel {$hwaccel}";
$cmd[] = "-hwaccel_device {$device}";
}
$cmd[] = '-i ' . escapeshellarg($sourcePath);
// One video+audio stream per variant
$n = count($variants);
for ($i = 0; $i < $n; $i++) {
$cmd[] = '-map 0:v:0';
$cmd[] = '-map 0:a:0?';
}
// Video codec
$cmd[] = "-c:v {$encoder}";
if ($gpuEnabled && str_contains($encoder, 'nvenc')) {
$cmd[] = "-preset {$preset}";
$cmd[] = '-rc vbr';
$cmd[] = '-cq 23';
$cmd[] = "-gpu {$device}";
} else {
$cmd[] = "-preset {$preset}";
$cmd[] = '-crf 23';
}
$cmd[] = '-pix_fmt yuv420p';
// Audio codec
$cmd[] = '-c:a aac';
$cmd[] = '-b:a 128k';
$cmd[] = '-ar 48000';
// Per-variant scale + bitrate
for ($i = 0; $i < $n; $i++) {
$cmd[] = "-filter:v:{$i} scale=-2:{$variants[$i]['height']}";
$cmd[] = "-b:v:{$i} {$variants[$i]['bitrate']}";
}
// HLS muxer options
$cmd[] = '-g 48';
$cmd[] = '-sc_threshold 0';
$cmd[] = '-f hls';
$cmd[] = '-hls_time 6';
$cmd[] = '-hls_list_size 0';
$cmd[] = '-hls_flags independent_segments';
$cmd[] = '-hls_segment_filename ' . escapeshellarg($hlsPath . '/%v/%03d.ts');
$cmd[] = '-master_pl_name playlist.m3u8';
$vsm = implode(' ', array_map(
fn ($i, $v) => "v:{$i},a:{$i},name:{$v['name']}",
array_keys($variants),
$variants
));
$cmd[] = '-var_stream_map ' . escapeshellarg($vsm);
$cmd[] = escapeshellarg($hlsPath . '/%v/index.m3u8');
$fullCmd = implode(' ', $cmd) . ' 2>&1';
Log::info('GenerateHlsJob: Starting', [
'video_id' => $video->id,
'gpu' => $gpuEnabled,
'encoder' => $encoder,
'device' => $gpuEnabled ? $device : 'cpu',
]);
Log::info('GenerateHlsJob: HLS generated successfully', [
exec($fullCmd, $output, $exitCode);
if ($exitCode !== 0) {
$tail = implode("\n", array_slice($output, -30));
throw new \RuntimeException("FFmpeg exited {$exitCode}:\n{$tail}");
}
$video->update(['has_hls' => true, 'hls_path' => $hlsDir]);
Log::info('GenerateHlsJob: HLS generated', [
'video_id' => $video->id,
'variants' => array_column($variants, 'name'),
'hls_url' => asset('storage/' . $hlsDir . '/playlist.m3u8'),
'encoder' => $encoder,
]);
} catch (\Exception $e) {
@ -126,4 +155,3 @@ class GenerateHlsJob implements ShouldQueue
}
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Mail;
use App\Models\Video;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class NewVideoNotification extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public Video $video, public User $uploader)
{
}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->uploader->name . ' just uploaded a new video on ' . config('app.name'),
);
}
public function content(): Content
{
return new Content(
view: 'emails.new-video-notification',
);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class TwoFactorDisableConfirmation extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public User $user, public string $confirmUrl)
{
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Confirm disabling Two-Factor Authentication — ' . config('app.name'),
);
}
public function content(): Content
{
return new Content(
view: 'emails.2fa-disable-confirm',
);
}
}

86
app/Models/AuditLog.php Normal file
View File

@ -0,0 +1,86 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AuditLog extends Model
{
public $timestamps = false;
protected $fillable = [
'user_id', 'user_name', 'action', 'subject_type', 'subject_id',
'subject_label', 'ip_address', 'user_agent', 'details', 'created_at',
];
protected $casts = [
'details' => 'array',
'created_at' => 'datetime',
];
public function user()
{
return $this->belongsTo(User::class);
}
public static function record(string $action, array $options = []): void
{
$request = request();
$user = auth()->user();
$ip = $request->header('CF-Connecting-IP')
?? $request->header('X-Forwarded-For')
?? $request->ip();
static::create([
'user_id' => $options['user_id'] ?? $user?->id,
'user_name' => $options['user_name'] ?? $user?->name,
'action' => $action,
'subject_type' => $options['subject_type'] ?? null,
'subject_id' => $options['subject_id'] ?? null,
'subject_label' => $options['subject_label'] ?? null,
'ip_address' => $ip,
'user_agent' => substr($request->userAgent() ?? '', 0, 255),
'details' => $options['details'] ?? null,
'created_at' => now(),
]);
}
// Severity grouping for UI color-coding
public function getSeverityAttribute(): string
{
return match(true) {
str_contains($this->action, 'delete') || str_contains($this->action, 'destroy') => 'danger',
str_contains($this->action, 'login.failed') => 'warning',
str_contains($this->action, 'login') => 'info',
str_contains($this->action, 'logout') => 'muted',
str_contains($this->action, 'admin.') => 'orange',
str_contains($this->action, 'upload') || str_contains($this->action, 'create') => 'success',
str_contains($this->action, '2fa') || str_contains($this->action, 'password') => 'purple',
default => 'default',
};
}
public function getActionLabelAttribute(): string
{
return match($this->action) {
'video.uploaded' => 'Video Uploaded',
'video.deleted' => 'Video Deleted',
'video.updated' => 'Video Updated',
'user.login' => 'Logged In',
'user.login.failed' => 'Login Failed',
'user.logout' => 'Logged Out',
'user.logout_all' => 'Logged Out All Devices',
'user.password_changed' => 'Password Changed',
'user.2fa.enabled' => '2FA Enabled',
'user.2fa.disabled' => '2FA Disabled',
'admin.impersonate' => 'Impersonated User',
'admin.impersonate.exit' => 'Exited Impersonation',
'admin.user.deleted' => 'Admin: User Deleted',
'admin.video.deleted' => 'Admin: Video Deleted',
'admin.user.updated' => 'Admin: User Updated',
'admin.video.updated' => 'Admin: Video Updated',
default => ucwords(str_replace(['.', '_'], ' ', $this->action)),
};
}
}

View File

@ -34,6 +34,11 @@ class Comment extends Model
return $this->hasMany(Comment::class, 'parent_id')->latest();
}
public function likes()
{
return $this->hasMany(CommentLike::class);
}
// Get mentioned users from comment body
public function getMentionedUsers()
{

View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CommentLike extends Model
{
public $timestamps = false;
protected $fillable = ['comment_id', 'user_id'];
public function comment()
{
return $this->belongsTo(Comment::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@ -16,6 +16,7 @@ class Playlist extends Model
'thumbnail',
'visibility',
'is_default',
'share_token',
];
protected $casts = [
@ -143,10 +144,10 @@ class Playlist extends Model
->first();
}
// Get shareable URL
// All share URLs use the unguessable token route
public function getShareUrlAttribute()
{
return route('playlists.show', $this->id);
return route('playlists.showByToken', $this->share_token);
}
// Scope for public playlists
@ -178,7 +179,12 @@ class Playlist extends Model
return $this->visibility === 'private';
}
// Check if user can view this playlist
public function isUnlisted()
{
return $this->visibility === 'unlisted';
}
// Check if user can view this playlist via the ID route
public function canView($user = null)
{
// Owner can always view
@ -186,10 +192,20 @@ class Playlist extends Model
return true;
}
// Public playlists can be viewed by anyone
// Only public playlists are accessible via the ID route
return $this->visibility === 'public';
}
// Check if user can view this playlist via the share-token route
public function canViewViaToken($user = null)
{
if ($user && $this->user_id === $user->id) {
return true;
}
return in_array($this->visibility, ['public', 'unlisted']);
}
// Check if user can edit this playlist
public function canEdit($user = null)
{

51
app/Models/Post.php Normal file
View File

@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
protected $fillable = ['user_id', 'body', 'video_id', 'image'];
public function user()
{
return $this->belongsTo(User::class);
}
public function video()
{
return $this->belongsTo(Video::class)->withDefault();
}
public function reactions()
{
return $this->hasMany(PostReaction::class);
}
public function postImages()
{
return $this->hasMany(PostImage::class)->orderBy('sort_order');
}
public function postVideos()
{
return $this->hasMany(PostVideo::class)->with('video')->orderBy('sort_order');
}
public function isLikedBy(?User $user): bool
{
if (! $user) return false;
return $this->reactions()->where('user_id', $user->id)->exists();
}
public function getReactionCountAttribute(): int
{
return $this->reactions()->count();
}
public function getImageUrlAttribute(): ?string
{
return $this->image ? asset('storage/post_images/' . $this->image) : null;
}
}

20
app/Models/PostImage.php Normal file
View File

@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PostImage extends Model
{
protected $fillable = ['post_id', 'filename', 'sort_order'];
public function post()
{
return $this->belongsTo(Post::class);
}
public function getImageUrlAttribute(): string
{
return asset('storage/post_images/' . $this->filename);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PostReaction extends Model
{
protected $fillable = ['user_id', 'post_id', 'type'];
public function user()
{
return $this->belongsTo(User::class);
}
public function post()
{
return $this->belongsTo(Post::class);
}
}

20
app/Models/PostVideo.php Normal file
View File

@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PostVideo extends Model
{
protected $fillable = ['post_id', 'video_id', 'sort_order'];
public function post()
{
return $this->belongsTo(Post::class);
}
public function video()
{
return $this->belongsTo(Video::class)->withDefault();
}
}

92
app/Models/Setting.php Normal file
View File

@ -0,0 +1,92 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
protected $fillable = ['key', 'value'];
private static array $cache = [];
public static function get(string $key, mixed $default = null): mixed
{
if (! array_key_exists($key, self::$cache)) {
$row = static::where('key', $key)->first();
self::$cache[$key] = $row ? $row->value : $default;
}
return self::$cache[$key];
}
public static function set(string $key, mixed $value): void
{
static::updateOrCreate(['key' => $key], ['value' => (string) $value]);
self::$cache[$key] = (string) $value;
}
/** Returns the configured FFmpeg binary path, falling back to the config file default. */
public static function ffmpegBinary(): string
{
return static::get('ffmpeg_binary', config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg'));
}
public static function gpuEnabled(): bool
{
return static::get('gpu_enabled', 'true') === 'true';
}
public static function gpuDevice(): int
{
return (int) static::get('gpu_device', '0');
}
public static function gpuEncoder(): string
{
return static::gpuEnabled()
? static::get('gpu_encoder', 'h264_nvenc')
: 'libx264';
}
public static function gpuPreset(): string
{
return static::gpuEnabled()
? static::get('gpu_preset', 'p4')
: 'fast';
}
public static function gpuHwaccel(): string
{
return static::get('gpu_hwaccel', 'cuda');
}
/** Returns the full video codec flags for FFmpeg shell commands. */
public static function ffmpegVideoFlags(bool $stillImage = false): string
{
if (static::gpuEnabled()) {
$enc = static::get('gpu_encoder', 'h264_nvenc');
$preset = static::get('gpu_preset', 'p4');
$device = static::gpuDevice();
$gpuFlag = str_contains($enc, 'nvenc') ? " -gpu {$device}" : '';
return "-c:v {$enc} -preset {$preset} -rc vbr -cq 23{$gpuFlag} -pix_fmt yuv420p";
}
$tune = $stillImage ? ' -tune stillimage' : '';
return "-c:v libx264 -preset fast -crf 23{$tune} -pix_fmt yuv420p";
}
/** CPU-only video codec flags — used as automatic fallback when GPU encoding fails. */
public static function ffmpegVideoFlagsCpu(bool $stillImage = false): string
{
$tune = $stillImage ? ' -tune stillimage' : '';
return "-c:v libx264 -preset fast -crf 23{$tune} -pix_fmt yuv420p";
}
/** Returns hwaccel decode flags when the input source is a video file. */
public static function ffmpegHwaccelFlags(bool $inputIsVideo): string
{
if (! $inputIsVideo || ! static::gpuEnabled()) return '';
$hwaccel = static::get('gpu_hwaccel', 'cuda');
$device = static::gpuDevice();
return "-hwaccel {$hwaccel} -hwaccel_device {$device} ";
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ShareAccess extends Model
{
public $timestamps = false;
protected $guarded = [];
public function share()
{
return $this->belongsTo(VideoShare::class, 'share_id');
}
}

View File

@ -2,13 +2,15 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Notifications\VerifyEmail;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
class User extends Authenticatable implements MustVerifyEmail
{
use HasApiTokens, HasFactory, Notifiable;
@ -29,6 +31,18 @@ class User extends Authenticatable
'tiktok',
'birthday',
'location',
'gender',
'nationality',
'phone_code',
'phone_number',
'timezone',
'whatsapp',
'google_location',
'social_phone',
'social_email',
'two_factor_secret',
'two_factor_enabled',
'banner',
];
protected $hidden = [
@ -39,8 +53,45 @@ class User extends Authenticatable
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_enabled' => 'boolean',
];
protected $appends = ['avatar_url', 'banner_url'];
// Auto-generate a unique slug-based username when creating a user without one
protected static function boot(): void
{
parent::boot();
static::creating(function (User $user) {
if (empty($user->username)) {
$user->username = static::generateUniqueUsername($user->name);
}
});
}
public static function generateUniqueUsername(string $name): string
{
$base = substr(Str::slug(trim($name)), 0, 18);
if ($base === '') {
$base = 'user';
}
do {
$slug = $base . '-' . Str::lower(Str::random(6));
} while (static::where('username', $slug)->exists());
return $slug;
}
// Returns the channel URL slug — the username, auto-generated and saved if missing
public function getChannelAttribute(): string
{
if (empty($this->username)) {
$this->username = static::generateUniqueUsername($this->name);
$this->saveQuietly();
}
return $this->username;
}
// Relationships
public function videos()
{
@ -67,6 +118,11 @@ class User extends Authenticatable
return $this->hasMany(Playlist::class);
}
public function posts()
{
return $this->hasMany(\App\Models\Post::class);
}
public function getAvatarUrlAttribute()
{
if ($this->avatar) {
@ -76,6 +132,19 @@ class User extends Authenticatable
return 'https://i.pravatar.cc/150?u='.$this->id;
}
public function getBannerUrlAttribute(): ?string
{
if ($this->banner) {
return asset('storage/banners/'.$this->banner);
}
return null;
}
public function sendEmailVerificationNotification(): void
{
$this->notify(new VerifyEmail);
}
// Role helper methods
public function isSuperAdmin()
{
@ -92,31 +161,47 @@ class User extends Authenticatable
return $this->role === 'user' || $this->role === null;
}
// Placeholder for subscriber count (would need a separate table in full implementation)
public function getSubscriberCountAttribute()
// Users who subscribe TO this channel
public function subscribers()
{
// For now, return a placeholder - in production this would come from a subscriptions table
return rand(100, 10000);
return $this->belongsToMany(
User::class,
'user_subscriptions',
'channel_id',
'subscriber_id'
)->withPivot('created_at');
}
// Get social links as an array
public function getSocialLinksAttribute()
// Channels this user subscribes to
public function subscriptions()
{
return [
'twitter' => $this->twitter,
'instagram' => $this->instagram,
'facebook' => $this->facebook,
'youtube' => $this->youtube,
'linkedin' => $this->linkedin,
'tiktok' => $this->tiktok,
];
return $this->belongsToMany(
User::class,
'user_subscriptions',
'subscriber_id',
'channel_id'
)->withPivot('created_at');
}
public function isSubscribedTo(User $channel): bool
{
return $this->subscriptions()->where('channel_id', $channel->id)->exists();
}
public function getSubscriberCountAttribute(): int
{
return $this->subscribers()->count();
}
// Check if user has any social links
public function socialLinks()
{
return $this->hasMany(UserSocialLink::class)->orderBy('sort_order');
}
public function hasSocialLinks()
{
return $this->twitter || $this->instagram || $this->facebook ||
$this->youtube || $this->linkedin || $this->tiktok;
return $this->socialLinks()->exists();
}
// Get formatted website URL

View File

@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class UserSocialLink extends Model
{
protected $fillable = ['user_id', 'platform', 'value', 'visibility', 'sort_order'];
const VISIBILITIES = ['public', 'registered', 'subscribers', 'only_me'];
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@ -25,6 +25,11 @@ class Video extends Model
'is_shorts',
'has_hls',
'hls_path',
'download_access',
'download_count',
'share_count',
'share_token',
'slideshow_video_path',
];
protected $casts = [
@ -54,6 +59,16 @@ class Video extends Model
->withTimestamps();
}
public function slides()
{
return $this->hasMany(VideoSlide::class)->orderBy('position');
}
public function hasSlideshow(): bool
{
return $this->slides()->count() > 1;
}
// Accessors
public function getUrlAttribute()
{
@ -92,10 +107,84 @@ class Video extends Model
return \DB::table('video_views')->where('video_id', $this->id)->count();
}
// Get shareable URL for the video
// ── Short opaque URL encoding ─────────────────────────────────────
// 3-round Feistel cipher over 30-bit space → base62.
// Consecutive IDs produce completely different output. Pure PHP, no extensions.
private const URL_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
private static function feistelRound(int $n, int $round): int
{
// Deterministic pseudo-random function per round (no extension needed)
$keys = [0x2D4F8A1B, 0x7C3E6912, 0xA5B2D083];
return (int)(($n * $keys[$round % 3] + ($round * 0x9E3779B9)) & 0x7FFF);
}
public static function encodeId(int $id): string
{
// Split 30-bit id into two 15-bit halves
$L = ($id >> 15) & 0x7FFF;
$R = $id & 0x7FFF;
// 3 Feistel rounds
for ($i = 0; $i < 3; $i++) {
$tmp = $L ^ self::feistelRound($R, $i);
$L = $R;
$R = $tmp;
}
$n = (($L & 0x7FFF) << 15) | ($R & 0x7FFF);
$base = strlen(self::URL_ALPHABET);
$out = '';
do {
$out = self::URL_ALPHABET[$n % $base] . $out;
$n = intdiv($n, $base);
} while ($n > 0);
// Pad to fixed length so all URLs look uniform
return str_pad($out, 5, '0', STR_PAD_LEFT);
}
public static function decodeId(string $encoded): ?int
{
$base = strlen(self::URL_ALPHABET);
$n = 0;
for ($i = 0, $len = strlen($encoded); $i < $len; $i++) {
$pos = strpos(self::URL_ALPHABET, $encoded[$i]);
if ($pos === false) return null;
$n = $n * $base + $pos;
}
// Reverse Feistel
$L = ($n >> 15) & 0x7FFF;
$R = $n & 0x7FFF;
for ($i = 2; $i >= 0; $i--) {
$tmp = $R ^ self::feistelRound($L, $i);
$R = $L;
$L = $tmp;
}
return (($L & 0x7FFF) << 15) | ($R & 0x7FFF);
}
// Route model binding — use encoded ID in URLs
public function getRouteKey()
{
return self::encodeId($this->id);
}
public function getRouteKeyName()
{
return 'id';
}
public function resolveRouteBinding($value, $field = null)
{
$realId = self::decodeId((string) $value);
if ($realId === null || $realId <= 0) {
abort(404);
}
return $this->where('id', $realId)->firstOrFail();
}
// All share URLs use the unguessable token route
public function getShareUrlAttribute()
{
return route('videos.show', $this->id);
return route('videos.showByToken', $this->share_token);
}
// Get formatted duration (e.g., "1:30" or "0:45" for shorts)
@ -213,6 +302,12 @@ class Video extends Model
return $this->type === 'match';
}
public function isAudioOnly(): bool
{
$ext = strtolower(pathinfo($this->filename ?? '', PATHINFO_EXTENSION));
return in_array($ext, ['mp3', 'm4a', 'aac', 'flac', 'wav']);
}
// Shorts helpers
public function isShorts()
{
@ -331,13 +426,13 @@ class Video extends Model
// Get video stream URL for Open Graph
public function getStreamUrlAttribute()
{
return route('videos.stream', $this->id);
return route('videos.stream', $this);
}
// Get secure share URL
public function getSecureShareUrlAttribute()
{
return secure_url(route('videos.show', $this->id));
return secure_url(route('videos.show', $this));
}
// Get secure thumbnail URL

26
app/Models/VideoShare.php Normal file
View File

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VideoShare extends Model
{
public $timestamps = false;
protected $guarded = [];
public function video()
{
return $this->belongsTo(Video::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function accesses()
{
return $this->hasMany(ShareAccess::class, 'share_id');
}
}

20
app/Models/VideoSlide.php Normal file
View File

@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VideoSlide extends Model
{
protected $fillable = ['video_id', 'filename', 'position'];
public function video()
{
return $this->belongsTo(Video::class);
}
public function getUrlAttribute(): string
{
return asset('storage/thumbnails/' . $this->filename);
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Notifications;
use App\Models\Comment;
use App\Models\User;
use App\Models\Video;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
class NewCommentLikeNotification extends Notification
{
public function __construct(
public Video $video,
public Comment $comment,
public User $liker
) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
return [
'type' => 'comment_like',
'video_id' => $this->video->id,
'video_route_key' => $this->video->getRouteKey(),
'video_title' => $this->video->title,
'video_thumbnail' => $this->video->thumbnail,
'actor_id' => $this->liker->id,
'actor_name' => $this->liker->name,
'actor_avatar' => $this->liker->avatar_url,
'comment_preview' => Str::limit($this->comment->body, 80),
];
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Notifications;
use App\Models\Comment;
use App\Models\User;
use App\Models\Video;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
class NewCommentNotification extends Notification
{
public function __construct(
public Video $video,
public Comment $comment,
public User $commenter
) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
return [
'type' => 'new_comment',
'video_id' => $this->video->id,
'video_route_key' => $this->video->getRouteKey(),
'video_title' => $this->video->title,
'video_thumbnail' => $this->video->thumbnail,
'actor_id' => $this->commenter->id,
'actor_name' => $this->commenter->name,
'actor_avatar' => $this->commenter->avatar_url,
'comment_preview' => Str::limit($this->comment->body, 80),
];
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Notifications;
use App\Models\Comment;
use App\Models\User;
use App\Models\Video;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
class NewReplyNotification extends Notification
{
public function __construct(
public Video $video,
public Comment $reply,
public User $replier
) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
return [
'type' => 'new_reply',
'video_id' => $this->video->id,
'video_route_key' => $this->video->getRouteKey(),
'video_title' => $this->video->title,
'video_thumbnail' => $this->video->thumbnail,
'actor_id' => $this->replier->id,
'actor_name' => $this->replier->name,
'actor_avatar' => $this->replier->avatar_url,
'comment_preview' => Str::limit($this->reply->body, 80),
];
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Notifications;
use App\Models\Video;
use App\Models\User;
use Illuminate\Notifications\Notification;
class NewVideoUploaded extends Notification
{
public function __construct(public Video $video, public User $uploader)
{
}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
return [
'type' => 'new_video',
'video_id' => $this->video->id,
'video_route_key' => $this->video->getRouteKey(),
'video_title' => $this->video->title,
'video_thumbnail' => $this->video->thumbnail,
'uploader_id' => $this->uploader->id,
'uploader_name' => $this->uploader->name,
'uploader_avatar' => $this->uploader->avatar_url,
];
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Notifications;
use Illuminate\Auth\Notifications\VerifyEmail as BaseVerifyEmail;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\URL;
class VerifyEmail extends BaseVerifyEmail
{
use Queueable;
public function toMail($notifiable): MailMessage
{
$url = $this->verificationUrl($notifiable);
return (new MailMessage)
->subject('Verify your email address — ' . config('app.name'))
->view('emails.verify-email', [
'url' => $url,
'userName' => $notifiable->name,
]);
}
protected function verificationUrl($notifiable): string
{
return URL::temporarySignedRoute(
'verification.verify',
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
[
'id' => $notifiable->getKey(),
'hash' => sha1($notifiable->getEmailForVerification()),
]
);
}
}

View File

@ -5,6 +5,7 @@ namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Request;
use Illuminate\Pagination\Paginator;
class AppServiceProvider extends ServiceProvider
{
@ -27,5 +28,9 @@ class AppServiceProvider extends ServiceProvider
// Force HTTPS
URL::forceScheme('https');
// Universal pagination view — used everywhere by default
Paginator::defaultView('partials.pagination');
Paginator::defaultSimpleView('partials.pagination');
}
}

View File

@ -0,0 +1,141 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class NotDisposableEmail implements ValidationRule
{
// Top disposable/throwaway email domains
private const BLOCKED = [
'0-mail.com','0815.ru','0clickemail.com','0wnd.net','0wnd.org','10minutemail.com',
'10minutemail.net','10minutemail.org','10minutemail.de','10mail.org','20minutemail.com',
'2prong.com','30minutemail.com','33mail.com','3d-painting.com','4warding.com',
'amilegit.com','anonbox.net','anonymail.net','anonymbox.com','antichef.com',
'antichef.net','antireg.com','antispam.de','antispammail.de','armyspy.com',
'baxomale.ht.cx','beefmilk.com','bigstring.com','binkmail.com','bio-muesli.net',
'bobmail.info','bodhi.lawlita.com','bofthew.com','bootybay.de','bossmail.de',
'bouncr.com','breakthru.com','brefmail.com','broadbandninja.com','bsnow.net',
'bugmenot.com','bumpymail.com','casualdx.com','centermail.com','centermail.net',
'chacuo.net','chammy.info','childsavetrust.org','chogmail.com','choplife.com',
'clixser.com','cmail.net','coiico.com','cool.fr.nf','correo.blogos.net',
'crapmail.org','crossroadsmail.com','curryworld.de','cust.in','dacoolest.com',
'dandikmail.com','dayrep.com','dcemail.com','deadaddress.com','deadletter.ga',
'deagot.com','dealja.com','despam.it','devnullmail.com','dfgh.net',
'digitalsanctuary.com','discardmail.com','discardmail.de','disposableaddress.com',
'disposableinbox.com','disposablemail.net','disposablemail.org','disposablemail.top',
'dispostable.com','dk4f9h7y.fun','dodgeit.com','dodgit.com','donemail.ru',
'dontreg.com','dontsendmespam.de','drdrb.com','drdrb.net','dropmail.me',
'dumpandfuck.com','dumpmail.de','dumpyemail.com','e4ward.com','easytrashmail.com',
'email60.com','emailfake.com','emailias.com','emailigo.com','emailinfive.com',
'emailisvalid.com','emaillime.com','emailmiser.com','emailna.com','emailnax.com',
'emailnew.com','emailo.pro','emailondeck.com','emailthe.net','emailtmp.com',
'emailwarden.com','emailx.at.hm','emailxfer.com','emeil.in','emeil.ir',
'emlhub.com','emltmp.com','enterto.com','ephemail.net','etranquil.com',
'evopo.com','explodemail.com','express.net.ua','eyepaste.com','fakeinbox.com',
'fakeinformation.com','fakemail.fr','fakemailgenerator.com','fakemail.net',
'fastacura.com','fastchevy.com','fastchrysler.com','fastkawasaki.com','fastmazda.com',
'fastmitsubishi.com','fastnissan.com','fastsubaru.com','fastsuzuki.com','fasttoyota.com',
'fastyamaha.com','fatflap.com','fdcserver.net','fightallspam.com','filzmail.com',
'fixmail.tk','fizmail.com','fleckens.hu','flemail.com','frapmail.com',
'friendlymail.co.uk','fuckingduh.com','fudgerub.com','fux0ringduh.com','fyxm.net',
'garliclife.com','gehensiemirnichtaufdensack.de','gelitik.in','get2mail.fr',
'getairmail.com','getonemail.com','giantmail.de','girlsundertheinfluence.com',
'gishpuppy.com','gmailnew.com','goemailgo.com','gorillaswithdirtyarmpits.com',
'gotmail.net','gotmail.org','gowikibooks.com','gowikicampus.com','gowikicars.com',
'gowikifilms.com','gowikigames.com','gowikimusic.com','gowikinetwork.com',
'gowikitravel.com','grr.la','guerillamail.biz','guerillamail.com','guerillamail.de',
'guerillamail.info','guerillamail.net','guerillamail.org','guerrillamail.biz',
'guerrillamail.com','guerrillamail.de','guerrillamail.info','guerrillamail.net',
'guerrillamail.org','guerrillamailblock.com','gustr.com','h.mintemail.com',
'haltospam.com','haqed.com','harakirimail.com','hat-geld.de','hatespam.org',
'herp.in','hidemail.de','high.net','hmamail.com','hopemail.biz',
'hulapla.de','ieatspam.eu','ieatspam.info','ieh-mail.de','ihateyoualot.info',
'iheartspam.org','ikbenspamvrij.nl','imails.info','inboxclean.com','inboxclean.org',
'incognitomail.net','incognitomail.org','insorg.org','internet-e-mail.de','jetable.fr',
'jetable.net','jetable.org','jnxjn.com','joggly.com','junk.to',
'justamail.net','kasmail.com','kaspop.com','killmail.com','killmail.net',
'klzlk.com','koszmail.pl','kurzepost.de','laoeq.com','lavabit.com',
'letthemeatspam.com','lhsdv.com','lifebyfood.com','lol.ovpn.to','lookugly.com',
'lopl.co.cc','lortemail.dk','lovemeleaveme.com','lr78.com','lukop.dk',
'm4ilweb.info','maboard.com','mail-filter.com','mail-temporaire.com','mail.by',
'mail1a.de','mail2rss.org','mail333.com','mailbidon.com','mailblocks.com',
'mailbucket.org','mailchop.com','maileater.com','maileimer.de','mailexpire.com',
'mailfa.tk','mailforspam.com','mailfree.ga','mailguard.me','mailimate.com',
'mailinatar.com','mailinater.com','mailinator.com','mailinator.net','mailinator.org',
'mailinator.us','mailinator2.com','mailincubator.com','mailismagic.com','mailjunk.cf',
'mailjunk.ga','mailjunk.gq','mailjunk.ml','mailjunk.tk','mailme.gq',
'mailme.ir','mailme24.com','mailmetrash.com','mailmoat.com','mailms.com',
'mailnull.com','mailpick.biz','mailproxsy.com','mailquack.com','mailrock.biz',
'mailscrap.com','mailseal.de','mailshell.com','mailsiphon.com','mailslapping.com',
'mailslite.com','mailsoul.com','mailsucker.net','mailtemp.info','mailtome.de',
'mailtothis.com','mailtrash.net','mailtv.net','mailzilla.com','makemetheking.com',
'mbx.cc','mega.zik.dj','meinspamschutz.de','meltmail.com','messagebeamer.de',
'mezimages.net','mintemail.com','moncourrier.fr.nf','monemail.fr.nf','monmail.fr.nf',
'msa.minsmail.com','msgos.com','mt2014.com','mt2015.com','mucincanon.com',
'mucke.de','mugglenet.com','myfastmail.com','mymailoasis.com','mynetstore.de',
'netzidiot.de','neverbox.com','nice-4u.com','nincsmail.hu','nmail.cf',
'nnot.net','nobulk.com','noclickemail.com','nodezine.com','nomail.pw',
'nomail.xl.cx','nomail2me.com','nomoremail.net','nospam.ze.tc','nospam4.us',
'nospamfor.us','nospammail.net','nospamthanks.info','notmailinator.com','nowmymail.com',
'odnorazovoe.ru','onewaymail.com','online.ms','opayq.com','ordinaryamerican.net',
'ovpn.to','owlpic.com','pecinan.com','pecinan.net','pecinan.org',
'pepbot.com','pfui.ru','pimpedupmyspace.com','plexolan.de','pookmail.com',
'privacy.net','proxymail.eu','prtnx.com','purelymail.com','putthisinyourspamdatabase.com',
'qq.com.de','quickinbox.com','rcpt.at','recode.me','recursor.net',
'regbypass.com','rklips.com','rmqkr.net','rppkn.com','rtrtr.com',
'runbox.com','s0ny.net','safe-mail.net','safetymail.info','safetypost.de',
'sandelf.de','saynotospams.com','schafmail.de','schrott-email.de','secretemail.de',
'secure-mail.biz','sexyalwasmi.top','sharedmailbox.org','sharklasers.com','shieldedmail.com',
'shiftmail.com','shitmail.de','shitmail.me','shitmail.org','shitware.nl',
'shortmail.net','sibmail.com','skeefmail.com','slaskpost.se','slopsbox.com',
'smellfear.com','snakemail.com','sneakemail.com','snkmail.com','sofimail.com',
'sogetthis.com','sohu.com.de','soisz.com','spam.la','spam.org.tr',
'spam.su','spam4.me','spamavert.com','spambob.com','spambob.net',
'spambob.org','spambog.com','spambog.de','spambog.ru','spambox.info',
'spambox.us','spamcannon.com','spamcannon.net','spamcero.com','spamcon.org',
'spamcorptastic.com','spamcowboy.com','spamcowboy.net','spamcowboy.org','spamday.com',
'spamex.com','spamfree.eu','spamfree24.de','spamfree24.eu','spamfree24.info',
'spamfree24.net','spamfree24.org','spamgourmet.com','spamgourmet.net','spamgourmet.org',
'spamherelots.com','spamhereplease.com','spamhole.com','spamify.com','spaminator.de',
'spamkill.info','spaml.com','spaml.de','spammotel.com','spamoff.de',
'spamsalad.in','spamslicer.com','spamspot.com','spamstack.net','spamthis.co.uk',
'spamthisplease.com','spamtrail.com','spamtroll.net','speed.1s.fr','speedymail.net',
'spoofmail.de','super-auswahl.de','supergreatmail.com','supermailer.jp','superrito.com',
'superstachel.de','suremail.info','sweetxxx.de','tafmail.com','tagyourself.com',
'teleworm.com','teleworm.us','temp-mail.io','temp-mail.org','temp-mail.ru',
'temp.bartbot.com','tempail.com','tempalias.com','tempe-mail.com','tempemailaddress.com',
'tempinbox.co.uk','tempinbox.com','tempmail.it','tempmail.net','tempmail.us',
'tempmail2.com','tempomail.fr','temporaryemail.net','temporaryemail.us','temporaryforwarding.com',
'temporaryinbox.com','temporarymail.org','tempsky.com','thankyou2010.com','thc.st',
'thelimestones.com','thisisnotmyrealemail.com','throam.com','throwam.com','throwaway.email',
'throwam.com','throwaway.email','throwam.com','tilien.com','tinyurl24.com',
'tmailinator.com','toiea.com','toomail.biz','top9top.com','tradermail.info',
'trash-amil.com','trash-mail.at','trash-mail.com','trash-mail.de','trash-mail.io',
'trash-mail.net','trash2009.com','trash2010.com','trash2011.com','trashcanmail.com',
'trashdevil.com','trashdevil.de','trashemail.de','trashimail.com','trashmail.at',
'trashmail.com','trashmail.es','trashmail.io','trashmail.me','trashmail.net',
'trashmail.org','trashmailer.com','trashpanda.de','trashymail.com','trillianpro.com',
'turual.com','twinmail.de','tyldd.com','uggsrock.com','upliftnow.com',
'uroid.com','username.e4ward.com','venompen.com','veryrealemail.com','vidchart.com',
'viditag.com','viewcastmedia.com','viewcastmedia.net','viewcastmedia.org','vomoto.com',
'vubby.com','walala.org','watch-harry-potter.com','wetrainbayarea.com','wetrainbayarea.org',
'wilemail.com','willhackforfood.biz','willselfdestruct.com','wmail.cf','wolfsmail.tk',
'wuzupmail.net','xagloo.com','xemaps.com','xents.com','xMailer.net',
'xmaily.com','xn--9kq967o.com','xoxox.cc','xperiae5.com','xyzfree.net',
'yahomail.org','yapped.net','yepmail.net','yourdomain.com','yopmail.com',
'yopmail.fr','yopmail.net','youremail.cf','yourewronghello.com','z1p.biz',
'za.com','zippymail.info','zoemail.net','zoemail.org','zomg.info',
'zxcv.com','zxcvbnm.com','zzz.com',
];
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$domain = strtolower(trim(substr(strrchr($value, '@'), 1)));
if (in_array($domain, self::BLOCKED, true)) {
$fail('Registrations from disposable email addresses are not allowed.');
}
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GeoIpService
{
private static array $privateRanges = [
'127.', '::1', '10.', '172.16.', '172.17.', '172.18.', '172.19.',
'172.20.', '172.21.', '172.22.', '172.23.', '172.24.', '172.25.',
'172.26.', '172.27.', '172.28.', '172.29.', '172.30.', '172.31.',
'192.168.',
];
public static function lookup(string $ip): array
{
foreach (self::$privateRanges as $prefix) {
if (str_starts_with($ip, $prefix)) {
return ['country' => null, 'country_name' => null];
}
}
return Cache::remember('geoip_' . md5($ip), 86400, function () use ($ip) {
try {
$response = Http::timeout(3)->get(
"http://ip-api.com/json/{$ip}",
['fields' => 'status,country,countryCode']
);
if ($response->successful() && $response->json('status') === 'success') {
return [
'country' => $response->json('countryCode'),
'country_name' => $response->json('country'),
];
}
} catch (\Throwable $e) {
Log::debug('GeoIP lookup failed for ' . $ip . ': ' . $e->getMessage());
}
return ['country' => null, 'country_name' => null];
});
}
}

View File

@ -6,11 +6,15 @@
"license": "MIT",
"require": {
"php": "^8.1",
"bacon/bacon-qr-code": "^3.1",
"doctrine/dbal": "^3.0",
"guzzlehttp/guzzle": "^7.2",
"laravel/framework": "^10.10",
"laravel/sanctum": "^3.3",
"laravel/tinker": "^2.8",
"php-ffmpeg/php-ffmpeg": "^1.4"
"p7h/nas-file-manager": "dev-main",
"php-ffmpeg/php-ffmpeg": "^1.4",
"pragmarx/google2fa-laravel": "^3.0"
},
"require-dev": {
"fakerphp/faker": "^1.9.1",
@ -62,6 +66,12 @@
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"repositories": [
{
"type": "vcs",
"url": "git@github.com:itsp7h/File-Structure-package.git"
}
],
"minimum-stability": "dev",
"prefer-stable": true
}

672
composer.lock generated
View File

@ -4,8 +4,63 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "1e4c8a43b8df70dffbd0107a8308b2ec",
"content-hash": "40fa327b55e9b6fafab4b2da3f763724",
"packages": [
{
"name": "bacon/bacon-qr-code",
"version": "v3.1.1",
"source": {
"type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git",
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
"shasum": ""
},
"require": {
"dasprid/enum": "^1.0.3",
"ext-iconv": "*",
"php": "^8.1"
},
"require-dev": {
"phly/keep-a-changelog": "^2.12",
"phpunit/phpunit": "^10.5.11 || ^11.0.4",
"spatie/phpunit-snapshot-assertions": "^5.1.5",
"spatie/pixelmatch-php": "^1.2.0",
"squizlabs/php_codesniffer": "^3.9"
},
"suggest": {
"ext-imagick": "to generate QR code images"
},
"type": "library",
"autoload": {
"psr-4": {
"BaconQrCode\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "BaconQrCode is a QR code generator for PHP.",
"homepage": "https://github.com/Bacon/BaconQrCode",
"support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues",
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1"
},
"time": "2026-04-05T21:06:35+00:00"
},
{
"name": "brick/math",
"version": "0.12.3",
@ -135,6 +190,56 @@
],
"time": "2023-12-11T17:09:12+00:00"
},
{
"name": "dasprid/enum",
"version": "1.0.7",
"source": {
"type": "git",
"url": "https://github.com/DASPRiD/Enum.git",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"shasum": ""
},
"require": {
"php": ">=7.1 <9.0"
},
"require-dev": {
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "*"
},
"type": "library",
"autoload": {
"psr-4": {
"DASPRiD\\Enum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "PHP 7.1 enum implementation",
"keywords": [
"enum",
"map"
],
"support": {
"issues": "https://github.com/DASPRiD/Enum/issues",
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
},
"time": "2025-09-16T12:23:56+00:00"
},
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
@ -210,6 +315,259 @@
},
"time": "2024-07-08T12:26:09+00:00"
},
{
"name": "doctrine/dbal",
"version": "3.10.5",
"source": {
"type": "git",
"url": "https://github.com/doctrine/dbal.git",
"reference": "95d84866bf3c04b2ddca1df7c049714660959aef"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/dbal/zipball/95d84866bf3c04b2ddca1df7c049714660959aef",
"reference": "95d84866bf3c04b2ddca1df7c049714660959aef",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2",
"doctrine/deprecations": "^0.5.3|^1",
"doctrine/event-manager": "^1|^2",
"php": "^7.4 || ^8.0",
"psr/cache": "^1|^2|^3",
"psr/log": "^1|^2|^3"
},
"conflict": {
"doctrine/cache": "< 1.11"
},
"require-dev": {
"doctrine/cache": "^1.11|^2.0",
"doctrine/coding-standard": "14.0.0",
"fig/log-test": "^1",
"jetbrains/phpstorm-stubs": "2023.1",
"phpstan/phpstan": "2.1.30",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "9.6.34",
"slevomat/coding-standard": "8.27.1",
"squizlabs/php_codesniffer": "4.0.1",
"symfony/cache": "^5.4|^6.0|^7.0|^8.0",
"symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0"
},
"suggest": {
"symfony/console": "For helpful console commands such as SQL execution and import of files."
},
"bin": [
"bin/doctrine-dbal"
],
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\DBAL\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Guilherme Blanco",
"email": "guilhermeblanco@gmail.com"
},
{
"name": "Roman Borschel",
"email": "roman@code-factory.org"
},
{
"name": "Benjamin Eberlei",
"email": "kontakt@beberlei.de"
},
{
"name": "Jonathan Wage",
"email": "jonwage@gmail.com"
}
],
"description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.",
"homepage": "https://www.doctrine-project.org/projects/dbal.html",
"keywords": [
"abstraction",
"database",
"db2",
"dbal",
"mariadb",
"mssql",
"mysql",
"oci8",
"oracle",
"pdo",
"pgsql",
"postgresql",
"queryobject",
"sasql",
"sql",
"sqlite",
"sqlserver",
"sqlsrv"
],
"support": {
"issues": "https://github.com/doctrine/dbal/issues",
"source": "https://github.com/doctrine/dbal/tree/3.10.5"
},
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
"type": "custom"
},
{
"url": "https://www.patreon.com/phpdoctrine",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal",
"type": "tidelift"
}
],
"time": "2026-02-24T08:03:57+00:00"
},
{
"name": "doctrine/deprecations",
"version": "1.1.6",
"source": {
"type": "git",
"url": "https://github.com/doctrine/deprecations.git",
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"conflict": {
"phpunit/phpunit": "<=7.5 || >=14"
},
"require-dev": {
"doctrine/coding-standard": "^9 || ^12 || ^14",
"phpstan/phpstan": "1.4.10 || 2.1.30",
"phpstan/phpstan-phpunit": "^1.0 || ^2",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
"psr/log": "^1 || ^2 || ^3"
},
"suggest": {
"psr/log": "Allows logging deprecations via PSR-3 logger implementation"
},
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\Deprecations\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
"homepage": "https://www.doctrine-project.org/",
"support": {
"issues": "https://github.com/doctrine/deprecations/issues",
"source": "https://github.com/doctrine/deprecations/tree/1.1.6"
},
"time": "2026-02-07T07:09:04+00:00"
},
{
"name": "doctrine/event-manager",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/doctrine/event-manager.git",
"reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf",
"reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"conflict": {
"doctrine/common": "<2.9"
},
"require-dev": {
"doctrine/coding-standard": "^14",
"phpdocumentor/guides-cli": "^1.4",
"phpstan/phpstan": "^2.1.32",
"phpunit/phpunit": "^10.5.58"
},
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\Common\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Guilherme Blanco",
"email": "guilhermeblanco@gmail.com"
},
{
"name": "Roman Borschel",
"email": "roman@code-factory.org"
},
{
"name": "Benjamin Eberlei",
"email": "kontakt@beberlei.de"
},
{
"name": "Jonathan Wage",
"email": "jonwage@gmail.com"
},
{
"name": "Johannes Schmitt",
"email": "schmittjoh@gmail.com"
},
{
"name": "Marco Pivetta",
"email": "ocramius@gmail.com"
}
],
"description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.",
"homepage": "https://www.doctrine-project.org/projects/event-manager.html",
"keywords": [
"event",
"event dispatcher",
"event manager",
"event system",
"events"
],
"support": {
"issues": "https://github.com/doctrine/event-manager/issues",
"source": "https://github.com/doctrine/event-manager/tree/2.1.1"
},
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
"type": "custom"
},
{
"url": "https://www.patreon.com/phpdoctrine",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager",
"type": "tidelift"
}
],
"time": "2026-01-29T07:11:08+00:00"
},
{
"name": "doctrine/inflector",
"version": "2.1.0",
@ -2444,6 +2802,117 @@
],
"time": "2024-11-21T10:36:35+00:00"
},
{
"name": "p7h/nas-file-manager",
"version": "dev-main",
"source": {
"type": "git",
"url": "https://github.com/itsp7h/File-Structure-package.git",
"reference": "3daa53b6ae5646b9dd1ec0416228312f9f1ff9ef"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/itsp7h/File-Structure-package/zipball/3daa53b6ae5646b9dd1ec0416228312f9f1ff9ef",
"reference": "3daa53b6ae5646b9dd1ec0416228312f9f1ff9ef",
"shasum": ""
},
"require": {
"illuminate/support": "^10.0|^11.0|^12.0",
"php": "^8.1"
},
"default-branch": true,
"type": "library",
"extra": {
"laravel": {
"providers": [
"P7H\\NasFileManager\\NasFileManagerServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"P7H\\NasFileManager\\": "src/"
}
},
"license": [
"MIT"
],
"description": "NAS folder-structure schema viewer and live file manager for Laravel",
"support": {
"source": "https://github.com/itsp7h/File-Structure-package/tree/main",
"issues": "https://github.com/itsp7h/File-Structure-package/issues"
},
"time": "2026-05-13T07:28:46+00:00"
},
{
"name": "paragonie/constant_time_encoding",
"version": "v3.1.3",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"shasum": ""
},
"require": {
"php": "^8"
},
"require-dev": {
"infection/infection": "^0",
"nikic/php-fuzzer": "^0",
"phpunit/phpunit": "^9|^10|^11",
"vimeo/psalm": "^4|^5|^6"
},
"type": "library",
"autoload": {
"psr-4": {
"ParagonIE\\ConstantTime\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com",
"role": "Maintainer"
},
{
"name": "Steve 'Sc00bz' Thomas",
"email": "steve@tobtu.com",
"homepage": "https://www.tobtu.com",
"role": "Original Developer"
}
],
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
"keywords": [
"base16",
"base32",
"base32_decode",
"base32_encode",
"base64",
"base64_decode",
"base64_encode",
"bin2hex",
"encoding",
"hex",
"hex2bin",
"rfc4648"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
"time": "2025-09-24T15:06:41+00:00"
},
{
"name": "php-ffmpeg/php-ffmpeg",
"version": "v1.4.0",
@ -2608,6 +3077,201 @@
],
"time": "2025-12-27T19:41:33+00:00"
},
{
"name": "pragmarx/google2fa",
"version": "v8.0.3",
"source": {
"type": "git",
"url": "https://github.com/antonioribeiro/google2fa.git",
"reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad",
"reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^1.0|^2.0|^3.0",
"php": "^7.1|^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.9",
"phpunit/phpunit": "^7.5.15|^8.5|^9.0"
},
"type": "library",
"autoload": {
"psr-4": {
"PragmaRX\\Google2FA\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Antonio Carlos Ribeiro",
"email": "acr@antoniocarlosribeiro.com",
"role": "Creator & Designer"
}
],
"description": "A One Time Password Authentication package, compatible with Google Authenticator.",
"keywords": [
"2fa",
"Authentication",
"Two Factor Authentication",
"google2fa"
],
"support": {
"issues": "https://github.com/antonioribeiro/google2fa/issues",
"source": "https://github.com/antonioribeiro/google2fa/tree/v8.0.3"
},
"time": "2024-09-05T11:56:40+00:00"
},
{
"name": "pragmarx/google2fa-laravel",
"version": "v3.0.1",
"source": {
"type": "git",
"url": "https://github.com/antonioribeiro/google2fa-laravel.git",
"reference": "d885bb5bca8be03b226d040aa80250402760a67c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/antonioribeiro/google2fa-laravel/zipball/d885bb5bca8be03b226d040aa80250402760a67c",
"reference": "d885bb5bca8be03b226d040aa80250402760a67c",
"shasum": ""
},
"require": {
"laravel/framework": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
"php": ">=7.0",
"pragmarx/google2fa-qrcode": "^1.0|^2.0|^3.0"
},
"require-dev": {
"bacon/bacon-qr-code": "^2.0",
"orchestra/testbench": "3.4.*|3.5.*|3.6.*|3.7.*|4.*|5.*|6.*|7.*|8.*|9.*|10.*|11.*",
"phpunit/phpunit": "~5|~6|~7|~8|~9|~10|~11|~12"
},
"suggest": {
"bacon/bacon-qr-code": "Required to generate inline QR Codes.",
"pragmarx/recovery": "Generate recovery codes."
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Google2FA": "PragmaRX\\Google2FALaravel\\Facade"
},
"providers": [
"PragmaRX\\Google2FALaravel\\ServiceProvider"
]
},
"component": "package",
"frameworks": [
"Laravel"
],
"branch-alias": {
"dev-master": "0.2-dev"
}
},
"autoload": {
"psr-4": {
"PragmaRX\\Google2FALaravel\\": "src/",
"PragmaRX\\Google2FALaravel\\Tests\\": "tests/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Antonio Carlos Ribeiro",
"email": "acr@antoniocarlosribeiro.com",
"role": "Creator & Designer"
}
],
"description": "A One Time Password Authentication package, compatible with Google Authenticator.",
"keywords": [
"Authentication",
"Two Factor Authentication",
"google2fa",
"laravel"
],
"support": {
"issues": "https://github.com/antonioribeiro/google2fa-laravel/issues",
"source": "https://github.com/antonioribeiro/google2fa-laravel/tree/v3.0.1"
},
"time": "2026-03-17T20:54:53+00:00"
},
{
"name": "pragmarx/google2fa-qrcode",
"version": "v3.0.1",
"source": {
"type": "git",
"url": "https://github.com/antonioribeiro/google2fa-qrcode.git",
"reference": "c23ebcc3a50de0d1566016a6dd1486e183bb78e1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/antonioribeiro/google2fa-qrcode/zipball/c23ebcc3a50de0d1566016a6dd1486e183bb78e1",
"reference": "c23ebcc3a50de0d1566016a6dd1486e183bb78e1",
"shasum": ""
},
"require": {
"php": ">=7.1",
"pragmarx/google2fa": "^4.0|^5.0|^6.0|^7.0|^8.0"
},
"require-dev": {
"bacon/bacon-qr-code": "^2.0",
"chillerlan/php-qrcode": "^1.0|^2.0|^3.0|^4.0",
"khanamiryan/qrcode-detector-decoder": "^1.0",
"phpunit/phpunit": "~4|~5|~6|~7|~8|~9"
},
"suggest": {
"bacon/bacon-qr-code": "For QR Code generation, requires imagick",
"chillerlan/php-qrcode": "For QR Code generation"
},
"type": "library",
"extra": {
"component": "package",
"branch-alias": {
"dev-master": "1.0-dev"
}
},
"autoload": {
"psr-4": {
"PragmaRX\\Google2FAQRCode\\": "src/",
"PragmaRX\\Google2FAQRCode\\Tests\\": "tests/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Antonio Carlos Ribeiro",
"email": "acr@antoniocarlosribeiro.com",
"role": "Creator & Designer"
}
],
"description": "QR Code package for Google2FA",
"keywords": [
"2fa",
"Authentication",
"Two Factor Authentication",
"google2fa",
"qr code",
"qrcode"
],
"support": {
"issues": "https://github.com/antonioribeiro/google2fa-qrcode/issues",
"source": "https://github.com/antonioribeiro/google2fa-qrcode/tree/v3.0.1"
},
"time": "2025-09-19T23:02:26+00:00"
},
{
"name": "psr/cache",
"version": "3.0.0",
@ -8699,8 +9363,10 @@
}
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"minimum-stability": "dev",
"stability-flags": {
"p7h/nas-file-manager": 20
},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {

View File

@ -6,8 +6,8 @@ return [
| FFmpeg Binaries
|--------------------------------------------------------------------------
*/
'ffmpeg' => '/usr/bin/ffmpeg',
'ffprobe' => '/usr/bin/ffprobe',
'ffmpeg' => '/usr/lib/jellyfin-ffmpeg/ffmpeg',
'ffprobe' => '/usr/lib/jellyfin-ffmpeg/ffprobe',
'timeout' => 3600,
'thread_number' => 0,
// auto-detect cores

View File

@ -0,0 +1,57 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| NAS Connection
|--------------------------------------------------------------------------
| Protocol: sftp | ftp | ftps | smb
*/
'connection' => [
'protocol' => env('NAS_PROTOCOL', 'sftp'),
'host' => env('NAS_HOST', ''),
'port' => (int) env('NAS_PORT', 22),
'username' => env('NAS_USERNAME', ''),
'password' => env('NAS_PASSWORD', ''),
'path' => env('NAS_PATH', '/media'),
'smb_share' => env('NAS_SMB_SHARE', ''),
'smb_domain' => env('NAS_SMB_DOMAIN', ''),
],
/*
|--------------------------------------------------------------------------
| Routing
|--------------------------------------------------------------------------
*/
'route_prefix' => env('NAS_FM_ROUTE_PREFIX', 'nas-file-manager'),
'middleware' => ['web', 'auth', 'super_admin'],
/*
|--------------------------------------------------------------------------
| Authorization
|--------------------------------------------------------------------------
| Gate or permission name that controls create / rename / delete actions.
| Set to null to allow any authenticated user.
| Example: 'edit-nas' (checked with Gate::allows())
*/
'edit_gate' => null,
/*
|--------------------------------------------------------------------------
| Folder Schema
|--------------------------------------------------------------------------
| Static schema shown in the "Schema" tab.
| Each node: depth (int), label (string), path (string),
| parent_path (?string), is_template (bool), can_edit (bool)
|
| You can also pass $nodes dynamically to the Blade component:
| <x-nas-file-manager::file-manager :nodes="$myNodes" />
*/
'schema' => [
// Example — uncomment and adapt:
// ['depth' => 0, 'label' => 'Media', 'path' => 'Media', 'parent_path' => null, 'is_template' => false, 'can_edit' => false],
// ['depth' => 1, 'label' => 'Outlets', 'path' => 'Media/Outlets', 'parent_path' => 'Media', 'is_template' => false, 'can_edit' => true],
],
];

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('video_views', function (Blueprint $table) {
$table->string('ip_address', 45)->nullable()->after('video_id');
$table->string('country', 2)->nullable()->after('ip_address');
$table->string('country_name', 100)->nullable()->after('country');
// Allow null so guest (unauthenticated) views can be recorded
$table->unsignedBigInteger('user_id')->nullable()->change();
});
}
public function down(): void
{
Schema::table('video_views', function (Blueprint $table) {
$table->dropColumn(['ip_address', 'country', 'country_name']);
$table->unsignedBigInteger('user_id')->nullable(false)->change();
});
}
};

View File

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

View File

@ -0,0 +1,25 @@
<?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('users', function (Blueprint $table) {
$table->string('nationality', 2)->nullable()->after('gender'); // ISO2 e.g. "BH"
$table->string('phone_code', 20)->nullable()->after('nationality'); // e.g. "+973|BH"
$table->string('phone_number', 30)->nullable()->after('phone_code');
$table->string('timezone', 60)->nullable()->after('phone_number'); // IANA e.g. "Asia/Bahrain"
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['nationality', 'phone_code', 'phone_number', 'timezone']);
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('whatsapp', 30)->nullable()->after('tiktok');
$table->string('google_location', 500)->nullable()->after('whatsapp');
$table->string('social_phone', 30)->nullable()->after('google_location');
$table->string('social_email', 100)->nullable()->after('social_phone');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['whatsapp', 'google_location', 'social_phone', 'social_email']);
});
}
};

View File

@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_social_links', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('platform', 30);
$table->string('value', 500);
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamps();
$table->index(['user_id', 'sort_order']);
});
// Migrate existing individual social columns into the new table
$cols = ['twitter','instagram','facebook','youtube','linkedin','tiktok',
'website','whatsapp','google_location','social_phone','social_email'];
foreach (\DB::table('users')->get() as $user) {
$order = 0;
foreach ($cols as $col) {
$val = $user->$col ?? null;
if ($val) {
\DB::table('user_social_links')->insert([
'user_id' => $user->id,
'platform' => $col,
'value' => $val,
'sort_order' => $order++,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_social_links');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('user_social_links', function (Blueprint $table) {
$table->enum('visibility', ['public', 'registered', 'subscribers', 'only_me'])
->default('public')
->after('value');
});
// Default sensitive platforms to 'subscribers'
\DB::table('user_social_links')
->whereIn('platform', ['whatsapp', 'social_phone', 'social_email'])
->update(['visibility' => 'subscribers']);
}
public function down(): void
{
Schema::table('user_social_links', function (Blueprint $table) {
$table->dropColumn('visibility');
});
}
};

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('videos', function (Blueprint $table) {
$table->boolean('allow_download')->default(false)->after('is_shorts');
});
}
public function down(): void
{
Schema::table('videos', function (Blueprint $table) {
$table->dropColumn('allow_download');
});
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('subscriber_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('channel_id')->constrained('users')->cascadeOnDelete();
$table->timestamp('created_at')->useCurrent();
$table->unique(['subscriber_id', 'channel_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_subscriptions');
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('videos', function (Blueprint $table) {
$table->string('download_access')->default('disabled')->after('allow_download');
});
// Migrate existing data
DB::table('videos')->where('allow_download', true)->update(['download_access' => 'everyone']);
Schema::table('videos', function (Blueprint $table) {
$table->dropColumn('allow_download');
});
}
public function down(): void
{
Schema::table('videos', function (Blueprint $table) {
$table->boolean('allow_download')->default(false)->after('download_access');
});
DB::table('videos')->where('download_access', 'everyone')->update(['allow_download' => true]);
Schema::table('videos', function (Blueprint $table) {
$table->dropColumn('download_access');
});
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->text('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('notifications');
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->text('body')->nullable();
$table->foreignId('video_id')->nullable()->constrained()->nullOnDelete();
$table->string('image')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('post_reactions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->string('type', 20)->default('like');
$table->timestamps();
$table->unique(['user_id', 'post_id']);
});
}
public function down(): void
{
Schema::dropIfExists('post_reactions');
}
};

View File

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

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('video_downloads', function (Blueprint $table) {
$table->id();
$table->foreignId('video_id')->constrained()->onDelete('cascade');
$table->foreignId('user_id')->nullable()->constrained()->onDelete('set null');
$table->string('ip_address', 45)->nullable();
$table->string('country', 2)->nullable();
$table->string('country_name', 100)->nullable();
$table->string('type', 10)->default('video'); // 'video' or 'mp3'
$table->timestamp('downloaded_at')->useCurrent();
$table->index(['video_id', 'downloaded_at']);
$table->index(['video_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('video_downloads');
}
};

View File

@ -0,0 +1,36 @@
<?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('video_shares', function (Blueprint $table) {
$table->id();
$table->foreignId('video_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('token', 12)->unique();
$table->timestamp('created_at')->useCurrent();
});
Schema::create('share_accesses', function (Blueprint $table) {
$table->id();
$table->foreignId('share_id')->constrained('video_shares')->cascadeOnDelete();
$table->string('device_id', 64);
$table->string('ip_address', 45)->nullable();
$table->string('country', 2)->nullable();
$table->string('country_name')->nullable();
$table->timestamp('accessed_at')->useCurrent();
$table->unique(['share_id', 'device_id']);
});
}
public function down(): void
{
Schema::dropIfExists('share_accesses');
Schema::dropIfExists('video_shares');
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('comment_likes', function (Blueprint $table) {
$table->id();
$table->foreignId('comment_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->timestamp('created_at')->nullable();
$table->unique(['comment_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('comment_likes');
}
};

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('two_factor_secret')->nullable()->after('password');
$table->boolean('two_factor_enabled')->default(false)->after('two_factor_secret');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['two_factor_secret', 'two_factor_enabled']);
});
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('video_slides', function (Blueprint $table) {
$table->id();
$table->foreignId('video_id')->constrained()->cascadeOnDelete();
$table->string('filename');
$table->unsignedSmallInteger('position')->default(0);
$table->timestamps();
});
Schema::table('videos', function (Blueprint $table) {
$table->string('slideshow_video_path')->nullable()->after('thumbnail');
});
}
public function down(): void
{
Schema::dropIfExists('video_slides');
Schema::table('videos', function (Blueprint $table) {
$table->dropColumn('slideshow_video_path');
});
}
};

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
Schema::create('settings', function (Blueprint $table) {
$table->id();
$table->string('key')->unique();
$table->text('value')->nullable();
$table->timestamps();
});
// Seed defaults
$defaults = [
['key' => 'gpu_enabled', 'value' => 'true'],
['key' => 'gpu_device', 'value' => '0'],
['key' => 'gpu_encoder', 'value' => 'h264_nvenc'],
['key' => 'gpu_hwaccel', 'value' => 'cuda'],
['key' => 'gpu_preset', 'value' => 'p4'],
];
foreach ($defaults as $row) {
DB::table('settings')->insert(array_merge($row, [
'created_at' => now(), 'updated_at' => now(),
]));
}
}
public function down(): void
{
Schema::dropIfExists('settings');
}
};

View File

@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (DB::getDriverName() === 'mysql') {
DB::statement("ALTER TABLE playlists MODIFY COLUMN visibility ENUM('public', 'private', 'unlisted') NOT NULL DEFAULT 'private'");
} else {
// SQLite stores enums as CHECK constraints; use table rebuild to update the constraint.
$this->rebuildSqliteEnum(['public', 'private', 'unlisted']);
}
}
public function down(): void
{
if (DB::getDriverName() === 'mysql') {
DB::table('playlists')->where('visibility', 'unlisted')->update(['visibility' => 'private']);
DB::statement("ALTER TABLE playlists MODIFY COLUMN visibility ENUM('public', 'private') NOT NULL DEFAULT 'private'");
} else {
DB::table('playlists')->where('visibility', 'unlisted')->update(['visibility' => 'private']);
$this->rebuildSqliteEnum(['public', 'private']);
}
}
private function rebuildSqliteEnum(array $values): void
{
$inList = implode(', ', array_map(fn($v) => "'$v'", $values));
DB::statement('PRAGMA foreign_keys = OFF');
DB::statement("
CREATE TABLE playlists_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id INTEGER NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
thumbnail VARCHAR(255),
visibility VARCHAR(255) CHECK (visibility IN ({$inList})) NOT NULL DEFAULT 'private',
is_default TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
");
DB::statement('INSERT INTO playlists_new SELECT * FROM playlists');
DB::statement('DROP TABLE playlists');
DB::statement('ALTER TABLE playlists_new RENAME TO playlists');
// Recreate indexes
DB::statement('CREATE INDEX playlists_user_id_visibility_index ON playlists (user_id, visibility)');
DB::statement('PRAGMA foreign_keys = ON');
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use App\Models\Playlist;
return new class extends Migration
{
public function up(): void
{
Schema::table('playlists', function (Blueprint $table) {
$table->string('share_token', 64)->nullable()->unique()->after('is_default');
});
// Backfill tokens for existing playlists
Playlist::whereNull('share_token')->each(function ($playlist) {
$playlist->updateQuietly(['share_token' => Str::random(32)]);
});
}
public function down(): void
{
Schema::table('playlists', function (Blueprint $table) {
$table->dropColumn('share_token');
});
}
};

View File

@ -0,0 +1,36 @@
<?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('playlist_shares', function (Blueprint $table) {
$table->id();
$table->foreignId('playlist_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('token', 12)->unique();
$table->timestamp('created_at')->useCurrent();
});
Schema::create('playlist_share_accesses', function (Blueprint $table) {
$table->id();
$table->foreignId('share_id')->constrained('playlist_shares')->cascadeOnDelete();
$table->string('device_id', 64);
$table->string('ip_address', 45)->nullable();
$table->string('country', 2)->nullable();
$table->string('country_name')->nullable();
$table->timestamp('accessed_at')->useCurrent();
$table->unique(['share_id', 'device_id']);
});
}
public function down(): void
{
Schema::dropIfExists('playlist_share_accesses');
Schema::dropIfExists('playlist_shares');
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use App\Models\Video;
return new class extends Migration
{
public function up(): void
{
Schema::table('videos', function (Blueprint $table) {
$table->string('share_token', 64)->nullable()->unique()->after('share_count');
});
// Backfill existing videos
Video::whereNull('share_token')->each(function ($video) {
$video->updateQuietly(['share_token' => Str::random(32)]);
});
}
public function down(): void
{
Schema::table('videos', function (Blueprint $table) {
$table->dropColumn('share_token');
});
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('post_images', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->string('filename');
$table->unsignedTinyInteger('sort_order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('post_images');
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('post_videos', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->foreignId('video_id')->nullable()->nullOnDelete();
$table->unsignedTinyInteger('sort_order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('post_videos');
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('username', 50)->nullable()->unique()->after('name');
});
// Back-fill existing users with a unique slug
DB::table('users')->orderBy('id')->each(function ($user) {
$base = substr(Str::slug(trim($user->name)), 0, 18);
if ($base === '') $base = 'user';
do {
$slug = $base . '-' . Str::lower(Str::random(6));
} while (DB::table('users')->where('username', $slug)->exists());
DB::table('users')->where('id', $user->id)->update(['username' => $slug]);
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('username');
});
}
};

View File

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

View File

@ -0,0 +1,35 @@
<?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('audit_logs', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('user_name')->nullable();
$table->string('action', 64);
$table->string('subject_type', 64)->nullable();
$table->string('subject_id', 64)->nullable();
$table->string('subject_label')->nullable();
$table->string('ip_address', 45)->nullable();
$table->string('user_agent')->nullable();
$table->json('details')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index('user_id');
$table->index('action');
$table->index('created_at');
$table->index('ip_address');
});
}
public function down(): void
{
Schema::dropIfExists('audit_logs');
}
};

9
public/css/cropme.min.css vendored Normal file
View File

@ -0,0 +1,9 @@
/*!
* cropme v1.4.0
* https://shpontex.github.io/cropme
*
* Copyright 2019 shpontex
* Released under the MIT license
*
* Date: 2019-10-28T17:18:45.700Z
*/.cropme-wrapper{width:100%;height:100%}.cropme-container{position:relative;overflow:hidden;margin:0 auto}.cropme-container img{width:auto!important;cursor:move;opacity:0;touch-action:none}#img{border:5px solid red}.viewport{box-sizing:content-box!important;position:absolute;border-style:solid;margin:auto;top:0;bottom:0;right:0;left:0;box-shadow:0 0 2000px 2000px rgba(0,0,0,.5);z-index:0;pointer-events:none}.viewport.circle{border-radius:50%}.cropme-rotation-slider,.cropme-slider{text-align:center}.cropme-rotation-slider input,.cropme-slider input{-webkit-appearance:none}.cropme-rotation-slider input:disabled,.cropme-slider input:disabled{opacity:.5}.cropme-rotation-slider input::-webkit-slider-runnable-track,.cropme-slider input::-webkit-slider-runnable-track{height:3px;background:rgba(0,0,0,.5);border-radius:3px}.cropme-rotation-slider input::-webkit-slider-thumb,.cropme-slider input::-webkit-slider-thumb{-webkit-appearance:none;height:16px;width:16px;border-radius:50%;background:#ddd;margin-top:-6px}.cropme-rotation-slider input:focus,.cropme-slider input:focus{outline:none}

10
public/js/cropme.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
public/js/jquery-4.0.0.min.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
public/sounds/chime.wav Normal file

Binary file not shown.

View File

@ -0,0 +1,385 @@
@extends('admin.layout')
@section('title', 'Audit Logs')
@section('content')
<style>
.al-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 20px; flex-wrap: wrap; gap: 12px;
}
.al-title { font-size: 22px; font-weight: 600; display: flex; align-items: center; gap: 10px; }
.al-title i { color: var(--brand); }
/* Filter bar */
.al-filters {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 20px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
align-items: end;
}
.al-filter-group label {
display: block; font-size: 11px; font-weight: 700;
letter-spacing: .7px; text-transform: uppercase;
color: var(--text-muted); margin-bottom: 6px;
}
.al-filter-input {
width: 100%; background: var(--bg-body); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 12px; color: var(--text);
font-size: 13px; line-height: 1;
}
.al-filter-input:focus { outline: none; border-color: var(--brand); }
.al-filter-input option { background: #1a1a1a; }
.al-filter-btn {
display: flex; gap: 8px;
}
.al-btn {
padding: 9px 16px; border-radius: 8px; font-size: 13px;
font-weight: 600; cursor: pointer; border: none; white-space: nowrap;
}
.al-btn-primary { background: var(--brand); color: #fff; }
.al-btn-ghost { background: var(--border-light); color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 6px; }
.al-btn-ghost:hover { background: var(--border); color: var(--text); }
/* Stats strip */
.al-stats {
display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap;
}
.al-stat {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 10px; padding: 10px 18px; font-size: 13px;
display: flex; align-items: center; gap: 8px;
}
.al-stat strong { font-size: 18px; font-weight: 700; }
.al-stat-label { color: var(--text-muted); font-size: 12px; }
/* Table */
.al-table-wrap {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.al-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.al-table thead tr {
background: rgba(255,255,255,.03);
border-bottom: 1px solid var(--border);
}
.al-table th {
padding: 11px 14px; text-align: left;
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: .6px; color: var(--text-muted); white-space: nowrap;
}
.al-table td { padding: 10px 14px; border-bottom: 1px solid rgba(255,255,255,.04); vertical-align: middle; }
.al-table tr:last-child td { border-bottom: none; }
.al-table tbody tr:hover { background: rgba(255,255,255,.02); }
/* Severity badges */
.al-badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 10px; border-radius: 20px;
font-size: 11px; font-weight: 700; white-space: nowrap;
}
.al-badge-danger { background: rgba(239,68,68,.15); color: #f87171; border: 1px solid rgba(239,68,68,.3); }
.al-badge-warning { background: rgba(251,191,36,.15); color: #fbbf24; border: 1px solid rgba(251,191,36,.3); }
.al-badge-success { background: rgba(34,197,94,.15); color: #4ade80; border: 1px solid rgba(34,197,94,.3); }
.al-badge-info { background: rgba(96,165,250,.15); color: #60a5fa; border: 1px solid rgba(96,165,250,.3); }
.al-badge-purple { background: rgba(167,139,250,.15);color: #a78bfa; border: 1px solid rgba(167,139,250,.3); }
.al-badge-orange { background: rgba(251,146,60,.15); color: #fb923c; border: 1px solid rgba(251,146,60,.3); }
.al-badge-muted { background: rgba(255,255,255,.06); color: var(--text-muted); border: 1px solid var(--border); }
.al-badge-default { background: rgba(255,255,255,.06); color: var(--text); border: 1px solid var(--border); }
/* Icon per severity */
.al-badge-danger i { color: #f87171; }
.al-badge-warning i { color: #fbbf24; }
.al-badge-success i { color: #4ade80; }
.al-badge-info i { color: #60a5fa; }
/* User cell */
.al-user { display: flex; align-items: center; gap: 8px; }
.al-user-avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.al-user-name { font-weight: 600; }
.al-user-id { font-size: 11px; color: var(--text-muted); }
/* IP cell */
.al-ip { font-family: monospace; font-size: 12px; color: var(--text-muted); }
.al-ip a { color: inherit; text-decoration: underline dotted; }
/* Subject cell */
.al-subject { max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Details expand */
.al-details-btn {
background: none; border: none; color: var(--text-muted);
cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 12px;
}
.al-details-btn:hover { background: var(--border-light); color: var(--text); }
.al-details-row td {
padding: 0 !important;
border-bottom: 1px solid rgba(255,255,255,.04) !important;
}
.al-details-inner {
padding: 10px 14px 14px 44px;
font-family: monospace; font-size: 12px;
color: var(--text-muted); white-space: pre-wrap;
background: rgba(0,0,0,.15);
}
/* Pagination */
.al-pagination {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; border-top: 1px solid var(--border);
font-size: 13px; color: var(--text-muted); flex-wrap: wrap; gap: 10px;
}
.al-page-links { display: flex; gap: 4px; }
.al-page-links a, .al-page-links span {
padding: 5px 10px; border-radius: 6px; font-size: 13px;
border: 1px solid var(--border); color: var(--text-muted); text-decoration: none;
}
.al-page-links a:hover { background: var(--border-light); color: var(--text); }
.al-page-links span.active { background: var(--brand); border-color: var(--brand); color: #fff; }
.al-page-links span.disabled { opacity: .4; cursor: not-allowed; }
/* Empty state */
.al-empty {
padding: 60px 20px; text-align: center; color: var(--text-muted);
}
.al-empty i { font-size: 48px; display: block; margin-bottom: 12px; }
</style>
<div class="al-header">
<div class="al-title">
<i class="bi bi-shield-check"></i> Audit Logs
</div>
<div style="font-size:13px;color:var(--text-muted);">
{{ $logs->total() }} events found
@if(request()->hasAny(['action','user','ip','subject','date_from','date_to']))
<a href="{{ route('admin.audit') }}" style="color:var(--brand);">clear filters</a>
@endif
</div>
</div>
{{-- Filters --}}
<form method="GET" action="{{ route('admin.audit') }}">
<div class="al-filters">
<div class="al-filter-group">
<label>Action Type</label>
<select name="action" class="al-filter-input">
<option value="">All Actions</option>
@foreach($actionTypes as $type)
<option value="{{ $type }}" {{ request('action') === $type ? 'selected' : '' }}>
{{ ucwords(str_replace(['.','_'], ' ', $type)) }}
</option>
@endforeach
</select>
</div>
<div class="al-filter-group">
<label>User / Email</label>
<input type="text" name="user" class="al-filter-input" value="{{ request('user') }}" placeholder="Name or email…">
</div>
<div class="al-filter-group">
<label>IP Address</label>
<input type="text" name="ip" class="al-filter-input" value="{{ request('ip') }}" placeholder="e.g. 1.2.3.4">
</div>
<div class="al-filter-group">
<label>Subject</label>
<input type="text" name="subject" class="al-filter-input" value="{{ request('subject') }}" placeholder="Video title, username…">
</div>
<div class="al-filter-group">
<label>From Date</label>
<input type="date" name="date_from" class="al-filter-input" value="{{ request('date_from') }}">
</div>
<div class="al-filter-group">
<label>To Date</label>
<input type="date" name="date_to" class="al-filter-input" value="{{ request('date_to') }}">
</div>
<div class="al-filter-group al-filter-btn">
<button type="submit" class="al-btn al-btn-primary"><i class="bi bi-funnel-fill"></i> Filter</button>
<a href="{{ route('admin.audit') }}" class="al-btn al-btn-ghost"><i class="bi bi-x-lg"></i></a>
</div>
</div>
</form>
{{-- Table --}}
<div class="al-table-wrap">
@if($logs->isEmpty())
<div class="al-empty">
<i class="bi bi-shield-check"></i>
No audit events found.
</div>
@else
<table class="al-table" id="auditTable">
<thead>
<tr>
<th>Time</th>
<th>Action</th>
<th>User</th>
<th>Subject</th>
<th>IP Address</th>
<th>Device</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach($logs as $log)
@php
$severity = $log->severity;
$icon = match($severity) {
'danger' => 'bi-trash3-fill',
'warning' => 'bi-exclamation-triangle-fill',
'success' => 'bi-cloud-upload-fill',
'info' => 'bi-box-arrow-in-right',
'purple' => 'bi-shield-lock-fill',
'orange' => 'bi-person-badge-fill',
'muted' => 'bi-box-arrow-right',
default => 'bi-dot',
};
$ua = $log->user_agent ?? '';
$device = match(true) {
str_contains($ua, 'Mobile') || str_contains($ua, 'Android') => '📱 Mobile',
str_contains($ua, 'Tablet') || str_contains($ua, 'iPad') => '📟 Tablet',
default => '🖥️ Desktop',
};
$browser = match(true) {
str_contains($ua, 'Firefox') => 'Firefox',
str_contains($ua, 'Chrome') && !str_contains($ua, 'Chromium') && !str_contains($ua, 'Edg') => 'Chrome',
str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome') => 'Safari',
str_contains($ua, 'Edg') => 'Edge',
str_contains($ua, 'curl') => 'cURL',
default => 'Unknown',
};
@endphp
<tr>
<td style="white-space:nowrap;">
<div style="font-weight:600;font-size:13px;">{{ $log->created_at->format('d M Y') }}</div>
<div style="font-size:11px;color:var(--text-muted);">{{ $log->created_at->format('H:i:s') }}</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:1px;" title="{{ $log->created_at }}">{{ $log->created_at->diffForHumans() }}</div>
</td>
<td>
<span class="al-badge al-badge-{{ $severity }}">
<i class="bi {{ $icon }}"></i>
{{ $log->action_label }}
</span>
</td>
<td>
<div class="al-user">
@if($log->user)
<img src="{{ $log->user->avatar_url }}" alt="" class="al-user-avatar">
@else
<div class="al-user-avatar" style="background:var(--border);display:flex;align-items:center;justify-content:center;font-size:12px;">
<i class="bi bi-person" style="color:var(--text-muted);"></i>
</div>
@endif
<div>
<div class="al-user-name">
@if($log->user)
<a href="{{ route('admin.users.edit', $log->user) }}" style="color:inherit;text-decoration:none;" onmouseover="this.style.textDecoration='underline'" onmouseout="this.style.textDecoration='none'">
{{ $log->user_name ?? 'Unknown' }}
</a>
@else
{{ $log->user_name ?? ($log->action === 'user.login.failed' ? 'Guest' : 'Unknown') }}
@endif
</div>
@if($log->user_id)
<div class="al-user-id">#{{ $log->user_id }}</div>
@endif
</div>
</div>
</td>
<td>
@if($log->subject_label)
<div class="al-subject" title="{{ $log->subject_label }}">
@if($log->subject_type === 'Video' && $log->subject_id)
<a href="{{ route('videos.show', \App\Models\Video::encodeId((int)$log->subject_id)) }}" style="color:var(--brand);text-decoration:none;" target="_blank">
{{ $log->subject_label }}
</a>
@elseif($log->subject_type === 'User' && $log->subject_id)
<a href="{{ route('admin.users.edit', $log->subject_id) }}" style="color:var(--brand);text-decoration:none;">
{{ $log->subject_label }}
</a>
@else
{{ $log->subject_label }}
@endif
</div>
<div style="font-size:11px;color:var(--text-muted);">{{ $log->subject_type }}</div>
@else
<span style="color:var(--text-muted);"></span>
@endif
</td>
<td>
<div class="al-ip">
@if($log->ip_address)
<a href="{{ route('admin.audit', ['ip' => $log->ip_address]) }}" title="Filter by this IP">
{{ $log->ip_address }}
</a>
@else
<span></span>
@endif
</div>
</td>
<td style="font-size:12px;color:var(--text-muted);white-space:nowrap;">
{{ $device }} · {{ $browser }}
</td>
<td>
@if($log->details)
<button class="al-details-btn" onclick="toggleDetails({{ $log->id }})" title="View details">
<i class="bi bi-code-slash"></i>
</button>
@endif
</td>
</tr>
@if($log->details)
<tr class="al-details-row" id="details-{{ $log->id }}" style="display:none;">
<td colspan="7">
<div class="al-details-inner">{{ json_encode($log->details, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</div>
</td>
</tr>
@endif
@endforeach
</tbody>
</table>
{{-- Pagination --}}
@if($logs->hasPages())
<div class="al-pagination">
<div>
Showing {{ $logs->firstItem() }}{{ $logs->lastItem() }} of {{ $logs->total() }} events
</div>
<div class="al-page-links">
@if($logs->onFirstPage())
<span class="disabled"><i class="bi bi-chevron-left"></i></span>
@else
<a href="{{ $logs->previousPageUrl() }}"><i class="bi bi-chevron-left"></i></a>
@endif
@foreach($logs->getUrlRange(max(1, $logs->currentPage()-2), min($logs->lastPage(), $logs->currentPage()+2)) as $page => $url)
@if($page == $logs->currentPage())
<span class="active">{{ $page }}</span>
@else
<a href="{{ $url }}">{{ $page }}</a>
@endif
@endforeach
@if($logs->hasMorePages())
<a href="{{ $logs->nextPageUrl() }}"><i class="bi bi-chevron-right"></i></a>
@else
<span class="disabled"><i class="bi bi-chevron-right"></i></span>
@endif
</div>
</div>
@endif
@endif
</div>
<script>
function toggleDetails(id) {
const row = document.getElementById('details-' + id);
if (row) row.style.display = row.style.display === 'none' ? 'table-row' : 'none';
}
</script>
@endsection

File diff suppressed because it is too large Load Diff

View File

@ -3,131 +3,310 @@
@section('title', 'Edit User')
@section('page_title', 'Edit User')
@section('extra_styles')
<style>
.ef-grid {
display: grid;
grid-template-columns: 1fr 320px;
gap: 20px;
align-items: start;
}
@media (max-width: 900px) {
.ef-grid { grid-template-columns: 1fr; }
}
/* ── Form fields ── */
.ef-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; }
.ef-field:last-of-type { margin-bottom: 0; }
.ef-label {
font-size: 12px; font-weight: 600; color: var(--text-2);
text-transform: uppercase; letter-spacing: .04em;
}
.ef-input, .ef-select, .ef-textarea {
width: 100%;
background: var(--bg-2);
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
font-size: 13px;
outline: none;
transition: border-color .15s;
font-family: inherit;
}
.ef-input, .ef-select { height: 38px; padding: 0 12px; }
.ef-select { cursor: pointer; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23888' viewBox='0 0 16 16'%3E%3Cpath d='M7.247 11.14L2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px;
}
.ef-textarea { height: 90px; padding: 10px 12px; resize: vertical; }
.ef-input:focus, .ef-select:focus, .ef-textarea:focus { border-color: var(--brand); }
.ef-input.is-invalid, .ef-select.is-invalid { border-color: #f87171; }
.ef-error { font-size: 12px; color: #f87171; margin-top: 2px; }
.ef-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 500px) { .ef-row { grid-template-columns: 1fr; } }
/* ── Section divider ── */
.ef-section {
margin: 24px 0 20px;
padding-top: 20px;
border-top: 1px solid var(--border);
display: flex; align-items: center; gap: 10px;
}
.ef-section-title {
font-size: 13px; font-weight: 600; color: var(--text-2);
white-space: nowrap;
}
.ef-section-hint { font-size: 12px; color: var(--text-3); }
/* ── Actions ── */
.ef-actions { display: flex; gap: 10px; margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border); }
/* ── Sidebar info card ── */
.ef-user-avatar {
width: 72px; height: 72px; border-radius: 50%; object-fit: cover;
border: 3px solid var(--border);
}
.ef-stat-row {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,.04);
font-size: 13px;
}
.ef-stat-row:last-child { border-bottom: none; padding-bottom: 0; }
.ef-stat-label { color: var(--text-2); }
.ef-stat-val { font-weight: 600; color: var(--text); }
/* ── Danger zone ── */
.ef-danger-zone {
margin-top: 16px;
padding: 16px;
border: 1px solid rgba(248,113,113,.25);
border-radius: 10px;
background: rgba(248,113,113,.05);
}
.ef-danger-title { font-size: 12px; font-weight: 700; color: #f87171; margin-bottom: 10px; text-transform: uppercase; letter-spacing: .05em; }
</style>
@endsection
@section('content')
<!-- Alerts -->
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
@endif
@if(session('error'))
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ session('error') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
@endif
<div class="row">
<div class="col-lg-8">
<!-- Edit User Form -->
<div class="admin-card">
<div class="admin-card-header">
<h5 class="admin-card-title">Edit User</h5>
<a href="{{ route('admin.users') }}" class="btn btn-outline-light btn-sm">
<i class="bi bi-arrow-left"></i> Back to Users
{{-- ── Page header ── --}}
<div class="adm-page-header" style="margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:12px;">
<a href="{{ route('admin.users') }}" class="adm-btn adm-btn-sm">
<i class="bi bi-arrow-left"></i>
</a>
<h1 class="adm-page-title" style="margin:0;">
<i class="bi bi-person-gear"></i> Edit User
</h1>
</div>
</div>
{{-- ── Alerts ── --}}
@if(session('success'))
<div class="adm-alert adm-alert-success" style="margin-bottom:16px;">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
@if(session('error'))
<div class="adm-alert adm-alert-error" style="margin-bottom:16px;">
<i class="bi bi-exclamation-circle-fill"></i>
<span>{{ session('error') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
<div class="ef-grid">
{{-- ── Left: edit form ── --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title"><i class="bi bi-pencil-square"></i> Account Details</div>
</div>
<div class="adm-card-body">
<form method="POST" action="{{ route('admin.users.update', $user->id) }}">
@csrf
@method('PUT')
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input type="text" class="form-control @error('name') is-invalid @enderror" id="name" name="name" value="{{ old('name', $user->name) }}" required>
@error('name')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- Name --}}
<div class="ef-field">
<label class="ef-label" for="name">Full Name</label>
<input class="ef-input @error('name') is-invalid @enderror"
id="name" name="name" type="text"
value="{{ old('name', $user->name) }}" required autocomplete="off">
@error('name')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control @error('email') is-invalid @enderror" id="email" name="email" value="{{ old('email', $user->email) }}" required>
@error('email')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- Email --}}
<div class="ef-field">
<label class="ef-label" for="email">Email Address</label>
<input class="ef-input @error('email') is-invalid @enderror"
id="email" name="email" type="email"
value="{{ old('email', $user->email) }}" required autocomplete="off">
@error('email')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="mb-3">
<label for="role" class="form-label">Role</label>
<select class="form-select @error('role') is-invalid @enderror" id="role" name="role" required>
<option value="user" {{ old('role', $user->role) == 'user' ? 'selected' : '' }}>User</option>
<option value="admin" {{ old('role', $user->role) == 'admin' ? 'selected' : '' }}>Admin</option>
<option value="super_admin" {{ old('role', $user->role) == 'super_admin' ? 'selected' : '' }}>Super Admin</option>
{{-- Role --}}
<div class="ef-field">
<label class="ef-label" for="role">Role</label>
<select class="ef-select @error('role') is-invalid @enderror" id="role" name="role">
<option value="user" {{ old('role', $user->role) === 'user' ? 'selected' : '' }}>User</option>
<option value="admin" {{ old('role', $user->role) === 'admin' ? 'selected' : '' }}>Admin</option>
<option value="super_admin" {{ old('role', $user->role) === 'super_admin' ? 'selected' : '' }}>Super Admin</option>
</select>
@error('role')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
@error('role')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<hr style="border-color: var(--border-color);">
<h6 class="mb-3">Change Password (Optional)</h6>
<div class="mb-3">
<label for="new_password" class="form-label">New Password</label>
<input type="password" class="form-control @error('new_password') is-invalid @enderror" id="new_password" name="new_password" placeholder="Leave blank to keep current password">
@error('new_password')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- Password section --}}
<div class="ef-section">
<span class="ef-section-title"><i class="bi bi-lock-fill" style="color:var(--brand);"></i> Change Password</span>
<span class="ef-section-hint"> leave blank to keep current password</span>
</div>
<div class="mb-3">
<label for="new_password_confirmation" class="form-label">Confirm New Password</label>
<input type="password" class="form-control" id="new_password_confirmation" name="new_password_confirmation" placeholder="Confirm new password">
<div class="ef-row">
<div class="ef-field" style="margin-bottom:0;">
<label class="ef-label" for="new_password">New Password</label>
<input class="ef-input @error('new_password') is-invalid @enderror"
id="new_password" name="new_password" type="password"
placeholder="Min. 8 characters" autocomplete="new-password">
@error('new_password')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="ef-field" style="margin-bottom:0;">
<label class="ef-label" for="new_password_confirmation">Confirm Password</label>
<input class="ef-input"
id="new_password_confirmation" name="new_password_confirmation"
type="password" placeholder="Repeat password" autocomplete="new-password">
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Update User
<div class="ef-actions">
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-check-circle-fill"></i> Save Changes
</button>
<a href="{{ route('admin.users') }}" class="btn btn-outline-light">Cancel</a>
<a href="{{ route('admin.users') }}" class="adm-btn">Cancel</a>
</div>
</form>
</div>
</div>
<div class="col-lg-4">
<!-- User Info -->
<div class="admin-card">
<div class="admin-card-header">
<h5 class="admin-card-title">User Info</h5>
</div>
{{-- ── Right: user info sidebar ── --}}
<div style="display:flex;flex-direction:column;gap:16px;">
<div class="text-center mb-3">
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="rounded-circle" style="width: 80px; height: 80px; object-fit: cover;">
<h5 class="mt-2">{{ $user->name }}</h5>
<p class="text-secondary mb-1">{{ $user->email }}</p>
{{-- Profile card --}}
<div class="adm-card">
<div class="adm-card-body" style="text-align:center;padding-top:28px;padding-bottom:24px;">
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="ef-user-avatar">
<div style="margin-top:12px;font-size:16px;font-weight:700;color:var(--text);">{{ $user->name }}</div>
<div style="font-size:12px;color:var(--text-2);margin:4px 0 10px;">{{ $user->email }}</div>
@if($user->role === 'super_admin')
<span class="badge-role badge-super-admin">Super Admin</span>
<span class="adm-badge adm-badge-superadmin">Super Admin</span>
@elseif($user->role === 'admin')
<span class="badge-role badge-admin">Admin</span>
<span class="adm-badge adm-badge-admin">Admin</span>
@else
<span class="badge-role badge-user">User</span>
<span class="adm-badge adm-badge-user">User</span>
@endif
</div>
</div>
<hr style="border-color: var(--border-color);">
{{-- Stats card --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title"><i class="bi bi-info-circle"></i> Account Info</div>
</div>
<div class="adm-card-body">
<div class="ef-stat-row">
<span class="ef-stat-label">User ID</span>
<span class="ef-stat-val">#{{ $user->id }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Joined</span>
<span class="ef-stat-val">{{ $user->created_at->format('M d, Y') }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Last active</span>
<span class="ef-stat-val">{{ $user->updated_at->diffForHumans() }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Videos</span>
<span class="ef-stat-val" style="color:#818cf8;">{{ $user->videos->count() }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Email verified</span>
@if($user->email_verified_at)
<span class="adm-badge adm-badge-verified" style="font-size:11px;">Verified</span>
@else
<span class="adm-badge adm-badge-unverified" style="font-size:11px;">Unverified</span>
@endif
</div>
</div>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">User ID</span>
<span>#{{ $user->id }}</span>
{{-- Impersonate --}}
@if(!$user->isSuperAdmin())
<form method="POST" action="{{ route('admin.users.impersonate', $user->id) }}">
@csrf
<button type="submit" class="adm-btn adm-btn-impersonate" style="width:100%;">
<i class="bi bi-person-badge"></i> Impersonate User
</button>
</form>
@endif
{{-- Danger zone --}}
@if(!$user->isSuperAdmin())
<div class="ef-danger-zone">
<div class="ef-danger-title"><i class="bi bi-exclamation-triangle-fill me-1"></i> Danger Zone</div>
<button type="button" class="adm-btn adm-btn-danger" style="width:100%;"
onclick="openDelDialog()">
<i class="bi bi-trash3-fill"></i> Delete This User
</button>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Joined</span>
<span>{{ $user->created_at->format('M d, Y') }}</span>
@endif
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Total Videos</span>
<span>{{ $user->videos->count() }}</span>
</div>
{{-- ── Delete confirmation dialog ── --}}
@if(!$user->isSuperAdmin())
<div class="adm-dialog-overlay" id="delDialog">
<div class="adm-dialog">
<div class="adm-dialog-header">
<div class="adm-dialog-title">
<i class="bi bi-exclamation-triangle-fill"></i> Delete User
</div>
<div class="d-flex justify-content-between">
<span class="text-secondary">Email Verified</span>
<span>{{ $user->email_verified_at ? 'Yes' : 'No' }}</span>
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDelDialog()"
style="border:none;background:none;color:var(--text-2);">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="adm-dialog-body">
<p>You are about to permanently delete <strong>{{ $user->name }}</strong>.</p>
<div class="adm-dialog-warning">
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
All videos uploaded by this user will also be deleted. This cannot be undone.
</div>
</div>
<div class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDelDialog()">Cancel</button>
<form method="POST" action="{{ route('admin.users.delete', $user->id) }}">
@csrf @method('DELETE')
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
<i class="bi bi-trash3-fill"></i> Delete Permanently
</button>
</form>
</div>
</div>
</div>
@endif
@endsection
@section('scripts')
<script>
function openDelDialog() { document.getElementById('delDialog').classList.add('open'); }
function closeDelDialog() { document.getElementById('delDialog').classList.remove('open'); }
document.getElementById('delDialog')?.addEventListener('click', e => { if (e.target === document.getElementById('delDialog')) closeDelDialog(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDelDialog(); });
</script>
@endsection

View File

@ -3,164 +3,312 @@
@section('title', 'Edit Video')
@section('page_title', 'Edit Video')
@section('extra_styles')
<style>
.ef-grid {
display: grid;
grid-template-columns: 1fr 300px;
gap: 20px;
align-items: start;
}
@media (max-width: 900px) {
.ef-grid { grid-template-columns: 1fr; }
}
/* ── Form fields ── */
.ef-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; }
.ef-field:last-of-type { margin-bottom: 0; }
.ef-label {
font-size: 12px; font-weight: 600; color: var(--text-2);
text-transform: uppercase; letter-spacing: .04em;
}
.ef-input, .ef-select, .ef-textarea {
width: 100%;
background: var(--bg-2);
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
font-size: 13px;
outline: none;
transition: border-color .15s;
font-family: inherit;
}
.ef-input, .ef-select { height: 38px; padding: 0 12px; }
.ef-select { cursor: pointer; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23888' viewBox='0 0 16 16'%3E%3Cpath d='M7.247 11.14L2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px;
}
.ef-textarea { height: 100px; padding: 10px 12px; resize: vertical; }
.ef-input:focus, .ef-select:focus, .ef-textarea:focus { border-color: var(--brand); }
.ef-input.is-invalid, .ef-select.is-invalid, .ef-textarea.is-invalid { border-color: #f87171; }
.ef-error { font-size: 12px; color: #f87171; margin-top: 2px; }
.ef-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
@media (max-width: 600px) { .ef-row-3 { grid-template-columns: 1fr; } }
/* ── Actions ── */
.ef-actions { display: flex; gap: 10px; margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border); }
/* ── Sidebar stats ── */
.ef-thumb {
width: 100%; border-radius: 10px; object-fit: cover; display: block;
background: var(--bg-2);
}
.ef-thumb-placeholder {
width: 100%; height: 160px; border-radius: 10px;
background: var(--bg-2); border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center;
color: var(--text-3); font-size: 36px;
}
.ef-stat-row {
display: flex; justify-content: space-between; align-items: center;
padding: 9px 0; border-bottom: 1px solid rgba(255,255,255,.04);
font-size: 13px;
}
.ef-stat-row:last-child { border-bottom: none; padding-bottom: 0; }
.ef-stat-label { color: var(--text-2); }
.ef-stat-val { font-weight: 600; color: var(--text); }
/* ── Danger zone ── */
.ef-danger-zone {
padding: 16px;
border: 1px solid rgba(248,113,113,.25);
border-radius: 10px;
background: rgba(248,113,113,.05);
}
.ef-danger-title { font-size: 12px; font-weight: 700; color: #f87171; margin-bottom: 10px; text-transform: uppercase; letter-spacing: .05em; }
</style>
@endsection
@section('content')
<!-- Alerts -->
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
@endif
@if(session('error'))
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ session('error') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
@endif
<div class="row">
<div class="col-lg-8">
<!-- Edit Video Form -->
<div class="admin-card">
<div class="admin-card-header">
<h5 class="admin-card-title">Edit Video</h5>
<a href="{{ route('admin.videos') }}" class="btn btn-outline-light btn-sm">
<i class="bi bi-arrow-left"></i> Back to Videos
{{-- ── Page header ── --}}
<div class="adm-page-header" style="margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:12px;">
<a href="{{ route('admin.videos') }}" class="adm-btn adm-btn-sm">
<i class="bi bi-arrow-left"></i>
</a>
<h1 class="adm-page-title" style="margin:0;">
<i class="bi bi-pencil-square"></i> Edit Video
</h1>
</div>
</div>
<form method="POST" action="{{ route('admin.videos.update', $video->id) }}">
{{-- ── Alerts ── --}}
@if(session('success'))
<div class="adm-alert adm-alert-success" style="margin-bottom:16px;">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
@if(session('error'))
<div class="adm-alert adm-alert-error" style="margin-bottom:16px;">
<i class="bi bi-exclamation-circle-fill"></i>
<span>{{ session('error') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
<div class="ef-grid">
{{-- ── Left: form ── --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title"><i class="bi bi-film"></i> Video Details</div>
</div>
<div class="adm-card-body">
<form method="POST" action="{{ route('admin.videos.update', $video) }}">
@csrf
@method('PUT')
<div class="mb-3">
<label for="title" class="form-label">Title</label>
<input type="text" class="form-control @error('title') is-invalid @enderror" id="title" name="title" value="{{ old('title', $video->title) }}" required>
@error('title')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- Title --}}
<div class="ef-field">
<label class="ef-label" for="title">Title</label>
<input class="ef-input @error('title') is-invalid @enderror"
id="title" name="title" type="text"
value="{{ old('title', $video->title) }}" required>
@error('title')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control @error('description') is-invalid @enderror" id="description" name="description" rows="4">{{ old('description', $video->description) }}</textarea>
@error('description')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- Description --}}
<div class="ef-field">
<label class="ef-label" for="description">Description</label>
<textarea class="ef-textarea @error('description') is-invalid @enderror"
id="description" name="description">{{ old('description', $video->description) }}</textarea>
@error('description')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="row mb-3">
<div class="col-md-4">
<label for="visibility" class="form-label">Visibility</label>
<select class="form-select @error('visibility') is-invalid @enderror" id="visibility" name="visibility" required>
<option value="public" {{ old('visibility', $video->visibility) == 'public' ? 'selected' : '' }}>Public</option>
<option value="unlisted" {{ old('visibility', $video->visibility) == 'unlisted' ? 'selected' : '' }}>Unlisted</option>
<option value="private" {{ old('visibility', $video->visibility) == 'private' ? 'selected' : '' }}>Private</option>
{{-- Visibility / Type / Status --}}
<div class="ef-row-3">
<div class="ef-field" style="margin-bottom:0;">
<label class="ef-label" for="visibility">Visibility</label>
<select class="ef-select @error('visibility') is-invalid @enderror" id="visibility" name="visibility">
<option value="public" {{ old('visibility', $video->visibility) === 'public' ? 'selected' : '' }}>Public</option>
<option value="unlisted" {{ old('visibility', $video->visibility) === 'unlisted' ? 'selected' : '' }}>Unlisted</option>
<option value="private" {{ old('visibility', $video->visibility) === 'private' ? 'selected' : '' }}>Private</option>
</select>
@error('visibility')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
@error('visibility')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="col-md-4">
<label for="type" class="form-label">Type</label>
<select class="form-select @error('type') is-invalid @enderror" id="type" name="type" required>
<option value="generic" {{ old('type', $video->type) == 'generic' ? 'selected' : '' }}>Generic</option>
<option value="music" {{ old('type', $video->type) == 'music' ? 'selected' : '' }}>Music</option>
<option value="match" {{ old('type', $video->type) == 'match' ? 'selected' : '' }}>Match</option>
<div class="ef-field" style="margin-bottom:0;">
<label class="ef-label" for="type">Type</label>
<select class="ef-select @error('type') is-invalid @enderror" id="type" name="type">
<option value="generic" {{ old('type', $video->type) === 'generic' ? 'selected' : '' }}>Generic</option>
<option value="music" {{ old('type', $video->type) === 'music' ? 'selected' : '' }}>Music</option>
<option value="match" {{ old('type', $video->type) === 'match' ? 'selected' : '' }}>Match</option>
</select>
@error('type')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
@error('type')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="col-md-4">
<label for="status" class="form-label">Status</label>
<select class="form-select @error('status') is-invalid @enderror" id="status" name="status" required>
<option value="pending" {{ old('status', $video->status) == 'pending' ? 'selected' : '' }}>Pending</option>
<option value="processing" {{ old('status', $video->status) == 'processing' ? 'selected' : '' }}>Processing</option>
<option value="ready" {{ old('status', $video->status) == 'ready' ? 'selected' : '' }}>Ready</option>
<option value="failed" {{ old('status', $video->status) == 'failed' ? 'selected' : '' }}>Failed</option>
<div class="ef-field" style="margin-bottom:0;">
<label class="ef-label" for="status">Status</label>
<select class="ef-select @error('status') is-invalid @enderror" id="status" name="status">
<option value="pending" {{ old('status', $video->status) === 'pending' ? 'selected' : '' }}>Pending</option>
<option value="processing" {{ old('status', $video->status) === 'processing' ? 'selected' : '' }}>Processing</option>
<option value="ready" {{ old('status', $video->status) === 'ready' ? 'selected' : '' }}>Ready</option>
<option value="failed" {{ old('status', $video->status) === 'failed' ? 'selected' : '' }}>Failed</option>
</select>
@error('status')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
@error('status')<div class="ef-error">{{ $message }}</div>@enderror
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Update Video
{{-- Download Access --}}
<div class="ef-field" style="margin-top:18px;">
<label class="ef-label" for="download_access">Who Can Download</label>
<select class="ef-select @error('download_access') is-invalid @enderror" id="download_access" name="download_access">
<option value="disabled" {{ old('download_access', $video->download_access) === 'disabled' ? 'selected' : '' }}>No one (disabled)</option>
<option value="everyone" {{ old('download_access', $video->download_access) === 'everyone' ? 'selected' : '' }}>Everyone (guests too)</option>
<option value="registered" {{ old('download_access', $video->download_access) === 'registered' ? 'selected' : '' }}>Registered members</option>
<option value="subscribers" {{ old('download_access', $video->download_access) === 'subscribers' ? 'selected' : '' }}>Subscribers only</option>
</select>
@error('download_access')<div class="ef-error">{{ $message }}</div>@enderror
</div>
<div class="ef-actions">
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-check-circle-fill"></i> Save Changes
</button>
<a href="{{ route('admin.videos') }}" class="btn btn-outline-light">Cancel</a>
<a href="{{ route('admin.videos') }}" class="adm-btn">Cancel</a>
<a href="{{ route('videos.show', $video) }}" target="_blank" class="adm-btn" style="margin-left:auto;">
<i class="bi bi-play-circle"></i> View Video
</a>
</div>
</form>
</div>
</div>
<div class="col-lg-4">
<!-- Video Info -->
<div class="admin-card">
<div class="admin-card-header">
<h5 class="admin-card-title">Video Info</h5>
</div>
{{-- ── Right: sidebar ── --}}
<div style="display:flex;flex-direction:column;gap:16px;">
{{-- Thumbnail --}}
<div class="adm-card">
<div class="adm-card-body">
@if($video->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}" class="img-fluid rounded mb-3" style="width: 100%;">
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}"
alt="{{ $video->title }}" class="ef-thumb">
@else
<div class="bg-secondary rounded d-flex align-items-center justify-content-center mb-3" style="height: 180px;">
<i class="bi bi-play-circle text-white" style="font-size: 3rem;"></i>
</div>
<div class="ef-thumb-placeholder"><i class="bi bi-play-circle"></i></div>
@endif
<hr style="border-color: var(--border-color);">
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Video ID</span>
<span>#{{ $video->id }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Owner</span>
<a href="{{ route('channel', $video->user->id) }}" target="_blank">{{ $video->user->name }}</a>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Uploaded</span>
<span>{{ $video->created_at->format('M d, Y') }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">File Size</span>
<span>{{ number_format($video->size / 1024 / 1024, 2) }} MB</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Duration</span>
<span>{{ $video->duration ? gmdate('H:i:s', $video->duration) : 'N/A' }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Orientation</span>
<span class="text-capitalize">{{ $video->orientation }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Dimensions</span>
<span>{{ $video->width ?? 'N/A' }} x {{ $video->height ?? 'N/A' }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Views</span>
<span>{{ number_format(\DB::table('video_views')->where('video_id', $video->id)->count()) }}</span>
</div>
<div class="d-flex justify-content-between">
<span class="text-secondary">Likes</span>
<span>{{ number_format(\DB::table('video_likes')->where('video_id', $video->id)->count()) }}</span>
</div>
<hr style="border-color: var(--border-color);">
<div class="d-grid gap-2">
<a href="{{ route('videos.show', $video->id) }}" target="_blank" class="btn btn-outline-light btn-sm">
<i class="bi bi-play-circle"></i> View Video
{{-- Stats --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title"><i class="bi bi-info-circle"></i> Video Info</div>
</div>
<div class="adm-card-body">
<div class="ef-stat-row">
<span class="ef-stat-label">Owner</span>
<a href="{{ route('channel', $video->user->channel) }}" target="_blank"
style="color:var(--brand);font-weight:600;font-size:13px;text-decoration:none;">
{{ $video->user->name }}
</a>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Uploaded</span>
<span class="ef-stat-val">{{ $video->created_at->format('M d, Y') }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Views</span>
<span class="ef-stat-val" style="color:#22d3ee;">
{{ number_format(\DB::table('video_views')->where('video_id', $video->id)->count()) }}
</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Likes</span>
<span class="ef-stat-val" style="color:#f472b6;">
{{ number_format(\DB::table('video_likes')->where('video_id', $video->id)->count()) }}
</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Duration</span>
<span class="ef-stat-val">{{ $video->duration ? gmdate('H:i:s', $video->duration) : '—' }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Dimensions</span>
<span class="ef-stat-val">{{ $video->width ?? '—' }} × {{ $video->height ?? '—' }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">File size</span>
<span class="ef-stat-val">{{ $video->size ? number_format($video->size / 1024 / 1024, 1) . ' MB' : '—' }}</span>
</div>
<div class="ef-stat-row">
<span class="ef-stat-label">Orientation</span>
<span class="ef-stat-val" style="text-transform:capitalize;">{{ $video->orientation ?? '—' }}</span>
</div>
</div>
</div>
{{-- Danger zone --}}
<div class="ef-danger-zone">
<div class="ef-danger-title"><i class="bi bi-exclamation-triangle-fill me-1"></i> Danger Zone</div>
<button type="button" class="adm-btn adm-btn-danger" style="width:100%;"
onclick="openDelDialog()">
<i class="bi bi-trash3-fill"></i> Delete This Video
</button>
</div>
</div>
</div>
{{-- ── Delete confirmation dialog ── --}}
<div class="adm-dialog-overlay" id="delDialog">
<div class="adm-dialog">
<div class="adm-dialog-header">
<div class="adm-dialog-title">
<i class="bi bi-exclamation-triangle-fill"></i> Delete Video
</div>
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDelDialog()"
style="border:none;background:none;color:var(--text-2);">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="adm-dialog-body">
<p>You are about to permanently delete <strong>{{ $video->title }}</strong>.</p>
<div class="adm-dialog-warning">
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
The video file, thumbnail, and all associated data will be removed. This cannot be undone.
</div>
</div>
<div class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDelDialog()">Cancel</button>
<form method="POST" action="{{ route('admin.videos.delete', $video) }}">
@csrf @method('DELETE')
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
<i class="bi bi-trash3-fill"></i> Delete Permanently
</button>
</form>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
function openDelDialog() { document.getElementById('delDialog').classList.add('open'); }
function closeDelDialog() { document.getElementById('delDialog').classList.remove('open'); }
document.getElementById('delDialog').addEventListener('click', e => { if (e.target === document.getElementById('delDialog')) closeDelDialog(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDelDialog(); });
</script>
@endsection

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,100 @@
@extends('layouts.app')
@section('title', 'Error Logs | Admin')
@section('content')
<div style="max-width: 1400px; margin: 0 auto; padding: 24px;">
{{-- Header --}}
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; flex-wrap: wrap; gap: 12px;">
<div style="display: flex; align-items: center; gap: 12px;">
<a href="{{ route('admin.dashboard') }}" class="action-btn">
<i class="bi bi-arrow-left"></i> <span>Dashboard</span>
</a>
<h1 style="font-size: 22px; font-weight: 700; margin: 0;">
<i class="bi bi-bug-fill" style="color: #e61e1e; margin-right: 8px;"></i>Error Logs
</h1>
</div>
<div style="display: flex; gap: 8px; align-items: center;">
<span style="font-size: 13px; color: var(--text-secondary);">{{ count($lines) }} entries shown</span>
<a href="{{ route('admin.logs') }}" class="action-btn">
<i class="bi bi-arrow-clockwise"></i> <span>Refresh</span>
</a>
<button class="action-btn" onclick="copyAll()">
<i class="bi bi-clipboard"></i> <span>Copy All</span>
</button>
</div>
</div>
{{-- Filters --}}
<form method="GET" action="{{ route('admin.logs') }}"
style="display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; align-items: flex-end;">
<div style="flex: 1; min-width: 200px;">
<label style="display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">Search</label>
<input type="text" name="filter" value="{{ $filter }}"
placeholder="Filter by keyword…"
style="width: 100%; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 8px 12px; font-size: 13px;">
</div>
<div>
<label style="display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">Level</label>
<select name="level"
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 8px 12px; font-size: 13px; cursor: pointer;">
<option value="" {{ $level === '' ? 'selected' : '' }}>All levels</option>
<option value="ERROR" {{ $level === 'ERROR' ? 'selected' : '' }}>ERROR</option>
<option value="WARNING" {{ $level === 'WARNING' ? 'selected' : '' }}>WARNING</option>
<option value="INFO" {{ $level === 'INFO' ? 'selected' : '' }}>INFO</option>
<option value="DEBUG" {{ $level === 'DEBUG' ? 'selected' : '' }}>DEBUG</option>
</select>
</div>
<div>
<label style="display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">Show</label>
<select name="limit"
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 8px 12px; font-size: 13px; cursor: pointer;">
@foreach([50, 100, 200, 500] as $n)
<option value="{{ $n }}" {{ $limit == $n ? 'selected' : '' }}>{{ $n }} lines</option>
@endforeach
</select>
</div>
<button type="submit" class="action-btn action-btn-primary">
<i class="bi bi-search"></i> <span>Filter</span>
</button>
</form>
{{-- Log lines --}}
@if(empty($lines))
<div style="text-align: center; padding: 60px; color: var(--text-secondary);">
<i class="bi bi-check-circle" style="font-size: 48px; color: #22c55e; display: block; margin-bottom: 12px;"></i>
No log entries found{{ $filter || $level ? ' matching your filter' : '' }}.
</div>
@else
<div id="logOutput" style="font-family: 'Courier New', monospace; font-size: 12px; line-height: 1.6; background: #0a0a0a; border: 1px solid var(--border-color); border-radius: 10px; overflow: auto; max-height: calc(100vh - 260px); padding: 12px 16px;">
@foreach($lines as $line)
@php
$color = '#aaa';
if (str_contains($line, '.ERROR:')) $color = '#f87171';
elseif (str_contains($line, '.WARNING:')) $color = '#fbbf24';
elseif (str_contains($line, '.INFO:')) $color = '#6ee7b7';
elseif (str_contains($line, '.DEBUG:')) $color = '#93c5fd';
@endphp
<div class="log-line" style="color: {{ $color }}; border-bottom: 1px solid #1a1a1a; padding: 3px 0; white-space: pre-wrap; word-break: break-all;">{{ $line }}</div>
@endforeach
</div>
@endif
</div>
<script>
function copyAll() {
const text = Array.from(document.querySelectorAll('.log-line'))
.map(el => el.textContent).join('\n');
navigator.clipboard.writeText(text).then(() => showToast('Copied to clipboard', 'success'));
}
// Auto-scroll to top (newest entries are at top)
document.getElementById('logOutput')?.scrollTo(0, 0);
</script>
@endsection

View File

@ -0,0 +1,89 @@
@extends('admin.layout')
@section('title', 'NAS Storage')
@section('extra_styles')
<style>
/* Tailwind-like resets needed for the NAS component (light-theme widget) */
.nas-wrapper * { box-sizing: border-box; }
/* brand-* color mappings for the NAS file manager component */
.nas-wrapper .bg-brand-600, .nas-wrapper .bg-brand-700 { background-color: #e61e1e !important; }
.nas-wrapper .text-brand-600, .nas-wrapper .text-brand-500 { color: #e61e1e !important; }
.nas-wrapper .ring-brand-500 { --tw-ring-color: #e61e1e !important; }
.nas-wrapper .hover\:bg-brand-700:hover { background-color: #c91a1a !important; }
.nas-wrapper .focus\:ring-brand-500:focus { --tw-ring-color: #e61e1e !important; }
.nas-wrapper .hover\:text-brand-600:hover { color: #e61e1e !important; }
.nas-wrapper .hover\:bg-brand-50:hover { background-color: rgba(230,30,30,.08) !important; }
.nas-wrapper .bg-brand-600.text-white { color: #fff !important; }
</style>
@endsection
@section('content')
<div class="adm-page-header">
<h1 class="adm-page-title">
<i class="bi bi-hdd-network"></i> NAS Storage
</h1>
</div>
{{-- Connection config summary --}}
<div class="adm-card" style="margin-bottom:20px;">
<div class="adm-card-header">
<span class="adm-card-title"><i class="bi bi-plug"></i> Connection</span>
<a href="{{ route('admin.settings') }}" class="adm-btn adm-btn-sm">
<i class="bi bi-gear"></i> Settings
</a>
</div>
<div class="adm-card-body" style="display:flex;gap:32px;flex-wrap:wrap;">
@php
$conn = config('nas-file-manager.connection');
$fields = [
'Protocol' => strtoupper($conn['protocol'] ?? '—'),
'Host' => $conn['host'] ?: '—',
'Port' => $conn['port'] ?: '—',
'Share' => $conn['smb_share'] ?: '—',
'Path' => $conn['path'] ?: '—',
'User' => $conn['username'] ?: '—',
];
@endphp
@foreach($fields as $label => $value)
<div>
<div style="font-size:11px;color:var(--text-2);text-transform:uppercase;letter-spacing:.5px;margin-bottom:3px;">{{ $label }}</div>
<div style="font-size:13px;font-weight:600;font-family:monospace;">{{ $value }}</div>
</div>
@endforeach
</div>
</div>
{{-- NAS File Manager component (Alpine.js + Tailwind) --}}
<div class="nas-wrapper">
@include('nas-file-manager::file-manager', [
'nodes' => $nodes,
'canEdit' => true,
'title' => 'NAS Storage Browser',
])
</div>
@endsection
@section('scripts')
<script src="https://cdn.jsdelivr.net/npm/@alpinejs/intersect@3.x.x/dist/cdn.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@3.4.1/base.min.css">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: {
50: 'rgba(230,30,30,.08)',
500: '#e61e1e',
600: '#e61e1e',
700: '#c91a1a',
}
}
}
},
corePlugins: { preflight: false }
}
</script>
@endsection

View File

@ -0,0 +1,25 @@
<div class="gpu-grid">
@foreach($gpus as $gpu)
@php
$used = $gpu['mem_total'] - $gpu['mem_free'];
$usedPct = $gpu['mem_total'] > 0 ? round($used / $gpu['mem_total'] * 100) : 0;
$sel = (string)$gpu['index'] === (string)$selectedDevice;
@endphp
<div class="gpu-card {{ $sel ? 'selected' : '' }}"
data-index="{{ $gpu['index'] }}"
onclick="selectGpuCard(this)">
<div class="gpu-card-check">@if($sel)<i class="bi bi-check"></i>@endif</div>
<div class="gpu-card-name">{{ $gpu['name'] }}</div>
<div class="gpu-stat"><span>VRAM</span><span class="gpu-stat-val">{{ number_format($gpu['mem_total']) }} MB</span></div>
<div class="gpu-stat"><span>Free</span><span class="gpu-stat-val">{{ number_format($gpu['mem_free']) }} MB</span></div>
<div class="gpu-stat"><span>GPU Load</span><span class="gpu-stat-val">{{ $gpu['util'] }}%</span></div>
<div class="gpu-stat"><span>Temp</span><span class="gpu-stat-val">{{ $gpu['temp'] }} °C</span></div>
<div class="gpu-stat"><span>Driver</span><span class="gpu-stat-val">{{ $gpu['driver'] }}</span></div>
<div class="mem-bar-wrap">
<div class="mem-bar-track">
<div class="mem-bar-fill" style="width:{{ $usedPct }}%"></div>
</div>
</div>
</div>
@endforeach
</div>

View File

@ -0,0 +1,47 @@
@if ($paginator->hasPages())
<nav class="adm-pagination" aria-label="Pagination">
{{-- Prev --}}
@if ($paginator->onFirstPage())
<span class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-left"></i></span>
</span>
@else
<a class="page-item" href="{{ $paginator->previousPageUrl() }}" rel="prev">
<span class="page-link"><i class="bi bi-chevron-left"></i></span>
</a>
@endif
{{-- Pages --}}
@foreach ($elements as $element)
@if (is_string($element))
<span class="page-item disabled"><span class="page-link">{{ $element }}</span></span>
@endif
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<span class="page-item active" aria-current="page">
<span class="page-link">{{ $page }}</span>
</span>
@else
<a class="page-item" href="{{ $url }}">
<span class="page-link">{{ $page }}</span>
</a>
@endif
@endforeach
@endif
@endforeach
{{-- Next --}}
@if ($paginator->hasMorePages())
<a class="page-item" href="{{ $paginator->nextPageUrl() }}" rel="next">
<span class="page-link"><i class="bi bi-chevron-right"></i></span>
</a>
@else
<span class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-right"></i></span>
</span>
@endif
</nav>
@endif

View File

@ -0,0 +1,430 @@
@extends('admin.layout')
@section('title', 'System Settings')
@section('page_title', 'Settings')
@section('extra_styles')
<style>
.settings-section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 24px;
overflow: hidden;
}
.settings-section-header {
display: flex; align-items: center; gap: 10px;
padding: 18px 22px;
border-bottom: 1px solid var(--border);
font-size: 14px; font-weight: 600;
}
.settings-section-header i { color: var(--brand); font-size: 16px; }
.settings-section-body { padding: 22px; }
.setting-row {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 24px; padding: 16px 0;
border-bottom: 1px solid var(--border);
}
.setting-row:last-child { border-bottom: none; padding-bottom: 0; }
.setting-row:first-child { padding-top: 0; }
.setting-label { flex: 1; }
.setting-label strong { display: block; font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 3px; }
.setting-label small { font-size: 12px; color: var(--text-2); line-height: 1.5; }
.setting-control { flex-shrink: 0; min-width: 180px; }
/* Toggle switch */
.toggle-wrap { display: flex; align-items: center; gap: 10px; }
.toggle-switch {
position: relative; width: 44px; height: 24px; flex-shrink: 0; cursor: pointer;
}
.toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.toggle-track {
position: absolute; inset: 0;
background: var(--border-light); border-radius: 12px;
transition: background .2s;
}
.toggle-thumb {
position: absolute; top: 3px; left: 3px;
width: 18px; height: 18px; border-radius: 50%;
background: #fff; transition: transform .2s;
box-shadow: 0 1px 3px rgba(0,0,0,.4);
}
.toggle-switch input:checked ~ .toggle-track { background: var(--brand); }
.toggle-switch input:checked ~ .toggle-thumb { transform: translateX(20px); }
.toggle-label { font-size: 13px; color: var(--text-2); }
.toggle-switch input:checked + .toggle-track + .toggle-label { color: var(--text); }
/* Select */
.adm-select-full {
width: 100%; height: 38px;
background: var(--bg); border: 1px solid var(--border-light);
border-radius: 8px; color: var(--text); font-size: 13px;
padding: 0 12px; outline: none; font-family: inherit;
transition: border-color .15s; cursor: pointer;
}
.adm-select-full:focus { border-color: var(--brand); }
.adm-select-full option { background: #1e1e1e; }
/* GPU cards */
.gpu-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-bottom: 4px; }
.gpu-card {
background: var(--bg); border: 1px solid var(--border);
border-radius: 10px; padding: 16px;
cursor: pointer; transition: border-color .15s, background .15s;
position: relative;
}
.gpu-card:hover { border-color: #444; background: var(--bg-card2); }
.gpu-card.selected { border-color: var(--brand); background: var(--brand-dim); }
.gpu-card-check {
position: absolute; top: 10px; right: 10px;
width: 18px; height: 18px; border-radius: 50%;
border: 2px solid var(--border); background: transparent;
display: flex; align-items: center; justify-content: center;
font-size: 10px; color: #fff;
transition: background .15s, border-color .15s;
}
.gpu-card.selected .gpu-card-check { background: var(--brand); border-color: var(--brand); }
.gpu-card-name { font-size: 13px; font-weight: 600; margin-bottom: 8px; padding-right: 24px; }
.gpu-stat { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.gpu-stat:last-child { margin-bottom: 0; }
.gpu-stat-val { color: var(--text); font-weight: 500; }
/* Mem bar */
.mem-bar-wrap { margin-top: 8px; }
.mem-bar-track { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
.mem-bar-fill { height: 100%; background: var(--brand); border-radius: 2px; transition: width .4s; }
/* No GPU state */
.no-gpu-state { text-align: center; padding: 28px 20px; color: var(--text-2); }
.no-gpu-state i { font-size: 32px; display: block; margin-bottom: 10px; opacity: .3; }
/* Status chip */
.chip {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600;
}
.chip-green { background: rgba(34,197,94,.15); color: #4ade80; border: 1px solid rgba(34,197,94,.25); }
.chip-red { background: rgba(239,68,68,.15); color: #f87171; border: 1px solid rgba(239,68,68,.25); }
.chip-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
/* Encoder option cards */
.enc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
.enc-card {
background: var(--bg); border: 1px solid var(--border);
border-radius: 8px; padding: 12px 14px;
cursor: pointer; transition: border-color .15s;
text-align: left;
}
.enc-card:hover { border-color: #444; }
.enc-card.selected { border-color: var(--brand); background: var(--brand-dim); }
.enc-card-name { font-size: 13px; font-weight: 600; display: block; }
.enc-card-desc { font-size: 11px; color: var(--text-2); margin-top: 3px; display: block; }
.save-bar {
display: flex; align-items: center; justify-content: flex-end;
gap: 12px; padding-top: 4px;
}
</style>
@endsection
@section('content')
<div class="adm-page-header">
<h1 class="adm-page-title"><i class="bi bi-gear-fill"></i> System Settings</h1>
</div>
@if(session('success'))
<div class="adm-alert adm-alert-success" style="margin-bottom:20px;">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
</div>
@endif
@if($errors->any())
<div class="adm-alert adm-alert-error" style="margin-bottom:20px;">
<i class="bi bi-exclamation-triangle-fill"></i>
<span>{{ $errors->first() }}</span>
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
</div>
@endif
<form method="POST" action="{{ route('admin.settings.update') }}" id="settingsForm">
@csrf
{{-- ── GPU Processing ───────────────────────────────────────── --}}
<div class="settings-section">
<div class="settings-section-header">
<i class="bi bi-gpu-card"></i>
GPU Accelerated Processing
<span id="gpuStatusChip" style="margin-left:6px;">
@if(count($gpus))
<span class="chip chip-green"><span class="chip-dot"></span> {{ count($gpus) }} GPU{{ count($gpus) > 1 ? 's' : '' }} detected</span>
@else
<span class="chip chip-red"><span class="chip-dot"></span> No GPU detected</span>
@endif
</span>
{{-- NVENC encoding health --}}
<span id="nvencStatusChip" style="margin-left:6px;">
@if($nvencWorks)
<span class="chip chip-green"><span class="chip-dot"></span> NVENC encoding </span>
@else
<span class="chip chip-red"><span class="chip-dot"></span> NVENC encoding </span>
@endif
</span>
</div>
@if(!$nvencWorks && count($gpus))
<div style="background:rgba(239,68,68,.08);border-left:3px solid #f87171;padding:12px 18px;font-size:13px;color:#f87171;line-height:1.6;">
<strong> NVENC is not working with the current FFmpeg binary.</strong><br>
The GPU is detected but FFmpeg {{ shell_exec('/usr/bin/ffmpeg -version 2>/dev/null | head -1') ?? '' }} cannot initialise CUDA on this driver.<br>
<strong>Fix:</strong> Install a newer FFmpeg with CUDA 12+ support (e.g. <code>jellyfin-ffmpeg7</code>), then update the binary path below.<br>
Until then, video encoding will automatically fall back to CPU (libx264).
</div>
@endif
<div class="settings-section-body">
{{-- Detect button + GPU cards --}}
<div style="margin-bottom:20px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
<span style="font-size:13px;color:var(--text-2);">Available GPUs</span>
<button type="button" class="adm-btn adm-btn-sm" id="detectBtn" onclick="detectGpus()">
<i class="bi bi-arrow-repeat" id="detectIcon"></i> Detect GPUs
</button>
</div>
<div id="gpuCardsWrap">
@if(count($gpus))
@include('admin.partials.gpu-cards', ['gpus' => $gpus, 'selectedDevice' => $settings['gpu_device']])
@else
<div class="no-gpu-state">
<i class="bi bi-gpu-card"></i>
<p>No NVIDIA GPUs detected. Click "Detect GPUs" to scan.</p>
</div>
@endif
</div>
</div>
{{-- Enable GPU --}}
<div class="setting-row">
<div class="setting-label">
<strong>Enable GPU acceleration</strong>
<small>When enabled, video encoding uses the NVIDIA GPU. When disabled, falls back to CPU (libx264).</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<div class="toggle-switch">
<input type="checkbox" id="gpuEnabledInput" name="gpu_enabled_check"
{{ $settings['gpu_enabled'] === 'true' ? 'checked' : '' }}>
<div class="toggle-track"></div>
<div class="toggle-thumb"></div>
</div>
<span class="toggle-label" id="gpuEnabledLabel">
{{ $settings['gpu_enabled'] === 'true' ? 'Enabled' : 'Disabled' }}
</span>
</label>
<input type="hidden" name="gpu_enabled" id="gpuEnabledHidden"
value="{{ $settings['gpu_enabled'] }}">
</div>
</div>
{{-- GPU device (hidden input, controlled by card click) --}}
<input type="hidden" name="gpu_device" id="gpuDeviceInput" value="{{ $settings['gpu_device'] }}">
{{-- GPU Encoder --}}
<div class="setting-row" id="gpuEncoderRow">
<div class="setting-label">
<strong>Video encoder</strong>
<small>h264_nvenc is broadly compatible. hevc_nvenc produces smaller files (H.265) but requires compatible players. libx264 forces CPU encoding regardless of the toggle above.</small>
</div>
<div class="setting-control">
<div class="enc-grid">
@foreach([
['h264_nvenc', 'H.264 NVENC', 'GPU · max compatibility'],
['hevc_nvenc', 'H.265 NVENC', 'GPU · smaller files'],
['libx264', 'libx264', 'CPU · software fallback'],
] as [$val, $label, $desc])
<button type="button"
class="enc-card {{ $settings['gpu_encoder'] === $val ? 'selected' : '' }}"
data-encoder="{{ $val }}"
onclick="selectEncoder(this)">
<span class="enc-card-name">{{ $label }}</span>
<span class="enc-card-desc">{{ $desc }}</span>
</button>
@endforeach
</div>
<input type="hidden" name="gpu_encoder" id="gpuEncoderInput" value="{{ $settings['gpu_encoder'] }}">
</div>
</div>
{{-- Encoding preset --}}
<div class="setting-row" id="gpuPresetRow">
<div class="setting-label">
<strong>Encoding preset</strong>
<small>NVENC presets: p1 (fastest) p7 (best quality). libx264 presets: fast / medium / slow. Preset only affects speed vs file size; quality is controlled by CQ/CRF.</small>
</div>
<div class="setting-control">
<select name="gpu_preset" class="adm-select-full" id="gpuPresetSelect">
<optgroup label="NVENC (GPU)">
@foreach(['p1','p2','p3','p4','p5','p6','p7'] as $p)
<option value="{{ $p }}" {{ $settings['gpu_preset'] === $p ? 'selected' : '' }}>
{{ $p }}{{ $p === 'p4' ? ' — recommended' : '' }}
</option>
@endforeach
</optgroup>
<optgroup label="libx264 (CPU)">
@foreach(['fast','medium','slow'] as $p)
<option value="{{ $p }}" {{ $settings['gpu_preset'] === $p ? 'selected' : '' }}>
{{ $p }}
</option>
@endforeach
</optgroup>
</select>
</div>
</div>
{{-- HW Accel --}}
<div class="setting-row" id="gpuHwaccelRow">
<div class="setting-label">
<strong>Hardware decode acceleration</strong>
<small>Use CUDA to decode the source video on the GPU before re-encoding, speeding up the pipeline. Disable if you see CUDA errors in the logs.</small>
</div>
<div class="setting-control">
<select name="gpu_hwaccel" class="adm-select-full">
<option value="cuda" {{ $settings['gpu_hwaccel'] === 'cuda' ? 'selected' : '' }}>cuda GPU decode</option>
<option value="none" {{ $settings['gpu_hwaccel'] === 'none' ? 'selected' : '' }}>none CPU decode</option>
</select>
</div>
</div>
{{-- FFmpeg binary path --}}
<div class="setting-row">
<div class="setting-label">
<strong>FFmpeg binary path</strong>
<small>
Absolute path to the <code>ffmpeg</code> executable.
Change this to use a newer build (e.g. <code>/usr/lib/jellyfin-ffmpeg/ffmpeg</code>)
that supports your GPU driver. Current: <code>{{ config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg') }}</code>
</small>
</div>
<div class="setting-control">
<input type="text" name="ffmpeg_binary" class="adm-select-full" style="height:auto;padding:8px 12px;"
value="{{ $settings['ffmpeg_binary'] }}"
placeholder="/usr/bin/ffmpeg">
</div>
</div>
</div>
</div>
{{-- ── Save ─────────────────────────────────────────────────── --}}
<div class="save-bar">
<a href="{{ route('admin.dashboard') }}" class="adm-btn">Cancel</a>
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-floppy"></i> Save Settings
</button>
</div>
</form>
@endsection
@section('scripts')
<script>
// ── GPU card selection ────────────────────────────────────────
function selectGpuCard(el) {
document.querySelectorAll('.gpu-card').forEach(c => c.classList.remove('selected'));
el.classList.add('selected');
document.getElementById('gpuDeviceInput').value = el.dataset.index;
}
// ── Encoder card selection ────────────────────────────────────
function selectEncoder(el) {
document.querySelectorAll('.enc-card').forEach(c => c.classList.remove('selected'));
el.classList.add('selected');
document.getElementById('gpuEncoderInput').value = el.dataset.encoder;
// Sync preset optgroup visibility hint
const isCpu = el.dataset.encoder === 'libx264';
document.getElementById('gpuPresetSelect').value = isCpu ? 'fast' : 'p4';
}
// ── GPU toggle ────────────────────────────────────────────────
const gpuToggle = document.getElementById('gpuEnabledInput');
const gpuHidden = document.getElementById('gpuEnabledHidden');
const gpuLabel = document.getElementById('gpuEnabledLabel');
function applyGpuToggle() {
const on = gpuToggle.checked;
gpuHidden.value = on ? 'true' : 'false';
gpuLabel.textContent = on ? 'Enabled' : 'Disabled';
}
gpuToggle.addEventListener('change', applyGpuToggle);
// ── Live GPU detect ───────────────────────────────────────────
async function detectGpus() {
const btn = document.getElementById('detectBtn');
const icon = document.getElementById('detectIcon');
btn.disabled = true;
icon.className = 'bi bi-arrow-repeat spin';
try {
const res = await fetch('{{ route('admin.settings.detect-gpu') }}');
const data = await res.json();
const gpus = data.gpus || [];
const wrap = document.getElementById('gpuCardsWrap');
const chip = document.getElementById('gpuStatusChip');
const selectedDevice = parseInt(document.getElementById('gpuDeviceInput').value);
if (gpus.length === 0) {
wrap.innerHTML = '<div class="no-gpu-state"><i class="bi bi-gpu-card"></i><p>No NVIDIA GPUs detected.</p></div>';
chip.innerHTML = '<span class="chip chip-red"><span class="chip-dot"></span> No GPU detected</span>';
} else {
chip.innerHTML = `<span class="chip chip-green"><span class="chip-dot"></span> ${gpus.length} GPU${gpus.length > 1 ? 's' : ''} detected</span>`;
wrap.innerHTML = buildGpuCards(gpus, selectedDevice);
}
} catch (e) {
console.error(e);
}
btn.disabled = false;
icon.className = 'bi bi-arrow-repeat';
}
function buildGpuCards(gpus, selectedDevice) {
if (!gpus.length) return '';
const grid = document.createElement('div');
grid.className = 'gpu-grid';
gpus.forEach(gpu => {
const used = gpu.mem_total - gpu.mem_free;
const usedPct = Math.round((used / gpu.mem_total) * 100);
const sel = gpu.index === selectedDevice;
const card = document.createElement('div');
card.className = 'gpu-card' + (sel ? ' selected' : '');
card.dataset.index = gpu.index;
card.onclick = function() { selectGpuCard(this); };
card.innerHTML = `
<div class="gpu-card-check">${sel ? '<i class="bi bi-check"></i>' : ''}</div>
<div class="gpu-card-name">${escHtml(gpu.name)}</div>
<div class="gpu-stat"><span>VRAM</span><span class="gpu-stat-val">${gpu.mem_total.toLocaleString()} MB</span></div>
<div class="gpu-stat"><span>Free</span><span class="gpu-stat-val">${gpu.mem_free.toLocaleString()} MB</span></div>
<div class="gpu-stat"><span>GPU Load</span><span class="gpu-stat-val">${gpu.util}%</span></div>
<div class="gpu-stat"><span>Temp</span><span class="gpu-stat-val">${gpu.temp} °C</span></div>
<div class="gpu-stat"><span>Driver</span><span class="gpu-stat-val">${escHtml(gpu.driver)}</span></div>
<div class="mem-bar-wrap">
<div class="mem-bar-track"><div class="mem-bar-fill" style="width:${usedPct}%"></div></div>
</div>`;
grid.appendChild(card);
});
return grid.outerHTML;
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
<style>
@keyframes spin { to { transform: rotate(360deg); } }
.spin { display: inline-block; animation: spin .6s linear infinite; }
</style>
@endsection

View File

@ -1,135 +1,181 @@
@extends('admin.layout')
@section('title', 'User Management')
@section('page_title', 'User Management')
@section('title', 'Users')
@section('content')
<!-- Alerts -->
{{-- ── Page header ──────────────────────────────────────────────────── --}}
<div class="adm-page-header">
<h1 class="adm-page-title"><i class="bi bi-people"></i> Users</h1>
</div>
{{-- ── Alerts ───────────────────────────────────────────────────────── --}}
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="adm-alert adm-alert-success">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
@if(session('error'))
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ session('error') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="adm-alert adm-alert-error">
<i class="bi bi-exclamation-circle-fill"></i>
<span>{{ session('error') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
<!-- Search & Filters -->
<div class="admin-card">
<form method="GET" action="{{ route('admin.users') }}" class="filter-form">
<div class="form-group">
<label for="search">Search</label>
<input type="text" name="search" id="search" class="form-control" placeholder="Search by name or email..." value="{{ request('search') }}">
{{-- ── Filter card ──────────────────────────────────────────────────── --}}
<div class="adm-card">
<div class="adm-card-body" style="padding:16px 20px;">
<form method="GET" action="{{ route('admin.users') }}" class="adm-filter-form">
<div class="adm-filter-search">
<i class="bi bi-search"></i>
<input type="text" name="search" class="adm-input"
placeholder="Search name or email…"
value="{{ request('search') }}" autocomplete="off">
</div>
<div class="form-group">
<label for="role">Role</label>
<select name="role" id="role" class="form-select">
<select name="role" class="adm-select">
<option value="">All Roles</option>
<option value="user" {{ request('role') == 'user' ? 'selected' : '' }}>User</option>
<option value="admin" {{ request('role') == 'admin' ? 'selected' : '' }}>Admin</option>
<option value="super_admin" {{ request('role') == 'super_admin' ? 'selected' : '' }}>Super Admin</option>
<option value="user" {{ request('role') === 'user' ? 'selected' : '' }}>User</option>
<option value="admin" {{ request('role') === 'admin' ? 'selected' : '' }}>Admin</option>
<option value="super_admin" {{ request('role') === 'super_admin' ? 'selected' : '' }}>Super Admin</option>
</select>
</div>
<div class="form-group">
<label for="sort">Sort By</label>
<select name="sort" id="sort" class="form-select">
<option value="latest" {{ request('sort') == 'latest' ? 'selected' : '' }}>Latest First</option>
<option value="oldest" {{ request('sort') == 'oldest' ? 'selected' : '' }}>Oldest First</option>
<option value="name_asc" {{ request('sort') == 'name_asc' ? 'selected' : '' }}>Name (A-Z)</option>
<option value="name_desc" {{ request('sort') == 'name_desc' ? 'selected' : '' }}>Name (Z-A)</option>
<select name="sort" class="adm-select">
<option value="latest" {{ request('sort', 'latest') === 'latest' ? 'selected' : '' }}>Newest first</option>
<option value="oldest" {{ request('sort') === 'oldest' ? 'selected' : '' }}>Oldest first</option>
<option value="name_asc" {{ request('sort') === 'name_asc' ? 'selected' : '' }}>Name AZ</option>
<option value="name_desc" {{ request('sort') === 'name_desc' ? 'selected' : '' }}>Name ZA</option>
</select>
</div>
<div class="form-group">
<label>&nbsp;</label>
<button type="submit" class="btn btn-primary">
<i class="bi bi-search"></i> Filter
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-funnel"></i> Filter
</button>
<a href="{{ route('admin.users') }}" class="btn btn-outline-light">
<i class="bi bi-x-circle"></i> Clear
@if(request()->hasAny(['search','role','sort']))
<a href="{{ route('admin.users') }}" class="adm-btn">
<i class="bi bi-x-lg"></i> Clear
</a>
</div>
@endif
</form>
</div>
</div>
<!-- Users Table -->
<div class="admin-card">
<div class="admin-card-header">
All Users ({{ $users->count() }})
{{-- ── Users table ──────────────────────────────────────────────────── --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title">
<i class="bi bi-people"></i>
All Users
<span class="adm-badge adm-badge-user">{{ $users->total() ?? $users->count() }}</span>
</div>
</div>
<div class="table-responsive">
<table class="admin-table">
<div class="adm-table-wrap">
<table class="adm-table">
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Role</th>
<th>Verified</th>
<th>Videos</th>
<th>Joined</th>
<th>Actions</th>
<th style="width:80px; text-align:right;">Actions</th>
</tr>
</thead>
<tbody>
@forelse($users as $user)
<tr>
{{-- User cell --}}
<td>
<div class="d-flex align-items-center gap-2">
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="user-avatar">
<div class="adm-user-cell">
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}">
<div>
<div>{{ $user->name }}</div>
<div style="display:flex;align-items:center;gap:4px;">
<span class="adm-user-cell-name">{{ $user->name }}</span>
@if($user->id === auth()->id())
<small class="text-info">(You)</small>
<span class="adm-user-cell-you">you</span>
@endif
</div>
<div class="adm-user-cell-email">{{ $user->email }}</div>
</div>
</div>
</td>
<td>{{ $user->email }}</td>
{{-- Role --}}
<td>
@if($user->role === 'super_admin')
<span class="badge-role badge-super-admin">Super Admin</span>
<span class="adm-badge adm-badge-superadmin"><i class="bi bi-shield-fill"></i> Super Admin</span>
@elseif($user->role === 'admin')
<span class="badge-role badge-admin">Admin</span>
<span class="adm-badge adm-badge-admin"><i class="bi bi-person-badge"></i> Admin</span>
@else
<span class="badge-role badge-user">User</span>
<span class="adm-badge adm-badge-user"><i class="bi bi-person"></i> User</span>
@endif
</td>
{{-- Verified --}}
<td>
<a href="{{ route('channel', $user->id) }}" target="_blank" class="text-decoration-none">
{{ $user->videos->count() }} videos
@if($user->email_verified_at)
<span class="adm-badge adm-badge-verified"><i class="bi bi-check-circle-fill"></i> Verified</span>
@else
<span class="adm-badge adm-badge-unverified"><i class="bi bi-clock"></i> Pending</span>
@endif
</td>
{{-- Videos --}}
<td>
<a href="{{ route('channel', $user->channel) }}" target="_blank"
class="text-dim" style="text-decoration:none; font-size:13px;">
{{ $user->videos->count() }}
<i class="bi bi-box-arrow-up-right" style="font-size:10px; opacity:.5; margin-left:2px;"></i>
</a>
</td>
<td>{{ $user->created_at->format('M d, Y') }}</td>
{{-- Joined --}}
<td class="text-muted-sm">{{ $user->created_at->format('M d, Y') }}</td>
{{-- Actions --}}
<td>
<div class="dropdown">
<button class="btn btn-sm btn-outline-light dropdown-toggle" data-bs-toggle="dropdown">
<i class="bi bi-gear"></i>
<div class="adm-row-actions" style="justify-content:flex-end;">
@if(!$user->email_verified_at)
<form method="POST" action="{{ route('admin.users.verify', $user->id) }}" style="display:inline;">
@csrf
<button type="submit" class="adm-btn adm-btn-sm adm-btn-verify" title="Manually verify account">
<i class="bi bi-patch-check-fill"></i>
</button>
<ul class="dropdown-menu dropdown-menu-dark">
<li>
<a class="dropdown-item" href="{{ route('admin.users.edit', $user->id) }}">
<i class="bi bi-pencil"></i> Edit
</form>
@endif
@if($user->id !== auth()->id() && !$user->isSuperAdmin())
<form method="POST" action="{{ route('admin.users.impersonate', $user->id) }}" style="display:inline;">
@csrf
<button type="submit" class="adm-btn adm-btn-sm adm-btn-impersonate" title="Impersonate user">
<i class="bi bi-person-fill-gear"></i>
</button>
</form>
@endif
<a href="{{ route('admin.users.edit', $user->id) }}"
class="adm-btn adm-btn-sm" title="Edit user">
<i class="bi bi-pencil"></i>
</a>
</li>
@if($user->id !== auth()->id())
<li>
<button class="dropdown-item text-danger" onclick="confirmDeleteUser({{ $user->id }}, '{{ $user->name }}')">
<i class="bi bi-trash"></i> Delete
<button type="button"
class="adm-btn adm-btn-sm adm-btn-danger"
title="Delete user"
onclick="openDeleteDialog({{ $user->id }}, '{{ addslashes($user->name) }}')">
<i class="bi bi-trash"></i>
</button>
</li>
@endif
</ul>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="text-center text-secondary py-4">
No users found
<td colspan="6">
<div class="empty-state">
<i class="bi bi-people"></i>
<p>No users found</p>
</div>
</td>
</tr>
@endforelse
@ -137,56 +183,62 @@ All Users ({{ $users->count() }})
</table>
</div>
<!-- Pagination -->
<div class="d-flex justify-content-center">
{{ $users->links() }}
{{-- Pagination --}}
@if($users instanceof \Illuminate\Pagination\LengthAwarePaginator && $users->hasPages())
<div style="padding:16px 20px; border-top:1px solid var(--border);">
{{ $users->onEachSide(1)->links() }}
</div>
@endif
</div>
<!-- Delete User Modal -->
<div class="modal fade" id="deleteUserModal" tabindex="-1" aria-labelledby="deleteUserModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content" style="background: #1a1a1a; border: 1px solid #3f3f3f; border-radius: 12px;">
<div class="modal-header" style="border-bottom: 1px solid #3f3f3f; padding: 20px 24px;">
<h5 class="modal-title" id="deleteUserModalLabel" style="color: #fff; font-weight: 600;">
<i class="bi bi-exclamation-triangle-fill text-danger me-2"></i>
{{-- ── Delete confirmation dialog ───────────────────────────────────── --}}
<div class="adm-dialog-overlay" id="deleteDialog">
<div class="adm-dialog">
<div class="adm-dialog-header">
<div class="adm-dialog-title">
<i class="bi bi-exclamation-triangle-fill"></i>
Delete User
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" style="padding: 24px;">
<p>Are you sure you want to delete this user? This action cannot be undone.</p>
<p><strong>User:</strong> <span id="deleteUserName"></span></p>
<div class="alert alert-warning">
<i class="bi bi-info-circle me-2"></i>
This will also delete all videos uploaded by this user.
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDeleteDialog()" style="border:none;background:none;color:var(--text-2);">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="adm-dialog-body">
<p>You are about to permanently delete <strong id="dlgUserName"></strong>.</p>
<div class="adm-dialog-warning">
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
All videos uploaded by this user will also be deleted. This cannot be undone.
</div>
</div>
<div class="modal-footer" style="border-top: 1px solid #3f3f3f; padding: 16px 24px;">
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>
<form id="deleteUserForm" method="POST">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger">Delete User</button>
<div class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDeleteDialog()">Cancel</button>
<form id="deleteForm" method="POST">
@csrf @method('DELETE')
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
<i class="bi bi-trash"></i> Delete User
</button>
</form>
</div>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
let currentDeleteUserId = null;
function confirmDeleteUser(userId, userName) {
currentDeleteUserId = userId;
document.getElementById('deleteUserName').textContent = userName;
document.getElementById('deleteUserForm').action = '/admin/users/' + userId;
const modal = new bootstrap.Modal(document.getElementById('deleteUserModal'));
modal.show();
}
function openDeleteDialog(userId, userName) {
document.getElementById('dlgUserName').textContent = userName;
document.getElementById('deleteForm').action = '/admin/users/' + userId;
document.getElementById('deleteDialog').classList.add('open');
}
function closeDeleteDialog() {
document.getElementById('deleteDialog').classList.remove('open');
}
document.getElementById('deleteDialog').addEventListener('click', function(e) {
if (e.target === this) closeDeleteDialog();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeDeleteDialog();
});
</script>
@endsection

View File

@ -0,0 +1,590 @@
@extends('admin.layout')
@section('title', 'Analytics — ' . $video->title)
@section('page_title', 'Video Analytics')
@php
function flagEmoji(string $code): string {
$chars = '';
foreach (str_split(strtoupper($code)) as $c) {
$chars .= mb_chr(0x1F1E6 + ord($c) - ord('A'));
}
return $chars;
}
@endphp
@section('content')
<style>
.analytics-back {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
text-decoration: none;
font-size: 13px;
margin-bottom: 18px;
transition: color .15s;
}
.analytics-back:hover { color: var(--text-primary); }
/* Video info header */
.video-info-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px 24px;
display: flex;
gap: 20px;
align-items: flex-start;
margin-bottom: 24px;
flex-wrap: wrap;
}
.video-info-thumb {
width: 140px;
height: 88px;
border-radius: 8px;
object-fit: cover;
background: #1a1a1a;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 28px;
}
.video-info-thumb img { width: 100%; height: 100%; object-fit: cover; border-radius: 8px; }
.video-info-body { flex: 1; min-width: 200px; }
.video-info-title { font-size: 18px; font-weight: 700; margin-bottom: 6px; line-height: 1.3; }
.video-info-meta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; }
.video-info-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
padding: 3px 10px;
border-radius: 20px;
background: #2a2a2a;
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.video-info-badge.red { color: var(--brand-red); border-color: var(--brand-red); }
.video-info-actions { display: flex; gap: 10px; align-items: center; margin-left: auto; flex-shrink: 0; }
/* KPI cards */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 14px;
margin-bottom: 24px;
}
.kpi-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 18px 16px;
text-align: center;
}
.kpi-card .kpi-icon {
font-size: 22px;
margin-bottom: 8px;
color: var(--text-secondary);
}
.kpi-card .kpi-value {
font-size: 26px;
font-weight: 700;
line-height: 1;
margin-bottom: 4px;
}
.kpi-card .kpi-label {
font-size: 12px;
color: var(--text-secondary);
}
.kpi-card.red .kpi-value { color: var(--brand-red); }
.kpi-card.green .kpi-value { color: #4caf50; }
.kpi-card.blue .kpi-value { color: #2196f3; }
.kpi-card.orange .kpi-value { color: #ff9800; }
.kpi-card.purple .kpi-value { color: #9c27b0; }
.kpi-card.teal .kpi-value { color: #009688; }
/* Panel */
.an-panel {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px 22px;
margin-bottom: 24px;
}
.an-panel-title {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: .6px;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 7px;
}
.chart-wrap { position: relative; height: 220px; }
.chart-wrap-tall { position: relative; height: 280px; }
/* Country list */
.country-row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
border-bottom: 1px solid rgba(255,255,255,.04);
}
.country-row:last-child { border-bottom: none; }
.country-rank { font-size: 12px; color: var(--text-secondary); width: 18px; text-align: right; flex-shrink: 0; }
.country-flag { font-size: 18px; line-height: 1; flex-shrink: 0; }
.country-name { flex: 1; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.country-bar-wrap { width: 90px; flex-shrink: 0; }
.country-bar { height: 5px; border-radius: 3px; background: var(--brand-red); min-width: 3px; }
.country-count { font-size: 13px; font-weight: 600; flex-shrink: 0; width: 36px; text-align: right; }
/* Age & gender grid */
.analytics-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
@media (max-width: 768px) { .analytics-2col { grid-template-columns: 1fr; } }
/* Gender placeholder */
.gender-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 160px;
color: var(--text-secondary);
text-align: center;
gap: 10px;
}
.gender-placeholder i { font-size: 32px; opacity: .4; }
.gender-placeholder p { font-size: 13px; line-height: 1.5; max-width: 260px; margin: 0; }
/* Recent viewers table */
.viewer-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.viewer-table th {
font-size: 11px;
text-transform: uppercase;
letter-spacing: .5px;
color: var(--text-secondary);
padding: 0 12px 10px;
text-align: left;
border-bottom: 1px solid var(--border-color);
font-weight: 500;
}
.viewer-table td { padding: 10px 12px; border-bottom: 1px solid rgba(255,255,255,.04); vertical-align: middle; }
.viewer-table tr:last-child td { border-bottom: none; }
.viewer-avatar {
width: 30px; height: 30px; border-radius: 50%; object-fit: cover;
background: #2a2a2a; display: inline-flex; align-items: center;
justify-content: center; font-size: 13px; color: var(--text-secondary);
}
.viewer-avatar img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
.guest-badge { font-size: 10px; padding: 2px 7px; border-radius: 10px; background: #252525; color: #888; border: 1px solid #333; }
.auth-badge { font-size: 10px; padding: 2px 7px; border-radius: 10px; background: rgba(33,150,243,.12); color: #64b5f6; border: 1px solid rgba(33,150,243,.25); }
</style>
<!-- Back -->
<a href="{{ route('admin.videos') }}" class="analytics-back">
<i class="bi bi-arrow-left"></i> Back to Videos
</a>
<!-- Video info header -->
<div class="video-info-card">
<div class="video-info-thumb">
@if($video->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}">
@else
<i class="bi bi-play-circle"></i>
@endif
</div>
<div class="video-info-body">
<div class="video-info-title">{{ $video->title }}</div>
<div style="font-size:13px; color:var(--text-secondary); margin-bottom:6px;">
by <a href="{{ route('channel', $video->user->channel) }}" target="_blank" style="color:var(--text-secondary);">{{ $video->user->name }}</a>
</div>
<div class="video-info-meta">
<span class="video-info-badge">
<i class="bi bi-calendar3"></i>
{{ $video->created_at->format('M d, Y') }}
</span>
<span class="video-info-badge">
<i class="bi bi-clock"></i>
{{ $video->formatted_duration ?? '—' }}
</span>
<span class="video-info-badge">
<i class="bi bi-tag"></i>
{{ ucfirst($video->type) }}
</span>
@php
$statusColors = ['ready'=>'#4caf50','processing'=>'#ff9800','pending'=>'#888','failed'=>'#f44336'];
$sc = $statusColors[$video->status] ?? '#888';
@endphp
<span class="video-info-badge" style="color:{{ $sc }}; border-color:{{ $sc }};">
<i class="bi bi-circle-fill" style="font-size:7px;"></i>
{{ ucfirst($video->status) }}
</span>
<span class="video-info-badge">
<i class="bi bi-eye"></i>
{{ ucfirst($video->visibility) }}
</span>
</div>
</div>
<div class="video-info-actions">
<a href="{{ route('videos.show', $video) }}" target="_blank" class="btn btn-sm btn-outline-light">
<i class="bi bi-play-circle"></i> Watch
</a>
<a href="{{ route('admin.videos.edit', $video) }}" class="btn btn-sm btn-outline-light">
<i class="bi bi-pencil"></i> Edit
</a>
</div>
</div>
<!-- KPIs -->
<div class="kpi-grid">
<div class="kpi-card red">
<div class="kpi-icon"><i class="bi bi-eye"></i></div>
<div class="kpi-value">{{ number_format($totalViews) }}</div>
<div class="kpi-label">Total Views</div>
<div style="font-size:10px; color:var(--text-secondary); margin-top:3px;">all watch events</div>
</div>
<div class="kpi-card blue">
<div class="kpi-icon"><i class="bi bi-people"></i></div>
<div class="kpi-value">{{ number_format($totalUniqueViewers) }}</div>
<div class="kpi-label">Unique Viewers</div>
<div style="font-size:10px; color:var(--text-secondary); margin-top:3px;">each person once</div>
</div>
<div class="kpi-card green">
<div class="kpi-icon"><i class="bi bi-heart"></i></div>
<div class="kpi-value">{{ number_format($totalLikes) }}</div>
<div class="kpi-label">Likes</div>
</div>
<div class="kpi-card orange">
<div class="kpi-icon"><i class="bi bi-chat"></i></div>
<div class="kpi-value">{{ number_format($totalComments) }}</div>
<div class="kpi-label">Comments</div>
</div>
<div class="kpi-card purple">
<div class="kpi-icon"><i class="bi bi-person-check"></i></div>
<div class="kpi-value">{{ number_format($authViewers) }}</div>
<div class="kpi-label">Logged-in</div>
</div>
<div class="kpi-card teal">
<div class="kpi-icon"><i class="bi bi-incognito"></i></div>
<div class="kpi-value">{{ number_format($guestViewers) }}</div>
<div class="kpi-label">Guests</div>
</div>
</div>
<!-- Views over time -->
<div class="an-panel">
<div class="an-panel-title"><i class="bi bi-graph-up"></i> View Events Last 30 Days <span style="font-weight:400; font-size:11px; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(total plays, not unique)</span></div>
<div class="chart-wrap">
<canvas id="dailyChart"></canvas>
</div>
</div>
<!-- Countries -->
<div class="analytics-2col">
<!-- Left: list -->
<div class="an-panel" style="margin-bottom:0;">
<div class="an-panel-title"><i class="bi bi-globe2"></i> Viewers by Country <span style="font-weight:400; font-size:11px; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(unique viewers)</span></div>
@php $maxCountry = $viewsByCountry->first()->total ?? 1; @endphp
@forelse($viewsByCountry as $i => $row)
<div class="country-row">
<span class="country-rank">{{ $i + 1 }}</span>
<span class="country-flag">{{ flagEmoji($row->country) }}</span>
<span class="country-name">{{ $row->country_name ?? $row->country }}</span>
<div class="country-bar-wrap">
<div class="country-bar" style="width:{{ round(($row->total / $maxCountry) * 100) }}%"></div>
</div>
<span class="country-count">{{ number_format($row->total) }}</span>
</div>
@empty
<div style="text-align:center; padding:40px 0; color:var(--text-secondary); font-size:13px;">
No country data yet views are still being collected.
</div>
@endforelse
</div>
<!-- Right: chart -->
<div class="an-panel" style="margin-bottom:0;">
<div class="an-panel-title"><i class="bi bi-bar-chart-horizontal"></i> Country Distribution</div>
@if($viewsByCountry->isNotEmpty())
<div class="chart-wrap-tall">
<canvas id="countryChart"></canvas>
</div>
@else
<div style="text-align:center; padding:40px 0; color:var(--text-secondary); font-size:13px;">
Chart will appear once geo data is collected.
</div>
@endif
</div>
</div>
<div style="margin-bottom:24px;"></div>
<!-- Age groups + Gender -->
<div class="analytics-2col">
<!-- Age -->
<div class="an-panel" style="margin-bottom:0;">
<div class="an-panel-title"><i class="bi bi-person-badge"></i> Age Groups <span style="font-weight:400; font-size:11px; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(unique viewers)</span></div>
@php $totalAge = array_sum($ageGroups); @endphp
@if($totalAge > 0)
<div class="chart-wrap">
<canvas id="ageChart"></canvas>
</div>
<div style="margin-top:14px; display:grid; grid-template-columns: repeat(auto-fill, minmax(100px,1fr)); gap:6px;">
@foreach($ageGroups as $label => $count)
@if($count > 0)
<div style="background:#1a1a1a; border-radius:8px; padding:8px 10px; text-align:center;">
<div style="font-size:15px; font-weight:700;">{{ number_format($count) }}</div>
<div style="font-size:11px; color:var(--text-secondary);">{{ $label }}</div>
</div>
@endif
@endforeach
</div>
@else
<div style="text-align:center; padding:40px 0; color:var(--text-secondary); font-size:13px;">
Age data requires users to set their birthday in profile settings.
</div>
@endif
</div>
<!-- Gender -->
<div class="an-panel" style="margin-bottom:0;">
<div class="an-panel-title"><i class="bi bi-gender-ambiguous"></i> Gender Split <span style="font-weight:400; font-size:11px; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(unique viewers)</span></div>
@php $genderTotal = array_sum($genderCounts); @endphp
@if($genderTotal > 0)
<div style="display:flex; align-items:center; gap:24px; flex-wrap:wrap;">
<div style="position:relative; width:160px; height:160px; flex-shrink:0; margin: 0 auto;">
<canvas id="genderChart"></canvas>
</div>
<div style="flex:1; min-width:120px;">
@php
$genderIcons = ['Male' => 'bi-gender-male', 'Female' => 'bi-gender-female', 'Prefer not to say' => 'bi-dash-circle'];
$genderColors = ['Male' => '#2196f3', 'Female' => '#e91e63', 'Prefer not to say' => '#888'];
@endphp
@foreach($genderCounts as $label => $count)
@if($count > 0)
<div style="display:flex; align-items:center; gap:9px; margin-bottom:10px;">
<span style="width:10px; height:10px; border-radius:50%; background:{{ $genderColors[$label] }}; flex-shrink:0;"></span>
<i class="bi {{ $genderIcons[$label] }}" style="color:{{ $genderColors[$label] }};"></i>
<span style="flex:1; font-size:13px;">{{ $label }}</span>
<strong style="font-size:14px;">{{ number_format($count) }}</strong>
<span style="font-size:12px; color:var(--text-secondary);">{{ round(($count / $genderTotal) * 100) }}%</span>
</div>
@endif
@endforeach
<div style="font-size:11px; color:var(--text-secondary); margin-top:8px;">Based on {{ number_format($genderTotal) }} logged-in viewer{{ $genderTotal !== 1 ? 's' : '' }} who set their gender.</div>
</div>
</div>
@else
<div class="gender-placeholder">
<i class="bi bi-gender-ambiguous"></i>
<p>No gender data yet. Viewers can set their gender in profile settings.</p>
</div>
@endif
</div>
</div>
<div style="margin-bottom:24px;"></div>
<!-- Recent viewers -->
<div class="an-panel">
<div class="an-panel-title"><i class="bi bi-clock-history"></i> Recent View Events <span style="font-weight:400; font-size:11px; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(last 20 individual plays)</span></div>
@if($recentViews->isNotEmpty())
<div style="overflow-x:auto;">
<table class="viewer-table">
<thead>
<tr>
<th>Viewer</th>
<th>Country</th>
<th>IP Address</th>
<th>Type</th>
<th>Watched At</th>
</tr>
</thead>
<tbody>
@foreach($recentViews as $view)
<tr>
<td>
<div style="display:flex; align-items:center; gap:10px;">
<div class="viewer-avatar">
@if($view->viewer_avatar)
<img src="{{ asset('storage/avatars/' . $view->viewer_avatar) }}" alt="">
@else
<i class="bi bi-person"></i>
@endif
</div>
<span>{{ $view->viewer_name ?? 'Guest' }}</span>
</div>
</td>
<td>
@if($view->country)
<span style="font-size:16px;">{{ flagEmoji($view->country) }}</span>
<span style="font-size:13px; margin-left:4px;">{{ $view->country_name ?? $view->country }}</span>
@else
<span style="color:var(--text-secondary); font-size:12px;">Unknown</span>
@endif
</td>
<td style="font-family:monospace; font-size:12px; color:var(--text-secondary);">
{{ $view->ip_address ?? '—' }}
</td>
<td>
@if($view->user_id)
<span class="auth-badge"><i class="bi bi-person-check"></i> Logged in</span>
@else
<span class="guest-badge"><i class="bi bi-incognito"></i> Guest</span>
@endif
</td>
<td style="color:var(--text-secondary); font-size:12px; white-space:nowrap;">
{{ \Carbon\Carbon::parse($view->watched_at)->diffForHumans() }}
<br><span style="font-size:11px; opacity:.6;">{{ \Carbon\Carbon::parse($view->watched_at)->format('M d, Y H:i') }}</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div style="text-align:center; padding:40px 0; color:var(--text-secondary); font-size:13px;">
No views recorded yet.
</div>
@endif
</div>
@endsection
@section('scripts')
<script>
// ── Shared colours ──────────────────────────────────────────────────────────
const RED = 'rgba(230,30,30,1)';
const RED_BG = 'rgba(230,30,30,0.15)';
const GRID = 'rgba(255,255,255,0.06)';
const TICK = '#888';
Chart.defaults.color = TICK;
Chart.defaults.borderColor = GRID;
// ── Views per day ───────────────────────────────────────────────────────────
new Chart(document.getElementById('dailyChart'), {
type: 'line',
data: {
labels: {!! json_encode($dailyLabels) !!},
datasets: [{
label: 'Views',
data: {!! json_encode($dailyViews) !!},
borderColor: RED,
backgroundColor: RED_BG,
borderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,
pointBackgroundColor: RED,
fill: true,
tension: 0.3,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: GRID }, ticks: { color: TICK, maxTicksLimit: 10 } },
y: { grid: { color: GRID }, ticks: { color: TICK, precision: 0 }, beginAtZero: true }
}
}
});
// ── Country chart ───────────────────────────────────────────────────────────
@if($viewsByCountry->isNotEmpty())
const countryLabels = {!! json_encode($viewsByCountry->map(fn($r) => ($r->country_name ?? $r->country))->values()) !!};
const countryData = {!! json_encode($viewsByCountry->pluck('total')->values()) !!};
const countryMax = Math.max(...countryData);
new Chart(document.getElementById('countryChart'), {
type: 'bar',
data: {
labels: countryLabels,
datasets: [{
data: countryData,
backgroundColor: countryData.map((v, i) => `rgba(230,30,30,${0.9 - (i / countryData.length) * 0.55})`),
borderRadius: 4,
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: GRID }, ticks: { color: TICK, precision: 0 }, beginAtZero: true },
y: { grid: { color: 'transparent' }, ticks: { color: TICK } }
}
}
});
@endif
// ── Age groups ──────────────────────────────────────────────────────────────
@php
$ageLabels = array_keys($ageGroups);
$ageValues = array_values($ageGroups);
$ageColors = ['rgba(33,150,243,0.85)', 'rgba(76,175,80,0.85)', 'rgba(255,152,0,0.85)',
'rgba(233,30,99,0.85)', 'rgba(156,39,176,0.85)', 'rgba(0,188,212,0.85)', 'rgba(120,120,120,0.6)'];
@endphp
@if(array_sum($ageGroups) > 0)
new Chart(document.getElementById('ageChart'), {
type: 'bar',
data: {
labels: {!! json_encode($ageLabels) !!},
datasets: [{
data: {!! json_encode($ageValues) !!},
backgroundColor: {!! json_encode($ageColors) !!},
borderRadius: 5,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: 'transparent' }, ticks: { color: TICK } },
y: { grid: { color: GRID }, ticks: { color: TICK, precision: 0 }, beginAtZero: true }
}
}
});
@endif
// ── Gender donut ─────────────────────────────────────────────────────────────
@php $genderTotal = array_sum($genderCounts); @endphp
@if($genderTotal > 0)
new Chart(document.getElementById('genderChart'), {
type: 'doughnut',
data: {
labels: {!! json_encode(array_keys($genderCounts)) !!},
datasets: [{
data: {!! json_encode(array_values($genderCounts)) !!},
backgroundColor: ['#2196f3', '#e91e63', '#555'],
borderWidth: 0,
hoverOffset: 6,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => ` ${ctx.label}: ${ctx.parsed} (${Math.round(ctx.parsed / {{ $genderTotal }} * 100)}%)`
}
}
}
}
});
@endif
</script>
@endsection

View File

@ -1,88 +1,96 @@
@extends('admin.layout')
@section('title', 'Video Management')
@section('page_title', 'Video Management')
@section('title', 'Videos')
@section('content')
<!-- Alerts -->
{{-- ── Page header ──────────────────────────────────────────────────── --}}
<div class="adm-page-header">
<h1 class="adm-page-title"><i class="bi bi-play-circle"></i> Videos</h1>
</div>
{{-- ── Alerts ───────────────────────────────────────────────────────── --}}
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="adm-alert adm-alert-success">
<i class="bi bi-check-circle-fill"></i>
<span>{{ session('success') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
@if(session('error'))
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ session('error') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="adm-alert adm-alert-error">
<i class="bi bi-exclamation-circle-fill"></i>
<span>{{ session('error') }}</span>
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
</div>
@endif
<!-- Search & Filters -->
<div class="admin-card">
<form method="GET" action="{{ route('admin.videos') }}" class="filter-form">
<div class="form-group">
<label for="search">Search</label>
<input type="text" name="search" id="search" class="form-control" placeholder="Search by title or description..." value="{{ request('search') }}">
{{-- ── Filter card ──────────────────────────────────────────────────── --}}
<div class="adm-card">
<div class="adm-card-body" style="padding:16px 20px;">
<form method="GET" action="{{ route('admin.videos') }}" class="adm-filter-form">
<div class="adm-filter-search">
<i class="bi bi-search"></i>
<input type="text" name="search" class="adm-input"
placeholder="Search title or description…"
value="{{ request('search') }}" autocomplete="off">
</div>
<div class="form-group">
<label for="status">Status</label>
<select name="status" id="status" class="form-select">
<select name="status" class="adm-select">
<option value="">All Status</option>
<option value="ready" {{ request('status') == 'ready' ? 'selected' : '' }}>Ready</option>
<option value="processing" {{ request('status') == 'processing' ? 'selected' : '' }}>Processing</option>
<option value="pending" {{ request('status') == 'pending' ? 'selected' : '' }}>Pending</option>
<option value="failed" {{ request('status') == 'failed' ? 'selected' : '' }}>Failed</option>
<option value="ready" {{ request('status') === 'ready' ? 'selected' : '' }}>Ready</option>
<option value="processing" {{ request('status') === 'processing' ? 'selected' : '' }}>Processing</option>
<option value="pending" {{ request('status') === 'pending' ? 'selected' : '' }}>Pending</option>
<option value="failed" {{ request('status') === 'failed' ? 'selected' : '' }}>Failed</option>
</select>
</div>
<div class="form-group">
<label for="visibility">Visibility</label>
<select name="visibility" id="visibility" class="form-select">
<select name="visibility" class="adm-select">
<option value="">All Visibility</option>
<option value="public" {{ request('visibility') == 'public' ? 'selected' : '' }}>Public</option>
<option value="unlisted" {{ request('visibility') == 'unlisted' ? 'selected' : '' }}>Unlisted</option>
<option value="private" {{ request('visibility') == 'private' ? 'selected' : '' }}>Private</option>
<option value="public" {{ request('visibility') === 'public' ? 'selected' : '' }}>Public</option>
<option value="unlisted" {{ request('visibility') === 'unlisted' ? 'selected' : '' }}>Unlisted</option>
<option value="private" {{ request('visibility') === 'private' ? 'selected' : '' }}>Private</option>
</select>
</div>
<div class="form-group">
<label for="type">Type</label>
<select name="type" id="type" class="form-select">
<select name="type" class="adm-select">
<option value="">All Types</option>
<option value="generic" {{ request('type') == 'generic' ? 'selected' : '' }}>Generic</option>
<option value="music" {{ request('type') == 'music' ? 'selected' : '' }}>Music</option>
<option value="match" {{ request('type') == 'match' ? 'selected' : '' }}>Match</option>
<option value="generic" {{ request('type') === 'generic' ? 'selected' : '' }}>Generic</option>
<option value="music" {{ request('type') === 'music' ? 'selected' : '' }}>Music</option>
<option value="match" {{ request('type') === 'match' ? 'selected' : '' }}>Match</option>
</select>
</div>
<div class="form-group">
<label for="sort">Sort By</label>
<select name="sort" id="sort" class="form-select">
<option value="latest" {{ request('sort') == 'latest' ? 'selected' : '' }}>Latest First</option>
<option value="oldest" {{ request('sort') == 'oldest' ? 'selected' : '' }}>Oldest First</option>
<option value="title_asc" {{ request('sort') == 'title_asc' ? 'selected' : '' }}>Title (A-Z)</option>
<option value="title_desc" {{ request('sort') == 'title_desc' ? 'selected' : '' }}>Title (Z-A)</option>
<select name="sort" class="adm-select">
<option value="latest" {{ request('sort', 'latest') === 'latest' ? 'selected' : '' }}>Newest first</option>
<option value="oldest" {{ request('sort') === 'oldest' ? 'selected' : '' }}>Oldest first</option>
<option value="title_asc" {{ request('sort') === 'title_asc' ? 'selected' : '' }}>Title AZ</option>
<option value="title_desc" {{ request('sort') === 'title_desc' ? 'selected' : '' }}>Title ZA</option>
</select>
</div>
<div class="form-group">
<label>&nbsp;</label>
<button type="submit" class="btn btn-primary">
<i class="bi bi-search"></i> Filter
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-funnel"></i> Filter
</button>
<a href="{{ route('admin.videos') }}" class="btn btn-outline-light">
<i class="bi bi-x-circle"></i> Clear
@if(request()->hasAny(['search','status','visibility','type','sort']))
<a href="{{ route('admin.videos') }}" class="adm-btn">
<i class="bi bi-x-lg"></i> Clear
</a>
</div>
@endif
</form>
</div>
</div>
<!-- Videos Table -->
<div class="admin-card">
<div class="admin-card-header">
All Videos ({{ $videos->count() }})
{{-- ── Videos table ─────────────────────────────────────────────────── --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title">
<i class="bi bi-play-circle"></i>
All Videos
<span class="adm-badge adm-badge-user">{{ $videos->total() ?? $videos->count() }}</span>
</div>
</div>
<div class="table-responsive">
<table class="admin-table">
<div class="adm-table-wrap">
<table class="adm-table">
<thead>
<tr>
<th>Video</th>
@ -93,97 +101,128 @@ All Videos ({{ $videos->count() }})
<th>Views</th>
<th>Likes</th>
<th>Uploaded</th>
<th>Actions</th>
<th style="width:110px; text-align:right;">Actions</th>
</tr>
</thead>
<tbody>
@forelse($videos as $video)
<tr>
{{-- Thumbnail + title --}}
<td>
<div class="d-flex align-items-center gap-2">
<div style="display:flex; align-items:center; gap:12px;">
@if($video->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}" style="width: 80px; height: 50px; object-fit: cover; border-radius: 4px;">
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}"
alt="" style="width:72px;height:44px;object-fit:cover;border-radius:6px;flex-shrink:0;border:1px solid var(--border);">
@else
<div style="width: 80px; height: 50px; background: #333; border-radius: 4px; display: flex; align-items: center; justify-content: center;">
<i class="bi bi-play-circle text-secondary"></i>
<div style="width:72px;height:44px;border-radius:6px;background:var(--bg-card2);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i class="bi bi-play-circle" style="color:var(--text-3);font-size:18px;"></i>
</div>
@endif
<div style="min-width:0;">
<div style="font-weight:500;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;">
{{ $video->title }}
</div>
@if($video->description)
<div style="font-size:11px;color:var(--text-2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;">
{{ Str::limit($video->description, 55) }}
</div>
@endif
<div style="max-width: 200px;">
<div style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 500;">{{ $video->title }}</div>
<small class="text-secondary">{{ Str::limit($video->description, 50) }}</small>
</div>
</div>
</td>
{{-- Owner --}}
<td>
<a href="{{ route('channel', $video->user->id) }}" target="_blank" class="text-decoration-none">
<a href="{{ route('channel', $video->user->channel) }}" target="_blank"
style="font-size:13px;color:var(--text);text-decoration:none;display:flex;align-items:center;gap:4px;">
{{ $video->user->name }}
<i class="bi bi-box-arrow-up-right" style="font-size:10px;opacity:.4;"></i>
</a>
</td>
{{-- Status --}}
<td>
@switch($video->status)
@case('ready')
<span class="badge-status badge-ready">Ready</span>
@break
@case('processing')
<span class="badge-status badge-processing">Processing</span>
@break
@case('pending')
<span class="badge-status badge-pending">Pending</span>
@break
@case('failed')
<span class="badge-status badge-failed">Failed</span>
@break
@endswitch
@php
$statusMap = [
'ready' => ['adm-badge-ready', 'bi-check-circle-fill', 'Ready'],
'processing' => ['adm-badge-unlisted','bi-arrow-repeat', 'Processing'],
'pending' => ['adm-badge-unverified','bi-clock', 'Pending'],
'failed' => ['adm-badge-superadmin','bi-x-circle-fill', 'Failed'],
];
[$cls, $ico, $lbl] = $statusMap[$video->status] ?? ['adm-badge-user','bi-dash','Unknown'];
@endphp
<span class="adm-badge {{ $cls }}"><i class="bi {{ $ico }}"></i> {{ $lbl }}</span>
</td>
{{-- Visibility --}}
<td>
@switch($video->visibility)
@case('public')
<span class="badge-status badge-public">Public</span>
@break
@case('unlisted')
<span class="badge-status badge-unlisted">Unlisted</span>
@break
@case('private')
<span class="badge-status badge-private">Private</span>
@break
@endswitch
@php
$visMap = [
'public' => ['adm-badge-ready', 'bi-globe2', 'Public'],
'unlisted' => ['adm-badge-unlisted','bi-link-45deg', 'Unlisted'],
'private' => ['adm-badge-private', 'bi-lock-fill', 'Private'],
];
[$vcls, $vico, $vlbl] = $visMap[$video->visibility] ?? ['adm-badge-user','bi-question','—'];
@endphp
<span class="adm-badge {{ $vcls }}"><i class="bi {{ $vico }}"></i> {{ $vlbl }}</span>
</td>
{{-- Type --}}
<td>
<span class="text-capitalize">{{ $video->type }}</span>
@php
$typeMap = [
'generic' => ['bi-play-circle','#94a3b8'],
'music' => ['bi-music-note-beamed','#a78bfa'],
'match' => ['bi-trophy','#fbbf24'],
];
[$tico, $tcol] = $typeMap[$video->type] ?? ['bi-play-circle','#94a3b8'];
@endphp
<span style="display:inline-flex;align-items:center;gap:5px;font-size:12px;color:{{ $tcol }};">
<i class="bi {{ $tico }}"></i>
{{ ucfirst($video->type) }}
</span>
</td>
<td>{{ number_format(\DB::table('video_views')->where('video_id', $video->id)->count()) }}</td>
<td>{{ number_format(\DB::table('video_likes')->where('video_id', $video->id)->count()) }}</td>
<td>{{ $video->created_at->format('M d, Y') }}</td>
{{-- Views --}}
<td style="font-size:13px;color:var(--text-2);">
{{ number_format(\DB::table('video_views')->where('video_id',$video->id)->count()) }}
</td>
{{-- Likes --}}
<td style="font-size:13px;color:var(--text-2);">
{{ number_format(\DB::table('video_likes')->where('video_id',$video->id)->count()) }}
</td>
{{-- Date --}}
<td class="text-muted-sm">{{ $video->created_at->format('M d, Y') }}</td>
{{-- Actions --}}
<td>
<div class="dropdown">
<button class="btn btn-sm btn-outline-light dropdown-toggle" data-bs-toggle="dropdown">
<i class="bi bi-gear"></i>
<div class="adm-row-actions" style="justify-content:flex-end;">
<a href="{{ route('videos.show', $video) }}" target="_blank"
class="adm-btn adm-btn-sm" title="Watch">
<i class="bi bi-play"></i>
</a>
<a href="{{ route('admin.videos.edit', $video) }}"
class="adm-btn adm-btn-sm" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<button type="button"
class="adm-btn adm-btn-sm adm-btn-danger"
title="Delete"
onclick="openDeleteDialog('{{ $video->getRouteKey() }}', '{{ addslashes($video->title) }}')">
<i class="bi bi-trash"></i>
</button>
<ul class="dropdown-menu dropdown-menu-dark">
<li>
<a class="dropdown-item" href="{{ route('videos.show', $video->id) }}" target="_blank">
<i class="bi bi-play-circle"></i> View
</a>
</li>
<li>
<a class="dropdown-item" href="{{ route('admin.videos.edit', $video->id) }}">
<i class="bi bi-pencil"></i> Edit
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<button class="dropdown-item text-danger" onclick="confirmDeleteVideo({{ $video->id }}, '{{ $video->title }}')">
<i class="bi bi-trash"></i> Delete
</button>
</li>
</ul>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="9" class="text-center text-secondary py-4">
No videos found
<td colspan="9">
<div class="empty-state">
<i class="bi bi-play-circle"></i>
<p>No videos found</p>
</div>
</td>
</tr>
@endforelse
@ -191,53 +230,62 @@ All Videos ({{ $videos->count() }})
</table>
</div>
<!-- Pagination -->
<div class="d-flex justify-content-center">
{{ $videos->links() }}
{{-- Pagination --}}
@if($videos instanceof \Illuminate\Pagination\LengthAwarePaginator && $videos->hasPages())
<div style="padding:16px 20px; border-top:1px solid var(--border);">
{{ $videos->onEachSide(1)->links() }}
</div>
@endif
</div>
<!-- Delete Video Modal -->
<div class="modal fade" id="deleteVideoModal" tabindex="-1" aria-labelledby="deleteVideoModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content" style="background: #1a1a1a; border: 1px solid #3f3f3f; border-radius: 12px;">
<div class="modal-header" style="border-bottom: 1px solid #3f3f3f; padding: 20px 24px;">
<h5 class="modal-title" id="deleteVideoModalLabel" style="color: #fff; font-weight: 600;">
<i class="bi bi-exclamation-triangle-fill text-danger me-2"></i>
Delete Video
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
{{-- ── Delete confirmation dialog ───────────────────────────────────── --}}
<div class="adm-dialog-overlay" id="deleteDialog">
<div class="adm-dialog">
<div class="adm-dialog-header">
<div class="adm-dialog-title">
<i class="bi bi-exclamation-triangle-fill"></i> Delete Video
</div>
<div class="modal-body" style="padding: 24px;">
<p>Are you sure you want to delete this video? This action cannot be undone.</p>
<p><strong>Video:</strong> <span id="deleteVideoTitle"></span></p>
<div class="alert alert-warning">
<i class="bi bi-info-circle me-2"></i>
This will also delete all likes and views associated with this video.
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDeleteDialog()"
style="border:none;background:none;color:var(--text-2);">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="adm-dialog-body">
<p>You are about to permanently delete <strong id="dlgVideoTitle"></strong>.</p>
<div class="adm-dialog-warning">
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
All views, likes, comments, and HLS files for this video will also be deleted.
</div>
</div>
<div class="modal-footer" style="border-top: 1px solid #3f3f3f; padding: 16px 24px;">
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>
<form id="deleteVideoForm" method="POST">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger">Delete Video</button>
<div class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDeleteDialog()">Cancel</button>
<form id="deleteForm" method="POST">
@csrf @method('DELETE')
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
<i class="bi bi-trash"></i> Delete Video
</button>
</form>
</div>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
function confirmDeleteVideo(videoId, videoTitle) {
document.getElementById('deleteVideoTitle').textContent = videoTitle;
document.getElementById('deleteVideoForm').action = '/admin/videos/' + videoId;
const modal = new bootstrap.Modal(document.getElementById('deleteVideoModal'));
modal.show();
}
function openDeleteDialog(videoId, videoTitle) {
document.getElementById('dlgVideoTitle').textContent = videoTitle;
document.getElementById('deleteForm').action = '/admin/videos/' + videoId;
document.getElementById('deleteDialog').classList.add('open');
}
function closeDeleteDialog() {
document.getElementById('deleteDialog').classList.remove('open');
}
document.getElementById('deleteDialog').addEventListener('click', function(e) {
if (e.target === this) closeDeleteDialog();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeDeleteDialog();
});
</script>
@endsection

View File

@ -0,0 +1,31 @@
@extends('layouts.auth')
@section('title', 'Two-Factor Authentication | ' . config('app.name'))
@section('content')
<div class="auth-card">
<h1 class="auth-title">Two-Factor Authentication</h1>
<p class="auth-subtitle">Enter the 6-digit code from your authenticator app</p>
@if($errors->any())
<div class="auth-error">
<i class="bi bi-exclamation-circle-fill"></i>
<span>{{ $errors->first() }}</span>
</div>
@endif
<form method="POST" action="{{ route('2fa.verify') }}">
@csrf
<div class="form-group">
<label class="form-label">Authentication Code</label>
<input type="text" name="code" class="form-input"
inputmode="numeric" pattern="[0-9]*" maxlength="6"
placeholder="000000" autofocus autocomplete="one-time-code"
style="letter-spacing: 0.3em; font-size: 22px; text-align: center;">
</div>
<button type="submit" class="btn-primary">Verify</button>
</form>
</div>
@endsection

Some files were not shown because too many files have changed in this diff Show More