WIP: storage-fix-local-nas work before playlist controls feature
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6b3ab5b65e
commit
c160242dbc
52
CLAUDE.md
52
CLAUDE.md
@ -142,6 +142,58 @@ The tracker is the source of truth for blast radius. If the tracker is out of da
|
|||||||
|
|
||||||
**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`).
|
**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
|
### Infrastructure Notes
|
||||||
- **Cloudflare proxy**: `AppServiceProvider` forces HTTPS and trusts Cloudflare headers via `TrustProxies`.
|
- **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.
|
- **FFmpeg config**: `/config/ffmpeg.php` — binaries at `/usr/bin/ffmpeg` and `/usr/bin/ffprobe`, GPU device 0, 3600 s timeout.
|
||||||
|
|||||||
@ -12,7 +12,8 @@ class NasFreeLocalStorage extends Command
|
|||||||
{
|
{
|
||||||
protected $signature = 'nas:free-local
|
protected $signature = 'nas:free-local
|
||||||
{--dry-run : Preview what would be deleted without deleting}
|
{--dry-run : Preview what would be deleted without deleting}
|
||||||
{--force : Actually delete local files confirmed on NAS}';
|
{--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';
|
protected $description = 'Delete local files (videos, thumbnails, avatars, banners) already stored on the NAS';
|
||||||
|
|
||||||
@ -140,6 +141,7 @@ class NasFreeLocalStorage extends Command
|
|||||||
$this->newLine();
|
$this->newLine();
|
||||||
$this->info('Scanning avatar files…');
|
$this->info('Scanning avatar files…');
|
||||||
|
|
||||||
|
// Legacy flat dir
|
||||||
$avatarDir = storage_path('app/public/avatars');
|
$avatarDir = storage_path('app/public/avatars');
|
||||||
if (is_dir($avatarDir)) {
|
if (is_dir($avatarDir)) {
|
||||||
foreach (glob($avatarDir . '/*') as $file) {
|
foreach (glob($avatarDir . '/*') as $file) {
|
||||||
@ -148,23 +150,42 @@ class NasFreeLocalStorage extends Command
|
|||||||
$user = User::where('avatar', $filename)->first();
|
$user = User::where('avatar', $filename)->first();
|
||||||
if (! $user) continue;
|
if (! $user) continue;
|
||||||
|
|
||||||
// Confirm avatar.webp is on NAS
|
|
||||||
$dir = "users/{$nas->userSlug($user)}/profile";
|
$dir = "users/{$nas->userSlug($user)}/profile";
|
||||||
$raw = null;
|
$raw = null;
|
||||||
try { $raw = $nas->getContent("{$dir}/avatar.webp"); } catch (\Throwable) {}
|
try { $raw = $nas->getContent("{$dir}/avatar.webp"); } catch (\Throwable) {}
|
||||||
if ($raw !== null) {
|
if ($raw !== null) {
|
||||||
$bytes = filesize($file);
|
$bytes = filesize($file); $totalBytes += $bytes;
|
||||||
$totalBytes += $bytes;
|
|
||||||
$toDelete[] = ['label' => "avatar:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
$toDelete[] = ['label' => "avatar:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->line(' Done scanning avatars.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 ───────────────────────────────────────────────────────────
|
// ── Banners ───────────────────────────────────────────────────────────
|
||||||
$this->newLine();
|
$this->newLine();
|
||||||
$this->info('Scanning banner files…');
|
$this->info('Scanning banner files…');
|
||||||
|
|
||||||
|
// Legacy flat dir
|
||||||
$bannerDir = storage_path('app/public/banners');
|
$bannerDir = storage_path('app/public/banners');
|
||||||
if (is_dir($bannerDir)) {
|
if (is_dir($bannerDir)) {
|
||||||
foreach (glob($bannerDir . '/*') as $file) {
|
foreach (glob($bannerDir . '/*') as $file) {
|
||||||
@ -177,14 +198,32 @@ class NasFreeLocalStorage extends Command
|
|||||||
$raw = null;
|
$raw = null;
|
||||||
try { $raw = $nas->getContent("{$dir}/cover.webp"); } catch (\Throwable) {}
|
try { $raw = $nas->getContent("{$dir}/cover.webp"); } catch (\Throwable) {}
|
||||||
if ($raw !== null) {
|
if ($raw !== null) {
|
||||||
$bytes = filesize($file);
|
$bytes = filesize($file); $totalBytes += $bytes;
|
||||||
$totalBytes += $bytes;
|
|
||||||
$toDelete[] = ['label' => "banner:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
$toDelete[] = ['label' => "banner:{$filename}", 'path' => $file, 'bytes' => $bytes];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->line(' Done scanning banners.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 ───────────────────────────────────────
|
// ── Slideshow cache directories ───────────────────────────────────────
|
||||||
// The slideshow/ directory is a render cache that is always regenerated on
|
// The slideshow/ directory is a render cache that is always regenerated on
|
||||||
// demand, so its contents are safe to delete unconditionally.
|
// demand, so its contents are safe to delete unconditionally.
|
||||||
@ -205,6 +244,27 @@ class NasFreeLocalStorage extends Command
|
|||||||
$this->line(' Done scanning slideshow cache.');
|
$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 ───────────────────────────────────────────────────────────
|
// ── Summary ───────────────────────────────────────────────────────────
|
||||||
$this->newLine();
|
$this->newLine();
|
||||||
|
|
||||||
@ -249,6 +309,15 @@ class NasFreeLocalStorage extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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->newLine();
|
||||||
$this->info("Deleted {$deleted} file(s), freed {$this->humanBytes($totalBytes)}.");
|
$this->info("Deleted {$deleted} file(s), freed {$this->humanBytes($totalBytes)}.");
|
||||||
|
|
||||||
@ -259,6 +328,32 @@ class NasFreeLocalStorage extends Command
|
|||||||
return 0;
|
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 ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private function humanBytes(int $bytes): string
|
private function humanBytes(int $bytes): string
|
||||||
|
|||||||
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} * * * *")
|
->cron("*/{$interval} * * * *")
|
||||||
->withoutOverlapping()
|
->withoutOverlapping()
|
||||||
->runInBackground();
|
->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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Playlist;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Video;
|
use App\Models\Video;
|
||||||
use App\Models\VideoSlide;
|
use App\Models\VideoSlide;
|
||||||
@ -47,6 +48,11 @@ class MediaController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Might be a playlist thumbnail
|
||||||
|
if (! file_exists($local)) {
|
||||||
|
$nas->ensureLocalAsset($local, $filename);
|
||||||
|
}
|
||||||
|
|
||||||
if (! file_exists($local)) abort(404);
|
if (! file_exists($local)) abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,6 +93,27 @@ class MediaController extends Controller
|
|||||||
|
|
||||||
public function avatar(string $filename, NasSyncService $nas): Response
|
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);
|
$local = storage_path('app/public/avatars/' . $filename);
|
||||||
|
|
||||||
if (! file_exists($local)) {
|
if (! file_exists($local)) {
|
||||||
@ -104,6 +131,27 @@ class MediaController extends Controller
|
|||||||
|
|
||||||
public function banner(string $filename, NasSyncService $nas): Response
|
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);
|
$local = storage_path('app/public/banners/' . $filename);
|
||||||
|
|
||||||
if (! file_exists($local)) {
|
if (! file_exists($local)) {
|
||||||
@ -119,6 +167,29 @@ class MediaController extends Controller
|
|||||||
return $this->fileResponse($local);
|
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 ────────────────────────────────────────────────────────────────
|
// ── Helper ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private function fileResponse(string $path): Response
|
private function fileResponse(string $path): Response
|
||||||
|
|||||||
@ -159,9 +159,8 @@ class PlaylistController extends Controller
|
|||||||
// Handle thumbnail upload
|
// Handle thumbnail upload
|
||||||
if ($request->hasFile('thumbnail')) {
|
if ($request->hasFile('thumbnail')) {
|
||||||
$file = $request->file('thumbnail');
|
$file = $request->file('thumbnail');
|
||||||
$filename = self::generateFilename($file->getClientOriginalExtension());
|
$nasPath = self::pushPlaylistThumbnailToNas($file, $playlist);
|
||||||
$file->storeAs('public/thumbnails', $filename);
|
$playlist->update(['thumbnail' => $nasPath]);
|
||||||
$playlist->update(['thumbnail' => $filename]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload playlist with thumbnail
|
// Reload playlist with thumbnail
|
||||||
@ -228,28 +227,19 @@ class PlaylistController extends Controller
|
|||||||
|
|
||||||
// Handle thumbnail upload
|
// Handle thumbnail upload
|
||||||
if ($request->hasFile('thumbnail')) {
|
if ($request->hasFile('thumbnail')) {
|
||||||
// Delete old thumbnail if exists
|
// Delete old thumbnail from NAS if exists
|
||||||
if ($playlist->thumbnail) {
|
if ($playlist->thumbnail) {
|
||||||
$oldPath = storage_path('app/public/thumbnails/'.$playlist->thumbnail);
|
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
|
||||||
if (file_exists($oldPath)) {
|
|
||||||
unlink($oldPath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload new thumbnail
|
|
||||||
$file = $request->file('thumbnail');
|
$file = $request->file('thumbnail');
|
||||||
$filename = self::generateFilename($file->getClientOriginalExtension());
|
$updateData['thumbnail'] = self::pushPlaylistThumbnailToNas($file, $playlist);
|
||||||
$file->storeAs('public/thumbnails', $filename);
|
|
||||||
$updateData['thumbnail'] = $filename;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle thumbnail removal
|
// Handle thumbnail removal
|
||||||
if ($request->input('remove_thumbnail') == '1') {
|
if ($request->input('remove_thumbnail') == '1') {
|
||||||
if ($playlist->thumbnail) {
|
if ($playlist->thumbnail) {
|
||||||
$oldPath = storage_path('app/public/thumbnails/'.$playlist->thumbnail);
|
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
|
||||||
if (file_exists($oldPath)) {
|
|
||||||
unlink($oldPath);
|
|
||||||
}
|
|
||||||
$updateData['thumbnail'] = null;
|
$updateData['thumbnail'] = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -472,4 +462,37 @@ class PlaylistController extends Controller
|
|||||||
|
|
||||||
return redirect(route('videos.show', $randomVideo) . '?playlist=' . $playlist->share_token);
|
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) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,9 +6,9 @@ use App\Models\Post;
|
|||||||
use App\Models\PostImage;
|
use App\Models\PostImage;
|
||||||
use App\Models\PostVideo;
|
use App\Models\PostVideo;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\NasSyncService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
|
|
||||||
class PostController extends Controller
|
class PostController extends Controller
|
||||||
{
|
{
|
||||||
@ -29,7 +29,6 @@ class PostController extends Controller
|
|||||||
'images.*' => 'image|mimes:jpg,jpeg,png,webp,gif|max:8192',
|
'images.*' => 'image|mimes:jpg,jpeg,png,webp,gif|max:8192',
|
||||||
'video_ids' => 'nullable|array|max:10',
|
'video_ids' => 'nullable|array|max:10',
|
||||||
'video_ids.*' => 'exists:videos,id',
|
'video_ids.*' => 'exists:videos,id',
|
||||||
// Legacy fields
|
|
||||||
'video_id' => 'nullable|exists:videos,id',
|
'video_id' => 'nullable|exists:videos,id',
|
||||||
'image' => 'nullable|image|mimes:jpg,jpeg,png,webp,gif|max:8192',
|
'image' => 'nullable|image|mimes:jpg,jpeg,png,webp,gif|max:8192',
|
||||||
]);
|
]);
|
||||||
@ -43,35 +42,69 @@ class PostController extends Controller
|
|||||||
return back()->withErrors(['body' => 'Post cannot be empty.']);
|
return back()->withErrors(['body' => 'Post cannot be empty.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = [
|
// Create post first — we need the ID as the folder name
|
||||||
|
$post = Post::create([
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'body' => $request->body,
|
'body' => $request->body,
|
||||||
'video_id' => $request->video_id ?? null,
|
'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);
|
||||||
|
|
||||||
// Legacy single image (backward compat)
|
|
||||||
if ($hasLegacyImg) {
|
if ($hasLegacyImg) {
|
||||||
$filename = uniqid('post_', true) . '.' . $request->file('image')->getClientOriginalExtension();
|
$file = $request->file('image');
|
||||||
$request->file('image')->storeAs('public/post_images', $filename);
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||||
$data['image'] = $filename;
|
$nasPath = "{$postDir}/0.{$ext}";
|
||||||
|
$nas->putFile($file->getRealPath(), $nasPath);
|
||||||
|
$post->update(['image' => $nasPath]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$post = Post::create($data);
|
|
||||||
|
|
||||||
// New multi-image
|
|
||||||
if ($hasImages) {
|
if ($hasImages) {
|
||||||
foreach ($request->file('images') as $idx => $file) {
|
foreach ($request->file('images') as $idx => $file) {
|
||||||
$filename = uniqid('post_', true) . '.' . $file->getClientOriginalExtension();
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
||||||
$file->storeAs('public/post_images', $filename);
|
$nasPath = "{$postDir}/" . ($idx + 1) . ".{$ext}";
|
||||||
|
$nas->putFile($file->getRealPath(), $nasPath);
|
||||||
PostImage::create([
|
PostImage::create([
|
||||||
'post_id' => $post->id,
|
'post_id' => $post->id,
|
||||||
'filename' => $filename,
|
'filename' => $nasPath,
|
||||||
'sort_order' => $idx,
|
'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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// New multi-video
|
|
||||||
if ($hasVideoIds) {
|
if ($hasVideoIds) {
|
||||||
foreach ($request->input('video_ids') as $idx => $videoId) {
|
foreach ($request->input('video_ids') as $idx => $videoId) {
|
||||||
PostVideo::create([
|
PostVideo::create([
|
||||||
@ -91,14 +124,17 @@ class PostController extends Controller
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($post->image) {
|
$post->loadMissing('postImages');
|
||||||
Storage::delete('public/post_images/' . $post->image);
|
$nas = app(NasSyncService::class);
|
||||||
|
|
||||||
|
if ($nas->isEnabled()) {
|
||||||
|
try {
|
||||||
|
$nas->deleteNasPost($post);
|
||||||
|
} catch (\Throwable) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete multi-image files
|
// Always clean up local copies (handles both legacy flat and new structured format)
|
||||||
foreach ($post->postImages as $postImage) {
|
$nas->deleteLocalPostImages($post);
|
||||||
Storage::delete('public/post_images/' . $postImage->filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
$post->delete();
|
$post->delete();
|
||||||
|
|
||||||
|
|||||||
@ -313,12 +313,46 @@ class SuperAdminController extends Controller
|
|||||||
return redirect()->route('admin.users')->with('success', 'User updated successfully!');
|
return redirect()->route('admin.users')->with('success', 'User updated successfully!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns true if admin is already within the 30-min verified window
|
||||||
|
private function adminIsVerified(): bool
|
||||||
|
{
|
||||||
|
$admin = auth()->user();
|
||||||
|
if (! $admin->two_factor_enabled || ! $admin->two_factor_secret) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$verifiedAt = session('admin_2fa_verified_at');
|
||||||
|
return $verifiedAt && now()->timestamp - $verifiedAt < 1800;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validates OTP and stamps the session on success
|
||||||
|
private function verify2fa(Request $request): bool
|
||||||
|
{
|
||||||
|
$admin = auth()->user();
|
||||||
|
if (! $admin->two_factor_enabled || ! $admin->two_factor_secret) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($this->adminIsVerified()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$code = $request->input('otp_code', '');
|
||||||
|
$google2fa = app('pragmarx.google2fa');
|
||||||
|
if ($google2fa->verifyKey(decrypt($admin->two_factor_secret), (string) $code)) {
|
||||||
|
session(['admin_2fa_verified_at' => now()->timestamp]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Delete user
|
// Delete user
|
||||||
public function deleteUser(User $user)
|
public function deleteUser(Request $request, User $user)
|
||||||
{
|
{
|
||||||
// Prevent deleting yourself
|
// Prevent deleting yourself
|
||||||
if (auth()->id() === $user->id) {
|
if (auth()->id() === $user->id) {
|
||||||
return back()->with('error', 'You cannot delete your own account!');
|
return response()->json(['success' => false, 'message' => 'You cannot delete your own account!'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->verify2fa($request)) {
|
||||||
|
return response()->json(['success' => false, 'message' => 'Invalid 2FA code. Please try again.'], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
AuditLog::record('admin.user.deleted', [
|
AuditLog::record('admin.user.deleted', [
|
||||||
@ -343,7 +377,7 @@ class SuperAdminController extends Controller
|
|||||||
|
|
||||||
$user->delete();
|
$user->delete();
|
||||||
|
|
||||||
return redirect()->route('admin.users')->with('success', 'User deleted successfully!');
|
return response()->json(['success' => true, 'message' => 'User deleted successfully!']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// List all videos
|
// List all videos
|
||||||
@ -530,16 +564,33 @@ class SuperAdminController extends Controller
|
|||||||
'download_access' => 'nullable|in:disabled,everyone,registered,subscribers',
|
'download_access' => 'nullable|in:disabled,everyone,registered,subscribers',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$oldTitle = $video->title;
|
||||||
|
|
||||||
$data = $request->only(['title', 'description', 'visibility', 'type', 'status', 'is_shorts', 'download_access']);
|
$data = $request->only(['title', 'description', 'visibility', 'type', 'status', 'is_shorts', 'download_access']);
|
||||||
|
|
||||||
$video->update($data);
|
$video->update($data);
|
||||||
|
|
||||||
|
if (($data['title'] ?? $oldTitle) !== $oldTitle) {
|
||||||
|
try {
|
||||||
|
$nas = app(\App\Services\NasSyncService::class);
|
||||||
|
if ($nas->isEnabled()) {
|
||||||
|
$nas->renameVideoDir($video->fresh());
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Illuminate\Support\Facades\Log::warning('NAS video dir rename failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->route('admin.videos')->with('success', 'Video updated successfully!');
|
return redirect()->route('admin.videos')->with('success', 'Video updated successfully!');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete video
|
// Delete video
|
||||||
public function deleteVideo(Video $video)
|
public function deleteVideo(Request $request, Video $video)
|
||||||
{
|
{
|
||||||
|
if (! $this->verify2fa($request)) {
|
||||||
|
return response()->json(['success' => false, 'message' => 'Invalid 2FA code. Please try again.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
$videoTitle = $video->title;
|
$videoTitle = $video->title;
|
||||||
|
|
||||||
AuditLog::record('admin.video.deleted', [
|
AuditLog::record('admin.video.deleted', [
|
||||||
@ -561,7 +612,7 @@ class SuperAdminController extends Controller
|
|||||||
|
|
||||||
$video->delete();
|
$video->delete();
|
||||||
|
|
||||||
return redirect()->route('admin.videos')->with('success', 'Video "' . $videoTitle . '" deleted successfully!');
|
return response()->json(['success' => true, 'message' => 'Video "' . $videoTitle . '" deleted successfully!']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -819,7 +870,6 @@ class SuperAdminController extends Controller
|
|||||||
'gpu_hwaccel' => 'required|in:cuda,none',
|
'gpu_hwaccel' => 'required|in:cuda,none',
|
||||||
'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
|
'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
|
||||||
'ffmpeg_binary' => 'required|string|max:255',
|
'ffmpeg_binary' => 'required|string|max:255',
|
||||||
'nas_sync_enabled' => 'required|in:true,false',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
|
$binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
|
||||||
@ -833,7 +883,6 @@ class SuperAdminController extends Controller
|
|||||||
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
|
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
|
||||||
Setting::set('gpu_preset', $request->gpu_preset);
|
Setting::set('gpu_preset', $request->gpu_preset);
|
||||||
Setting::set('ffmpeg_binary', $binary);
|
Setting::set('ffmpeg_binary', $binary);
|
||||||
Setting::set('nas_sync_enabled', $request->nas_sync_enabled);
|
|
||||||
|
|
||||||
return back()->with('success', 'Settings saved.');
|
return back()->with('success', 'Settings saved.');
|
||||||
}
|
}
|
||||||
@ -897,4 +946,433 @@ class SuperAdminController extends Controller
|
|||||||
$nodes = config('nas-file-manager.schema', []);
|
$nodes = config('nas-file-manager.schema', []);
|
||||||
return view('admin.nas-storage', compact('nodes'));
|
return view('admin.nas-storage', compact('nodes'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function nasDelete(Request $request)
|
||||||
|
{
|
||||||
|
$path = trim($request->input('path', ''));
|
||||||
|
$type = $request->input('type', 'dir');
|
||||||
|
|
||||||
|
if ($path === '') {
|
||||||
|
return response()->json(['success' => false, 'message' => 'Path is required.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$nas = app(\App\Services\NasSyncService::class);
|
||||||
|
|
||||||
|
if (! $nas->isEnabled()) {
|
||||||
|
return response()->json(['success' => false, 'message' => 'NAS not enabled.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($type === 'dir') {
|
||||||
|
$nas->deleteNasTree($path);
|
||||||
|
} else {
|
||||||
|
$nas->deleteFile($path);
|
||||||
|
}
|
||||||
|
return response()->json(['success' => true]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['success' => false, 'message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nasRepair(Request $request)
|
||||||
|
{
|
||||||
|
$nas = app(\App\Services\NasSyncService::class);
|
||||||
|
|
||||||
|
if (! $nas->isEnabled()) {
|
||||||
|
return response()->json(['success' => false, 'message' => 'NAS sync is not enabled.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Collect stuck items ───────────────────────────────────────────────
|
||||||
|
$stuckVideos = $this->collectStuckVideos();
|
||||||
|
$stuckAvatars = $this->collectStuckAvatars();
|
||||||
|
$stuckBanners = $this->collectStuckBanners();
|
||||||
|
$stuckThumbs = $this->collectStuckLegacyThumbnails();
|
||||||
|
$nasOrphans = $nas->scanNasOrphans();
|
||||||
|
|
||||||
|
$totalStuck = $stuckVideos->count() + $stuckAvatars->count()
|
||||||
|
+ $stuckBanners->count() + $stuckThumbs->count()
|
||||||
|
+ count($nasOrphans);
|
||||||
|
|
||||||
|
// Scan-only mode ───────────────────────────────────────────────────────
|
||||||
|
if ($request->boolean('scan_only')) {
|
||||||
|
$details = [];
|
||||||
|
foreach ($stuckVideos as $item) {
|
||||||
|
$details[] = "[video] #{$item['video']->id} {$item['video']->title}: " . implode(', ', $item['files']);
|
||||||
|
}
|
||||||
|
foreach ($stuckAvatars as $item) {
|
||||||
|
$details[] = "[avatar] {$item['user']->username}: {$item['file']}";
|
||||||
|
}
|
||||||
|
foreach ($stuckBanners as $item) {
|
||||||
|
$details[] = "[banner] {$item['user']->username}: {$item['file']}";
|
||||||
|
}
|
||||||
|
foreach ($stuckThumbs as $item) {
|
||||||
|
$details[] = "[{$item['type']}] {$item['file']} (video #{$item['video_id']})";
|
||||||
|
}
|
||||||
|
foreach ($nasOrphans as $orphan) {
|
||||||
|
$label = $orphan['video_id'] ? "video #{$orphan['video_id']}" : 'no meta.json';
|
||||||
|
$details[] = "[nas-orphan] {$orphan['dir']} ({$label} — not in DB)";
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheBytes = $nas->nasCacheSize();
|
||||||
|
if ($cacheBytes > 0) {
|
||||||
|
$cacheMb = round($cacheBytes / 1048576, 1);
|
||||||
|
$details[] = "[stream-cache] {$cacheMb} MB of on-demand video cache (safe to clear)";
|
||||||
|
$totalStuck++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['stuck' => $totalStuck, 'details' => $details]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repair mode ─────────────────────────────────────────────────────────
|
||||||
|
$repaired = 0;
|
||||||
|
$failed = 0;
|
||||||
|
$details = [];
|
||||||
|
|
||||||
|
foreach ($stuckVideos as $item) {
|
||||||
|
$video = $item['video'];
|
||||||
|
try {
|
||||||
|
$nas->syncVideo($video);
|
||||||
|
$nas->deleteLocalAssets($video);
|
||||||
|
if ($video->hls_path || $video->type === 'music') $nas->deleteLocalVideo($video);
|
||||||
|
$nas->pruneLocalVideoDir($video);
|
||||||
|
$repaired++;
|
||||||
|
$details[] = "✓ [video] #{$video->id}: {$video->title}";
|
||||||
|
\Log::info("nas:repair: fixed video #{$video->id}");
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$failed++;
|
||||||
|
$details[] = "✗ [video] #{$video->id}: {$e->getMessage()}";
|
||||||
|
\Log::error("nas:repair: failed video #{$video->id}: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($stuckAvatars as $item) {
|
||||||
|
try {
|
||||||
|
$nas->syncAvatar($item['user'], $item['path']);
|
||||||
|
$nas->deleteLocalAvatar($item['user']);
|
||||||
|
$repaired++;
|
||||||
|
$details[] = "✓ [avatar] {$item['user']->username}";
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$failed++;
|
||||||
|
$details[] = "✗ [avatar] {$item['user']->username}: {$e->getMessage()}";
|
||||||
|
\Log::error("nas:repair: failed avatar user#{$item['user']->id}: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($stuckBanners as $item) {
|
||||||
|
try {
|
||||||
|
$nas->syncCover($item['user'], $item['path']);
|
||||||
|
$nas->deleteLocalBanner($item['user']);
|
||||||
|
$repaired++;
|
||||||
|
$details[] = "✓ [banner] {$item['user']->username}";
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$failed++;
|
||||||
|
$details[] = "✗ [banner] {$item['user']->username}: {$e->getMessage()}";
|
||||||
|
\Log::error("nas:repair: failed banner user#{$item['user']->id}: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($stuckThumbs as $item) {
|
||||||
|
try {
|
||||||
|
if ($item['type'] === 'thumbnail' && $item['video']) {
|
||||||
|
$nas->syncVideo($item['video']);
|
||||||
|
} elseif ($item['type'] === 'slide' && $item['slide'] && $item['video']) {
|
||||||
|
$dir = $nas->resolveVideoDir($item['video']);
|
||||||
|
$ext = pathinfo($item['path'], PATHINFO_EXTENSION) ?: 'jpg';
|
||||||
|
$nas->mkdirp("{$dir}/slides");
|
||||||
|
$nas->putFile($item['path'], "{$dir}/slides/{$item['slide']->position}.{$ext}");
|
||||||
|
}
|
||||||
|
@unlink($item['path']);
|
||||||
|
$repaired++;
|
||||||
|
$details[] = "✓ [{$item['type']}] {$item['file']}";
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$failed++;
|
||||||
|
$details[] = "✗ [{$item['type']}] {$item['file']}: {$e->getMessage()}";
|
||||||
|
\Log::error("nas:repair: failed legacy thumb {$item['file']}: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete NAS orphan folders
|
||||||
|
foreach ($nasOrphans as $orphan) {
|
||||||
|
try {
|
||||||
|
$nas->deleteNasTree($orphan['dir']);
|
||||||
|
$repaired++;
|
||||||
|
$details[] = "✓ [nas-orphan] deleted {$orphan['dir']}";
|
||||||
|
\Log::info('nas:repair: deleted NAS orphan', ['dir' => $orphan['dir']]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$failed++;
|
||||||
|
$details[] = "✗ [nas-orphan] {$orphan['dir']}: {$e->getMessage()}";
|
||||||
|
\Log::error('nas:repair: failed to delete NAS orphan', ['dir' => $orphan['dir'], 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict NAS stream cache (24h TTL by default)
|
||||||
|
$evicted = $nas->clearNasCache(24);
|
||||||
|
if ($evicted > 0) {
|
||||||
|
$details[] = "✓ [stream-cache] evicted {$evicted} cached file(s)";
|
||||||
|
$repaired += $evicted;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->pruneLocalStorageDirs();
|
||||||
|
|
||||||
|
if ($totalStuck === 0) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Nothing to repair — no stuck local files and no NAS orphans found.',
|
||||||
|
'repaired' => 0, 'failed' => 0, 'details' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => $failed === 0,
|
||||||
|
'message' => $failed === 0
|
||||||
|
? "Repaired {$repaired} item(s) successfully."
|
||||||
|
: "Repaired {$repaired}, failed {$failed} — check logs.",
|
||||||
|
'repaired' => $repaired,
|
||||||
|
'failed' => $failed,
|
||||||
|
'details' => $details,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function collectStuckVideos(): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
return \App\Models\Video::with(['user', 'slides'])->get()
|
||||||
|
->filter(fn ($v) => str_starts_with($v->path, 'users/'))
|
||||||
|
->map(function ($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 collectStuckAvatars(): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
$results = collect();
|
||||||
|
|
||||||
|
// Legacy flat dir
|
||||||
|
$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 = \App\Models\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)), '/');
|
||||||
|
$user = \App\Models\User::where('avatar', $relPath)->first();
|
||||||
|
if ($user) {
|
||||||
|
$results->push(['user' => $user, 'file' => basename($path), 'path' => $path]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function collectStuckBanners(): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
$results = collect();
|
||||||
|
|
||||||
|
// Legacy flat dir
|
||||||
|
$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 = \App\Models\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 = \App\Models\User::where('banner', $relPath)->first();
|
||||||
|
if ($user) {
|
||||||
|
$results->push(['user' => $user, 'file' => basename($path), 'path' => $path]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function collectStuckLegacyThumbnails(): \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;
|
||||||
|
|
||||||
|
$video = \App\Models\Video::where('thumbnail', $filename)->first();
|
||||||
|
if ($video) {
|
||||||
|
$results[] = ['type' => 'thumbnail', 'file' => $filename, 'path' => $path, 'video_id' => $video->id, 'video' => $video, 'slide' => null];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slide = \App\Models\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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pruneLocalStorageDirs(): void
|
||||||
|
{
|
||||||
|
// NAS-mirrored tree
|
||||||
|
$nasRoot = storage_path('app/users');
|
||||||
|
if (is_dir($nasRoot)) {
|
||||||
|
$iter = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($nasRoot, \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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat asset dirs — remove if empty
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NAS Disable Flow ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function nasDisable(Request $request)
|
||||||
|
{
|
||||||
|
$mode = $request->input('mode'); // 'migrate' or 'fresh'
|
||||||
|
|
||||||
|
if ($mode === 'migrate') {
|
||||||
|
// Reset progress cache, dispatch job
|
||||||
|
\Cache::put('nas_disable_progress', json_encode([
|
||||||
|
'current' => 0, 'total' => 0,
|
||||||
|
'phase' => 'Starting...', 'done' => false, 'error' => null,
|
||||||
|
]), 3600);
|
||||||
|
\App\Jobs\NasToLocalMigrationJob::dispatch()
|
||||||
|
->onQueue('video-processing')
|
||||||
|
->onConnection('database');
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mode === 'fresh') {
|
||||||
|
// Truncate all media tables, reset user avatars/banners, disable NAS
|
||||||
|
$tables = [
|
||||||
|
'videos','video_slides','video_likes','video_views','video_shares',
|
||||||
|
'video_downloads','playlist_videos','playlists','comments','comment_likes',
|
||||||
|
'posts','post_images','post_reactions','post_videos',
|
||||||
|
'coach_reviews','match_rounds','match_points',
|
||||||
|
'share_accesses','playlist_share_accesses','notifications',
|
||||||
|
];
|
||||||
|
foreach ($tables as $t) {
|
||||||
|
\DB::table($t)->delete();
|
||||||
|
}
|
||||||
|
\DB::table('users')->update(['avatar' => null, 'banner' => null]);
|
||||||
|
Setting::set('nas_sync_enabled', 'false');
|
||||||
|
AuditLog::record('admin.nas_disabled_fresh');
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Invalid mode'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nasMigrateProgress()
|
||||||
|
{
|
||||||
|
$raw = \Cache::get('nas_disable_progress');
|
||||||
|
if (! $raw) return response()->json(['done' => false, 'current' => 0, 'total' => 0, 'phase' => 'Not started']);
|
||||||
|
return response()->json(json_decode($raw, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function backupUsersSettings()
|
||||||
|
{
|
||||||
|
$users = \DB::table('users')->get()->map(function ($u) {
|
||||||
|
return (array) $u;
|
||||||
|
})->toArray();
|
||||||
|
|
||||||
|
$settings = \DB::table('settings')->get()->map(function ($s) {
|
||||||
|
return (array) $s;
|
||||||
|
})->toArray();
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'version' => '1.0',
|
||||||
|
'exported_at' => now()->toIso8601String(),
|
||||||
|
'users' => $users,
|
||||||
|
'settings' => $settings,
|
||||||
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
return response($payload, 200, [
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'Content-Disposition' => 'attachment; filename="takeone-backup-' . now()->format('Ymd-His') . '.json"',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restoreUsersSettings(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate(['backup' => 'required|file|mimes:json|max:10240']);
|
||||||
|
|
||||||
|
$content = file_get_contents($request->file('backup')->getRealPath());
|
||||||
|
$data = json_decode($content, true);
|
||||||
|
|
||||||
|
if (! isset($data['users']) || ! isset($data['settings'])) {
|
||||||
|
return back()->with('toast_error', 'Invalid backup file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore settings
|
||||||
|
foreach ($data['settings'] as $row) {
|
||||||
|
\DB::table('settings')->updateOrInsert(
|
||||||
|
['key' => $row['key']],
|
||||||
|
['key' => $row['key'], 'value' => $row['value']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore users (upsert by email)
|
||||||
|
$restored = 0;
|
||||||
|
foreach ($data['users'] as $row) {
|
||||||
|
unset($row['id']); // let DB assign new IDs to avoid PK conflicts
|
||||||
|
\DB::table('users')->updateOrInsert(
|
||||||
|
['email' => $row['email']],
|
||||||
|
$row
|
||||||
|
);
|
||||||
|
$restored++;
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLog::record('admin.backup_restored', ['users' => $restored]);
|
||||||
|
return back()->with('toast_success', "Backup restored: {$restored} users + settings.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,17 +68,36 @@ class UserController extends Controller
|
|||||||
'timezone' => $request->timezone ?: null,
|
'timezone' => $request->timezone ?: null,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$nas = app(\App\Services\NasSyncService::class);
|
||||||
|
|
||||||
if ($request->hasFile('avatar')) {
|
if ($request->hasFile('avatar')) {
|
||||||
if ($user->avatar) {
|
// Delete old avatar (handles both flat and new relative-path formats)
|
||||||
Storage::delete('public/avatars/'.$user->avatar);
|
$nas->deleteLocalAvatar($user);
|
||||||
}
|
|
||||||
$filename = self::generateFilename($request->file('avatar')->getClientOriginalExtension());
|
$ext = $request->file('avatar')->getClientOriginalExtension() ?: 'webp';
|
||||||
$request->file('avatar')->storeAs('public/avatars', $filename);
|
$profileDir = $nas->localProfileDir($user);
|
||||||
$data['avatar'] = $filename;
|
$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);
|
$user->update($data);
|
||||||
|
|
||||||
|
// 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
|
// Sync social links
|
||||||
$user->socialLinks()->delete();
|
$user->socialLinks()->delete();
|
||||||
$order = 0;
|
$order = 0;
|
||||||
@ -418,14 +437,68 @@ class UserController extends Controller
|
|||||||
public function updateAvatar(Request $request)
|
public function updateAvatar(Request $request)
|
||||||
{
|
{
|
||||||
$request->validate(['path' => 'required|string|max:300']);
|
$request->validate(['path' => 'required|string|max:300']);
|
||||||
Auth::user()->update(['avatar' => basename($request->path)]);
|
$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]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateBanner(Request $request)
|
public function updateBanner(Request $request)
|
||||||
{
|
{
|
||||||
$request->validate(['path' => 'required|string|max:300']);
|
$request->validate(['path' => 'required|string|max:300']);
|
||||||
Auth::user()->update(['banner' => basename($request->path)]);
|
$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]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -527,6 +527,8 @@ class VideoController extends Controller
|
|||||||
'slides_add.*' => 'image|mimes:jpg,jpeg,png,webp|max:20480',
|
'slides_add.*' => 'image|mimes:jpg,jpeg,png,webp|max:20480',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$oldTitle = $video->title;
|
||||||
|
|
||||||
$data = $request->only(['title', 'description', 'visibility', 'type']);
|
$data = $request->only(['title', 'description', 'visibility', 'type']);
|
||||||
$data['download_access'] = $request->input('download_access', 'disabled');
|
$data['download_access'] = $request->input('download_access', 'disabled');
|
||||||
|
|
||||||
@ -544,10 +546,17 @@ class VideoController extends Controller
|
|||||||
$userSlug = $nas->userSlug($video->user);
|
$userSlug = $nas->userSlug($video->user);
|
||||||
$data['thumbnail'] = 'users/' . $userSlug . '/videos/' . basename($localDir) . "/thumb.{$ext}";
|
$data['thumbnail'] = 'users/' . $userSlug . '/videos/' . basename($localDir) . "/thumb.{$ext}";
|
||||||
} else {
|
} else {
|
||||||
// Legacy video: keep in flat thumbnails dir
|
// Legacy video: push thumbnail directly to NAS
|
||||||
|
$nas = app(\App\Services\NasSyncService::class);
|
||||||
$thumbFilename = self::generateFilename($request->file('thumbnail')->getClientOriginalExtension());
|
$thumbFilename = self::generateFilename($request->file('thumbnail')->getClientOriginalExtension());
|
||||||
$data['thumbnail'] = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
|
$request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
|
||||||
$data['thumbnail'] = basename($data['thumbnail']);
|
$tempAbs = storage_path('app/public/thumbnails/' . $thumbFilename);
|
||||||
|
$nasDir = $nas->resolveVideoDir($video);
|
||||||
|
$ext = pathinfo($thumbFilename, PATHINFO_EXTENSION);
|
||||||
|
$nas->mkdirp($nasDir);
|
||||||
|
$nas->putFile($tempAbs, "{$nasDir}/thumb.{$ext}");
|
||||||
|
@unlink($tempAbs);
|
||||||
|
$data['thumbnail'] = "{$nasDir}/thumb.{$ext}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -579,11 +588,11 @@ class VideoController extends Controller
|
|||||||
if ($request->hasFile('slides_add')) {
|
if ($request->hasFile('slides_add')) {
|
||||||
$nextPos = count($keptOrder);
|
$nextPos = count($keptOrder);
|
||||||
$isNewFormat = str_starts_with($video->path, 'users/');
|
$isNewFormat = str_starts_with($video->path, 'users/');
|
||||||
|
$nasForSlides = app(\App\Services\NasSyncService::class);
|
||||||
if ($isNewFormat) {
|
if ($isNewFormat) {
|
||||||
$nas = app(\App\Services\NasSyncService::class);
|
$localDir = $nasForSlides->localVideoDir($video);
|
||||||
$localDir = $nas->localVideoDir($video);
|
|
||||||
@mkdir("{$localDir}/slides", 0755, true);
|
@mkdir("{$localDir}/slides", 0755, true);
|
||||||
$userSlug = $nas->userSlug($video->user);
|
$userSlug = $nasForSlides->userSlug($video->user);
|
||||||
$relDir = 'users/' . $userSlug . '/videos/' . basename($localDir);
|
$relDir = 'users/' . $userSlug . '/videos/' . basename($localDir);
|
||||||
}
|
}
|
||||||
foreach ($request->file('slides_add') as $file) {
|
foreach ($request->file('slides_add') as $file) {
|
||||||
@ -594,9 +603,18 @@ class VideoController extends Controller
|
|||||||
$file->move("{$localDir}/slides", "{$slide->id}.{$ext}");
|
$file->move("{$localDir}/slides", "{$slide->id}.{$ext}");
|
||||||
$slide->update(['filename' => "{$relDir}/slides/{$slide->id}.{$ext}"]);
|
$slide->update(['filename' => "{$relDir}/slides/{$slide->id}.{$ext}"]);
|
||||||
} else {
|
} else {
|
||||||
|
// Legacy video: push slide directly to NAS
|
||||||
$fname = self::generateFilename($file->getClientOriginalExtension());
|
$fname = self::generateFilename($file->getClientOriginalExtension());
|
||||||
$file->storeAs('public/thumbnails', $fname);
|
$file->storeAs('public/thumbnails', $fname);
|
||||||
VideoSlide::create(['video_id' => $video->id, 'filename' => $fname, 'position' => $nextPos]);
|
$tempAbs = storage_path('app/public/thumbnails/' . $fname);
|
||||||
|
$nasDir = $nasForSlides->resolveVideoDir($video);
|
||||||
|
$nasForSlides->mkdirp("{$nasDir}/slides");
|
||||||
|
$ext = pathinfo($fname, PATHINFO_EXTENSION);
|
||||||
|
$slide = VideoSlide::create(['video_id' => $video->id, 'filename' => '__pending__', 'position' => $nextPos]);
|
||||||
|
$nasSlide = "{$nasDir}/slides/{$slide->id}.{$ext}";
|
||||||
|
$nasForSlides->putFile($tempAbs, $nasSlide);
|
||||||
|
@unlink($tempAbs);
|
||||||
|
$slide->update(['filename' => $nasSlide]);
|
||||||
}
|
}
|
||||||
$nextPos++;
|
$nextPos++;
|
||||||
$slidesChanged = true;
|
$slidesChanged = true;
|
||||||
@ -618,6 +636,19 @@ class VideoController extends Controller
|
|||||||
|
|
||||||
$video->update($data);
|
$video->update($data);
|
||||||
|
|
||||||
|
// If the title changed, rename the NAS/local folder before syncing so
|
||||||
|
// the sync job writes to the correctly-named directory.
|
||||||
|
if (($data['title'] ?? $oldTitle) !== $oldTitle) {
|
||||||
|
try {
|
||||||
|
$nas = app(\App\Services\NasSyncService::class);
|
||||||
|
if ($nas->isEnabled()) {
|
||||||
|
$nas->renameVideoDir($video->fresh());
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Illuminate\Support\Facades\Log::warning('NAS video dir rename failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
NasSyncVideoJob::dispatch($video->fresh());
|
NasSyncVideoJob::dispatch($video->fresh());
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@ -692,6 +723,182 @@ class VideoController extends Controller
|
|||||||
return redirect()->route('videos.index')->with('success', 'Video deleted!');
|
return redirect()->route('videos.index')->with('success', 'Video deleted!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Replace media file (keeps all metadata, views, likes, comments intact)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Replace media file (keeps all metadata, views, likes, comments intact)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
public function replaceFile(Request $request, Video $video)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
if ($user->id !== $video->user_id && ! $user->isSuperAdmin()) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'replacement_file' => [
|
||||||
|
'required',
|
||||||
|
'file',
|
||||||
|
'mimes:mp4,webm,ogg,mov,avi,wmv,flv,mkv,mp3,m4a,aac,wav,flac,opus',
|
||||||
|
'max:512000',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$newFile = $request->file('replacement_file');
|
||||||
|
$mimeType = $newFile->getMimeType();
|
||||||
|
$isAudio = str_starts_with($mimeType, 'audio/');
|
||||||
|
$newSize = $newFile->getSize();
|
||||||
|
$newExt = strtolower($newFile->getClientOriginalExtension() ?: ($isAudio ? 'mp3' : 'mp4'));
|
||||||
|
$nas = app(\App\Services\NasSyncService::class);
|
||||||
|
|
||||||
|
// ── 1. Clear old HLS ─────────────────────────────────────────────────
|
||||||
|
if ($video->has_hls && $video->hls_path) {
|
||||||
|
\Storage::deleteDirectory($video->hls_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Delete old media file ─────────────────────────────────────────
|
||||||
|
// NAS: only delete the video file; thumbnail/slides/meta.json stay
|
||||||
|
// Local: unlink the local copy
|
||||||
|
if ($nas->isEnabled() && str_starts_with($video->path, 'users/')) {
|
||||||
|
try { $nas->deleteFile($video->path); } catch (\Throwable) {}
|
||||||
|
} else {
|
||||||
|
$oldLocal = storage_path('app/' . $video->path);
|
||||||
|
if (file_exists($oldLocal)) @unlink($oldLocal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Store new file to a temporary local path ──────────────────────
|
||||||
|
$tempFilename = \Str::uuid() . '.' . $newExt;
|
||||||
|
$tempRelPath = 'public/videos/' . $tempFilename;
|
||||||
|
$newFile->storeAs('public/videos', $tempFilename);
|
||||||
|
$tempAbsPath = storage_path('app/' . $tempRelPath);
|
||||||
|
|
||||||
|
if (! file_exists($tempAbsPath)) {
|
||||||
|
return response()->json(['success' => false, 'message' => 'Failed to store the uploaded file.'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Extract metadata via FFprobe ──────────────────────────────────
|
||||||
|
$width = $height = null;
|
||||||
|
$orientation = 'landscape';
|
||||||
|
$duration = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ffprobeBin = config('ffmpeg.ffprobe', '/usr/bin/ffprobe');
|
||||||
|
$out = [];
|
||||||
|
exec("{$ffprobeBin} -v error -show_entries format=duration -of csv=p=0 " . escapeshellarg($tempAbsPath), $out);
|
||||||
|
$duration = (int) round((float) ($out[0] ?? 0));
|
||||||
|
|
||||||
|
if (! $isAudio) {
|
||||||
|
$ffprobe = \FFMpeg\FFProbe::create();
|
||||||
|
$stream = $ffprobe->streams($tempAbsPath)->videos()->first();
|
||||||
|
if ($stream) {
|
||||||
|
$width = $stream->get('width');
|
||||||
|
$height = $stream->get('height');
|
||||||
|
if ($width && $height) {
|
||||||
|
if ($height > $width) $orientation = 'portrait';
|
||||||
|
elseif ($width > $height) $orientation = 'landscape';
|
||||||
|
else $orientation = 'square';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Log::warning('replaceFile: FFprobe failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Persist the file to its final location ─────────────────────────
|
||||||
|
//
|
||||||
|
// NAS path: push directly to NAS using uploadDirectToNas()
|
||||||
|
// This handles legacy paths correctly by computing the
|
||||||
|
// proper users/.../videos/... directory.
|
||||||
|
// uploadDirectToNas() updates path/filename in DB.
|
||||||
|
//
|
||||||
|
// Local path: update path/filename to temp location, then call
|
||||||
|
// organizeLocalFiles() which moves it to users/... layout.
|
||||||
|
// CompressVideoJob() sets status=ready and chains HLS.
|
||||||
|
|
||||||
|
if ($nas->isEnabled()) {
|
||||||
|
|
||||||
|
// Point filename at the new file (uploadDirectToNas uses this for ext)
|
||||||
|
$video->update(['filename' => $tempFilename, 'mime_type' => $mimeType, 'size' => $newSize]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Pass null for thumb — we don't want to overwrite the existing thumbnail
|
||||||
|
$nas->uploadDirectToNas($video, $tempAbsPath, null);
|
||||||
|
$video->refresh();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Log::error('replaceFile: NAS upload failed: ' . $e->getMessage());
|
||||||
|
@unlink($tempAbsPath);
|
||||||
|
return response()->json(['success' => false, 'message' => 'NAS upload failed. Please try again.'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$metaUpdates = [
|
||||||
|
'size' => $newSize,
|
||||||
|
'mime_type'=> $mimeType,
|
||||||
|
'has_hls' => false,
|
||||||
|
'hls_path' => null,
|
||||||
|
// For NAS the upload is the "done" state — set ready so GenerateHlsJob runs
|
||||||
|
'status' => 'ready',
|
||||||
|
];
|
||||||
|
if (! $isAudio) {
|
||||||
|
$metaUpdates['duration'] = $duration ?: $video->duration;
|
||||||
|
$metaUpdates['width'] = $width ?: $video->width;
|
||||||
|
$metaUpdates['height'] = $height ?: $video->height;
|
||||||
|
$metaUpdates['orientation'] = $orientation;
|
||||||
|
$metaUpdates['is_shorts'] = ($duration ?: $video->duration) <= 60 && $orientation === 'portrait';
|
||||||
|
}
|
||||||
|
$video->update($metaUpdates);
|
||||||
|
|
||||||
|
if (! $isAudio) {
|
||||||
|
\App\Jobs\GenerateHlsJob::dispatch($video->fresh())
|
||||||
|
->onQueue('video-processing')
|
||||||
|
->onConnection('database');
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
// Point the record at the temp file so organizeLocalFiles can move it
|
||||||
|
$video->update(['path' => $tempRelPath, 'filename' => $tempFilename]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$nas->organizeLocalFiles($video);
|
||||||
|
$video->refresh();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Log::warning('replaceFile: organizeLocalFiles failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$metaUpdates = [
|
||||||
|
'size' => $newSize,
|
||||||
|
'mime_type'=> $mimeType,
|
||||||
|
'has_hls' => false,
|
||||||
|
'hls_path' => null,
|
||||||
|
'status' => $isAudio ? 'ready' : 'processing',
|
||||||
|
];
|
||||||
|
if (! $isAudio) {
|
||||||
|
$metaUpdates['duration'] = $duration ?: $video->duration;
|
||||||
|
$metaUpdates['width'] = $width ?: $video->width;
|
||||||
|
$metaUpdates['height'] = $height ?: $video->height;
|
||||||
|
$metaUpdates['orientation'] = $orientation;
|
||||||
|
$metaUpdates['is_shorts'] = ($duration ?: $video->duration) <= 60 && $orientation === 'portrait';
|
||||||
|
}
|
||||||
|
$video->update($metaUpdates);
|
||||||
|
|
||||||
|
if (! $isAudio) {
|
||||||
|
\App\Jobs\CompressVideoJob::dispatch($video->fresh())
|
||||||
|
->onQueue('video-processing')
|
||||||
|
->onConnection('database');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => $isAudio
|
||||||
|
? 'Audio file replaced successfully.'
|
||||||
|
: 'File replaced — re-encoding has started. The video will be ready shortly.',
|
||||||
|
'status' => $video->fresh()->status,
|
||||||
|
'is_audio' => $isAudio,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function trending(Request $request)
|
public function trending(Request $request)
|
||||||
{
|
{
|
||||||
$hours = $request->get('hours', 48);
|
$hours = $request->get('hours', 48);
|
||||||
@ -1127,8 +1334,12 @@ class VideoController extends Controller
|
|||||||
$path = $video->localVideoPath();
|
$path = $video->localVideoPath();
|
||||||
|
|
||||||
if (! file_exists($path)) {
|
if (! file_exists($path)) {
|
||||||
|
$nas = app(\App\Services\NasSyncService::class);
|
||||||
|
$path = $nas->ensureLocalCopy($video);
|
||||||
|
if (! $path) {
|
||||||
abort(404, 'Video file not found.');
|
abort(404, 'Video file not found.');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$slug = $this->safeFilename($video->title, 'audio');
|
$slug = $this->safeFilename($video->title, 'audio');
|
||||||
$ffmpeg = \App\Models\Setting::ffmpegBinary();
|
$ffmpeg = \App\Models\Setting::ffmpegBinary();
|
||||||
@ -1568,7 +1779,7 @@ class VideoController extends Controller
|
|||||||
'channel' => $u->username,
|
'channel' => $u->username,
|
||||||
'name' => $u->name,
|
'name' => $u->name,
|
||||||
'avatar' => $u->avatar
|
'avatar' => $u->avatar
|
||||||
? asset('storage/avatars/' . $u->avatar)
|
? route('media.avatar', $u->avatar)
|
||||||
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||||
'count' => (int) $u->cnt,
|
'count' => (int) $u->cnt,
|
||||||
'last_at' => $u->last_at,
|
'last_at' => $u->last_at,
|
||||||
@ -1602,7 +1813,7 @@ class VideoController extends Controller
|
|||||||
'user_name' => $r->user_name ?? 'Guest',
|
'user_name' => $r->user_name ?? 'Guest',
|
||||||
'user_avatar' => $r->user_id
|
'user_avatar' => $r->user_id
|
||||||
? ($r->user_avatar
|
? ($r->user_avatar
|
||||||
? asset('storage/avatars/' . $r->user_avatar)
|
? route('media.avatar', $r->user_avatar)
|
||||||
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
|
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
|
||||||
: null,
|
: null,
|
||||||
]);
|
]);
|
||||||
@ -1631,7 +1842,7 @@ class VideoController extends Controller
|
|||||||
'id' => $u->id,
|
'id' => $u->id,
|
||||||
'name' => $u->name,
|
'name' => $u->name,
|
||||||
'avatar' => $u->avatar
|
'avatar' => $u->avatar
|
||||||
? asset('storage/avatars/' . $u->avatar)
|
? route('media.avatar', $u->avatar)
|
||||||
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||||
'count' => (int) $u->cnt,
|
'count' => (int) $u->cnt,
|
||||||
'last_at' => $u->last_at,
|
'last_at' => $u->last_at,
|
||||||
@ -1668,7 +1879,7 @@ class VideoController extends Controller
|
|||||||
'user_name' => $r->user_name ?? 'Guest',
|
'user_name' => $r->user_name ?? 'Guest',
|
||||||
'user_avatar'=> $r->user_id
|
'user_avatar'=> $r->user_id
|
||||||
? ($r->user_avatar
|
? ($r->user_avatar
|
||||||
? asset('storage/avatars/' . $r->user_avatar)
|
? route('media.avatar', $r->user_avatar)
|
||||||
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
|
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
|
||||||
: null,
|
: null,
|
||||||
]);
|
]);
|
||||||
@ -1728,6 +1939,24 @@ class VideoController extends Controller
|
|||||||
'pct' => $totalAge > 0 ? round($cnt / $totalAge * 100) : 0,
|
'pct' => $totalAge > 0 ? round($cnt / $totalAge * 100) : 0,
|
||||||
])->values();
|
])->values();
|
||||||
|
|
||||||
|
// Who liked this video
|
||||||
|
$likers = \DB::table('video_likes')
|
||||||
|
->join('users', 'users.id', '=', 'video_likes.user_id')
|
||||||
|
->select('users.id', 'users.name', 'users.avatar', 'users.username', 'video_likes.created_at as liked_at')
|
||||||
|
->where('video_likes.video_id', $id)
|
||||||
|
->orderByDesc('video_likes.created_at')
|
||||||
|
->limit(50)
|
||||||
|
->get()
|
||||||
|
->map(fn ($u) => [
|
||||||
|
'id' => $u->id,
|
||||||
|
'channel' => $u->username,
|
||||||
|
'name' => $u->name,
|
||||||
|
'avatar' => $u->avatar
|
||||||
|
? route('media.avatar', $u->avatar)
|
||||||
|
: 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||||
|
'liked_at' => $u->liked_at,
|
||||||
|
]);
|
||||||
|
|
||||||
// ── Share analytics ────────────────────────────────────────
|
// ── Share analytics ────────────────────────────────────────
|
||||||
$shareLinks = \DB::table('video_shares')->where('video_id', $id)->get();
|
$shareLinks = \DB::table('video_shares')->where('video_id', $id)->get();
|
||||||
$shareIds = $shareLinks->pluck('id');
|
$shareIds = $shareLinks->pluck('id');
|
||||||
@ -1774,6 +2003,7 @@ class VideoController extends Controller
|
|||||||
'daily' => $daily,
|
'daily' => $daily,
|
||||||
'peak_hour' => $peakHour,
|
'peak_hour' => $peakHour,
|
||||||
'likes' => $video->like_count,
|
'likes' => $video->like_count,
|
||||||
|
'likers' => $likers,
|
||||||
'genders' => $genders,
|
'genders' => $genders,
|
||||||
'age_groups' => $ageGroups,
|
'age_groups' => $ageGroups,
|
||||||
]);
|
]);
|
||||||
@ -1805,7 +2035,7 @@ class VideoController extends Controller
|
|||||||
'id' => $u->id,
|
'id' => $u->id,
|
||||||
'channel' => $u->username,
|
'channel' => $u->username,
|
||||||
'name' => $u->name,
|
'name' => $u->name,
|
||||||
'avatar' => $u->avatar ? asset('storage/avatars/' . $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
|
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||||
'count' => (int) $u->cnt,
|
'count' => (int) $u->cnt,
|
||||||
'last_at' => $u->last_at,
|
'last_at' => $u->last_at,
|
||||||
]);
|
]);
|
||||||
@ -1869,7 +2099,7 @@ class VideoController extends Controller
|
|||||||
'id' => $u->id,
|
'id' => $u->id,
|
||||||
'channel' => $u->username,
|
'channel' => $u->username,
|
||||||
'name' => $u->name,
|
'name' => $u->name,
|
||||||
'avatar' => $u->avatar ? asset('storage/avatars/' . $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
|
'avatar' => $u->avatar ? route('media.avatar', $u->avatar) : 'https://i.pravatar.cc/150?u=' . $u->id,
|
||||||
'count' => (int) $u->cnt,
|
'count' => (int) $u->cnt,
|
||||||
'last_at' => $u->last_at,
|
'last_at' => $u->last_at,
|
||||||
]);
|
]);
|
||||||
@ -1940,7 +2170,7 @@ class VideoController extends Controller
|
|||||||
'user' => [
|
'user' => [
|
||||||
'id' => $user->id,
|
'id' => $user->id,
|
||||||
'name' => $user->name,
|
'name' => $user->name,
|
||||||
'avatar' => $user->avatar ? asset('storage/avatars/' . $user->avatar) : 'https://i.pravatar.cc/150?u=' . $user->id,
|
'avatar' => $user->avatar ? route('media.avatar', $user->avatar) : 'https://i.pravatar.cc/150?u=' . $user->id,
|
||||||
],
|
],
|
||||||
'total' => $records->count(),
|
'total' => $records->count(),
|
||||||
'records' => $records,
|
'records' => $records,
|
||||||
|
|||||||
@ -30,10 +30,15 @@ class NasSyncVideoJob implements ShouldQueue
|
|||||||
// Video uploads must keep the local file until GenerateHlsJob finishes.
|
// Video uploads must keep the local file until GenerateHlsJob finishes.
|
||||||
if ($this->video->type === 'music') {
|
if ($this->video->type === 'music') {
|
||||||
$nas->deleteLocalVideo($this->video);
|
$nas->deleteLocalVideo($this->video);
|
||||||
$nas->deleteLocalAssets($this->video);
|
|
||||||
}
|
}
|
||||||
|
$nas->deleteLocalAssets($this->video);
|
||||||
|
$nas->pruneLocalVideoDir($this->video);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
\Illuminate\Support\Facades\Log::warning('NAS syncVideo failed for video ' . $this->video->id . ': ' . $e->getMessage());
|
\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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -43,13 +43,12 @@ class Playlist extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Accessors
|
// Accessors
|
||||||
public function getThumbnailUrlAttribute()
|
public function getThumbnailUrlAttribute(): string
|
||||||
{
|
{
|
||||||
if ($this->thumbnail) {
|
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';
|
return 'https://ui-avatars.com/api/?name='.urlencode($this->name).'&background=random&size=200';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -46,6 +46,10 @@ class Post extends Model
|
|||||||
|
|
||||||
public function getImageUrlAttribute(): ?string
|
public function getImageUrlAttribute(): ?string
|
||||||
{
|
{
|
||||||
return $this->image ? asset('storage/post_images/' . $this->image) : null;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,9 @@ class PostImage extends Model
|
|||||||
|
|
||||||
public function getImageUrlAttribute(): string
|
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);
|
return asset('storage/post_images/' . $this->filename);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -123,10 +123,10 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
return $this->hasMany(\App\Models\Post::class);
|
return $this->hasMany(\App\Models\Post::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getAvatarUrlAttribute()
|
public function getAvatarUrlAttribute(): string
|
||||||
{
|
{
|
||||||
if ($this->avatar) {
|
if ($this->avatar) {
|
||||||
return asset('storage/avatars/'.$this->avatar);
|
return route('media.avatar', $this->avatar);
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'https://i.pravatar.cc/150?u='.$this->id;
|
return 'https://i.pravatar.cc/150?u='.$this->id;
|
||||||
@ -135,7 +135,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
public function getBannerUrlAttribute(): ?string
|
public function getBannerUrlAttribute(): ?string
|
||||||
{
|
{
|
||||||
if ($this->banner) {
|
if ($this->banner) {
|
||||||
return asset('storage/banners/'.$this->banner);
|
return route('media.banner', $this->banner);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,5 +32,27 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
// Universal pagination view — used everywhere by default
|
// Universal pagination view — used everywhere by default
|
||||||
Paginator::defaultView('partials.pagination');
|
Paginator::defaultView('partials.pagination');
|
||||||
Paginator::defaultSimpleView('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
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,9 +26,22 @@ class NasSyncService
|
|||||||
|
|
||||||
public function titleSlug(string $title): string
|
public function titleSlug(string $title): string
|
||||||
{
|
{
|
||||||
$slug = mb_strtolower($title);
|
// NFC normalisation ensures consistent codepoints across sources
|
||||||
$slug = preg_replace('/[^a-z0-9]+/u', '-', $slug);
|
$title = \Normalizer::normalize($title, \Normalizer::FORM_C) ?: $title;
|
||||||
|
|
||||||
|
// Keep all Unicode letters and numbers; replace everything else with dashes.
|
||||||
|
// This preserves Chinese, Arabic, Japanese, Cyrillic, etc. as-is.
|
||||||
|
// Characters illegal in SMB/Windows filenames (\ / : * ? " < > |) are all
|
||||||
|
// excluded by \p{L}\p{N}, so the result is always filesystem-safe.
|
||||||
|
$slug = preg_replace('/[^\p{L}\p{N}]+/u', '-', $title);
|
||||||
|
$slug = mb_strtolower($slug);
|
||||||
$slug = trim($slug, '-');
|
$slug = trim($slug, '-');
|
||||||
|
|
||||||
|
// Cap at 100 chars to stay safely within any filesystem path limit
|
||||||
|
if (mb_strlen($slug) > 100) {
|
||||||
|
$slug = rtrim(mb_substr($slug, 0, 100), '-');
|
||||||
|
}
|
||||||
|
|
||||||
return $slug ?: 'video';
|
return $slug ?: 'video';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -338,6 +351,59 @@ class NasSyncService
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete cached NAS video files from nas_cache/videos/.
|
||||||
|
*
|
||||||
|
* @param int $olderThanHours Only delete files last-accessed more than N hours ago.
|
||||||
|
* Pass 0 to delete everything regardless of age.
|
||||||
|
* @return int Number of files deleted.
|
||||||
|
*/
|
||||||
|
public function clearNasCache(int $olderThanHours = 24): int
|
||||||
|
{
|
||||||
|
$cacheDir = storage_path('app/nas_cache/videos');
|
||||||
|
if (! is_dir($cacheDir)) return 0;
|
||||||
|
|
||||||
|
$cutoff = time() - ($olderThanHours * 3600);
|
||||||
|
$deleted = 0;
|
||||||
|
|
||||||
|
foreach (glob("{$cacheDir}/*") as $file) {
|
||||||
|
if (! is_file($file)) continue;
|
||||||
|
// Use mtime (last modified) as a proxy for last-used
|
||||||
|
if ($olderThanHours === 0 || filemtime($file) < $cutoff) {
|
||||||
|
if (@unlink($file)) {
|
||||||
|
$deleted++;
|
||||||
|
Log::info('NAS cache: evicted ' . basename($file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the directory itself if now empty
|
||||||
|
if (is_dir($cacheDir) && empty(array_diff(scandir($cacheDir) ?: [], ['.', '..']))) {
|
||||||
|
@rmdir($cacheDir);
|
||||||
|
$parent = dirname($cacheDir); // nas_cache/
|
||||||
|
if (is_dir($parent) && empty(array_diff(scandir($parent) ?: [], ['.', '..']))) {
|
||||||
|
@rmdir($parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the total size in bytes of all files in nas_cache/videos/.
|
||||||
|
*/
|
||||||
|
public function nasCacheSize(): int
|
||||||
|
{
|
||||||
|
$cacheDir = storage_path('app/nas_cache/videos');
|
||||||
|
if (! is_dir($cacheDir)) return 0;
|
||||||
|
|
||||||
|
$total = 0;
|
||||||
|
foreach (glob("{$cacheDir}/*") as $file) {
|
||||||
|
if (is_file($file)) $total += filesize($file);
|
||||||
|
}
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete the local video file after it has been successfully pushed to NAS.
|
* Delete the local video file after it has been successfully pushed to NAS.
|
||||||
*/
|
*/
|
||||||
@ -372,6 +438,49 @@ class NasSyncService
|
|||||||
Log::info('NAS: local assets removed after NAS push', ['video_id' => $video->id]);
|
Log::info('NAS: local assets removed after NAS push', ['video_id' => $video->id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the local video directory and all empty ancestor directories up to
|
||||||
|
* storage/app/users. Call this after deleteLocalVideo + deleteLocalAssets so
|
||||||
|
* no ghost folders are left behind.
|
||||||
|
*/
|
||||||
|
public function pruneLocalVideoDir(Video $video): void
|
||||||
|
{
|
||||||
|
$videoDir = $this->localVideoDir($video);
|
||||||
|
|
||||||
|
// Remove meta.json helper file
|
||||||
|
@unlink("{$videoDir}/meta.json");
|
||||||
|
|
||||||
|
// Remove slides/ subdirectory if empty
|
||||||
|
$slidesDir = "{$videoDir}/slides";
|
||||||
|
if (is_dir($slidesDir) && $this->isDirEmpty($slidesDir)) {
|
||||||
|
@rmdir($slidesDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the video directory itself if now empty
|
||||||
|
if (is_dir($videoDir) && $this->isDirEmpty($videoDir)) {
|
||||||
|
@rmdir($videoDir);
|
||||||
|
|
||||||
|
// Walk up: remove videos/ and users/{username}/ if they become empty
|
||||||
|
$parent = dirname($videoDir); // …/users/{username}/videos
|
||||||
|
if (is_dir($parent) && $this->isDirEmpty($parent)) {
|
||||||
|
@rmdir($parent);
|
||||||
|
|
||||||
|
$grandparent = dirname($parent); // …/users/{username}
|
||||||
|
if (is_dir($grandparent) && $this->isDirEmpty($grandparent)) {
|
||||||
|
@rmdir($grandparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('NAS: local video directory pruned', ['video_id' => $video->id, 'dir' => $videoDir]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isDirEmpty(string $dir): bool
|
||||||
|
{
|
||||||
|
$items = array_diff(scandir($dir) ?: [], ['.', '..']);
|
||||||
|
return empty($items);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Direct NAS upload (NAS-primary mode) ──────────────────────────────────
|
// ── Direct NAS upload (NAS-primary mode) ──────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -534,16 +643,107 @@ class NasSyncService
|
|||||||
|
|
||||||
public function deleteVideo(Video $video): void
|
public function deleteVideo(Video $video): void
|
||||||
{
|
{
|
||||||
$video->loadMissing('user');
|
$video->loadMissing(['user', 'slides']);
|
||||||
$dir = $this->resolveVideoDir($video);
|
$dir = $this->resolveVideoDir($video);
|
||||||
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
|
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
|
||||||
|
|
||||||
|
// ── Files in the video root ───────────────────────────────────────────
|
||||||
$this->deleteFile("{$dir}/" . $this->titleSlug($video->title) . ".{$ext}");
|
$this->deleteFile("{$dir}/" . $this->titleSlug($video->title) . ".{$ext}");
|
||||||
$this->deleteFile("{$dir}/thumb.webp");
|
$this->deleteFile("{$dir}/thumb.webp");
|
||||||
$this->deleteFile("{$dir}/meta.json");
|
$this->deleteFile("{$dir}/meta.json");
|
||||||
$this->deleteFile("{$dir}/view-log.json");
|
$this->deleteFile("{$dir}/view-log.json");
|
||||||
$this->deleteFile("{$dir}/edit-log.json");
|
$this->deleteFile("{$dir}/edit-log.json");
|
||||||
|
|
||||||
|
// ── slides/ subdirectory ──────────────────────────────────────────────
|
||||||
|
// Delete each known slide file, then any leftover wildcard, then the dir.
|
||||||
|
foreach ($video->slides as $slide) {
|
||||||
|
$slideExt = pathinfo($slide->filename, PATHINFO_EXTENSION) ?: 'jpg';
|
||||||
|
$this->deleteFile("{$dir}/slides/{$slide->position}.{$slideExt}");
|
||||||
|
}
|
||||||
|
$this->deleteFilesInDir("{$dir}/slides"); // catch anything not in DB
|
||||||
|
$this->deleteFolder("{$dir}/slides");
|
||||||
|
|
||||||
|
// ── Video directory itself ────────────────────────────────────────────
|
||||||
$this->deleteFolder($dir);
|
$this->deleteFolder($dir);
|
||||||
|
|
||||||
|
// ── Local NAS stream-cache copy ───────────────────────────────────────
|
||||||
|
$cachePath = $this->localCachePath($video);
|
||||||
|
if (file_exists($cachePath)) @unlink($cachePath);
|
||||||
|
|
||||||
|
Log::info('NAS: video deleted', ['video_id' => $video->id, 'dir' => $dir]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Posts ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NAS directory for a post's attachments: users/{slug}/posts/{id}/
|
||||||
|
*/
|
||||||
|
public function resolvePostDir(\App\Models\Post $post): string
|
||||||
|
{
|
||||||
|
$post->loadMissing('user');
|
||||||
|
return 'users/' . $this->userSlug($post->user) . '/posts/' . $post->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload all new-format post images to NAS (NAS path == relative path stored in DB).
|
||||||
|
*/
|
||||||
|
public function syncPostImages(\App\Models\Post $post): void
|
||||||
|
{
|
||||||
|
$post->loadMissing('postImages');
|
||||||
|
$dir = $this->resolvePostDir($post);
|
||||||
|
$this->mkdirp($dir);
|
||||||
|
|
||||||
|
if ($post->image && str_starts_with($post->image, 'users/')) {
|
||||||
|
$local = storage_path('app/' . $post->image);
|
||||||
|
if (file_exists($local)) $this->putFile($local, $post->image);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($post->postImages as $img) {
|
||||||
|
if (! str_starts_with($img->filename, 'users/')) continue;
|
||||||
|
$local = storage_path('app/' . $img->filename);
|
||||||
|
if (file_exists($local)) $this->putFile($local, $img->filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete local post image files and prune the empty local post directory.
|
||||||
|
* Handles both the old flat format and the new users/{slug}/posts/{id}/ format.
|
||||||
|
*/
|
||||||
|
public function deleteLocalPostImages(\App\Models\Post $post): void
|
||||||
|
{
|
||||||
|
$post->loadMissing('postImages');
|
||||||
|
|
||||||
|
$paths = [];
|
||||||
|
if ($post->image) {
|
||||||
|
$paths[] = str_starts_with($post->image, 'users/')
|
||||||
|
? storage_path('app/' . $post->image)
|
||||||
|
: storage_path('app/public/post_images/' . $post->image);
|
||||||
|
}
|
||||||
|
foreach ($post->postImages as $img) {
|
||||||
|
$paths[] = str_starts_with($img->filename, 'users/')
|
||||||
|
? storage_path('app/' . $img->filename)
|
||||||
|
: storage_path('app/public/post_images/' . $img->filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
if (file_exists($path)) @unlink($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune empty local post dir
|
||||||
|
$localDir = storage_path('app/' . $this->resolvePostDir($post));
|
||||||
|
if (is_dir($localDir) && empty(array_diff(scandir($localDir) ?: [], ['.', '..']))) {
|
||||||
|
@rmdir($localDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the post's entire NAS directory tree.
|
||||||
|
*/
|
||||||
|
public function deleteNasPost(\App\Models\Post $post): void
|
||||||
|
{
|
||||||
|
$dir = $this->resolvePostDir($post);
|
||||||
|
$this->deleteNasTree($dir);
|
||||||
|
Log::info('NAS: post deleted', ['post_id' => $post->id, 'dir' => $dir]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function syncAvatar(User $user, string $localAbsPath): void
|
public function syncAvatar(User $user, string $localAbsPath): void
|
||||||
@ -560,6 +760,68 @@ class NasSyncService
|
|||||||
$this->putFile($localAbsPath, "{$dir}/cover.webp");
|
$this->putFile($localAbsPath, "{$dir}/cover.webp");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute path of the local profile directory for a user.
|
||||||
|
* Mirrors the NAS path: storage/app/users/{slug}/profile/
|
||||||
|
*/
|
||||||
|
public function localProfileDir(User $user): string
|
||||||
|
{
|
||||||
|
return storage_path('app/users/' . $this->userSlug($user) . '/profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteLocalAvatar(User $user): void
|
||||||
|
{
|
||||||
|
if (! $user->avatar) return;
|
||||||
|
|
||||||
|
if (str_starts_with($user->avatar, 'users/')) {
|
||||||
|
// New format: relative path stored in DB
|
||||||
|
$path = storage_path('app/' . $user->avatar);
|
||||||
|
} else {
|
||||||
|
// Legacy flat format
|
||||||
|
$path = storage_path('app/public/avatars/' . $user->avatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_exists($path)) @unlink($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteLocalBanner(User $user): void
|
||||||
|
{
|
||||||
|
if (! $user->banner) return;
|
||||||
|
|
||||||
|
if (str_starts_with($user->banner, 'users/')) {
|
||||||
|
$path = storage_path('app/' . $user->banner);
|
||||||
|
} else {
|
||||||
|
$path = storage_path('app/public/banners/' . $user->banner);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_exists($path)) @unlink($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a file exists on the NAS share.
|
||||||
|
* Uses a lightweight smbclient ls — does not download the file.
|
||||||
|
*/
|
||||||
|
public function nasFileExists(string $nasRelPath): bool
|
||||||
|
{
|
||||||
|
$cfg = $this->cfg();
|
||||||
|
$target = escapeshellarg($this->smbTarget($cfg));
|
||||||
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
||||||
|
$cmd = 'ls "' . $nasRelPath . '"';
|
||||||
|
|
||||||
|
exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg($cmd) . ' 2>&1', $output, $code);
|
||||||
|
|
||||||
|
// smbclient exits 0 and output contains the filename when found;
|
||||||
|
// exits non-zero or contains NT_STATUS_NO_SUCH_FILE when missing.
|
||||||
|
if ($code !== 0) return false;
|
||||||
|
foreach ($output as $line) {
|
||||||
|
if (str_contains($line, 'NT_STATUS_NO_SUCH_FILE') ||
|
||||||
|
str_contains($line, 'NT_STATUS_OBJECT_NAME_NOT_FOUND')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ── SMB primitives ────────────────────────────────────────────────────────
|
// ── SMB primitives ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function mkdirp(string $path): void
|
public function mkdirp(string $path): void
|
||||||
@ -633,6 +895,21 @@ class NasSyncService
|
|||||||
exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg('rm "' . $nasRelPath . '"') . ' 2>&1');
|
exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg('rm "' . $nasRelPath . '"') . ' 2>&1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all files inside a NAS directory using a wildcard.
|
||||||
|
* Does NOT remove the directory itself — call deleteFolder() after.
|
||||||
|
*/
|
||||||
|
public function deleteFilesInDir(string $nasRelPath): void
|
||||||
|
{
|
||||||
|
$cfg = $this->cfg();
|
||||||
|
$target = escapeshellarg($this->smbTarget($cfg));
|
||||||
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
||||||
|
// smbclient `del` accepts a mask relative to the share root
|
||||||
|
exec('smbclient ' . $target . ' -U ' . $cred
|
||||||
|
. ' -c ' . escapeshellarg('del "' . $nasRelPath . '/*"')
|
||||||
|
. ' 2>&1');
|
||||||
|
}
|
||||||
|
|
||||||
public function deleteFolder(string $nasRelPath): void
|
public function deleteFolder(string $nasRelPath): void
|
||||||
{
|
{
|
||||||
$cfg = $this->cfg();
|
$cfg = $this->cfg();
|
||||||
@ -641,6 +918,175 @@ class NasSyncService
|
|||||||
exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg('rmdir "' . $nasRelPath . '"') . ' 2>&1');
|
exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg('rmdir "' . $nasRelPath . '"') . ' 2>&1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename the video's NAS and local directories to match the current title,
|
||||||
|
* then update all affected DB paths (video, slides).
|
||||||
|
*
|
||||||
|
* Call this after video.title has been saved but before syncVideo(), so that
|
||||||
|
* the sync job writes to the correctly-named folder.
|
||||||
|
*/
|
||||||
|
public function renameVideoDir(Video $video): void
|
||||||
|
{
|
||||||
|
if (! str_starts_with($video->path, 'users/')) return;
|
||||||
|
|
||||||
|
$video->loadMissing(['user', 'slides']);
|
||||||
|
$userSlug = $this->userSlug($video->user);
|
||||||
|
$base = "users/{$userSlug}/videos";
|
||||||
|
|
||||||
|
// Current folder derived from the stored path (title not yet reflected in folder name)
|
||||||
|
$currentDir = dirname($video->path); // "users/{slug}/videos/{old-slug}"
|
||||||
|
|
||||||
|
// Desired folder based on the (already-saved) new title
|
||||||
|
$newDir = $this->findFreeVideoDir($base, $this->titleSlug($video->title), $video->id);
|
||||||
|
|
||||||
|
if ($currentDir === $newDir) return;
|
||||||
|
|
||||||
|
// Rename on NAS
|
||||||
|
$this->renameNasPath($currentDir, $newDir);
|
||||||
|
|
||||||
|
// Rename locally if the dir exists
|
||||||
|
$oldLocal = storage_path('app/' . $currentDir);
|
||||||
|
$newLocal = storage_path('app/' . $newDir);
|
||||||
|
if (is_dir($oldLocal)) {
|
||||||
|
rename($oldLocal, $newLocal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all DB paths that reference the old directory
|
||||||
|
$oldPrefix = $currentDir . '/';
|
||||||
|
$newPrefix = $newDir . '/';
|
||||||
|
|
||||||
|
$videoUpdates = [];
|
||||||
|
if (str_starts_with($video->path, $oldPrefix)) {
|
||||||
|
$videoUpdates['path'] = $newPrefix . substr($video->path, strlen($oldPrefix));
|
||||||
|
}
|
||||||
|
if ($video->thumbnail && str_starts_with($video->thumbnail, $oldPrefix)) {
|
||||||
|
$videoUpdates['thumbnail'] = $newPrefix . substr($video->thumbnail, strlen($oldPrefix));
|
||||||
|
}
|
||||||
|
if (! empty($videoUpdates)) {
|
||||||
|
$video->update($videoUpdates);
|
||||||
|
$video->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($video->slides as $slide) {
|
||||||
|
if (str_starts_with($slide->filename, $oldPrefix)) {
|
||||||
|
$slide->update(['filename' => $newPrefix . substr($slide->filename, strlen($oldPrefix))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('NAS: video dir renamed', [
|
||||||
|
'video_id' => $video->id,
|
||||||
|
'from' => $currentDir,
|
||||||
|
'to' => $newDir,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a free (or already-owned) NAS video directory for the given slug.
|
||||||
|
*/
|
||||||
|
private function findFreeVideoDir(string $base, string $slug, int $videoId): string
|
||||||
|
{
|
||||||
|
for ($i = 1; $i <= 50; $i++) {
|
||||||
|
$candidate = $i === 1 ? $slug : "{$slug}-{$i}";
|
||||||
|
$meta = $this->readMeta("{$base}/{$candidate}");
|
||||||
|
if ($meta === null) return "{$base}/{$candidate}";
|
||||||
|
if (($meta['id'] ?? null) === $videoId) return "{$base}/{$candidate}";
|
||||||
|
}
|
||||||
|
return "{$base}/{$slug}-{$videoId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a path on the NAS share (works for both files and directories).
|
||||||
|
*/
|
||||||
|
private function renameNasPath(string $oldNasRelPath, string $newNasRelPath): void
|
||||||
|
{
|
||||||
|
$cfg = $this->cfg();
|
||||||
|
$target = escapeshellarg($this->smbTarget($cfg));
|
||||||
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
||||||
|
$old = str_replace('/', '\\', $oldNasRelPath);
|
||||||
|
$new = str_replace('/', '\\', $newNasRelPath);
|
||||||
|
exec(
|
||||||
|
'smbclient ' . $target . ' -U ' . $cred
|
||||||
|
. ' -c ' . escapeshellarg("rename \"{$old}\" \"{$new}\"")
|
||||||
|
. ' 2>&1',
|
||||||
|
$output, $code
|
||||||
|
);
|
||||||
|
if ($code !== 0) {
|
||||||
|
Log::warning('NAS rename failed', [
|
||||||
|
'old' => $oldNasRelPath,
|
||||||
|
'new' => $newNasRelPath,
|
||||||
|
'output' => implode(' ', $output),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List subdirectory names directly under a NAS path.
|
||||||
|
*/
|
||||||
|
public function listNasDirs(string $nasRelPath): array
|
||||||
|
{
|
||||||
|
$cfg = $this->cfg();
|
||||||
|
$target = escapeshellarg($this->smbTarget($cfg));
|
||||||
|
$cred = escapeshellarg($this->smbCredential($cfg));
|
||||||
|
$smbPath = str_replace('/', '\\', $nasRelPath) . '\\*';
|
||||||
|
|
||||||
|
exec('smbclient ' . $target . ' -U ' . $cred . ' -c ' . escapeshellarg("ls \"{$smbPath}\"") . ' 2>&1', $output);
|
||||||
|
|
||||||
|
$dirs = [];
|
||||||
|
foreach ($output as $line) {
|
||||||
|
if (! preg_match('/^ (.+?)\s{2,}D\s/', $line, $m)) continue;
|
||||||
|
$name = trim($m[1]);
|
||||||
|
if ($name === '.' || $name === '..') continue;
|
||||||
|
$dirs[] = $name;
|
||||||
|
}
|
||||||
|
return $dirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively delete a NAS directory tree (all files, subdirs, then the dir itself).
|
||||||
|
*/
|
||||||
|
public function deleteNasTree(string $nasRelPath): void
|
||||||
|
{
|
||||||
|
foreach ($this->listNasDirs($nasRelPath) as $subdir) {
|
||||||
|
$this->deleteNasTree("{$nasRelPath}/{$subdir}");
|
||||||
|
}
|
||||||
|
$this->deleteFilesInDir($nasRelPath);
|
||||||
|
$this->deleteFolder($nasRelPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan every video folder on the NAS and return those whose meta.json
|
||||||
|
* references a video ID that no longer exists in the database.
|
||||||
|
*
|
||||||
|
* Returns array of ['dir' => 'users/{slug}/videos/{slug}', 'video_id' => int|null]
|
||||||
|
*/
|
||||||
|
public function scanNasOrphans(): array
|
||||||
|
{
|
||||||
|
$orphans = [];
|
||||||
|
$userDirs = $this->listNasDirs('users');
|
||||||
|
|
||||||
|
foreach ($userDirs as $userSlug) {
|
||||||
|
$videosBase = "users/{$userSlug}/videos";
|
||||||
|
$videoDirs = $this->listNasDirs($videosBase);
|
||||||
|
|
||||||
|
foreach ($videoDirs as $videoSlug) {
|
||||||
|
$dir = "{$videosBase}/{$videoSlug}";
|
||||||
|
$meta = $this->readMeta($dir);
|
||||||
|
$videoId = $meta ? ($meta['id'] ?? null) : null;
|
||||||
|
|
||||||
|
if ($videoId === null) {
|
||||||
|
$orphans[] = ['dir' => $dir, 'video_id' => null];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! \App\Models\Video::where('id', $videoId)->exists()) {
|
||||||
|
$orphans[] = ['dir' => $dir, 'video_id' => $videoId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $orphans;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private function cfg(): array
|
private function cfg(): array
|
||||||
|
|||||||
19
composer.lock
generated
19
composer.lock
generated
@ -2808,18 +2808,21 @@
|
|||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/itsp7h/File-Structure-package.git",
|
"url": "https://github.com/itsp7h/File-Structure-package.git",
|
||||||
"reference": "9018271e2b73099730328191c8a4a3f2606ddc47"
|
"reference": "44462665a31200d2ff791557bafc970fdf07a6c2"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/itsp7h/File-Structure-package/zipball/9018271e2b73099730328191c8a4a3f2606ddc47",
|
"url": "https://api.github.com/repos/itsp7h/File-Structure-package/zipball/44462665a31200d2ff791557bafc970fdf07a6c2",
|
||||||
"reference": "9018271e2b73099730328191c8a4a3f2606ddc47",
|
"reference": "44462665a31200d2ff791557bafc970fdf07a6c2",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"illuminate/support": "^10.0|^11.0|^12.0",
|
"illuminate/support": "^10.0|^11.0|^12.0",
|
||||||
"php": "^8.1"
|
"php": "^8.1"
|
||||||
},
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"composer/composer": "^2.0"
|
||||||
|
},
|
||||||
"default-branch": true,
|
"default-branch": true,
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
@ -2834,6 +2837,14 @@
|
|||||||
"P7H\\NasFileManager\\": "src/"
|
"P7H\\NasFileManager\\": "src/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"post-install-cmd": [
|
||||||
|
"P7H\\NasFileManager\\Installer::install"
|
||||||
|
],
|
||||||
|
"post-update-cmd": [
|
||||||
|
"P7H\\NasFileManager\\Installer::install"
|
||||||
|
]
|
||||||
|
},
|
||||||
"license": [
|
"license": [
|
||||||
"MIT"
|
"MIT"
|
||||||
],
|
],
|
||||||
@ -2842,7 +2853,7 @@
|
|||||||
"source": "https://github.com/itsp7h/File-Structure-package/tree/main",
|
"source": "https://github.com/itsp7h/File-Structure-package/tree/main",
|
||||||
"issues": "https://github.com/itsp7h/File-Structure-package/issues"
|
"issues": "https://github.com/itsp7h/File-Structure-package/issues"
|
||||||
},
|
},
|
||||||
"time": "2026-05-13T10:39:12+00:00"
|
"time": "2026-05-14T12:07:04+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "paragonie/constant_time_encoding",
|
"name": "paragonie/constant_time_encoding",
|
||||||
|
|||||||
@ -49,9 +49,32 @@ return [
|
|||||||
| <x-nas-file-manager::file-manager :nodes="$myNodes" />
|
| <x-nas-file-manager::file-manager :nodes="$myNodes" />
|
||||||
*/
|
*/
|
||||||
'schema' => [
|
'schema' => [
|
||||||
// Example — uncomment and adapt:
|
|
||||||
// ['depth' => 0, 'label' => 'Media', 'path' => 'Media', 'parent_path' => null, 'is_template' => false, 'can_edit' => false],
|
// ── users ─────────────────────────────────────────────────────────────
|
||||||
// ['depth' => 1, 'label' => 'Outlets', 'path' => 'Media/Outlets', 'parent_path' => 'Media', 'is_template' => false, 'can_edit' => true],
|
['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],
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -366,7 +366,7 @@
|
|||||||
<td style="color:var(--text-secondary);font-weight:700;">{{ $i + 1 }}</td>
|
<td style="color:var(--text-secondary);font-weight:700;">{{ $i + 1 }}</td>
|
||||||
<td>
|
<td>
|
||||||
@if($v->thumbnail)
|
@if($v->thumbnail)
|
||||||
<img src="{{ asset('storage/thumbnails/'.$v->thumbnail) }}" class="top-thumb" alt="">
|
<img src="{{ route('media.thumbnail', $v->thumbnail) }}" class="top-thumb" alt="">
|
||||||
@else
|
@else
|
||||||
<div class="top-thumb-placeholder"><i class="bi bi-play-fill"></i></div>
|
<div class="top-thumb-placeholder"><i class="bi bi-play-fill"></i></div>
|
||||||
@endif
|
@endif
|
||||||
@ -559,7 +559,7 @@
|
|||||||
<tr class="clickable-row" onclick="window.open('{{ route('videos.show', $video) }}','_blank')">
|
<tr class="clickable-row" onclick="window.open('{{ route('videos.show', $video) }}','_blank')">
|
||||||
<td>
|
<td>
|
||||||
@if($video->thumbnail)
|
@if($video->thumbnail)
|
||||||
<img src="{{ asset('storage/thumbnails/'.$video->thumbnail) }}" class="top-thumb" alt="">
|
<img src="{{ route('media.thumbnail', $video->thumbnail) }}" class="top-thumb" alt="">
|
||||||
@else
|
@else
|
||||||
<div class="top-thumb-placeholder"><i class="bi bi-play-fill"></i></div>
|
<div class="top-thumb-placeholder"><i class="bi bi-play-fill"></i></div>
|
||||||
@endif
|
@endif
|
||||||
@ -619,6 +619,12 @@
|
|||||||
<i class="bi bi-trash3-fill me-2"></i> Clean Orphaned Files
|
<i class="bi bi-trash3-fill me-2"></i> Clean Orphaned Files
|
||||||
</button>
|
</button>
|
||||||
<div id="cleanupStatus" class="mt-2"></div>
|
<div id="cleanupStatus" class="mt-2"></div>
|
||||||
|
@if(\App\Models\Setting::get('nas_sync_enabled', 'false') === 'true')
|
||||||
|
<button id="nasRepairBtn" class="btn btn-warning w-100 mt-2">
|
||||||
|
<i class="bi bi-arrow-repeat me-2"></i> Repair NAS Storage
|
||||||
|
</button>
|
||||||
|
<div id="nasRepairStatus" class="mt-2"></div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
@ -1046,5 +1052,32 @@ document.getElementById('cleanupBtn')?.addEventListener('click', async function(
|
|||||||
this.disabled = false;
|
this.disabled = false;
|
||||||
this.innerHTML = '<i class="bi bi-trash3-fill me-2"></i> Clean Orphaned Files';
|
this.innerHTML = '<i class="bi bi-trash3-fill me-2"></i> Clean Orphaned Files';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── NAS Repair ──────────────────────────────────────────────────
|
||||||
|
document.getElementById('nasRepairBtn')?.addEventListener('click', async function() {
|
||||||
|
this.disabled = true;
|
||||||
|
this.innerHTML = '<i class="bi bi-arrow-repeat me-2"></i> Scanning…';
|
||||||
|
const status = document.getElementById('nasRepairStatus');
|
||||||
|
status.innerHTML = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('{{ url("/admin/nas-repair") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content, 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
const cls = data.success ? 'alert-success' : 'alert-warning';
|
||||||
|
const icon = data.success ? '✅' : '⚠️';
|
||||||
|
let html = `<div class="alert ${cls} mt-2 py-2 px-3" style="font-size:13px;">${icon} ${data.message}`;
|
||||||
|
if (data.details && data.details.length) {
|
||||||
|
html += `<ul class="mb-0 mt-1 ps-3" style="font-size:12px;">` + data.details.map(d => `<li>${d}</li>`).join('') + `</ul>`;
|
||||||
|
}
|
||||||
|
html += `</div>`;
|
||||||
|
status.innerHTML = html;
|
||||||
|
} catch(e) {
|
||||||
|
status.innerHTML = `<div class="alert alert-danger mt-2 py-2 px-3" style="font-size:13px;">❌ ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
this.disabled = false;
|
||||||
|
this.innerHTML = '<i class="bi bi-arrow-repeat me-2"></i> Repair NAS Storage';
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@ -3,6 +3,11 @@
|
|||||||
@section('title', 'Edit Video')
|
@section('title', 'Edit Video')
|
||||||
@section('page_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')
|
@section('extra_styles')
|
||||||
<style>
|
<style>
|
||||||
.ef-grid {
|
.ef-grid {
|
||||||
@ -77,6 +82,9 @@
|
|||||||
background: rgba(248,113,113,.05);
|
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; }
|
.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>
|
</style>
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@ -203,7 +211,7 @@
|
|||||||
<div class="adm-card">
|
<div class="adm-card">
|
||||||
<div class="adm-card-body">
|
<div class="adm-card-body">
|
||||||
@if($video->thumbnail)
|
@if($video->thumbnail)
|
||||||
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}"
|
<img src="{{ route('media.thumbnail', $video->thumbnail) }}"
|
||||||
alt="{{ $video->title }}" class="ef-thumb">
|
alt="{{ $video->title }}" class="ef-thumb">
|
||||||
@else
|
@else
|
||||||
<div class="ef-thumb-placeholder"><i class="bi bi-play-circle"></i></div>
|
<div class="ef-thumb-placeholder"><i class="bi bi-play-circle"></i></div>
|
||||||
@ -259,6 +267,41 @@
|
|||||||
</div>
|
</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 --}}
|
{{-- Danger zone --}}
|
||||||
<div class="ef-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>
|
<div class="ef-danger-title"><i class="bi bi-exclamation-triangle-fill me-1"></i> Danger Zone</div>
|
||||||
@ -289,15 +332,25 @@
|
|||||||
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
|
<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.
|
The video file, thumbnail, and all associated data will be removed. This cannot be undone.
|
||||||
</div>
|
</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
|
||||||
|
<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>
|
||||||
<div class="adm-dialog-footer">
|
<div class="adm-dialog-footer">
|
||||||
<button type="button" class="adm-btn" onclick="closeDelDialog()">Cancel</button>
|
<button type="button" class="adm-btn" onclick="closeDelDialog()">Cancel</button>
|
||||||
<form method="POST" action="{{ route('admin.videos.delete', $video) }}">
|
<button type="button" class="adm-btn adm-btn-danger" id="delDialogConfirmBtn" onclick="confirmDelVideo()">
|
||||||
@csrf @method('DELETE')
|
|
||||||
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
|
|
||||||
<i class="bi bi-trash3-fill"></i> Delete Permanently
|
<i class="bi bi-trash3-fill"></i> Delete Permanently
|
||||||
</button>
|
</button>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -306,9 +359,130 @@
|
|||||||
|
|
||||||
@section('scripts')
|
@section('scripts')
|
||||||
<script>
|
<script>
|
||||||
function openDelDialog() { document.getElementById('delDialog').classList.add('open'); }
|
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 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.getElementById('delDialog').addEventListener('click', e => { if (e.target === document.getElementById('delDialog')) closeDelDialog(); });
|
||||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') 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>
|
</script>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@ -535,7 +535,7 @@
|
|||||||
@auth
|
@auth
|
||||||
<button class="adm-user-btn" id="userMenuBtn" type="button">
|
<button class="adm-user-btn" id="userMenuBtn" type="button">
|
||||||
@if(Auth::user()->avatar)
|
@if(Auth::user()->avatar)
|
||||||
<img src="{{ asset('storage/avatars/' . Auth::user()->avatar) }}" class="adm-user-avatar" alt="">
|
<img src="{{ route('media.avatar', Auth::user()->avatar) }}" class="adm-user-avatar" alt="">
|
||||||
@else
|
@else
|
||||||
<img src="https://i.pravatar.cc/150?u={{ Auth::user()->id }}" class="adm-user-avatar" alt="">
|
<img src="https://i.pravatar.cc/150?u={{ Auth::user()->id }}" class="adm-user-avatar" alt="">
|
||||||
@endif
|
@endif
|
||||||
@ -622,7 +622,7 @@
|
|||||||
<div class="adm-sidebar-footer">
|
<div class="adm-sidebar-footer">
|
||||||
<a href="{{ route('profile') }}" class="adm-sidebar-user">
|
<a href="{{ route('profile') }}" class="adm-sidebar-user">
|
||||||
@if(Auth::user()->avatar)
|
@if(Auth::user()->avatar)
|
||||||
<img src="{{ asset('storage/avatars/' . Auth::user()->avatar) }}" alt="">
|
<img src="{{ route('media.avatar', Auth::user()->avatar) }}" alt="">
|
||||||
@else
|
@else
|
||||||
<img src="https://i.pravatar.cc/150?u={{ Auth::user()->id }}" alt="">
|
<img src="https://i.pravatar.cc/150?u={{ Auth::user()->id }}" alt="">
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@ -336,26 +336,81 @@
|
|||||||
<small>
|
<small>
|
||||||
When enabled, uploads go <strong>directly to the NAS</strong> — no permanent local copy is kept.
|
When enabled, uploads go <strong>directly to the NAS</strong> — no permanent local copy is kept.
|
||||||
Files are stored at <code>users/{username}/videos/{title-slug}/</code> on the NAS share.
|
Files are stored at <code>users/{username}/videos/{title-slug}/</code> on the NAS share.
|
||||||
Thumbnails and video are fetched from NAS on demand for streaming and playback.
|
When disabled, all files are served from local disk using the same directory schema.
|
||||||
When disabled, files are stored in local storage using the same directory schema.
|
<strong>Disabling NAS will prompt you to migrate files or start fresh.</strong>
|
||||||
Requires the NAS connection to be configured under
|
|
||||||
<a href="{{ route('admin.nas-storage') }}" style="color:var(--brand)">NAS Storage</a>.
|
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-control">
|
<div class="setting-control">
|
||||||
<label class="toggle-wrap">
|
@if($settings['nas_sync_enabled'] === 'true')
|
||||||
<div class="toggle-switch">
|
<button type="button" class="adm-btn adm-btn-danger" onclick="openNasDisableModal()">
|
||||||
<input type="checkbox" id="nasSyncInput" name="nas_sync_enabled_check"
|
<i class="bi bi-hdd-network"></i> Disable NAS
|
||||||
{{ $settings['nas_sync_enabled'] === 'true' ? 'checked' : '' }}>
|
</button>
|
||||||
<div class="toggle-track"></div>
|
@else
|
||||||
<div class="toggle-thumb"></div>
|
<span style="font-size:13px;color:var(--text-2);">NAS is disabled. Enable it under <a href="{{ route('admin.nas-storage') }}" style="color:var(--brand)">NAS Storage</a>.</span>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<span class="toggle-label" id="nasSyncLabel">
|
</div>
|
||||||
{{ $settings['nas_sync_enabled'] === 'true' ? 'Enabled' : 'Disabled' }}
|
|
||||||
</span>
|
@if($settings['nas_sync_enabled'] === 'true')
|
||||||
</label>
|
<div class="setting-row" style="border-top:1px solid var(--border-color);padding-top:18px;">
|
||||||
<input type="hidden" name="nas_sync_enabled" id="nasSyncHidden"
|
<div class="setting-label">
|
||||||
value="{{ $settings['nas_sync_enabled'] }}">
|
<strong>Repair stuck files</strong>
|
||||||
|
<small>
|
||||||
|
Scans for files that were saved locally but never reached the NAS (e.g. due to a
|
||||||
|
connection error during upload or edit). Uploads them to the NAS, then removes the
|
||||||
|
local copies. Safe to run at any time — nothing is deleted until the NAS confirms receipt.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control" style="gap:10px;align-items:flex-start;">
|
||||||
|
<button type="button" id="nasRepairScanBtn" class="adm-btn" style="white-space:nowrap;">
|
||||||
|
<i class="bi bi-search" id="nasRepairScanIcon"></i> Scan
|
||||||
|
</button>
|
||||||
|
<button type="button" id="nasRepairFixBtn" class="adm-btn adm-btn-primary" style="white-space:nowrap;display:none;">
|
||||||
|
<i class="bi bi-arrow-repeat" id="nasRepairFixIcon"></i> Fix All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="nasRepairResult" style="padding:0 22px 18px;display:none;">
|
||||||
|
<div id="nasRepairResultInner"></div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ── Backup & Restore ──────────────────────────────────────────────────────── --}}
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section-header">
|
||||||
|
<i class="bi bi-archive"></i>
|
||||||
|
Backup & Restore
|
||||||
|
</div>
|
||||||
|
<div class="settings-section-body">
|
||||||
|
|
||||||
|
<div class="setting-row" style="padding-top:0;">
|
||||||
|
<div class="setting-label">
|
||||||
|
<strong>Export users & settings</strong>
|
||||||
|
<small>Downloads a JSON file containing all user accounts and system settings. Does not include media files.</small>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<a href="{{ route('admin.backup.users-settings') }}" class="adm-btn">
|
||||||
|
<i class="bi bi-download"></i> Download Backup
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">
|
||||||
|
<strong>Restore users & settings</strong>
|
||||||
|
<small>Upload a previously exported backup JSON. Existing users are matched by email and updated; new users are created. Settings are merged.</small>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<form method="POST" action="{{ route('admin.backup.restore') }}" enctype="multipart/form-data" id="restoreForm">
|
||||||
|
@csrf
|
||||||
|
<input type="file" name="backup" id="restoreFile" accept=".json" style="display:none" onchange="document.getElementById('restoreForm').submit()">
|
||||||
|
<button type="button" class="adm-btn" onclick="document.getElementById('restoreFile').click()">
|
||||||
|
<i class="bi bi-upload"></i> Upload & Restore
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -392,15 +447,6 @@ function selectEncoder(el) {
|
|||||||
document.getElementById('gpuPresetSelect').value = isCpu ? 'fast' : 'p4';
|
document.getElementById('gpuPresetSelect').value = isCpu ? 'fast' : 'p4';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── NAS sync toggle ───────────────────────────────────────────
|
|
||||||
const nasToggle = document.getElementById('nasSyncInput');
|
|
||||||
const nasHidden = document.getElementById('nasSyncHidden');
|
|
||||||
const nasLabel = document.getElementById('nasSyncLabel');
|
|
||||||
nasToggle.addEventListener('change', () => {
|
|
||||||
nasHidden.value = nasToggle.checked ? 'true' : 'false';
|
|
||||||
nasLabel.textContent = nasToggle.checked ? 'Enabled' : 'Disabled';
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── GPU toggle ────────────────────────────────────────────────
|
// ── GPU toggle ────────────────────────────────────────────────
|
||||||
const gpuToggle = document.getElementById('gpuEnabledInput');
|
const gpuToggle = document.getElementById('gpuEnabledInput');
|
||||||
const gpuHidden = document.getElementById('gpuEnabledHidden');
|
const gpuHidden = document.getElementById('gpuEnabledHidden');
|
||||||
@ -476,9 +522,271 @@ function buildGpuCards(gpus, selectedDevice) {
|
|||||||
function escHtml(s) {
|
function escHtml(s) {
|
||||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── NAS Repair ────────────────────────────────────────────────
|
||||||
|
(function () {
|
||||||
|
const scanBtn = document.getElementById('nasRepairScanBtn');
|
||||||
|
const fixBtn = document.getElementById('nasRepairFixBtn');
|
||||||
|
const resultEl = document.getElementById('nasRepairResult');
|
||||||
|
const inner = document.getElementById('nasRepairResultInner');
|
||||||
|
const scanIcon = document.getElementById('nasRepairScanIcon');
|
||||||
|
const fixIcon = document.getElementById('nasRepairFixIcon');
|
||||||
|
if (! scanBtn) return;
|
||||||
|
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
|
||||||
|
let stuckCount = 0;
|
||||||
|
|
||||||
|
function showResult(html, type = 'info') {
|
||||||
|
const colours = { success: '#22c55e', warning: '#f59e0b', danger: '#ef4444', info: 'var(--text-secondary)' };
|
||||||
|
inner.innerHTML = `<div style="font-size:13px;color:${colours[type] ?? colours.info};padding:12px 0;">${html}</div>`;
|
||||||
|
resultEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
scanBtn.addEventListener('click', async function () {
|
||||||
|
scanBtn.disabled = true;
|
||||||
|
fixBtn.style.display = 'none';
|
||||||
|
scanIcon.className = 'bi bi-arrow-repeat spin';
|
||||||
|
showResult('Scanning…', 'info');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('{{ url("/admin/nas-repair") }}?scan=1', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
body: JSON.stringify({ scan_only: true }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
stuckCount = data.stuck ?? 0;
|
||||||
|
|
||||||
|
if (stuckCount === 0) {
|
||||||
|
showResult('✅ All clear — no stuck local files found.', 'success');
|
||||||
|
} else {
|
||||||
|
let html = `<strong style="color:var(--brand);">⚠ ${stuckCount} video(s) have files stuck locally.</strong>`;
|
||||||
|
if (data.details && data.details.length) {
|
||||||
|
html += `<ul style="margin:8px 0 0;padding-left:18px;">` +
|
||||||
|
data.details.map(d => `<li>${escHtml(d)}</li>`).join('') + `</ul>`;
|
||||||
|
}
|
||||||
|
html += `<p style="margin-top:10px;color:var(--text-secondary);font-size:12px;">Click <strong>Fix All</strong> to upload them to the NAS and remove local copies.</p>`;
|
||||||
|
showResult(html, 'warning');
|
||||||
|
fixBtn.style.display = '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showResult('❌ Scan failed: ' + escHtml(e.message), 'danger');
|
||||||
|
}
|
||||||
|
|
||||||
|
scanIcon.className = 'bi bi-search';
|
||||||
|
scanBtn.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
fixBtn.addEventListener('click', async function () {
|
||||||
|
if (! confirm(`Upload ${stuckCount} stuck file(s) to NAS and remove local copies?`)) return;
|
||||||
|
fixBtn.disabled = true;
|
||||||
|
scanBtn.disabled = true;
|
||||||
|
fixIcon.className = 'bi bi-arrow-repeat spin';
|
||||||
|
showResult('Uploading to NAS…', 'info');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('{{ url("/admin/nas-repair") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
const type = data.failed > 0 ? 'warning' : 'success';
|
||||||
|
let html = `${data.success ? '✅' : '⚠️'} ${escHtml(data.message)}`;
|
||||||
|
if (data.details && data.details.length) {
|
||||||
|
html += `<ul style="margin:8px 0 0;padding-left:18px;">` +
|
||||||
|
data.details.map(d => `<li>${escHtml(d)}</li>`).join('') + `</ul>`;
|
||||||
|
}
|
||||||
|
showResult(html, type);
|
||||||
|
fixBtn.style.display = 'none';
|
||||||
|
} catch (e) {
|
||||||
|
showResult('❌ Repair failed: ' + escHtml(e.message), 'danger');
|
||||||
|
}
|
||||||
|
|
||||||
|
fixIcon.className = 'bi bi-arrow-repeat';
|
||||||
|
fixBtn.disabled = false;
|
||||||
|
scanBtn.disabled = false;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── NAS Disable Modal ────────────────────────────────────────
|
||||||
|
let _nasOpt = null;
|
||||||
|
let _nasPollTimer = null;
|
||||||
|
|
||||||
|
function openNasDisableModal() {
|
||||||
|
_nasOpt = null;
|
||||||
|
document.getElementById('nasDisableModal').style.display = 'flex';
|
||||||
|
document.getElementById('nasDisableStep1').style.display = 'block';
|
||||||
|
document.getElementById('nasDisableStep2Migrate').style.display = 'none';
|
||||||
|
document.getElementById('nasDisableStep2Fresh').style.display = 'none';
|
||||||
|
document.getElementById('nasDisableNextBtn').disabled = true;
|
||||||
|
['optMigrate','optFresh'].forEach(id => {
|
||||||
|
document.getElementById(id).style.borderColor = 'var(--border)';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeNasDisableModal() {
|
||||||
|
if (_nasPollTimer) clearInterval(_nasPollTimer);
|
||||||
|
document.getElementById('nasDisableModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectNasOpt(opt) {
|
||||||
|
_nasOpt = opt;
|
||||||
|
document.getElementById('optMigrate').style.borderColor = opt === 'migrate' ? 'var(--brand)' : 'var(--border)';
|
||||||
|
document.getElementById('optFresh').style.borderColor = opt === 'fresh' ? '#e74c3c' : 'var(--border)';
|
||||||
|
document.getElementById('nasDisableNextBtn').disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nasDisableNext() {
|
||||||
|
document.getElementById('nasDisableStep1').style.display = 'none';
|
||||||
|
if (_nasOpt === 'migrate') {
|
||||||
|
document.getElementById('nasDisableStep2Migrate').style.display = 'block';
|
||||||
|
nasStartMigration();
|
||||||
|
} else {
|
||||||
|
document.getElementById('nasDisableStep2Fresh').style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nasStartMigration() {
|
||||||
|
try {
|
||||||
|
await fetch('{{ route("admin.nas.disable") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||||
|
body: JSON.stringify({ mode: 'migrate' }),
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('nasDisableError').textContent = 'Failed to start migration: ' + e.message;
|
||||||
|
document.getElementById('nasDisableError').style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_nasPollTimer = setInterval(nasPollProgress, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nasPollProgress() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('{{ route("admin.nas.migrate-progress") }}');
|
||||||
|
const d = await r.json();
|
||||||
|
const pct = d.total > 0 ? Math.round((d.current / d.total) * 100) : 0;
|
||||||
|
document.getElementById('nasDisableBar').style.width = pct + '%';
|
||||||
|
document.getElementById('nasDisableCount').textContent = d.current + ' / ' + d.total;
|
||||||
|
document.getElementById('nasDisablePhase').textContent = d.phase || '';
|
||||||
|
if (d.error) {
|
||||||
|
clearInterval(_nasPollTimer);
|
||||||
|
document.getElementById('nasDisableError').textContent = 'Error: ' + d.error;
|
||||||
|
document.getElementById('nasDisableError').style.display = 'block';
|
||||||
|
}
|
||||||
|
if (d.done) {
|
||||||
|
clearInterval(_nasPollTimer);
|
||||||
|
document.getElementById('nasDisableBar').style.width = '100%';
|
||||||
|
document.getElementById('nasDisableCount').textContent = d.total + ' / ' + d.total;
|
||||||
|
document.getElementById('nasDisableDone').style.display = 'block';
|
||||||
|
}
|
||||||
|
} catch(e) { /* network blip, keep polling */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nasDisableFreshConfirm() {
|
||||||
|
const val = document.getElementById('nasDeleteConfirmInput').value.trim();
|
||||||
|
const errEl = document.getElementById('nasDeleteConfirmError');
|
||||||
|
if (val !== 'DELETE') {
|
||||||
|
errEl.textContent = 'Type DELETE (all caps) to confirm.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
errEl.style.display = 'none';
|
||||||
|
try {
|
||||||
|
const r = await fetch('{{ route("admin.nas.disable") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||||
|
body: JSON.stringify({ mode: 'fresh' }),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.ok) {
|
||||||
|
closeNasDisableModal();
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
errEl.textContent = d.message || 'An error occurred.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
errEl.textContent = 'Failed: ' + e.message;
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
.spin { display: inline-block; animation: spin .6s linear infinite; }
|
.spin { display: inline-block; animation: spin .6s linear infinite; }
|
||||||
|
.adm-input {
|
||||||
|
background: var(--bg-input, #1e1e1e);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.adm-input:focus { border-color: var(--brand); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
{{-- ── NAS Disable Modal ─────────────────────────────────────── --}}
|
||||||
|
<div id="nasDisableModal" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.7);align-items:center;justify-content:center;">
|
||||||
|
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);width:min(520px,94vw);padding:28px;max-height:90vh;overflow-y:auto;">
|
||||||
|
|
||||||
|
{{-- Step 1: Choose action --}}
|
||||||
|
<div id="nasDisableStep1">
|
||||||
|
<h3 style="margin:0 0 8px;font-size:17px;">Disable NAS Storage</h3>
|
||||||
|
<p style="margin:0 0 20px;font-size:13px;color:var(--text-2);">All your files currently live on the NAS. Choose what to do before disabling:</p>
|
||||||
|
|
||||||
|
<div style="display:grid;gap:12px;margin-bottom:24px;">
|
||||||
|
<div id="optMigrate" onclick="selectNasOpt('migrate')" style="border:2px solid var(--border);border-radius:8px;padding:16px;cursor:pointer;transition:border-color .15s;">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
|
||||||
|
<i class="bi bi-arrow-down-circle" style="color:var(--brand);font-size:18px;"></i>
|
||||||
|
<strong style="font-size:14px;">Copy all files to local disk</strong>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0;font-size:12px;color:var(--text-2);">Downloads every video, thumbnail, avatar, and banner from the NAS to <code>storage/app/users/…</code>. Same directory structure — everything keeps working. May take a while.</p>
|
||||||
|
</div>
|
||||||
|
<div id="optFresh" onclick="selectNasOpt('fresh')" style="border:2px solid var(--border);border-radius:8px;padding:16px;cursor:pointer;transition:border-color .15s;">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
|
||||||
|
<i class="bi bi-trash3" style="color:#e74c3c;font-size:18px;"></i>
|
||||||
|
<strong style="font-size:14px;color:#e74c3c;">Delete all media, start fresh</strong>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0;font-size:12px;color:var(--text-2);">Removes all videos, thumbnails, playlists, comments, and posts. <strong>User accounts are kept.</strong> Nothing is downloaded.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
||||||
|
<button type="button" class="adm-btn" onclick="closeNasDisableModal()">Cancel</button>
|
||||||
|
<button type="button" id="nasDisableNextBtn" class="adm-btn adm-btn-danger" disabled onclick="nasDisableNext()">Continue →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Step 2a: Migration progress --}}
|
||||||
|
<div id="nasDisableStep2Migrate" style="display:none;">
|
||||||
|
<h3 style="margin:0 0 8px;font-size:17px;">Migrating files to local disk…</h3>
|
||||||
|
<p id="nasDisablePhase" style="margin:0 0 16px;font-size:13px;color:var(--text-2);">Starting…</p>
|
||||||
|
<div style="background:var(--border);border-radius:4px;height:8px;margin-bottom:8px;overflow:hidden;">
|
||||||
|
<div id="nasDisableBar" style="height:100%;background:var(--brand);border-radius:4px;width:0%;transition:width .3s;"></div>
|
||||||
|
</div>
|
||||||
|
<div id="nasDisableCount" style="font-size:12px;color:var(--text-2);margin-bottom:20px;">0 / 0</div>
|
||||||
|
<div id="nasDisableDone" style="display:none;">
|
||||||
|
<div style="color:#27ae60;font-size:13px;margin-bottom:16px;"><i class="bi bi-check-circle-fill"></i> Migration complete! NAS has been disabled. Reload the page to continue.</div>
|
||||||
|
<button type="button" class="adm-btn adm-btn-primary" onclick="location.reload()">Reload Page</button>
|
||||||
|
</div>
|
||||||
|
<div id="nasDisableError" style="display:none;color:#e74c3c;font-size:13px;margin-bottom:16px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Step 2b: Fresh start confirmation --}}
|
||||||
|
<div id="nasDisableStep2Fresh" style="display:none;">
|
||||||
|
<h3 style="margin:0 0 8px;font-size:17px;color:#e74c3c;">Delete all media?</h3>
|
||||||
|
<p style="margin:0 0 16px;font-size:13px;color:var(--text-2);">This will permanently delete all videos, playlists, comments, and posts. User accounts will remain. <strong>This cannot be undone.</strong></p>
|
||||||
|
<p style="margin:0 0 8px;font-size:13px;">Type <strong>DELETE</strong> to confirm:</p>
|
||||||
|
<input type="text" id="nasDeleteConfirmInput" placeholder="DELETE" class="adm-input" style="width:100%;margin-bottom:8px;box-sizing:border-box;">
|
||||||
|
<div id="nasDeleteConfirmError" style="display:none;color:#e74c3c;font-size:12px;margin-bottom:12px;"></div>
|
||||||
|
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
||||||
|
<button type="button" class="adm-btn" onclick="closeNasDisableModal()">Cancel</button>
|
||||||
|
<button type="button" class="adm-btn adm-btn-danger" onclick="nasDisableFreshConfirm()">Delete & Disable NAS</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@ -2,14 +2,229 @@
|
|||||||
|
|
||||||
@section('title', 'Users')
|
@section('title', 'Users')
|
||||||
|
|
||||||
|
@php
|
||||||
|
$adminNeedsOtp = auth()->user()->two_factor_enabled &&
|
||||||
|
(! session('admin_2fa_verified_at') || now()->timestamp - session('admin_2fa_verified_at') >= 1800);
|
||||||
|
|
||||||
|
$totalUsers = \App\Models\User::count();
|
||||||
|
$totalAdmins = \App\Models\User::whereIn('role', ['admin','super_admin'])->count();
|
||||||
|
$newThisWeek = \App\Models\User::where('created_at', '>=', now()->subDays(7))->count();
|
||||||
|
$unverified = \App\Models\User::whereNull('email_verified_at')->count();
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@section('extra_styles')
|
||||||
|
<style>
|
||||||
|
|
||||||
|
/* ═══ STAT CARDS ═══════════════════════════════════════════════════ */
|
||||||
|
.u-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.u-stat {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color .2s, transform .2s;
|
||||||
|
}
|
||||||
|
.u-stat:hover { border-color: #3a3a3a; transform: translateY(-1px); }
|
||||||
|
.u-stat-top { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 14px; }
|
||||||
|
.u-stat-icon {
|
||||||
|
width: 40px; height: 40px; border-radius: 10px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 17px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.u-stat-icon.c-blue { background: rgba(96,165,250,.14); color: #60a5fa; }
|
||||||
|
.u-stat-icon.c-red { background: rgba(248,113,113,.14); color: #f87171; }
|
||||||
|
.u-stat-icon.c-green { background: rgba(52,211,153,.14); color: #34d399; }
|
||||||
|
.u-stat-icon.c-amber { background: rgba(251,191,36,.14); color: #fbbf24; }
|
||||||
|
.u-stat-accent {
|
||||||
|
position: absolute; top: 0; left: 0; right: 0; height: 2px;
|
||||||
|
border-radius: 14px 14px 0 0;
|
||||||
|
}
|
||||||
|
.u-stat-val { font-size: 32px; font-weight: 700; line-height: 1; color: var(--text-primary); letter-spacing: -1px; }
|
||||||
|
.u-stat-lbl { font-size: 13px; color: var(--text-secondary); margin-top: 4px; font-weight: 500; }
|
||||||
|
|
||||||
|
/* ═══ FILTER BAR ══════════════════════════════════════════════════ */
|
||||||
|
.u-filter {
|
||||||
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||||
|
padding: 14px 18px;
|
||||||
|
}
|
||||||
|
.u-search {
|
||||||
|
position: relative; flex: 1; min-width: 200px;
|
||||||
|
}
|
||||||
|
.u-search i {
|
||||||
|
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
|
||||||
|
font-size: 13px; color: var(--text-secondary); pointer-events: none;
|
||||||
|
}
|
||||||
|
.u-search input {
|
||||||
|
width: 100%; height: 38px;
|
||||||
|
background: var(--bg-card2, #1a1a1a);
|
||||||
|
border: 1px solid var(--border-color); border-radius: 9px;
|
||||||
|
padding: 0 14px 0 36px; color: var(--text-primary); font-size: 13px;
|
||||||
|
transition: border-color .15s;
|
||||||
|
}
|
||||||
|
.u-search input:focus { outline: none; border-color: var(--brand-red); }
|
||||||
|
.u-search input::placeholder { color: #444; }
|
||||||
|
|
||||||
|
/* ═══ TABLE ════════════════════════════════════════════════════════ */
|
||||||
|
.u-table-wrap { overflow-x: auto; }
|
||||||
|
|
||||||
|
.u-table {
|
||||||
|
width: 100%; border-collapse: collapse; min-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.u-table thead tr {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.u-table thead th {
|
||||||
|
padding: 11px 18px;
|
||||||
|
font-size: 11px; font-weight: 600; letter-spacing: .7px;
|
||||||
|
text-transform: uppercase; color: var(--text-secondary);
|
||||||
|
white-space: nowrap; text-align: left;
|
||||||
|
}
|
||||||
|
.u-table thead th.right { text-align: right; }
|
||||||
|
|
||||||
|
.u-table tbody tr {
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,.04);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .1s;
|
||||||
|
}
|
||||||
|
.u-table tbody tr:last-child { border-bottom: none; }
|
||||||
|
.u-table tbody tr:hover { background: rgba(255,255,255,.03); }
|
||||||
|
.u-table tbody td {
|
||||||
|
padding: 0 18px; height: 68px;
|
||||||
|
vertical-align: middle; font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Identity cell */
|
||||||
|
.u-identity { display: flex; align-items: center; gap: 13px; }
|
||||||
|
.u-avatar-wrap { position: relative; flex-shrink: 0; }
|
||||||
|
.u-avatar {
|
||||||
|
width: 40px; height: 40px; border-radius: 50%;
|
||||||
|
object-fit: cover; display: block;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
transition: border-color .15s;
|
||||||
|
}
|
||||||
|
.u-table tbody tr:hover .u-avatar { border-color: var(--brand-red); }
|
||||||
|
.u-name {
|
||||||
|
font-size: 14px; font-weight: 600; color: var(--text-primary);
|
||||||
|
white-space: nowrap; display: flex; align-items: center; gap: 7px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.u-email { font-size: 12px; color: #555; margin-top: 3px; }
|
||||||
|
.u-you {
|
||||||
|
font-size: 9px; font-weight: 700; letter-spacing: .5px;
|
||||||
|
padding: 2px 6px; border-radius: 4px;
|
||||||
|
background: rgba(230,30,30,.15); color: var(--brand-red);
|
||||||
|
text-transform: uppercase; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Role cell */
|
||||||
|
.u-role { display: flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 500; white-space: nowrap; }
|
||||||
|
.u-role-dot {
|
||||||
|
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.u-role-dot.super { background: #f87171; box-shadow: 0 0 6px rgba(248,113,113,.5); }
|
||||||
|
.u-role-dot.admin { background: #fbbf24; box-shadow: 0 0 6px rgba(251,191,36,.4); }
|
||||||
|
.u-role-dot.user { background: #555; }
|
||||||
|
|
||||||
|
/* Status cell */
|
||||||
|
.u-status { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 500; white-space: nowrap; }
|
||||||
|
.u-status-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.u-status.verified { color: #34d399; }
|
||||||
|
.u-status.verified .u-status-dot { background: #34d399; box-shadow: 0 0 5px rgba(52,211,153,.5); }
|
||||||
|
.u-status.unverified { color: #fbbf24; }
|
||||||
|
.u-status.unverified .u-status-dot { background: #fbbf24; }
|
||||||
|
|
||||||
|
/* Videos cell */
|
||||||
|
.u-vcount { font-size: 15px; font-weight: 700; color: var(--text-primary); }
|
||||||
|
.u-vcount-lbl { font-size: 11px; color: #444; }
|
||||||
|
|
||||||
|
/* Joined cell */
|
||||||
|
.u-joined-date { font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
||||||
|
.u-joined-ago { font-size: 11px; color: #444; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* Row click hint */
|
||||||
|
.u-table tbody tr:hover td:last-child::after {
|
||||||
|
content: '⋯';
|
||||||
|
float: right;
|
||||||
|
color: #444;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Row dropdown */
|
||||||
|
#uRowMenu {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
min-width: 190px;
|
||||||
|
background: #1c1c1c;
|
||||||
|
border: 1px solid #2e2e2e;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 5px 0;
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,.7);
|
||||||
|
animation: uMenuIn .1s ease;
|
||||||
|
}
|
||||||
|
@keyframes uMenuIn {
|
||||||
|
from { opacity:0; transform:translateY(-4px); }
|
||||||
|
to { opacity:1; transform:translateY(0); }
|
||||||
|
}
|
||||||
|
#uRowMenu .u-menu-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
width: 100%; padding: 9px 14px;
|
||||||
|
background: none; border: none;
|
||||||
|
color: var(--text-primary); font-size: 13px;
|
||||||
|
cursor: pointer; text-align: left; text-decoration: none;
|
||||||
|
transition: background .1s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#uRowMenu .u-menu-item:hover { background: rgba(255,255,255,.06); }
|
||||||
|
#uRowMenu .u-menu-item.danger { color: #f87171; }
|
||||||
|
#uRowMenu .u-menu-item.danger:hover { background: rgba(248,113,113,.1); }
|
||||||
|
#uRowMenu .u-menu-sep {
|
||||||
|
height: 1px; background: #2a2a2a; margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.u-empty { padding: 80px 20px; text-align: center; }
|
||||||
|
.u-empty-circle {
|
||||||
|
width: 72px; height: 72px; border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,.03); border: 1px solid var(--border-color);
|
||||||
|
margin: 0 auto 18px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 30px; color: #333;
|
||||||
|
}
|
||||||
|
.u-empty h3 { font-size: 16px; font-weight: 600; color: var(--text-secondary); margin: 0 0 6px; }
|
||||||
|
.u-empty p { font-size: 13px; color: #444; margin: 0; }
|
||||||
|
|
||||||
|
/* ═══ RESPONSIVE ════════════════════════════════════════════════════ */
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.u-stats { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.u-hide-md { display: none !important; }
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.u-stats { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.u-hide-sm { display: none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@endsection
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
|
||||||
{{-- ── Page header ──────────────────────────────────────────────────── --}}
|
|
||||||
<div class="adm-page-header">
|
<div class="adm-page-header">
|
||||||
<h1 class="adm-page-title"><i class="bi bi-people"></i> Users</h1>
|
<h1 class="adm-page-title"><i class="bi bi-people"></i> Users</h1>
|
||||||
|
<a href="{{ route('admin.dashboard') }}" class="adm-btn" style="font-size:12px;">
|
||||||
|
<i class="bi bi-arrow-left"></i> Dashboard
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- ── Alerts ───────────────────────────────────────────────────────── --}}
|
{{-- Alerts --}}
|
||||||
@if(session('success'))
|
@if(session('success'))
|
||||||
<div class="adm-alert adm-alert-success">
|
<div class="adm-alert adm-alert-success">
|
||||||
<i class="bi bi-check-circle-fill"></i>
|
<i class="bi bi-check-circle-fill"></i>
|
||||||
@ -25,156 +240,193 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- ── Filter card ──────────────────────────────────────────────────── --}}
|
{{-- Stats --}}
|
||||||
<div class="adm-card">
|
<div class="u-stats">
|
||||||
<div class="adm-card-body" style="padding:16px 20px;">
|
<div class="u-stat">
|
||||||
<form method="GET" action="{{ route('admin.users') }}" class="adm-filter-form">
|
<div class="u-stat-accent" style="background: linear-gradient(90deg,#60a5fa,#3b82f6);"></div>
|
||||||
<div class="adm-filter-search">
|
<div class="u-stat-top">
|
||||||
|
<div>
|
||||||
|
<div class="u-stat-val">{{ number_format($totalUsers) }}</div>
|
||||||
|
<div class="u-stat-lbl">Total users</div>
|
||||||
|
</div>
|
||||||
|
<div class="u-stat-icon c-blue"><i class="bi bi-people-fill"></i></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="u-stat">
|
||||||
|
<div class="u-stat-accent" style="background: linear-gradient(90deg,#f87171,#e61e1e);"></div>
|
||||||
|
<div class="u-stat-top">
|
||||||
|
<div>
|
||||||
|
<div class="u-stat-val">{{ $totalAdmins }}</div>
|
||||||
|
<div class="u-stat-lbl">Admins</div>
|
||||||
|
</div>
|
||||||
|
<div class="u-stat-icon c-red"><i class="bi bi-shield-fill"></i></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="u-stat">
|
||||||
|
<div class="u-stat-accent" style="background: linear-gradient(90deg,#34d399,#059669);"></div>
|
||||||
|
<div class="u-stat-top">
|
||||||
|
<div>
|
||||||
|
<div class="u-stat-val">{{ $newThisWeek }}</div>
|
||||||
|
<div class="u-stat-lbl">New this week</div>
|
||||||
|
</div>
|
||||||
|
<div class="u-stat-icon c-green"><i class="bi bi-person-plus-fill"></i></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="u-stat">
|
||||||
|
<div class="u-stat-accent" style="background: linear-gradient(90deg,#fbbf24,#d97706);"></div>
|
||||||
|
<div class="u-stat-top">
|
||||||
|
<div>
|
||||||
|
<div class="u-stat-val">{{ $unverified }}</div>
|
||||||
|
<div class="u-stat-lbl">Unverified</div>
|
||||||
|
</div>
|
||||||
|
<div class="u-stat-icon c-amber"><i class="bi bi-envelope-exclamation-fill"></i></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Filter --}}
|
||||||
|
<div class="adm-card" style="margin-bottom:16px;">
|
||||||
|
<form method="GET" action="{{ route('admin.users') }}" class="u-filter">
|
||||||
|
<div class="u-search">
|
||||||
<i class="bi bi-search"></i>
|
<i class="bi bi-search"></i>
|
||||||
<input type="text" name="search" class="adm-input"
|
<input type="text" name="search" placeholder="Search by name or email…"
|
||||||
placeholder="Search name or email…"
|
|
||||||
value="{{ request('search') }}" autocomplete="off">
|
value="{{ request('search') }}" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<select name="role" class="adm-select">
|
<select name="role" class="adm-select">
|
||||||
<option value="">All Roles</option>
|
<option value="">All Roles</option>
|
||||||
<option value="user" {{ request('role') === 'user' ? 'selected' : '' }}>User</option>
|
<option value="user" {{ request('role') === 'user' ? 'selected' : '' }}>Users</option>
|
||||||
<option value="admin" {{ request('role') === 'admin' ? 'selected' : '' }}>Admin</option>
|
<option value="admin" {{ request('role') === 'admin' ? 'selected' : '' }}>Admins</option>
|
||||||
<option value="super_admin" {{ request('role') === 'super_admin' ? 'selected' : '' }}>Super Admin</option>
|
<option value="super_admin" {{ request('role') === 'super_admin' ? 'selected' : '' }}>Super Admins</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select name="sort" class="adm-select">
|
<select name="sort" class="adm-select">
|
||||||
<option value="latest" {{ request('sort', 'latest') === 'latest' ? 'selected' : '' }}>Newest first</option>
|
<option value="latest" {{ request('sort','latest') === 'latest' ? 'selected' : '' }}>Newest first</option>
|
||||||
<option value="oldest" {{ request('sort') === 'oldest' ? 'selected' : '' }}>Oldest first</option>
|
<option value="oldest" {{ request('sort') === 'oldest' ? 'selected' : '' }}>Oldest first</option>
|
||||||
<option value="name_asc" {{ request('sort') === 'name_asc' ? 'selected' : '' }}>Name A–Z</option>
|
<option value="name_asc" {{ request('sort') === 'name_asc' ? 'selected' : '' }}>Name A–Z</option>
|
||||||
<option value="name_desc" {{ request('sort') === 'name_desc' ? 'selected' : '' }}>Name Z–A</option>
|
<option value="name_desc" {{ request('sort') === 'name_desc' ? 'selected' : '' }}>Name Z–A</option>
|
||||||
</select>
|
</select>
|
||||||
|
<button type="submit" class="adm-btn adm-btn-primary"><i class="bi bi-funnel-fill"></i> Filter</button>
|
||||||
<button type="submit" class="adm-btn adm-btn-primary">
|
|
||||||
<i class="bi bi-funnel"></i> Filter
|
|
||||||
</button>
|
|
||||||
@if(request()->hasAny(['search','role','sort']))
|
@if(request()->hasAny(['search','role','sort']))
|
||||||
<a href="{{ route('admin.users') }}" class="adm-btn">
|
<a href="{{ route('admin.users') }}" class="adm-btn"><i class="bi bi-x-lg"></i> Clear</a>
|
||||||
<i class="bi bi-x-lg"></i> Clear
|
|
||||||
</a>
|
|
||||||
@endif
|
@endif
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- ── Users table ──────────────────────────────────────────────────── --}}
|
{{-- Table --}}
|
||||||
<div class="adm-card">
|
<div class="adm-card">
|
||||||
<div class="adm-card-header">
|
<div class="adm-card-header">
|
||||||
<div class="adm-card-title">
|
<div class="adm-card-title">
|
||||||
<i class="bi bi-people"></i>
|
<i class="bi bi-people"></i> Members
|
||||||
All Users
|
|
||||||
<span class="adm-badge adm-badge-user">{{ $users->total() ?? $users->count() }}</span>
|
<span class="adm-badge adm-badge-user">{{ $users->total() ?? $users->count() }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span style="font-size:12px; color:#444; display:flex; align-items:center; gap:5px;">
|
||||||
|
<i class="bi bi-hand-index" style="font-size:11px;"></i>
|
||||||
|
Click a row for actions
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="adm-table-wrap">
|
<div class="u-table-wrap">
|
||||||
<table class="adm-table">
|
<table class="u-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>User</th>
|
<th>User</th>
|
||||||
<th>Role</th>
|
<th class="u-hide-sm">Role</th>
|
||||||
<th>Verified</th>
|
<th class="u-hide-sm">Status</th>
|
||||||
<th>Videos</th>
|
<th class="u-hide-md">Videos</th>
|
||||||
<th>Joined</th>
|
<th class="u-hide-md">Likes</th>
|
||||||
<th style="width:80px; text-align:right;">Actions</th>
|
<th class="u-hide-md">Shares</th>
|
||||||
|
<th class="u-hide-md">Joined</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@forelse($users as $user)
|
@forelse($users as $user)
|
||||||
<tr>
|
<tr class="u-clickable-row"
|
||||||
{{-- User cell --}}
|
data-channel="{{ route('channel', $user->channel) }}"
|
||||||
|
data-user-id="{{ $user->id }}"
|
||||||
|
data-user-name="{{ addslashes($user->name) }}"
|
||||||
|
data-verified="{{ $user->email_verified_at ? '1' : '0' }}"
|
||||||
|
data-is-self="{{ $user->id === auth()->id() ? '1' : '0' }}"
|
||||||
|
data-is-super="{{ $user->isSuperAdmin() ? '1' : '0' }}"
|
||||||
|
data-edit-url="{{ route('admin.users.edit', $user->id) }}"
|
||||||
|
data-verify-url="{{ route('admin.users.verify', $user->id) }}"
|
||||||
|
data-impersonate-url="{{ route('admin.users.impersonate', $user->id) }}"
|
||||||
|
data-delete-id="{{ $user->id }}">
|
||||||
|
|
||||||
|
{{-- Identity --}}
|
||||||
<td>
|
<td>
|
||||||
<div class="adm-user-cell">
|
<div class="u-identity">
|
||||||
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}">
|
<div class="u-avatar-wrap">
|
||||||
|
<img class="u-avatar" src="{{ $user->avatar_url }}" alt="{{ $user->name }}">
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style="display:flex;align-items:center;gap:4px;">
|
<div class="u-name">
|
||||||
<span class="adm-user-cell-name">{{ $user->name }}</span>
|
{{ $user->name }}
|
||||||
@if($user->id === auth()->id())
|
@if($user->id === auth()->id())
|
||||||
<span class="adm-user-cell-you">you</span>
|
<span class="u-you">you</span>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="adm-user-cell-email">{{ $user->email }}</div>
|
<div class="u-email">{{ $user->email }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{{-- Role --}}
|
{{-- Role --}}
|
||||||
<td>
|
<td class="u-hide-sm">
|
||||||
@if($user->role === 'super_admin')
|
@if($user->role === 'super_admin')
|
||||||
<span class="adm-badge adm-badge-superadmin"><i class="bi bi-shield-fill"></i> Super Admin</span>
|
<div class="u-role"><span class="u-role-dot super"></span> Super Admin</div>
|
||||||
@elseif($user->role === 'admin')
|
@elseif($user->role === 'admin')
|
||||||
<span class="adm-badge adm-badge-admin"><i class="bi bi-person-badge"></i> Admin</span>
|
<div class="u-role"><span class="u-role-dot admin"></span> Admin</div>
|
||||||
@else
|
@else
|
||||||
<span class="adm-badge adm-badge-user"><i class="bi bi-person"></i> User</span>
|
<div class="u-role" style="color:#666;"><span class="u-role-dot user"></span> User</div>
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{{-- Verified --}}
|
{{-- Status --}}
|
||||||
<td>
|
<td class="u-hide-sm">
|
||||||
@if($user->email_verified_at)
|
@if($user->email_verified_at)
|
||||||
<span class="adm-badge adm-badge-verified"><i class="bi bi-check-circle-fill"></i> Verified</span>
|
<div class="u-status verified">
|
||||||
|
<span class="u-status-dot"></span> Verified
|
||||||
|
</div>
|
||||||
@else
|
@else
|
||||||
<span class="adm-badge adm-badge-unverified"><i class="bi bi-clock"></i> Pending</span>
|
<div class="u-status unverified">
|
||||||
|
<span class="u-status-dot"></span> Pending
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{{-- Videos --}}
|
{{-- Videos --}}
|
||||||
<td>
|
<td class="u-hide-md">
|
||||||
<a href="{{ route('channel', $user->channel) }}" target="_blank"
|
<div class="u-vcount">{{ $user->videos->count() }}</div>
|
||||||
class="text-dim" style="text-decoration:none; font-size:13px;">
|
<div class="u-vcount-lbl">videos</div>
|
||||||
{{ $user->videos->count() }}
|
</td>
|
||||||
<i class="bi bi-box-arrow-up-right" style="font-size:10px; opacity:.5; margin-left:2px;"></i>
|
|
||||||
</a>
|
{{-- Likes --}}
|
||||||
|
<td class="u-hide-md">
|
||||||
|
@php $videoIds = $user->videos->pluck('id'); @endphp
|
||||||
|
<div class="u-vcount">{{ number_format(\DB::table('video_likes')->whereIn('video_id', $videoIds)->count()) }}</div>
|
||||||
|
<div class="u-vcount-lbl">likes</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{{-- Shares --}}
|
||||||
|
<td class="u-hide-md">
|
||||||
|
<div class="u-vcount">{{ number_format(\DB::table('video_shares')->whereIn('video_id', $videoIds)->count()) }}</div>
|
||||||
|
<div class="u-vcount-lbl">shares</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{{-- Joined --}}
|
{{-- Joined --}}
|
||||||
<td class="text-muted-sm">{{ $user->created_at->format('M d, Y') }}</td>
|
<td class="u-hide-md">
|
||||||
|
<div class="u-joined-date">{{ $user->created_at->format('M d, Y') }}</div>
|
||||||
{{-- Actions --}}
|
<div class="u-joined-ago">{{ $user->created_at->diffForHumans() }}</div>
|
||||||
<td>
|
|
||||||
<div class="adm-row-actions" style="justify-content:flex-end;">
|
|
||||||
@if(!$user->email_verified_at)
|
|
||||||
<form method="POST" action="{{ route('admin.users.verify', $user->id) }}" style="display:inline;">
|
|
||||||
@csrf
|
|
||||||
<button type="submit" class="adm-btn adm-btn-sm adm-btn-verify" title="Manually verify account">
|
|
||||||
<i class="bi bi-patch-check-fill"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
@endif
|
|
||||||
@if($user->id !== auth()->id() && !$user->isSuperAdmin())
|
|
||||||
<form method="POST" action="{{ route('admin.users.impersonate', $user->id) }}" style="display:inline;">
|
|
||||||
@csrf
|
|
||||||
<button type="submit" class="adm-btn adm-btn-sm adm-btn-impersonate" title="Impersonate user">
|
|
||||||
<i class="bi bi-person-fill-gear"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
@endif
|
|
||||||
<a href="{{ route('admin.users.edit', $user->id) }}"
|
|
||||||
class="adm-btn adm-btn-sm" title="Edit user">
|
|
||||||
<i class="bi bi-pencil"></i>
|
|
||||||
</a>
|
|
||||||
@if($user->id !== auth()->id())
|
|
||||||
<button type="button"
|
|
||||||
class="adm-btn adm-btn-sm adm-btn-danger"
|
|
||||||
title="Delete user"
|
|
||||||
onclick="openDeleteDialog({{ $user->id }}, '{{ addslashes($user->name) }}')">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6">
|
<td colspan="7" style="border:none;">
|
||||||
<div class="empty-state">
|
<div class="u-empty">
|
||||||
<i class="bi bi-people"></i>
|
<div class="u-empty-circle"><i class="bi bi-people"></i></div>
|
||||||
<p>No users found</p>
|
<h3>No users found</h3>
|
||||||
|
<p>Try adjusting your search or filter criteria</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -183,23 +435,25 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Pagination --}}
|
|
||||||
@if($users instanceof \Illuminate\Pagination\LengthAwarePaginator && $users->hasPages())
|
@if($users instanceof \Illuminate\Pagination\LengthAwarePaginator && $users->hasPages())
|
||||||
<div style="padding:16px 20px; border-top:1px solid var(--border);">
|
<div style="padding:14px 20px; border-top:1px solid var(--border-color);">
|
||||||
{{ $users->onEachSide(1)->links() }}
|
{{ $users->onEachSide(1)->links() }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- ── Delete confirmation dialog ───────────────────────────────────── --}}
|
{{-- Row action dropdown --}}
|
||||||
|
<div id="uRowMenu"></div>
|
||||||
|
|
||||||
|
{{-- Delete dialog --}}
|
||||||
<div class="adm-dialog-overlay" id="deleteDialog">
|
<div class="adm-dialog-overlay" id="deleteDialog">
|
||||||
<div class="adm-dialog">
|
<div class="adm-dialog">
|
||||||
<div class="adm-dialog-header">
|
<div class="adm-dialog-header">
|
||||||
<div class="adm-dialog-title">
|
<div class="adm-dialog-title">
|
||||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
<i class="bi bi-exclamation-triangle-fill"></i> Delete User
|
||||||
Delete User
|
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDeleteDialog()" style="border:none;background:none;color:var(--text-2);">
|
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDeleteDialog()"
|
||||||
|
style="border:none;background:none;color:var(--text-secondary);">
|
||||||
<i class="bi bi-x-lg"></i>
|
<i class="bi bi-x-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -209,15 +463,26 @@
|
|||||||
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
|
<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.
|
All videos uploaded by this user will also be deleted. This cannot be undone.
|
||||||
</div>
|
</div>
|
||||||
|
@if($adminNeedsOtp)
|
||||||
|
<div style="margin-top:16px;">
|
||||||
|
<label style="font-size:13px;color:var(--text-secondary);display:flex;align-items:center;gap:6px;margin-bottom:8px;">
|
||||||
|
<i class="bi bi-shield-lock-fill" style="color:#a78bfa;"></i>
|
||||||
|
Enter your <strong style="color:var(--text-primary);">2FA code</strong> to confirm
|
||||||
|
</label>
|
||||||
|
<input type="text" id="dlgUserOtp" inputmode="numeric" pattern="[0-9]*"
|
||||||
|
maxlength="6" autocomplete="one-time-code" placeholder="000 000"
|
||||||
|
style="width:100%;background:#111;border:1px solid var(--border-color);border-radius:8px;padding:12px 16px;color:#fff;font-size:26px;letter-spacing:.25em;text-align:center;transition:border-color .15s;"
|
||||||
|
onfocus="this.style.borderColor='#a78bfa'" onblur="this.style.borderColor='var(--border-color)'">
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div id="dlgUserError" style="display:none;margin-top:10px;padding:10px 14px;background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.25);border-radius:8px;color:#f87171;font-size:13px;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="adm-dialog-footer">
|
<div class="adm-dialog-footer">
|
||||||
<button type="button" class="adm-btn" onclick="closeDeleteDialog()">Cancel</button>
|
<button type="button" class="adm-btn" onclick="closeDeleteDialog()">Cancel</button>
|
||||||
<form id="deleteForm" method="POST">
|
<button type="button" class="adm-btn adm-btn-danger" id="dlgUserConfirmBtn" onclick="confirmDeleteUser()"
|
||||||
@csrf @method('DELETE')
|
style="height:36px;">
|
||||||
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
|
<i class="bi bi-trash"></i> Delete permanently
|
||||||
<i class="bi bi-trash"></i> Delete User
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -226,19 +491,132 @@
|
|||||||
|
|
||||||
@section('scripts')
|
@section('scripts')
|
||||||
<script>
|
<script>
|
||||||
|
const _adminNeedsOtp = {{ $adminNeedsOtp ? 'true' : 'false' }};
|
||||||
|
let _deleteUserId = null;
|
||||||
|
|
||||||
|
/* ── Row dropdown ─────────────────────────────────────────────── */
|
||||||
|
const _menu = document.getElementById('uRowMenu');
|
||||||
|
|
||||||
|
function _closeMenu() { _menu.style.display = 'none'; }
|
||||||
|
|
||||||
|
document.querySelectorAll('.u-clickable-row').forEach(function(row) {
|
||||||
|
row.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const d = row.dataset;
|
||||||
|
const isSelf = d.isSelf === '1';
|
||||||
|
const isSuper = d.isSuper === '1';
|
||||||
|
const verified = d.verified === '1';
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// Open channel
|
||||||
|
html += `<a class="u-menu-item" href="${d.channel}" target="_blank"><i class="bi bi-box-arrow-up-right"></i> Open Channel</a>`;
|
||||||
|
html += `<a class="u-menu-item" href="${d.editUrl}"><i class="bi bi-pencil"></i> Edit User</a>`;
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
html += `<button class="u-menu-item" onclick="_verifyUser('${d.verifyUrl}')"><i class="bi bi-patch-check-fill" style="color:#34d399;"></i> Verify Account</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSelf && !isSuper) {
|
||||||
|
html += `<button class="u-menu-item" onclick="_impersonateUser('${d.impersonateUrl}')"><i class="bi bi-person-fill-gear" style="color:#60a5fa;"></i> Impersonate</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSelf) {
|
||||||
|
html += `<div class="u-menu-sep"></div>`;
|
||||||
|
html += `<button class="u-menu-item danger" onclick="_closeMenu(); openDeleteDialog(${d.deleteId}, '${d.userName}')"><i class="bi bi-trash"></i> Delete User</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_menu.innerHTML = html;
|
||||||
|
|
||||||
|
// Position near click, keep within viewport
|
||||||
|
const vw = window.innerWidth, vh = window.innerHeight;
|
||||||
|
const mw = 200, mh = _menu.childElementCount * 40;
|
||||||
|
let x = e.clientX, y = e.clientY + 6;
|
||||||
|
if (x + mw > vw - 12) x = vw - mw - 12;
|
||||||
|
if (y + mh > vh - 12) y = e.clientY - mh - 6;
|
||||||
|
|
||||||
|
_menu.style.left = x + 'px';
|
||||||
|
_menu.style.top = y + 'px';
|
||||||
|
_menu.style.display = 'block';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', _closeMenu);
|
||||||
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') { _closeMenu(); closeDeleteDialog(); } });
|
||||||
|
|
||||||
|
function _verifyUser(url) {
|
||||||
|
_closeMenu();
|
||||||
|
const f = document.createElement('form');
|
||||||
|
f.method = 'POST'; f.action = url;
|
||||||
|
f.innerHTML = '<input type="hidden" name="_token" value="{{ csrf_token() }}">';
|
||||||
|
document.body.appendChild(f); f.submit();
|
||||||
|
}
|
||||||
|
function _impersonateUser(url) {
|
||||||
|
_closeMenu();
|
||||||
|
const f = document.createElement('form');
|
||||||
|
f.method = 'POST'; f.action = url;
|
||||||
|
f.innerHTML = '<input type="hidden" name="_token" value="{{ csrf_token() }}">';
|
||||||
|
document.body.appendChild(f); f.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Delete dialog ────────────────────────────────────────────── */
|
||||||
function openDeleteDialog(userId, userName) {
|
function openDeleteDialog(userId, userName) {
|
||||||
|
_deleteUserId = userId;
|
||||||
document.getElementById('dlgUserName').textContent = userName;
|
document.getElementById('dlgUserName').textContent = userName;
|
||||||
document.getElementById('deleteForm').action = '/admin/users/' + userId;
|
document.getElementById('dlgUserError').style.display = 'none';
|
||||||
|
if (_adminNeedsOtp) document.getElementById('dlgUserOtp').value = '';
|
||||||
document.getElementById('deleteDialog').classList.add('open');
|
document.getElementById('deleteDialog').classList.add('open');
|
||||||
|
if (_adminNeedsOtp) setTimeout(() => document.getElementById('dlgUserOtp').focus(), 120);
|
||||||
}
|
}
|
||||||
function closeDeleteDialog() {
|
function closeDeleteDialog() {
|
||||||
document.getElementById('deleteDialog').classList.remove('open');
|
document.getElementById('deleteDialog').classList.remove('open');
|
||||||
|
_deleteUserId = null;
|
||||||
}
|
}
|
||||||
document.getElementById('deleteDialog').addEventListener('click', function(e) {
|
function confirmDeleteUser() {
|
||||||
if (e.target === this) closeDeleteDialog();
|
if (!_deleteUserId) return;
|
||||||
});
|
const btn = document.getElementById('dlgUserConfirmBtn');
|
||||||
document.addEventListener('keydown', function(e) {
|
const errEl = document.getElementById('dlgUserError');
|
||||||
if (e.key === 'Escape') closeDeleteDialog();
|
const otp = _adminNeedsOtp ? document.getElementById('dlgUserOtp').value.replace(/\s/g,'') : '';
|
||||||
|
|
||||||
|
if (_adminNeedsOtp && otp.length !== 6) {
|
||||||
|
errEl.textContent = 'Please enter your 6-digit 2FA code.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
document.getElementById('dlgUserOtp').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', otp);
|
||||||
|
|
||||||
|
fetch('/admin/users/' + _deleteUserId, {
|
||||||
|
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) { closeDeleteDialog(); window.location.reload(); }
|
||||||
|
else {
|
||||||
|
errEl.textContent = data.message || 'Delete failed.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-trash"></i> Delete permanently';
|
||||||
|
if (_adminNeedsOtp) { document.getElementById('dlgUserOtp').value = ''; document.getElementById('dlgUserOtp').focus(); }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
errEl.textContent = 'An unexpected error occurred.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-trash"></i> Delete permanently';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('deleteDialog').addEventListener('click', e => {
|
||||||
|
if (e.target === document.getElementById('deleteDialog')) closeDeleteDialog();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@ -195,7 +195,7 @@ function flagEmoji(string $code): string {
|
|||||||
<div class="video-info-card">
|
<div class="video-info-card">
|
||||||
<div class="video-info-thumb">
|
<div class="video-info-thumb">
|
||||||
@if($video->thumbnail)
|
@if($video->thumbnail)
|
||||||
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}">
|
<img src="{{ route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}">
|
||||||
@else
|
@else
|
||||||
<i class="bi bi-play-circle"></i>
|
<i class="bi bi-play-circle"></i>
|
||||||
@endif
|
@endif
|
||||||
@ -414,7 +414,7 @@ function flagEmoji(string $code): string {
|
|||||||
<div style="display:flex; align-items:center; gap:10px;">
|
<div style="display:flex; align-items:center; gap:10px;">
|
||||||
<div class="viewer-avatar">
|
<div class="viewer-avatar">
|
||||||
@if($view->viewer_avatar)
|
@if($view->viewer_avatar)
|
||||||
<img src="{{ asset('storage/avatars/' . $view->viewer_avatar) }}" alt="">
|
<img src="{{ route('media.avatar', $view->viewer_avatar) }}" alt="">
|
||||||
@else
|
@else
|
||||||
<i class="bi bi-person"></i>
|
<i class="bi bi-person"></i>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
@section('title', 'Videos')
|
@section('title', 'Videos')
|
||||||
|
|
||||||
|
@php
|
||||||
|
$adminNeedsOtp = auth()->user()->two_factor_enabled &&
|
||||||
|
(! session('admin_2fa_verified_at') || now()->timestamp - session('admin_2fa_verified_at') >= 1800);
|
||||||
|
@endphp
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
|
||||||
{{-- ── Page header ──────────────────────────────────────────────────── --}}
|
{{-- ── Page header ──────────────────────────────────────────────────── --}}
|
||||||
@ -100,18 +105,19 @@
|
|||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Views</th>
|
<th>Views</th>
|
||||||
<th>Likes</th>
|
<th>Likes</th>
|
||||||
|
<th>Shares</th>
|
||||||
<th>Uploaded</th>
|
<th>Uploaded</th>
|
||||||
<th style="width:110px; text-align:right;">Actions</th>
|
<th style="width:110px; text-align:right;">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@forelse($videos as $video)
|
@forelse($videos as $video)
|
||||||
<tr>
|
<tr style="cursor:pointer;" onclick="window.open('{{ route('videos.show', $video) }}', '_blank')">
|
||||||
{{-- Thumbnail + title --}}
|
{{-- Thumbnail + title --}}
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex; align-items:center; gap:12px;">
|
<div style="display:flex; align-items:center; gap:12px;">
|
||||||
@if($video->thumbnail)
|
@if($video->thumbnail)
|
||||||
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}"
|
<img src="{{ route('media.thumbnail', $video->thumbnail) }}"
|
||||||
alt="" style="width:72px;height:44px;object-fit:cover;border-radius:6px;flex-shrink:0;border:1px solid var(--border);">
|
alt="" style="width:72px;height:44px;object-fit:cover;border-radius:6px;flex-shrink:0;border:1px solid var(--border);">
|
||||||
@else
|
@else
|
||||||
<div style="width:72px;height:44px;border-radius:6px;background:var(--bg-card2);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;flex-shrink:0;">
|
<div style="width:72px;height:44px;border-radius:6px;background:var(--bg-card2);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;flex-shrink:0;">
|
||||||
@ -132,7 +138,7 @@
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
{{-- Owner --}}
|
{{-- Owner --}}
|
||||||
<td>
|
<td onclick="event.stopPropagation()">
|
||||||
<a href="{{ route('channel', $video->user->channel) }}" target="_blank"
|
<a href="{{ route('channel', $video->user->channel) }}" target="_blank"
|
||||||
style="font-size:13px;color:var(--text);text-decoration:none;display:flex;align-items:center;gap:4px;">
|
style="font-size:13px;color:var(--text);text-decoration:none;display:flex;align-items:center;gap:4px;">
|
||||||
{{ $video->user->name }}
|
{{ $video->user->name }}
|
||||||
@ -193,11 +199,16 @@
|
|||||||
{{ number_format(\DB::table('video_likes')->where('video_id',$video->id)->count()) }}
|
{{ number_format(\DB::table('video_likes')->where('video_id',$video->id)->count()) }}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
{{-- Shares --}}
|
||||||
|
<td style="font-size:13px;color:var(--text-2);">
|
||||||
|
{{ number_format(\DB::table('video_shares')->where('video_id',$video->id)->count()) }}
|
||||||
|
</td>
|
||||||
|
|
||||||
{{-- Date --}}
|
{{-- Date --}}
|
||||||
<td class="text-muted-sm">{{ $video->created_at->format('M d, Y') }}</td>
|
<td class="text-muted-sm">{{ $video->created_at->format('M d, Y') }}</td>
|
||||||
|
|
||||||
{{-- Actions --}}
|
{{-- Actions --}}
|
||||||
<td>
|
<td onclick="event.stopPropagation()">
|
||||||
<div class="adm-row-actions" style="justify-content:flex-end;">
|
<div class="adm-row-actions" style="justify-content:flex-end;">
|
||||||
<a href="{{ route('videos.show', $video) }}" target="_blank"
|
<a href="{{ route('videos.show', $video) }}" target="_blank"
|
||||||
class="adm-btn adm-btn-sm" title="Watch">
|
class="adm-btn adm-btn-sm" title="Watch">
|
||||||
@ -218,7 +229,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="9">
|
<td colspan="10">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<i class="bi bi-play-circle"></i>
|
<i class="bi bi-play-circle"></i>
|
||||||
<p>No videos found</p>
|
<p>No videos found</p>
|
||||||
@ -256,15 +267,25 @@
|
|||||||
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
|
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
|
||||||
All views, likes, comments, and HLS files for this video will also be deleted.
|
All views, likes, comments, and HLS files for this video will also be deleted.
|
||||||
</div>
|
</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="dlgVideoOtp" 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
|
||||||
|
<div id="dlgVideoError" 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>
|
||||||
<div class="adm-dialog-footer">
|
<div class="adm-dialog-footer">
|
||||||
<button type="button" class="adm-btn" onclick="closeDeleteDialog()">Cancel</button>
|
<button type="button" class="adm-btn" onclick="closeDeleteDialog()">Cancel</button>
|
||||||
<form id="deleteForm" method="POST">
|
<button type="button" class="adm-btn adm-btn-danger" id="dlgVideoConfirmBtn" onclick="confirmDeleteVideo()">
|
||||||
@csrf @method('DELETE')
|
|
||||||
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
|
|
||||||
<i class="bi bi-trash"></i> Delete Video
|
<i class="bi bi-trash"></i> Delete Video
|
||||||
</button>
|
</button>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -273,13 +294,65 @@
|
|||||||
|
|
||||||
@section('scripts')
|
@section('scripts')
|
||||||
<script>
|
<script>
|
||||||
|
const _adminNeedsOtp = {{ $adminNeedsOtp ? 'true' : 'false' }};
|
||||||
|
let _deleteVideoId = null;
|
||||||
|
|
||||||
function openDeleteDialog(videoId, videoTitle) {
|
function openDeleteDialog(videoId, videoTitle) {
|
||||||
|
_deleteVideoId = videoId;
|
||||||
document.getElementById('dlgVideoTitle').textContent = videoTitle;
|
document.getElementById('dlgVideoTitle').textContent = videoTitle;
|
||||||
document.getElementById('deleteForm').action = '/admin/videos/' + videoId;
|
document.getElementById('dlgVideoError').style.display = 'none';
|
||||||
|
if (_adminNeedsOtp) document.getElementById('dlgVideoOtp').value = '';
|
||||||
document.getElementById('deleteDialog').classList.add('open');
|
document.getElementById('deleteDialog').classList.add('open');
|
||||||
|
if (_adminNeedsOtp) setTimeout(() => document.getElementById('dlgVideoOtp').focus(), 100);
|
||||||
}
|
}
|
||||||
function closeDeleteDialog() {
|
function closeDeleteDialog() {
|
||||||
document.getElementById('deleteDialog').classList.remove('open');
|
document.getElementById('deleteDialog').classList.remove('open');
|
||||||
|
_deleteVideoId = null;
|
||||||
|
}
|
||||||
|
function confirmDeleteVideo() {
|
||||||
|
if (!_deleteVideoId) return;
|
||||||
|
const btn = document.getElementById('dlgVideoConfirmBtn');
|
||||||
|
const errEl = document.getElementById('dlgVideoError');
|
||||||
|
const otpCode = _adminNeedsOtp ? document.getElementById('dlgVideoOtp').value.replace(/\s/g,'') : '';
|
||||||
|
|
||||||
|
if (_adminNeedsOtp && otpCode.length !== 6) {
|
||||||
|
errEl.textContent = 'Please enter your 6-digit 2FA code.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
document.getElementById('dlgVideoOtp').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/' + _deleteVideoId, {
|
||||||
|
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) {
|
||||||
|
closeDeleteDialog();
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
errEl.textContent = data.message || 'Delete failed.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-trash"></i> Delete Video';
|
||||||
|
if (_adminNeedsOtp) { document.getElementById('dlgVideoOtp').value = ''; document.getElementById('dlgVideoOtp').focus(); }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
errEl.textContent = 'An error occurred. Please try again.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-trash"></i> Delete Video';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
document.getElementById('deleteDialog').addEventListener('click', function(e) {
|
document.getElementById('deleteDialog').addEventListener('click', function(e) {
|
||||||
if (e.target === this) closeDeleteDialog();
|
if (e.target === this) closeDeleteDialog();
|
||||||
|
|||||||
@ -1,7 +1,24 @@
|
|||||||
@props(['playlist', 'showTypeBadge' => false])
|
@props(['playlist'])
|
||||||
|
|
||||||
@once
|
@once
|
||||||
<style>
|
<style>
|
||||||
|
.pl-count-badge {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 0 auto;
|
||||||
|
width: 72px;
|
||||||
|
background: rgba(0,0,0,.78);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.pl-count-badge i { font-size: 20px; }
|
||||||
.pl-visibility-badge {
|
.pl-visibility-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
@ -18,6 +35,26 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: .4px;
|
letter-spacing: .4px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.pl-type-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
background: rgba(0,0,0,.82);
|
||||||
|
color: #a78bfa;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .4px;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@endonce
|
@endonce
|
||||||
@ -27,16 +64,14 @@
|
|||||||
<div class="yt-video-card">
|
<div class="yt-video-card">
|
||||||
<a href="{{ route('playlists.show', $pl->id) }}">
|
<a href="{{ route('playlists.show', $pl->id) }}">
|
||||||
<div class="yt-video-thumb">
|
<div class="yt-video-thumb">
|
||||||
<img src="{{ $pl->thumbnail_url }}" alt="{{ $pl->name }}" loading="lazy">
|
<img src="{{ $pl->thumbnail_url }}" alt="{{ $pl->name }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
|
||||||
<div class="pl-count-badge">
|
<div class="pl-count-badge">
|
||||||
<i class="bi bi-collection-play-fill"></i>
|
<i class="bi bi-collection-play-fill"></i>
|
||||||
{{ $pl->videos_count }}
|
{{ $pl->videos_count }}
|
||||||
</div>
|
</div>
|
||||||
@if($showTypeBadge)
|
<span class="pl-type-badge">
|
||||||
<span class="feed-pl-type-badge">
|
<i class="bi bi-collection-play-fill"></i> Playlist
|
||||||
<i class="bi bi-collection-play-fill"></i> PLAYLIST
|
|
||||||
</span>
|
</span>
|
||||||
@endif
|
|
||||||
@if($pl->visibility === 'private')
|
@if($pl->visibility === 'private')
|
||||||
<span class="pl-visibility-badge"><i class="bi bi-lock-fill"></i> Private</span>
|
<span class="pl-visibility-badge"><i class="bi bi-lock-fill"></i> Private</span>
|
||||||
@elseif($pl->visibility === 'unlisted')
|
@elseif($pl->visibility === 'unlisted')
|
||||||
|
|||||||
@ -4,18 +4,33 @@
|
|||||||
.action-btn {
|
.action-btn {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px 14px;
|
padding: 0 14px;
|
||||||
|
height: 36px;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn i,
|
||||||
|
.action-btn svg {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn:hover {
|
.action-btn:hover {
|
||||||
@ -113,6 +128,11 @@
|
|||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="action-btn desktop-action" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})"
|
||||||
|
style="color:#ef4444;border-color:rgba(239,68,68,.35);">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
@elseif (Auth::id() !== $video->user_id)
|
@elseif (Auth::id() !== $video->user_id)
|
||||||
@php $isSubscribed = Auth::user()->isSubscribedTo($video->user); @endphp
|
@php $isSubscribed = Auth::user()->isSubscribedTo($video->user); @endphp
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@ -149,16 +169,13 @@
|
|||||||
@if ($video->isShareable())
|
@if ($video->isShareable())
|
||||||
<button class="action-btn desktop-action"
|
<button class="action-btn desktop-action"
|
||||||
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')">
|
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')">
|
||||||
<i class="bi bi-share"></i> Share
|
<i class="bi bi-share"></i><span>Share</span>
|
||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<!-- Save to Playlist Button -->
|
<!-- Save to Playlist Button -->
|
||||||
<button class="action-btn desktop-action" onclick="openAddToPlaylistModal({{ $video->id }})">
|
<button class="action-btn desktop-action" onclick="openAddToPlaylistModal({{ $video->id }})">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
<i class="bi bi-bookmark"></i>
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Save</span>
|
<span>Save</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -590,4 +607,110 @@ if (!window._slideshowDlInit) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Video delete dialog ──────────────────────────────────────────────
|
||||||
|
@auth
|
||||||
|
@if(Auth::id() === $video->user_id)
|
||||||
|
(function () {
|
||||||
|
var _videoDeleteUrl = '{{ route('videos.destroy', $video) }}';
|
||||||
|
var _videoCsrf = '{{ csrf_token() }}';
|
||||||
|
var _owner2fa = {{ (Auth::user()->two_factor_enabled && Auth::user()->two_factor_secret) ? 'true' : 'false' }};
|
||||||
|
var _dlgId = 'vaDlg_{{ $video->id }}';
|
||||||
|
var _dlgHtml = '<div id="' + _dlgId + '" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.75);backdrop-filter:blur(4px);align-items:center;justify-content:center;padding:16px;" onclick="if(event.target===this)_closeVaDlg()">'
|
||||||
|
+ '<div style="background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:16px;width:100%;max-width:420px;overflow:hidden;box-shadow:0 24px 60px rgba(0,0,0,.6);">'
|
||||||
|
+ '<div style="padding:20px 24px 16px;border-bottom:1px solid var(--border-color);display:flex;align-items:center;justify-content:space-between;">'
|
||||||
|
+ '<div style="display:flex;align-items:center;gap:10px;font-size:16px;font-weight:600;color:#ef4444;"><i class="bi bi-trash"></i> Delete Video</div>'
|
||||||
|
+ '<button onclick="_closeVaDlg()" style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:18px;line-height:1;padding:4px;"><i class="bi bi-x-lg"></i></button>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div style="padding:20px 24px;">'
|
||||||
|
+ '<p id="' + _dlgId + '_title" style="margin:0 0 12px;font-size:14px;color:var(--text-primary);"></p>'
|
||||||
|
+ '<div style="background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.25);border-radius:8px;padding:10px 14px;font-size:13px;color:#fca5a5;display:flex;gap:8px;align-items:flex-start;">'
|
||||||
|
+ '<i class="bi bi-exclamation-triangle-fill" style="flex-shrink:0;margin-top:1px;"></i>'
|
||||||
|
+ '<span>All views, likes, comments and HLS files will be deleted. This cannot be undone.</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ (_owner2fa ? '<div style="margin-top:16px;"><label style="font-size:13px;color:var(--text-secondary);display:flex;align-items:center;gap:6px;margin-bottom:8px;"><i class="bi bi-shield-lock-fill" style="color:#a78bfa;"></i>Enter your <strong style=\'color:var(--text-primary);\'>2FA code</strong> to confirm</label>'
|
||||||
|
+ '<input type="text" id="' + _dlgId + '_otp" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" placeholder="000000"'
|
||||||
|
+ ' style="width:100%;background:var(--bg-primary);border:1px solid var(--border-color);border-radius:8px;padding:10px 14px;color:#fff;font-size:22px;letter-spacing:.3em;text-align:center;box-sizing:border-box;"></div>' : '')
|
||||||
|
+ '<div id="' + _dlgId + '_err" style="display:none;margin-top:12px;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 style="padding:12px 24px 20px;display:flex;gap:10px;justify-content:flex-end;">'
|
||||||
|
+ '<button onclick="_closeVaDlg()" class="action-btn">Cancel</button>'
|
||||||
|
+ '<button id="' + _dlgId + '_btn" onclick="_confirmVaDlg()" class="action-btn" style="background:#ef4444;color:#fff;border-color:#ef4444;"><i class="bi bi-trash"></i> Delete</button>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</div></div>';
|
||||||
|
|
||||||
|
var _dlgInserted = false;
|
||||||
|
function _ensureDlg() {
|
||||||
|
if (_dlgInserted) return;
|
||||||
|
document.body.insertAdjacentHTML('beforeend', _dlgHtml);
|
||||||
|
_dlgInserted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.showDeleteModal = function (routeKey, title) {
|
||||||
|
_ensureDlg();
|
||||||
|
var dlg = document.getElementById(_dlgId);
|
||||||
|
document.getElementById(_dlgId + '_title').textContent = 'You are about to permanently delete "' + title + '".';
|
||||||
|
document.getElementById(_dlgId + '_err').style.display = 'none';
|
||||||
|
if (_owner2fa) { document.getElementById(_dlgId + '_otp').value = ''; }
|
||||||
|
dlg.style.display = 'flex';
|
||||||
|
dlg.style.alignItems = 'center';
|
||||||
|
dlg.style.justifyContent = 'center';
|
||||||
|
if (_owner2fa) setTimeout(function () { document.getElementById(_dlgId + '_otp').focus(); }, 100);
|
||||||
|
document.addEventListener('keydown', _escHandler);
|
||||||
|
};
|
||||||
|
|
||||||
|
window._closeVaDlg = function () {
|
||||||
|
var dlg = document.getElementById(_dlgId);
|
||||||
|
if (dlg) dlg.style.display = 'none';
|
||||||
|
document.removeEventListener('keydown', _escHandler);
|
||||||
|
};
|
||||||
|
|
||||||
|
function _escHandler(e) { if (e.key === 'Escape') _closeVaDlg(); }
|
||||||
|
|
||||||
|
window._confirmVaDlg = function () {
|
||||||
|
var btn = document.getElementById(_dlgId + '_btn');
|
||||||
|
var errEl = document.getElementById(_dlgId + '_err');
|
||||||
|
var otp = _owner2fa ? document.getElementById(_dlgId + '_otp').value.replace(/\s/g, '') : '';
|
||||||
|
|
||||||
|
if (_owner2fa && otp.length !== 6) {
|
||||||
|
errEl.textContent = 'Please enter your 6-digit 2FA code.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
document.getElementById(_dlgId + '_otp').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Deleting…';
|
||||||
|
errEl.style.display = 'none';
|
||||||
|
|
||||||
|
var body = new URLSearchParams({ '_token': _videoCsrf, '_method': 'DELETE' });
|
||||||
|
if (_owner2fa) body.append('otp_code', otp);
|
||||||
|
|
||||||
|
fetch(_videoDeleteUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body.toString()
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.success) {
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
errEl.textContent = data.message || 'Delete failed.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-trash"></i> Delete';
|
||||||
|
if (_owner2fa) { document.getElementById(_dlgId + '_otp').value = ''; document.getElementById(_dlgId + '_otp').focus(); }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
errEl.textContent = 'An error occurred. Please try again.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-trash"></i> Delete';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
@endif
|
||||||
|
@endauth
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
@php
|
@php
|
||||||
$videoUrl = $video ? asset('storage/videos/' . $video->filename) : null;
|
$videoUrl = $video ? asset('storage/videos/' . $video->filename) : null;
|
||||||
$thumbnailUrl = $video && $video->thumbnail
|
$thumbnailUrl = $video && $video->thumbnail
|
||||||
? asset('storage/thumbnails/' . $video->thumbnail)
|
? route('media.thumbnail', $video->thumbnail)
|
||||||
: ($video ? 'https://picsum.photos/seed/' . $video->id . '/640/360' : 'https://picsum.photos/seed/random/640/360');
|
: ($video ? 'https://picsum.photos/seed/' . $video->id . '/640/360' : 'https://picsum.photos/seed/random/640/360');
|
||||||
|
|
||||||
$typeIcon = $video ? match($video->type) {
|
$typeIcon = $video ? match($video->type) {
|
||||||
@ -29,7 +29,7 @@ $sizeClasses = match($size) {
|
|||||||
<a href="{{ $video ? route('videos.show', $video) : '#' }}">
|
<a href="{{ $video ? route('videos.show', $video) : '#' }}">
|
||||||
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
|
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
|
||||||
data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}">
|
data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}">
|
||||||
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}">
|
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
|
||||||
@if($videoUrl)
|
@if($videoUrl)
|
||||||
<video preload="none">
|
<video preload="none">
|
||||||
<source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}">
|
<source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}">
|
||||||
@ -64,7 +64,7 @@ $sizeClasses = match($size) {
|
|||||||
<a href="{{ $video && $video->user ? route('channel', $video->user->channel) : '#' }}"
|
<a href="{{ $video && $video->user ? route('channel', $video->user->channel) : '#' }}"
|
||||||
class="yt-channel-icon" onclick="event.stopPropagation();">
|
class="yt-channel-icon" onclick="event.stopPropagation();">
|
||||||
@if($video && $video->user && $video->user->avatar_url)
|
@if($video && $video->user && $video->user->avatar_url)
|
||||||
<img src="{{ $video->user->avatar_url }}" alt="{{ $video->user->name }}" style="width: 100%; height: 100%; object-fit: cover; border-radius: 50%;">
|
<img src="{{ $video->user->avatar_url }}" alt="{{ $video->user->name }}" loading="lazy" decoding="async" onload="this.classList.add('loaded')" style="width: 100%; height: 100%; object-fit: cover; border-radius: 50%; opacity: 0; transition: opacity 0.25s ease;">
|
||||||
@endif
|
@endif
|
||||||
</a>
|
</a>
|
||||||
<div class="yt-video-details">
|
<div class="yt-video-details">
|
||||||
@ -275,6 +275,28 @@ $sizeClasses = match($size) {
|
|||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Skeleton shimmer while thumbnail loads */
|
||||||
|
.yt-video-card .yt-video-thumb::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, #1a1a1a 25%, #2a2a2a 50%, #1a1a1a 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: thumb-shimmer 1.4s ease infinite;
|
||||||
|
z-index: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes thumb-shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-video-card .yt-video-thumb.loaded::before {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.yt-video-card .yt-video-thumb img {
|
.yt-video-card .yt-video-thumb img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -282,9 +304,16 @@ $sizeClasses = match($size) {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
transition: transform 0.2s ease;
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease, transform 0.2s ease;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.yt-video-card:hover .yt-video-thumb img {
|
|
||||||
|
.yt-video-card .yt-video-thumb img.loaded {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-video-card:hover .yt-video-thumb img.loaded {
|
||||||
transform: scale(1.03);
|
transform: scale(1.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -298,6 +327,7 @@ $sizeClasses = match($size) {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
background: #000;
|
background: #000;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-video-card .yt-video-thumb video.active {
|
.yt-video-card .yt-video-thumb video.active {
|
||||||
@ -315,6 +345,7 @@ $sizeClasses = match($size) {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-video-thumb.audio-playing .audio-preview-overlay {
|
.yt-video-thumb.audio-playing .audio-preview-overlay {
|
||||||
@ -360,6 +391,7 @@ $sizeClasses = match($size) {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-video-card .yt-shorts-badge {
|
.yt-video-card .yt-shorts-badge {
|
||||||
@ -377,6 +409,7 @@ $sizeClasses = match($size) {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-video-card .yt-shorts-badge i {
|
.yt-video-card .yt-shorts-badge i {
|
||||||
@ -395,6 +428,7 @@ $sizeClasses = match($size) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-video-card .yt-visibility-private {
|
.yt-video-card .yt-visibility-private {
|
||||||
@ -423,6 +457,10 @@ $sizeClasses = match($size) {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.yt-video-card .yt-channel-icon img.loaded {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.yt-video-card .yt-video-details {
|
.yt-video-card .yt-video-details {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@ -79,6 +79,18 @@
|
|||||||
.ins-demo-pct { font-size:12px; font-weight:700; color:var(--text-secondary); min-width:34px; text-align:right; }
|
.ins-demo-pct { font-size:12px; font-weight:700; color:var(--text-secondary); min-width:34px; text-align:right; }
|
||||||
.ins-demo-cnt { font-size:11px; color:var(--text-secondary); min-width:30px; text-align:right; }
|
.ins-demo-cnt { font-size:11px; color:var(--text-secondary); min-width:30px; text-align:right; }
|
||||||
|
|
||||||
|
/* ── Two-column grid (collapses to 1 col on mobile) ─── */
|
||||||
|
.ins-two-col { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
|
||||||
|
@media(max-width:600px){ .ins-two-col { grid-template-columns:1fr; } }
|
||||||
|
|
||||||
|
/* ── Who Liked rows ──────────────────────────────────── */
|
||||||
|
.ins-liker-row { display:flex; align-items:center; gap:10px; padding:7px 6px; border-bottom:1px solid rgba(255,255,255,.04); cursor:pointer; border-radius:8px; transition:background .12s; }
|
||||||
|
.ins-liker-row:last-child { border-bottom:none; }
|
||||||
|
.ins-liker-row:hover { background:rgba(255,255,255,.05); }
|
||||||
|
.ins-liker-avatar { width:34px; height:34px; border-radius:50%; object-fit:cover; flex-shrink:0; background:rgba(255,255,255,.08); }
|
||||||
|
.ins-liker-name { flex:1; font-size:13px; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.ins-liker-time { font-size:11px; color:var(--text-secondary); flex-shrink:0; }
|
||||||
|
|
||||||
/* ── Skeleton ─────────────────────────────────────────── */
|
/* ── Skeleton ─────────────────────────────────────────── */
|
||||||
.ins-skeleton { display:flex; flex-direction:column; gap:12px; padding:4px 0; }
|
.ins-skeleton { display:flex; flex-direction:column; gap:12px; padding:4px 0; }
|
||||||
.ins-skel-row { height:14px; border-radius:6px; background:rgba(255,255,255,.06); animation:skelPulse 1.4s ease-in-out infinite; }
|
.ins-skel-row { height:14px; border-radius:6px; background:rgba(255,255,255,.06); animation:skelPulse 1.4s ease-in-out infinite; }
|
||||||
@ -367,7 +379,7 @@ function renderInsights(d) {
|
|||||||
<div class="ins-dl-header">
|
<div class="ins-dl-header">
|
||||||
<div class="ins-section-title" style="margin:0;"><i class="bi bi-people-fill"></i> Viewers — ${_fmt(d.unique_viewers)} registered · ${_fmt(d.guest_views||0)} guest</div>
|
<div class="ins-section-title" style="margin:0;"><i class="bi bi-people-fill"></i> Viewers — ${_fmt(d.unique_viewers)} registered · ${_fmt(d.guest_views||0)} guest</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
|
<div class="ins-two-col">
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">Top Viewers</div>
|
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">Top Viewers</div>
|
||||||
${topViewerRows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No registered viewers yet.</p>'}
|
${topViewerRows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No registered viewers yet.</p>'}
|
||||||
@ -379,6 +391,30 @@ function renderInsights(d) {
|
|||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
// ── Who Liked section ───────────────────────────────
|
||||||
|
let likersHtml = '';
|
||||||
|
if (d.likes > 0 && d.likers && d.likers.length) {
|
||||||
|
const likerRows = d.likers.map(u => `
|
||||||
|
<div class="ins-liker-row" onclick="window.location.href='/channel/${u.channel||u.id}'" title="View ${u.name}'s profile">
|
||||||
|
<img src="${u.avatar}" alt="${u.name}" class="ins-liker-avatar">
|
||||||
|
<div class="ins-liker-name">${u.name}</div>
|
||||||
|
<div class="ins-liker-time">${_ago(u.liked_at)}</div>
|
||||||
|
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
likersHtml = `
|
||||||
|
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
|
||||||
|
<div class="ins-section-title"><i class="bi bi-heart-fill" style="color:#ef4444;"></i> Liked by — ${_fmt(d.likes)} ${d.likes===1?'person':'people'}</div>
|
||||||
|
${likerRows}
|
||||||
|
</div>`;
|
||||||
|
} else if (d.likes === 0) {
|
||||||
|
likersHtml = `
|
||||||
|
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
|
||||||
|
<div class="ins-section-title"><i class="bi bi-heart" style="color:var(--text-secondary);"></i> Likes</div>
|
||||||
|
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No likes yet.</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Downloads section ───────────────────────────────
|
// ── Downloads section ───────────────────────────────
|
||||||
let dlHtml = '';
|
let dlHtml = '';
|
||||||
if (d.downloads===0) {
|
if (d.downloads===0) {
|
||||||
@ -422,7 +458,7 @@ function renderInsights(d) {
|
|||||||
<div class="ins-section-title" style="margin:0;"><i class="bi bi-download"></i> Downloads — ${_fmt(d.downloads)} total</div>
|
<div class="ins-section-title" style="margin:0;"><i class="bi bi-download"></i> Downloads — ${_fmt(d.downloads)} total</div>
|
||||||
<div class="ins-dl-type-pills">${pills}</div>
|
<div class="ins-dl-type-pills">${pills}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
|
<div class="ins-two-col">
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">
|
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">
|
||||||
Top Downloaders <span style="font-weight:400;opacity:.5;">(tap to see history)</span>
|
Top Downloaders <span style="font-weight:400;opacity:.5;">(tap to see history)</span>
|
||||||
@ -523,7 +559,7 @@ function renderInsights(d) {
|
|||||||
<div>
|
<div>
|
||||||
<div class="ins-section-title"><i class="bi bi-globe2"></i> Audience by Country <span style="font-size:10px;opacity:.5;font-weight:400;text-transform:none;">(tap for viewer breakdown)</span></div>
|
<div class="ins-section-title"><i class="bi bi-globe2"></i> Audience by Country <span style="font-size:10px;opacity:.5;font-weight:400;text-transform:none;">(tap for viewer breakdown)</span></div>
|
||||||
${countriesHtml}
|
${countriesHtml}
|
||||||
</div></div>` + viewersHtml + dlHtml + shareHtml + demographicsHtml;
|
</div></div>` + viewersHtml + likersHtml + dlHtml + shareHtml + demographicsHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
<div class="email-thumb-wrap">
|
<div class="email-thumb-wrap">
|
||||||
@if($video->thumbnail)
|
@if($video->thumbnail)
|
||||||
<a href="{{ route('videos.show', $video) }}">
|
<a href="{{ route('videos.show', $video) }}">
|
||||||
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}">
|
<img src="{{ route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}">
|
||||||
</a>
|
</a>
|
||||||
@else
|
@else
|
||||||
<div class="email-thumb-placeholder">
|
<div class="email-thumb-placeholder">
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
<div class="email-thumb-wrap">
|
<div class="email-thumb-wrap">
|
||||||
@if($video->thumbnail)
|
@if($video->thumbnail)
|
||||||
<a href="{{ route('videos.show', $video) }}">
|
<a href="{{ route('videos.show', $video) }}">
|
||||||
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}">
|
<img src="{{ route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}">
|
||||||
</a>
|
</a>
|
||||||
@else
|
@else
|
||||||
<div class="email-thumb-placeholder">
|
<div class="email-thumb-placeholder">
|
||||||
|
|||||||
@ -277,6 +277,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{{-- ── Replace File panel (outside main form, separate POST) ── --}}
|
||||||
|
<div id="replace-file-panel" style="margin-top:24px;padding-top:20px;border-top:1px solid #3a3a3a;">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||||
|
<i class="bi bi-arrow-repeat" style="color:#f87171;font-size:16px;"></i>
|
||||||
|
<span style="font-size:13px;font-weight:700;color:#f87171;text-transform:uppercase;letter-spacing:.05em;">Replace Media File</span>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:12px;color:#888;margin:0 0 14px;line-height:1.5;">
|
||||||
|
Upload a new file to fix a corrupted or missing video/audio. All views, likes, and comments are preserved.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="rfl-dropzone" onclick="document.getElementById('rfl-input').click()"
|
||||||
|
style="border:2px dashed #444;border-radius:10px;padding:20px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;"
|
||||||
|
onmouseenter="this.style.borderColor='#ef4444';this.style.background='rgba(239,68,68,.05)'"
|
||||||
|
onmouseleave="this.style.borderColor='#444';this.style.background='transparent'">
|
||||||
|
<i class="bi bi-cloud-upload" style="font-size:28px;color:#666;display:block;margin-bottom:8px;"></i>
|
||||||
|
<span id="rfl-drop-label" style="font-size:13px;color:#888;">Click to choose replacement file</span>
|
||||||
|
<input type="file" id="rfl-input" accept="video/*,audio/*,.mp4,.webm,.ogg,.mov,.avi,.wmv,.flv,.mkv,.mp3,.m4a,.aac,.wav,.flac,.opus" style="display:none;" onchange="rflFileSelected(this)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="rfl-file-info" style="display:none;margin-top:10px;background:#1a1a1a;border:1px solid #333;border-radius:8px;padding:10px 14px;display:none;align-items:center;gap:10px;">
|
||||||
|
<i class="bi bi-file-earmark-play" style="font-size:22px;color:#ef4444;flex-shrink:0;"></i>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div id="rfl-file-name" style="font-size:13px;font-weight:600;color:#e5e5e5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></div>
|
||||||
|
<div id="rfl-file-size" style="font-size:11px;color:#888;margin-top:2px;"></div>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="rflClearFile()" style="background:none;border:none;color:#888;cursor:pointer;font-size:16px;flex-shrink:0;padding:0;"><i class="bi bi-x-lg"></i></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="rfl-status" style="display:none;margin-top:10px;font-size:12px;padding:8px 12px;border-radius:6px;"></div>
|
||||||
|
|
||||||
|
<button type="button" id="rfl-submit-btn" onclick="rflSubmit()" disabled
|
||||||
|
class="action-btn action-btn-danger" style="width:100%;justify-content:center;margin-top:12px;">
|
||||||
|
<i class="bi bi-arrow-repeat"></i> <span>Replace File</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1403,6 +1439,75 @@
|
|||||||
submitBtn.innerHTML = '<i class="bi bi-check-lg"></i> Save Changes';
|
submitBtn.innerHTML = '<i class="bi bi-check-lg"></i> Save Changes';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Replace File ──────────────────────────────────────────────────────
|
||||||
|
let _rflFile = null;
|
||||||
|
const _rflFmtSize = b => b >= 1073741824 ? (b/1073741824).toFixed(1)+' GB' : b >= 1048576 ? (b/1048576).toFixed(1)+' MB' : Math.round(b/1024)+' KB';
|
||||||
|
|
||||||
|
function rflFileSelected(input) {
|
||||||
|
_rflFile = input.files[0] || null;
|
||||||
|
const info = document.getElementById('rfl-file-info');
|
||||||
|
if (_rflFile) {
|
||||||
|
document.getElementById('rfl-file-name').textContent = _rflFile.name;
|
||||||
|
document.getElementById('rfl-file-size').textContent = _rflFmtSize(_rflFile.size);
|
||||||
|
document.getElementById('rfl-drop-label').textContent = _rflFile.name;
|
||||||
|
info.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
info.style.display = 'none';
|
||||||
|
document.getElementById('rfl-drop-label').textContent = 'Click to choose replacement file';
|
||||||
|
}
|
||||||
|
document.getElementById('rfl-submit-btn').disabled = !_rflFile;
|
||||||
|
document.getElementById('rfl-status').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function rflClearFile() {
|
||||||
|
_rflFile = null;
|
||||||
|
document.getElementById('rfl-input').value = '';
|
||||||
|
document.getElementById('rfl-file-info').style.display = 'none';
|
||||||
|
document.getElementById('rfl-drop-label').textContent = 'Click to choose replacement file';
|
||||||
|
document.getElementById('rfl-submit-btn').disabled = true;
|
||||||
|
document.getElementById('rfl-status').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function rflSubmit() {
|
||||||
|
if (!_rflFile || !window.currentVideoId) return;
|
||||||
|
const btn = document.getElementById('rfl-submit-btn');
|
||||||
|
const status = document.getElementById('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', _rflFile);
|
||||||
|
|
||||||
|
fetch(`/videos/${window.currentVideoId}/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,.1);border:1px solid rgba(74,222,128,.25);color:#4ade80;';
|
||||||
|
status.innerHTML = '<i class="bi bi-check-circle-fill"></i> ' + data.message;
|
||||||
|
rflClearFile();
|
||||||
|
setTimeout(() => location.reload(), 2000);
|
||||||
|
} else {
|
||||||
|
status.style.cssText = 'display:block;background:rgba(239,68,68,.1);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> <span>Replace File</span>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
status.style.cssText = 'display:block;background:rgba(239,68,68,.1);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> <span>Replace File</span>';
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<x-image-cropper
|
<x-image-cropper
|
||||||
|
|||||||
@ -318,19 +318,29 @@ const thumbnailDefault = document.getElementById('playlist-thumbnail-default');
|
|||||||
const thumbnailPreview = document.getElementById('playlist-thumbnail-preview');
|
const thumbnailPreview = document.getElementById('playlist-thumbnail-preview');
|
||||||
const thumbnailImg = document.getElementById('playlist-thumbnail-img');
|
const thumbnailImg = document.getElementById('playlist-thumbnail-img');
|
||||||
|
|
||||||
// Click to upload
|
// Click to upload — open file picker; cropper will open after file is chosen
|
||||||
thumbnailDropzone.addEventListener('click', function(e) {
|
thumbnailDropzone.addEventListener('click', function(e) {
|
||||||
if (e.target.closest('button')) return;
|
if (e.target.closest('button')) return;
|
||||||
if (typeof window.openCropperModal_thumb_pl_create === 'function') {
|
|
||||||
window.openCropperModal_thumb_pl_create();
|
|
||||||
} else {
|
|
||||||
thumbnailInput.click();
|
thumbnailInput.click();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// File input change
|
// File input change — if cropper is open (result came from cropper), just show preview;
|
||||||
|
// otherwise preload the file into the cropper and open it.
|
||||||
thumbnailInput.addEventListener('change', function() {
|
thumbnailInput.addEventListener('change', function() {
|
||||||
|
if (!this.files || !this.files[0]) return;
|
||||||
|
const cropperOpen = document.getElementById('tcOverlay_thumb_pl_create')?.classList.contains('open');
|
||||||
|
if (cropperOpen) {
|
||||||
handleThumbnailSelect(this);
|
handleThumbnailSelect(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = this.files[0];
|
||||||
|
if (typeof window.tcPreload_thumb_pl_create === 'function' &&
|
||||||
|
typeof window.openCropperModal_thumb_pl_create === 'function') {
|
||||||
|
window.tcPreload_thumb_pl_create(file);
|
||||||
|
window.openCropperModal_thumb_pl_create();
|
||||||
|
} else {
|
||||||
|
handleThumbnailSelect(this);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drag and drop
|
// Drag and drop
|
||||||
@ -353,7 +363,8 @@ thumbnailDropzone.addEventListener('drop', function(e) {
|
|||||||
|
|
||||||
if (e.dataTransfer.files.length) {
|
if (e.dataTransfer.files.length) {
|
||||||
const droppedFile = e.dataTransfer.files[0];
|
const droppedFile = e.dataTransfer.files[0];
|
||||||
if (typeof window.tcPreload_thumb_pl_create === 'function') {
|
if (typeof window.tcPreload_thumb_pl_create === 'function' &&
|
||||||
|
typeof window.openCropperModal_thumb_pl_create === 'function') {
|
||||||
window.tcPreload_thumb_pl_create(droppedFile);
|
window.tcPreload_thumb_pl_create(droppedFile);
|
||||||
window.openCropperModal_thumb_pl_create();
|
window.openCropperModal_thumb_pl_create();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -1590,7 +1590,7 @@ $headerSocialMap = [
|
|||||||
@foreach($allVids as $v)
|
@foreach($allVids as $v)
|
||||||
<a href="{{ route('videos.show', $v) }}" class="ch-post-video-card" style="{{ !$loop->first ? 'margin-top:8px;' : '' }}">
|
<a href="{{ route('videos.show', $v) }}" class="ch-post-video-card" style="{{ !$loop->first ? 'margin-top:8px;' : '' }}">
|
||||||
<div class="ch-post-video-thumb-wrap">
|
<div class="ch-post-video-thumb-wrap">
|
||||||
<img src="{{ $v->thumbnail ? asset('storage/thumbnails/'.$v->thumbnail) : '' }}"
|
<img src="{{ $v->thumbnail ? route('media.thumbnail', $v->thumbnail) : '' }}"
|
||||||
alt="" onerror="this.style.display='none'">
|
alt="" onerror="this.style.display='none'">
|
||||||
<div class="ch-post-video-play"><i class="bi bi-play-circle-fill"></i></div>
|
<div class="ch-post-video-play"><i class="bi bi-play-circle-fill"></i></div>
|
||||||
</div>
|
</div>
|
||||||
@ -1774,7 +1774,7 @@ $headerSocialMap = [
|
|||||||
@foreach($shorts as $short)
|
@foreach($shorts as $short)
|
||||||
<a href="{{ route('videos.show', $short) }}" class="ch-short-card">
|
<a href="{{ route('videos.show', $short) }}" class="ch-short-card">
|
||||||
<div class="ch-short-thumb">
|
<div class="ch-short-thumb">
|
||||||
<img src="{{ $short->thumbnail ? asset('storage/thumbnails/' . $short->thumbnail) : 'https://picsum.photos/seed/' . $short->id . '/360/640' }}"
|
<img src="{{ $short->thumbnail ? route('media.thumbnail', $short->thumbnail) : 'https://picsum.photos/seed/' . $short->id . '/360/640' }}"
|
||||||
alt="{{ $short->title }}" loading="lazy">
|
alt="{{ $short->title }}" loading="lazy">
|
||||||
@if($short->duration)
|
@if($short->duration)
|
||||||
<span class="ch-short-duration">{{ gmdate('i:s', $short->duration) }}</span>
|
<span class="ch-short-duration">{{ gmdate('i:s', $short->duration) }}</span>
|
||||||
@ -2454,11 +2454,11 @@ if ($oldLinks) {
|
|||||||
<div class="ch-vp-card"
|
<div class="ch-vp-card"
|
||||||
data-id="{{ $v->id }}"
|
data-id="{{ $v->id }}"
|
||||||
data-title="{{ $v->title }}"
|
data-title="{{ $v->title }}"
|
||||||
data-thumb="{{ $v->thumbnail ? asset('storage/thumbnails/'.$v->thumbnail) : '' }}"
|
data-thumb="{{ $v->thumbnail ? route('media.thumbnail', $v->thumbnail) : '' }}"
|
||||||
onclick="togglePickerCard(this)">
|
onclick="togglePickerCard(this)">
|
||||||
<div class="ch-vp-card-thumb">
|
<div class="ch-vp-card-thumb">
|
||||||
@if($v->thumbnail)
|
@if($v->thumbnail)
|
||||||
<img src="{{ asset('storage/thumbnails/'.$v->thumbnail) }}" alt="" loading="lazy">
|
<img src="{{ route('media.thumbnail', $v->thumbnail) }}" alt="" loading="lazy">
|
||||||
@else
|
@else
|
||||||
<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,.2);font-size:22px;"><i class="bi bi-film"></i></div>
|
<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,.2);font-size:22px;"><i class="bi bi-film"></i></div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@ -328,7 +328,7 @@ if ($oldLinks) {
|
|||||||
<div class="profile-content">
|
<div class="profile-content">
|
||||||
<div class="profile-avatar-wrap">
|
<div class="profile-avatar-wrap">
|
||||||
@if($user->avatar)
|
@if($user->avatar)
|
||||||
<img src="{{ asset('storage/avatars/' . $user->avatar) }}" alt="{{ $user->name }}" class="profile-avatar" id="pageAvatar">
|
<img src="{{ route('media.avatar', $user->avatar) }}" alt="{{ $user->name }}" class="profile-avatar" id="pageAvatar">
|
||||||
@else
|
@else
|
||||||
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" alt="{{ $user->name }}" class="profile-avatar" id="pageAvatar">
|
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" alt="{{ $user->name }}" class="profile-avatar" id="pageAvatar">
|
||||||
@endif
|
@endif
|
||||||
@ -416,7 +416,7 @@ if ($oldLinks) {
|
|||||||
<div class="video-card">
|
<div class="video-card">
|
||||||
<a href="{{ route('videos.show', $video) }}">
|
<a href="{{ route('videos.show', $video) }}">
|
||||||
<div class="video-thumbnail">
|
<div class="video-thumbnail">
|
||||||
<img src="{{ $video->thumbnail ? asset('storage/thumbnails/' . $video->thumbnail) : 'https://i.ytimg.com/vi/default.jpg' }}" alt="{{ $video->title }}">
|
<img src="{{ $video->thumbnail ? route('media.thumbnail', $video->thumbnail) : 'https://i.ytimg.com/vi/default.jpg' }}" alt="{{ $video->title }}">
|
||||||
<span class="video-duration">{{ $video->duration ?? '0:00' }}</span>
|
<span class="video-duration">{{ $video->duration ?? '0:00' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@ -525,7 +525,7 @@ if ($oldLinks) {
|
|||||||
<div class="avatar-upload-area">
|
<div class="avatar-upload-area">
|
||||||
<div class="avatar-preview-wrap">
|
<div class="avatar-preview-wrap">
|
||||||
@if($user->avatar)
|
@if($user->avatar)
|
||||||
<img src="{{ asset('storage/avatars/' . $user->avatar) }}" class="avatar-preview" id="avatarPreview" alt="Avatar">
|
<img src="{{ route('media.avatar', $user->avatar) }}" class="avatar-preview" id="avatarPreview" alt="Avatar">
|
||||||
@else
|
@else
|
||||||
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" class="avatar-preview" id="avatarPreview" alt="Avatar">
|
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" class="avatar-preview" id="avatarPreview" alt="Avatar">
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -179,7 +179,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label"><i class="bi bi-image"></i> Current Thumbnail</label>
|
<label class="form-label"><i class="bi bi-image"></i> Current Thumbnail</label>
|
||||||
<div id="current-thumb-wrap" class="{{ $video->thumbnail ? 'has-thumb' : '' }}">
|
<div id="current-thumb-wrap" class="{{ $video->thumbnail ? 'has-thumb' : '' }}">
|
||||||
<img src="{{ $video->thumbnail ? asset('storage/thumbnails/'.$video->thumbnail) : '' }}" alt="Thumbnail">
|
<img src="{{ $video->thumbnail ? route('media.thumbnail', $video->thumbnail) : '' }}" alt="Thumbnail">
|
||||||
<div id="current-thumb-placeholder">
|
<div id="current-thumb-placeholder">
|
||||||
<i class="bi bi-card-image"></i>
|
<i class="bi bi-card-image"></i>
|
||||||
<span>No thumbnail set</span>
|
<span>No thumbnail set</span>
|
||||||
@ -305,8 +305,41 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{{-- Replace File --}}
|
||||||
|
<div class="danger-zone" style="margin-top:20px;">
|
||||||
|
<p style="color:#f87171;"><i class="bi bi-arrow-repeat" style="margin-right:6px;"></i>Replace Media File</p>
|
||||||
|
<p style="font-size:12px;color:#888;margin:-8px 0 14px;line-height:1.5;">
|
||||||
|
Fix a corrupted or missing file without losing any views, likes, or comments.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="ep-rfl-dropzone" onclick="document.getElementById('ep-rfl-input').click()"
|
||||||
|
style="border:2px dashed #444;border-radius:10px;padding:18px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;margin-bottom:10px;"
|
||||||
|
onmouseenter="this.style.borderColor='#ef4444';this.style.background='rgba(239,68,68,.05)'"
|
||||||
|
onmouseleave="this.style.borderColor='#444';this.style.background='transparent'">
|
||||||
|
<i class="bi bi-cloud-upload" style="font-size:26px;color:#666;display:block;margin-bottom:6px;"></i>
|
||||||
|
<span id="ep-rfl-label" style="font-size:13px;color:#888;">Tap to choose replacement file</span>
|
||||||
|
<input type="file" id="ep-rfl-input" accept="video/*,audio/*,.mp4,.webm,.ogg,.mov,.avi,.wmv,.flv,.mkv,.mp3,.m4a,.aac,.wav,.flac,.opus" style="display:none;" onchange="epRflSelected(this)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ep-rfl-info" style="display:none;background:#1a1a1a;border:1px solid #333;border-radius:8px;padding:10px 14px;align-items:center;gap:10px;margin-bottom:10px;">
|
||||||
|
<i class="bi bi-file-earmark-play" style="font-size:20px;color:#ef4444;flex-shrink:0;"></i>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div id="ep-rfl-name" style="font-size:13px;font-weight:600;color:#e5e5e5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></div>
|
||||||
|
<div id="ep-rfl-size" style="font-size:11px;color:#888;margin-top:2px;"></div>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="epRflClear()" style="background:none;border:none;color:#888;cursor:pointer;font-size:16px;flex-shrink:0;padding:0;"><i class="bi bi-x-lg"></i></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ep-rfl-status" style="display:none;margin-bottom:10px;font-size:12px;padding:8px 12px;border-radius:6px;"></div>
|
||||||
|
|
||||||
|
<button type="button" id="ep-rfl-btn" onclick="epRflSubmit()" disabled
|
||||||
|
class="action-btn action-btn-danger" style="width:100%;justify-content:center;">
|
||||||
|
<i class="bi bi-arrow-repeat"></i> <span>Replace File</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{-- Danger zone --}}
|
{{-- Danger zone --}}
|
||||||
<div class="danger-zone">
|
<div class="danger-zone" style="margin-top:12px;">
|
||||||
<p>Danger Zone</p>
|
<p>Danger Zone</p>
|
||||||
<form action="{{ route('videos.destroy', $video) }}" method="POST" id="delete-edit-form">
|
<form action="{{ route('videos.destroy', $video) }}" method="POST" id="delete-edit-form">
|
||||||
@csrf
|
@csrf
|
||||||
@ -380,7 +413,7 @@
|
|||||||
}
|
}
|
||||||
@else
|
@else
|
||||||
// ── Slides manager (audio tracks only) ────────────────────────
|
// ── Slides manager (audio tracks only) ────────────────────────
|
||||||
let epSlidesData = @json($video->slides->map(fn($s) => ['id' => $s->id, 'url' => asset('storage/thumbnails/'.$s->filename)])->values());
|
let epSlidesData = @json($video->slides->map(fn($s) => ['id' => $s->id, 'url' => route('media.thumbnail', $s->filename)])->values());
|
||||||
let epDragSrc = null;
|
let epDragSrc = null;
|
||||||
|
|
||||||
function _epSyncOrder() {
|
function _epSyncOrder() {
|
||||||
@ -536,6 +569,75 @@
|
|||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = '<i class="bi bi-check-lg"></i> <span>Save Changes</span>';
|
btn.innerHTML = '<i class="bi bi-check-lg"></i> <span>Save Changes</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Replace File ──────────────────────────────────────────────────────
|
||||||
|
let _epRflFile = null;
|
||||||
|
const _epRflFmtSize = b => b >= 1073741824 ? (b/1073741824).toFixed(1)+' GB' : b >= 1048576 ? (b/1048576).toFixed(1)+' MB' : Math.round(b/1024)+' KB';
|
||||||
|
|
||||||
|
function epRflSelected(input) {
|
||||||
|
_epRflFile = input.files[0] || null;
|
||||||
|
const info = document.getElementById('ep-rfl-info');
|
||||||
|
if (_epRflFile) {
|
||||||
|
document.getElementById('ep-rfl-name').textContent = _epRflFile.name;
|
||||||
|
document.getElementById('ep-rfl-size').textContent = _epRflFmtSize(_epRflFile.size);
|
||||||
|
document.getElementById('ep-rfl-label').textContent = _epRflFile.name;
|
||||||
|
info.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
info.style.display = 'none';
|
||||||
|
document.getElementById('ep-rfl-label').textContent = 'Tap to choose replacement file';
|
||||||
|
}
|
||||||
|
document.getElementById('ep-rfl-btn').disabled = !_epRflFile;
|
||||||
|
document.getElementById('ep-rfl-status').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function epRflClear() {
|
||||||
|
_epRflFile = null;
|
||||||
|
document.getElementById('ep-rfl-input').value = '';
|
||||||
|
document.getElementById('ep-rfl-info').style.display = 'none';
|
||||||
|
document.getElementById('ep-rfl-label').textContent = 'Tap to choose replacement file';
|
||||||
|
document.getElementById('ep-rfl-btn').disabled = true;
|
||||||
|
document.getElementById('ep-rfl-status').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function epRflSubmit() {
|
||||||
|
if (!_epRflFile) return;
|
||||||
|
const btn = document.getElementById('ep-rfl-btn');
|
||||||
|
const status = document.getElementById('ep-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', _epRflFile);
|
||||||
|
|
||||||
|
fetch('{{ route("videos.replaceFile", $video) }}', {
|
||||||
|
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,.1);border:1px solid rgba(74,222,128,.25);color:#4ade80;';
|
||||||
|
status.innerHTML = '<i class="bi bi-check-circle-fill"></i> ' + data.message;
|
||||||
|
epRflClear();
|
||||||
|
setTimeout(() => location.reload(), 2000);
|
||||||
|
} else {
|
||||||
|
status.style.cssText = 'display:block;background:rgba(239,68,68,.1);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> <span>Replace File</span>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
status.style.cssText = 'display:block;background:rgba(239,68,68,.1);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> <span>Replace File</span>';
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<x-image-cropper
|
<x-image-cropper
|
||||||
id="thumb_edit_mobile"
|
id="thumb_edit_mobile"
|
||||||
|
|||||||
@ -14,28 +14,8 @@
|
|||||||
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: 1fr; gap: 14px; } }
|
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: 1fr; gap: 14px; } }
|
||||||
|
|
||||||
|
|
||||||
/* ── Playlist count badge (right strip on thumbnail) ── */
|
/* ── Thumbnail orientation fix ── */
|
||||||
.pl-count-badge {
|
|
||||||
position: absolute; inset: 0 0 0 auto;
|
|
||||||
width: 72px;
|
|
||||||
background: rgba(0,0,0,.78);
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
align-items: center; justify-content: center;
|
|
||||||
gap: 4px; font-size: 12px; font-weight: 600; color: #fff;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.pl-count-badge i { font-size: 20px; }
|
|
||||||
|
|
||||||
/* ── Playlist type badge (top-left on thumbnail) ── */
|
|
||||||
.feed-pl-type-badge {
|
|
||||||
position: absolute; top: 8px; left: 8px;
|
|
||||||
background: rgba(0,0,0,.75); color: #fff;
|
|
||||||
font-size: 11px; font-weight: 700;
|
|
||||||
padding: 3px 8px; border-radius: 4px;
|
|
||||||
display: flex; align-items: center; gap: 4px;
|
|
||||||
text-transform: uppercase; letter-spacing: .4px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Thumbnail orientation fix ── */
|
/* ── Thumbnail orientation fix ── */
|
||||||
.yt-video-card .yt-video-thumb img { object-fit: cover; }
|
.yt-video-card .yt-video-thumb img { object-fit: cover; }
|
||||||
@ -167,7 +147,7 @@
|
|||||||
@include('components.video-card', ['video' => $entry['item']])
|
@include('components.video-card', ['video' => $entry['item']])
|
||||||
@else
|
@else
|
||||||
@php $pl = $entry['item']; @endphp
|
@php $pl = $entry['item']; @endphp
|
||||||
@include('components.playlist-card', ['playlist' => $pl, 'showTypeBadge' => true])
|
@include('components.playlist-card', ['playlist' => $pl])
|
||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
@php
|
@php
|
||||||
$audioUrl = route('videos.stream', $video);
|
$audioUrl = route('videos.stream', $video);
|
||||||
$coverUrl = $video->thumbnail ? asset('storage/thumbnails/' . $video->thumbnail) : asset('storage/images/logo.png');
|
$coverUrl = $video->thumbnail ? route('media.thumbnail', $video->thumbnail) : asset('storage/images/logo.png');
|
||||||
$nextUrl = isset($nextVideo, $playlist) ? route('videos.show', $nextVideo).'?playlist='.$playlist->share_token : null;
|
$nextUrl = isset($nextVideo, $playlist) ? route('videos.show', $nextVideo).'?playlist='.$playlist->share_token : null;
|
||||||
$prevUrl = isset($previousVideo, $playlist) ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null;
|
$prevUrl = isset($previousVideo, $playlist) ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null;
|
||||||
$slideUrls = $video->slides->count() > 1
|
$slideUrls = $video->slides->count() > 1
|
||||||
? $video->slides->map(fn($s) => asset('storage/thumbnails/' . $s->filename))->values()->all()
|
? $video->slides->map(fn($s) => route('media.thumbnail', $s->filename))->values()->all()
|
||||||
: [];
|
: [];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
|
|||||||
@ -478,11 +478,15 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Edit Button - Only for video owner -->
|
<!-- Edit / Delete Buttons - Only for video owner -->
|
||||||
@if (Auth::id() === $video->user_id)
|
@if (Auth::id() === $video->user_id)
|
||||||
<button class="yt-action-btn" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
|
<button class="yt-action-btn" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
|
||||||
<i class="bi bi-pencil"></i> Edit
|
<i class="bi bi-pencil"></i> Edit
|
||||||
</button>
|
</button>
|
||||||
|
<button class="yt-action-btn" onclick="openVideoDeleteDialog()"
|
||||||
|
style="color:#ef4444;">
|
||||||
|
<i class="bi bi-trash"></i> Delete
|
||||||
|
</button>
|
||||||
@endif
|
@endif
|
||||||
@else
|
@else
|
||||||
<a href="{{ route('login') }}" class="yt-action-btn">
|
<a href="{{ route('login') }}" class="yt-action-btn">
|
||||||
@ -696,7 +700,7 @@
|
|||||||
style="{{ $isCurrent ? 'cursor:default;' : 'cursor:pointer;' }}">
|
style="{{ $isCurrent ? 'cursor:default;' : 'cursor:pointer;' }}">
|
||||||
<div class="sidebar-thumb" style="position: relative;">
|
<div class="sidebar-thumb" style="position: relative;">
|
||||||
@if ($playlistVideo->thumbnail)
|
@if ($playlistVideo->thumbnail)
|
||||||
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
|
<img src="{{ route('media.thumbnail', $playlistVideo->thumbnail) }}"
|
||||||
alt="{{ $playlistVideo->title }}">
|
alt="{{ $playlistVideo->title }}">
|
||||||
@else
|
@else
|
||||||
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
|
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
|
||||||
@ -751,7 +755,7 @@
|
|||||||
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
|
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
|
||||||
<div class="sidebar-thumb" style="position: relative;">
|
<div class="sidebar-thumb" style="position: relative;">
|
||||||
@if ($recVideo->thumbnail)
|
@if ($recVideo->thumbnail)
|
||||||
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
|
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
|
||||||
alt="{{ $recVideo->title }}">
|
alt="{{ $recVideo->title }}">
|
||||||
@else
|
@else
|
||||||
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
|
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
|
||||||
@ -822,7 +826,12 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
@auth
|
@auth
|
||||||
@if (Auth::id() !== $video->user_id)
|
@if (Auth::id() === $video->user_id)
|
||||||
|
<button class="yt-action-btn" onclick="openVideoDeleteDialog()"
|
||||||
|
style="flex:1;color:#ef4444;">
|
||||||
|
<i class="bi bi-trash"></i><span>Delete</span>
|
||||||
|
</button>
|
||||||
|
@else
|
||||||
<button class="yt-action-btn" style="flex:1;background:var(--brand-red);color:white;">
|
<button class="yt-action-btn" style="flex:1;background:var(--brand-red);color:white;">
|
||||||
<i class="bi bi-bell"></i><span>Subscribe</span>
|
<i class="bi bi-bell"></i><span>Subscribe</span>
|
||||||
</button>
|
</button>
|
||||||
@ -886,6 +895,113 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@auth
|
||||||
|
@if(Auth::id() === $video->user_id)
|
||||||
|
@php $owner2fa = Auth::user()->two_factor_enabled && Auth::user()->two_factor_secret; @endphp
|
||||||
|
|
||||||
|
{{-- Delete confirmation dialog --}}
|
||||||
|
<div id="videoDeleteDialog" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.7);backdrop-filter:blur(4px);align-items:center;justify-content:center;padding:16px;" onclick="if(event.target===this)closeVideoDeleteDialog()">
|
||||||
|
<div style="background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:16px;width:100%;max-width:420px;overflow:hidden;box-shadow:0 24px 60px rgba(0,0,0,.6);">
|
||||||
|
<div style="padding:20px 24px 16px;border-bottom:1px solid var(--border-color);display:flex;align-items:center;justify-content:space-between;">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;font-size:16px;font-weight:600;color:#ef4444;">
|
||||||
|
<i class="bi bi-trash"></i> Delete Video
|
||||||
|
</div>
|
||||||
|
<button onclick="closeVideoDeleteDialog()" style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:18px;line-height:1;padding:4px;"><i class="bi bi-x-lg"></i></button>
|
||||||
|
</div>
|
||||||
|
<div style="padding:20px 24px;">
|
||||||
|
<p style="margin:0 0 12px;font-size:14px;color:var(--text-primary);">
|
||||||
|
You are about to permanently delete <strong>{{ $video->title }}</strong>.
|
||||||
|
</p>
|
||||||
|
<div style="background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.25);border-radius:8px;padding:10px 14px;font-size:13px;color:#fca5a5;display:flex;gap:8px;align-items:flex-start;">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill" style="flex-shrink:0;margin-top:1px;"></i>
|
||||||
|
<span>All views, likes, comments, and HLS files will also be deleted. This cannot be undone.</span>
|
||||||
|
</div>
|
||||||
|
@if($owner2fa)
|
||||||
|
<div style="margin-top:16px;">
|
||||||
|
<label style="font-size:13px;color:var(--text-secondary);display:flex;align-items:center;gap:6px;margin-bottom:8px;">
|
||||||
|
<i class="bi bi-shield-lock-fill" style="color:#a78bfa;"></i>
|
||||||
|
Enter your <strong style="color:var(--text-primary);">2FA code</strong> to confirm
|
||||||
|
</label>
|
||||||
|
<input type="text" id="delVideoOtp" inputmode="numeric" pattern="[0-9]*"
|
||||||
|
maxlength="6" autocomplete="one-time-code" placeholder="000000"
|
||||||
|
style="width:100%;background:var(--bg-primary);border:1px solid var(--border-color);border-radius:8px;padding:10px 14px;color:#fff;font-size:22px;letter-spacing:.3em;text-align:center;box-sizing:border-box;">
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div id="delVideoError" style="display:none;margin-top:12px;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 style="padding:12px 24px 20px;display:flex;gap:10px;justify-content:flex-end;">
|
||||||
|
<button onclick="closeVideoDeleteDialog()" class="yt-action-btn">Cancel</button>
|
||||||
|
<button id="delVideoConfirmBtn" onclick="confirmVideoDelete()" class="yt-action-btn" style="background:#ef4444;color:#fff;border-color:#ef4444;">
|
||||||
|
<i class="bi bi-trash"></i> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const _owner2fa = {{ $owner2fa ? 'true' : 'false' }};
|
||||||
|
const _videoDeleteUrl = '{{ route('videos.destroy', $video) }}';
|
||||||
|
const _videoCsrf = '{{ csrf_token() }}';
|
||||||
|
|
||||||
|
function openVideoDeleteDialog() {
|
||||||
|
const dlg = document.getElementById('videoDeleteDialog');
|
||||||
|
dlg.style.display = 'flex';
|
||||||
|
dlg.style.alignItems = 'center';
|
||||||
|
dlg.style.justifyContent = 'center';
|
||||||
|
document.getElementById('delVideoError').style.display = 'none';
|
||||||
|
if (_owner2fa) { document.getElementById('delVideoOtp').value = ''; setTimeout(() => document.getElementById('delVideoOtp').focus(), 100); }
|
||||||
|
}
|
||||||
|
function closeVideoDeleteDialog() {
|
||||||
|
document.getElementById('videoDeleteDialog').style.display = 'none';
|
||||||
|
}
|
||||||
|
function confirmVideoDelete() {
|
||||||
|
const btn = document.getElementById('delVideoConfirmBtn');
|
||||||
|
const errEl = document.getElementById('delVideoError');
|
||||||
|
const otpCode = _owner2fa ? document.getElementById('delVideoOtp').value.replace(/\s/g,'') : '';
|
||||||
|
|
||||||
|
if (_owner2fa && otpCode.length !== 6) {
|
||||||
|
errEl.textContent = 'Please enter your 6-digit 2FA code.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
document.getElementById('delVideoOtp').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Deleting…';
|
||||||
|
errEl.style.display = 'none';
|
||||||
|
|
||||||
|
const body = new URLSearchParams({ '_token': _videoCsrf, '_method': 'DELETE' });
|
||||||
|
if (_owner2fa) body.append('otp_code', otpCode);
|
||||||
|
|
||||||
|
fetch(_videoDeleteUrl, {
|
||||||
|
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 = '/';
|
||||||
|
} else {
|
||||||
|
errEl.textContent = data.message || 'Delete failed.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-trash"></i> Delete';
|
||||||
|
if (_owner2fa) { document.getElementById('delVideoOtp').value = ''; document.getElementById('delVideoOtp').focus(); }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
errEl.textContent = 'An error occurred. Please try again.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-trash"></i> Delete';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeVideoDeleteDialog(); });
|
||||||
|
</script>
|
||||||
|
@endif
|
||||||
|
@endauth
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -455,7 +455,7 @@
|
|||||||
onclick="window.location.href='{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}'">
|
onclick="window.location.href='{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}'">
|
||||||
<div class="sidebar-thumb" style="position: relative;">
|
<div class="sidebar-thumb" style="position: relative;">
|
||||||
@if ($playlistVideo->thumbnail)
|
@if ($playlistVideo->thumbnail)
|
||||||
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
|
<img src="{{ route('media.thumbnail', $playlistVideo->thumbnail) }}"
|
||||||
alt="{{ $playlistVideo->title }}">
|
alt="{{ $playlistVideo->title }}">
|
||||||
@else
|
@else
|
||||||
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
|
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
|
||||||
@ -507,7 +507,7 @@
|
|||||||
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
|
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
|
||||||
<div class="sidebar-thumb" style="position: relative;">
|
<div class="sidebar-thumb" style="position: relative;">
|
||||||
@if ($recVideo->thumbnail)
|
@if ($recVideo->thumbnail)
|
||||||
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
|
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
|
||||||
alt="{{ $recVideo->title }}">
|
alt="{{ $recVideo->title }}">
|
||||||
@else
|
@else
|
||||||
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
|
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
|
||||||
|
|||||||
@ -2543,7 +2543,7 @@
|
|||||||
onclick="window.location.href='{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}'">
|
onclick="window.location.href='{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}'">
|
||||||
<div class="sidebar-thumb" style="position: relative;">
|
<div class="sidebar-thumb" style="position: relative;">
|
||||||
@if ($playlistVideo->thumbnail)
|
@if ($playlistVideo->thumbnail)
|
||||||
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
|
<img src="{{ route('media.thumbnail', $playlistVideo->thumbnail) }}"
|
||||||
alt="{{ $playlistVideo->title }}">
|
alt="{{ $playlistVideo->title }}">
|
||||||
@else
|
@else
|
||||||
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
|
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
|
||||||
@ -2595,7 +2595,7 @@
|
|||||||
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
|
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
|
||||||
<div class="sidebar-thumb" style="position: relative;">
|
<div class="sidebar-thumb" style="position: relative;">
|
||||||
@if ($recVideo->thumbnail)
|
@if ($recVideo->thumbnail)
|
||||||
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
|
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
|
||||||
alt="{{ $recVideo->title }}">
|
alt="{{ $recVideo->title }}">
|
||||||
@else
|
@else
|
||||||
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
|
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
|
||||||
|
|||||||
@ -483,7 +483,7 @@
|
|||||||
onclick="window.location.href='{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}'">
|
onclick="window.location.href='{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}'">
|
||||||
<div class="sidebar-thumb" style="position: relative;">
|
<div class="sidebar-thumb" style="position: relative;">
|
||||||
@if ($playlistVideo->thumbnail)
|
@if ($playlistVideo->thumbnail)
|
||||||
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
|
<img src="{{ route('media.thumbnail', $playlistVideo->thumbnail) }}"
|
||||||
alt="{{ $playlistVideo->title }}">
|
alt="{{ $playlistVideo->title }}">
|
||||||
@else
|
@else
|
||||||
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
|
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
|
||||||
@ -535,7 +535,7 @@
|
|||||||
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
|
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
|
||||||
<div class="sidebar-thumb" style="position: relative;">
|
<div class="sidebar-thumb" style="position: relative;">
|
||||||
@if ($recVideo->thumbnail)
|
@if ($recVideo->thumbnail)
|
||||||
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
|
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
|
||||||
alt="{{ $recVideo->title }}">
|
alt="{{ $recVideo->title }}">
|
||||||
@else
|
@else
|
||||||
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
|
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
use App\Http\Controllers\CommentController;
|
use App\Http\Controllers\CommentController;
|
||||||
use App\Http\Controllers\ImageUploadController;
|
use App\Http\Controllers\ImageUploadController;
|
||||||
use App\Http\Controllers\MatchEventController;
|
use App\Http\Controllers\MatchEventController;
|
||||||
|
use App\Http\Controllers\MediaController;
|
||||||
use App\Http\Controllers\PostController;
|
use App\Http\Controllers\PostController;
|
||||||
use App\Http\Controllers\SuperAdminController;
|
use App\Http\Controllers\SuperAdminController;
|
||||||
use App\Http\Controllers\UserController;
|
use App\Http\Controllers\UserController;
|
||||||
@ -10,6 +11,12 @@ use App\Http\Controllers\VideoController;
|
|||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
// Media asset routes — NAS-aware image serving (public, no auth)
|
||||||
|
Route::get('/media/thumbnails/{filename}', [MediaController::class, 'thumbnail'])->name('media.thumbnail')->where('filename', '.+');
|
||||||
|
Route::get('/media/avatars/{filename}', [MediaController::class, 'avatar'])->name('media.avatar')->where('filename', '.+');
|
||||||
|
Route::get('/media/banners/{filename}', [MediaController::class, 'banner'])->name('media.banner')->where('filename', '.+');
|
||||||
|
Route::get('/media/post-images/{filename}', [MediaController::class, 'postImage'])->name('media.post-image')->where('filename', '.+');
|
||||||
|
|
||||||
// Root route - show videos
|
// Root route - show videos
|
||||||
Route::get('/', [VideoController::class, 'index'])->name('home');
|
Route::get('/', [VideoController::class, 'index'])->name('home');
|
||||||
|
|
||||||
@ -52,6 +59,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
Route::post('/videos', [VideoController::class, 'store'])->name('videos.store');
|
Route::post('/videos', [VideoController::class, 'store'])->name('videos.store');
|
||||||
Route::get('/videos/{video}/edit', [VideoController::class, 'edit'])->name('videos.edit');
|
Route::get('/videos/{video}/edit', [VideoController::class, 'edit'])->name('videos.edit');
|
||||||
Route::put('/videos/{video}', [VideoController::class, 'update'])->name('videos.update');
|
Route::put('/videos/{video}', [VideoController::class, 'update'])->name('videos.update');
|
||||||
|
Route::post('/videos/{video}/replace-file', [VideoController::class, 'replaceFile'])->name('videos.replaceFile');
|
||||||
Route::delete('/videos/{video}', [VideoController::class, 'destroy'])->name('videos.destroy');
|
Route::delete('/videos/{video}', [VideoController::class, 'destroy'])->name('videos.destroy');
|
||||||
|
|
||||||
// Like/unlike routes
|
// Like/unlike routes
|
||||||
@ -197,6 +205,16 @@ Route::middleware(['auth', 'super_admin'])->prefix('admin')->name('admin.')->gro
|
|||||||
|
|
||||||
// NAS Storage
|
// NAS Storage
|
||||||
Route::get('/nas-storage', [SuperAdminController::class, 'nasStorage'])->name('nas-storage');
|
Route::get('/nas-storage', [SuperAdminController::class, 'nasStorage'])->name('nas-storage');
|
||||||
|
Route::post('/nas-repair', [SuperAdminController::class, 'nasRepair'])->name('admin.nas.repair');
|
||||||
|
Route::post('/nas-delete', [SuperAdminController::class, 'nasDelete'])->name('nas.delete');
|
||||||
|
|
||||||
|
// NAS Disable Flow
|
||||||
|
Route::post('/nas/disable', [SuperAdminController::class, 'nasDisable'])->name('nas.disable');
|
||||||
|
Route::get('/nas/migrate-progress', [SuperAdminController::class, 'nasMigrateProgress'])->name('nas.migrate-progress');
|
||||||
|
|
||||||
|
// Backup & Restore
|
||||||
|
Route::get('/backup/users-settings', [SuperAdminController::class, 'backupUsersSettings'])->name('backup.users-settings');
|
||||||
|
Route::post('/backup/restore', [SuperAdminController::class, 'restoreUsersSettings'])->name('backup.restore');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Exit impersonation — accessible by any authenticated user (the impersonated user hitting exit)
|
// Exit impersonation — accessible by any authenticated user (the impersonated user hitting exit)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user