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:
parent
d44490dfe0
commit
0b2e95ea65
186
.claude/component-usage.md
Normal file
186
.claude/component-usage.md
Normal 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, 1–31), month list (January–December), 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
5
.claude/settings.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"frontend-design@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
11
.env.example
11
.env.example
@ -57,3 +57,14 @@ VITE_PUSHER_HOST="${PUSHER_HOST}"
|
|||||||
VITE_PUSHER_PORT="${PUSHER_PORT}"
|
VITE_PUSHER_PORT="${PUSHER_PORT}"
|
||||||
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
|
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
|
||||||
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
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
156
CLAUDE.md
Normal 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
27
README_cleanup.md
Normal 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. 🎥🧹
|
||||||
10
TODO_admin_cleanup_dashboard.md
Normal file
10
TODO_admin_cleanup_dashboard.md
Normal 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
361
app/Data/Countries.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,28 +3,32 @@
|
|||||||
namespace App\Exceptions;
|
namespace App\Exceptions;
|
||||||
|
|
||||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class Handler extends ExceptionHandler
|
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 = [
|
protected $dontFlash = [
|
||||||
'current_password',
|
'current_password',
|
||||||
'password',
|
'password',
|
||||||
'password_confirmation',
|
'password_confirmation',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Register the exception handling callbacks for the application.
|
|
||||||
*/
|
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
$this->reportable(function (Throwable $e) {
|
$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
101
app/Helpers/Horoscope.php
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AuditLog;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
@ -23,10 +24,32 @@ class AuthenticatedSessionController extends Controller
|
|||||||
$remember = $request->filled('remember');
|
$remember = $request->filled('remember');
|
||||||
|
|
||||||
if (Auth::attempt($credentials, $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();
|
$request->session()->regenerate();
|
||||||
|
|
||||||
|
AuditLog::record('user.login', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'user_name' => $user->name,
|
||||||
|
'details' => ['email' => $user->email],
|
||||||
|
]);
|
||||||
|
|
||||||
return redirect()->intended('/videos');
|
return redirect()->intended('/videos');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AuditLog::record('user.login.failed', [
|
||||||
|
'user_id' => null,
|
||||||
|
'user_name' => null,
|
||||||
|
'details' => ['email' => $credentials['email']],
|
||||||
|
]);
|
||||||
|
|
||||||
return back()->withErrors([
|
return back()->withErrors([
|
||||||
'email' => 'The provided credentials do not match our records.',
|
'email' => 'The provided credentials do not match our records.',
|
||||||
]);
|
]);
|
||||||
@ -34,6 +57,13 @@ class AuthenticatedSessionController extends Controller
|
|||||||
|
|
||||||
public function destroy(Request $request)
|
public function destroy(Request $request)
|
||||||
{
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
if ($user) {
|
||||||
|
AuditLog::record('user.logout', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'user_name' => $user->name,
|
||||||
|
]);
|
||||||
|
}
|
||||||
Auth::logout();
|
Auth::logout();
|
||||||
$request->session()->invalidate();
|
$request->session()->invalidate();
|
||||||
$request->session()->regenerateToken();
|
$request->session()->regenerateToken();
|
||||||
|
|||||||
@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Rules\NotDisposableEmail;
|
||||||
use Illuminate\Auth\Events\Registered;
|
use Illuminate\Auth\Events\Registered;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
@ -18,22 +19,34 @@ class RegisteredUserController extends Controller
|
|||||||
|
|
||||||
public function store(Request $request)
|
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([
|
$request->validate([
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'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()],
|
'password' => ['required', 'confirmed', Password::defaults()],
|
||||||
|
'birthday' => ['required', 'date', 'before:today'],
|
||||||
|
'gender' => ['required', 'in:male,female'],
|
||||||
|
'nationality' => ['required', 'string', 'size:2'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::create([
|
$user = User::create([
|
||||||
'name' => $request->name,
|
'name' => $request->name,
|
||||||
'email' => $request->email,
|
'email' => $request->email,
|
||||||
'password' => Hash::make($request->password),
|
'password' => Hash::make($request->password),
|
||||||
|
'birthday' => $request->birthday,
|
||||||
|
'gender' => $request->gender,
|
||||||
|
'nationality' => $request->nationality,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
event(new Registered($user));
|
event(new Registered($user));
|
||||||
|
|
||||||
auth()->login($user);
|
auth()->login($user);
|
||||||
|
|
||||||
return redirect('/videos');
|
return redirect()->route('verification.notice');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,11 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Comment;
|
use App\Models\Comment;
|
||||||
|
use App\Models\CommentLike;
|
||||||
use App\Models\Video;
|
use App\Models\Video;
|
||||||
|
use App\Notifications\NewCommentLikeNotification;
|
||||||
|
use App\Notifications\NewCommentNotification;
|
||||||
|
use App\Notifications\NewReplyNotification;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
@ -24,28 +28,37 @@ class CommentController extends Controller
|
|||||||
public function store(Request $request, Video $video)
|
public function store(Request $request, Video $video)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'body' => 'required|string|max:1000',
|
'body' => 'required|string|max:1000',
|
||||||
'parent_id' => 'nullable|exists:comments,id',
|
'parent_id' => 'nullable|exists:comments,id',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$commenter = Auth::user();
|
||||||
|
|
||||||
$comment = $video->comments()->create([
|
$comment = $video->comments()->create([
|
||||||
'user_id' => Auth::id(),
|
'user_id' => $commenter->id,
|
||||||
'body' => $request->body,
|
'body' => $request->body,
|
||||||
'parent_id' => $request->parent_id,
|
'parent_id' => $request->parent_id,
|
||||||
]);
|
]);
|
||||||
// $video->increment('comment_count'); // Disabled - was causing SQL error
|
|
||||||
$comment->load('user:id,name,avatar_url');
|
|
||||||
|
|
||||||
// Handle mentions
|
$comment->load('user:id,name,avatar');
|
||||||
preg_match_all('/@(\w+)/', $request->body, $matches);
|
|
||||||
if (! empty($matches[1])) {
|
// Fire notifications (never notify yourself)
|
||||||
// Mentions found - in production, you would send notifications here
|
if ($request->parent_id) {
|
||||||
// For now, we just parse them
|
// 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([
|
return response()->json([
|
||||||
'success' => true,
|
'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',
|
'body' => 'required|string|max:1000',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$comment->update([
|
$comment->update(['body' => $request->body]);
|
||||||
'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'));
|
||||||
|
|
||||||
return response()->json($comment->load('user:id,name,avatar_url'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Comment $comment)
|
public function destroy(Comment $comment)
|
||||||
@ -78,4 +88,34 @@ class CommentController extends Controller
|
|||||||
|
|
||||||
return response()->json(['success' => true]);
|
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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,4 +9,29 @@ use Illuminate\Routing\Controller as BaseController;
|
|||||||
class Controller extends BaseController
|
class Controller extends BaseController
|
||||||
{
|
{
|
||||||
use AuthorizesRequests, ValidatesRequests;
|
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}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
app/Http/Controllers/ImageUploadController.php
Normal file
45
app/Http/Controllers/ImageUploadController.php
Normal 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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,14 +4,17 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Models\Playlist;
|
use App\Models\Playlist;
|
||||||
use App\Models\Video;
|
use App\Models\Video;
|
||||||
|
use App\Services\GeoIpService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class PlaylistController extends Controller
|
class PlaylistController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct()
|
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
|
// List user's playlists
|
||||||
@ -31,11 +34,100 @@ class PlaylistController extends Controller
|
|||||||
abort(404, 'Playlist not found');
|
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'));
|
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
|
// Create new playlist form
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
@ -48,8 +140,8 @@ class PlaylistController extends Controller
|
|||||||
$request->validate([
|
$request->validate([
|
||||||
'name' => 'required|string|max:100',
|
'name' => 'required|string|max:100',
|
||||||
'description' => 'nullable|string|max:500',
|
'description' => 'nullable|string|max:500',
|
||||||
'visibility' => 'nullable|in:public,private',
|
'visibility' => 'nullable|in:public,private,unlisted',
|
||||||
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:5120',
|
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:20480',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$playlistData = [
|
$playlistData = [
|
||||||
@ -58,6 +150,7 @@ class PlaylistController extends Controller
|
|||||||
'description' => $request->description,
|
'description' => $request->description,
|
||||||
'visibility' => $request->visibility ?? 'private',
|
'visibility' => $request->visibility ?? 'private',
|
||||||
'is_default' => false,
|
'is_default' => false,
|
||||||
|
'share_token' => Str::random(32),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Create playlist first to get ID for thumbnail naming
|
// Create playlist first to get ID for thumbnail naming
|
||||||
@ -66,7 +159,7 @@ class PlaylistController extends Controller
|
|||||||
// Handle thumbnail upload
|
// Handle thumbnail upload
|
||||||
if ($request->hasFile('thumbnail')) {
|
if ($request->hasFile('thumbnail')) {
|
||||||
$file = $request->file('thumbnail');
|
$file = $request->file('thumbnail');
|
||||||
$filename = 'playlist_'.$playlist->id.'_'.time().'.'.$file->getClientOriginalExtension();
|
$filename = self::generateFilename($file->getClientOriginalExtension());
|
||||||
$file->storeAs('public/thumbnails', $filename);
|
$file->storeAs('public/thumbnails', $filename);
|
||||||
$playlist->update(['thumbnail' => $filename]);
|
$playlist->update(['thumbnail' => $filename]);
|
||||||
}
|
}
|
||||||
@ -123,8 +216,8 @@ class PlaylistController extends Controller
|
|||||||
$request->validate([
|
$request->validate([
|
||||||
'name' => 'required|string|max:100',
|
'name' => 'required|string|max:100',
|
||||||
'description' => 'nullable|string|max:500',
|
'description' => 'nullable|string|max:500',
|
||||||
'visibility' => 'nullable|in:public,private',
|
'visibility' => 'nullable|in:public,private,unlisted',
|
||||||
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:5120',
|
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:20480',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$updateData = [
|
$updateData = [
|
||||||
@ -145,7 +238,7 @@ class PlaylistController extends Controller
|
|||||||
|
|
||||||
// Upload new thumbnail
|
// Upload new thumbnail
|
||||||
$file = $request->file('thumbnail');
|
$file = $request->file('thumbnail');
|
||||||
$filename = 'playlist_'.$playlist->id.'_'.time().'.'.$file->getClientOriginalExtension();
|
$filename = self::generateFilename($file->getClientOriginalExtension());
|
||||||
$file->storeAs('public/thumbnails', $filename);
|
$file->storeAs('public/thumbnails', $filename);
|
||||||
$updateData['thumbnail'] = $filename;
|
$updateData['thumbnail'] = $filename;
|
||||||
}
|
}
|
||||||
@ -351,7 +444,7 @@ class PlaylistController extends Controller
|
|||||||
// Play all videos in playlist (redirect to first video with playlist context)
|
// Play all videos in playlist (redirect to first video with playlist context)
|
||||||
public function playAll(Playlist $playlist)
|
public function playAll(Playlist $playlist)
|
||||||
{
|
{
|
||||||
if (! $playlist->canView(Auth::user())) {
|
if (! $playlist->canViewViaToken(Auth::user())) {
|
||||||
abort(404, 'Playlist not found');
|
abort(404, 'Playlist not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -361,17 +454,13 @@ class PlaylistController extends Controller
|
|||||||
return back()->with('error', 'Playlist is empty.');
|
return back()->with('error', 'Playlist is empty.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to first video with playlist parameter
|
return redirect(route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token);
|
||||||
return redirect()->route('videos.show', [
|
|
||||||
'video' => $firstVideo->id,
|
|
||||||
'playlist' => $playlist->id,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shuffle play - redirect to random video
|
// Shuffle play - redirect to random video
|
||||||
public function shuffle(Playlist $playlist)
|
public function shuffle(Playlist $playlist)
|
||||||
{
|
{
|
||||||
if (! $playlist->canView(Auth::user())) {
|
if (! $playlist->canViewViaToken(Auth::user())) {
|
||||||
abort(404, 'Playlist not found');
|
abort(404, 'Playlist not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -381,9 +470,6 @@ class PlaylistController extends Controller
|
|||||||
return back()->with('error', 'Playlist is empty.');
|
return back()->with('error', 'Playlist is empty.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->route('videos.show', [
|
return redirect(route('videos.show', $randomVideo) . '?playlist=' . $playlist->share_token);
|
||||||
'video' => $randomVideo->id,
|
|
||||||
'playlist' => $playlist->id,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
126
app/Http/Controllers/PostController.php
Normal file
126
app/Http/Controllers/PostController.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,47 +2,242 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\Setting;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Video;
|
use App\Models\Video;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class SuperAdminController extends Controller
|
class SuperAdminController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct()
|
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
|
// Dashboard - Overview statistics
|
||||||
public function dashboard()
|
public function dashboard()
|
||||||
{
|
{
|
||||||
$stats = [
|
$now = now();
|
||||||
'total_users' => User::count(),
|
$w0 = $now->copy()->subDays(7); // start of this week window
|
||||||
'total_videos' => Video::count(),
|
$w1 = $now->copy()->subDays(14); // start of last week window
|
||||||
'total_views' => \DB::table('video_views')->count('id'),
|
|
||||||
'total_likes' => \DB::table('video_likes')->count('id'),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Recent users
|
// ── Core totals ────────────────────────────────────────────
|
||||||
$recentUsers = User::latest()->take(5)->get();
|
$totalUsers = User::count();
|
||||||
|
$totalVideos = Video::count();
|
||||||
|
$totalViews = \DB::table('video_views')->count();
|
||||||
|
$totalLikes = \DB::table('video_likes')->count();
|
||||||
|
$totalComments = \DB::table('comments')->count();
|
||||||
|
|
||||||
// Recent videos
|
// ── 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();
|
||||||
$recentVideos = Video::with('user')->latest()->take(5)->get();
|
$recentVideos = Video::with('user')->latest()->take(5)->get();
|
||||||
|
|
||||||
// Videos by status
|
// ── Storage ───────────────────────────────────────────────
|
||||||
$videosByStatus = Video::select('status', \DB::raw('count(*) as count'))
|
$disk = Storage::disk('public');
|
||||||
->groupBy('status')
|
|
||||||
->pluck('count', 'status');
|
|
||||||
|
|
||||||
// Videos by visibility
|
$sizeVideos = $this->dirSize($disk, 'videos');
|
||||||
$videosByVisibility = Video::select('visibility', \DB::raw('count(*) as count'))
|
$sizeThumbnails = $this->dirSize($disk, 'thumbnails');
|
||||||
->groupBy('visibility')
|
$sizeAvatars = $this->dirSize($disk, 'avatars');
|
||||||
->pluck('count', 'visibility');
|
$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
|
// List all users with search/filter
|
||||||
@ -126,6 +321,13 @@ class SuperAdminController extends Controller
|
|||||||
return back()->with('error', 'You cannot delete your own account!');
|
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
|
// Delete user's videos and associated files
|
||||||
foreach ($user->videos as $video) {
|
foreach ($user->videos as $video) {
|
||||||
Storage::delete('public/videos/' . $video->filename);
|
Storage::delete('public/videos/' . $video->filename);
|
||||||
@ -202,6 +404,113 @@ class SuperAdminController extends Controller
|
|||||||
return view('admin.videos', compact('videos'));
|
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, '18–24' => 0, '25–34' => 0, '35–44' => 0, '45–54' => 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['18–24']++;
|
||||||
|
elseif ($age < 35) $ageGroups['25–34']++;
|
||||||
|
elseif ($age < 45) $ageGroups['35–44']++;
|
||||||
|
elseif ($age < 55) $ageGroups['45–54']++;
|
||||||
|
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
|
// Show edit video form
|
||||||
public function editVideo(Video $video)
|
public function editVideo(Video $video)
|
||||||
{
|
{
|
||||||
@ -212,15 +521,16 @@ class SuperAdminController extends Controller
|
|||||||
public function updateVideo(Request $request, Video $video)
|
public function updateVideo(Request $request, Video $video)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'title' => 'required|string|max:255',
|
'title' => 'required|string|max:255',
|
||||||
'description' => 'nullable|string',
|
'description' => 'nullable|string',
|
||||||
'visibility' => 'required|in:public,unlisted,private',
|
'visibility' => 'required|in:public,unlisted,private',
|
||||||
'type' => 'required|in:generic,music,match',
|
'type' => 'required|in:generic,music,match',
|
||||||
'status' => 'required|in:pending,processing,ready,failed',
|
'status' => 'required|in:pending,processing,ready,failed',
|
||||||
'is_shorts' => 'nullable|boolean',
|
'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);
|
$video->update($data);
|
||||||
|
|
||||||
@ -232,6 +542,13 @@ class SuperAdminController extends Controller
|
|||||||
{
|
{
|
||||||
$videoTitle = $video->title;
|
$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
|
// Delete files
|
||||||
Storage::delete('public/videos/' . $video->filename);
|
Storage::delete('public/videos/' . $video->filename);
|
||||||
if ($video->thumbnail) {
|
if ($video->thumbnail) {
|
||||||
@ -246,4 +563,335 @@ class SuperAdminController extends Controller
|
|||||||
|
|
||||||
return redirect()->route('admin.videos')->with('success', 'Video "' . $videoTitle . '" deleted successfully!');
|
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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
171
app/Http/Controllers/TwoFactorController.php
Normal file
171
app/Http/Controllers/TwoFactorController.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Helpers\Horoscope;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\Post;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Video;
|
use App\Models\Video;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class UserController extends Controller
|
class UserController extends Controller
|
||||||
{
|
{
|
||||||
@ -17,7 +19,7 @@ class UserController extends Controller
|
|||||||
$this->middleware('auth')->except(['channel']);
|
$this->middleware('auth')->except(['channel']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Profile page - view own profile
|
// Profile page - personal overview for the authenticated user
|
||||||
public function profile()
|
public function profile()
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
@ -28,58 +30,83 @@ class UserController extends Controller
|
|||||||
// Update profile
|
// Update profile
|
||||||
public function updateProfile(Request $request)
|
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([
|
$request->validate([
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:5120',
|
'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:5120',
|
||||||
'bio' => 'nullable|string|max:500',
|
'bio' => 'nullable|string|max:500',
|
||||||
'website' => 'nullable|string|max:255',
|
'birthday' => 'nullable|date',
|
||||||
'twitter' => 'nullable|string|max:100',
|
'location' => 'nullable|string|max:100',
|
||||||
'instagram' => 'nullable|string|max:100',
|
'gender' => 'nullable|in:male,female,prefer_not_to_say',
|
||||||
'facebook' => 'nullable|string|max:100',
|
'nationality' => 'nullable|string|size:2',
|
||||||
'youtube' => 'nullable|string|max:100',
|
'phone_code' => 'nullable|string|max:20',
|
||||||
'linkedin' => 'nullable|string|max:100',
|
'phone_number' => 'nullable|string|max:30',
|
||||||
'tiktok' => 'nullable|string|max:100',
|
'timezone' => 'nullable|timezone:all',
|
||||||
'birthday' => 'nullable|date',
|
'slink' => 'nullable|array',
|
||||||
'location' => 'nullable|string|max:100',
|
'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 = [
|
$data = [
|
||||||
'name' => $request->name,
|
'name' => $request->name,
|
||||||
'bio' => $request->bio,
|
'bio' => $request->bio,
|
||||||
'website' => $request->website,
|
'birthday' => $request->birthday,
|
||||||
'twitter' => $request->twitter,
|
'location' => $request->location,
|
||||||
'instagram' => $request->instagram,
|
'gender' => $request->gender ?: null,
|
||||||
'facebook' => $request->facebook,
|
'nationality' => $request->nationality ?: null,
|
||||||
'youtube' => $request->youtube,
|
'phone_code' => $request->phone_code ?: null,
|
||||||
'linkedin' => $request->linkedin,
|
'phone_number' => $request->phone_number ?: null,
|
||||||
'tiktok' => $request->tiktok,
|
'timezone' => $request->timezone ?: null,
|
||||||
'birthday' => $request->birthday,
|
|
||||||
'location' => $request->location,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($request->hasFile('avatar')) {
|
if ($request->hasFile('avatar')) {
|
||||||
// Delete old avatar
|
|
||||||
if ($user->avatar) {
|
if ($user->avatar) {
|
||||||
Storage::delete('public/avatars/'.$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);
|
$request->file('avatar')->storeAs('public/avatars', $filename);
|
||||||
$data['avatar'] = $filename;
|
$data['avatar'] = $filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->update($data);
|
$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++,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// Settings page - redirects to channel settings tab
|
||||||
public function settings()
|
public function settings()
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
return redirect()->route('channel')->with('_open_tab', 'settings');
|
||||||
|
|
||||||
return view('user.settings', compact('user'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update settings (password)
|
// Update settings (password)
|
||||||
@ -100,36 +127,121 @@ class UserController extends Controller
|
|||||||
'password' => Hash::make($request->new_password),
|
'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
|
// User's channel page - view videos
|
||||||
public function channel($userId = null)
|
public function channel($username = null)
|
||||||
{
|
{
|
||||||
if ($userId) {
|
if ($username) {
|
||||||
$user = User::findOrFail($userId);
|
// Look up by username slug only — never by sequential ID
|
||||||
|
$user = User::where('username', $username)->firstOrFail();
|
||||||
} else {
|
} else {
|
||||||
$user = Auth::user();
|
$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
|
$sort = request('sort', 'latest');
|
||||||
// If viewing someone else's channel, show only public videos
|
$isOwner = Auth::check() && Auth::user()->id === $user->id;
|
||||||
if (Auth::check() && Auth::user()->id === $user->id) {
|
$preview = $isOwner && request()->boolean('preview'); // owner viewing as visitor
|
||||||
$videos = Video::where('user_id', $user->id)
|
$isOwner = $isOwner && !$preview;
|
||||||
->latest()
|
|
||||||
->paginate(12);
|
|
||||||
|
|
||||||
// Also get user's playlists for their own channel
|
$baseQuery = $isOwner
|
||||||
$playlists = $user->playlists()->orderBy('created_at', 'desc')->get();
|
? Video::where('user_id', $user->id)
|
||||||
} else {
|
: Video::public()->where('user_id', $user->id);
|
||||||
$videos = Video::public()
|
|
||||||
->where('user_id', $user->id)
|
$allQuery = clone $baseQuery;
|
||||||
->latest()
|
|
||||||
->paginate(12);
|
switch ($sort) {
|
||||||
$playlists = null;
|
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
|
// Watch history
|
||||||
@ -151,13 +263,23 @@ class UserController extends Controller
|
|||||||
->orWhere('user_id', $user->id);
|
->orWhere('user_id', $user->id);
|
||||||
})
|
})
|
||||||
->get()
|
->get()
|
||||||
->sortByDesc(function ($video) use ($videoIds) {
|
->sortBy(function ($video) use ($videoIds) {
|
||||||
return $videoIds->search($video->id);
|
return $videoIds->search($video->id);
|
||||||
});
|
});
|
||||||
|
|
||||||
return view('user.history', compact('videos'));
|
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
|
// Liked videos
|
||||||
public function liked()
|
public function liked()
|
||||||
{
|
{
|
||||||
@ -214,4 +336,96 @@ class UserController extends Controller
|
|||||||
'like_count' => $video->like_count,
|
'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
@ -33,6 +33,7 @@ class Kernel extends HttpKernel
|
|||||||
\App\Http\Middleware\EncryptCookies::class,
|
\App\Http\Middleware\EncryptCookies::class,
|
||||||
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
||||||
\Illuminate\Session\Middleware\StartSession::class,
|
\Illuminate\Session\Middleware\StartSession::class,
|
||||||
|
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||||
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||||
\App\Http\Middleware\VerifyCsrfToken::class,
|
\App\Http\Middleware\VerifyCsrfToken::class,
|
||||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||||
|
|||||||
@ -12,7 +12,7 @@ class TrustProxies extends Middleware
|
|||||||
*
|
*
|
||||||
* @var array<int, string>|string|null
|
* @var array<int, string>|string|null
|
||||||
*/
|
*/
|
||||||
protected $proxies;
|
protected $proxies = '*';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The headers that should be used to detect proxies.
|
* The headers that should be used to detect proxies.
|
||||||
|
|||||||
@ -12,6 +12,8 @@ class VerifyCsrfToken extends Middleware
|
|||||||
* @var array<int, string>
|
* @var array<int, string>
|
||||||
*/
|
*/
|
||||||
protected $except = [
|
protected $except = [
|
||||||
//
|
'videos/*/share',
|
||||||
|
'playlists/*/share',
|
||||||
|
'videos/*/slideshow/generate',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
use App\Models\Video;
|
use App\Models\Video;
|
||||||
use FFMpeg\FFMpeg;
|
use FFMpeg\FFMpeg;
|
||||||
use FFMpeg\Format\Video\X264;
|
use FFMpeg\Format\Video\X264;
|
||||||
@ -42,17 +43,41 @@ class CompressVideoJob implements ShouldQueue
|
|||||||
$compressedPath = storage_path('app/public/videos/' . $compressedFilename);
|
$compressedPath = storage_path('app/public/videos/' . $compressedFilename);
|
||||||
|
|
||||||
try {
|
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);
|
$ffmpegVideo = $ffmpeg->open($originalPath);
|
||||||
|
|
||||||
// Use CRF 18 for high quality (lower = better quality, 18-23 is good range)
|
$gpuEnabled = Setting::gpuEnabled();
|
||||||
// Use 'slow' preset for better compression efficiency
|
$encoder = Setting::gpuEncoder();
|
||||||
// GPU NVENC encoding via config
|
$preset = Setting::gpuPreset();
|
||||||
$ffmpegConfig = Config::get('ffmpeg');
|
$device = Setting::gpuDevice();
|
||||||
$videoPasses = $ffmpegConfig['defaults']['video'] ?? [];
|
|
||||||
$audioPasses = $ffmpegConfig['defaults']['audio'] ?? [];
|
|
||||||
|
|
||||||
$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) {
|
foreach ($videoPasses as $pass) {
|
||||||
$format->addLegacyOption($pass);
|
$format->addLegacyOption($pass);
|
||||||
}
|
}
|
||||||
@ -79,12 +104,13 @@ class CompressVideoJob implements ShouldQueue
|
|||||||
'mime_type' => 'video/mp4',
|
'mime_type' => 'video/mp4',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Log::info('CompressVideoJob: Video compressed with GPU NVENC', [
|
Log::info('CompressVideoJob: Video compressed', [
|
||||||
'video_id' => $video->id,
|
'video_id' => $video->id,
|
||||||
'original_size' => $originalSize,
|
'original_size' => $originalSize,
|
||||||
'compressed_size' => $compressedSize,
|
'compressed_size' => $compressedSize,
|
||||||
'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%',
|
'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%',
|
||||||
'encoder' => 'h264_nvenc'
|
'encoder' => $encoder,
|
||||||
|
'gpu' => $gpuEnabled,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
// Compressed file is larger, delete it
|
// Compressed file is larger, delete it
|
||||||
|
|||||||
@ -2,17 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
use App\Models\Video;
|
use App\Models\Video;
|
||||||
use FFMpeg\FFMpeg;
|
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Facades\Config;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class GenerateHlsJob implements ShouldQueue
|
class GenerateHlsJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
@ -41,83 +39,114 @@ class GenerateHlsJob implements ShouldQueue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$hlsDir = 'public/hls/' . $video->id;
|
$hlsDir = 'public/hls/' . $video->id;
|
||||||
$hlsPath = storage_path('app/' . $hlsDir);
|
$hlsPath = storage_path('app/' . $hlsDir);
|
||||||
|
|
||||||
// Clean existing HLS
|
|
||||||
if (is_dir($hlsPath)) {
|
if (is_dir($hlsPath)) {
|
||||||
Storage::deleteDirectory($hlsDir);
|
Storage::deleteDirectory($hlsDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
Storage::makeDirectory($hlsDir);
|
Storage::makeDirectory($hlsDir);
|
||||||
|
|
||||||
|
$variants = [
|
||||||
|
['height' => 480, 'name' => '480p', 'bitrate' => '1000k'],
|
||||||
|
['height' => 720, 'name' => '720p', 'bitrate' => '2500k'],
|
||||||
|
['height' => 1080, 'name' => '1080p', 'bitrate' => '5000k'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($variants as $v) {
|
||||||
|
@mkdir($hlsPath . '/' . $v['name'], 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$ffmpegConfig = Config::get('ffmpeg');
|
$ffmpegBin = \App\Models\Setting::ffmpegBinary();
|
||||||
$ffmpeg = FFMpeg::create([
|
$gpuEnabled = Setting::gpuEnabled();
|
||||||
'ffmpeg.binaries' => $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg',
|
$encoder = Setting::gpuEncoder(); // h264_nvenc / hevc_nvenc / libx264
|
||||||
'ffprobe.binaries' => $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe',
|
$preset = Setting::gpuPreset(); // p1–p7 for NVENC, fast/medium/slow for x264
|
||||||
'timeout' => $ffmpegConfig['timeout'] ?? 3600,
|
$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',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$videoMedia = $ffmpeg->open($sourcePath);
|
exec($fullCmd, $output, $exitCode);
|
||||||
|
|
||||||
// HLS variants: 480p, 720p, 1080p
|
if ($exitCode !== 0) {
|
||||||
$variants = [
|
$tail = implode("\n", array_slice($output, -30));
|
||||||
[
|
throw new \RuntimeException("FFmpeg exited {$exitCode}:\n{$tail}");
|
||||||
'height' => 480,
|
}
|
||||||
'name' => '480p',
|
|
||||||
'bitrate' => 1000,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'height' => 720,
|
|
||||||
'name' => '720p',
|
|
||||||
'bitrate' => 2500,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'height' => 1080,
|
|
||||||
'name' => '1080p',
|
|
||||||
'bitrate' => 5000,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$hlsOptions = [
|
$video->update(['has_hls' => true, 'hls_path' => $hlsDir]);
|
||||||
'-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) {
|
Log::info('GenerateHlsJob: HLS generated', [
|
||||||
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');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark HLS ready
|
|
||||||
$video->update([
|
|
||||||
'has_hls' => true,
|
|
||||||
'hls_path' => $hlsDir,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Log::info('GenerateHlsJob: HLS generated successfully', [
|
|
||||||
'video_id' => $video->id,
|
'video_id' => $video->id,
|
||||||
'variants' => array_column($variants, 'name'),
|
'variants' => array_column($variants, 'name'),
|
||||||
'hls_url' => asset('storage/' . $hlsDir . '/playlist.m3u8'),
|
'encoder' => $encoder,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@ -126,4 +155,3 @@ class GenerateHlsJob implements ShouldQueue
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
34
app/Mail/NewVideoNotification.php
Normal file
34
app/Mail/NewVideoNotification.php
Normal 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Mail/TwoFactorDisableConfirmation.php
Normal file
33
app/Mail/TwoFactorDisableConfirmation.php
Normal 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
86
app/Models/AuditLog.php
Normal 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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -34,6 +34,11 @@ class Comment extends Model
|
|||||||
return $this->hasMany(Comment::class, 'parent_id')->latest();
|
return $this->hasMany(Comment::class, 'parent_id')->latest();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function likes()
|
||||||
|
{
|
||||||
|
return $this->hasMany(CommentLike::class);
|
||||||
|
}
|
||||||
|
|
||||||
// Get mentioned users from comment body
|
// Get mentioned users from comment body
|
||||||
public function getMentionedUsers()
|
public function getMentionedUsers()
|
||||||
{
|
{
|
||||||
|
|||||||
22
app/Models/CommentLike.php
Normal file
22
app/Models/CommentLike.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,6 +16,7 @@ class Playlist extends Model
|
|||||||
'thumbnail',
|
'thumbnail',
|
||||||
'visibility',
|
'visibility',
|
||||||
'is_default',
|
'is_default',
|
||||||
|
'share_token',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@ -143,10 +144,10 @@ class Playlist extends Model
|
|||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get shareable URL
|
// All share URLs use the unguessable token route
|
||||||
public function getShareUrlAttribute()
|
public function getShareUrlAttribute()
|
||||||
{
|
{
|
||||||
return route('playlists.show', $this->id);
|
return route('playlists.showByToken', $this->share_token);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scope for public playlists
|
// Scope for public playlists
|
||||||
@ -178,7 +179,12 @@ class Playlist extends Model
|
|||||||
return $this->visibility === 'private';
|
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)
|
public function canView($user = null)
|
||||||
{
|
{
|
||||||
// Owner can always view
|
// Owner can always view
|
||||||
@ -186,10 +192,20 @@ class Playlist extends Model
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public playlists can be viewed by anyone
|
// Only public playlists are accessible via the ID route
|
||||||
return $this->visibility === 'public';
|
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
|
// Check if user can edit this playlist
|
||||||
public function canEdit($user = null)
|
public function canEdit($user = null)
|
||||||
{
|
{
|
||||||
|
|||||||
51
app/Models/Post.php
Normal file
51
app/Models/Post.php
Normal 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
20
app/Models/PostImage.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Models/PostReaction.php
Normal file
20
app/Models/PostReaction.php
Normal 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
20
app/Models/PostVideo.php
Normal 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
92
app/Models/Setting.php
Normal 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} ";
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Models/ShareAccess.php
Normal file
16
app/Models/ShareAccess.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
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\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable implements MustVerifyEmail
|
||||||
{
|
{
|
||||||
use HasApiTokens, HasFactory, Notifiable;
|
use HasApiTokens, HasFactory, Notifiable;
|
||||||
|
|
||||||
@ -29,6 +31,18 @@ class User extends Authenticatable
|
|||||||
'tiktok',
|
'tiktok',
|
||||||
'birthday',
|
'birthday',
|
||||||
'location',
|
'location',
|
||||||
|
'gender',
|
||||||
|
'nationality',
|
||||||
|
'phone_code',
|
||||||
|
'phone_number',
|
||||||
|
'timezone',
|
||||||
|
'whatsapp',
|
||||||
|
'google_location',
|
||||||
|
'social_phone',
|
||||||
|
'social_email',
|
||||||
|
'two_factor_secret',
|
||||||
|
'two_factor_enabled',
|
||||||
|
'banner',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
@ -37,10 +51,47 @@ class User extends Authenticatable
|
|||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'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
|
// Relationships
|
||||||
public function videos()
|
public function videos()
|
||||||
{
|
{
|
||||||
@ -67,6 +118,11 @@ class User extends Authenticatable
|
|||||||
return $this->hasMany(Playlist::class);
|
return $this->hasMany(Playlist::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function posts()
|
||||||
|
{
|
||||||
|
return $this->hasMany(\App\Models\Post::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function getAvatarUrlAttribute()
|
public function getAvatarUrlAttribute()
|
||||||
{
|
{
|
||||||
if ($this->avatar) {
|
if ($this->avatar) {
|
||||||
@ -76,6 +132,19 @@ class User extends Authenticatable
|
|||||||
return 'https://i.pravatar.cc/150?u='.$this->id;
|
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
|
// Role helper methods
|
||||||
public function isSuperAdmin()
|
public function isSuperAdmin()
|
||||||
{
|
{
|
||||||
@ -92,31 +161,47 @@ class User extends Authenticatable
|
|||||||
return $this->role === 'user' || $this->role === null;
|
return $this->role === 'user' || $this->role === null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Placeholder for subscriber count (would need a separate table in full implementation)
|
// Users who subscribe TO this channel
|
||||||
public function getSubscriberCountAttribute()
|
public function subscribers()
|
||||||
{
|
{
|
||||||
// For now, return a placeholder - in production this would come from a subscriptions table
|
return $this->belongsToMany(
|
||||||
return rand(100, 10000);
|
User::class,
|
||||||
|
'user_subscriptions',
|
||||||
|
'channel_id',
|
||||||
|
'subscriber_id'
|
||||||
|
)->withPivot('created_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get social links as an array
|
// Channels this user subscribes to
|
||||||
public function getSocialLinksAttribute()
|
public function subscriptions()
|
||||||
{
|
{
|
||||||
return [
|
return $this->belongsToMany(
|
||||||
'twitter' => $this->twitter,
|
User::class,
|
||||||
'instagram' => $this->instagram,
|
'user_subscriptions',
|
||||||
'facebook' => $this->facebook,
|
'subscriber_id',
|
||||||
'youtube' => $this->youtube,
|
'channel_id'
|
||||||
'linkedin' => $this->linkedin,
|
)->withPivot('created_at');
|
||||||
'tiktok' => $this->tiktok,
|
}
|
||||||
];
|
|
||||||
|
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
|
// Check if user has any social links
|
||||||
|
public function socialLinks()
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserSocialLink::class)->orderBy('sort_order');
|
||||||
|
}
|
||||||
|
|
||||||
public function hasSocialLinks()
|
public function hasSocialLinks()
|
||||||
{
|
{
|
||||||
return $this->twitter || $this->instagram || $this->facebook ||
|
return $this->socialLinks()->exists();
|
||||||
$this->youtube || $this->linkedin || $this->tiktok;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get formatted website URL
|
// Get formatted website URL
|
||||||
|
|||||||
17
app/Models/UserSocialLink.php
Normal file
17
app/Models/UserSocialLink.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,6 +25,11 @@ class Video extends Model
|
|||||||
'is_shorts',
|
'is_shorts',
|
||||||
'has_hls',
|
'has_hls',
|
||||||
'hls_path',
|
'hls_path',
|
||||||
|
'download_access',
|
||||||
|
'download_count',
|
||||||
|
'share_count',
|
||||||
|
'share_token',
|
||||||
|
'slideshow_video_path',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@ -54,6 +59,16 @@ class Video extends Model
|
|||||||
->withTimestamps();
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function slides()
|
||||||
|
{
|
||||||
|
return $this->hasMany(VideoSlide::class)->orderBy('position');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasSlideshow(): bool
|
||||||
|
{
|
||||||
|
return $this->slides()->count() > 1;
|
||||||
|
}
|
||||||
|
|
||||||
// Accessors
|
// Accessors
|
||||||
public function getUrlAttribute()
|
public function getUrlAttribute()
|
||||||
{
|
{
|
||||||
@ -92,10 +107,84 @@ class Video extends Model
|
|||||||
return \DB::table('video_views')->where('video_id', $this->id)->count();
|
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()
|
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)
|
// Get formatted duration (e.g., "1:30" or "0:45" for shorts)
|
||||||
@ -213,6 +302,12 @@ class Video extends Model
|
|||||||
return $this->type === 'match';
|
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
|
// Shorts helpers
|
||||||
public function isShorts()
|
public function isShorts()
|
||||||
{
|
{
|
||||||
@ -331,13 +426,13 @@ class Video extends Model
|
|||||||
// Get video stream URL for Open Graph
|
// Get video stream URL for Open Graph
|
||||||
public function getStreamUrlAttribute()
|
public function getStreamUrlAttribute()
|
||||||
{
|
{
|
||||||
return route('videos.stream', $this->id);
|
return route('videos.stream', $this);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get secure share URL
|
// Get secure share URL
|
||||||
public function getSecureShareUrlAttribute()
|
public function getSecureShareUrlAttribute()
|
||||||
{
|
{
|
||||||
return secure_url(route('videos.show', $this->id));
|
return secure_url(route('videos.show', $this));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get secure thumbnail URL
|
// Get secure thumbnail URL
|
||||||
|
|||||||
26
app/Models/VideoShare.php
Normal file
26
app/Models/VideoShare.php
Normal 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
20
app/Models/VideoSlide.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Notifications/NewCommentLikeNotification.php
Normal file
38
app/Notifications/NewCommentLikeNotification.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Notifications/NewCommentNotification.php
Normal file
38
app/Notifications/NewCommentNotification.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Notifications/NewReplyNotification.php
Normal file
38
app/Notifications/NewReplyNotification.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Notifications/NewVideoUploaded.php
Normal file
33
app/Notifications/NewVideoUploaded.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Notifications/VerifyEmail.php
Normal file
39
app/Notifications/VerifyEmail.php
Normal 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()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ namespace App\Providers;
|
|||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\Facades\Request;
|
use Illuminate\Support\Facades\Request;
|
||||||
|
use Illuminate\Pagination\Paginator;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@ -27,5 +28,9 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
// Force HTTPS
|
// Force HTTPS
|
||||||
URL::forceScheme('https');
|
URL::forceScheme('https');
|
||||||
|
|
||||||
|
// Universal pagination view — used everywhere by default
|
||||||
|
Paginator::defaultView('partials.pagination');
|
||||||
|
Paginator::defaultSimpleView('partials.pagination');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
141
app/Rules/NotDisposableEmail.php
Normal file
141
app/Rules/NotDisposableEmail.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Services/GeoIpService.php
Normal file
46
app/Services/GeoIpService.php
Normal 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];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,11 +6,15 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.1",
|
"php": "^8.1",
|
||||||
|
"bacon/bacon-qr-code": "^3.1",
|
||||||
|
"doctrine/dbal": "^3.0",
|
||||||
"guzzlehttp/guzzle": "^7.2",
|
"guzzlehttp/guzzle": "^7.2",
|
||||||
"laravel/framework": "^10.10",
|
"laravel/framework": "^10.10",
|
||||||
"laravel/sanctum": "^3.3",
|
"laravel/sanctum": "^3.3",
|
||||||
"laravel/tinker": "^2.8",
|
"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": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.9.1",
|
"fakerphp/faker": "^1.9.1",
|
||||||
@ -62,6 +66,12 @@
|
|||||||
"php-http/discovery": true
|
"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
|
"prefer-stable": true
|
||||||
}
|
}
|
||||||
|
|||||||
672
composer.lock
generated
672
composer.lock
generated
@ -4,8 +4,63 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "1e4c8a43b8df70dffbd0107a8308b2ec",
|
"content-hash": "40fa327b55e9b6fafab4b2da3f763724",
|
||||||
"packages": [
|
"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",
|
"name": "brick/math",
|
||||||
"version": "0.12.3",
|
"version": "0.12.3",
|
||||||
@ -135,6 +190,56 @@
|
|||||||
],
|
],
|
||||||
"time": "2023-12-11T17:09:12+00:00"
|
"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",
|
"name": "dflydev/dot-access-data",
|
||||||
"version": "v3.0.3",
|
"version": "v3.0.3",
|
||||||
@ -210,6 +315,259 @@
|
|||||||
},
|
},
|
||||||
"time": "2024-07-08T12:26:09+00:00"
|
"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",
|
"name": "doctrine/inflector",
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
@ -2444,6 +2802,117 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-11-21T10:36:35+00:00"
|
"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",
|
"name": "php-ffmpeg/php-ffmpeg",
|
||||||
"version": "v1.4.0",
|
"version": "v1.4.0",
|
||||||
@ -2608,6 +3077,201 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-12-27T19:41:33+00:00"
|
"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",
|
"name": "psr/cache",
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
@ -8699,8 +9363,10 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "dev",
|
||||||
"stability-flags": [],
|
"stability-flags": {
|
||||||
|
"p7h/nas-file-manager": 20
|
||||||
|
},
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
|
|||||||
@ -6,8 +6,8 @@ return [
|
|||||||
| FFmpeg Binaries
|
| FFmpeg Binaries
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
'ffmpeg' => '/usr/bin/ffmpeg',
|
'ffmpeg' => '/usr/lib/jellyfin-ffmpeg/ffmpeg',
|
||||||
'ffprobe' => '/usr/bin/ffprobe',
|
'ffprobe' => '/usr/lib/jellyfin-ffmpeg/ffprobe',
|
||||||
'timeout' => 3600,
|
'timeout' => 3600,
|
||||||
'thread_number' => 0,
|
'thread_number' => 0,
|
||||||
// auto-detect cores
|
// auto-detect cores
|
||||||
|
|||||||
57
config/nas-file-manager.php
Normal file
57
config/nas-file-manager.php
Normal 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],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
24
database/migrations/2026_04_30_220000_create_posts_table.php
Normal file
24
database/migrations/2026_04_30_220000_create_posts_table.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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
9
public/css/cropme.min.css
vendored
Normal 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
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
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
BIN
public/sounds/chime.wav
Normal file
Binary file not shown.
385
resources/views/admin/audit-logs.blade.php
Normal file
385
resources/views/admin/audit-logs.blade.php
Normal 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
@ -3,131 +3,310 @@
|
|||||||
@section('title', 'Edit User')
|
@section('title', 'Edit User')
|
||||||
@section('page_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')
|
@section('content')
|
||||||
<!-- Alerts -->
|
|
||||||
|
{{-- ── 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'))
|
@if(session('success'))
|
||||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
<div class="adm-alert adm-alert-success" style="margin-bottom:16px;">
|
||||||
{{ session('success') }}
|
<i class="bi bi-check-circle-fill"></i>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
<span>{{ session('success') }}</span>
|
||||||
|
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if(session('error'))
|
@if(session('error'))
|
||||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
<div class="adm-alert adm-alert-error" style="margin-bottom:16px;">
|
||||||
{{ session('error') }}
|
<i class="bi bi-exclamation-circle-fill"></i>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
<span>{{ session('error') }}</span>
|
||||||
|
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="row">
|
<div class="ef-grid">
|
||||||
<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
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{{-- ── 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) }}">
|
<form method="POST" action="{{ route('admin.users.update', $user->id) }}">
|
||||||
@csrf
|
@csrf
|
||||||
@method('PUT')
|
@method('PUT')
|
||||||
|
|
||||||
<div class="mb-3">
|
{{-- Name --}}
|
||||||
<label for="name" class="form-label">Name</label>
|
<div class="ef-field">
|
||||||
<input type="text" class="form-control @error('name') is-invalid @enderror" id="name" name="name" value="{{ old('name', $user->name) }}" required>
|
<label class="ef-label" for="name">Full Name</label>
|
||||||
@error('name')
|
<input class="ef-input @error('name') is-invalid @enderror"
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
id="name" name="name" type="text"
|
||||||
@enderror
|
value="{{ old('name', $user->name) }}" required autocomplete="off">
|
||||||
|
@error('name')<div class="ef-error">{{ $message }}</div>@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
{{-- Email --}}
|
||||||
<label for="email" class="form-label">Email</label>
|
<div class="ef-field">
|
||||||
<input type="email" class="form-control @error('email') is-invalid @enderror" id="email" name="email" value="{{ old('email', $user->email) }}" required>
|
<label class="ef-label" for="email">Email Address</label>
|
||||||
@error('email')
|
<input class="ef-input @error('email') is-invalid @enderror"
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
id="email" name="email" type="email"
|
||||||
@enderror
|
value="{{ old('email', $user->email) }}" required autocomplete="off">
|
||||||
|
@error('email')<div class="ef-error">{{ $message }}</div>@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
{{-- Role --}}
|
||||||
<label for="role" class="form-label">Role</label>
|
<div class="ef-field">
|
||||||
<select class="form-select @error('role') is-invalid @enderror" id="role" name="role" required>
|
<label class="ef-label" for="role">Role</label>
|
||||||
<option value="user" {{ old('role', $user->role) == 'user' ? 'selected' : '' }}>User</option>
|
<select class="ef-select @error('role') is-invalid @enderror" id="role" name="role">
|
||||||
<option value="admin" {{ old('role', $user->role) == 'admin' ? 'selected' : '' }}>Admin</option>
|
<option value="user" {{ old('role', $user->role) === 'user' ? 'selected' : '' }}>User</option>
|
||||||
<option value="super_admin" {{ old('role', $user->role) == 'super_admin' ? 'selected' : '' }}>Super Admin</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>
|
</select>
|
||||||
@error('role')
|
@error('role')<div class="ef-error">{{ $message }}</div>@enderror
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
|
||||||
@enderror
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr style="border-color: var(--border-color);">
|
{{-- Password section --}}
|
||||||
|
<div class="ef-section">
|
||||||
<h6 class="mb-3">Change Password (Optional)</h6>
|
<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 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
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="ef-row">
|
||||||
<label for="new_password_confirmation" class="form-label">Confirm New Password</label>
|
<div class="ef-field" style="margin-bottom:0;">
|
||||||
<input type="password" class="form-control" id="new_password_confirmation" name="new_password_confirmation" placeholder="Confirm new password">
|
<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>
|
||||||
|
|
||||||
<div class="d-flex gap-2">
|
<div class="ef-actions">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="adm-btn adm-btn-primary">
|
||||||
<i class="bi bi-check-circle"></i> Update User
|
<i class="bi bi-check-circle-fill"></i> Save Changes
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ route('admin.users') }}" class="btn btn-outline-light">Cancel</a>
|
<a href="{{ route('admin.users') }}" class="adm-btn">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-4">
|
{{-- ── Right: user info sidebar ── --}}
|
||||||
<!-- User Info -->
|
<div style="display:flex;flex-direction:column;gap:16px;">
|
||||||
<div class="admin-card">
|
|
||||||
<div class="admin-card-header">
|
|
||||||
<h5 class="admin-card-title">User Info</h5>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center mb-3">
|
{{-- Profile card --}}
|
||||||
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="rounded-circle" style="width: 80px; height: 80px; object-fit: cover;">
|
<div class="adm-card">
|
||||||
<h5 class="mt-2">{{ $user->name }}</h5>
|
<div class="adm-card-body" style="text-align:center;padding-top:28px;padding-bottom:24px;">
|
||||||
<p class="text-secondary mb-1">{{ $user->email }}</p>
|
<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')
|
@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')
|
@elseif($user->role === 'admin')
|
||||||
<span class="badge-role badge-admin">Admin</span>
|
<span class="adm-badge adm-badge-admin">Admin</span>
|
||||||
@else
|
@else
|
||||||
<span class="badge-role badge-user">User</span>
|
<span class="adm-badge adm-badge-user">User</span>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</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">
|
{{-- Impersonate --}}
|
||||||
<span class="text-secondary">User ID</span>
|
@if(!$user->isSuperAdmin())
|
||||||
<span>#{{ $user->id }}</span>
|
<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>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDelDialog()"
|
||||||
<span class="text-secondary">Joined</span>
|
style="border:none;background:none;color:var(--text-2);">
|
||||||
<span>{{ $user->created_at->format('M d, Y') }}</span>
|
<i class="bi bi-x-lg"></i>
|
||||||
</div>
|
</button>
|
||||||
<div class="d-flex justify-content-between mb-2">
|
</div>
|
||||||
<span class="text-secondary">Total Videos</span>
|
<div class="adm-dialog-body">
|
||||||
<span>{{ $user->videos->count() }}</span>
|
<p>You are about to permanently delete <strong>{{ $user->name }}</strong>.</p>
|
||||||
</div>
|
<div class="adm-dialog-warning">
|
||||||
<div class="d-flex justify-content-between">
|
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
|
||||||
<span class="text-secondary">Email Verified</span>
|
All videos uploaded by this user will also be deleted. This cannot be undone.
|
||||||
<span>{{ $user->email_verified_at ? 'Yes' : 'No' }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</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
|
@endsection
|
||||||
|
|||||||
@ -3,164 +3,312 @@
|
|||||||
@section('title', 'Edit Video')
|
@section('title', 'Edit Video')
|
||||||
@section('page_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')
|
@section('content')
|
||||||
<!-- Alerts -->
|
|
||||||
|
{{-- ── 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>
|
||||||
|
|
||||||
|
{{-- ── Alerts ── --}}
|
||||||
@if(session('success'))
|
@if(session('success'))
|
||||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
<div class="adm-alert adm-alert-success" style="margin-bottom:16px;">
|
||||||
{{ session('success') }}
|
<i class="bi bi-check-circle-fill"></i>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
<span>{{ session('success') }}</span>
|
||||||
|
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if(session('error'))
|
@if(session('error'))
|
||||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
<div class="adm-alert adm-alert-error" style="margin-bottom:16px;">
|
||||||
{{ session('error') }}
|
<i class="bi bi-exclamation-circle-fill"></i>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
<span>{{ session('error') }}</span>
|
||||||
|
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="row">
|
<div class="ef-grid">
|
||||||
<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
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="POST" action="{{ route('admin.videos.update', $video->id) }}">
|
{{-- ── 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
|
@csrf
|
||||||
@method('PUT')
|
@method('PUT')
|
||||||
|
|
||||||
<div class="mb-3">
|
{{-- Title --}}
|
||||||
<label for="title" class="form-label">Title</label>
|
<div class="ef-field">
|
||||||
<input type="text" class="form-control @error('title') is-invalid @enderror" id="title" name="title" value="{{ old('title', $video->title) }}" required>
|
<label class="ef-label" for="title">Title</label>
|
||||||
@error('title')
|
<input class="ef-input @error('title') is-invalid @enderror"
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
id="title" name="title" type="text"
|
||||||
@enderror
|
value="{{ old('title', $video->title) }}" required>
|
||||||
|
@error('title')<div class="ef-error">{{ $message }}</div>@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
{{-- Description --}}
|
||||||
<label for="description" class="form-label">Description</label>
|
<div class="ef-field">
|
||||||
<textarea class="form-control @error('description') is-invalid @enderror" id="description" name="description" rows="4">{{ old('description', $video->description) }}</textarea>
|
<label class="ef-label" for="description">Description</label>
|
||||||
@error('description')
|
<textarea class="ef-textarea @error('description') is-invalid @enderror"
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
id="description" name="description">{{ old('description', $video->description) }}</textarea>
|
||||||
@enderror
|
@error('description')<div class="ef-error">{{ $message }}</div>@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mb-3">
|
{{-- Visibility / Type / Status --}}
|
||||||
<div class="col-md-4">
|
<div class="ef-row-3">
|
||||||
<label for="visibility" class="form-label">Visibility</label>
|
<div class="ef-field" style="margin-bottom:0;">
|
||||||
<select class="form-select @error('visibility') is-invalid @enderror" id="visibility" name="visibility" required>
|
<label class="ef-label" for="visibility">Visibility</label>
|
||||||
<option value="public" {{ old('visibility', $video->visibility) == 'public' ? 'selected' : '' }}>Public</option>
|
<select class="ef-select @error('visibility') is-invalid @enderror" id="visibility" name="visibility">
|
||||||
<option value="unlisted" {{ old('visibility', $video->visibility) == 'unlisted' ? 'selected' : '' }}>Unlisted</option>
|
<option value="public" {{ old('visibility', $video->visibility) === 'public' ? 'selected' : '' }}>Public</option>
|
||||||
<option value="private" {{ old('visibility', $video->visibility) == 'private' ? 'selected' : '' }}>Private</option>
|
<option value="unlisted" {{ old('visibility', $video->visibility) === 'unlisted' ? 'selected' : '' }}>Unlisted</option>
|
||||||
|
<option value="private" {{ old('visibility', $video->visibility) === 'private' ? 'selected' : '' }}>Private</option>
|
||||||
</select>
|
</select>
|
||||||
@error('visibility')
|
@error('visibility')<div class="ef-error">{{ $message }}</div>@enderror
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
|
||||||
@enderror
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ef-field" style="margin-bottom:0;">
|
||||||
<div class="col-md-4">
|
<label class="ef-label" for="type">Type</label>
|
||||||
<label for="type" class="form-label">Type</label>
|
<select class="ef-select @error('type') is-invalid @enderror" id="type" name="type">
|
||||||
<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="generic" {{ old('type', $video->type) == 'generic' ? 'selected' : '' }}>Generic</option>
|
<option value="music" {{ old('type', $video->type) === 'music' ? 'selected' : '' }}>Music</option>
|
||||||
<option value="music" {{ old('type', $video->type) == 'music' ? 'selected' : '' }}>Music</option>
|
<option value="match" {{ old('type', $video->type) === 'match' ? 'selected' : '' }}>Match</option>
|
||||||
<option value="match" {{ old('type', $video->type) == 'match' ? 'selected' : '' }}>Match</option>
|
|
||||||
</select>
|
</select>
|
||||||
@error('type')
|
@error('type')<div class="ef-error">{{ $message }}</div>@enderror
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
|
||||||
@enderror
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ef-field" style="margin-bottom:0;">
|
||||||
<div class="col-md-4">
|
<label class="ef-label" for="status">Status</label>
|
||||||
<label for="status" class="form-label">Status</label>
|
<select class="ef-select @error('status') is-invalid @enderror" id="status" name="status">
|
||||||
<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="pending" {{ old('status', $video->status) == 'pending' ? 'selected' : '' }}>Pending</option>
|
<option value="processing" {{ old('status', $video->status) === 'processing' ? 'selected' : '' }}>Processing</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="ready" {{ old('status', $video->status) == 'ready' ? 'selected' : '' }}>Ready</option>
|
<option value="failed" {{ old('status', $video->status) === 'failed' ? 'selected' : '' }}>Failed</option>
|
||||||
<option value="failed" {{ old('status', $video->status) == 'failed' ? 'selected' : '' }}>Failed</option>
|
|
||||||
</select>
|
</select>
|
||||||
@error('status')
|
@error('status')<div class="ef-error">{{ $message }}</div>@enderror
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
|
||||||
@enderror
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex gap-2">
|
{{-- Download Access --}}
|
||||||
<button type="submit" class="btn btn-primary">
|
<div class="ef-field" style="margin-top:18px;">
|
||||||
<i class="bi bi-check-circle"></i> Update Video
|
<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>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-4">
|
{{-- ── Right: sidebar ── --}}
|
||||||
<!-- Video Info -->
|
<div style="display:flex;flex-direction:column;gap:16px;">
|
||||||
<div class="admin-card">
|
|
||||||
<div class="admin-card-header">
|
|
||||||
<h5 class="admin-card-title">Video Info</h5>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if($video->thumbnail)
|
{{-- Thumbnail --}}
|
||||||
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}" class="img-fluid rounded mb-3" style="width: 100%;">
|
<div class="adm-card">
|
||||||
@else
|
<div class="adm-card-body">
|
||||||
<div class="bg-secondary rounded d-flex align-items-center justify-content-center mb-3" style="height: 180px;">
|
@if($video->thumbnail)
|
||||||
<i class="bi bi-play-circle text-white" style="font-size: 3rem;"></i>
|
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}"
|
||||||
|
alt="{{ $video->title }}" class="ef-thumb">
|
||||||
|
@else
|
||||||
|
<div class="ef-thumb-placeholder"><i class="bi bi-play-circle"></i></div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 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>
|
||||||
@endif
|
<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>
|
||||||
|
|
||||||
<hr style="border-color: var(--border-color);">
|
{{-- 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 class="d-flex justify-content-between mb-2">
|
</div>
|
||||||
<span class="text-secondary">Video ID</span>
|
</div>
|
||||||
<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);">
|
{{-- ── Delete confirmation dialog ── --}}
|
||||||
|
<div class="adm-dialog-overlay" id="delDialog">
|
||||||
<div class="d-grid gap-2">
|
<div class="adm-dialog">
|
||||||
<a href="{{ route('videos.show', $video->id) }}" target="_blank" class="btn btn-outline-light btn-sm">
|
<div class="adm-dialog-header">
|
||||||
<i class="bi bi-play-circle"></i> View Video
|
<div class="adm-dialog-title">
|
||||||
</a>
|
<i class="bi bi-exclamation-triangle-fill"></i> Delete Video
|
||||||
</div>
|
</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>
|
</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
|
@endsection
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
100
resources/views/admin/logs.blade.php
Normal file
100
resources/views/admin/logs.blade.php
Normal 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
|
||||||
89
resources/views/admin/nas-storage.blade.php
Normal file
89
resources/views/admin/nas-storage.blade.php
Normal 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
|
||||||
25
resources/views/admin/partials/gpu-cards.blade.php
Normal file
25
resources/views/admin/partials/gpu-cards.blade.php
Normal 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>
|
||||||
47
resources/views/admin/partials/pagination.blade.php
Normal file
47
resources/views/admin/partials/pagination.blade.php
Normal 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
|
||||||
430
resources/views/admin/settings.blade.php
Normal file
430
resources/views/admin/settings.blade.php
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.spin { display: inline-block; animation: spin .6s linear infinite; }
|
||||||
|
</style>
|
||||||
|
@endsection
|
||||||
@ -1,135 +1,181 @@
|
|||||||
@extends('admin.layout')
|
@extends('admin.layout')
|
||||||
|
|
||||||
@section('title', 'User Management')
|
@section('title', 'Users')
|
||||||
@section('page_title', 'User Management')
|
|
||||||
|
|
||||||
@section('content')
|
@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'))
|
@if(session('success'))
|
||||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
<div class="adm-alert adm-alert-success">
|
||||||
{{ session('success') }}
|
<i class="bi bi-check-circle-fill"></i>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
<span>{{ session('success') }}</span>
|
||||||
|
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if(session('error'))
|
@if(session('error'))
|
||||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
<div class="adm-alert adm-alert-error">
|
||||||
{{ session('error') }}
|
<i class="bi bi-exclamation-circle-fill"></i>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
<span>{{ session('error') }}</span>
|
||||||
|
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<!-- Search & Filters -->
|
{{-- ── Filter card ──────────────────────────────────────────────────── --}}
|
||||||
<div class="admin-card">
|
<div class="adm-card">
|
||||||
<form method="GET" action="{{ route('admin.users') }}" class="filter-form">
|
<div class="adm-card-body" style="padding:16px 20px;">
|
||||||
<div class="form-group">
|
<form method="GET" action="{{ route('admin.users') }}" class="adm-filter-form">
|
||||||
<label for="search">Search</label>
|
<div class="adm-filter-search">
|
||||||
<input type="text" name="search" id="search" class="form-control" placeholder="Search by name or email..." value="{{ request('search') }}">
|
<i class="bi bi-search"></i>
|
||||||
</div>
|
<input type="text" name="search" class="adm-input"
|
||||||
<div class="form-group">
|
placeholder="Search name or email…"
|
||||||
<label for="role">Role</label>
|
value="{{ request('search') }}" autocomplete="off">
|
||||||
<select name="role" id="role" class="form-select">
|
</div>
|
||||||
|
|
||||||
|
<select name="role" class="adm-select">
|
||||||
<option value="">All Roles</option>
|
<option value="">All Roles</option>
|
||||||
<option value="user" {{ request('role') == 'user' ? 'selected' : '' }}>User</option>
|
<option value="user" {{ request('role') === 'user' ? 'selected' : '' }}>User</option>
|
||||||
<option value="admin" {{ request('role') == 'admin' ? 'selected' : '' }}>Admin</option>
|
<option value="admin" {{ request('role') === 'admin' ? 'selected' : '' }}>Admin</option>
|
||||||
<option value="super_admin" {{ request('role') == 'super_admin' ? 'selected' : '' }}>Super Admin</option>
|
<option value="super_admin" {{ request('role') === 'super_admin' ? 'selected' : '' }}>Super Admin</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<select name="sort" class="adm-select">
|
||||||
<label for="sort">Sort By</label>
|
<option value="latest" {{ request('sort', 'latest') === 'latest' ? 'selected' : '' }}>Newest first</option>
|
||||||
<select name="sort" id="sort" class="form-select">
|
<option value="oldest" {{ request('sort') === 'oldest' ? 'selected' : '' }}>Oldest first</option>
|
||||||
<option value="latest" {{ request('sort') == 'latest' ? 'selected' : '' }}>Latest First</option>
|
<option value="name_asc" {{ request('sort') === 'name_asc' ? 'selected' : '' }}>Name A–Z</option>
|
||||||
<option value="oldest" {{ request('sort') == 'oldest' ? 'selected' : '' }}>Oldest First</option>
|
<option value="name_desc" {{ request('sort') === 'name_desc' ? 'selected' : '' }}>Name Z–A</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>
|
</select>
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<button type="submit" class="adm-btn adm-btn-primary">
|
||||||
<label> </label>
|
<i class="bi bi-funnel"></i> Filter
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<i class="bi bi-search"></i> Filter
|
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ route('admin.users') }}" class="btn btn-outline-light">
|
@if(request()->hasAny(['search','role','sort']))
|
||||||
<i class="bi bi-x-circle"></i> Clear
|
<a href="{{ route('admin.users') }}" class="adm-btn">
|
||||||
|
<i class="bi bi-x-lg"></i> Clear
|
||||||
</a>
|
</a>
|
||||||
</div>
|
@endif
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Users Table -->
|
{{-- ── Users table ──────────────────────────────────────────────────── --}}
|
||||||
<div class="admin-card">
|
<div class="adm-card">
|
||||||
<div class="admin-card-header">
|
<div class="adm-card-header">
|
||||||
All Users ({{ $users->count() }})
|
<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>
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="adm-table-wrap">
|
||||||
<table class="admin-table">
|
<table class="adm-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>User</th>
|
<th>User</th>
|
||||||
<th>Email</th>
|
|
||||||
<th>Role</th>
|
<th>Role</th>
|
||||||
|
<th>Verified</th>
|
||||||
<th>Videos</th>
|
<th>Videos</th>
|
||||||
<th>Joined</th>
|
<th>Joined</th>
|
||||||
<th>Actions</th>
|
<th style="width:80px; text-align:right;">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@forelse($users as $user)
|
@forelse($users as $user)
|
||||||
<tr>
|
<tr>
|
||||||
|
{{-- User cell --}}
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="adm-user-cell">
|
||||||
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="user-avatar">
|
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}">
|
||||||
<div>
|
<div>
|
||||||
<div>{{ $user->name }}</div>
|
<div style="display:flex;align-items:center;gap:4px;">
|
||||||
@if($user->id === auth()->id())
|
<span class="adm-user-cell-name">{{ $user->name }}</span>
|
||||||
<small class="text-info">(You)</small>
|
@if($user->id === auth()->id())
|
||||||
@endif
|
<span class="adm-user-cell-you">you</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="adm-user-cell-email">{{ $user->email }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ $user->email }}</td>
|
|
||||||
|
{{-- Role --}}
|
||||||
<td>
|
<td>
|
||||||
@if($user->role === 'super_admin')
|
@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')
|
@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
|
@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
|
@endif
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
{{-- Verified --}}
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ route('channel', $user->id) }}" target="_blank" class="text-decoration-none">
|
@if($user->email_verified_at)
|
||||||
{{ $user->videos->count() }} videos
|
<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>
|
</a>
|
||||||
</td>
|
</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>
|
<td>
|
||||||
<div class="dropdown">
|
<div class="adm-row-actions" style="justify-content:flex-end;">
|
||||||
<button class="btn btn-sm btn-outline-light dropdown-toggle" data-bs-toggle="dropdown">
|
@if(!$user->email_verified_at)
|
||||||
<i class="bi bi-gear"></i>
|
<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>
|
||||||
|
</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>
|
||||||
|
@if($user->id !== auth()->id())
|
||||||
|
<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>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-dark">
|
@endif
|
||||||
<li>
|
|
||||||
<a class="dropdown-item" href="{{ route('admin.users.edit', $user->id) }}">
|
|
||||||
<i class="bi bi-pencil"></i> Edit
|
|
||||||
</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>
|
|
||||||
</li>
|
|
||||||
@endif
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="text-center text-secondary py-4">
|
<td colspan="6">
|
||||||
No users found
|
<div class="empty-state">
|
||||||
|
<i class="bi bi-people"></i>
|
||||||
|
<p>No users found</p>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@endforelse
|
@endforelse
|
||||||
@ -137,40 +183,42 @@ All Users ({{ $users->count() }})
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
{{-- Pagination --}}
|
||||||
<div class="d-flex justify-content-center">
|
@if($users instanceof \Illuminate\Pagination\LengthAwarePaginator && $users->hasPages())
|
||||||
{{ $users->links() }}
|
<div style="padding:16px 20px; border-top:1px solid var(--border);">
|
||||||
|
{{ $users->onEachSide(1)->links() }}
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete User Modal -->
|
{{-- ── Delete confirmation dialog ───────────────────────────────────── --}}
|
||||||
<div class="modal fade" id="deleteUserModal" tabindex="-1" aria-labelledby="deleteUserModalLabel" aria-hidden="true">
|
<div class="adm-dialog-overlay" id="deleteDialog">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="adm-dialog">
|
||||||
<div class="modal-content" style="background: #1a1a1a; border: 1px solid #3f3f3f; border-radius: 12px;">
|
<div class="adm-dialog-header">
|
||||||
<div class="modal-header" style="border-bottom: 1px solid #3f3f3f; padding: 20px 24px;">
|
<div class="adm-dialog-title">
|
||||||
<h5 class="modal-title" id="deleteUserModalLabel" style="color: #fff; font-weight: 600;">
|
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||||
<i class="bi bi-exclamation-triangle-fill text-danger me-2"></i>
|
Delete User
|
||||||
Delete User
|
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body" style="padding: 24px;">
|
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDeleteDialog()" style="border:none;background:none;color:var(--text-2);">
|
||||||
<p>Are you sure you want to delete this user? This action cannot be undone.</p>
|
<i class="bi bi-x-lg"></i>
|
||||||
<p><strong>User:</strong> <span id="deleteUserName"></span></p>
|
</button>
|
||||||
<div class="alert alert-warning">
|
</div>
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
<div class="adm-dialog-body">
|
||||||
This will also delete all videos uploaded by this user.
|
<p>You are about to permanently delete <strong id="dlgUserName"></strong>.</p>
|
||||||
</div>
|
<div class="adm-dialog-warning">
|
||||||
</div>
|
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
|
||||||
<div class="modal-footer" style="border-top: 1px solid #3f3f3f; padding: 16px 24px;">
|
All videos uploaded by this user will also be deleted. This cannot be undone.
|
||||||
<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>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
@ -178,15 +226,19 @@ All Users ({{ $users->count() }})
|
|||||||
|
|
||||||
@section('scripts')
|
@section('scripts')
|
||||||
<script>
|
<script>
|
||||||
let currentDeleteUserId = null;
|
function openDeleteDialog(userId, userName) {
|
||||||
|
document.getElementById('dlgUserName').textContent = userName;
|
||||||
function confirmDeleteUser(userId, userName) {
|
document.getElementById('deleteForm').action = '/admin/users/' + userId;
|
||||||
currentDeleteUserId = userId;
|
document.getElementById('deleteDialog').classList.add('open');
|
||||||
document.getElementById('deleteUserName').textContent = userName;
|
}
|
||||||
document.getElementById('deleteUserForm').action = '/admin/users/' + userId;
|
function closeDeleteDialog() {
|
||||||
|
document.getElementById('deleteDialog').classList.remove('open');
|
||||||
const modal = new bootstrap.Modal(document.getElementById('deleteUserModal'));
|
}
|
||||||
modal.show();
|
document.getElementById('deleteDialog').addEventListener('click', function(e) {
|
||||||
}
|
if (e.target === this) closeDeleteDialog();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') closeDeleteDialog();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
590
resources/views/admin/video-analytics.blade.php
Normal file
590
resources/views/admin/video-analytics.blade.php
Normal 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
|
||||||
@ -1,88 +1,96 @@
|
|||||||
@extends('admin.layout')
|
@extends('admin.layout')
|
||||||
|
|
||||||
@section('title', 'Video Management')
|
@section('title', 'Videos')
|
||||||
@section('page_title', 'Video Management')
|
|
||||||
|
|
||||||
@section('content')
|
@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'))
|
@if(session('success'))
|
||||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
<div class="adm-alert adm-alert-success">
|
||||||
{{ session('success') }}
|
<i class="bi bi-check-circle-fill"></i>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
<span>{{ session('success') }}</span>
|
||||||
|
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if(session('error'))
|
@if(session('error'))
|
||||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
<div class="adm-alert adm-alert-error">
|
||||||
{{ session('error') }}
|
<i class="bi bi-exclamation-circle-fill"></i>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
<span>{{ session('error') }}</span>
|
||||||
|
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<!-- Search & Filters -->
|
{{-- ── Filter card ──────────────────────────────────────────────────── --}}
|
||||||
<div class="admin-card">
|
<div class="adm-card">
|
||||||
<form method="GET" action="{{ route('admin.videos') }}" class="filter-form">
|
<div class="adm-card-body" style="padding:16px 20px;">
|
||||||
<div class="form-group">
|
<form method="GET" action="{{ route('admin.videos') }}" class="adm-filter-form">
|
||||||
<label for="search">Search</label>
|
|
||||||
<input type="text" name="search" id="search" class="form-control" placeholder="Search by title or description..." value="{{ request('search') }}">
|
<div class="adm-filter-search">
|
||||||
</div>
|
<i class="bi bi-search"></i>
|
||||||
<div class="form-group">
|
<input type="text" name="search" class="adm-input"
|
||||||
<label for="status">Status</label>
|
placeholder="Search title or description…"
|
||||||
<select name="status" id="status" class="form-select">
|
value="{{ request('search') }}" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select name="status" class="adm-select">
|
||||||
<option value="">All Status</option>
|
<option value="">All Status</option>
|
||||||
<option value="ready" {{ request('status') == 'ready' ? 'selected' : '' }}>Ready</option>
|
<option value="ready" {{ request('status') === 'ready' ? 'selected' : '' }}>Ready</option>
|
||||||
<option value="processing" {{ request('status') == 'processing' ? 'selected' : '' }}>Processing</option>
|
<option value="processing" {{ request('status') === 'processing' ? 'selected' : '' }}>Processing</option>
|
||||||
<option value="pending" {{ request('status') == 'pending' ? 'selected' : '' }}>Pending</option>
|
<option value="pending" {{ request('status') === 'pending' ? 'selected' : '' }}>Pending</option>
|
||||||
<option value="failed" {{ request('status') == 'failed' ? 'selected' : '' }}>Failed</option>
|
<option value="failed" {{ request('status') === 'failed' ? 'selected' : '' }}>Failed</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<select name="visibility" class="adm-select">
|
||||||
<label for="visibility">Visibility</label>
|
|
||||||
<select name="visibility" id="visibility" class="form-select">
|
|
||||||
<option value="">All Visibility</option>
|
<option value="">All Visibility</option>
|
||||||
<option value="public" {{ request('visibility') == 'public' ? 'selected' : '' }}>Public</option>
|
<option value="public" {{ request('visibility') === 'public' ? 'selected' : '' }}>Public</option>
|
||||||
<option value="unlisted" {{ request('visibility') == 'unlisted' ? 'selected' : '' }}>Unlisted</option>
|
<option value="unlisted" {{ request('visibility') === 'unlisted' ? 'selected' : '' }}>Unlisted</option>
|
||||||
<option value="private" {{ request('visibility') == 'private' ? 'selected' : '' }}>Private</option>
|
<option value="private" {{ request('visibility') === 'private' ? 'selected' : '' }}>Private</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<select name="type" class="adm-select">
|
||||||
<label for="type">Type</label>
|
|
||||||
<select name="type" id="type" class="form-select">
|
|
||||||
<option value="">All Types</option>
|
<option value="">All Types</option>
|
||||||
<option value="generic" {{ request('type') == 'generic' ? 'selected' : '' }}>Generic</option>
|
<option value="generic" {{ request('type') === 'generic' ? 'selected' : '' }}>Generic</option>
|
||||||
<option value="music" {{ request('type') == 'music' ? 'selected' : '' }}>Music</option>
|
<option value="music" {{ request('type') === 'music' ? 'selected' : '' }}>Music</option>
|
||||||
<option value="match" {{ request('type') == 'match' ? 'selected' : '' }}>Match</option>
|
<option value="match" {{ request('type') === 'match' ? 'selected' : '' }}>Match</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<select name="sort" class="adm-select">
|
||||||
<label for="sort">Sort By</label>
|
<option value="latest" {{ request('sort', 'latest') === 'latest' ? 'selected' : '' }}>Newest first</option>
|
||||||
<select name="sort" id="sort" class="form-select">
|
<option value="oldest" {{ request('sort') === 'oldest' ? 'selected' : '' }}>Oldest first</option>
|
||||||
<option value="latest" {{ request('sort') == 'latest' ? 'selected' : '' }}>Latest First</option>
|
<option value="title_asc" {{ request('sort') === 'title_asc' ? 'selected' : '' }}>Title A–Z</option>
|
||||||
<option value="oldest" {{ request('sort') == 'oldest' ? 'selected' : '' }}>Oldest First</option>
|
<option value="title_desc" {{ request('sort') === 'title_desc' ? 'selected' : '' }}>Title Z–A</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>
|
</select>
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<button type="submit" class="adm-btn adm-btn-primary">
|
||||||
<label> </label>
|
<i class="bi bi-funnel"></i> Filter
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<i class="bi bi-search"></i> Filter
|
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ route('admin.videos') }}" class="btn btn-outline-light">
|
@if(request()->hasAny(['search','status','visibility','type','sort']))
|
||||||
<i class="bi bi-x-circle"></i> Clear
|
<a href="{{ route('admin.videos') }}" class="adm-btn">
|
||||||
|
<i class="bi bi-x-lg"></i> Clear
|
||||||
</a>
|
</a>
|
||||||
</div>
|
@endif
|
||||||
</form>
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Videos Table -->
|
{{-- ── Videos table ─────────────────────────────────────────────────── --}}
|
||||||
<div class="admin-card">
|
<div class="adm-card">
|
||||||
<div class="admin-card-header">
|
<div class="adm-card-header">
|
||||||
All Videos ({{ $videos->count() }})
|
<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>
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="adm-table-wrap">
|
||||||
<table class="admin-table">
|
<table class="adm-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Video</th>
|
<th>Video</th>
|
||||||
@ -93,97 +101,128 @@ All Videos ({{ $videos->count() }})
|
|||||||
<th>Views</th>
|
<th>Views</th>
|
||||||
<th>Likes</th>
|
<th>Likes</th>
|
||||||
<th>Uploaded</th>
|
<th>Uploaded</th>
|
||||||
<th>Actions</th>
|
<th style="width:110px; text-align:right;">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@forelse($videos as $video)
|
@forelse($videos as $video)
|
||||||
<tr>
|
<tr>
|
||||||
|
{{-- Thumbnail + title --}}
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div style="display:flex; align-items:center; gap:12px;">
|
||||||
@if($video->thumbnail)
|
@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
|
@else
|
||||||
<div style="width: 80px; height: 50px; background: #333; border-radius: 4px; display: flex; align-items: center; justify-content: center;">
|
<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 text-secondary"></i>
|
<i class="bi bi-play-circle" style="color:var(--text-3);font-size:18px;"></i>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
<div style="max-width: 200px;">
|
<div style="min-width:0;">
|
||||||
<div style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 500;">{{ $video->title }}</div>
|
<div style="font-weight:500;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;">
|
||||||
<small class="text-secondary">{{ Str::limit($video->description, 50) }}</small>
|
{{ $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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
{{-- Owner --}}
|
||||||
<td>
|
<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 }}
|
{{ $video->user->name }}
|
||||||
|
<i class="bi bi-box-arrow-up-right" style="font-size:10px;opacity:.4;"></i>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
{{-- Status --}}
|
||||||
<td>
|
<td>
|
||||||
@switch($video->status)
|
@php
|
||||||
@case('ready')
|
$statusMap = [
|
||||||
<span class="badge-status badge-ready">Ready</span>
|
'ready' => ['adm-badge-ready', 'bi-check-circle-fill', 'Ready'],
|
||||||
@break
|
'processing' => ['adm-badge-unlisted','bi-arrow-repeat', 'Processing'],
|
||||||
@case('processing')
|
'pending' => ['adm-badge-unverified','bi-clock', 'Pending'],
|
||||||
<span class="badge-status badge-processing">Processing</span>
|
'failed' => ['adm-badge-superadmin','bi-x-circle-fill', 'Failed'],
|
||||||
@break
|
];
|
||||||
@case('pending')
|
[$cls, $ico, $lbl] = $statusMap[$video->status] ?? ['adm-badge-user','bi-dash','Unknown'];
|
||||||
<span class="badge-status badge-pending">Pending</span>
|
@endphp
|
||||||
@break
|
<span class="adm-badge {{ $cls }}"><i class="bi {{ $ico }}"></i> {{ $lbl }}</span>
|
||||||
@case('failed')
|
|
||||||
<span class="badge-status badge-failed">Failed</span>
|
|
||||||
@break
|
|
||||||
@endswitch
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
{{-- Visibility --}}
|
||||||
<td>
|
<td>
|
||||||
@switch($video->visibility)
|
@php
|
||||||
@case('public')
|
$visMap = [
|
||||||
<span class="badge-status badge-public">Public</span>
|
'public' => ['adm-badge-ready', 'bi-globe2', 'Public'],
|
||||||
@break
|
'unlisted' => ['adm-badge-unlisted','bi-link-45deg', 'Unlisted'],
|
||||||
@case('unlisted')
|
'private' => ['adm-badge-private', 'bi-lock-fill', 'Private'],
|
||||||
<span class="badge-status badge-unlisted">Unlisted</span>
|
];
|
||||||
@break
|
[$vcls, $vico, $vlbl] = $visMap[$video->visibility] ?? ['adm-badge-user','bi-question','—'];
|
||||||
@case('private')
|
@endphp
|
||||||
<span class="badge-status badge-private">Private</span>
|
<span class="adm-badge {{ $vcls }}"><i class="bi {{ $vico }}"></i> {{ $vlbl }}</span>
|
||||||
@break
|
|
||||||
@endswitch
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
{{-- Type --}}
|
||||||
<td>
|
<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>
|
||||||
<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>
|
{{-- Views --}}
|
||||||
<td>{{ $video->created_at->format('M d, Y') }}</td>
|
<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>
|
<td>
|
||||||
<div class="dropdown">
|
<div class="adm-row-actions" style="justify-content:flex-end;">
|
||||||
<button class="btn btn-sm btn-outline-light dropdown-toggle" data-bs-toggle="dropdown">
|
<a href="{{ route('videos.show', $video) }}" target="_blank"
|
||||||
<i class="bi bi-gear"></i>
|
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>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="9" class="text-center text-secondary py-4">
|
<td colspan="9">
|
||||||
No videos found
|
<div class="empty-state">
|
||||||
|
<i class="bi bi-play-circle"></i>
|
||||||
|
<p>No videos found</p>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@endforelse
|
@endforelse
|
||||||
@ -191,40 +230,42 @@ All Videos ({{ $videos->count() }})
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
{{-- Pagination --}}
|
||||||
<div class="d-flex justify-content-center">
|
@if($videos instanceof \Illuminate\Pagination\LengthAwarePaginator && $videos->hasPages())
|
||||||
{{ $videos->links() }}
|
<div style="padding:16px 20px; border-top:1px solid var(--border);">
|
||||||
|
{{ $videos->onEachSide(1)->links() }}
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete Video Modal -->
|
{{-- ── Delete confirmation dialog ───────────────────────────────────── --}}
|
||||||
<div class="modal fade" id="deleteVideoModal" tabindex="-1" aria-labelledby="deleteVideoModalLabel" aria-hidden="true">
|
<div class="adm-dialog-overlay" id="deleteDialog">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="adm-dialog">
|
||||||
<div class="modal-content" style="background: #1a1a1a; border: 1px solid #3f3f3f; border-radius: 12px;">
|
<div class="adm-dialog-header">
|
||||||
<div class="modal-header" style="border-bottom: 1px solid #3f3f3f; padding: 20px 24px;">
|
<div class="adm-dialog-title">
|
||||||
<h5 class="modal-title" id="deleteVideoModalLabel" style="color: #fff; font-weight: 600;">
|
<i class="bi bi-exclamation-triangle-fill"></i> Delete Video
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body" style="padding: 24px;">
|
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDeleteDialog()"
|
||||||
<p>Are you sure you want to delete this video? This action cannot be undone.</p>
|
style="border:none;background:none;color:var(--text-2);">
|
||||||
<p><strong>Video:</strong> <span id="deleteVideoTitle"></span></p>
|
<i class="bi bi-x-lg"></i>
|
||||||
<div class="alert alert-warning">
|
</button>
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
</div>
|
||||||
This will also delete all likes and views associated with this video.
|
<div class="adm-dialog-body">
|
||||||
</div>
|
<p>You are about to permanently delete <strong id="dlgVideoTitle"></strong>.</p>
|
||||||
</div>
|
<div class="adm-dialog-warning">
|
||||||
<div class="modal-footer" style="border-top: 1px solid #3f3f3f; padding: 16px 24px;">
|
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
|
||||||
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>
|
All views, likes, comments, and HLS files for this video will also be deleted.
|
||||||
<form id="deleteVideoForm" method="POST">
|
|
||||||
@csrf
|
|
||||||
@method('DELETE')
|
|
||||||
<button type="submit" class="btn btn-danger">Delete Video</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
@ -232,12 +273,19 @@ All Videos ({{ $videos->count() }})
|
|||||||
|
|
||||||
@section('scripts')
|
@section('scripts')
|
||||||
<script>
|
<script>
|
||||||
function confirmDeleteVideo(videoId, videoTitle) {
|
function openDeleteDialog(videoId, videoTitle) {
|
||||||
document.getElementById('deleteVideoTitle').textContent = videoTitle;
|
document.getElementById('dlgVideoTitle').textContent = videoTitle;
|
||||||
document.getElementById('deleteVideoForm').action = '/admin/videos/' + videoId;
|
document.getElementById('deleteForm').action = '/admin/videos/' + videoId;
|
||||||
|
document.getElementById('deleteDialog').classList.add('open');
|
||||||
const modal = new bootstrap.Modal(document.getElementById('deleteVideoModal'));
|
}
|
||||||
modal.show();
|
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>
|
</script>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
31
resources/views/auth/2fa-challenge.blade.php
Normal file
31
resources/views/auth/2fa-challenge.blade.php
Normal 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
Loading…
x
Reference in New Issue
Block a user