Compare commits
10 Commits
d44490dfe0
...
05db0e128a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05db0e128a | ||
|
|
c160242dbc | ||
|
|
6b3ab5b65e | ||
|
|
296d605864 | ||
|
|
0b75acec89 | ||
|
|
d1441b213a | ||
|
|
615e7efd7c | ||
|
|
8a00bcecac | ||
|
|
69ae56331a | ||
|
|
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_SCHEME="${PUSHER_SCHEME}"
|
||||
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
||||
|
||||
# NAS File Manager
|
||||
NAS_PROTOCOL=smb
|
||||
NAS_HOST=
|
||||
NAS_PORT=445
|
||||
NAS_USERNAME=
|
||||
NAS_PASSWORD=
|
||||
NAS_PATH=/media
|
||||
NAS_SMB_SHARE=
|
||||
NAS_SMB_DOMAIN=
|
||||
NAS_FM_ROUTE_PREFIX=nas-file-manager
|
||||
|
||||
208
CLAUDE.md
Normal file
208
CLAUDE.md
Normal file
@ -0,0 +1,208 @@
|
||||
# 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`).
|
||||
|
||||
### NAS is always enabled — it is the only storage backend
|
||||
|
||||
**The NAS is permanently enabled in this project. Local disk is never a storage destination — it is a temporary write buffer only. Every user file must end up on the NAS and be served from the NAS. No exceptions.**
|
||||
|
||||
File types and their NAS locations:
|
||||
|
||||
| File type | NAS path | Served via |
|
||||
|---|---|---|
|
||||
| Video / audio | `users/{slug}/videos/{title-slug}/{title-slug}.{ext}` | `NasSyncService::ensureLocalCopy()` |
|
||||
| Video thumbnail | `users/{slug}/videos/{title-slug}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
|
||||
| Audio slides | `users/{slug}/videos/{title-slug}/slides/{n}.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
|
||||
| Playlist thumbnail | `users/{slug}/playlists/{playlist-id}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
|
||||
| Avatar | `users/{slug}/profile/avatar.{ext}` | `MediaController::avatar` + `ensureLocalAsset()` |
|
||||
| Banner | `users/{slug}/profile/banner.{ext}` | `MediaController::banner` + `ensureLocalAsset()` |
|
||||
| Post images | `users/{slug}/posts/{post-id}/{filename}` | `MediaController::postImage` + `ensureLocalAsset()` |
|
||||
|
||||
The only files that live permanently on local disk are HLS segments (`storage/app/public/hls/{video_id}/`) because they are generated locally and served directly. Everything else is NAS.
|
||||
|
||||
**The following local directories must never exist as permanent storage.** They were deleted after migration and must not be recreated as destinations:
|
||||
- `storage/app/public/thumbnails/` — formerly held video/slide/playlist thumbnails; now NAS only
|
||||
- `storage/app/public/avatars/` — formerly held user avatars; now NAS only
|
||||
- `storage/app/public/videos/` — formerly held uploaded video files; now NAS only
|
||||
|
||||
These directories may appear temporarily during an upload (as a write buffer before NAS push) and are cleaned up immediately. If you ever find files lingering there after an upload completes, it means the NAS push failed — investigate the NAS connection, do not leave files there.
|
||||
|
||||
**Absolute rules — these must never be violated:**
|
||||
|
||||
1. **Never use `asset('storage/...')` for any user file URL.** Always use the named media routes: `route('media.thumbnail', $path)`, `route('media.avatar', $path)`, `route('media.banner', $path)`, `route('media.post-image', $path)`. These routes go through `MediaController` which calls `ensureLocalAsset()` and pulls from NAS automatically.
|
||||
|
||||
2. **After writing any file to local disk, immediately push it to NAS and delete the local copy.** The upload flow is always: write to temp → push to NAS → delete local. Use the correct service method for each type:
|
||||
- Videos/audio → `NasSyncService::uploadDirectToNas()` then `deleteLocalVideo()`
|
||||
- Thumbnails (video/slide) → `NasSyncService::putFile($tempAbs, "{$nasDir}/thumb.{$ext}")` then `@unlink($tempAbs)`, store full NAS path in DB
|
||||
- Playlist thumbnails → `PlaylistController::pushPlaylistThumbnailToNas()` (handles mkdirp, putFile, unlink internally)
|
||||
- Avatars → `NasSyncService::syncAvatar()` then `deleteLocalAvatar()`
|
||||
- Banners → `NasSyncService::syncBanner()` then `deleteLocalBanner()`
|
||||
- Post images → `NasSyncService::syncPostImages()` then `deleteLocalPostImages()`
|
||||
|
||||
3. **Always store the full NAS relative path in the DB, never just the filename.** The DB column must contain the full `users/...` path (e.g. `users/hanzo-hattori-bfnmwq/videos/my-title/thumb.png`). Storing only the basename (e.g. `thumb.png` or a UUID filename) is the legacy format that breaks NAS serving and the MediaController fallback logic.
|
||||
|
||||
4. **Never call `putFile()` directly for video/audio uploads.** Always use `uploadDirectToNas()` — it resolves the correct `users/...` directory, writes `meta.json`, and updates the DB `path` and `filename` columns. Calling `putFile()` with a manually constructed path will create files in the wrong location that the streaming layer cannot find.
|
||||
|
||||
5. **Set `video->status = 'ready'` before dispatching `GenerateHlsJob` for NAS uploads.** The job checks `if ($video->status !== 'ready') return` and silently does nothing otherwise. For NAS, the upload is the compression step — the video is ready as soon as `uploadDirectToNas()` completes. For local storage, `CompressVideoJob` handles the status transition automatically.
|
||||
|
||||
6. **Never check `NasSyncService::isEnabled()` before doing a NAS operation in this project.** It is always enabled. Writing code with an `if ($nas->isEnabled())` branch that falls back to local-only storage will result in broken files the moment that branch is taken.
|
||||
|
||||
**If you find legacy local files that should be on NAS**, follow this migration procedure (same pattern used to clean up thumbnails and avatars):
|
||||
1. Identify which DB record owns each local file (`Video::where('thumbnail', $filename)`, `User::where('avatar', $filename)`, etc.)
|
||||
2. For each owned file: call `NasSyncService::mkdirp($nasDir)` then `putFile($localAbs, $nasPath)` then `@unlink($localAbs)`, then update the DB record to the full NAS path
|
||||
3. Delete files with no DB match (orphans) directly with `@unlink()`
|
||||
4. Once a directory is empty, `rmdir()` it — do not leave empty legacy directories
|
||||
5. For playlists: use `PlaylistController::pushPlaylistThumbnailToNas()` or replicate its pattern (`mkdirp` + `putFile` + `unlink`)
|
||||
|
||||
### 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**
|
||||
366
app/Console/Commands/NasFreeLocalStorage.php
Normal file
366
app/Console/Commands/NasFreeLocalStorage.php
Normal file
@ -0,0 +1,366 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NasFreeLocalStorage extends Command
|
||||
{
|
||||
protected $signature = 'nas:free-local
|
||||
{--dry-run : Preview what would be deleted without deleting}
|
||||
{--force : Actually delete local files confirmed on NAS}
|
||||
{--cache-ttl=24 : Hours after which NAS stream-cache files are evicted (0 = all)}';
|
||||
|
||||
protected $description = 'Delete local files (videos, thumbnails, avatars, banners) already stored on the NAS';
|
||||
|
||||
public function handle(NasSyncService $nas): int
|
||||
{
|
||||
if (! $nas->isEnabled()) {
|
||||
$this->error('NAS sync is not enabled. Enable it in Admin → Settings first.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$dryRun = $this->option('dry-run');
|
||||
$force = $this->option('force');
|
||||
|
||||
if (! $dryRun && ! $force) {
|
||||
$this->warn('Pass --dry-run to preview, or --force to delete.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$mode = $dryRun ? 'DRY RUN — nothing will be deleted' : 'FORCE — deleting confirmed NAS files';
|
||||
$this->info($mode);
|
||||
$this->newLine();
|
||||
|
||||
$totalBytes = 0;
|
||||
$toDelete = [];
|
||||
|
||||
// ── Videos ───────────────────────────────────────────────────────────
|
||||
$this->info('Scanning video files…');
|
||||
|
||||
$videos = Video::all()->filter(fn (Video $v) => file_exists(storage_path('app/' . $v->path)));
|
||||
|
||||
if ($videos->isNotEmpty()) {
|
||||
$bar = $this->output->createProgressBar($videos->count());
|
||||
$bar->start();
|
||||
|
||||
foreach ($videos as $video) {
|
||||
$localPath = storage_path('app/' . $video->path);
|
||||
$dir = $nas->resolveVideoDir($video);
|
||||
$meta = null;
|
||||
try {
|
||||
$raw = $nas->getContent("{$dir}/meta.json");
|
||||
$meta = $raw ? json_decode($raw, true) : null;
|
||||
} catch (\Throwable) {}
|
||||
|
||||
if (is_array($meta) && ($meta['id'] ?? null) === $video->id) {
|
||||
$bytes = filesize($localPath);
|
||||
$totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => "video #{$video->id}", 'path' => $localPath, 'bytes' => $bytes];
|
||||
}
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
} else {
|
||||
$this->line(' No local video files found.');
|
||||
}
|
||||
|
||||
// ── Thumbnails & slides (both legacy flat dir and new NAS-mirrored dirs) ──
|
||||
$this->newLine();
|
||||
$this->info('Scanning thumbnail and slide files…');
|
||||
|
||||
// Helper: check if a video's NAS dir is confirmed, using meta.json
|
||||
$confirmNas = function (Video $v) use ($nas): bool {
|
||||
try {
|
||||
$raw = $nas->getContent($nas->resolveVideoDir($v) . '/meta.json');
|
||||
$meta = $raw ? json_decode($raw, true) : null;
|
||||
return is_array($meta) && ($meta['id'] ?? null) === $v->id;
|
||||
} catch (\Throwable) { return false; }
|
||||
};
|
||||
|
||||
// Legacy flat thumbnails dir
|
||||
$thumbDir = storage_path('app/public/thumbnails');
|
||||
if (is_dir($thumbDir)) {
|
||||
foreach (glob($thumbDir . '/*') as $file) {
|
||||
if (! is_file($file)) continue;
|
||||
$filename = basename($file);
|
||||
$video = Video::where('thumbnail', $filename)->first();
|
||||
if ($video) {
|
||||
if ($confirmNas($video)) {
|
||||
$bytes = filesize($file); $totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => "thumb:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
||||
}
|
||||
} else {
|
||||
$slide = \App\Models\VideoSlide::where('filename', $filename)->with('video')->first();
|
||||
if ($slide && $slide->video && $confirmNas($slide->video)) {
|
||||
$bytes = filesize($file); $totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => "slide:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New NAS-mirrored dirs: storage/app/users/{username}/videos/{slug}/thumb.*
|
||||
// slides/{id}.*
|
||||
$usersBase = storage_path('app/users');
|
||||
if (is_dir($usersBase)) {
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($usersBase, \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file->isFile()) continue;
|
||||
$absPath = $file->getPathname();
|
||||
$relPath = ltrim(str_replace(storage_path('app'), '', $absPath), '/');
|
||||
|
||||
// Match thumbnail or slide by relative path stored in DB
|
||||
$video = Video::where('thumbnail', $relPath)->first();
|
||||
if ($video) {
|
||||
if ($confirmNas($video)) {
|
||||
$bytes = $file->getSize(); $totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => 'thumb:' . basename($relPath), 'path' => $absPath, 'bytes' => $bytes];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$slide = \App\Models\VideoSlide::where('filename', $relPath)->with('video')->first();
|
||||
if ($slide && $slide->video && $confirmNas($slide->video)) {
|
||||
$bytes = $file->getSize(); $totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => 'slide:' . basename($relPath), 'path' => $absPath, 'bytes' => $bytes];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->line(' Done scanning thumbnails.');
|
||||
|
||||
// ── Avatars ───────────────────────────────────────────────────────────
|
||||
$this->newLine();
|
||||
$this->info('Scanning avatar files…');
|
||||
|
||||
// Legacy flat dir
|
||||
$avatarDir = storage_path('app/public/avatars');
|
||||
if (is_dir($avatarDir)) {
|
||||
foreach (glob($avatarDir . '/*') as $file) {
|
||||
if (! is_file($file)) continue;
|
||||
$filename = basename($file);
|
||||
$user = User::where('avatar', $filename)->first();
|
||||
if (! $user) continue;
|
||||
|
||||
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||
$raw = null;
|
||||
try { $raw = $nas->getContent("{$dir}/avatar.webp"); } catch (\Throwable) {}
|
||||
if ($raw !== null) {
|
||||
$bytes = filesize($file); $totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => "avatar:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New structured dir: users/{slug}/profile/avatar.*
|
||||
$usersBase = storage_path('app/users');
|
||||
if (is_dir($usersBase)) {
|
||||
foreach (glob("{$usersBase}/*/profile/avatar.*") ?: [] as $file) {
|
||||
if (! is_file($file)) continue;
|
||||
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $file)), '/');
|
||||
$user = User::where('avatar', $relPath)->first();
|
||||
if (! $user) continue;
|
||||
|
||||
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||
$raw = null;
|
||||
try { $raw = $nas->getContent("{$dir}/avatar.webp"); } catch (\Throwable) {}
|
||||
if ($raw !== null) {
|
||||
$bytes = filesize($file); $totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => 'avatar:' . basename($file), 'path' => $file, 'bytes' => $bytes];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->line(' Done scanning avatars.');
|
||||
|
||||
// ── Banners ───────────────────────────────────────────────────────────
|
||||
$this->newLine();
|
||||
$this->info('Scanning banner files…');
|
||||
|
||||
// Legacy flat dir
|
||||
$bannerDir = storage_path('app/public/banners');
|
||||
if (is_dir($bannerDir)) {
|
||||
foreach (glob($bannerDir . '/*') as $file) {
|
||||
if (! is_file($file)) continue;
|
||||
$filename = basename($file);
|
||||
$user = User::where('banner', $filename)->first();
|
||||
if (! $user) continue;
|
||||
|
||||
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||
$raw = null;
|
||||
try { $raw = $nas->getContent("{$dir}/cover.webp"); } catch (\Throwable) {}
|
||||
if ($raw !== null) {
|
||||
$bytes = filesize($file); $totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => "banner:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New structured dir: users/{slug}/profile/cover.*
|
||||
if (is_dir($usersBase)) {
|
||||
foreach (glob("{$usersBase}/*/profile/cover.*") ?: [] as $file) {
|
||||
if (! is_file($file)) continue;
|
||||
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $file)), '/');
|
||||
$user = User::where('banner', $relPath)->first();
|
||||
if (! $user) continue;
|
||||
|
||||
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||
$raw = null;
|
||||
try { $raw = $nas->getContent("{$dir}/cover.webp"); } catch (\Throwable) {}
|
||||
if ($raw !== null) {
|
||||
$bytes = filesize($file); $totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => 'banner:' . basename($file), 'path' => $file, 'bytes' => $bytes];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->line(' Done scanning banners.');
|
||||
|
||||
// ── Slideshow cache directories ───────────────────────────────────────
|
||||
// The slideshow/ directory is a render cache that is always regenerated on
|
||||
// demand, so its contents are safe to delete unconditionally.
|
||||
$this->newLine();
|
||||
$this->info('Scanning slideshow cache…');
|
||||
|
||||
$slideshowDir = storage_path('app/public/slideshow');
|
||||
if (is_dir($slideshowDir)) {
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($slideshowDir, \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file->isFile()) continue;
|
||||
$bytes = $file->getSize();
|
||||
$totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => 'slideshow', 'path' => $file->getPathname(), 'bytes' => $bytes];
|
||||
}
|
||||
$this->line(' Done scanning slideshow cache.');
|
||||
}
|
||||
|
||||
// ── NAS stream cache (nas_cache/videos/) ──────────────────────────────
|
||||
// These are on-demand local copies of NAS videos used for HTTP streaming.
|
||||
// Always safe to delete — they are re-downloaded from NAS on next play.
|
||||
$this->newLine();
|
||||
$this->info('Scanning NAS stream cache…');
|
||||
|
||||
$nasCacheDir = storage_path('app/nas_cache/videos');
|
||||
$ttl = (int) $this->option('cache-ttl');
|
||||
$cutoff = time() - ($ttl * 3600);
|
||||
|
||||
if (is_dir($nasCacheDir)) {
|
||||
foreach (glob("{$nasCacheDir}/*") as $file) {
|
||||
if (! is_file($file)) continue;
|
||||
if ($ttl > 0 && filemtime($file) >= $cutoff) continue;
|
||||
$bytes = filesize($file);
|
||||
$totalBytes += $bytes;
|
||||
$toDelete[] = ['label' => 'nas-cache', 'path' => $file, 'bytes' => $bytes];
|
||||
}
|
||||
$this->line(' Done scanning NAS stream cache.');
|
||||
}
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────────
|
||||
$this->newLine();
|
||||
|
||||
if (empty($toDelete)) {
|
||||
$this->info('Nothing to delete — all local files are either not yet on NAS or already gone.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Type', 'File', 'Size'],
|
||||
array_map(fn ($row) => [
|
||||
$row['label'],
|
||||
basename($row['path']),
|
||||
$this->humanBytes($row['bytes']),
|
||||
], $toDelete)
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
$this->line(sprintf(
|
||||
'Found <comment>%d</comment> file(s) totalling <comment>%s</comment> that can be freed.',
|
||||
count($toDelete),
|
||||
$this->humanBytes($totalBytes)
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
$this->newLine();
|
||||
$this->warn('Run with --force to delete these files.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Actually delete ───────────────────────────────────────────────────
|
||||
$deleted = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($toDelete as $row) {
|
||||
if (@unlink($row['path'])) {
|
||||
$deleted++;
|
||||
Log::info('nas:free-local: deleted ' . $row['path']);
|
||||
} else {
|
||||
$failed++;
|
||||
$this->warn("Could not delete: {$row['path']}");
|
||||
}
|
||||
}
|
||||
|
||||
// Prune empty directories left behind under storage/app/users/ and flat asset dirs
|
||||
$this->pruneEmptyDirs(storage_path('app/users'));
|
||||
foreach (['public/avatars', 'public/banners', 'public/thumbnails'] as $rel) {
|
||||
$path = storage_path("app/{$rel}");
|
||||
if (is_dir($path) && empty(array_diff(scandir($path) ?: [], ['.', '..']))) {
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Deleted {$deleted} file(s), freed {$this->humanBytes($totalBytes)}.");
|
||||
|
||||
if ($failed > 0) {
|
||||
$this->warn("{$failed} file(s) could not be deleted — check permissions.");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom-up prune: remove dirs that are empty or contain only meta.json.
|
||||
*/
|
||||
private function pruneEmptyDirs(string $root): void
|
||||
{
|
||||
if (! is_dir($root)) return;
|
||||
|
||||
$iter = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($iter as $item) {
|
||||
if (! $item->isDir()) continue;
|
||||
$path = $item->getPathname();
|
||||
$contents = array_diff(scandir($path) ?: [], ['.', '..']);
|
||||
$nonMeta = array_diff($contents, ['meta.json']);
|
||||
if (empty($contents)) {
|
||||
@rmdir($path);
|
||||
} elseif (empty($nonMeta)) {
|
||||
@unlink("{$path}/meta.json");
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private function humanBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes >= 1_073_741_824) return round($bytes / 1_073_741_824, 2) . ' GB';
|
||||
if ($bytes >= 1_048_576) return round($bytes / 1_048_576, 2) . ' MB';
|
||||
if ($bytes >= 1_024) return round($bytes / 1_024, 2) . ' KB';
|
||||
return $bytes . ' B';
|
||||
}
|
||||
}
|
||||
435
app/Console/Commands/NasRepairLocalFiles.php
Normal file
435
app/Console/Commands/NasRepairLocalFiles.php
Normal file
@ -0,0 +1,435 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use App\Models\VideoSlide;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NasRepairLocalFiles extends Command
|
||||
{
|
||||
protected $signature = 'nas:repair
|
||||
{--dry-run : Preview what would be pushed without making changes}
|
||||
{--force : Actually upload to NAS and remove local copies}
|
||||
{--cache-ttl=24 : Hours after which NAS stream-cache files are evicted (0 = all)}';
|
||||
|
||||
protected $description = 'Upload stuck local files to NAS, fix DB paths, and delete local copies';
|
||||
|
||||
private NasSyncService $nas;
|
||||
|
||||
public function handle(NasSyncService $nas): int
|
||||
{
|
||||
if (! $nas->isEnabled()) {
|
||||
$this->error('NAS sync is not enabled. Enable it in Admin → Settings first.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->nas = $nas;
|
||||
|
||||
$dryRun = $this->option('dry-run');
|
||||
$force = $this->option('force');
|
||||
|
||||
if (! $dryRun && ! $force) {
|
||||
$this->warn('Pass --dry-run to preview, or --force to repair.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info($dryRun ? 'DRY RUN — nothing will be changed' : 'FORCE — uploading to NAS and removing local copies');
|
||||
$this->newLine();
|
||||
|
||||
$totalRepaired = 0;
|
||||
$totalFailed = 0;
|
||||
|
||||
// ── NAS-format videos / slides / thumbnails ───────────────────────────
|
||||
$stuckVideos = $this->findStuckVideos();
|
||||
if ($stuckVideos->isNotEmpty()) {
|
||||
$this->info('Video / slide files stuck locally:');
|
||||
$rows = [];
|
||||
foreach ($stuckVideos as $item) {
|
||||
foreach ($item['files'] as $label) {
|
||||
$rows[] = [$item['video']->id, substr($item['video']->title, 0, 40), $label];
|
||||
}
|
||||
}
|
||||
$this->table(['Video ID', 'Title', 'File'], $rows);
|
||||
$this->newLine();
|
||||
|
||||
if ($force) {
|
||||
foreach ($stuckVideos as $item) {
|
||||
$video = $item['video'];
|
||||
$this->line(" Repairing video #{$video->id}: {$video->title}…");
|
||||
try {
|
||||
$nas->syncVideo($video);
|
||||
$nas->deleteLocalAssets($video);
|
||||
if ($video->hls_path || $video->type === 'music') {
|
||||
$nas->deleteLocalVideo($video);
|
||||
}
|
||||
$nas->pruneLocalVideoDir($video);
|
||||
$totalRepaired++;
|
||||
$this->line(' <info>✓ Done</info>');
|
||||
Log::info("nas:repair: fixed video #{$video->id}");
|
||||
} catch (\Throwable $e) {
|
||||
$totalFailed++;
|
||||
$this->warn(" ✗ Failed: {$e->getMessage()}");
|
||||
Log::error("nas:repair: failed video #{$video->id}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
$this->newLine();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Avatars ───────────────────────────────────────────────────────────
|
||||
$stuckAvatars = $this->findStuckAvatars();
|
||||
if ($stuckAvatars->isNotEmpty()) {
|
||||
$this->info('Avatar files stuck locally:');
|
||||
$this->table(['User ID', 'Username', 'File'], $stuckAvatars->map(fn ($r) => [
|
||||
$r['user']->id, $r['user']->username ?? $r['user']->name, $r['file'],
|
||||
])->all());
|
||||
$this->newLine();
|
||||
|
||||
if ($force) {
|
||||
foreach ($stuckAvatars as $item) {
|
||||
$user = $item['user'];
|
||||
$path = $item['path'];
|
||||
$this->line(" Repairing avatar for {$user->username}…");
|
||||
try {
|
||||
$nas->syncAvatar($user, $path);
|
||||
$nas->deleteLocalAvatar($user);
|
||||
$totalRepaired++;
|
||||
$this->line(' <info>✓ Done</info>');
|
||||
} catch (\Throwable $e) {
|
||||
$totalFailed++;
|
||||
$this->warn(" ✗ Failed: {$e->getMessage()}");
|
||||
Log::error("nas:repair: failed avatar user#{$user->id}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
$this->newLine();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Banners ───────────────────────────────────────────────────────────
|
||||
$stuckBanners = $this->findStuckBanners();
|
||||
if ($stuckBanners->isNotEmpty()) {
|
||||
$this->info('Banner files stuck locally:');
|
||||
$this->table(['User ID', 'Username', 'File'], $stuckBanners->map(fn ($r) => [
|
||||
$r['user']->id, $r['user']->username ?? $r['user']->name, $r['file'],
|
||||
])->all());
|
||||
$this->newLine();
|
||||
|
||||
if ($force) {
|
||||
foreach ($stuckBanners as $item) {
|
||||
$user = $item['user'];
|
||||
$path = $item['path'];
|
||||
$this->line(" Repairing banner for {$user->username}…");
|
||||
try {
|
||||
$nas->syncCover($user, $path);
|
||||
$nas->deleteLocalBanner($user);
|
||||
$totalRepaired++;
|
||||
$this->line(' <info>✓ Done</info>');
|
||||
} catch (\Throwable $e) {
|
||||
$totalFailed++;
|
||||
$this->warn(" ✗ Failed: {$e->getMessage()}");
|
||||
Log::error("nas:repair: failed banner user#{$user->id}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
$this->newLine();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Legacy flat thumbnails (public/thumbnails/) ───────────────────────
|
||||
$stuckThumbs = $this->findStuckLegacyThumbnails();
|
||||
if ($stuckThumbs->isNotEmpty()) {
|
||||
$this->info('Legacy thumbnail/slide files stuck locally:');
|
||||
$this->table(['Type', 'File', 'Video ID'], $stuckThumbs->map(fn ($r) => [
|
||||
$r['type'], $r['file'], $r['video_id'],
|
||||
])->all());
|
||||
$this->newLine();
|
||||
|
||||
if ($force) {
|
||||
foreach ($stuckThumbs as $item) {
|
||||
$this->line(" Repairing {$item['type']}: {$item['file']}…");
|
||||
try {
|
||||
if ($item['type'] === 'thumbnail' && $item['video']) {
|
||||
$nas->syncVideo($item['video']);
|
||||
} elseif ($item['type'] === 'slide' && $item['slide'] && $item['video']) {
|
||||
// Upload slide directly to NAS
|
||||
$video = $item['video'];
|
||||
$slide = $item['slide'];
|
||||
$dir = $nas->resolveVideoDir($video);
|
||||
$ext = pathinfo($item['path'], PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$nas->mkdirp("{$dir}/slides");
|
||||
$nas->putFile($item['path'], "{$dir}/slides/{$slide->position}.{$ext}");
|
||||
}
|
||||
@unlink($item['path']);
|
||||
$totalRepaired++;
|
||||
$this->line(' <info>✓ Done</info>');
|
||||
} catch (\Throwable $e) {
|
||||
$totalFailed++;
|
||||
$this->warn(" ✗ Failed: {$e->getMessage()}");
|
||||
Log::error("nas:repair: failed legacy thumb {$item['file']}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
$this->newLine();
|
||||
}
|
||||
}
|
||||
|
||||
$anyStuck = $stuckVideos->isNotEmpty() || $stuckAvatars->isNotEmpty()
|
||||
|| $stuckBanners->isNotEmpty() || $stuckThumbs->isNotEmpty();
|
||||
|
||||
// ── NAS orphaned video folders ─────────────────────────────────────────
|
||||
$this->newLine();
|
||||
$this->info('Scanning NAS for orphaned video folders (no matching DB record)…');
|
||||
$nasOrphans = $nas->scanNasOrphans();
|
||||
|
||||
if (! empty($nasOrphans)) {
|
||||
$this->table(
|
||||
['NAS Directory', 'meta.json video_id'],
|
||||
array_map(fn ($o) => [$o['dir'], $o['video_id'] ?? '(none)'], $nasOrphans)
|
||||
);
|
||||
$this->newLine();
|
||||
|
||||
if ($force) {
|
||||
$deletedOrphans = 0;
|
||||
$failedOrphans = 0;
|
||||
foreach ($nasOrphans as $orphan) {
|
||||
$this->line(" Deleting NAS orphan: {$orphan['dir']}…");
|
||||
try {
|
||||
$nas->deleteNasTree($orphan['dir']);
|
||||
$deletedOrphans++;
|
||||
$this->line(' <info>✓ Deleted</info>');
|
||||
Log::info('nas:repair: deleted NAS orphan', ['dir' => $orphan['dir']]);
|
||||
} catch (\Throwable $e) {
|
||||
$failedOrphans++;
|
||||
$this->warn(" ✗ Failed: {$e->getMessage()}");
|
||||
Log::error('nas:repair: failed to delete NAS orphan', ['dir' => $orphan['dir'], 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
$totalRepaired += $deletedOrphans;
|
||||
$totalFailed += $failedOrphans;
|
||||
$this->newLine();
|
||||
}
|
||||
} else {
|
||||
$this->line(' No orphaned NAS folders found.');
|
||||
}
|
||||
|
||||
$anyIssues = $anyStuck || ! empty($nasOrphans);
|
||||
|
||||
// ── NAS stream cache ──────────────────────────────────────────────────
|
||||
$cacheBytes = $nas->nasCacheSize();
|
||||
if ($cacheBytes > 0) {
|
||||
$ttl = (int) $this->option('cache-ttl');
|
||||
$ttlLabel = $ttl === 0 ? 'all files' : "files older than {$ttl}h";
|
||||
$this->info(sprintf(
|
||||
'NAS stream cache: <comment>%s</comment> occupying <comment>%s</comment> — will be evicted on --force (%s).',
|
||||
count(glob(storage_path('app/nas_cache/videos/*')) ?: []) . ' file(s)',
|
||||
$this->humanBytes($cacheBytes),
|
||||
$ttlLabel
|
||||
));
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
if (! $anyIssues && $cacheBytes === 0) {
|
||||
$this->info('Nothing to repair — no stuck local files, no NAS orphans, and no stream cache found.');
|
||||
} elseif (! $anyIssues) {
|
||||
$this->info('No issues found (stream cache will be evicted on --force).');
|
||||
}
|
||||
|
||||
if ($dryRun && ($anyIssues || $cacheBytes > 0)) {
|
||||
$this->warn('Run with --force to repair.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($force) {
|
||||
$this->pruneAllLocalDirs();
|
||||
|
||||
$ttl = (int) $this->option('cache-ttl');
|
||||
$evicted = $nas->clearNasCache($ttl);
|
||||
if ($evicted > 0) {
|
||||
$this->line("Evicted <comment>{$evicted}</comment> NAS stream-cache file(s).");
|
||||
}
|
||||
}
|
||||
|
||||
if ($totalRepaired > 0 || $force) {
|
||||
$this->newLine();
|
||||
if ($totalFailed === 0) {
|
||||
$this->info("Repaired {$totalRepaired} item(s). All local directories cleaned up.");
|
||||
} else {
|
||||
$this->warn("Repaired: {$totalRepaired} Failed: {$totalFailed} — check logs for details.");
|
||||
}
|
||||
}
|
||||
|
||||
return $totalFailed > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
// ── Scanners ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function findStuckVideos(): \Illuminate\Support\Collection
|
||||
{
|
||||
return Video::with(['user', 'slides'])->get()
|
||||
->filter(fn (Video $v) => str_starts_with($v->path, 'users/'))
|
||||
->map(function (Video $video) {
|
||||
$files = [];
|
||||
if (file_exists(storage_path('app/' . $video->path)))
|
||||
$files[] = basename($video->path) . ' (video)';
|
||||
if ($video->thumbnail && str_contains($video->thumbnail, '/') &&
|
||||
file_exists(storage_path('app/' . $video->thumbnail)))
|
||||
$files[] = basename($video->thumbnail) . ' (thumbnail)';
|
||||
foreach ($video->slides as $slide) {
|
||||
if (file_exists($slide->localPath()))
|
||||
$files[] = basename($slide->filename) . " (slide #{$slide->position})";
|
||||
}
|
||||
return $files ? ['video' => $video, 'files' => $files] : null;
|
||||
})
|
||||
->filter();
|
||||
}
|
||||
|
||||
private function findStuckAvatars(): \Illuminate\Support\Collection
|
||||
{
|
||||
$results = collect();
|
||||
|
||||
// Legacy flat dir: public/avatars/
|
||||
$dir = storage_path('app/public/avatars');
|
||||
if (is_dir($dir)) {
|
||||
$flat = collect(scandir($dir) ?: [])
|
||||
->filter(fn ($f) => $f !== '.' && $f !== '..' && is_file("{$dir}/{$f}"))
|
||||
->map(function ($filename) use ($dir) {
|
||||
$user = User::where('avatar', $filename)->first();
|
||||
return $user ? ['user' => $user, 'file' => $filename, 'path' => "{$dir}/{$filename}"] : null;
|
||||
})
|
||||
->filter();
|
||||
$results = $results->merge($flat);
|
||||
}
|
||||
|
||||
// New structured dir: users/{slug}/profile/avatar.*
|
||||
$usersBase = storage_path('app/users');
|
||||
if (is_dir($usersBase)) {
|
||||
foreach (glob("{$usersBase}/*/profile/avatar.*") ?: [] as $path) {
|
||||
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $path)), '/');
|
||||
// relPath = "users/{slug}/profile/avatar.{ext}"
|
||||
$user = User::where('avatar', $relPath)->first();
|
||||
if ($user) {
|
||||
$results->push(['user' => $user, 'file' => basename($path), 'path' => $path]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function findStuckBanners(): \Illuminate\Support\Collection
|
||||
{
|
||||
$results = collect();
|
||||
|
||||
// Legacy flat dir: public/banners/
|
||||
$dir = storage_path('app/public/banners');
|
||||
if (is_dir($dir)) {
|
||||
$flat = collect(scandir($dir) ?: [])
|
||||
->filter(fn ($f) => $f !== '.' && $f !== '..' && is_file("{$dir}/{$f}"))
|
||||
->map(function ($filename) use ($dir) {
|
||||
$user = User::where('banner', $filename)->first();
|
||||
return $user ? ['user' => $user, 'file' => $filename, 'path' => "{$dir}/{$filename}"] : null;
|
||||
})
|
||||
->filter();
|
||||
$results = $results->merge($flat);
|
||||
}
|
||||
|
||||
// New structured dir: users/{slug}/profile/cover.*
|
||||
$usersBase = storage_path('app/users');
|
||||
if (is_dir($usersBase)) {
|
||||
foreach (glob("{$usersBase}/*/profile/cover.*") ?: [] as $path) {
|
||||
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $path)), '/');
|
||||
$user = User::where('banner', $relPath)->first();
|
||||
if ($user) {
|
||||
$results->push(['user' => $user, 'file' => basename($path), 'path' => $path]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function findStuckLegacyThumbnails(): \Illuminate\Support\Collection
|
||||
{
|
||||
$dir = storage_path('app/public/thumbnails');
|
||||
if (! is_dir($dir)) return collect();
|
||||
|
||||
$results = [];
|
||||
foreach (scandir($dir) ?: [] as $filename) {
|
||||
if ($filename === '.' || $filename === '..') continue;
|
||||
$path = "{$dir}/{$filename}";
|
||||
if (! is_file($path)) continue;
|
||||
|
||||
// Check if it's a video thumbnail
|
||||
$video = Video::where('thumbnail', $filename)->first();
|
||||
if ($video) {
|
||||
$results[] = ['type' => 'thumbnail', 'file' => $filename, 'path' => $path, 'video_id' => $video->id, 'video' => $video, 'slide' => null];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's an old-format slide
|
||||
$slide = VideoSlide::where('filename', $filename)->with('video')->first();
|
||||
if ($slide && $slide->video) {
|
||||
$results[] = ['type' => 'slide', 'file' => $filename, 'path' => $path, 'video_id' => $slide->video_id, 'video' => $slide->video, 'slide' => $slide];
|
||||
}
|
||||
}
|
||||
|
||||
return collect($results);
|
||||
}
|
||||
|
||||
// ── Directory pruning ─────────────────────────────────────────────────────
|
||||
|
||||
private function pruneAllLocalDirs(): void
|
||||
{
|
||||
// NAS-mirrored dirs
|
||||
$this->pruneEmptyDirTree(storage_path('app/users'));
|
||||
|
||||
// Flat asset dirs — remove if empty
|
||||
foreach (['public/avatars', 'public/banners', 'public/thumbnails'] as $rel) {
|
||||
$path = storage_path("app/{$rel}");
|
||||
if (is_dir($path) && $this->isDirEmpty($path)) {
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom-up prune: remove dirs that contain only meta.json or nothing.
|
||||
*/
|
||||
private function pruneEmptyDirTree(string $root): void
|
||||
{
|
||||
if (! is_dir($root)) return;
|
||||
|
||||
$iter = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($iter as $item) {
|
||||
if (! $item->isDir()) continue;
|
||||
$path = $item->getPathname();
|
||||
$contents = array_diff(scandir($path) ?: [], ['.', '..']);
|
||||
$nonMeta = array_diff($contents, ['meta.json']);
|
||||
if (empty($contents)) {
|
||||
@rmdir($path);
|
||||
} elseif (empty($nonMeta)) {
|
||||
@unlink("{$path}/meta.json");
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function isDirEmpty(string $dir): bool
|
||||
{
|
||||
return empty(array_diff(scandir($dir) ?: [], ['.', '..']));
|
||||
}
|
||||
|
||||
private function humanBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes >= 1_073_741_824) return round($bytes / 1_073_741_824, 2) . ' GB';
|
||||
if ($bytes >= 1_048_576) return round($bytes / 1_048_576, 2) . ' MB';
|
||||
if ($bytes >= 1_024) return round($bytes / 1_024, 2) . ' KB';
|
||||
return $bytes . ' B';
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,14 @@ class Kernel extends ConsoleKernel
|
||||
->cron("*/{$interval} * * * *")
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// Evict NAS stream-cache files older than 24 hours
|
||||
$schedule->call(function () {
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
if ($nas->isEnabled()) {
|
||||
$nas->clearNasCache(24);
|
||||
}
|
||||
})->daily()->name('nas-cache-evict')->withoutOverlapping();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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;
|
||||
|
||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Throwable;
|
||||
|
||||
class Handler extends ExceptionHandler
|
||||
{
|
||||
/**
|
||||
* The list of the inputs that are never flashed to the session on validation exceptions.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $dontFlash = [
|
||||
'current_password',
|
||||
'password',
|
||||
'password_confirmation',
|
||||
];
|
||||
|
||||
/**
|
||||
* Register the exception handling callbacks for the application.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->reportable(function (Throwable $e) {
|
||||
//
|
||||
});
|
||||
|
||||
// Redirect to home with a toast when a video URL can't be resolved
|
||||
$this->renderable(function (NotFoundHttpException $e, $request) {
|
||||
$prev = $e->getPrevious();
|
||||
$isVideoRoute = str_starts_with($request->path(), 'videos/');
|
||||
|
||||
if ($isVideoRoute && ($prev instanceof ModelNotFoundException || $e->getMessage() === '')) {
|
||||
return redirect('/')->with('toast_error', 'This video is no longer available.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
101
app/Helpers/Horoscope.php
Normal file
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;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
@ -23,10 +24,32 @@ class AuthenticatedSessionController extends Controller
|
||||
$remember = $request->filled('remember');
|
||||
|
||||
if (Auth::attempt($credentials, $remember)) {
|
||||
$user = Auth::user();
|
||||
|
||||
if ($user->two_factor_enabled && $user->two_factor_secret) {
|
||||
Auth::logout();
|
||||
$request->session()->put('2fa_user_id', $user->id);
|
||||
$request->session()->put('2fa_remember', $remember);
|
||||
return redirect()->route('2fa.challenge');
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
AuditLog::record('user.login', [
|
||||
'user_id' => $user->id,
|
||||
'user_name' => $user->name,
|
||||
'details' => ['email' => $user->email],
|
||||
]);
|
||||
|
||||
return redirect()->intended('/videos');
|
||||
}
|
||||
|
||||
AuditLog::record('user.login.failed', [
|
||||
'user_id' => null,
|
||||
'user_name' => null,
|
||||
'details' => ['email' => $credentials['email']],
|
||||
]);
|
||||
|
||||
return back()->withErrors([
|
||||
'email' => 'The provided credentials do not match our records.',
|
||||
]);
|
||||
@ -34,6 +57,13 @@ class AuthenticatedSessionController extends Controller
|
||||
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if ($user) {
|
||||
AuditLog::record('user.logout', [
|
||||
'user_id' => $user->id,
|
||||
'user_name' => $user->name,
|
||||
]);
|
||||
}
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Rules\NotDisposableEmail;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@ -18,22 +19,34 @@ class RegisteredUserController extends Controller
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
// Honeypot — bots fill hidden fields; real users never do
|
||||
if ($request->filled('_hp')) {
|
||||
// Silently appear to succeed to confuse the bot
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new NotDisposableEmail],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'birthday' => ['required', 'date', 'before:today'],
|
||||
'gender' => ['required', 'in:male,female'],
|
||||
'nationality' => ['required', 'string', 'size:2'],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
'birthday' => $request->birthday,
|
||||
'gender' => $request->gender,
|
||||
'nationality' => $request->nationality,
|
||||
]);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
auth()->login($user);
|
||||
|
||||
return redirect('/videos');
|
||||
return redirect()->route('verification.notice');
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,11 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Comment;
|
||||
use App\Models\CommentLike;
|
||||
use App\Models\Video;
|
||||
use App\Notifications\NewCommentLikeNotification;
|
||||
use App\Notifications\NewCommentNotification;
|
||||
use App\Notifications\NewReplyNotification;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
@ -24,28 +28,37 @@ class CommentController extends Controller
|
||||
public function store(Request $request, Video $video)
|
||||
{
|
||||
$request->validate([
|
||||
'body' => 'required|string|max:1000',
|
||||
'body' => 'required|string|max:1000',
|
||||
'parent_id' => 'nullable|exists:comments,id',
|
||||
]);
|
||||
|
||||
$commenter = Auth::user();
|
||||
|
||||
$comment = $video->comments()->create([
|
||||
'user_id' => Auth::id(),
|
||||
'body' => $request->body,
|
||||
'user_id' => $commenter->id,
|
||||
'body' => $request->body,
|
||||
'parent_id' => $request->parent_id,
|
||||
]);
|
||||
// $video->increment('comment_count'); // Disabled - was causing SQL error
|
||||
$comment->load('user:id,name,avatar_url');
|
||||
|
||||
// Handle mentions
|
||||
preg_match_all('/@(\w+)/', $request->body, $matches);
|
||||
if (! empty($matches[1])) {
|
||||
// Mentions found - in production, you would send notifications here
|
||||
// For now, we just parse them
|
||||
$comment->load('user:id,name,avatar');
|
||||
|
||||
// Fire notifications (never notify yourself)
|
||||
if ($request->parent_id) {
|
||||
// Reply — notify the parent comment's author
|
||||
$parent = Comment::find($request->parent_id);
|
||||
if ($parent && $parent->user_id !== $commenter->id) {
|
||||
$parent->user->notify(new NewReplyNotification($video, $comment, $commenter));
|
||||
}
|
||||
} else {
|
||||
// Top-level comment — notify the video owner
|
||||
if ($video->user_id !== $commenter->id) {
|
||||
$video->user->notify(new NewCommentNotification($video, $comment, $commenter));
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'comment' => $comment->load('user:id,name,avatar_url'),
|
||||
'comment' => $comment->load('user:id,name,avatar'),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -59,13 +72,10 @@ class CommentController extends Controller
|
||||
'body' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
$comment->update([
|
||||
'body' => $request->body,
|
||||
]);
|
||||
$comment->update(['body' => $request->body]);
|
||||
$comment->load('user:id,name,avatar');
|
||||
|
||||
$comment->load('user:id,name,avatar_url');
|
||||
|
||||
return response()->json($comment->load('user:id,name,avatar_url'));
|
||||
return response()->json($comment->load('user:id,name,avatar'));
|
||||
}
|
||||
|
||||
public function destroy(Comment $comment)
|
||||
@ -78,4 +88,34 @@ class CommentController extends Controller
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function like(Comment $comment)
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$existing = CommentLike::where('comment_id', $comment->id)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
$liked = false;
|
||||
} else {
|
||||
CommentLike::create(['comment_id' => $comment->id, 'user_id' => $userId]);
|
||||
$liked = true;
|
||||
|
||||
// Notify the comment author (not self-likes)
|
||||
if ($comment->user_id !== $userId) {
|
||||
$video = $comment->video;
|
||||
if ($video) {
|
||||
$comment->user->notify(
|
||||
new NewCommentLikeNotification($video, $comment, Auth::user())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$count = CommentLike::where('comment_id', $comment->id)->count();
|
||||
|
||||
return response()->json(['liked' => $liked, 'count' => $count]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,4 +9,29 @@ use Illuminate\Routing\Controller as BaseController;
|
||||
class Controller extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
/**
|
||||
* Generate a collision-safe filename using the authenticated user's name,
|
||||
* a compact timestamp with millisecond precision, and the original extension.
|
||||
*
|
||||
* Format: {username}_{YYYYMMDD}_{HHmmss}_{ms}.{ext}
|
||||
* Example: ghassanyusuf_20260301_120102_020.mp4
|
||||
*/
|
||||
protected static function generateFilename(string $extension, ?string $username = null): string
|
||||
{
|
||||
$user = $username ?? (auth()->user()?->name ?? 'user');
|
||||
|
||||
// Sanitize: lowercase, keep only alphanumeric, collapse to max 20 chars
|
||||
$slug = preg_replace('/[^a-z0-9]/', '', strtolower($user));
|
||||
$slug = substr($slug ?: 'user', 0, 20);
|
||||
|
||||
$now = now();
|
||||
$ms = str_pad((int) ($now->microsecond / 1000), 3, '0', STR_PAD_LEFT);
|
||||
|
||||
$timestamp = $now->format('Ymd') . '_' . $now->format('His') . '_' . $ms;
|
||||
|
||||
$ext = ltrim(strtolower($extension), '.');
|
||||
|
||||
return "{$slug}_{$timestamp}.{$ext}";
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
211
app/Http/Controllers/MediaController.php
Normal file
211
app/Http/Controllers/MediaController.php
Normal file
@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Playlist;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use App\Models\VideoSlide;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
/**
|
||||
* Serves image assets (thumbnails, slides, avatars, banners) with a transparent
|
||||
* NAS fallback: if the file is missing locally it is fetched from the NAS and
|
||||
* cached in the standard public storage directory before being served.
|
||||
*
|
||||
* All routes are public (no auth) so images render in emails and for guests.
|
||||
*/
|
||||
class MediaController extends Controller
|
||||
{
|
||||
public function thumbnail(string $filename, NasSyncService $nas): Response
|
||||
{
|
||||
// New NAS-mirrored path format: "users/{username}/videos/{slug}/…"
|
||||
if (str_starts_with($filename, 'users/')) {
|
||||
$local = storage_path('app/' . $filename);
|
||||
|
||||
if (! file_exists($local)) {
|
||||
@mkdir(dirname($local), 0755, true);
|
||||
|
||||
// The thumbnail field stores the local relative path.
|
||||
// On NAS, thumbnails are always "thumb.webp"; try that first, then match extension.
|
||||
$video = Video::where('thumbnail', $filename)->first();
|
||||
if ($video) {
|
||||
$dir = $nas->resolveVideoDir($video);
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'jpg';
|
||||
foreach (["thumb.webp", "thumb.{$ext}"] as $nasFile) {
|
||||
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Might be a slide
|
||||
if (! file_exists($local)) {
|
||||
$slide = VideoSlide::where('filename', $filename)->with('video')->first();
|
||||
if ($slide && $slide->video) {
|
||||
$dir = $nas->resolveVideoDir($slide->video);
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$nas->ensureLocalAsset($local, "{$dir}/slides/{$slide->position}.{$ext}");
|
||||
}
|
||||
}
|
||||
|
||||
// Might be a playlist thumbnail
|
||||
if (! file_exists($local)) {
|
||||
$nas->ensureLocalAsset($local, $filename);
|
||||
}
|
||||
|
||||
if (! file_exists($local)) abort(404);
|
||||
}
|
||||
|
||||
return $this->fileResponse($local);
|
||||
}
|
||||
|
||||
// Legacy flat filename format
|
||||
$local = storage_path('app/public/thumbnails/' . $filename);
|
||||
|
||||
if (! file_exists($local)) {
|
||||
// Find the video that owns this thumbnail and pull from NAS
|
||||
$video = Video::where('thumbnail', $filename)->first();
|
||||
|
||||
if ($video) {
|
||||
$dir = $nas->resolveVideoDir($video);
|
||||
$nasPath = "{$dir}/thumb.webp";
|
||||
$nas->ensureLocalAsset($local, $nasPath);
|
||||
}
|
||||
|
||||
// Not found via video — might be a slide
|
||||
if (! file_exists($local)) {
|
||||
$slide = VideoSlide::where('filename', $filename)->with('video')->first();
|
||||
if ($slide && $slide->video) {
|
||||
$dir = $nas->resolveVideoDir($slide->video);
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$nasPath = "{$dir}/slides/{$slide->position}.{$ext}";
|
||||
$nas->ensureLocalAsset($local, $nasPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (! file_exists($local)) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->fileResponse($local);
|
||||
}
|
||||
|
||||
public function avatar(string $filename, NasSyncService $nas): Response
|
||||
{
|
||||
// New format: "users/{slug}/profile/avatar.{ext}"
|
||||
if (str_starts_with($filename, 'users/')) {
|
||||
$local = storage_path('app/' . $filename);
|
||||
|
||||
if (! file_exists($local)) {
|
||||
@mkdir(dirname($local), 0755, true);
|
||||
$user = User::where('avatar', $filename)->first();
|
||||
if ($user) {
|
||||
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
|
||||
foreach (["avatar.webp", "avatar.{$ext}"] as $nasFile) {
|
||||
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) break;
|
||||
}
|
||||
}
|
||||
if (! file_exists($local)) abort(404);
|
||||
}
|
||||
|
||||
return $this->fileResponse($local);
|
||||
}
|
||||
|
||||
// Legacy flat format
|
||||
$local = storage_path('app/public/avatars/' . $filename);
|
||||
|
||||
if (! file_exists($local)) {
|
||||
$user = User::where('avatar', $filename)->first();
|
||||
if ($user) {
|
||||
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||
$nasPath = "{$dir}/avatar.webp";
|
||||
$nas->ensureLocalAsset($local, $nasPath);
|
||||
}
|
||||
if (! file_exists($local)) abort(404);
|
||||
}
|
||||
|
||||
return $this->fileResponse($local);
|
||||
}
|
||||
|
||||
public function banner(string $filename, NasSyncService $nas): Response
|
||||
{
|
||||
// New format: "users/{slug}/profile/cover.{ext}"
|
||||
if (str_starts_with($filename, 'users/')) {
|
||||
$local = storage_path('app/' . $filename);
|
||||
|
||||
if (! file_exists($local)) {
|
||||
@mkdir(dirname($local), 0755, true);
|
||||
$user = User::where('banner', $filename)->first();
|
||||
if ($user) {
|
||||
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
|
||||
foreach (["cover.webp", "cover.{$ext}"] as $nasFile) {
|
||||
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) break;
|
||||
}
|
||||
}
|
||||
if (! file_exists($local)) abort(404);
|
||||
}
|
||||
|
||||
return $this->fileResponse($local);
|
||||
}
|
||||
|
||||
// Legacy flat format
|
||||
$local = storage_path('app/public/banners/' . $filename);
|
||||
|
||||
if (! file_exists($local)) {
|
||||
$user = User::where('banner', $filename)->first();
|
||||
if ($user) {
|
||||
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||
$nasPath = "{$dir}/cover.webp";
|
||||
$nas->ensureLocalAsset($local, $nasPath);
|
||||
}
|
||||
if (! file_exists($local)) abort(404);
|
||||
}
|
||||
|
||||
return $this->fileResponse($local);
|
||||
}
|
||||
|
||||
public function postImage(string $filename, NasSyncService $nas): Response
|
||||
{
|
||||
// New format: "users/{slug}/posts/{id}/{seq}.{ext}"
|
||||
if (str_starts_with($filename, 'users/')) {
|
||||
$local = storage_path('app/' . $filename);
|
||||
|
||||
if (! file_exists($local)) {
|
||||
@mkdir(dirname($local), 0755, true);
|
||||
// NAS path is identical to the relative path stored in DB
|
||||
$nas->ensureLocalAsset($local, $filename);
|
||||
if (! file_exists($local)) abort(404);
|
||||
}
|
||||
|
||||
return $this->fileResponse($local);
|
||||
}
|
||||
|
||||
// Legacy flat format
|
||||
$local = storage_path('app/public/post_images/' . $filename);
|
||||
if (! file_exists($local)) abort(404);
|
||||
|
||||
return $this->fileResponse($local);
|
||||
}
|
||||
|
||||
// ── Helper ────────────────────────────────────────────────────────────────
|
||||
|
||||
private function fileResponse(string $path): Response
|
||||
{
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
$mime = match ($ext) {
|
||||
'webp' => 'image/webp',
|
||||
'png' => 'image/png',
|
||||
'gif' => 'image/gif',
|
||||
default => 'image/jpeg',
|
||||
};
|
||||
|
||||
return response(file_get_contents($path), 200, [
|
||||
'Content-Type' => $mime,
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
'Last-Modified' => gmdate('D, d M Y H:i:s', filemtime($path)) . ' GMT',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -4,14 +4,17 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Playlist;
|
||||
use App\Models\Video;
|
||||
use App\Services\GeoIpService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PlaylistController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth')->except(['index', 'show', 'publicPlaylists', 'userPlaylists']);
|
||||
$this->middleware('auth')->except(['index', 'show', 'showByToken', 'publicPlaylists', 'userPlaylists', 'recordShare', 'accessShare']);
|
||||
}
|
||||
|
||||
// List user's playlists
|
||||
@ -31,11 +34,100 @@ class PlaylistController extends Controller
|
||||
abort(404, 'Playlist not found');
|
||||
}
|
||||
|
||||
$videos = $playlist->videos()->orderBy('position')->paginate(20);
|
||||
$playlist->loadMissing('user');
|
||||
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
|
||||
|
||||
return view('playlists.show', compact('playlist', 'videos'));
|
||||
}
|
||||
|
||||
// View playlist via unguessable share token (unlisted playlists)
|
||||
public function showByToken(string $token)
|
||||
{
|
||||
$playlist = Playlist::where('share_token', $token)->firstOrFail();
|
||||
|
||||
if (! $playlist->canViewViaToken(Auth::user())) {
|
||||
abort(404, 'Playlist not found');
|
||||
}
|
||||
|
||||
$playlist->loadMissing('user');
|
||||
$videos = $playlist->videos()->with('user')->orderBy('position')->get();
|
||||
|
||||
return view('playlists.show', compact('playlist', 'videos'));
|
||||
}
|
||||
|
||||
// Generate (or reuse) a per-user share tracking token and return the tracking URL
|
||||
public function recordShare(Playlist $playlist)
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
||||
if ($userId) {
|
||||
$existing = DB::table('playlist_shares')
|
||||
->where('playlist_id', $playlist->id)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
if ($existing) {
|
||||
return response()->json(['url' => route('playlists.accessShare', $existing->token)]);
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
$token = Str::random(10);
|
||||
} while (DB::table('playlist_shares')->where('token', $token)->exists());
|
||||
|
||||
DB::table('playlist_shares')->insert([
|
||||
'playlist_id' => $playlist->id,
|
||||
'user_id' => $userId,
|
||||
'token' => $token,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json(['url' => route('playlists.accessShare', $token)]);
|
||||
}
|
||||
|
||||
// Handle a share link click: record the access, then redirect to the playlist
|
||||
public function accessShare(Request $request, string $token)
|
||||
{
|
||||
$share = DB::table('playlist_shares')->where('token', $token)->first();
|
||||
if (! $share) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
$playlist = Playlist::find($share->playlist_id);
|
||||
if (! $playlist || ! $playlist->canViewViaToken(Auth::user())) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
$did = $request->cookie('_did') ?: (string) Str::uuid();
|
||||
$seen = DB::table('playlist_share_accesses')
|
||||
->where('share_id', $share->id)
|
||||
->where('device_id', $did)
|
||||
->exists();
|
||||
|
||||
if (! $seen) {
|
||||
$ip = $request->header('CF-Connecting-IP')
|
||||
?? $request->header('X-Real-IP')
|
||||
?? $request->ip();
|
||||
$geo = GeoIpService::lookup($ip);
|
||||
|
||||
DB::table('playlist_share_accesses')->insert([
|
||||
'share_id' => $share->id,
|
||||
'device_id' => $did,
|
||||
'ip_address' => $ip,
|
||||
'country' => $geo['country'] ?? null,
|
||||
'country_name' => $geo['country_name'] ?? null,
|
||||
'accessed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$firstVideo = $playlist->videos()->orderBy('position')->first();
|
||||
$destination = $firstVideo
|
||||
? route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token
|
||||
: route('playlists.showByToken', $playlist->share_token);
|
||||
|
||||
return redirect($destination)
|
||||
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
|
||||
}
|
||||
|
||||
// Create new playlist form
|
||||
public function create()
|
||||
{
|
||||
@ -48,8 +140,8 @@ class PlaylistController extends Controller
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'visibility' => 'nullable|in:public,private',
|
||||
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:5120',
|
||||
'visibility' => 'nullable|in:public,private,unlisted',
|
||||
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:20480',
|
||||
]);
|
||||
|
||||
$playlistData = [
|
||||
@ -58,6 +150,7 @@ class PlaylistController extends Controller
|
||||
'description' => $request->description,
|
||||
'visibility' => $request->visibility ?? 'private',
|
||||
'is_default' => false,
|
||||
'share_token' => Str::random(32),
|
||||
];
|
||||
|
||||
// Create playlist first to get ID for thumbnail naming
|
||||
@ -66,9 +159,8 @@ class PlaylistController extends Controller
|
||||
// Handle thumbnail upload
|
||||
if ($request->hasFile('thumbnail')) {
|
||||
$file = $request->file('thumbnail');
|
||||
$filename = 'playlist_'.$playlist->id.'_'.time().'.'.$file->getClientOriginalExtension();
|
||||
$file->storeAs('public/thumbnails', $filename);
|
||||
$playlist->update(['thumbnail' => $filename]);
|
||||
$nasPath = self::pushPlaylistThumbnailToNas($file, $playlist);
|
||||
$playlist->update(['thumbnail' => $nasPath]);
|
||||
}
|
||||
|
||||
// Reload playlist with thumbnail
|
||||
@ -123,8 +215,8 @@ class PlaylistController extends Controller
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'visibility' => 'nullable|in:public,private',
|
||||
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:5120',
|
||||
'visibility' => 'nullable|in:public,private,unlisted',
|
||||
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:20480',
|
||||
]);
|
||||
|
||||
$updateData = [
|
||||
@ -135,28 +227,19 @@ class PlaylistController extends Controller
|
||||
|
||||
// Handle thumbnail upload
|
||||
if ($request->hasFile('thumbnail')) {
|
||||
// Delete old thumbnail if exists
|
||||
// Delete old thumbnail from NAS if exists
|
||||
if ($playlist->thumbnail) {
|
||||
$oldPath = storage_path('app/public/thumbnails/'.$playlist->thumbnail);
|
||||
if (file_exists($oldPath)) {
|
||||
unlink($oldPath);
|
||||
}
|
||||
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
|
||||
}
|
||||
|
||||
// Upload new thumbnail
|
||||
$file = $request->file('thumbnail');
|
||||
$filename = 'playlist_'.$playlist->id.'_'.time().'.'.$file->getClientOriginalExtension();
|
||||
$file->storeAs('public/thumbnails', $filename);
|
||||
$updateData['thumbnail'] = $filename;
|
||||
$updateData['thumbnail'] = self::pushPlaylistThumbnailToNas($file, $playlist);
|
||||
}
|
||||
|
||||
// Handle thumbnail removal
|
||||
if ($request->input('remove_thumbnail') == '1') {
|
||||
if ($playlist->thumbnail) {
|
||||
$oldPath = storage_path('app/public/thumbnails/'.$playlist->thumbnail);
|
||||
if (file_exists($oldPath)) {
|
||||
unlink($oldPath);
|
||||
}
|
||||
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
|
||||
$updateData['thumbnail'] = null;
|
||||
}
|
||||
}
|
||||
@ -351,7 +434,7 @@ class PlaylistController extends Controller
|
||||
// Play all videos in playlist (redirect to first video with playlist context)
|
||||
public function playAll(Playlist $playlist)
|
||||
{
|
||||
if (! $playlist->canView(Auth::user())) {
|
||||
if (! $playlist->canViewViaToken(Auth::user())) {
|
||||
abort(404, 'Playlist not found');
|
||||
}
|
||||
|
||||
@ -361,17 +444,13 @@ class PlaylistController extends Controller
|
||||
return back()->with('error', 'Playlist is empty.');
|
||||
}
|
||||
|
||||
// Redirect to first video with playlist parameter
|
||||
return redirect()->route('videos.show', [
|
||||
'video' => $firstVideo->id,
|
||||
'playlist' => $playlist->id,
|
||||
]);
|
||||
return redirect(route('videos.show', $firstVideo) . '?playlist=' . $playlist->share_token);
|
||||
}
|
||||
|
||||
// Shuffle play - redirect to random video
|
||||
public function shuffle(Playlist $playlist)
|
||||
{
|
||||
if (! $playlist->canView(Auth::user())) {
|
||||
if (! $playlist->canViewViaToken(Auth::user())) {
|
||||
abort(404, 'Playlist not found');
|
||||
}
|
||||
|
||||
@ -381,9 +460,39 @@ class PlaylistController extends Controller
|
||||
return back()->with('error', 'Playlist is empty.');
|
||||
}
|
||||
|
||||
return redirect()->route('videos.show', [
|
||||
'video' => $randomVideo->id,
|
||||
'playlist' => $playlist->id,
|
||||
]);
|
||||
return redirect(route('videos.show', $randomVideo) . '?playlist=' . $playlist->share_token);
|
||||
}
|
||||
|
||||
// ── NAS thumbnail helpers ─────────────────────────────────────────────────
|
||||
|
||||
private static function nasPlaylistThumbPath(Playlist $playlist, string $ext): string
|
||||
{
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
$playlist->loadMissing('user');
|
||||
$userSlug = $nas->userSlug($playlist->user);
|
||||
return "users/{$userSlug}/playlists/{$playlist->id}/thumb.{$ext}";
|
||||
}
|
||||
|
||||
private static function pushPlaylistThumbnailToNas(\Illuminate\Http\UploadedFile $file, Playlist $playlist): string
|
||||
{
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$tmpName = self::generateFilename($ext);
|
||||
$file->storeAs('public/thumbnails', $tmpName);
|
||||
$tempAbs = storage_path('app/public/thumbnails/' . $tmpName);
|
||||
$nasPath = self::nasPlaylistThumbPath($playlist, $ext);
|
||||
$dir = dirname($nasPath);
|
||||
$nas->mkdirp($dir);
|
||||
$nas->putFile($tempAbs, $nasPath);
|
||||
@unlink($tempAbs);
|
||||
return $nasPath;
|
||||
}
|
||||
|
||||
private static function deletePlaylistThumbnailFromNas(?string $nasPath): void
|
||||
{
|
||||
if (! $nasPath || ! str_starts_with($nasPath, 'users/')) return;
|
||||
try {
|
||||
app(\App\Services\NasSyncService::class)->deleteFile($nasPath);
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
}
|
||||
|
||||
162
app/Http/Controllers/PostController.php
Normal file
162
app/Http/Controllers/PostController.php
Normal file
@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\PostImage;
|
||||
use App\Models\PostVideo;
|
||||
use App\Models\User;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
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',
|
||||
'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.']);
|
||||
}
|
||||
|
||||
// Create post first — we need the ID as the folder name
|
||||
$post = Post::create([
|
||||
'user_id' => $user->id,
|
||||
'body' => $request->body,
|
||||
'video_id' => $request->video_id ?? null,
|
||||
]);
|
||||
|
||||
$nas = app(NasSyncService::class);
|
||||
$nasMode = $nas->isEnabled();
|
||||
$postDir = $nas->resolvePostDir($post); // "users/{slug}/posts/{id}"
|
||||
|
||||
if ($hasImages || $hasLegacyImg) {
|
||||
if ($nasMode) {
|
||||
// ── NAS primary: upload directly from PHP temp files ──────────
|
||||
$nas->mkdirp($postDir);
|
||||
|
||||
if ($hasLegacyImg) {
|
||||
$file = $request->file('image');
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$nasPath = "{$postDir}/0.{$ext}";
|
||||
$nas->putFile($file->getRealPath(), $nasPath);
|
||||
$post->update(['image' => $nasPath]);
|
||||
}
|
||||
|
||||
if ($hasImages) {
|
||||
foreach ($request->file('images') as $idx => $file) {
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$nasPath = "{$postDir}/" . ($idx + 1) . ".{$ext}";
|
||||
$nas->putFile($file->getRealPath(), $nasPath);
|
||||
PostImage::create([
|
||||
'post_id' => $post->id,
|
||||
'filename' => $nasPath,
|
||||
'sort_order' => $idx,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Local storage: save inside the user's posts directory ─────
|
||||
$localDir = storage_path('app/' . $postDir);
|
||||
@mkdir($localDir, 0755, true);
|
||||
|
||||
if ($hasLegacyImg) {
|
||||
$ext = $request->file('image')->getClientOriginalExtension() ?: 'jpg';
|
||||
$filename = "0.{$ext}";
|
||||
$request->file('image')->move($localDir, $filename);
|
||||
$post->update(['image' => "{$postDir}/{$filename}"]);
|
||||
}
|
||||
|
||||
if ($hasImages) {
|
||||
foreach ($request->file('images') as $idx => $file) {
|
||||
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||
$filename = ($idx + 1) . ".{$ext}";
|
||||
$file->move($localDir, $filename);
|
||||
PostImage::create([
|
||||
'post_id' => $post->id,
|
||||
'filename' => "{$postDir}/{$filename}",
|
||||
'sort_order' => $idx,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
$post->loadMissing('postImages');
|
||||
$nas = app(NasSyncService::class);
|
||||
|
||||
if ($nas->isEnabled()) {
|
||||
try {
|
||||
$nas->deleteNasPost($post);
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
|
||||
// Always clean up local copies (handles both legacy flat and new structured format)
|
||||
$nas->deleteLocalPostImages($post);
|
||||
|
||||
$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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
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;
|
||||
|
||||
use App\Helpers\Horoscope;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
@ -17,7 +19,7 @@ class UserController extends Controller
|
||||
$this->middleware('auth')->except(['channel']);
|
||||
}
|
||||
|
||||
// Profile page - view own profile
|
||||
// Profile page - personal overview for the authenticated user
|
||||
public function profile()
|
||||
{
|
||||
$user = Auth::user();
|
||||
@ -28,58 +30,102 @@ class UserController extends Controller
|
||||
// Update profile
|
||||
public function updateProfile(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$authUser = Auth::user();
|
||||
|
||||
// Super admins may edit any user's profile by passing _edit_user_id
|
||||
if ($authUser->isSuperAdmin() && $request->filled('_edit_user_id')) {
|
||||
$user = User::findOrFail($request->input('_edit_user_id'));
|
||||
} else {
|
||||
$user = $authUser;
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:5120',
|
||||
'bio' => 'nullable|string|max:500',
|
||||
'website' => 'nullable|string|max:255',
|
||||
'twitter' => 'nullable|string|max:100',
|
||||
'instagram' => 'nullable|string|max:100',
|
||||
'facebook' => 'nullable|string|max:100',
|
||||
'youtube' => 'nullable|string|max:100',
|
||||
'linkedin' => 'nullable|string|max:100',
|
||||
'tiktok' => 'nullable|string|max:100',
|
||||
'birthday' => 'nullable|date',
|
||||
'location' => 'nullable|string|max:100',
|
||||
'name' => 'required|string|max:255',
|
||||
'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:5120',
|
||||
'bio' => 'nullable|string|max:500',
|
||||
'birthday' => 'nullable|date',
|
||||
'location' => 'nullable|string|max:100',
|
||||
'gender' => 'nullable|in:male,female,prefer_not_to_say',
|
||||
'nationality' => 'nullable|string|size:2',
|
||||
'phone_code' => 'nullable|string|max:20',
|
||||
'phone_number' => 'nullable|string|max:30',
|
||||
'timezone' => 'nullable|timezone:all',
|
||||
'slink' => 'nullable|array',
|
||||
'slink.*.platform' => 'required_with:slink|string|max:30',
|
||||
'slink.*.value' => 'required_with:slink|string|max:500',
|
||||
'slink.*.visibility' => 'nullable|in:public,registered,subscribers,only_me',
|
||||
]);
|
||||
|
||||
$data = [
|
||||
'name' => $request->name,
|
||||
'bio' => $request->bio,
|
||||
'website' => $request->website,
|
||||
'twitter' => $request->twitter,
|
||||
'instagram' => $request->instagram,
|
||||
'facebook' => $request->facebook,
|
||||
'youtube' => $request->youtube,
|
||||
'linkedin' => $request->linkedin,
|
||||
'tiktok' => $request->tiktok,
|
||||
'birthday' => $request->birthday,
|
||||
'location' => $request->location,
|
||||
'name' => $request->name,
|
||||
'bio' => $request->bio,
|
||||
'birthday' => $request->birthday,
|
||||
'location' => $request->location,
|
||||
'gender' => $request->gender ?: null,
|
||||
'nationality' => $request->nationality ?: null,
|
||||
'phone_code' => $request->phone_code ?: null,
|
||||
'phone_number' => $request->phone_number ?: null,
|
||||
'timezone' => $request->timezone ?: null,
|
||||
];
|
||||
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
|
||||
if ($request->hasFile('avatar')) {
|
||||
// Delete old avatar
|
||||
if ($user->avatar) {
|
||||
Storage::delete('public/avatars/'.$user->avatar);
|
||||
}
|
||||
$filename = Str::uuid().'.'.$request->file('avatar')->getClientOriginalExtension();
|
||||
$request->file('avatar')->storeAs('public/avatars', $filename);
|
||||
$data['avatar'] = $filename;
|
||||
// Delete old avatar (handles both flat and new relative-path formats)
|
||||
$nas->deleteLocalAvatar($user);
|
||||
|
||||
$ext = $request->file('avatar')->getClientOriginalExtension() ?: 'webp';
|
||||
$profileDir = $nas->localProfileDir($user);
|
||||
$destFilename = "avatar.{$ext}";
|
||||
$destPath = "{$profileDir}/{$destFilename}";
|
||||
$relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}";
|
||||
|
||||
@mkdir($profileDir, 0755, true);
|
||||
$request->file('avatar')->move($profileDir, $destFilename);
|
||||
$data['avatar'] = $relPath;
|
||||
}
|
||||
|
||||
$user->update($data);
|
||||
|
||||
return redirect()->route('profile')->with('success', 'Profile updated successfully!');
|
||||
// Push avatar to NAS and remove local copy when NAS is primary storage
|
||||
if ($nas->isEnabled()) {
|
||||
if ($request->hasFile('avatar')) {
|
||||
$destPath = storage_path('app/' . $data['avatar']);
|
||||
if (file_exists($destPath)) {
|
||||
$nas->syncAvatar($user, $destPath);
|
||||
$nas->deleteLocalAvatar($user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
return view('user.settings', compact('user'));
|
||||
return redirect()->route('channel')->with('_open_tab', 'settings');
|
||||
}
|
||||
|
||||
// Update settings (password)
|
||||
@ -100,36 +146,121 @@ class UserController extends Controller
|
||||
'password' => Hash::make($request->new_password),
|
||||
]);
|
||||
|
||||
return redirect()->route('settings')->with('success', 'Password updated successfully!');
|
||||
AuditLog::record('user.password_changed');
|
||||
|
||||
return redirect()->route('channel')->with('toast_success', 'Password updated!')->with('_open_tab', 'settings');
|
||||
}
|
||||
|
||||
// Logout all other devices
|
||||
public function logoutAllDevices(Request $request)
|
||||
{
|
||||
$request->validate(['password' => 'required']);
|
||||
|
||||
if (! Hash::check($request->password, Auth::user()->password)) {
|
||||
return back()->withErrors(['logout_password' => 'Incorrect password.'])->with('_open_tab', 'settings');
|
||||
}
|
||||
|
||||
Auth::logoutOtherDevices($request->password);
|
||||
|
||||
// AuthenticateSession stores a hash of the password; logoutOtherDevices rehashes it,
|
||||
// so we must update the session's copy or the current session gets invalidated too.
|
||||
$request->session()->put(
|
||||
'password_hash_' . Auth::getDefaultDriver(),
|
||||
Auth::user()->getAuthPassword()
|
||||
);
|
||||
|
||||
AuditLog::record('user.logout_all');
|
||||
|
||||
return redirect()->route('channel')->with('toast_success', 'All other sessions have been logged out.')->with('_open_tab', 'settings');
|
||||
}
|
||||
|
||||
// User's channel page - view videos
|
||||
public function channel($userId = null)
|
||||
public function channel($username = null)
|
||||
{
|
||||
if ($userId) {
|
||||
$user = User::findOrFail($userId);
|
||||
if ($username) {
|
||||
// Look up by username slug only — never by sequential ID
|
||||
$user = User::where('username', $username)->firstOrFail();
|
||||
} else {
|
||||
$user = Auth::user();
|
||||
if (! $user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
$user->channel; // triggers auto-generation if missing
|
||||
}
|
||||
|
||||
// If viewing own channel, show all videos including private
|
||||
// If viewing someone else's channel, show only public videos
|
||||
if (Auth::check() && Auth::user()->id === $user->id) {
|
||||
$videos = Video::where('user_id', $user->id)
|
||||
->latest()
|
||||
->paginate(12);
|
||||
$sort = request('sort', 'latest');
|
||||
$isOwner = Auth::check() && Auth::user()->id === $user->id;
|
||||
$preview = $isOwner && request()->boolean('preview'); // owner viewing as visitor
|
||||
$isOwner = $isOwner && !$preview;
|
||||
|
||||
// Also get user's playlists for their own channel
|
||||
$playlists = $user->playlists()->orderBy('created_at', 'desc')->get();
|
||||
} else {
|
||||
$videos = Video::public()
|
||||
->where('user_id', $user->id)
|
||||
->latest()
|
||||
->paginate(12);
|
||||
$playlists = null;
|
||||
$baseQuery = $isOwner
|
||||
? Video::where('user_id', $user->id)
|
||||
: Video::public()->where('user_id', $user->id);
|
||||
|
||||
$allQuery = clone $baseQuery;
|
||||
|
||||
switch ($sort) {
|
||||
case 'popular':
|
||||
$baseQuery->withCount('viewers')->orderByDesc('viewers_count');
|
||||
break;
|
||||
case 'oldest':
|
||||
$baseQuery->oldest();
|
||||
break;
|
||||
default:
|
||||
$baseQuery->latest();
|
||||
}
|
||||
|
||||
return view('user.channel', compact('user', 'videos', 'playlists'));
|
||||
$videos = $baseQuery->where('is_shorts', false)->get();
|
||||
$shorts = (clone $allQuery)->where('is_shorts', true)->latest()->get();
|
||||
$playlists = $isOwner
|
||||
? $user->playlists()->orderBy('created_at', 'desc')->get()
|
||||
: $user->playlists()->public()->where('is_default', false)->orderBy('created_at', 'desc')->get();
|
||||
|
||||
$totalViews = \DB::table('video_views')
|
||||
->whereIn('video_id', $user->videos()->pluck('id'))
|
||||
->count();
|
||||
|
||||
// Filter social links by visibility for the current viewer
|
||||
$viewer = Auth::user();
|
||||
$isOwner = $viewer && $viewer->id === $user->id;
|
||||
$isSubscriber = $viewer && !$isOwner && $viewer->isSubscribedTo($user);
|
||||
|
||||
$socialLinks = $user->socialLinks()
|
||||
->orderBy('sort_order')
|
||||
->get()
|
||||
->filter(function ($link) use ($isOwner, $viewer, $isSubscriber) {
|
||||
if ($isOwner) return true;
|
||||
return match ($link->visibility) {
|
||||
'public' => true,
|
||||
'registered' => (bool) $viewer,
|
||||
'subscribers' => $viewer && $isSubscriber,
|
||||
'only_me' => false,
|
||||
default => false,
|
||||
};
|
||||
});
|
||||
|
||||
// Posts for the Wall tab
|
||||
$posts = Post::where('user_id', $user->id)
|
||||
->with(['user', 'video', 'reactions', 'postImages', 'postVideos.video'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// Horoscope
|
||||
$horoscope = Horoscope::getSign($user->birthday);
|
||||
$viewerSign = null;
|
||||
$compatibility = null;
|
||||
if ($viewer && ! $isOwner && $viewer->birthday) {
|
||||
$viewerSign = Horoscope::getSign($viewer->birthday);
|
||||
$compatibility = Horoscope::compatibility($horoscope, $viewerSign);
|
||||
}
|
||||
|
||||
$canEdit = $isOwner || (Auth::check() && Auth::user()->isSuperAdmin());
|
||||
|
||||
return view('user.channel', compact(
|
||||
'user', 'videos', 'shorts', 'playlists', 'totalViews', 'sort',
|
||||
'socialLinks', 'isSubscriber', 'isOwner', 'canEdit', 'posts', 'horoscope', 'viewerSign', 'compatibility',
|
||||
'preview'
|
||||
));
|
||||
}
|
||||
|
||||
// Watch history
|
||||
@ -151,13 +282,23 @@ class UserController extends Controller
|
||||
->orWhere('user_id', $user->id);
|
||||
})
|
||||
->get()
|
||||
->sortByDesc(function ($video) use ($videoIds) {
|
||||
->sortBy(function ($video) use ($videoIds) {
|
||||
return $videoIds->search($video->id);
|
||||
});
|
||||
|
||||
return view('user.history', compact('videos'));
|
||||
}
|
||||
|
||||
// Clear watch history
|
||||
public function clearHistory()
|
||||
{
|
||||
\DB::table('video_views')
|
||||
->where('user_id', Auth::id())
|
||||
->delete();
|
||||
|
||||
return redirect()->route('history')->with('toast_success', 'Watch history cleared.');
|
||||
}
|
||||
|
||||
// Liked videos
|
||||
public function liked()
|
||||
{
|
||||
@ -214,4 +355,150 @@ class UserController extends Controller
|
||||
'like_count' => $video->like_count,
|
||||
]);
|
||||
}
|
||||
|
||||
public function toggleSubscribe(User $user)
|
||||
{
|
||||
$me = Auth::user();
|
||||
|
||||
if ($me->id === $user->id) {
|
||||
return response()->json(['error' => 'Cannot subscribe to yourself'], 422);
|
||||
}
|
||||
|
||||
if ($me->isSubscribedTo($user)) {
|
||||
$me->subscriptions()->detach($user->id);
|
||||
$subscribed = false;
|
||||
} else {
|
||||
$me->subscriptions()->attach($user->id);
|
||||
$subscribed = true;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'subscribed' => $subscribed,
|
||||
'subscriber_count' => $user->fresh()->subscriber_count,
|
||||
]);
|
||||
}
|
||||
|
||||
public function fetchNotifications()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$rawNotifications = $user->notifications()->latest()->take(50)->get();
|
||||
|
||||
// Bulk-fetch current video state for all notification types
|
||||
$videoIds = $rawNotifications
|
||||
->pluck('data.video_id')
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$videos = \App\Models\Video::whereIn('id', $videoIds)
|
||||
->whereIn('visibility', ['public', 'unlisted'])
|
||||
->get(['id', 'thumbnail', 'visibility'])
|
||||
->keyBy('id');
|
||||
|
||||
$notifications = $rawNotifications
|
||||
->filter(function ($n) use ($videos) {
|
||||
$videoId = $n->data['video_id'] ?? null;
|
||||
return $videoId && $videos->has($videoId);
|
||||
})
|
||||
->take(30)
|
||||
->map(function ($n) use ($videos) {
|
||||
$data = $n->data;
|
||||
$video = $videos->get($data['video_id']);
|
||||
$data['video_thumbnail'] = $video?->thumbnail ?? null;
|
||||
return [
|
||||
'id' => $n->id,
|
||||
'read' => ! is_null($n->read_at),
|
||||
'time' => $n->created_at->diffForHumans(),
|
||||
'data' => $data,
|
||||
];
|
||||
})
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'notifications' => $notifications,
|
||||
'unread_count' => $user->unreadNotifications()->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markNotificationRead(string $id)
|
||||
{
|
||||
$notification = Auth::user()->notifications()->findOrFail($id);
|
||||
$notification->markAsRead();
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function markAllNotificationsRead()
|
||||
{
|
||||
Auth::user()->unreadNotifications->markAsRead();
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function updateAvatar(Request $request)
|
||||
{
|
||||
$request->validate(['path' => 'required|string|max:300']);
|
||||
$user = Auth::user();
|
||||
$filename = basename($request->path);
|
||||
$tempPath = storage_path('app/public/avatars/' . $filename);
|
||||
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
|
||||
// Move temp file into the user's profile directory
|
||||
$profileDir = $nas->localProfileDir($user);
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
|
||||
$destFilename = "avatar.{$ext}";
|
||||
$destPath = "{$profileDir}/{$destFilename}";
|
||||
$relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}";
|
||||
|
||||
// Delete old avatar before moving new one in (handles both path formats)
|
||||
$nas->deleteLocalAvatar($user);
|
||||
|
||||
@mkdir($profileDir, 0755, true);
|
||||
if (file_exists($tempPath)) {
|
||||
rename($tempPath, $destPath);
|
||||
}
|
||||
|
||||
$user->update(['avatar' => $relPath]);
|
||||
|
||||
if ($nas->isEnabled() && file_exists($destPath)) {
|
||||
$nas->syncAvatar($user, $destPath);
|
||||
$nas->deleteLocalAvatar($user);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function updateBanner(Request $request)
|
||||
{
|
||||
$request->validate(['path' => 'required|string|max:300']);
|
||||
$user = Auth::user();
|
||||
$filename = basename($request->path);
|
||||
$tempPath = storage_path('app/public/banners/' . $filename);
|
||||
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
|
||||
// Move temp file into the user's profile directory
|
||||
$profileDir = $nas->localProfileDir($user);
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
|
||||
$destFilename = "cover.{$ext}";
|
||||
$destPath = "{$profileDir}/{$destFilename}";
|
||||
$relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}";
|
||||
|
||||
// Delete old banner before moving new one in (handles both path formats)
|
||||
$nas->deleteLocalBanner($user);
|
||||
|
||||
@mkdir($profileDir, 0755, true);
|
||||
if (file_exists($tempPath)) {
|
||||
rename($tempPath, $destPath);
|
||||
}
|
||||
|
||||
$user->update(['banner' => $relPath]);
|
||||
|
||||
if ($nas->isEnabled() && file_exists($destPath)) {
|
||||
$nas->syncCover($user, $destPath);
|
||||
$nas->deleteLocalBanner($user);
|
||||
}
|
||||
|
||||
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,
|
||||
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
||||
\Illuminate\Session\Middleware\StartSession::class,
|
||||
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||
\App\Http\Middleware\VerifyCsrfToken::class,
|
||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
|
||||
@ -12,7 +12,7 @@ class TrustProxies extends Middleware
|
||||
*
|
||||
* @var array<int, string>|string|null
|
||||
*/
|
||||
protected $proxies;
|
||||
protected $proxies = '*';
|
||||
|
||||
/**
|
||||
* The headers that should be used to detect proxies.
|
||||
|
||||
@ -12,6 +12,8 @@ class VerifyCsrfToken extends Middleware
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $except = [
|
||||
//
|
||||
'videos/*/share',
|
||||
'playlists/*/share',
|
||||
'videos/*/slideshow/generate',
|
||||
];
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Video;
|
||||
use FFMpeg\FFMpeg;
|
||||
use FFMpeg\Format\Video\X264;
|
||||
@ -37,22 +38,46 @@ class CompressVideoJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
// Create compressed filename
|
||||
// Create compressed file alongside the original
|
||||
$compressedFilename = 'compressed_' . $video->filename;
|
||||
$compressedPath = storage_path('app/public/videos/' . $compressedFilename);
|
||||
$compressedPath = dirname($originalPath) . '/' . $compressedFilename;
|
||||
|
||||
try {
|
||||
$ffmpeg = FFMpeg::create();
|
||||
$ffmpegConfig = Config::get('ffmpeg');
|
||||
$ffmpeg = FFMpeg::create([
|
||||
'ffmpeg.binaries' => $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg',
|
||||
'ffprobe.binaries' => $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe',
|
||||
'timeout' => $ffmpegConfig['timeout'] ?? 3600,
|
||||
]);
|
||||
$ffmpegVideo = $ffmpeg->open($originalPath);
|
||||
|
||||
// Use CRF 18 for high quality (lower = better quality, 18-23 is good range)
|
||||
// Use 'slow' preset for better compression efficiency
|
||||
// GPU NVENC encoding via config
|
||||
$ffmpegConfig = Config::get('ffmpeg');
|
||||
$videoPasses = $ffmpegConfig['defaults']['video'] ?? [];
|
||||
$audioPasses = $ffmpegConfig['defaults']['audio'] ?? [];
|
||||
$gpuEnabled = Setting::gpuEnabled();
|
||||
$encoder = Setting::gpuEncoder();
|
||||
$preset = Setting::gpuPreset();
|
||||
$device = Setting::gpuDevice();
|
||||
|
||||
$format = new X264('aac', 'h264_nvenc');
|
||||
if ($gpuEnabled) {
|
||||
$videoPasses = [
|
||||
"-c:v {$encoder}",
|
||||
"-preset {$preset}",
|
||||
'-rc vbr',
|
||||
'-cq 23',
|
||||
'-profile:v high',
|
||||
'-pix_fmt yuv420p',
|
||||
"-gpu {$device}",
|
||||
];
|
||||
} else {
|
||||
$videoPasses = [
|
||||
'-c:v libx264',
|
||||
'-preset fast',
|
||||
'-crf 23',
|
||||
'-profile:v high',
|
||||
'-pix_fmt yuv420p',
|
||||
];
|
||||
}
|
||||
$audioPasses = ['-c:a aac', '-b:a 192k'];
|
||||
|
||||
$format = new X264('aac', $encoder);
|
||||
foreach ($videoPasses as $pass) {
|
||||
$format->addLegacyOption($pass);
|
||||
}
|
||||
@ -79,12 +104,13 @@ class CompressVideoJob implements ShouldQueue
|
||||
'mime_type' => 'video/mp4',
|
||||
]);
|
||||
|
||||
Log::info('CompressVideoJob: Video compressed with GPU NVENC', [
|
||||
Log::info('CompressVideoJob: Video compressed', [
|
||||
'video_id' => $video->id,
|
||||
'original_size' => $originalSize,
|
||||
'compressed_size' => $compressedSize,
|
||||
'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%',
|
||||
'encoder' => 'h264_nvenc'
|
||||
'encoder' => $encoder,
|
||||
'gpu' => $gpuEnabled,
|
||||
]);
|
||||
} else {
|
||||
// Compressed file is larger, delete it
|
||||
|
||||
@ -2,17 +2,15 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Video;
|
||||
use FFMpeg\FFMpeg;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class GenerateHlsJob implements ShouldQueue
|
||||
{
|
||||
@ -35,95 +33,153 @@ class GenerateHlsJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
$sourcePath = storage_path('app/' . $video->path);
|
||||
if (!file_exists($sourcePath)) {
|
||||
Log::error('GenerateHlsJob: Source file missing', ['path' => $sourcePath]);
|
||||
return;
|
||||
$sourcePath = storage_path('app/' . $video->path);
|
||||
$nasDownloaded = null; // track a NAS-fetched local copy so we can clean it up
|
||||
|
||||
if (! file_exists($sourcePath)) {
|
||||
// NAS-primary mode: file lives on NAS, download a temporary local copy
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
if ($nas->isEnabled()) {
|
||||
$localCopy = $nas->ensureLocalCopy($video);
|
||||
if ($localCopy) {
|
||||
$sourcePath = $localCopy;
|
||||
$nasDownloaded = $localCopy;
|
||||
}
|
||||
}
|
||||
if (! file_exists($sourcePath)) {
|
||||
Log::error('GenerateHlsJob: Source file missing', ['path' => $sourcePath]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$hlsDir = 'public/hls/' . $video->id;
|
||||
$hlsDir = 'public/hls/' . $video->id;
|
||||
$hlsPath = storage_path('app/' . $hlsDir);
|
||||
|
||||
// Clean existing HLS
|
||||
if (is_dir($hlsPath)) {
|
||||
Storage::deleteDirectory($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 {
|
||||
$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,
|
||||
$ffmpegBin = \App\Models\Setting::ffmpegBinary();
|
||||
$gpuEnabled = Setting::gpuEnabled();
|
||||
$encoder = Setting::gpuEncoder(); // h264_nvenc / hevc_nvenc / libx264
|
||||
$preset = Setting::gpuPreset(); // p1–p7 for NVENC, fast/medium/slow for x264
|
||||
$device = Setting::gpuDevice(); // GPU index (0, 1, …)
|
||||
$hwaccel = Setting::gpuHwaccel(); // cuda / none
|
||||
|
||||
$cmd = [escapeshellcmd($ffmpegBin)];
|
||||
|
||||
// Hardware-accelerated decode when GPU is in use
|
||||
if ($gpuEnabled && $hwaccel !== 'none') {
|
||||
$cmd[] = "-hwaccel {$hwaccel}";
|
||||
$cmd[] = "-hwaccel_device {$device}";
|
||||
}
|
||||
|
||||
$cmd[] = '-i ' . escapeshellarg($sourcePath);
|
||||
|
||||
// One video+audio stream per variant
|
||||
$n = count($variants);
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
$cmd[] = '-map 0:v:0';
|
||||
$cmd[] = '-map 0:a:0?';
|
||||
}
|
||||
|
||||
// Video codec
|
||||
$cmd[] = "-c:v {$encoder}";
|
||||
if ($gpuEnabled && str_contains($encoder, 'nvenc')) {
|
||||
$cmd[] = "-preset {$preset}";
|
||||
$cmd[] = '-rc vbr';
|
||||
$cmd[] = '-cq 23';
|
||||
$cmd[] = "-gpu {$device}";
|
||||
} else {
|
||||
$cmd[] = "-preset {$preset}";
|
||||
$cmd[] = '-crf 23';
|
||||
}
|
||||
$cmd[] = '-pix_fmt yuv420p';
|
||||
|
||||
// Audio codec
|
||||
$cmd[] = '-c:a aac';
|
||||
$cmd[] = '-b:a 128k';
|
||||
$cmd[] = '-ar 48000';
|
||||
|
||||
// Per-variant scale + bitrate
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
$cmd[] = "-filter:v:{$i} scale=-2:{$variants[$i]['height']}";
|
||||
$cmd[] = "-b:v:{$i} {$variants[$i]['bitrate']}";
|
||||
}
|
||||
|
||||
// HLS muxer options
|
||||
$cmd[] = '-g 48';
|
||||
$cmd[] = '-sc_threshold 0';
|
||||
$cmd[] = '-f hls';
|
||||
$cmd[] = '-hls_time 6';
|
||||
$cmd[] = '-hls_list_size 0';
|
||||
$cmd[] = '-hls_flags independent_segments';
|
||||
$cmd[] = '-hls_segment_filename ' . escapeshellarg($hlsPath . '/%v/%03d.ts');
|
||||
$cmd[] = '-master_pl_name playlist.m3u8';
|
||||
|
||||
$vsm = implode(' ', array_map(
|
||||
fn ($i, $v) => "v:{$i},a:{$i},name:{$v['name']}",
|
||||
array_keys($variants),
|
||||
$variants
|
||||
));
|
||||
$cmd[] = '-var_stream_map ' . escapeshellarg($vsm);
|
||||
|
||||
$cmd[] = escapeshellarg($hlsPath . '/%v/index.m3u8');
|
||||
|
||||
$fullCmd = implode(' ', $cmd) . ' 2>&1';
|
||||
|
||||
Log::info('GenerateHlsJob: Starting', [
|
||||
'video_id' => $video->id,
|
||||
'gpu' => $gpuEnabled,
|
||||
'encoder' => $encoder,
|
||||
'device' => $gpuEnabled ? $device : 'cpu',
|
||||
]);
|
||||
|
||||
$videoMedia = $ffmpeg->open($sourcePath);
|
||||
exec($fullCmd, $output, $exitCode);
|
||||
|
||||
// HLS variants: 480p, 720p, 1080p
|
||||
$variants = [
|
||||
[
|
||||
'height' => 480,
|
||||
'name' => '480p',
|
||||
'bitrate' => 1000,
|
||||
],
|
||||
[
|
||||
'height' => 720,
|
||||
'name' => '720p',
|
||||
'bitrate' => 2500,
|
||||
],
|
||||
[
|
||||
'height' => 1080,
|
||||
'name' => '1080p',
|
||||
'bitrate' => 5000,
|
||||
],
|
||||
];
|
||||
if ($exitCode !== 0) {
|
||||
$tail = implode("\n", array_slice($output, -30));
|
||||
throw new \RuntimeException("FFmpeg exited {$exitCode}:\n{$tail}");
|
||||
}
|
||||
|
||||
$hlsOptions = [
|
||||
'-c:v h264_nvenc',
|
||||
'-preset p4',
|
||||
'-g 48', // GOP size 2s @25fps
|
||||
'-sc_threshold 0',
|
||||
'-c:a aac',
|
||||
'-ar 48000',
|
||||
'-f hls',
|
||||
'-hls_time 6',
|
||||
'-hls_list_size 0',
|
||||
'-hls_segment_filename',
|
||||
'%v/%03d.ts',
|
||||
'-hls_flags',
|
||||
'delete_segments+append_list',
|
||||
'-master_pl_name',
|
||||
'playlist.m3u8',
|
||||
];
|
||||
$video->update(['has_hls' => true, 'hls_path' => $hlsDir]);
|
||||
|
||||
$videoMedia->save(new \FFMpeg\Format\Video\X264(), $hlsPath, function ($filters) use ($variants) {
|
||||
foreach ($variants as $variant) {
|
||||
$filters->custom('-map 0:v:0 -map 0:a:0?')
|
||||
->size("trunc(oh*a/oh/{$variant['height']})*{$variant['height']}") // Scale
|
||||
->resize(new \FFMpeg\Coordinate\Dimension($variant['height'] * 16 / 9, $variant['height']))
|
||||
->videoCodec($variant['bitrate'] . 'k')
|
||||
->addLegacyOption('-var_stream_map v:0,name:' . $variant['name'] . ' v:1,name:720p v:2,name:1080p');
|
||||
}
|
||||
});
|
||||
|
||||
// Mark HLS ready
|
||||
$video->update([
|
||||
'has_hls' => true,
|
||||
'hls_path' => $hlsDir,
|
||||
]);
|
||||
|
||||
Log::info('GenerateHlsJob: HLS generated successfully', [
|
||||
Log::info('GenerateHlsJob: HLS generated', [
|
||||
'video_id' => $video->id,
|
||||
'variants' => array_column($variants, 'name'),
|
||||
'hls_url' => asset('storage/' . $hlsDir . '/playlist.m3u8'),
|
||||
'encoder' => $encoder,
|
||||
]);
|
||||
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
if ($nas->isEnabled()) {
|
||||
if ($nasDownloaded) {
|
||||
// NAS-primary mode: video was fetched from NAS for HLS generation.
|
||||
// The original is already on NAS — just delete the local temp copy.
|
||||
@unlink($nasDownloaded);
|
||||
} else {
|
||||
// Local-storage mode: push the (compressed) file to NAS and free local disk.
|
||||
// HLS segments stay local — per-segment SMB latency would hurt playback.
|
||||
$nas->syncVideo($video);
|
||||
$nas->deleteLocalVideo($video);
|
||||
$nas->deleteLocalAssets($video);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('GenerateHlsJob failed: ' . $e->getMessage(), ['video_id' => $video->id]);
|
||||
Storage::deleteDirectory($hlsDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
44
app/Jobs/NasSyncVideoJob.php
Normal file
44
app/Jobs/NasSyncVideoJob.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Video;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class NasSyncVideoJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
public int $backoff = 60;
|
||||
|
||||
public function __construct(public readonly Video $video) {}
|
||||
|
||||
public function handle(NasSyncService $nas): void
|
||||
{
|
||||
if (! $nas->isEnabled()) return;
|
||||
try {
|
||||
$nas->syncVideo($this->video);
|
||||
|
||||
// Audio/music uploads have no further processing jobs, so it's safe
|
||||
// to remove the local file immediately after a successful NAS push.
|
||||
// Video uploads must keep the local file until GenerateHlsJob finishes.
|
||||
if ($this->video->type === 'music') {
|
||||
$nas->deleteLocalVideo($this->video);
|
||||
}
|
||||
$nas->deleteLocalAssets($this->video);
|
||||
$nas->pruneLocalVideoDir($this->video);
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::error(
|
||||
'NasSyncVideoJob failed for video #' . $this->video->id .
|
||||
' ("' . $this->video->title . '"): ' . $e->getMessage() .
|
||||
' — local files kept. Run `php artisan nas:repair --force` to retry.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
183
app/Jobs/NasToLocalMigrationJob.php
Normal file
183
app/Jobs/NasToLocalMigrationJob.php
Normal file
@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use App\Services\NasSyncService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NasToLocalMigrationJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
public int $timeout = 7200; // 2 hours
|
||||
|
||||
private function updateProgress(array $data): void
|
||||
{
|
||||
Cache::put('nas_disable_progress', json_encode($data), 3600);
|
||||
}
|
||||
|
||||
public function handle(NasSyncService $nas): void
|
||||
{
|
||||
$progress = [
|
||||
'current' => 0,
|
||||
'total' => 0,
|
||||
'phase' => 'Counting files...',
|
||||
'done' => false,
|
||||
'error' => null,
|
||||
];
|
||||
$this->updateProgress($progress);
|
||||
|
||||
try {
|
||||
// ── Count all items ───────────────────────────────────────────────
|
||||
$videos = Video::where('path', 'like', 'users/%')->get();
|
||||
|
||||
$slides = \DB::table('video_slides')
|
||||
->where('filename', 'like', 'users/%')
|
||||
->get();
|
||||
|
||||
$usersWithAvatar = User::where('avatar', 'like', 'users/%')->get();
|
||||
$usersWithBanner = User::where('banner', 'like', 'users/%')->get();
|
||||
|
||||
$postImages = \DB::table('post_images')
|
||||
->where('filename', 'like', 'users/%')
|
||||
->get();
|
||||
|
||||
// Count thumbnails separately (they're additional per-video downloads)
|
||||
$videoThumbs = $videos->filter(fn($v) => $v->thumbnail && str_starts_with($v->thumbnail, 'users/'));
|
||||
|
||||
$total = $videos->count()
|
||||
+ $videoThumbs->count()
|
||||
+ $slides->count()
|
||||
+ $usersWithAvatar->count()
|
||||
+ $usersWithBanner->count()
|
||||
+ $postImages->count();
|
||||
|
||||
$progress['total'] = $total;
|
||||
$progress['phase'] = 'Downloading files from NAS...';
|
||||
$this->updateProgress($progress);
|
||||
|
||||
// ── Download videos ───────────────────────────────────────────────
|
||||
foreach ($videos as $video) {
|
||||
$nasPath = $video->path;
|
||||
$localPath = storage_path('app/' . $nasPath);
|
||||
|
||||
try {
|
||||
$nas->ensureLocalAsset($localPath, $nasPath);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('NasToLocalMigrationJob: failed to download video #' . $video->id . ' path=' . $nasPath . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$progress['current']++;
|
||||
$progress['phase'] = 'Downloading videos... (' . $progress['current'] . '/' . $total . ')';
|
||||
$this->updateProgress($progress);
|
||||
}
|
||||
|
||||
// ── Download thumbnails ───────────────────────────────────────────
|
||||
foreach ($videoThumbs as $video) {
|
||||
$nasPath = $video->thumbnail;
|
||||
$localPath = storage_path('app/' . $nasPath);
|
||||
|
||||
try {
|
||||
$nas->ensureLocalAsset($localPath, $nasPath);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('NasToLocalMigrationJob: failed to download thumbnail for video #' . $video->id . ' path=' . $nasPath . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$progress['current']++;
|
||||
$progress['phase'] = 'Downloading thumbnails... (' . $progress['current'] . '/' . $total . ')';
|
||||
$this->updateProgress($progress);
|
||||
}
|
||||
|
||||
// ── Download slides ───────────────────────────────────────────────
|
||||
foreach ($slides as $slide) {
|
||||
$nasPath = $slide->filename;
|
||||
$localPath = storage_path('app/' . $nasPath);
|
||||
|
||||
try {
|
||||
$nas->ensureLocalAsset($localPath, $nasPath);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('NasToLocalMigrationJob: failed to download slide id=' . $slide->id . ' path=' . $nasPath . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$progress['current']++;
|
||||
$progress['phase'] = 'Downloading audio slides... (' . $progress['current'] . '/' . $total . ')';
|
||||
$this->updateProgress($progress);
|
||||
}
|
||||
|
||||
// ── Download user avatars ─────────────────────────────────────────
|
||||
foreach ($usersWithAvatar as $user) {
|
||||
$nasPath = $user->avatar;
|
||||
$localPath = storage_path('app/' . $nasPath);
|
||||
|
||||
try {
|
||||
$nas->ensureLocalAsset($localPath, $nasPath);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('NasToLocalMigrationJob: failed to download avatar for user #' . $user->id . ' path=' . $nasPath . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$progress['current']++;
|
||||
$progress['phase'] = 'Downloading avatars... (' . $progress['current'] . '/' . $total . ')';
|
||||
$this->updateProgress($progress);
|
||||
}
|
||||
|
||||
// ── Download user banners ─────────────────────────────────────────
|
||||
foreach ($usersWithBanner as $user) {
|
||||
$nasPath = $user->banner;
|
||||
$localPath = storage_path('app/' . $nasPath);
|
||||
|
||||
try {
|
||||
$nas->ensureLocalAsset($localPath, $nasPath);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('NasToLocalMigrationJob: failed to download banner for user #' . $user->id . ' path=' . $nasPath . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$progress['current']++;
|
||||
$progress['phase'] = 'Downloading banners... (' . $progress['current'] . '/' . $total . ')';
|
||||
$this->updateProgress($progress);
|
||||
}
|
||||
|
||||
// ── Download post images ──────────────────────────────────────────
|
||||
foreach ($postImages as $img) {
|
||||
$nasPath = $img->filename;
|
||||
$localPath = storage_path('app/' . $nasPath);
|
||||
|
||||
try {
|
||||
$nas->ensureLocalAsset($localPath, $nasPath);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('NasToLocalMigrationJob: failed to download post image id=' . $img->id . ' path=' . $nasPath . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$progress['current']++;
|
||||
$progress['phase'] = 'Downloading post images... (' . $progress['current'] . '/' . $total . ')';
|
||||
$this->updateProgress($progress);
|
||||
}
|
||||
|
||||
// ── Disable NAS ───────────────────────────────────────────────────
|
||||
Setting::set('nas_sync_enabled', 'false');
|
||||
|
||||
$progress['done'] = true;
|
||||
$progress['phase'] = 'Complete';
|
||||
$this->updateProgress($progress);
|
||||
|
||||
Log::info('NasToLocalMigrationJob: completed. ' . $total . ' files migrated. NAS disabled.');
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('NasToLocalMigrationJob: fatal error: ' . $e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$progress['error'] = $e->getMessage();
|
||||
$this->updateProgress($progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
public function likes()
|
||||
{
|
||||
return $this->hasMany(CommentLike::class);
|
||||
}
|
||||
|
||||
// Get mentioned users from comment body
|
||||
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',
|
||||
'visibility',
|
||||
'is_default',
|
||||
'share_token',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@ -42,13 +43,12 @@ class Playlist extends Model
|
||||
}
|
||||
|
||||
// Accessors
|
||||
public function getThumbnailUrlAttribute()
|
||||
public function getThumbnailUrlAttribute(): string
|
||||
{
|
||||
if ($this->thumbnail) {
|
||||
return asset('storage/thumbnails/'.$this->thumbnail);
|
||||
return route('media.thumbnail', $this->thumbnail);
|
||||
}
|
||||
|
||||
// Generate a placeholder based on playlist name
|
||||
return 'https://ui-avatars.com/api/?name='.urlencode($this->name).'&background=random&size=200';
|
||||
}
|
||||
|
||||
@ -143,10 +143,10 @@ class Playlist extends Model
|
||||
->first();
|
||||
}
|
||||
|
||||
// Get shareable URL
|
||||
// All share URLs use the unguessable token route
|
||||
public function getShareUrlAttribute()
|
||||
{
|
||||
return route('playlists.show', $this->id);
|
||||
return route('playlists.showByToken', $this->share_token);
|
||||
}
|
||||
|
||||
// Scope for public playlists
|
||||
@ -178,7 +178,12 @@ class Playlist extends Model
|
||||
return $this->visibility === 'private';
|
||||
}
|
||||
|
||||
// Check if user can view this playlist
|
||||
public function isUnlisted()
|
||||
{
|
||||
return $this->visibility === 'unlisted';
|
||||
}
|
||||
|
||||
// Check if user can view this playlist via the ID route
|
||||
public function canView($user = null)
|
||||
{
|
||||
// Owner can always view
|
||||
@ -186,10 +191,20 @@ class Playlist extends Model
|
||||
return true;
|
||||
}
|
||||
|
||||
// Public playlists can be viewed by anyone
|
||||
// Only public playlists are accessible via the ID route
|
||||
return $this->visibility === 'public';
|
||||
}
|
||||
|
||||
// Check if user can view this playlist via the share-token route
|
||||
public function canViewViaToken($user = null)
|
||||
{
|
||||
if ($user && $this->user_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($this->visibility, ['public', 'unlisted']);
|
||||
}
|
||||
|
||||
// Check if user can edit this playlist
|
||||
public function canEdit($user = null)
|
||||
{
|
||||
|
||||
55
app/Models/Post.php
Normal file
55
app/Models/Post.php
Normal file
@ -0,0 +1,55 @@
|
||||
<?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
|
||||
{
|
||||
if (! $this->image) return null;
|
||||
if (str_starts_with($this->image, 'users/')) {
|
||||
return route('media.post-image', $this->image);
|
||||
}
|
||||
return asset('storage/post_images/' . $this->image);
|
||||
}
|
||||
}
|
||||
23
app/Models/PostImage.php
Normal file
23
app/Models/PostImage.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?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
|
||||
{
|
||||
if (str_starts_with($this->filename, 'users/')) {
|
||||
return route('media.post-image', $this->filename);
|
||||
}
|
||||
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;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use App\Notifications\VerifyEmail;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable
|
||||
class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
@ -29,6 +31,18 @@ class User extends Authenticatable
|
||||
'tiktok',
|
||||
'birthday',
|
||||
'location',
|
||||
'gender',
|
||||
'nationality',
|
||||
'phone_code',
|
||||
'phone_number',
|
||||
'timezone',
|
||||
'whatsapp',
|
||||
'google_location',
|
||||
'social_phone',
|
||||
'social_email',
|
||||
'two_factor_secret',
|
||||
'two_factor_enabled',
|
||||
'banner',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@ -37,10 +51,47 @@ class User extends Authenticatable
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'two_factor_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
protected $appends = ['avatar_url', 'banner_url'];
|
||||
|
||||
// Auto-generate a unique slug-based username when creating a user without one
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
static::creating(function (User $user) {
|
||||
if (empty($user->username)) {
|
||||
$user->username = static::generateUniqueUsername($user->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static function generateUniqueUsername(string $name): string
|
||||
{
|
||||
$base = substr(Str::slug(trim($name)), 0, 18);
|
||||
if ($base === '') {
|
||||
$base = 'user';
|
||||
}
|
||||
do {
|
||||
$slug = $base . '-' . Str::lower(Str::random(6));
|
||||
} while (static::where('username', $slug)->exists());
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
// Returns the channel URL slug — the username, auto-generated and saved if missing
|
||||
public function getChannelAttribute(): string
|
||||
{
|
||||
if (empty($this->username)) {
|
||||
$this->username = static::generateUniqueUsername($this->name);
|
||||
$this->saveQuietly();
|
||||
}
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
// Relationships
|
||||
public function videos()
|
||||
{
|
||||
@ -67,15 +118,33 @@ class User extends Authenticatable
|
||||
return $this->hasMany(Playlist::class);
|
||||
}
|
||||
|
||||
public function getAvatarUrlAttribute()
|
||||
public function posts()
|
||||
{
|
||||
return $this->hasMany(\App\Models\Post::class);
|
||||
}
|
||||
|
||||
public function getAvatarUrlAttribute(): string
|
||||
{
|
||||
if ($this->avatar) {
|
||||
return asset('storage/avatars/'.$this->avatar);
|
||||
return route('media.avatar', $this->avatar);
|
||||
}
|
||||
|
||||
return 'https://i.pravatar.cc/150?u='.$this->id;
|
||||
}
|
||||
|
||||
public function getBannerUrlAttribute(): ?string
|
||||
{
|
||||
if ($this->banner) {
|
||||
return route('media.banner', $this->banner);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function sendEmailVerificationNotification(): void
|
||||
{
|
||||
$this->notify(new VerifyEmail);
|
||||
}
|
||||
|
||||
// Role helper methods
|
||||
public function isSuperAdmin()
|
||||
{
|
||||
@ -92,31 +161,47 @@ class User extends Authenticatable
|
||||
return $this->role === 'user' || $this->role === null;
|
||||
}
|
||||
|
||||
// Placeholder for subscriber count (would need a separate table in full implementation)
|
||||
public function getSubscriberCountAttribute()
|
||||
// Users who subscribe TO this channel
|
||||
public function subscribers()
|
||||
{
|
||||
// For now, return a placeholder - in production this would come from a subscriptions table
|
||||
return rand(100, 10000);
|
||||
return $this->belongsToMany(
|
||||
User::class,
|
||||
'user_subscriptions',
|
||||
'channel_id',
|
||||
'subscriber_id'
|
||||
)->withPivot('created_at');
|
||||
}
|
||||
|
||||
// Get social links as an array
|
||||
public function getSocialLinksAttribute()
|
||||
// Channels this user subscribes to
|
||||
public function subscriptions()
|
||||
{
|
||||
return [
|
||||
'twitter' => $this->twitter,
|
||||
'instagram' => $this->instagram,
|
||||
'facebook' => $this->facebook,
|
||||
'youtube' => $this->youtube,
|
||||
'linkedin' => $this->linkedin,
|
||||
'tiktok' => $this->tiktok,
|
||||
];
|
||||
return $this->belongsToMany(
|
||||
User::class,
|
||||
'user_subscriptions',
|
||||
'subscriber_id',
|
||||
'channel_id'
|
||||
)->withPivot('created_at');
|
||||
}
|
||||
|
||||
public function isSubscribedTo(User $channel): bool
|
||||
{
|
||||
return $this->subscriptions()->where('channel_id', $channel->id)->exists();
|
||||
}
|
||||
|
||||
public function getSubscriberCountAttribute(): int
|
||||
{
|
||||
return $this->subscribers()->count();
|
||||
}
|
||||
|
||||
// Check if user has any social links
|
||||
public function socialLinks()
|
||||
{
|
||||
return $this->hasMany(UserSocialLink::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function hasSocialLinks()
|
||||
{
|
||||
return $this->twitter || $this->instagram || $this->facebook ||
|
||||
$this->youtube || $this->linkedin || $this->tiktok;
|
||||
return $this->socialLinks()->exists();
|
||||
}
|
||||
|
||||
// Get formatted website URL
|
||||
|
||||
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',
|
||||
'has_hls',
|
||||
'hls_path',
|
||||
'download_access',
|
||||
'download_count',
|
||||
'share_count',
|
||||
'share_token',
|
||||
'slideshow_video_path',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@ -54,19 +59,63 @@ class Video extends Model
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function slides()
|
||||
{
|
||||
return $this->hasMany(VideoSlide::class)->orderBy('position');
|
||||
}
|
||||
|
||||
public function hasSlideshow(): bool
|
||||
{
|
||||
return $this->slides()->count() > 1;
|
||||
}
|
||||
|
||||
// ── Local filesystem path helpers ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Absolute path to the video file on local disk.
|
||||
* Works for both old flat paths ("public/videos/…") and new NAS-mirrored paths ("users/…").
|
||||
*/
|
||||
public function localVideoPath(): string
|
||||
{
|
||||
return storage_path('app/' . $this->path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path to the thumbnail on local disk.
|
||||
* Old format: storage/app/public/thumbnails/{filename}
|
||||
* New format: storage/app/{relative-path} (path contains a slash, e.g. users/…/thumb.jpg)
|
||||
*/
|
||||
public function localThumbnailPath(): ?string
|
||||
{
|
||||
if (! $this->thumbnail) return null;
|
||||
return str_contains($this->thumbnail, '/')
|
||||
? storage_path('app/' . $this->thumbnail)
|
||||
: storage_path('app/public/thumbnails/' . $this->thumbnail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage::delete()-compatible key for the thumbnail.
|
||||
*/
|
||||
public function thumbnailStorageKey(): ?string
|
||||
{
|
||||
if (! $this->thumbnail) return null;
|
||||
return str_contains($this->thumbnail, '/')
|
||||
? $this->thumbnail
|
||||
: 'public/thumbnails/' . $this->thumbnail;
|
||||
}
|
||||
|
||||
// Accessors
|
||||
public function getUrlAttribute()
|
||||
{
|
||||
return asset('storage/videos/'.$this->filename);
|
||||
}
|
||||
|
||||
public function getThumbnailUrlAttribute()
|
||||
public function getThumbnailUrlAttribute(): ?string
|
||||
{
|
||||
if ($this->thumbnail) {
|
||||
return asset('storage/thumbnails/'.$this->thumbnail);
|
||||
return route('media.thumbnail', $this->thumbnail);
|
||||
}
|
||||
|
||||
// Return null when no thumbnail - social platforms will use their own preview
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -92,10 +141,84 @@ class Video extends Model
|
||||
return \DB::table('video_views')->where('video_id', $this->id)->count();
|
||||
}
|
||||
|
||||
// Get shareable URL for the video
|
||||
// ── Short opaque URL encoding ─────────────────────────────────────
|
||||
// 3-round Feistel cipher over 30-bit space → base62.
|
||||
// Consecutive IDs produce completely different output. Pure PHP, no extensions.
|
||||
private const URL_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
private static function feistelRound(int $n, int $round): int
|
||||
{
|
||||
// Deterministic pseudo-random function per round (no extension needed)
|
||||
$keys = [0x2D4F8A1B, 0x7C3E6912, 0xA5B2D083];
|
||||
return (int)(($n * $keys[$round % 3] + ($round * 0x9E3779B9)) & 0x7FFF);
|
||||
}
|
||||
|
||||
public static function encodeId(int $id): string
|
||||
{
|
||||
// Split 30-bit id into two 15-bit halves
|
||||
$L = ($id >> 15) & 0x7FFF;
|
||||
$R = $id & 0x7FFF;
|
||||
// 3 Feistel rounds
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$tmp = $L ^ self::feistelRound($R, $i);
|
||||
$L = $R;
|
||||
$R = $tmp;
|
||||
}
|
||||
$n = (($L & 0x7FFF) << 15) | ($R & 0x7FFF);
|
||||
$base = strlen(self::URL_ALPHABET);
|
||||
$out = '';
|
||||
do {
|
||||
$out = self::URL_ALPHABET[$n % $base] . $out;
|
||||
$n = intdiv($n, $base);
|
||||
} while ($n > 0);
|
||||
// Pad to fixed length so all URLs look uniform
|
||||
return str_pad($out, 5, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
public static function decodeId(string $encoded): ?int
|
||||
{
|
||||
$base = strlen(self::URL_ALPHABET);
|
||||
$n = 0;
|
||||
for ($i = 0, $len = strlen($encoded); $i < $len; $i++) {
|
||||
$pos = strpos(self::URL_ALPHABET, $encoded[$i]);
|
||||
if ($pos === false) return null;
|
||||
$n = $n * $base + $pos;
|
||||
}
|
||||
// Reverse Feistel
|
||||
$L = ($n >> 15) & 0x7FFF;
|
||||
$R = $n & 0x7FFF;
|
||||
for ($i = 2; $i >= 0; $i--) {
|
||||
$tmp = $R ^ self::feistelRound($L, $i);
|
||||
$R = $L;
|
||||
$L = $tmp;
|
||||
}
|
||||
return (($L & 0x7FFF) << 15) | ($R & 0x7FFF);
|
||||
}
|
||||
|
||||
// Route model binding — use encoded ID in URLs
|
||||
public function getRouteKey()
|
||||
{
|
||||
return self::encodeId($this->id);
|
||||
}
|
||||
|
||||
public function getRouteKeyName()
|
||||
{
|
||||
return 'id';
|
||||
}
|
||||
|
||||
public function resolveRouteBinding($value, $field = null)
|
||||
{
|
||||
$realId = self::decodeId((string) $value);
|
||||
if ($realId === null || $realId <= 0) {
|
||||
abort(404);
|
||||
}
|
||||
return $this->where('id', $realId)->firstOrFail();
|
||||
}
|
||||
|
||||
// All share URLs use the unguessable token route
|
||||
public function getShareUrlAttribute()
|
||||
{
|
||||
return route('videos.show', $this->id);
|
||||
return route('videos.showByToken', $this->share_token);
|
||||
}
|
||||
|
||||
// Get formatted duration (e.g., "1:30" or "0:45" for shorts)
|
||||
@ -213,6 +336,12 @@ class Video extends Model
|
||||
return $this->type === 'match';
|
||||
}
|
||||
|
||||
public function isAudioOnly(): bool
|
||||
{
|
||||
$ext = strtolower(pathinfo($this->filename ?? '', PATHINFO_EXTENSION));
|
||||
return in_array($ext, ['mp3', 'm4a', 'aac', 'flac', 'wav']);
|
||||
}
|
||||
|
||||
// Shorts helpers
|
||||
public function isShorts()
|
||||
{
|
||||
@ -331,13 +460,13 @@ class Video extends Model
|
||||
// Get video stream URL for Open Graph
|
||||
public function getStreamUrlAttribute()
|
||||
{
|
||||
return route('videos.stream', $this->id);
|
||||
return route('videos.stream', $this);
|
||||
}
|
||||
|
||||
// Get secure share URL
|
||||
public function getSecureShareUrlAttribute()
|
||||
{
|
||||
return secure_url(route('videos.show', $this->id));
|
||||
return secure_url(route('videos.show', $this));
|
||||
}
|
||||
|
||||
// Get secure thumbnail URL
|
||||
|
||||
26
app/Models/VideoShare.php
Normal file
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');
|
||||
}
|
||||
}
|
||||
42
app/Models/VideoSlide.php
Normal file
42
app/Models/VideoSlide.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?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 route('media.thumbnail', $this->filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path to the slide image on local disk.
|
||||
* Old format: storage/app/public/thumbnails/{filename}
|
||||
* New format: storage/app/{relative-path} (filename contains a slash)
|
||||
*/
|
||||
public function localPath(): string
|
||||
{
|
||||
return str_contains($this->filename, '/')
|
||||
? storage_path('app/' . $this->filename)
|
||||
: storage_path('app/public/thumbnails/' . $this->filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage::delete()-compatible key for this slide file.
|
||||
*/
|
||||
public function storageKey(): string
|
||||
{
|
||||
return str_contains($this->filename, '/')
|
||||
? $this->filename
|
||||
: 'public/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\Facades\URL;
|
||||
use Illuminate\Support\Facades\Request;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@ -27,5 +28,31 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
// Force HTTPS
|
||||
URL::forceScheme('https');
|
||||
|
||||
// Universal pagination view — used everywhere by default
|
||||
Paginator::defaultView('partials.pagination');
|
||||
Paginator::defaultSimpleView('partials.pagination');
|
||||
|
||||
// Merge NAS credentials stored in the DB into the package config at runtime.
|
||||
// This way the live browser and all NAS operations use DB values without needing .env.
|
||||
$this->app->booted(function () {
|
||||
try {
|
||||
$host = \App\Models\Setting::get('nas_host', '');
|
||||
if ($host) {
|
||||
config([
|
||||
'nas-file-manager.connection.protocol' => \App\Models\Setting::get('nas_protocol', 'smb'),
|
||||
'nas-file-manager.connection.host' => $host,
|
||||
'nas-file-manager.connection.port' => (int) \App\Models\Setting::get('nas_port', 445),
|
||||
'nas-file-manager.connection.username' => \App\Models\Setting::get('nas_username', ''),
|
||||
'nas-file-manager.connection.password' => \App\Models\Setting::get('nas_password', ''),
|
||||
'nas-file-manager.connection.path' => \App\Models\Setting::get('nas_path', '/media'),
|
||||
'nas-file-manager.connection.smb_share' => \App\Models\Setting::get('nas_smb_share', ''),
|
||||
'nas-file-manager.connection.smb_domain' => \App\Models\Setting::get('nas_smb_domain', ''),
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// DB may not exist yet (fresh install / migrations not run) — silently skip
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
});
|
||||
}
|
||||
}
|
||||
1107
app/Services/NasSyncService.php
Normal file
1107
app/Services/NasSyncService.php
Normal file
File diff suppressed because it is too large
Load Diff
@ -6,11 +6,15 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.1",
|
||||
"bacon/bacon-qr-code": "^3.1",
|
||||
"doctrine/dbal": "^3.0",
|
||||
"guzzlehttp/guzzle": "^7.2",
|
||||
"laravel/framework": "^10.10",
|
||||
"laravel/sanctum": "^3.3",
|
||||
"laravel/tinker": "^2.8",
|
||||
"php-ffmpeg/php-ffmpeg": "^1.4"
|
||||
"p7h/nas-file-manager": "dev-main",
|
||||
"php-ffmpeg/php-ffmpeg": "^1.4",
|
||||
"pragmarx/google2fa-laravel": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.9.1",
|
||||
@ -62,6 +66,12 @@
|
||||
"php-http/discovery": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "git@github.com:itsp7h/File-Structure-package.git"
|
||||
}
|
||||
],
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true
|
||||
}
|
||||
|
||||
683
composer.lock
generated
683
composer.lock
generated
@ -4,8 +4,63 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "1e4c8a43b8df70dffbd0107a8308b2ec",
|
||||
"content-hash": "40fa327b55e9b6fafab4b2da3f763724",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
"version": "v3.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Bacon/BaconQrCode.git",
|
||||
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
|
||||
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dasprid/enum": "^1.0.3",
|
||||
"ext-iconv": "*",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phly/keep-a-changelog": "^2.12",
|
||||
"phpunit/phpunit": "^10.5.11 || ^11.0.4",
|
||||
"spatie/phpunit-snapshot-assertions": "^5.1.5",
|
||||
"spatie/pixelmatch-php": "^1.2.0",
|
||||
"squizlabs/php_codesniffer": "^3.9"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-imagick": "to generate QR code images"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"BaconQrCode\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-2-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ben Scholzen 'DASPRiD'",
|
||||
"email": "mail@dasprids.de",
|
||||
"homepage": "https://dasprids.de/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "BaconQrCode is a QR code generator for PHP.",
|
||||
"homepage": "https://github.com/Bacon/BaconQrCode",
|
||||
"support": {
|
||||
"issues": "https://github.com/Bacon/BaconQrCode/issues",
|
||||
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1"
|
||||
},
|
||||
"time": "2026-04-05T21:06:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.12.3",
|
||||
@ -135,6 +190,56 @@
|
||||
],
|
||||
"time": "2023-12-11T17:09:12+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dasprid/enum",
|
||||
"version": "1.0.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/DASPRiD/Enum.git",
|
||||
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
|
||||
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1 <9.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
|
||||
"squizlabs/php_codesniffer": "*"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"DASPRiD\\Enum\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-2-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ben Scholzen 'DASPRiD'",
|
||||
"email": "mail@dasprids.de",
|
||||
"homepage": "https://dasprids.de/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "PHP 7.1 enum implementation",
|
||||
"keywords": [
|
||||
"enum",
|
||||
"map"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/DASPRiD/Enum/issues",
|
||||
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
|
||||
},
|
||||
"time": "2025-09-16T12:23:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dflydev/dot-access-data",
|
||||
"version": "v3.0.3",
|
||||
@ -210,6 +315,259 @@
|
||||
},
|
||||
"time": "2024-07-08T12:26:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/dbal",
|
||||
"version": "3.10.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/dbal.git",
|
||||
"reference": "95d84866bf3c04b2ddca1df7c049714660959aef"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/dbal/zipball/95d84866bf3c04b2ddca1df7c049714660959aef",
|
||||
"reference": "95d84866bf3c04b2ddca1df7c049714660959aef",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer-runtime-api": "^2",
|
||||
"doctrine/deprecations": "^0.5.3|^1",
|
||||
"doctrine/event-manager": "^1|^2",
|
||||
"php": "^7.4 || ^8.0",
|
||||
"psr/cache": "^1|^2|^3",
|
||||
"psr/log": "^1|^2|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"doctrine/cache": "< 1.11"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/cache": "^1.11|^2.0",
|
||||
"doctrine/coding-standard": "14.0.0",
|
||||
"fig/log-test": "^1",
|
||||
"jetbrains/phpstorm-stubs": "2023.1",
|
||||
"phpstan/phpstan": "2.1.30",
|
||||
"phpstan/phpstan-strict-rules": "^2",
|
||||
"phpunit/phpunit": "9.6.34",
|
||||
"slevomat/coding-standard": "8.27.1",
|
||||
"squizlabs/php_codesniffer": "4.0.1",
|
||||
"symfony/cache": "^5.4|^6.0|^7.0|^8.0",
|
||||
"symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"suggest": {
|
||||
"symfony/console": "For helpful console commands such as SQL execution and import of files."
|
||||
},
|
||||
"bin": [
|
||||
"bin/doctrine-dbal"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Doctrine\\DBAL\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Guilherme Blanco",
|
||||
"email": "guilhermeblanco@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Roman Borschel",
|
||||
"email": "roman@code-factory.org"
|
||||
},
|
||||
{
|
||||
"name": "Benjamin Eberlei",
|
||||
"email": "kontakt@beberlei.de"
|
||||
},
|
||||
{
|
||||
"name": "Jonathan Wage",
|
||||
"email": "jonwage@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.",
|
||||
"homepage": "https://www.doctrine-project.org/projects/dbal.html",
|
||||
"keywords": [
|
||||
"abstraction",
|
||||
"database",
|
||||
"db2",
|
||||
"dbal",
|
||||
"mariadb",
|
||||
"mssql",
|
||||
"mysql",
|
||||
"oci8",
|
||||
"oracle",
|
||||
"pdo",
|
||||
"pgsql",
|
||||
"postgresql",
|
||||
"queryobject",
|
||||
"sasql",
|
||||
"sql",
|
||||
"sqlite",
|
||||
"sqlserver",
|
||||
"sqlsrv"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/dbal/issues",
|
||||
"source": "https://github.com/doctrine/dbal/tree/3.10.5"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://www.doctrine-project.org/sponsorship.html",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/phpdoctrine",
|
||||
"type": "patreon"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-24T08:03:57+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/deprecations",
|
||||
"version": "1.1.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/deprecations.git",
|
||||
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
|
||||
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpunit/phpunit": "<=7.5 || >=14"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/coding-standard": "^9 || ^12 || ^14",
|
||||
"phpstan/phpstan": "1.4.10 || 2.1.30",
|
||||
"phpstan/phpstan-phpunit": "^1.0 || ^2",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
|
||||
"psr/log": "^1 || ^2 || ^3"
|
||||
},
|
||||
"suggest": {
|
||||
"psr/log": "Allows logging deprecations via PSR-3 logger implementation"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Doctrine\\Deprecations\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
|
||||
"homepage": "https://www.doctrine-project.org/",
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/deprecations/issues",
|
||||
"source": "https://github.com/doctrine/deprecations/tree/1.1.6"
|
||||
},
|
||||
"time": "2026-02-07T07:09:04+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/event-manager",
|
||||
"version": "2.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/event-manager.git",
|
||||
"reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf",
|
||||
"reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"conflict": {
|
||||
"doctrine/common": "<2.9"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/coding-standard": "^14",
|
||||
"phpdocumentor/guides-cli": "^1.4",
|
||||
"phpstan/phpstan": "^2.1.32",
|
||||
"phpunit/phpunit": "^10.5.58"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Doctrine\\Common\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Guilherme Blanco",
|
||||
"email": "guilhermeblanco@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Roman Borschel",
|
||||
"email": "roman@code-factory.org"
|
||||
},
|
||||
{
|
||||
"name": "Benjamin Eberlei",
|
||||
"email": "kontakt@beberlei.de"
|
||||
},
|
||||
{
|
||||
"name": "Jonathan Wage",
|
||||
"email": "jonwage@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Johannes Schmitt",
|
||||
"email": "schmittjoh@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Marco Pivetta",
|
||||
"email": "ocramius@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.",
|
||||
"homepage": "https://www.doctrine-project.org/projects/event-manager.html",
|
||||
"keywords": [
|
||||
"event",
|
||||
"event dispatcher",
|
||||
"event manager",
|
||||
"event system",
|
||||
"events"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/event-manager/issues",
|
||||
"source": "https://github.com/doctrine/event-manager/tree/2.1.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://www.doctrine-project.org/sponsorship.html",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/phpdoctrine",
|
||||
"type": "patreon"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-01-29T07:11:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/inflector",
|
||||
"version": "2.1.0",
|
||||
@ -2444,6 +2802,128 @@
|
||||
],
|
||||
"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": "44462665a31200d2ff791557bafc970fdf07a6c2"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/itsp7h/File-Structure-package/zipball/44462665a31200d2ff791557bafc970fdf07a6c2",
|
||||
"reference": "44462665a31200d2ff791557bafc970fdf07a6c2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/support": "^10.0|^11.0|^12.0",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"composer/composer": "^2.0"
|
||||
},
|
||||
"default-branch": true,
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"P7H\\NasFileManager\\NasFileManagerServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"P7H\\NasFileManager\\": "src/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"post-install-cmd": [
|
||||
"P7H\\NasFileManager\\Installer::install"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"P7H\\NasFileManager\\Installer::install"
|
||||
]
|
||||
},
|
||||
"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-14T12:07:04+00:00"
|
||||
},
|
||||
{
|
||||
"name": "paragonie/constant_time_encoding",
|
||||
"version": "v3.1.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paragonie/constant_time_encoding.git",
|
||||
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
|
||||
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8"
|
||||
},
|
||||
"require-dev": {
|
||||
"infection/infection": "^0",
|
||||
"nikic/php-fuzzer": "^0",
|
||||
"phpunit/phpunit": "^9|^10|^11",
|
||||
"vimeo/psalm": "^4|^5|^6"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"ParagonIE\\ConstantTime\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Paragon Initiative Enterprises",
|
||||
"email": "security@paragonie.com",
|
||||
"homepage": "https://paragonie.com",
|
||||
"role": "Maintainer"
|
||||
},
|
||||
{
|
||||
"name": "Steve 'Sc00bz' Thomas",
|
||||
"email": "steve@tobtu.com",
|
||||
"homepage": "https://www.tobtu.com",
|
||||
"role": "Original Developer"
|
||||
}
|
||||
],
|
||||
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
|
||||
"keywords": [
|
||||
"base16",
|
||||
"base32",
|
||||
"base32_decode",
|
||||
"base32_encode",
|
||||
"base64",
|
||||
"base64_decode",
|
||||
"base64_encode",
|
||||
"bin2hex",
|
||||
"encoding",
|
||||
"hex",
|
||||
"hex2bin",
|
||||
"rfc4648"
|
||||
],
|
||||
"support": {
|
||||
"email": "info@paragonie.com",
|
||||
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
|
||||
"source": "https://github.com/paragonie/constant_time_encoding"
|
||||
},
|
||||
"time": "2025-09-24T15:06:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "php-ffmpeg/php-ffmpeg",
|
||||
"version": "v1.4.0",
|
||||
@ -2608,6 +3088,201 @@
|
||||
],
|
||||
"time": "2025-12-27T19:41:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "pragmarx/google2fa",
|
||||
"version": "v8.0.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/antonioribeiro/google2fa.git",
|
||||
"reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad",
|
||||
"reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"paragonie/constant_time_encoding": "^1.0|^2.0|^3.0",
|
||||
"php": "^7.1|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.9",
|
||||
"phpunit/phpunit": "^7.5.15|^8.5|^9.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PragmaRX\\Google2FA\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Antonio Carlos Ribeiro",
|
||||
"email": "acr@antoniocarlosribeiro.com",
|
||||
"role": "Creator & Designer"
|
||||
}
|
||||
],
|
||||
"description": "A One Time Password Authentication package, compatible with Google Authenticator.",
|
||||
"keywords": [
|
||||
"2fa",
|
||||
"Authentication",
|
||||
"Two Factor Authentication",
|
||||
"google2fa"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/antonioribeiro/google2fa/issues",
|
||||
"source": "https://github.com/antonioribeiro/google2fa/tree/v8.0.3"
|
||||
},
|
||||
"time": "2024-09-05T11:56:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "pragmarx/google2fa-laravel",
|
||||
"version": "v3.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/antonioribeiro/google2fa-laravel.git",
|
||||
"reference": "d885bb5bca8be03b226d040aa80250402760a67c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/antonioribeiro/google2fa-laravel/zipball/d885bb5bca8be03b226d040aa80250402760a67c",
|
||||
"reference": "d885bb5bca8be03b226d040aa80250402760a67c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"laravel/framework": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||
"php": ">=7.0",
|
||||
"pragmarx/google2fa-qrcode": "^1.0|^2.0|^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"bacon/bacon-qr-code": "^2.0",
|
||||
"orchestra/testbench": "3.4.*|3.5.*|3.6.*|3.7.*|4.*|5.*|6.*|7.*|8.*|9.*|10.*|11.*",
|
||||
"phpunit/phpunit": "~5|~6|~7|~8|~9|~10|~11|~12"
|
||||
},
|
||||
"suggest": {
|
||||
"bacon/bacon-qr-code": "Required to generate inline QR Codes.",
|
||||
"pragmarx/recovery": "Generate recovery codes."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"Google2FA": "PragmaRX\\Google2FALaravel\\Facade"
|
||||
},
|
||||
"providers": [
|
||||
"PragmaRX\\Google2FALaravel\\ServiceProvider"
|
||||
]
|
||||
},
|
||||
"component": "package",
|
||||
"frameworks": [
|
||||
"Laravel"
|
||||
],
|
||||
"branch-alias": {
|
||||
"dev-master": "0.2-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PragmaRX\\Google2FALaravel\\": "src/",
|
||||
"PragmaRX\\Google2FALaravel\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Antonio Carlos Ribeiro",
|
||||
"email": "acr@antoniocarlosribeiro.com",
|
||||
"role": "Creator & Designer"
|
||||
}
|
||||
],
|
||||
"description": "A One Time Password Authentication package, compatible with Google Authenticator.",
|
||||
"keywords": [
|
||||
"Authentication",
|
||||
"Two Factor Authentication",
|
||||
"google2fa",
|
||||
"laravel"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/antonioribeiro/google2fa-laravel/issues",
|
||||
"source": "https://github.com/antonioribeiro/google2fa-laravel/tree/v3.0.1"
|
||||
},
|
||||
"time": "2026-03-17T20:54:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "pragmarx/google2fa-qrcode",
|
||||
"version": "v3.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/antonioribeiro/google2fa-qrcode.git",
|
||||
"reference": "c23ebcc3a50de0d1566016a6dd1486e183bb78e1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/antonioribeiro/google2fa-qrcode/zipball/c23ebcc3a50de0d1566016a6dd1486e183bb78e1",
|
||||
"reference": "c23ebcc3a50de0d1566016a6dd1486e183bb78e1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1",
|
||||
"pragmarx/google2fa": "^4.0|^5.0|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"bacon/bacon-qr-code": "^2.0",
|
||||
"chillerlan/php-qrcode": "^1.0|^2.0|^3.0|^4.0",
|
||||
"khanamiryan/qrcode-detector-decoder": "^1.0",
|
||||
"phpunit/phpunit": "~4|~5|~6|~7|~8|~9"
|
||||
},
|
||||
"suggest": {
|
||||
"bacon/bacon-qr-code": "For QR Code generation, requires imagick",
|
||||
"chillerlan/php-qrcode": "For QR Code generation"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"component": "package",
|
||||
"branch-alias": {
|
||||
"dev-master": "1.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PragmaRX\\Google2FAQRCode\\": "src/",
|
||||
"PragmaRX\\Google2FAQRCode\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Antonio Carlos Ribeiro",
|
||||
"email": "acr@antoniocarlosribeiro.com",
|
||||
"role": "Creator & Designer"
|
||||
}
|
||||
],
|
||||
"description": "QR Code package for Google2FA",
|
||||
"keywords": [
|
||||
"2fa",
|
||||
"Authentication",
|
||||
"Two Factor Authentication",
|
||||
"google2fa",
|
||||
"qr code",
|
||||
"qrcode"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/antonioribeiro/google2fa-qrcode/issues",
|
||||
"source": "https://github.com/antonioribeiro/google2fa-qrcode/tree/v3.0.1"
|
||||
},
|
||||
"time": "2025-09-19T23:02:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/cache",
|
||||
"version": "3.0.0",
|
||||
@ -8699,8 +9374,10 @@
|
||||
}
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"minimum-stability": "dev",
|
||||
"stability-flags": {
|
||||
"p7h/nas-file-manager": 20
|
||||
},
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
|
||||
@ -6,8 +6,8 @@ return [
|
||||
| FFmpeg Binaries
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'ffmpeg' => '/usr/bin/ffmpeg',
|
||||
'ffprobe' => '/usr/bin/ffprobe',
|
||||
'ffmpeg' => '/usr/lib/jellyfin-ffmpeg/ffmpeg',
|
||||
'ffprobe' => '/usr/lib/jellyfin-ffmpeg/ffprobe',
|
||||
'timeout' => 3600,
|
||||
'thread_number' => 0,
|
||||
// auto-detect cores
|
||||
|
||||
80
config/nas-file-manager.php
Normal file
80
config/nas-file-manager.php
Normal file
@ -0,0 +1,80 @@
|
||||
<?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' => [
|
||||
|
||||
// ── users ─────────────────────────────────────────────────────────────
|
||||
['depth' => 0, 'label' => 'users', 'path' => 'users', 'parent_path' => null, 'is_template' => false, 'can_edit' => false],
|
||||
['depth' => 1, 'label' => '{username}', 'path' => 'users/{username}', 'parent_path' => 'users', 'is_template' => true, 'can_edit' => false],
|
||||
|
||||
// ── profile ───────────────────────────────────────────────────────────
|
||||
['depth' => 2, 'label' => 'profile', 'path' => 'u/profile', 'parent_path' => 'users/{username}', 'is_template' => false, 'can_edit' => false],
|
||||
['depth' => 3, 'label' => 'avatar.webp', 'path' => 'profile/avatar.webp', 'parent_path' => 'u/profile', 'is_template' => false, 'can_edit' => false],
|
||||
['depth' => 3, 'label' => 'cover.webp', 'path' => 'profile/cover.webp', 'parent_path' => 'u/profile', 'is_template' => false, 'can_edit' => false],
|
||||
|
||||
// ── videos ────────────────────────────────────────────────────────────
|
||||
['depth' => 2, 'label' => 'videos', 'path' => 'u/videos', 'parent_path' => 'users/{username}', 'is_template' => false, 'can_edit' => false],
|
||||
['depth' => 3, 'label' => '{video-slug}', 'path' => 'videos/{video-slug}', 'parent_path' => 'u/videos', 'is_template' => true, 'can_edit' => false],
|
||||
['depth' => 4, 'label' => '{video-slug}.{ext}', 'path' => 'vid/file', 'parent_path' => 'videos/{video-slug}', 'is_template' => true, 'can_edit' => false],
|
||||
['depth' => 4, 'label' => 'thumb.webp', 'path' => 'vid/thumb.webp', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
|
||||
['depth' => 4, 'label' => 'meta.json', 'path' => 'vid/meta.json', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
|
||||
['depth' => 4, 'label' => 'view-log.json', 'path' => 'vid/view-log.json', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
|
||||
['depth' => 4, 'label' => 'edit-log.json', 'path' => 'vid/edit-log.json', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
|
||||
['depth' => 4, 'label' => 'slides', 'path' => 'vid/slides', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
|
||||
['depth' => 5, 'label' => '{position}.{ext}','path' => 'slide/file', 'parent_path' => 'vid/slides', 'is_template' => true, 'can_edit' => false],
|
||||
|
||||
// ── posts ─────────────────────────────────────────────────────────────
|
||||
['depth' => 2, 'label' => 'posts', 'path' => 'u/posts', 'parent_path' => 'users/{username}', 'is_template' => false, 'can_edit' => false],
|
||||
['depth' => 3, 'label' => '{post_id}', 'path' => 'posts/{post_id}', 'parent_path' => 'u/posts', 'is_template' => true, 'can_edit' => false],
|
||||
['depth' => 4, 'label' => '{n}.{ext}', 'path' => 'post/image', 'parent_path' => 'posts/{post_id}', 'is_template' => true, 'can_edit' => false],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@ -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('page_title', 'Edit User')
|
||||
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
.ef-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.ef-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── Form fields ── */
|
||||
.ef-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; }
|
||||
.ef-field:last-of-type { margin-bottom: 0; }
|
||||
.ef-label {
|
||||
font-size: 12px; font-weight: 600; color: var(--text-2);
|
||||
text-transform: uppercase; letter-spacing: .04em;
|
||||
}
|
||||
.ef-input, .ef-select, .ef-textarea {
|
||||
width: 100%;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ef-input, .ef-select { height: 38px; padding: 0 12px; }
|
||||
.ef-select { cursor: pointer; appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23888' viewBox='0 0 16 16'%3E%3Cpath d='M7.247 11.14L2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px;
|
||||
}
|
||||
.ef-textarea { height: 90px; padding: 10px 12px; resize: vertical; }
|
||||
.ef-input:focus, .ef-select:focus, .ef-textarea:focus { border-color: var(--brand); }
|
||||
.ef-input.is-invalid, .ef-select.is-invalid { border-color: #f87171; }
|
||||
.ef-error { font-size: 12px; color: #f87171; margin-top: 2px; }
|
||||
|
||||
.ef-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
@media (max-width: 500px) { .ef-row { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ── Section divider ── */
|
||||
.ef-section {
|
||||
margin: 24px 0 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.ef-section-title {
|
||||
font-size: 13px; font-weight: 600; color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ef-section-hint { font-size: 12px; color: var(--text-3); }
|
||||
|
||||
/* ── Actions ── */
|
||||
.ef-actions { display: flex; gap: 10px; margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border); }
|
||||
|
||||
/* ── Sidebar info card ── */
|
||||
.ef-user-avatar {
|
||||
width: 72px; height: 72px; border-radius: 50%; object-fit: cover;
|
||||
border: 3px solid var(--border);
|
||||
}
|
||||
.ef-stat-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,.04);
|
||||
font-size: 13px;
|
||||
}
|
||||
.ef-stat-row:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.ef-stat-label { color: var(--text-2); }
|
||||
.ef-stat-val { font-weight: 600; color: var(--text); }
|
||||
|
||||
/* ── Danger zone ── */
|
||||
.ef-danger-zone {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(248,113,113,.25);
|
||||
border-radius: 10px;
|
||||
background: rgba(248,113,113,.05);
|
||||
}
|
||||
.ef-danger-title { font-size: 12px; font-weight: 700; color: #f87171; margin-bottom: 10px; text-transform: uppercase; letter-spacing: .05em; }
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<!-- Alerts -->
|
||||
|
||||
{{-- ── Page header ── --}}
|
||||
<div class="adm-page-header" style="margin-bottom:20px;">
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<a href="{{ route('admin.users') }}" class="adm-btn adm-btn-sm">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<h1 class="adm-page-title" style="margin:0;">
|
||||
<i class="bi bi-person-gear"></i> Edit User
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Alerts ── --}}
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
{{ session('success') }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
<div class="adm-alert adm-alert-success" style="margin-bottom:16px;">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<span>{{ session('success') }}</span>
|
||||
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
{{ session('error') }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
<div class="adm-alert adm-alert-error" style="margin-bottom:16px;">
|
||||
<i class="bi bi-exclamation-circle-fill"></i>
|
||||
<span>{{ session('error') }}</span>
|
||||
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<!-- Edit User Form -->
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h5 class="admin-card-title">Edit User</h5>
|
||||
<a href="{{ route('admin.users') }}" class="btn btn-outline-light btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Back to Users
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="ef-grid">
|
||||
|
||||
{{-- ── Left: edit form ── --}}
|
||||
<div class="adm-card">
|
||||
<div class="adm-card-header">
|
||||
<div class="adm-card-title"><i class="bi bi-pencil-square"></i> Account Details</div>
|
||||
</div>
|
||||
<div class="adm-card-body">
|
||||
<form method="POST" action="{{ route('admin.users.update', $user->id) }}">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Name</label>
|
||||
<input type="text" class="form-control @error('name') is-invalid @enderror" id="name" name="name" value="{{ old('name', $user->name) }}" required>
|
||||
@error('name')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
|
||||
{{-- Name --}}
|
||||
<div class="ef-field">
|
||||
<label class="ef-label" for="name">Full Name</label>
|
||||
<input class="ef-input @error('name') is-invalid @enderror"
|
||||
id="name" name="name" type="text"
|
||||
value="{{ old('name', $user->name) }}" required autocomplete="off">
|
||||
@error('name')<div class="ef-error">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control @error('email') is-invalid @enderror" id="email" name="email" value="{{ old('email', $user->email) }}" required>
|
||||
@error('email')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
|
||||
{{-- Email --}}
|
||||
<div class="ef-field">
|
||||
<label class="ef-label" for="email">Email Address</label>
|
||||
<input class="ef-input @error('email') is-invalid @enderror"
|
||||
id="email" name="email" type="email"
|
||||
value="{{ old('email', $user->email) }}" required autocomplete="off">
|
||||
@error('email')<div class="ef-error">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="role" class="form-label">Role</label>
|
||||
<select class="form-select @error('role') is-invalid @enderror" id="role" name="role" required>
|
||||
<option value="user" {{ old('role', $user->role) == 'user' ? 'selected' : '' }}>User</option>
|
||||
<option value="admin" {{ old('role', $user->role) == 'admin' ? 'selected' : '' }}>Admin</option>
|
||||
<option value="super_admin" {{ old('role', $user->role) == 'super_admin' ? 'selected' : '' }}>Super Admin</option>
|
||||
|
||||
{{-- Role --}}
|
||||
<div class="ef-field">
|
||||
<label class="ef-label" for="role">Role</label>
|
||||
<select class="ef-select @error('role') is-invalid @enderror" id="role" name="role">
|
||||
<option value="user" {{ old('role', $user->role) === 'user' ? 'selected' : '' }}>User</option>
|
||||
<option value="admin" {{ old('role', $user->role) === 'admin' ? 'selected' : '' }}>Admin</option>
|
||||
<option value="super_admin" {{ old('role', $user->role) === 'super_admin' ? 'selected' : '' }}>Super Admin</option>
|
||||
</select>
|
||||
@error('role')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
@error('role')<div class="ef-error">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<hr style="border-color: var(--border-color);">
|
||||
|
||||
<h6 class="mb-3">Change Password (Optional)</h6>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="new_password" class="form-label">New Password</label>
|
||||
<input type="password" class="form-control @error('new_password') is-invalid @enderror" id="new_password" name="new_password" placeholder="Leave blank to keep current password">
|
||||
@error('new_password')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
|
||||
{{-- Password section --}}
|
||||
<div class="ef-section">
|
||||
<span class="ef-section-title"><i class="bi bi-lock-fill" style="color:var(--brand);"></i> Change Password</span>
|
||||
<span class="ef-section-hint">— leave blank to keep current password</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="new_password_confirmation" class="form-label">Confirm New Password</label>
|
||||
<input type="password" class="form-control" id="new_password_confirmation" name="new_password_confirmation" placeholder="Confirm new password">
|
||||
|
||||
<div class="ef-row">
|
||||
<div class="ef-field" style="margin-bottom:0;">
|
||||
<label class="ef-label" for="new_password">New Password</label>
|
||||
<input class="ef-input @error('new_password') is-invalid @enderror"
|
||||
id="new_password" name="new_password" type="password"
|
||||
placeholder="Min. 8 characters" autocomplete="new-password">
|
||||
@error('new_password')<div class="ef-error">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
<div class="ef-field" style="margin-bottom:0;">
|
||||
<label class="ef-label" for="new_password_confirmation">Confirm Password</label>
|
||||
<input class="ef-input"
|
||||
id="new_password_confirmation" name="new_password_confirmation"
|
||||
type="password" placeholder="Repeat password" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle"></i> Update User
|
||||
|
||||
<div class="ef-actions">
|
||||
<button type="submit" class="adm-btn adm-btn-primary">
|
||||
<i class="bi bi-check-circle-fill"></i> Save Changes
|
||||
</button>
|
||||
<a href="{{ route('admin.users') }}" class="btn btn-outline-light">Cancel</a>
|
||||
<a href="{{ route('admin.users') }}" class="adm-btn">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<!-- User Info -->
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h5 class="admin-card-title">User Info</h5>
|
||||
</div>
|
||||
|
||||
<div class="text-center mb-3">
|
||||
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="rounded-circle" style="width: 80px; height: 80px; object-fit: cover;">
|
||||
<h5 class="mt-2">{{ $user->name }}</h5>
|
||||
<p class="text-secondary mb-1">{{ $user->email }}</p>
|
||||
|
||||
{{-- ── Right: user info sidebar ── --}}
|
||||
<div style="display:flex;flex-direction:column;gap:16px;">
|
||||
|
||||
{{-- Profile card --}}
|
||||
<div class="adm-card">
|
||||
<div class="adm-card-body" style="text-align:center;padding-top:28px;padding-bottom:24px;">
|
||||
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="ef-user-avatar">
|
||||
<div style="margin-top:12px;font-size:16px;font-weight:700;color:var(--text);">{{ $user->name }}</div>
|
||||
<div style="font-size:12px;color:var(--text-2);margin:4px 0 10px;">{{ $user->email }}</div>
|
||||
@if($user->role === 'super_admin')
|
||||
<span class="badge-role badge-super-admin">Super Admin</span>
|
||||
<span class="adm-badge adm-badge-superadmin">Super Admin</span>
|
||||
@elseif($user->role === 'admin')
|
||||
<span class="badge-role badge-admin">Admin</span>
|
||||
<span class="adm-badge adm-badge-admin">Admin</span>
|
||||
@else
|
||||
<span class="badge-role badge-user">User</span>
|
||||
<span class="adm-badge adm-badge-user">User</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<hr style="border-color: var(--border-color);">
|
||||
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">User ID</span>
|
||||
<span>#{{ $user->id }}</span>
|
||||
</div>
|
||||
|
||||
{{-- 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="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">Joined</span>
|
||||
<span>{{ $user->created_at->format('M d, Y') }}</span>
|
||||
<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 class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">Total Videos</span>
|
||||
<span>{{ $user->videos->count() }}</span>
|
||||
</div>
|
||||
|
||||
{{-- Impersonate --}}
|
||||
@if(!$user->isSuperAdmin())
|
||||
<form method="POST" action="{{ route('admin.users.impersonate', $user->id) }}">
|
||||
@csrf
|
||||
<button type="submit" class="adm-btn adm-btn-impersonate" style="width:100%;">
|
||||
<i class="bi bi-person-badge"></i> Impersonate User
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
{{-- Danger zone --}}
|
||||
@if(!$user->isSuperAdmin())
|
||||
<div class="ef-danger-zone">
|
||||
<div class="ef-danger-title"><i class="bi bi-exclamation-triangle-fill me-1"></i> Danger Zone</div>
|
||||
<button type="button" class="adm-btn adm-btn-danger" style="width:100%;"
|
||||
onclick="openDelDialog()">
|
||||
<i class="bi bi-trash3-fill"></i> Delete This User
|
||||
</button>
|
||||
</div>
|
||||
@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 class="d-flex justify-content-between">
|
||||
<span class="text-secondary">Email Verified</span>
|
||||
<span>{{ $user->email_verified_at ? 'Yes' : 'No' }}</span>
|
||||
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDelDialog()"
|
||||
style="border:none;background:none;color:var(--text-2);">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="adm-dialog-body">
|
||||
<p>You are about to permanently delete <strong>{{ $user->name }}</strong>.</p>
|
||||
<div class="adm-dialog-warning">
|
||||
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
|
||||
All videos uploaded by this user will also be deleted. This cannot be undone.
|
||||
</div>
|
||||
</div>
|
||||
<div class="adm-dialog-footer">
|
||||
<button type="button" class="adm-btn" onclick="closeDelDialog()">Cancel</button>
|
||||
<form method="POST" action="{{ route('admin.users.delete', $user->id) }}">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
|
||||
<i class="bi bi-trash3-fill"></i> Delete Permanently
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
function openDelDialog() { document.getElementById('delDialog').classList.add('open'); }
|
||||
function closeDelDialog() { document.getElementById('delDialog').classList.remove('open'); }
|
||||
document.getElementById('delDialog')?.addEventListener('click', e => { if (e.target === document.getElementById('delDialog')) closeDelDialog(); });
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDelDialog(); });
|
||||
</script>
|
||||
@endsection
|
||||
|
||||
@ -3,164 +3,486 @@
|
||||
@section('title', 'Edit Video')
|
||||
@section('page_title', 'Edit Video')
|
||||
|
||||
@php
|
||||
$adminNeedsOtp = auth()->user()->two_factor_enabled &&
|
||||
(! session('admin_2fa_verified_at') || now()->timestamp - session('admin_2fa_verified_at') >= 1800);
|
||||
@endphp
|
||||
|
||||
@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; }
|
||||
.adm-btn-warning { color: #fb923c; border-color: rgba(251,146,60,.3); }
|
||||
.adm-btn-warning:hover { background: rgba(251,146,60,.1); border-color: rgba(251,146,60,.5); color: #fdba74; }
|
||||
.adm-btn-warning:disabled { opacity:.5; cursor:not-allowed; pointer-events:none; }
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@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'))
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
{{ session('success') }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
<div class="adm-alert adm-alert-success" style="margin-bottom:16px;">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<span>{{ session('success') }}</span>
|
||||
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
{{ session('error') }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
<div class="adm-alert adm-alert-error" style="margin-bottom:16px;">
|
||||
<i class="bi bi-exclamation-circle-fill"></i>
|
||||
<span>{{ session('error') }}</span>
|
||||
<button class="adm-alert-close"><i class="bi bi-x"></i></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<!-- Edit Video Form -->
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h5 class="admin-card-title">Edit Video</h5>
|
||||
<a href="{{ route('admin.videos') }}" class="btn btn-outline-light btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Back to Videos
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('admin.videos.update', $video->id) }}">
|
||||
<div class="ef-grid">
|
||||
|
||||
{{-- ── Left: form ── --}}
|
||||
<div class="adm-card">
|
||||
<div class="adm-card-header">
|
||||
<div class="adm-card-title"><i class="bi bi-film"></i> Video Details</div>
|
||||
</div>
|
||||
<div class="adm-card-body">
|
||||
<form method="POST" action="{{ route('admin.videos.update', $video) }}">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Title</label>
|
||||
<input type="text" class="form-control @error('title') is-invalid @enderror" id="title" name="title" value="{{ old('title', $video->title) }}" required>
|
||||
@error('title')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
|
||||
{{-- Title --}}
|
||||
<div class="ef-field">
|
||||
<label class="ef-label" for="title">Title</label>
|
||||
<input class="ef-input @error('title') is-invalid @enderror"
|
||||
id="title" name="title" type="text"
|
||||
value="{{ old('title', $video->title) }}" required>
|
||||
@error('title')<div class="ef-error">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea class="form-control @error('description') is-invalid @enderror" id="description" name="description" rows="4">{{ old('description', $video->description) }}</textarea>
|
||||
@error('description')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
|
||||
{{-- Description --}}
|
||||
<div class="ef-field">
|
||||
<label class="ef-label" for="description">Description</label>
|
||||
<textarea class="ef-textarea @error('description') is-invalid @enderror"
|
||||
id="description" name="description">{{ old('description', $video->description) }}</textarea>
|
||||
@error('description')<div class="ef-error">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label for="visibility" class="form-label">Visibility</label>
|
||||
<select class="form-select @error('visibility') is-invalid @enderror" id="visibility" name="visibility" required>
|
||||
<option value="public" {{ old('visibility', $video->visibility) == 'public' ? 'selected' : '' }}>Public</option>
|
||||
<option value="unlisted" {{ old('visibility', $video->visibility) == 'unlisted' ? 'selected' : '' }}>Unlisted</option>
|
||||
<option value="private" {{ old('visibility', $video->visibility) == 'private' ? 'selected' : '' }}>Private</option>
|
||||
|
||||
{{-- Visibility / Type / Status --}}
|
||||
<div class="ef-row-3">
|
||||
<div class="ef-field" style="margin-bottom:0;">
|
||||
<label class="ef-label" for="visibility">Visibility</label>
|
||||
<select class="ef-select @error('visibility') is-invalid @enderror" id="visibility" name="visibility">
|
||||
<option value="public" {{ old('visibility', $video->visibility) === 'public' ? 'selected' : '' }}>Public</option>
|
||||
<option value="unlisted" {{ old('visibility', $video->visibility) === 'unlisted' ? 'selected' : '' }}>Unlisted</option>
|
||||
<option value="private" {{ old('visibility', $video->visibility) === 'private' ? 'selected' : '' }}>Private</option>
|
||||
</select>
|
||||
@error('visibility')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
@error('visibility')<div class="ef-error">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label for="type" class="form-label">Type</label>
|
||||
<select class="form-select @error('type') is-invalid @enderror" id="type" name="type" required>
|
||||
<option value="generic" {{ old('type', $video->type) == 'generic' ? 'selected' : '' }}>Generic</option>
|
||||
<option value="music" {{ old('type', $video->type) == 'music' ? 'selected' : '' }}>Music</option>
|
||||
<option value="match" {{ old('type', $video->type) == 'match' ? 'selected' : '' }}>Match</option>
|
||||
<div class="ef-field" style="margin-bottom:0;">
|
||||
<label class="ef-label" for="type">Type</label>
|
||||
<select class="ef-select @error('type') is-invalid @enderror" id="type" name="type">
|
||||
<option value="generic" {{ old('type', $video->type) === 'generic' ? 'selected' : '' }}>Generic</option>
|
||||
<option value="music" {{ old('type', $video->type) === 'music' ? 'selected' : '' }}>Music</option>
|
||||
<option value="match" {{ old('type', $video->type) === 'match' ? 'selected' : '' }}>Match</option>
|
||||
</select>
|
||||
@error('type')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
@error('type')<div class="ef-error">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label for="status" class="form-label">Status</label>
|
||||
<select class="form-select @error('status') is-invalid @enderror" id="status" name="status" required>
|
||||
<option value="pending" {{ old('status', $video->status) == 'pending' ? 'selected' : '' }}>Pending</option>
|
||||
<option value="processing" {{ old('status', $video->status) == 'processing' ? 'selected' : '' }}>Processing</option>
|
||||
<option value="ready" {{ old('status', $video->status) == 'ready' ? 'selected' : '' }}>Ready</option>
|
||||
<option value="failed" {{ old('status', $video->status) == 'failed' ? 'selected' : '' }}>Failed</option>
|
||||
<div class="ef-field" style="margin-bottom:0;">
|
||||
<label class="ef-label" for="status">Status</label>
|
||||
<select class="ef-select @error('status') is-invalid @enderror" id="status" name="status">
|
||||
<option value="pending" {{ old('status', $video->status) === 'pending' ? 'selected' : '' }}>Pending</option>
|
||||
<option value="processing" {{ old('status', $video->status) === 'processing' ? 'selected' : '' }}>Processing</option>
|
||||
<option value="ready" {{ old('status', $video->status) === 'ready' ? 'selected' : '' }}>Ready</option>
|
||||
<option value="failed" {{ old('status', $video->status) === 'failed' ? 'selected' : '' }}>Failed</option>
|
||||
</select>
|
||||
@error('status')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
@error('status')<div class="ef-error">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle"></i> Update Video
|
||||
|
||||
{{-- Download Access --}}
|
||||
<div class="ef-field" style="margin-top:18px;">
|
||||
<label class="ef-label" for="download_access">Who Can Download</label>
|
||||
<select class="ef-select @error('download_access') is-invalid @enderror" id="download_access" name="download_access">
|
||||
<option value="disabled" {{ old('download_access', $video->download_access) === 'disabled' ? 'selected' : '' }}>No one (disabled)</option>
|
||||
<option value="everyone" {{ old('download_access', $video->download_access) === 'everyone' ? 'selected' : '' }}>Everyone (guests too)</option>
|
||||
<option value="registered" {{ old('download_access', $video->download_access) === 'registered' ? 'selected' : '' }}>Registered members</option>
|
||||
<option value="subscribers" {{ old('download_access', $video->download_access) === 'subscribers' ? 'selected' : '' }}>Subscribers only</option>
|
||||
</select>
|
||||
@error('download_access')<div class="ef-error">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="ef-actions">
|
||||
<button type="submit" class="adm-btn adm-btn-primary">
|
||||
<i class="bi bi-check-circle-fill"></i> Save Changes
|
||||
</button>
|
||||
<a href="{{ route('admin.videos') }}" class="btn btn-outline-light">Cancel</a>
|
||||
<a href="{{ route('admin.videos') }}" class="adm-btn">Cancel</a>
|
||||
<a href="{{ route('videos.show', $video) }}" target="_blank" class="adm-btn" style="margin-left:auto;">
|
||||
<i class="bi bi-play-circle"></i> View Video
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<!-- Video Info -->
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h5 class="admin-card-title">Video Info</h5>
|
||||
|
||||
{{-- ── Right: sidebar ── --}}
|
||||
<div style="display:flex;flex-direction:column;gap:16px;">
|
||||
|
||||
{{-- Thumbnail --}}
|
||||
<div class="adm-card">
|
||||
<div class="adm-card-body">
|
||||
@if($video->thumbnail)
|
||||
<img src="{{ route('media.thumbnail', $video->thumbnail) }}"
|
||||
alt="{{ $video->title }}" class="ef-thumb">
|
||||
@else
|
||||
<div class="ef-thumb-placeholder"><i class="bi bi-play-circle"></i></div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($video->thumbnail)
|
||||
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}" class="img-fluid rounded mb-3" style="width: 100%;">
|
||||
@else
|
||||
<div class="bg-secondary rounded d-flex align-items-center justify-content-center mb-3" style="height: 180px;">
|
||||
<i class="bi bi-play-circle text-white" style="font-size: 3rem;"></i>
|
||||
</div>
|
||||
|
||||
{{-- Stats --}}
|
||||
<div class="adm-card">
|
||||
<div class="adm-card-header">
|
||||
<div class="adm-card-title"><i class="bi bi-info-circle"></i> Video Info</div>
|
||||
</div>
|
||||
<div class="adm-card-body">
|
||||
<div class="ef-stat-row">
|
||||
<span class="ef-stat-label">Owner</span>
|
||||
<a href="{{ route('channel', $video->user->channel) }}" target="_blank"
|
||||
style="color:var(--brand);font-weight:600;font-size:13px;text-decoration:none;">
|
||||
{{ $video->user->name }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="ef-stat-row">
|
||||
<span class="ef-stat-label">Uploaded</span>
|
||||
<span class="ef-stat-val">{{ $video->created_at->format('M d, Y') }}</span>
|
||||
</div>
|
||||
<div class="ef-stat-row">
|
||||
<span class="ef-stat-label">Views</span>
|
||||
<span class="ef-stat-val" style="color:#22d3ee;">
|
||||
{{ number_format(\DB::table('video_views')->where('video_id', $video->id)->count()) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ef-stat-row">
|
||||
<span class="ef-stat-label">Likes</span>
|
||||
<span class="ef-stat-val" style="color:#f472b6;">
|
||||
{{ number_format(\DB::table('video_likes')->where('video_id', $video->id)->count()) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ef-stat-row">
|
||||
<span class="ef-stat-label">Duration</span>
|
||||
<span class="ef-stat-val">{{ $video->duration ? gmdate('H:i:s', $video->duration) : '—' }}</span>
|
||||
</div>
|
||||
<div class="ef-stat-row">
|
||||
<span class="ef-stat-label">Dimensions</span>
|
||||
<span class="ef-stat-val">{{ $video->width ?? '—' }} × {{ $video->height ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="ef-stat-row">
|
||||
<span class="ef-stat-label">File size</span>
|
||||
<span class="ef-stat-val">{{ $video->size ? number_format($video->size / 1024 / 1024, 1) . ' MB' : '—' }}</span>
|
||||
</div>
|
||||
<div class="ef-stat-row">
|
||||
<span class="ef-stat-label">Orientation</span>
|
||||
<span class="ef-stat-val" style="text-transform:capitalize;">{{ $video->orientation ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Replace File --}}
|
||||
<div class="ef-danger-zone" style="margin-bottom:12px;">
|
||||
<div class="ef-danger-title" style="color:#fb923c;border-color:rgba(251,146,60,.2);">
|
||||
<i class="bi bi-arrow-repeat me-1"></i> Replace Media File
|
||||
</div>
|
||||
<p style="font-size:12px;color:var(--text-2);margin:0 0 12px;line-height:1.5;">
|
||||
Fix a corrupted or missing file. All stats are preserved.
|
||||
</p>
|
||||
|
||||
<div id="adm-rfl-dropzone" onclick="document.getElementById('adm-rfl-input').click()"
|
||||
style="border:2px dashed var(--border);border-radius:8px;padding:14px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;margin-bottom:8px;"
|
||||
onmouseenter="this.style.borderColor='#ef4444';this.style.background='rgba(239,68,68,.05)'"
|
||||
onmouseleave="this.style.borderColor='var(--border)';this.style.background='transparent'">
|
||||
<i class="bi bi-cloud-upload" style="font-size:22px;color:var(--text-2);display:block;margin-bottom:4px;"></i>
|
||||
<span id="adm-rfl-label" style="font-size:12px;color:var(--text-2);">Click to choose replacement file</span>
|
||||
<input type="file" id="adm-rfl-input" accept="video/*,audio/*,.mp4,.webm,.ogg,.mov,.avi,.wmv,.flv,.mkv,.mp3,.m4a,.aac,.wav,.flac,.opus" style="display:none;" onchange="admRflSelected(this)">
|
||||
</div>
|
||||
|
||||
<div id="adm-rfl-info" style="display:none;background:var(--bg-2);border:1px solid var(--border);border-radius:6px;padding:8px 12px;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<i class="bi bi-file-earmark-play" style="font-size:18px;color:#ef4444;flex-shrink:0;"></i>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div id="adm-rfl-name" style="font-size:12px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></div>
|
||||
<div id="adm-rfl-size" style="font-size:11px;color:var(--text-2);margin-top:1px;"></div>
|
||||
</div>
|
||||
<button type="button" onclick="admRflClear()" style="background:none;border:none;color:var(--text-2);cursor:pointer;font-size:14px;flex-shrink:0;padding:0;"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
|
||||
<div id="adm-rfl-status" style="display:none;margin-bottom:8px;font-size:12px;padding:7px 10px;border-radius:6px;"></div>
|
||||
|
||||
<button type="button" id="adm-rfl-btn" onclick="admRflSubmit()" disabled
|
||||
class="adm-btn adm-btn-warning" style="width:100%;">
|
||||
<i class="bi bi-arrow-repeat"></i> Replace File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Danger zone --}}
|
||||
<div class="ef-danger-zone">
|
||||
<div class="ef-danger-title"><i class="bi bi-exclamation-triangle-fill me-1"></i> Danger Zone</div>
|
||||
<button type="button" class="adm-btn adm-btn-danger" style="width:100%;"
|
||||
onclick="openDelDialog()">
|
||||
<i class="bi bi-trash3-fill"></i> Delete This Video
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Delete confirmation dialog ── --}}
|
||||
<div class="adm-dialog-overlay" id="delDialog">
|
||||
<div class="adm-dialog">
|
||||
<div class="adm-dialog-header">
|
||||
<div class="adm-dialog-title">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> Delete Video
|
||||
</div>
|
||||
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDelDialog()"
|
||||
style="border:none;background:none;color:var(--text-2);">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="adm-dialog-body">
|
||||
<p>You are about to permanently delete <strong>{{ $video->title }}</strong>.</p>
|
||||
<div class="adm-dialog-warning">
|
||||
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
|
||||
The video file, thumbnail, and all associated data will be removed. This cannot be undone.
|
||||
</div>
|
||||
@if($adminNeedsOtp)
|
||||
<div style="margin-top:14px;">
|
||||
<label style="font-size:13px;color:var(--text-2);display:flex;align-items:center;gap:6px;margin-bottom:6px;">
|
||||
<i class="bi bi-shield-lock-fill" style="color:#a78bfa;"></i>
|
||||
Enter your <strong style="color:var(--text);">2FA code</strong> to confirm
|
||||
</label>
|
||||
<input type="text" id="delDialogOtp" inputmode="numeric" pattern="[0-9]*"
|
||||
maxlength="6" autocomplete="one-time-code"
|
||||
placeholder="000000"
|
||||
style="width:100%;background:var(--bg-card2);border:1px solid var(--border-light);border-radius:8px;padding:10px 14px;color:#fff;font-size:22px;letter-spacing:0.3em;text-align:center;">
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<hr style="border-color: var(--border-color);">
|
||||
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">Video ID</span>
|
||||
<span>#{{ $video->id }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">Owner</span>
|
||||
<a href="{{ route('channel', $video->user->id) }}" target="_blank">{{ $video->user->name }}</a>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">Uploaded</span>
|
||||
<span>{{ $video->created_at->format('M d, Y') }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">File Size</span>
|
||||
<span>{{ number_format($video->size / 1024 / 1024, 2) }} MB</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">Duration</span>
|
||||
<span>{{ $video->duration ? gmdate('H:i:s', $video->duration) : 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">Orientation</span>
|
||||
<span class="text-capitalize">{{ $video->orientation }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">Dimensions</span>
|
||||
<span>{{ $video->width ?? 'N/A' }} x {{ $video->height ?? 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">Views</span>
|
||||
<span>{{ number_format(\DB::table('video_views')->where('video_id', $video->id)->count()) }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="text-secondary">Likes</span>
|
||||
<span>{{ number_format(\DB::table('video_likes')->where('video_id', $video->id)->count()) }}</span>
|
||||
</div>
|
||||
|
||||
<hr style="border-color: var(--border-color);">
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ route('videos.show', $video->id) }}" target="_blank" class="btn btn-outline-light btn-sm">
|
||||
<i class="bi bi-play-circle"></i> View Video
|
||||
</a>
|
||||
</div>
|
||||
<div id="delDialogError" style="display:none;margin-top:10px;padding:8px 12px;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.3);border-radius:6px;color:#f87171;font-size:13px;"></div>
|
||||
</div>
|
||||
<div class="adm-dialog-footer">
|
||||
<button type="button" class="adm-btn" onclick="closeDelDialog()">Cancel</button>
|
||||
<button type="button" class="adm-btn adm-btn-danger" id="delDialogConfirmBtn" onclick="confirmDelVideo()">
|
||||
<i class="bi bi-trash3-fill"></i> Delete Permanently
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
const _adminNeedsOtp = {{ $adminNeedsOtp ? 'true' : 'false' }};
|
||||
const _editVideoId = '{{ $video->getRouteKey() }}';
|
||||
|
||||
function openDelDialog() {
|
||||
document.getElementById('delDialogError').style.display = 'none';
|
||||
if (_adminNeedsOtp) document.getElementById('delDialogOtp').value = '';
|
||||
document.getElementById('delDialog').classList.add('open');
|
||||
if (_adminNeedsOtp) setTimeout(() => document.getElementById('delDialogOtp').focus(), 100);
|
||||
}
|
||||
function closeDelDialog() { document.getElementById('delDialog').classList.remove('open'); }
|
||||
|
||||
function confirmDelVideo() {
|
||||
const btn = document.getElementById('delDialogConfirmBtn');
|
||||
const errEl = document.getElementById('delDialogError');
|
||||
const otpCode = _adminNeedsOtp ? document.getElementById('delDialogOtp').value.replace(/\s/g,'') : '';
|
||||
|
||||
if (_adminNeedsOtp && otpCode.length !== 6) {
|
||||
errEl.textContent = 'Please enter your 6-digit 2FA code.';
|
||||
errEl.style.display = 'block';
|
||||
document.getElementById('delDialogOtp').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Deleting…';
|
||||
errEl.style.display = 'none';
|
||||
|
||||
const body = new URLSearchParams({ '_token': '{{ csrf_token() }}', '_method': 'DELETE' });
|
||||
if (_adminNeedsOtp) body.append('otp_code', otpCode);
|
||||
|
||||
fetch('/admin/videos/' + _editVideoId, {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString()
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
window.location.href = '/admin/videos';
|
||||
} else {
|
||||
errEl.textContent = data.message || 'Delete failed.';
|
||||
errEl.style.display = 'block';
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-trash3-fill"></i> Delete Permanently';
|
||||
if (_adminNeedsOtp) { document.getElementById('delDialogOtp').value = ''; document.getElementById('delDialogOtp').focus(); }
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
errEl.textContent = 'An error occurred. Please try again.';
|
||||
errEl.style.display = 'block';
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-trash3-fill"></i> Delete Permanently';
|
||||
});
|
||||
}
|
||||
document.getElementById('delDialog').addEventListener('click', e => { if (e.target === document.getElementById('delDialog')) closeDelDialog(); });
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDelDialog(); });
|
||||
|
||||
// ── Admin Replace File ────────────────────────────────────────────────────
|
||||
let _admRflFile = null;
|
||||
const _admRflFmtSize = b => b >= 1073741824 ? (b/1073741824).toFixed(1)+' GB' : b >= 1048576 ? (b/1048576).toFixed(1)+' MB' : Math.round(b/1024)+' KB';
|
||||
|
||||
function admRflSelected(input) {
|
||||
_admRflFile = input.files[0] || null;
|
||||
const info = document.getElementById('adm-rfl-info');
|
||||
if (_admRflFile) {
|
||||
document.getElementById('adm-rfl-name').textContent = _admRflFile.name;
|
||||
document.getElementById('adm-rfl-size').textContent = _admRflFmtSize(_admRflFile.size);
|
||||
document.getElementById('adm-rfl-label').textContent = _admRflFile.name;
|
||||
info.style.display = 'flex';
|
||||
} else {
|
||||
info.style.display = 'none';
|
||||
document.getElementById('adm-rfl-label').textContent = 'Click to choose replacement file';
|
||||
}
|
||||
document.getElementById('adm-rfl-btn').disabled = !_admRflFile;
|
||||
document.getElementById('adm-rfl-status').style.display = 'none';
|
||||
}
|
||||
|
||||
function admRflClear() {
|
||||
_admRflFile = null;
|
||||
document.getElementById('adm-rfl-input').value = '';
|
||||
document.getElementById('adm-rfl-info').style.display = 'none';
|
||||
document.getElementById('adm-rfl-label').textContent = 'Click to choose replacement file';
|
||||
document.getElementById('adm-rfl-btn').disabled = true;
|
||||
document.getElementById('adm-rfl-status').style.display = 'none';
|
||||
}
|
||||
|
||||
function admRflSubmit() {
|
||||
if (!_admRflFile) return;
|
||||
const btn = document.getElementById('adm-rfl-btn');
|
||||
const status = document.getElementById('adm-rfl-status');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading…';
|
||||
status.style.display = 'none';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('_token', '{{ csrf_token() }}');
|
||||
fd.append('replacement_file', _admRflFile);
|
||||
|
||||
fetch('/videos/' + _editVideoId + '/replace-file', {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: fd
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
status.style.cssText = 'display:block;background:rgba(74,222,128,.08);border:1px solid rgba(74,222,128,.25);color:#4ade80;';
|
||||
status.innerHTML = '<i class="bi bi-check-circle-fill"></i> ' + data.message;
|
||||
admRflClear();
|
||||
setTimeout(() => location.reload(), 2200);
|
||||
} else {
|
||||
status.style.cssText = 'display:block;background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.25);color:#f87171;';
|
||||
status.textContent = data.message || 'Upload failed. Please try again.';
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Replace File';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
status.style.cssText = 'display:block;background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.25);color:#f87171;';
|
||||
status.textContent = 'Upload failed. Please try again.';
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Replace File';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@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
|
||||
23
resources/views/admin/nas-storage.blade.php
Normal file
23
resources/views/admin/nas-storage.blade.php
Normal file
@ -0,0 +1,23 @@
|
||||
@extends('admin.layout')
|
||||
|
||||
@section('title', 'NAS Storage')
|
||||
|
||||
@section('content')
|
||||
<div class="adm-page-header">
|
||||
<h1 class="adm-page-title">
|
||||
<i class="bi bi-hdd-network"></i> NAS Storage
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="adm-card">
|
||||
@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@3.x.x/dist/cdn.min.js" defer></script>
|
||||
@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