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:
ghassan 2026-05-16 11:15:20 +03:00
parent 6b3ab5b65e
commit c160242dbc
46 changed files with 5365 additions and 815 deletions

View File

@ -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`).
### NAS is always enabled — it is the only storage backend
**The NAS is permanently enabled in this project. Local disk is never a storage destination — it is a temporary write buffer only. Every user file must end up on the NAS and be served from the NAS. No exceptions.**
File types and their NAS locations:
| File type | NAS path | Served via |
|---|---|---|
| Video / audio | `users/{slug}/videos/{title-slug}/{title-slug}.{ext}` | `NasSyncService::ensureLocalCopy()` |
| Video thumbnail | `users/{slug}/videos/{title-slug}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Audio slides | `users/{slug}/videos/{title-slug}/slides/{n}.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Playlist thumbnail | `users/{slug}/playlists/{playlist-id}/thumb.{ext}` | `MediaController::thumbnail` + `ensureLocalAsset()` |
| Avatar | `users/{slug}/profile/avatar.{ext}` | `MediaController::avatar` + `ensureLocalAsset()` |
| Banner | `users/{slug}/profile/banner.{ext}` | `MediaController::banner` + `ensureLocalAsset()` |
| Post images | `users/{slug}/posts/{post-id}/{filename}` | `MediaController::postImage` + `ensureLocalAsset()` |
The only files that live permanently on local disk are HLS segments (`storage/app/public/hls/{video_id}/`) because they are generated locally and served directly. Everything else is NAS.
**The following local directories must never exist as permanent storage.** They were deleted after migration and must not be recreated as destinations:
- `storage/app/public/thumbnails/` — formerly held video/slide/playlist thumbnails; now NAS only
- `storage/app/public/avatars/` — formerly held user avatars; now NAS only
- `storage/app/public/videos/` — formerly held uploaded video files; now NAS only
These directories may appear temporarily during an upload (as a write buffer before NAS push) and are cleaned up immediately. If you ever find files lingering there after an upload completes, it means the NAS push failed — investigate the NAS connection, do not leave files there.
**Absolute rules — these must never be violated:**
1. **Never use `asset('storage/...')` for any user file URL.** Always use the named media routes: `route('media.thumbnail', $path)`, `route('media.avatar', $path)`, `route('media.banner', $path)`, `route('media.post-image', $path)`. These routes go through `MediaController` which calls `ensureLocalAsset()` and pulls from NAS automatically.
2. **After writing any file to local disk, immediately push it to NAS and delete the local copy.** The upload flow is always: write to temp → push to NAS → delete local. Use the correct service method for each type:
- Videos/audio → `NasSyncService::uploadDirectToNas()` then `deleteLocalVideo()`
- Thumbnails (video/slide) → `NasSyncService::putFile($tempAbs, "{$nasDir}/thumb.{$ext}")` then `@unlink($tempAbs)`, store full NAS path in DB
- Playlist thumbnails → `PlaylistController::pushPlaylistThumbnailToNas()` (handles mkdirp, putFile, unlink internally)
- Avatars → `NasSyncService::syncAvatar()` then `deleteLocalAvatar()`
- Banners → `NasSyncService::syncBanner()` then `deleteLocalBanner()`
- Post images → `NasSyncService::syncPostImages()` then `deleteLocalPostImages()`
3. **Always store the full NAS relative path in the DB, never just the filename.** The DB column must contain the full `users/...` path (e.g. `users/hanzo-hattori-bfnmwq/videos/my-title/thumb.png`). Storing only the basename (e.g. `thumb.png` or a UUID filename) is the legacy format that breaks NAS serving and the MediaController fallback logic.
4. **Never call `putFile()` directly for video/audio uploads.** Always use `uploadDirectToNas()` — it resolves the correct `users/...` directory, writes `meta.json`, and updates the DB `path` and `filename` columns. Calling `putFile()` with a manually constructed path will create files in the wrong location that the streaming layer cannot find.
5. **Set `video->status = 'ready'` before dispatching `GenerateHlsJob` for NAS uploads.** The job checks `if ($video->status !== 'ready') return` and silently does nothing otherwise. For NAS, the upload is the compression step — the video is ready as soon as `uploadDirectToNas()` completes. For local storage, `CompressVideoJob` handles the status transition automatically.
6. **Never check `NasSyncService::isEnabled()` before doing a NAS operation in this project.** It is always enabled. Writing code with an `if ($nas->isEnabled())` branch that falls back to local-only storage will result in broken files the moment that branch is taken.
**If you find legacy local files that should be on NAS**, follow this migration procedure (same pattern used to clean up thumbnails and avatars):
1. Identify which DB record owns each local file (`Video::where('thumbnail', $filename)`, `User::where('avatar', $filename)`, etc.)
2. For each owned file: call `NasSyncService::mkdirp($nasDir)` then `putFile($localAbs, $nasPath)` then `@unlink($localAbs)`, then update the DB record to the full NAS path
3. Delete files with no DB match (orphans) directly with `@unlink()`
4. Once a directory is empty, `rmdir()` it — do not leave empty legacy directories
5. For playlists: use `PlaylistController::pushPlaylistThumbnailToNas()` or replicate its pattern (`mkdirp` + `putFile` + `unlink`)
### Infrastructure Notes
- **Cloudflare proxy**: `AppServiceProvider` forces HTTPS and trusts Cloudflare headers via `TrustProxies`.
- **FFmpeg config**: `/config/ffmpeg.php` — binaries at `/usr/bin/ffmpeg` and `/usr/bin/ffprobe`, GPU device 0, 3600 s timeout.

View File

@ -12,7 +12,8 @@ class NasFreeLocalStorage extends Command
{
protected $signature = 'nas:free-local
{--dry-run : Preview what would be deleted without deleting}
{--force : Actually delete local files confirmed on NAS}';
{--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';
@ -140,6 +141,7 @@ class NasFreeLocalStorage extends Command
$this->newLine();
$this->info('Scanning avatar files…');
// Legacy flat dir
$avatarDir = storage_path('app/public/avatars');
if (is_dir($avatarDir)) {
foreach (glob($avatarDir . '/*') as $file) {
@ -148,23 +150,42 @@ class NasFreeLocalStorage extends Command
$user = User::where('avatar', $filename)->first();
if (! $user) continue;
// Confirm avatar.webp is on NAS
$dir = "users/{$nas->userSlug($user)}/profile";
$raw = null;
try { $raw = $nas->getContent("{$dir}/avatar.webp"); } catch (\Throwable) {}
if ($raw !== null) {
$bytes = filesize($file);
$totalBytes += $bytes;
$toDelete[] = ['label' => "avatar:{$filename}", 'path' => $file, 'bytes' => $bytes];
$bytes = filesize($file); $totalBytes += $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 ───────────────────────────────────────────────────────────
$this->newLine();
$this->info('Scanning banner files…');
// Legacy flat dir
$bannerDir = storage_path('app/public/banners');
if (is_dir($bannerDir)) {
foreach (glob($bannerDir . '/*') as $file) {
@ -177,14 +198,32 @@ class NasFreeLocalStorage extends Command
$raw = null;
try { $raw = $nas->getContent("{$dir}/cover.webp"); } catch (\Throwable) {}
if ($raw !== null) {
$bytes = filesize($file);
$totalBytes += $bytes;
$toDelete[] = ['label' => "banner:{$filename}", 'path' => $file, 'bytes' => $bytes];
$bytes = filesize($file); $totalBytes += $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 ───────────────────────────────────────
// The slideshow/ directory is a render cache that is always regenerated on
// demand, so its contents are safe to delete unconditionally.
@ -205,6 +244,27 @@ class NasFreeLocalStorage extends Command
$this->line(' Done scanning slideshow cache.');
}
// ── NAS stream cache (nas_cache/videos/) ──────────────────────────────
// These are on-demand local copies of NAS videos used for HTTP streaming.
// Always safe to delete — they are re-downloaded from NAS on next play.
$this->newLine();
$this->info('Scanning NAS stream cache…');
$nasCacheDir = storage_path('app/nas_cache/videos');
$ttl = (int) $this->option('cache-ttl');
$cutoff = time() - ($ttl * 3600);
if (is_dir($nasCacheDir)) {
foreach (glob("{$nasCacheDir}/*") as $file) {
if (! is_file($file)) continue;
if ($ttl > 0 && filemtime($file) >= $cutoff) continue;
$bytes = filesize($file);
$totalBytes += $bytes;
$toDelete[] = ['label' => 'nas-cache', 'path' => $file, 'bytes' => $bytes];
}
$this->line(' Done scanning NAS stream cache.');
}
// ── Summary ───────────────────────────────────────────────────────────
$this->newLine();
@ -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->info("Deleted {$deleted} file(s), freed {$this->humanBytes($totalBytes)}.");
@ -259,6 +328,32 @@ class NasFreeLocalStorage extends Command
return 0;
}
/**
* Bottom-up prune: remove dirs that are empty or contain only meta.json.
*/
private function pruneEmptyDirs(string $root): void
{
if (! is_dir($root)) return;
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iter as $item) {
if (! $item->isDir()) continue;
$path = $item->getPathname();
$contents = array_diff(scandir($path) ?: [], ['.', '..']);
$nonMeta = array_diff($contents, ['meta.json']);
if (empty($contents)) {
@rmdir($path);
} elseif (empty($nonMeta)) {
@unlink("{$path}/meta.json");
@rmdir($path);
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private function humanBytes(int $bytes): string

View 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';
}
}

View File

@ -17,6 +17,14 @@ class Kernel extends ConsoleKernel
->cron("*/{$interval} * * * *")
->withoutOverlapping()
->runInBackground();
// Evict NAS stream-cache files older than 24 hours
$schedule->call(function () {
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled()) {
$nas->clearNasCache(24);
}
})->daily()->name('nas-cache-evict')->withoutOverlapping();
}
/**

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Models\Playlist;
use App\Models\User;
use App\Models\Video;
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);
}
@ -87,6 +93,27 @@ class MediaController extends Controller
public function avatar(string $filename, NasSyncService $nas): Response
{
// New format: "users/{slug}/profile/avatar.{ext}"
if (str_starts_with($filename, 'users/')) {
$local = storage_path('app/' . $filename);
if (! file_exists($local)) {
@mkdir(dirname($local), 0755, true);
$user = User::where('avatar', $filename)->first();
if ($user) {
$dir = "users/{$nas->userSlug($user)}/profile";
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
foreach (["avatar.webp", "avatar.{$ext}"] as $nasFile) {
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) break;
}
}
if (! file_exists($local)) abort(404);
}
return $this->fileResponse($local);
}
// Legacy flat format
$local = storage_path('app/public/avatars/' . $filename);
if (! file_exists($local)) {
@ -104,6 +131,27 @@ class MediaController extends Controller
public function banner(string $filename, NasSyncService $nas): Response
{
// New format: "users/{slug}/profile/cover.{ext}"
if (str_starts_with($filename, 'users/')) {
$local = storage_path('app/' . $filename);
if (! file_exists($local)) {
@mkdir(dirname($local), 0755, true);
$user = User::where('banner', $filename)->first();
if ($user) {
$dir = "users/{$nas->userSlug($user)}/profile";
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp';
foreach (["cover.webp", "cover.{$ext}"] as $nasFile) {
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) break;
}
}
if (! file_exists($local)) abort(404);
}
return $this->fileResponse($local);
}
// Legacy flat format
$local = storage_path('app/public/banners/' . $filename);
if (! file_exists($local)) {
@ -119,6 +167,29 @@ class MediaController extends Controller
return $this->fileResponse($local);
}
public function postImage(string $filename, NasSyncService $nas): Response
{
// New format: "users/{slug}/posts/{id}/{seq}.{ext}"
if (str_starts_with($filename, 'users/')) {
$local = storage_path('app/' . $filename);
if (! file_exists($local)) {
@mkdir(dirname($local), 0755, true);
// NAS path is identical to the relative path stored in DB
$nas->ensureLocalAsset($local, $filename);
if (! file_exists($local)) abort(404);
}
return $this->fileResponse($local);
}
// Legacy flat format
$local = storage_path('app/public/post_images/' . $filename);
if (! file_exists($local)) abort(404);
return $this->fileResponse($local);
}
// ── Helper ────────────────────────────────────────────────────────────────
private function fileResponse(string $path): Response

View File

@ -159,9 +159,8 @@ class PlaylistController extends Controller
// Handle thumbnail upload
if ($request->hasFile('thumbnail')) {
$file = $request->file('thumbnail');
$filename = self::generateFilename($file->getClientOriginalExtension());
$file->storeAs('public/thumbnails', $filename);
$playlist->update(['thumbnail' => $filename]);
$nasPath = self::pushPlaylistThumbnailToNas($file, $playlist);
$playlist->update(['thumbnail' => $nasPath]);
}
// Reload playlist with thumbnail
@ -228,28 +227,19 @@ class PlaylistController extends Controller
// Handle thumbnail upload
if ($request->hasFile('thumbnail')) {
// Delete old thumbnail if exists
// Delete old thumbnail from NAS if exists
if ($playlist->thumbnail) {
$oldPath = storage_path('app/public/thumbnails/'.$playlist->thumbnail);
if (file_exists($oldPath)) {
unlink($oldPath);
}
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
}
// Upload new thumbnail
$file = $request->file('thumbnail');
$filename = self::generateFilename($file->getClientOriginalExtension());
$file->storeAs('public/thumbnails', $filename);
$updateData['thumbnail'] = $filename;
$updateData['thumbnail'] = self::pushPlaylistThumbnailToNas($file, $playlist);
}
// Handle thumbnail removal
if ($request->input('remove_thumbnail') == '1') {
if ($playlist->thumbnail) {
$oldPath = storage_path('app/public/thumbnails/'.$playlist->thumbnail);
if (file_exists($oldPath)) {
unlink($oldPath);
}
self::deletePlaylistThumbnailFromNas($playlist->thumbnail);
$updateData['thumbnail'] = null;
}
}
@ -472,4 +462,37 @@ class PlaylistController extends Controller
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) {}
}
}

View File

@ -6,9 +6,9 @@ use App\Models\Post;
use App\Models\PostImage;
use App\Models\PostVideo;
use App\Models\User;
use App\Services\NasSyncService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
class PostController extends Controller
{
@ -29,7 +29,6 @@ class PostController extends Controller
'images.*' => 'image|mimes:jpg,jpeg,png,webp,gif|max:8192',
'video_ids' => 'nullable|array|max:10',
'video_ids.*' => 'exists:videos,id',
// Legacy fields
'video_id' => 'nullable|exists:videos,id',
'image' => 'nullable|image|mimes:jpg,jpeg,png,webp,gif|max:8192',
]);
@ -43,35 +42,69 @@ class PostController extends Controller
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,
'body' => $request->body,
'video_id' => $request->video_id ?? null,
];
]);
// Legacy single image (backward compat)
if ($hasLegacyImg) {
$filename = uniqid('post_', true) . '.' . $request->file('image')->getClientOriginalExtension();
$request->file('image')->storeAs('public/post_images', $filename);
$data['image'] = $filename;
}
$nas = app(NasSyncService::class);
$nasMode = $nas->isEnabled();
$postDir = $nas->resolvePostDir($post); // "users/{slug}/posts/{id}"
$post = Post::create($data);
if ($hasImages || $hasLegacyImg) {
if ($nasMode) {
// ── NAS primary: upload directly from PHP temp files ──────────
$nas->mkdirp($postDir);
// New multi-image
if ($hasImages) {
foreach ($request->file('images') as $idx => $file) {
$filename = uniqid('post_', true) . '.' . $file->getClientOriginalExtension();
$file->storeAs('public/post_images', $filename);
PostImage::create([
'post_id' => $post->id,
'filename' => $filename,
'sort_order' => $idx,
]);
if ($hasLegacyImg) {
$file = $request->file('image');
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$nasPath = "{$postDir}/0.{$ext}";
$nas->putFile($file->getRealPath(), $nasPath);
$post->update(['image' => $nasPath]);
}
if ($hasImages) {
foreach ($request->file('images') as $idx => $file) {
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$nasPath = "{$postDir}/" . ($idx + 1) . ".{$ext}";
$nas->putFile($file->getRealPath(), $nasPath);
PostImage::create([
'post_id' => $post->id,
'filename' => $nasPath,
'sort_order' => $idx,
]);
}
}
} else {
// ── Local storage: save inside the user's posts directory ─────
$localDir = storage_path('app/' . $postDir);
@mkdir($localDir, 0755, true);
if ($hasLegacyImg) {
$ext = $request->file('image')->getClientOriginalExtension() ?: 'jpg';
$filename = "0.{$ext}";
$request->file('image')->move($localDir, $filename);
$post->update(['image' => "{$postDir}/{$filename}"]);
}
if ($hasImages) {
foreach ($request->file('images') as $idx => $file) {
$ext = $file->getClientOriginalExtension() ?: 'jpg';
$filename = ($idx + 1) . ".{$ext}";
$file->move($localDir, $filename);
PostImage::create([
'post_id' => $post->id,
'filename' => "{$postDir}/{$filename}",
'sort_order' => $idx,
]);
}
}
}
}
// New multi-video
if ($hasVideoIds) {
foreach ($request->input('video_ids') as $idx => $videoId) {
PostVideo::create([
@ -91,14 +124,17 @@ class PostController extends Controller
abort(403);
}
if ($post->image) {
Storage::delete('public/post_images/' . $post->image);
$post->loadMissing('postImages');
$nas = app(NasSyncService::class);
if ($nas->isEnabled()) {
try {
$nas->deleteNasPost($post);
} catch (\Throwable) {}
}
// Delete multi-image files
foreach ($post->postImages as $postImage) {
Storage::delete('public/post_images/' . $postImage->filename);
}
// Always clean up local copies (handles both legacy flat and new structured format)
$nas->deleteLocalPostImages($post);
$post->delete();
@ -107,7 +143,7 @@ class PostController extends Controller
public function react(Post $post)
{
$user = Auth::user();
$user = Auth::user();
$existing = $post->reactions()->where('user_id', $user->id)->first();
if ($existing) {

View File

@ -313,12 +313,46 @@ class SuperAdminController extends Controller
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
public function deleteUser(User $user)
public function deleteUser(Request $request, User $user)
{
// Prevent deleting yourself
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', [
@ -343,7 +377,7 @@ class SuperAdminController extends Controller
$user->delete();
return redirect()->route('admin.users')->with('success', 'User deleted successfully!');
return response()->json(['success' => true, 'message' => 'User deleted successfully!']);
}
// List all videos
@ -530,16 +564,33 @@ class SuperAdminController extends Controller
'download_access' => 'nullable|in:disabled,everyone,registered,subscribers',
]);
$oldTitle = $video->title;
$data = $request->only(['title', 'description', 'visibility', 'type', 'status', 'is_shorts', 'download_access']);
$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!');
}
// 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;
AuditLog::record('admin.video.deleted', [
@ -561,7 +612,7 @@ class SuperAdminController extends Controller
$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_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
'ffmpeg_binary' => 'required|string|max:255',
'nas_sync_enabled' => 'required|in:true,false',
]);
$binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
@ -827,13 +877,12 @@ class SuperAdminController extends Controller
return back()->withErrors(['ffmpeg_binary' => "FFmpeg binary not found or not executable: {$binary}"]);
}
Setting::set('gpu_enabled', $request->gpu_enabled);
Setting::set('gpu_device', (string) $request->gpu_device);
Setting::set('gpu_encoder', $request->gpu_encoder);
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
Setting::set('gpu_preset', $request->gpu_preset);
Setting::set('ffmpeg_binary', $binary);
Setting::set('nas_sync_enabled', $request->nas_sync_enabled);
Setting::set('gpu_enabled', $request->gpu_enabled);
Setting::set('gpu_device', (string) $request->gpu_device);
Setting::set('gpu_encoder', $request->gpu_encoder);
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
Setting::set('gpu_preset', $request->gpu_preset);
Setting::set('ffmpeg_binary', $binary);
return back()->with('success', 'Settings saved.');
}
@ -897,4 +946,433 @@ class SuperAdminController extends Controller
$nodes = config('nas-file-manager.schema', []);
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.");
}
}

View File

@ -68,17 +68,36 @@ class UserController extends Controller
'timezone' => $request->timezone ?: null,
];
$nas = app(\App\Services\NasSyncService::class);
if ($request->hasFile('avatar')) {
if ($user->avatar) {
Storage::delete('public/avatars/'.$user->avatar);
}
$filename = self::generateFilename($request->file('avatar')->getClientOriginalExtension());
$request->file('avatar')->storeAs('public/avatars', $filename);
$data['avatar'] = $filename;
// Delete old avatar (handles both flat and new relative-path formats)
$nas->deleteLocalAvatar($user);
$ext = $request->file('avatar')->getClientOriginalExtension() ?: 'webp';
$profileDir = $nas->localProfileDir($user);
$destFilename = "avatar.{$ext}";
$destPath = "{$profileDir}/{$destFilename}";
$relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}";
@mkdir($profileDir, 0755, true);
$request->file('avatar')->move($profileDir, $destFilename);
$data['avatar'] = $relPath;
}
$user->update($data);
// Push avatar to NAS and remove local copy when NAS is primary storage
if ($nas->isEnabled()) {
if ($request->hasFile('avatar')) {
$destPath = storage_path('app/' . $data['avatar']);
if (file_exists($destPath)) {
$nas->syncAvatar($user, $destPath);
$nas->deleteLocalAvatar($user);
}
}
}
// Sync social links
$user->socialLinks()->delete();
$order = 0;
@ -418,14 +437,68 @@ class UserController extends Controller
public function updateAvatar(Request $request)
{
$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]);
}
public function updateBanner(Request $request)
{
$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]);
}
}

View File

@ -527,6 +527,8 @@ class VideoController extends Controller
'slides_add.*' => 'image|mimes:jpg,jpeg,png,webp|max:20480',
]);
$oldTitle = $video->title;
$data = $request->only(['title', 'description', 'visibility', 'type']);
$data['download_access'] = $request->input('download_access', 'disabled');
@ -544,10 +546,17 @@ class VideoController extends Controller
$userSlug = $nas->userSlug($video->user);
$data['thumbnail'] = 'users/' . $userSlug . '/videos/' . basename($localDir) . "/thumb.{$ext}";
} 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());
$data['thumbnail'] = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
$data['thumbnail'] = basename($data['thumbnail']);
$request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
$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')) {
$nextPos = count($keptOrder);
$isNewFormat = str_starts_with($video->path, 'users/');
$nasForSlides = app(\App\Services\NasSyncService::class);
if ($isNewFormat) {
$nas = app(\App\Services\NasSyncService::class);
$localDir = $nas->localVideoDir($video);
$localDir = $nasForSlides->localVideoDir($video);
@mkdir("{$localDir}/slides", 0755, true);
$userSlug = $nas->userSlug($video->user);
$userSlug = $nasForSlides->userSlug($video->user);
$relDir = 'users/' . $userSlug . '/videos/' . basename($localDir);
}
foreach ($request->file('slides_add') as $file) {
@ -594,9 +603,18 @@ class VideoController extends Controller
$file->move("{$localDir}/slides", "{$slide->id}.{$ext}");
$slide->update(['filename' => "{$relDir}/slides/{$slide->id}.{$ext}"]);
} else {
$fname = self::generateFilename($file->getClientOriginalExtension());
// Legacy video: push slide directly to NAS
$fname = self::generateFilename($file->getClientOriginalExtension());
$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++;
$slidesChanged = true;
@ -618,6 +636,19 @@ class VideoController extends Controller
$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 {
NasSyncVideoJob::dispatch($video->fresh());
} catch (\Throwable $e) {
@ -692,6 +723,182 @@ class VideoController extends Controller
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)
{
$hours = $request->get('hours', 48);
@ -1127,7 +1334,11 @@ class VideoController extends Controller
$path = $video->localVideoPath();
if (! file_exists($path)) {
abort(404, 'Video file not found.');
$nas = app(\App\Services\NasSyncService::class);
$path = $nas->ensureLocalCopy($video);
if (! $path) {
abort(404, 'Video file not found.');
}
}
$slug = $this->safeFilename($video->title, 'audio');
@ -1568,7 +1779,7 @@ class VideoController extends Controller
'channel' => $u->username,
'name' => $u->name,
'avatar' => $u->avatar
? asset('storage/avatars/' . $u->avatar)
? route('media.avatar', $u->avatar)
: 'https://i.pravatar.cc/150?u=' . $u->id,
'count' => (int) $u->cnt,
'last_at' => $u->last_at,
@ -1602,7 +1813,7 @@ class VideoController extends Controller
'user_name' => $r->user_name ?? 'Guest',
'user_avatar' => $r->user_id
? ($r->user_avatar
? asset('storage/avatars/' . $r->user_avatar)
? route('media.avatar', $r->user_avatar)
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
: null,
]);
@ -1631,7 +1842,7 @@ class VideoController extends Controller
'id' => $u->id,
'name' => $u->name,
'avatar' => $u->avatar
? asset('storage/avatars/' . $u->avatar)
? route('media.avatar', $u->avatar)
: 'https://i.pravatar.cc/150?u=' . $u->id,
'count' => (int) $u->cnt,
'last_at' => $u->last_at,
@ -1668,7 +1879,7 @@ class VideoController extends Controller
'user_name' => $r->user_name ?? 'Guest',
'user_avatar'=> $r->user_id
? ($r->user_avatar
? asset('storage/avatars/' . $r->user_avatar)
? route('media.avatar', $r->user_avatar)
: 'https://i.pravatar.cc/150?u=' . $r->user_id)
: null,
]);
@ -1728,6 +1939,24 @@ class VideoController extends Controller
'pct' => $totalAge > 0 ? round($cnt / $totalAge * 100) : 0,
])->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 ────────────────────────────────────────
$shareLinks = \DB::table('video_shares')->where('video_id', $id)->get();
$shareIds = $shareLinks->pluck('id');
@ -1774,6 +2003,7 @@ class VideoController extends Controller
'daily' => $daily,
'peak_hour' => $peakHour,
'likes' => $video->like_count,
'likers' => $likers,
'genders' => $genders,
'age_groups' => $ageGroups,
]);
@ -1805,7 +2035,7 @@ class VideoController extends Controller
'id' => $u->id,
'channel' => $u->username,
'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,
'last_at' => $u->last_at,
]);
@ -1869,7 +2099,7 @@ class VideoController extends Controller
'id' => $u->id,
'channel' => $u->username,
'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,
'last_at' => $u->last_at,
]);
@ -1940,7 +2170,7 @@ class VideoController extends Controller
'user' => [
'id' => $user->id,
'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(),
'records' => $records,

View File

@ -30,10 +30,15 @@ class NasSyncVideoJob implements ShouldQueue
// Video uploads must keep the local file until GenerateHlsJob finishes.
if ($this->video->type === 'music') {
$nas->deleteLocalVideo($this->video);
$nas->deleteLocalAssets($this->video);
}
$nas->deleteLocalAssets($this->video);
$nas->pruneLocalVideoDir($this->video);
} 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.'
);
}
}
}

View 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);
}
}
}

View File

@ -43,13 +43,12 @@ class Playlist extends Model
}
// Accessors
public function getThumbnailUrlAttribute()
public function getThumbnailUrlAttribute(): string
{
if ($this->thumbnail) {
return asset('storage/thumbnails/'.$this->thumbnail);
return route('media.thumbnail', $this->thumbnail);
}
// Generate a placeholder based on playlist name
return 'https://ui-avatars.com/api/?name='.urlencode($this->name).'&background=random&size=200';
}

View File

@ -46,6 +46,10 @@ class Post extends Model
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);
}
}

View File

@ -15,6 +15,9 @@ class PostImage extends Model
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);
}
}

View File

@ -123,10 +123,10 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->hasMany(\App\Models\Post::class);
}
public function getAvatarUrlAttribute()
public function getAvatarUrlAttribute(): string
{
if ($this->avatar) {
return asset('storage/avatars/'.$this->avatar);
return route('media.avatar', $this->avatar);
}
return 'https://i.pravatar.cc/150?u='.$this->id;
@ -135,7 +135,7 @@ class User extends Authenticatable implements MustVerifyEmail
public function getBannerUrlAttribute(): ?string
{
if ($this->banner) {
return asset('storage/banners/'.$this->banner);
return route('media.banner', $this->banner);
}
return null;
}

View File

@ -32,5 +32,27 @@ class AppServiceProvider extends ServiceProvider
// Universal pagination view — used everywhere by default
Paginator::defaultView('partials.pagination');
Paginator::defaultSimpleView('partials.pagination');
// Merge NAS credentials stored in the DB into the package config at runtime.
// This way the live browser and all NAS operations use DB values without needing .env.
$this->app->booted(function () {
try {
$host = \App\Models\Setting::get('nas_host', '');
if ($host) {
config([
'nas-file-manager.connection.protocol' => \App\Models\Setting::get('nas_protocol', 'smb'),
'nas-file-manager.connection.host' => $host,
'nas-file-manager.connection.port' => (int) \App\Models\Setting::get('nas_port', 445),
'nas-file-manager.connection.username' => \App\Models\Setting::get('nas_username', ''),
'nas-file-manager.connection.password' => \App\Models\Setting::get('nas_password', ''),
'nas-file-manager.connection.path' => \App\Models\Setting::get('nas_path', '/media'),
'nas-file-manager.connection.smb_share' => \App\Models\Setting::get('nas_smb_share', ''),
'nas-file-manager.connection.smb_domain' => \App\Models\Setting::get('nas_smb_domain', ''),
]);
}
} catch (\Throwable $e) {
// DB may not exist yet (fresh install / migrations not run) — silently skip
}
});
}
}

View File

@ -26,9 +26,22 @@ class NasSyncService
public function titleSlug(string $title): string
{
$slug = mb_strtolower($title);
$slug = preg_replace('/[^a-z0-9]+/u', '-', $slug);
// NFC normalisation ensures consistent codepoints across sources
$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, '-');
// 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';
}
@ -338,6 +351,59 @@ class NasSyncService
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.
*/
@ -372,6 +438,49 @@ class NasSyncService
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) ──────────────────────────────────
/**
@ -534,16 +643,107 @@ class NasSyncService
public function deleteVideo(Video $video): void
{
$video->loadMissing('user');
$video->loadMissing(['user', 'slides']);
$dir = $this->resolveVideoDir($video);
$ext = pathinfo($video->filename, PATHINFO_EXTENSION) ?: 'mp4';
// ── Files in the video root ───────────────────────────────────────────
$this->deleteFile("{$dir}/" . $this->titleSlug($video->title) . ".{$ext}");
$this->deleteFile("{$dir}/thumb.webp");
$this->deleteFile("{$dir}/meta.json");
$this->deleteFile("{$dir}/view-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);
// ── 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
@ -560,6 +760,68 @@ class NasSyncService
$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 ────────────────────────────────────────────────────────
public function mkdirp(string $path): void
@ -633,6 +895,21 @@ class NasSyncService
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
{
$cfg = $this->cfg();
@ -641,6 +918,175 @@ class NasSyncService
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 ───────────────────────────────────────────────────────────────
private function cfg(): array

19
composer.lock generated
View File

@ -2808,18 +2808,21 @@
"source": {
"type": "git",
"url": "https://github.com/itsp7h/File-Structure-package.git",
"reference": "9018271e2b73099730328191c8a4a3f2606ddc47"
"reference": "44462665a31200d2ff791557bafc970fdf07a6c2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/itsp7h/File-Structure-package/zipball/9018271e2b73099730328191c8a4a3f2606ddc47",
"reference": "9018271e2b73099730328191c8a4a3f2606ddc47",
"url": "https://api.github.com/repos/itsp7h/File-Structure-package/zipball/44462665a31200d2ff791557bafc970fdf07a6c2",
"reference": "44462665a31200d2ff791557bafc970fdf07a6c2",
"shasum": ""
},
"require": {
"illuminate/support": "^10.0|^11.0|^12.0",
"php": "^8.1"
},
"require-dev": {
"composer/composer": "^2.0"
},
"default-branch": true,
"type": "library",
"extra": {
@ -2834,6 +2837,14 @@
"P7H\\NasFileManager\\": "src/"
}
},
"scripts": {
"post-install-cmd": [
"P7H\\NasFileManager\\Installer::install"
],
"post-update-cmd": [
"P7H\\NasFileManager\\Installer::install"
]
},
"license": [
"MIT"
],
@ -2842,7 +2853,7 @@
"source": "https://github.com/itsp7h/File-Structure-package/tree/main",
"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",

View File

@ -49,9 +49,32 @@ return [
| <x-nas-file-manager::file-manager :nodes="$myNodes" />
*/
'schema' => [
// Example — uncomment and adapt:
// ['depth' => 0, 'label' => 'Media', 'path' => 'Media', 'parent_path' => null, 'is_template' => false, 'can_edit' => false],
// ['depth' => 1, 'label' => 'Outlets', 'path' => 'Media/Outlets', 'parent_path' => 'Media', 'is_template' => false, 'can_edit' => true],
// ── users ─────────────────────────────────────────────────────────────
['depth' => 0, 'label' => 'users', 'path' => 'users', 'parent_path' => null, 'is_template' => false, 'can_edit' => false],
['depth' => 1, 'label' => '{username}', 'path' => 'users/{username}', 'parent_path' => 'users', 'is_template' => true, 'can_edit' => false],
// ── profile ───────────────────────────────────────────────────────────
['depth' => 2, 'label' => 'profile', 'path' => 'u/profile', 'parent_path' => 'users/{username}', 'is_template' => false, 'can_edit' => false],
['depth' => 3, 'label' => 'avatar.webp', 'path' => 'profile/avatar.webp', 'parent_path' => 'u/profile', 'is_template' => false, 'can_edit' => false],
['depth' => 3, 'label' => 'cover.webp', 'path' => 'profile/cover.webp', 'parent_path' => 'u/profile', 'is_template' => false, 'can_edit' => false],
// ── videos ────────────────────────────────────────────────────────────
['depth' => 2, 'label' => 'videos', 'path' => 'u/videos', 'parent_path' => 'users/{username}', 'is_template' => false, 'can_edit' => false],
['depth' => 3, 'label' => '{video-slug}', 'path' => 'videos/{video-slug}', 'parent_path' => 'u/videos', 'is_template' => true, 'can_edit' => false],
['depth' => 4, 'label' => '{video-slug}.{ext}', 'path' => 'vid/file', 'parent_path' => 'videos/{video-slug}', 'is_template' => true, 'can_edit' => false],
['depth' => 4, 'label' => 'thumb.webp', 'path' => 'vid/thumb.webp', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
['depth' => 4, 'label' => 'meta.json', 'path' => 'vid/meta.json', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
['depth' => 4, 'label' => 'view-log.json', 'path' => 'vid/view-log.json', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
['depth' => 4, 'label' => 'edit-log.json', 'path' => 'vid/edit-log.json', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
['depth' => 4, 'label' => 'slides', 'path' => 'vid/slides', 'parent_path' => 'videos/{video-slug}', 'is_template' => false, 'can_edit' => false],
['depth' => 5, 'label' => '{position}.{ext}','path' => 'slide/file', 'parent_path' => 'vid/slides', 'is_template' => true, 'can_edit' => false],
// ── posts ─────────────────────────────────────────────────────────────
['depth' => 2, 'label' => 'posts', 'path' => 'u/posts', 'parent_path' => 'users/{username}', 'is_template' => false, 'can_edit' => false],
['depth' => 3, 'label' => '{post_id}', 'path' => 'posts/{post_id}', 'parent_path' => 'u/posts', 'is_template' => true, 'can_edit' => false],
['depth' => 4, 'label' => '{n}.{ext}', 'path' => 'post/image', 'parent_path' => 'posts/{post_id}', 'is_template' => true, 'can_edit' => false],
],
];

View File

@ -366,7 +366,7 @@
<td style="color:var(--text-secondary);font-weight:700;">{{ $i + 1 }}</td>
<td>
@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
<div class="top-thumb-placeholder"><i class="bi bi-play-fill"></i></div>
@endif
@ -559,7 +559,7 @@
<tr class="clickable-row" onclick="window.open('{{ route('videos.show', $video) }}','_blank')">
<td>
@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
<div class="top-thumb-placeholder"><i class="bi bi-play-fill"></i></div>
@endif
@ -619,6 +619,12 @@
<i class="bi bi-trash3-fill me-2"></i> Clean Orphaned Files
</button>
<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 class="col-lg-6">
@ -1046,5 +1052,32 @@ document.getElementById('cleanupBtn')?.addEventListener('click', async function(
this.disabled = false;
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>
@endsection

View File

@ -3,6 +3,11 @@
@section('title', 'Edit Video')
@section('page_title', 'Edit Video')
@php
$adminNeedsOtp = auth()->user()->two_factor_enabled &&
(! session('admin_2fa_verified_at') || now()->timestamp - session('admin_2fa_verified_at') >= 1800);
@endphp
@section('extra_styles')
<style>
.ef-grid {
@ -77,6 +82,9 @@
background: rgba(248,113,113,.05);
}
.ef-danger-title { font-size: 12px; font-weight: 700; color: #f87171; margin-bottom: 10px; text-transform: uppercase; letter-spacing: .05em; }
.adm-btn-warning { color: #fb923c; border-color: rgba(251,146,60,.3); }
.adm-btn-warning:hover { background: rgba(251,146,60,.1); border-color: rgba(251,146,60,.5); color: #fdba74; }
.adm-btn-warning:disabled { opacity:.5; cursor:not-allowed; pointer-events:none; }
</style>
@endsection
@ -203,7 +211,7 @@
<div class="adm-card">
<div class="adm-card-body">
@if($video->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}"
<img src="{{ route('media.thumbnail', $video->thumbnail) }}"
alt="{{ $video->title }}" class="ef-thumb">
@else
<div class="ef-thumb-placeholder"><i class="bi bi-play-circle"></i></div>
@ -259,6 +267,41 @@
</div>
</div>
{{-- Replace File --}}
<div class="ef-danger-zone" style="margin-bottom:12px;">
<div class="ef-danger-title" style="color:#fb923c;border-color:rgba(251,146,60,.2);">
<i class="bi bi-arrow-repeat me-1"></i> Replace Media File
</div>
<p style="font-size:12px;color:var(--text-2);margin:0 0 12px;line-height:1.5;">
Fix a corrupted or missing file. All stats are preserved.
</p>
<div id="adm-rfl-dropzone" onclick="document.getElementById('adm-rfl-input').click()"
style="border:2px dashed var(--border);border-radius:8px;padding:14px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;margin-bottom:8px;"
onmouseenter="this.style.borderColor='#ef4444';this.style.background='rgba(239,68,68,.05)'"
onmouseleave="this.style.borderColor='var(--border)';this.style.background='transparent'">
<i class="bi bi-cloud-upload" style="font-size:22px;color:var(--text-2);display:block;margin-bottom:4px;"></i>
<span id="adm-rfl-label" style="font-size:12px;color:var(--text-2);">Click to choose replacement file</span>
<input type="file" id="adm-rfl-input" accept="video/*,audio/*,.mp4,.webm,.ogg,.mov,.avi,.wmv,.flv,.mkv,.mp3,.m4a,.aac,.wav,.flac,.opus" style="display:none;" onchange="admRflSelected(this)">
</div>
<div id="adm-rfl-info" style="display:none;background:var(--bg-2);border:1px solid var(--border);border-radius:6px;padding:8px 12px;align-items:center;gap:8px;margin-bottom:8px;">
<i class="bi bi-file-earmark-play" style="font-size:18px;color:#ef4444;flex-shrink:0;"></i>
<div style="flex:1;min-width:0;">
<div id="adm-rfl-name" style="font-size:12px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></div>
<div id="adm-rfl-size" style="font-size:11px;color:var(--text-2);margin-top:1px;"></div>
</div>
<button type="button" onclick="admRflClear()" style="background:none;border:none;color:var(--text-2);cursor:pointer;font-size:14px;flex-shrink:0;padding:0;"><i class="bi bi-x-lg"></i></button>
</div>
<div id="adm-rfl-status" style="display:none;margin-bottom:8px;font-size:12px;padding:7px 10px;border-radius:6px;"></div>
<button type="button" id="adm-rfl-btn" onclick="admRflSubmit()" disabled
class="adm-btn adm-btn-warning" style="width:100%;">
<i class="bi bi-arrow-repeat"></i> Replace File
</button>
</div>
{{-- Danger zone --}}
<div class="ef-danger-zone">
<div class="ef-danger-title"><i class="bi bi-exclamation-triangle-fill me-1"></i> Danger Zone</div>
@ -289,15 +332,25 @@
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
The video file, thumbnail, and all associated data will be removed. This cannot be undone.
</div>
@if($adminNeedsOtp)
<div style="margin-top:14px;">
<label style="font-size:13px;color:var(--text-2);display:flex;align-items:center;gap:6px;margin-bottom:6px;">
<i class="bi bi-shield-lock-fill" style="color:#a78bfa;"></i>
Enter your <strong style="color:var(--text);">2FA code</strong> to confirm
</label>
<input type="text" id="delDialogOtp" inputmode="numeric" pattern="[0-9]*"
maxlength="6" autocomplete="one-time-code"
placeholder="000000"
style="width:100%;background:var(--bg-card2);border:1px solid var(--border-light);border-radius:8px;padding:10px 14px;color:#fff;font-size:22px;letter-spacing:0.3em;text-align:center;">
</div>
@endif
<div id="delDialogError" style="display:none;margin-top:10px;padding:8px 12px;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.3);border-radius:6px;color:#f87171;font-size:13px;"></div>
</div>
<div class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDelDialog()">Cancel</button>
<form method="POST" action="{{ route('admin.videos.delete', $video) }}">
@csrf @method('DELETE')
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
<i class="bi bi-trash3-fill"></i> Delete Permanently
</button>
</form>
<button type="button" class="adm-btn adm-btn-danger" id="delDialogConfirmBtn" onclick="confirmDelVideo()">
<i class="bi bi-trash3-fill"></i> Delete Permanently
</button>
</div>
</div>
</div>
@ -306,9 +359,130 @@
@section('scripts')
<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 confirmDelVideo() {
const btn = document.getElementById('delDialogConfirmBtn');
const errEl = document.getElementById('delDialogError');
const otpCode = _adminNeedsOtp ? document.getElementById('delDialogOtp').value.replace(/\s/g,'') : '';
if (_adminNeedsOtp && otpCode.length !== 6) {
errEl.textContent = 'Please enter your 6-digit 2FA code.';
errEl.style.display = 'block';
document.getElementById('delDialogOtp').focus();
return;
}
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Deleting…';
errEl.style.display = 'none';
const body = new URLSearchParams({ '_token': '{{ csrf_token() }}', '_method': 'DELETE' });
if (_adminNeedsOtp) body.append('otp_code', otpCode);
fetch('/admin/videos/' + _editVideoId, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
})
.then(r => r.json())
.then(data => {
if (data.success) {
window.location.href = '/admin/videos';
} else {
errEl.textContent = data.message || 'Delete failed.';
errEl.style.display = 'block';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash3-fill"></i> Delete Permanently';
if (_adminNeedsOtp) { document.getElementById('delDialogOtp').value = ''; document.getElementById('delDialogOtp').focus(); }
}
})
.catch(() => {
errEl.textContent = 'An error occurred. Please try again.';
errEl.style.display = 'block';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash3-fill"></i> Delete Permanently';
});
}
document.getElementById('delDialog').addEventListener('click', e => { if (e.target === document.getElementById('delDialog')) closeDelDialog(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDelDialog(); });
// ── Admin Replace File ────────────────────────────────────────────────────
let _admRflFile = null;
const _admRflFmtSize = b => b >= 1073741824 ? (b/1073741824).toFixed(1)+' GB' : b >= 1048576 ? (b/1048576).toFixed(1)+' MB' : Math.round(b/1024)+' KB';
function admRflSelected(input) {
_admRflFile = input.files[0] || null;
const info = document.getElementById('adm-rfl-info');
if (_admRflFile) {
document.getElementById('adm-rfl-name').textContent = _admRflFile.name;
document.getElementById('adm-rfl-size').textContent = _admRflFmtSize(_admRflFile.size);
document.getElementById('adm-rfl-label').textContent = _admRflFile.name;
info.style.display = 'flex';
} else {
info.style.display = 'none';
document.getElementById('adm-rfl-label').textContent = 'Click to choose replacement file';
}
document.getElementById('adm-rfl-btn').disabled = !_admRflFile;
document.getElementById('adm-rfl-status').style.display = 'none';
}
function admRflClear() {
_admRflFile = null;
document.getElementById('adm-rfl-input').value = '';
document.getElementById('adm-rfl-info').style.display = 'none';
document.getElementById('adm-rfl-label').textContent = 'Click to choose replacement file';
document.getElementById('adm-rfl-btn').disabled = true;
document.getElementById('adm-rfl-status').style.display = 'none';
}
function admRflSubmit() {
if (!_admRflFile) return;
const btn = document.getElementById('adm-rfl-btn');
const status = document.getElementById('adm-rfl-status');
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading…';
status.style.display = 'none';
const fd = new FormData();
fd.append('_token', '{{ csrf_token() }}');
fd.append('replacement_file', _admRflFile);
fetch('/videos/' + _editVideoId + '/replace-file', {
method: 'POST',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: fd
})
.then(r => r.json())
.then(data => {
if (data.success) {
status.style.cssText = 'display:block;background:rgba(74,222,128,.08);border:1px solid rgba(74,222,128,.25);color:#4ade80;';
status.innerHTML = '<i class="bi bi-check-circle-fill"></i> ' + data.message;
admRflClear();
setTimeout(() => location.reload(), 2200);
} else {
status.style.cssText = 'display:block;background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.25);color:#f87171;';
status.textContent = data.message || 'Upload failed. Please try again.';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Replace File';
}
})
.catch(() => {
status.style.cssText = 'display:block;background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.25);color:#f87171;';
status.textContent = 'Upload failed. Please try again.';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Replace File';
});
}
</script>
@endsection

View File

@ -535,7 +535,7 @@
@auth
<button class="adm-user-btn" id="userMenuBtn" type="button">
@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
<img src="https://i.pravatar.cc/150?u={{ Auth::user()->id }}" class="adm-user-avatar" alt="">
@endif
@ -622,7 +622,7 @@
<div class="adm-sidebar-footer">
<a href="{{ route('profile') }}" class="adm-sidebar-user">
@if(Auth::user()->avatar)
<img src="{{ asset('storage/avatars/' . Auth::user()->avatar) }}" alt="">
<img src="{{ route('media.avatar', Auth::user()->avatar) }}" alt="">
@else
<img src="https://i.pravatar.cc/150?u={{ Auth::user()->id }}" alt="">
@endif

View File

@ -336,26 +336,81 @@
<small>
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.
Thumbnails and video are fetched from NAS on demand for streaming and playback.
When disabled, files are stored in local storage using the same directory schema.
Requires the NAS connection to be configured under
<a href="{{ route('admin.nas-storage') }}" style="color:var(--brand)">NAS Storage</a>.
When disabled, all files are served from local disk using the same directory schema.
<strong>Disabling NAS will prompt you to migrate files or start fresh.</strong>
</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<div class="toggle-switch">
<input type="checkbox" id="nasSyncInput" name="nas_sync_enabled_check"
{{ $settings['nas_sync_enabled'] === 'true' ? 'checked' : '' }}>
<div class="toggle-track"></div>
<div class="toggle-thumb"></div>
</div>
<span class="toggle-label" id="nasSyncLabel">
{{ $settings['nas_sync_enabled'] === 'true' ? 'Enabled' : 'Disabled' }}
</span>
</label>
<input type="hidden" name="nas_sync_enabled" id="nasSyncHidden"
value="{{ $settings['nas_sync_enabled'] }}">
@if($settings['nas_sync_enabled'] === 'true')
<button type="button" class="adm-btn adm-btn-danger" onclick="openNasDisableModal()">
<i class="bi bi-hdd-network"></i> Disable NAS
</button>
@else
<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>
@if($settings['nas_sync_enabled'] === 'true')
<div class="setting-row" style="border-top:1px solid var(--border-color);padding-top:18px;">
<div class="setting-label">
<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 &amp; 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 &amp; 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 &amp; Restore
</button>
</form>
</div>
</div>
@ -392,15 +447,6 @@ function selectEncoder(el) {
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 ────────────────────────────────────────────────
const gpuToggle = document.getElementById('gpuEnabledInput');
const gpuHidden = document.getElementById('gpuEnabledHidden');
@ -476,9 +522,271 @@ function buildGpuCards(gpus, selectedDevice) {
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── 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>
<style>
@keyframes spin { to { transform: rotate(360deg); } }
.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>
{{-- ── 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 &rarr;</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&hellip;</h3>
<p id="nasDisablePhase" style="margin:0 0 16px;font-size:13px;color:var(--text-2);">Starting&hellip;</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 &amp; Disable NAS</button>
</div>
</div>
</div>
</div>
@endsection

View File

@ -2,14 +2,229 @@
@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')
{{-- ── Page header ──────────────────────────────────────────────────── --}}
<div class="adm-page-header">
<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>
{{-- ── Alerts ───────────────────────────────────────────────────────── --}}
{{-- Alerts --}}
@if(session('success'))
<div class="adm-alert adm-alert-success">
<i class="bi bi-check-circle-fill"></i>
@ -25,181 +240,220 @@
</div>
@endif
{{-- ── Filter card ──────────────────────────────────────────────────── --}}
<div class="adm-card">
<div class="adm-card-body" style="padding:16px 20px;">
<form method="GET" action="{{ route('admin.users') }}" class="adm-filter-form">
<div class="adm-filter-search">
<i class="bi bi-search"></i>
<input type="text" name="search" class="adm-input"
placeholder="Search name or email…"
value="{{ request('search') }}" autocomplete="off">
{{-- Stats --}}
<div class="u-stats">
<div class="u-stat">
<div class="u-stat-accent" style="background: linear-gradient(90deg,#60a5fa,#3b82f6);"></div>
<div class="u-stat-top">
<div>
<div class="u-stat-val">{{ number_format($totalUsers) }}</div>
<div class="u-stat-lbl">Total users</div>
</div>
<select name="role" class="adm-select">
<option value="">All Roles</option>
<option value="user" {{ request('role') === 'user' ? 'selected' : '' }}>User</option>
<option value="admin" {{ request('role') === 'admin' ? 'selected' : '' }}>Admin</option>
<option value="super_admin" {{ request('role') === 'super_admin' ? 'selected' : '' }}>Super Admin</option>
</select>
<select name="sort" class="adm-select">
<option value="latest" {{ request('sort', 'latest') === 'latest' ? 'selected' : '' }}>Newest first</option>
<option value="oldest" {{ request('sort') === 'oldest' ? 'selected' : '' }}>Oldest first</option>
<option value="name_asc" {{ request('sort') === 'name_asc' ? 'selected' : '' }}>Name AZ</option>
<option value="name_desc" {{ request('sort') === 'name_desc' ? 'selected' : '' }}>Name ZA</option>
</select>
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-funnel"></i> Filter
</button>
@if(request()->hasAny(['search','role','sort']))
<a href="{{ route('admin.users') }}" class="adm-btn">
<i class="bi bi-x-lg"></i> Clear
</a>
@endif
</form>
<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>
{{-- ── Users table ──────────────────────────────────────────────────── --}}
{{-- 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>
<input type="text" name="search" placeholder="Search by name or email…"
value="{{ request('search') }}" autocomplete="off">
</div>
<select name="role" class="adm-select">
<option value="">All Roles</option>
<option value="user" {{ request('role') === 'user' ? 'selected' : '' }}>Users</option>
<option value="admin" {{ request('role') === 'admin' ? 'selected' : '' }}>Admins</option>
<option value="super_admin" {{ request('role') === 'super_admin' ? 'selected' : '' }}>Super Admins</option>
</select>
<select name="sort" class="adm-select">
<option value="latest" {{ request('sort','latest') === 'latest' ? 'selected' : '' }}>Newest first</option>
<option value="oldest" {{ request('sort') === 'oldest' ? 'selected' : '' }}>Oldest first</option>
<option value="name_asc" {{ request('sort') === 'name_asc' ? 'selected' : '' }}>Name AZ</option>
<option value="name_desc" {{ request('sort') === 'name_desc' ? 'selected' : '' }}>Name ZA</option>
</select>
<button type="submit" class="adm-btn adm-btn-primary"><i class="bi bi-funnel-fill"></i> Filter</button>
@if(request()->hasAny(['search','role','sort']))
<a href="{{ route('admin.users') }}" class="adm-btn"><i class="bi bi-x-lg"></i> Clear</a>
@endif
</form>
</div>
{{-- Table --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title">
<i class="bi bi-people"></i>
All Users
<i class="bi bi-people"></i> Members
<span class="adm-badge adm-badge-user">{{ $users->total() ?? $users->count() }}</span>
</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 class="adm-table-wrap">
<table class="adm-table">
<div class="u-table-wrap">
<table class="u-table">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Verified</th>
<th>Videos</th>
<th>Joined</th>
<th style="width:80px; text-align:right;">Actions</th>
<th class="u-hide-sm">Role</th>
<th class="u-hide-sm">Status</th>
<th class="u-hide-md">Videos</th>
<th class="u-hide-md">Likes</th>
<th class="u-hide-md">Shares</th>
<th class="u-hide-md">Joined</th>
</tr>
</thead>
<tbody>
@forelse($users as $user)
<tr>
{{-- User cell --}}
@forelse($users as $user)
<tr class="u-clickable-row"
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>
<div class="adm-user-cell">
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}">
<div class="u-identity">
<div class="u-avatar-wrap">
<img class="u-avatar" src="{{ $user->avatar_url }}" alt="{{ $user->name }}">
</div>
<div>
<div style="display:flex;align-items:center;gap:4px;">
<span class="adm-user-cell-name">{{ $user->name }}</span>
<div class="u-name">
{{ $user->name }}
@if($user->id === auth()->id())
<span class="adm-user-cell-you">you</span>
<span class="u-you">you</span>
@endif
</div>
<div class="adm-user-cell-email">{{ $user->email }}</div>
<div class="u-email">{{ $user->email }}</div>
</div>
</div>
</td>
{{-- Role --}}
<td>
<td class="u-hide-sm">
@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')
<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
<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
</td>
{{-- Verified --}}
<td>
{{-- Status --}}
<td class="u-hide-sm">
@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
<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
</td>
{{-- Videos --}}
<td>
<a href="{{ route('channel', $user->channel) }}" target="_blank"
class="text-dim" style="text-decoration:none; font-size:13px;">
{{ $user->videos->count() }}
<i class="bi bi-box-arrow-up-right" style="font-size:10px; opacity:.5; margin-left:2px;"></i>
</a>
<td class="u-hide-md">
<div class="u-vcount">{{ $user->videos->count() }}</div>
<div class="u-vcount-lbl">videos</div>
</td>
{{-- 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>
{{-- 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>
<div class="u-joined-ago">{{ $user->created_at->diffForHumans() }}</div>
</td>
{{-- Actions --}}
<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>
</tr>
@empty
@empty
<tr>
<td colspan="6">
<div class="empty-state">
<i class="bi bi-people"></i>
<p>No users found</p>
<td colspan="7" style="border:none;">
<div class="u-empty">
<div class="u-empty-circle"><i class="bi bi-people"></i></div>
<h3>No users found</h3>
<p>Try adjusting your search or filter criteria</p>
</div>
</td>
</tr>
@endforelse
@endforelse
</tbody>
</table>
</div>
{{-- Pagination --}}
@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() }}
</div>
@endif
</div>
{{-- ── Delete confirmation dialog ───────────────────────────────────── --}}
{{-- Row action dropdown --}}
<div id="uRowMenu"></div>
{{-- Delete dialog --}}
<div class="adm-dialog-overlay" id="deleteDialog">
<div class="adm-dialog">
<div class="adm-dialog-header">
<div class="adm-dialog-title">
<i class="bi bi-exclamation-triangle-fill"></i>
Delete User
<i class="bi bi-exclamation-triangle-fill"></i> Delete User
</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>
</button>
</div>
@ -209,15 +463,26 @@
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
All videos uploaded by this user will also be deleted. This cannot be undone.
</div>
@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 class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDeleteDialog()">Cancel</button>
<form id="deleteForm" method="POST">
@csrf @method('DELETE')
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
<i class="bi bi-trash"></i> Delete User
</button>
</form>
<button type="button" class="adm-btn adm-btn-danger" id="dlgUserConfirmBtn" onclick="confirmDeleteUser()"
style="height:36px;">
<i class="bi bi-trash"></i> Delete permanently
</button>
</div>
</div>
</div>
@ -226,19 +491,132 @@
@section('scripts')
<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) {
_deleteUserId = userId;
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');
if (_adminNeedsOtp) setTimeout(() => document.getElementById('dlgUserOtp').focus(), 120);
}
function closeDeleteDialog() {
document.getElementById('deleteDialog').classList.remove('open');
_deleteUserId = null;
}
document.getElementById('deleteDialog').addEventListener('click', function(e) {
if (e.target === this) closeDeleteDialog();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeDeleteDialog();
function confirmDeleteUser() {
if (!_deleteUserId) return;
const btn = document.getElementById('dlgUserConfirmBtn');
const errEl = document.getElementById('dlgUserError');
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>
@endsection

View File

@ -195,7 +195,7 @@ function flagEmoji(string $code): string {
<div class="video-info-card">
<div class="video-info-thumb">
@if($video->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}">
<img src="{{ route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}">
@else
<i class="bi bi-play-circle"></i>
@endif
@ -414,7 +414,7 @@ function flagEmoji(string $code): string {
<div style="display:flex; align-items:center; gap:10px;">
<div class="viewer-avatar">
@if($view->viewer_avatar)
<img src="{{ asset('storage/avatars/' . $view->viewer_avatar) }}" alt="">
<img src="{{ route('media.avatar', $view->viewer_avatar) }}" alt="">
@else
<i class="bi bi-person"></i>
@endif

View File

@ -2,6 +2,11 @@
@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')
{{-- ── Page header ──────────────────────────────────────────────────── --}}
@ -100,18 +105,19 @@
<th>Type</th>
<th>Views</th>
<th>Likes</th>
<th>Shares</th>
<th>Uploaded</th>
<th style="width:110px; text-align:right;">Actions</th>
</tr>
</thead>
<tbody>
@forelse($videos as $video)
<tr>
<tr style="cursor:pointer;" onclick="window.open('{{ route('videos.show', $video) }}', '_blank')">
{{-- Thumbnail + title --}}
<td>
<div style="display:flex; align-items:center; gap:12px;">
@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);">
@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;">
@ -132,7 +138,7 @@
</td>
{{-- Owner --}}
<td>
<td onclick="event.stopPropagation()">
<a href="{{ route('channel', $video->user->channel) }}" target="_blank"
style="font-size:13px;color:var(--text);text-decoration:none;display:flex;align-items:center;gap:4px;">
{{ $video->user->name }}
@ -193,11 +199,16 @@
{{ number_format(\DB::table('video_likes')->where('video_id',$video->id)->count()) }}
</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 --}}
<td class="text-muted-sm">{{ $video->created_at->format('M d, Y') }}</td>
{{-- Actions --}}
<td>
<td onclick="event.stopPropagation()">
<div class="adm-row-actions" style="justify-content:flex-end;">
<a href="{{ route('videos.show', $video) }}" target="_blank"
class="adm-btn adm-btn-sm" title="Watch">
@ -218,7 +229,7 @@
</tr>
@empty
<tr>
<td colspan="9">
<td colspan="10">
<div class="empty-state">
<i class="bi bi-play-circle"></i>
<p>No videos found</p>
@ -256,15 +267,25 @@
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
All views, likes, comments, and HLS files for this video will also be deleted.
</div>
@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 class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDeleteDialog()">Cancel</button>
<form id="deleteForm" method="POST">
@csrf @method('DELETE')
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
<i class="bi bi-trash"></i> Delete Video
</button>
</form>
<button type="button" class="adm-btn adm-btn-danger" id="dlgVideoConfirmBtn" onclick="confirmDeleteVideo()">
<i class="bi bi-trash"></i> Delete Video
</button>
</div>
</div>
</div>
@ -273,13 +294,65 @@
@section('scripts')
<script>
const _adminNeedsOtp = {{ $adminNeedsOtp ? 'true' : 'false' }};
let _deleteVideoId = null;
function openDeleteDialog(videoId, videoTitle) {
_deleteVideoId = videoId;
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');
if (_adminNeedsOtp) setTimeout(() => document.getElementById('dlgVideoOtp').focus(), 100);
}
function closeDeleteDialog() {
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) {
if (e.target === this) closeDeleteDialog();

View File

@ -1,7 +1,24 @@
@props(['playlist', 'showTypeBadge' => false])
@props(['playlist'])
@once
<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 {
position: absolute;
bottom: 8px;
@ -18,6 +35,26 @@
text-transform: uppercase;
letter-spacing: .4px;
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>
@endonce
@ -27,16 +64,14 @@
<div class="yt-video-card">
<a href="{{ route('playlists.show', $pl->id) }}">
<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">
<i class="bi bi-collection-play-fill"></i>
{{ $pl->videos_count }}
</div>
@if($showTypeBadge)
<span class="feed-pl-type-badge">
<i class="bi bi-collection-play-fill"></i> PLAYLIST
</span>
@endif
<span class="pl-type-badge">
<i class="bi bi-collection-play-fill"></i> Playlist
</span>
@if($pl->visibility === 'private')
<span class="pl-visibility-badge"><i class="bi bi-lock-fill"></i> Private</span>
@elseif($pl->visibility === 'unlisted')

View File

@ -4,18 +4,33 @@
.action-btn {
border: none;
border-radius: 8px;
padding: 8px 14px;
padding: 0 14px;
height: 36px;
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s ease;
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 {
@ -113,6 +128,11 @@
<i class="bi bi-pencil"></i>
<span>Edit</span>
</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)
@php $isSubscribed = Auth::user()->isSubscribedTo($video->user); @endphp
<button type="button"
@ -149,16 +169,13 @@
@if ($video->isShareable())
<button class="action-btn desktop-action"
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>
@endif
<!-- Save to Playlist Button -->
<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"
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>
<i class="bi bi-bookmark"></i>
<span>Save</span>
</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>

View File

@ -3,7 +3,7 @@
@php
$videoUrl = $video ? asset('storage/videos/' . $video->filename) : null;
$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');
$typeIcon = $video ? match($video->type) {
@ -29,7 +29,7 @@ $sizeClasses = match($size) {
<a href="{{ $video ? route('videos.show', $video) : '#' }}">
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
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)
<video preload="none">
<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) : '#' }}"
class="yt-channel-icon" onclick="event.stopPropagation();">
@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
</a>
<div class="yt-video-details">
@ -275,6 +275,28 @@ $sizeClasses = match($size) {
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 {
width: 100%;
height: 100%;
@ -282,9 +304,16 @@ $sizeClasses = match($size) {
position: absolute;
top: 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);
}
@ -298,6 +327,7 @@ $sizeClasses = match($size) {
opacity: 0;
transition: opacity 0.3s ease;
background: #000;
z-index: 2;
}
.yt-video-card .yt-video-thumb video.active {
@ -315,6 +345,7 @@ $sizeClasses = match($size) {
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
z-index: 3;
}
.yt-video-thumb.audio-playing .audio-preview-overlay {
@ -360,6 +391,7 @@ $sizeClasses = match($size) {
border-radius: 4px;
font-size: 12px;
font-weight: 500;
z-index: 3;
}
.yt-video-card .yt-shorts-badge {
@ -377,6 +409,7 @@ $sizeClasses = match($size) {
gap: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
z-index: 3;
}
.yt-video-card .yt-shorts-badge i {
@ -395,6 +428,7 @@ $sizeClasses = match($size) {
align-items: center;
gap: 4px;
pointer-events: none;
z-index: 3;
}
.yt-video-card .yt-visibility-private {
@ -423,6 +457,10 @@ $sizeClasses = match($size) {
overflow: hidden;
}
.yt-video-card .yt-channel-icon img.loaded {
opacity: 1 !important;
}
.yt-video-card .yt-video-details {
flex: 1;
min-width: 0;

View File

@ -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-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 ─────────────────────────────────────────── */
.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; }
@ -367,7 +379,7 @@ function renderInsights(d) {
<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>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div class="ins-two-col">
<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>'}
@ -379,6 +391,30 @@ function renderInsights(d) {
</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 ───────────────────────────────
let dlHtml = '';
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-dl-type-pills">${pills}</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div class="ins-two-col">
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">
Top Downloaders <span style="font-weight:400;opacity:.5;">(tap to see history)</span>
@ -523,7 +559,7 @@ function renderInsights(d) {
<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}
</div></div>` + viewersHtml + dlHtml + shareHtml + demographicsHtml;
</div></div>` + viewersHtml + likersHtml + dlHtml + shareHtml + demographicsHtml;
}
// ══════════════════════════════════════════════════════

View File

@ -10,7 +10,7 @@
<div class="email-thumb-wrap">
@if($video->thumbnail)
<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>
@else
<div class="email-thumb-placeholder">

View File

@ -16,7 +16,7 @@
<div class="email-thumb-wrap">
@if($video->thumbnail)
<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>
@else
<div class="email-thumb-placeholder">

View File

@ -277,6 +277,42 @@
</div>
</div>
</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>
@ -1403,6 +1439,75 @@
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>
<x-image-cropper

View File

@ -318,19 +318,29 @@ const thumbnailDefault = document.getElementById('playlist-thumbnail-default');
const thumbnailPreview = document.getElementById('playlist-thumbnail-preview');
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) {
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() {
handleThumbnailSelect(this);
if (!this.files || !this.files[0]) return;
const cropperOpen = document.getElementById('tcOverlay_thumb_pl_create')?.classList.contains('open');
if (cropperOpen) {
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
@ -353,7 +363,8 @@ thumbnailDropzone.addEventListener('drop', function(e) {
if (e.dataTransfer.files.length) {
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.openCropperModal_thumb_pl_create();
} else {

View File

@ -1590,7 +1590,7 @@ $headerSocialMap = [
@foreach($allVids as $v)
<a href="{{ route('videos.show', $v) }}" class="ch-post-video-card" style="{{ !$loop->first ? 'margin-top:8px;' : '' }}">
<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'">
<div class="ch-post-video-play"><i class="bi bi-play-circle-fill"></i></div>
</div>
@ -1774,7 +1774,7 @@ $headerSocialMap = [
@foreach($shorts as $short)
<a href="{{ route('videos.show', $short) }}" class="ch-short-card">
<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">
@if($short->duration)
<span class="ch-short-duration">{{ gmdate('i:s', $short->duration) }}</span>
@ -2454,11 +2454,11 @@ if ($oldLinks) {
<div class="ch-vp-card"
data-id="{{ $v->id }}"
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)">
<div class="ch-vp-card-thumb">
@if($v->thumbnail)
<img src="{{ asset('storage/thumbnails/'.$v->thumbnail) }}" alt="" loading="lazy">
<img src="{{ route('media.thumbnail', $v->thumbnail) }}" alt="" loading="lazy">
@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>
@endif

View File

@ -328,7 +328,7 @@ if ($oldLinks) {
<div class="profile-content">
<div class="profile-avatar-wrap">
@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
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" alt="{{ $user->name }}" class="profile-avatar" id="pageAvatar">
@endif
@ -416,7 +416,7 @@ if ($oldLinks) {
<div class="video-card">
<a href="{{ route('videos.show', $video) }}">
<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>
</div>
</a>
@ -525,7 +525,7 @@ if ($oldLinks) {
<div class="avatar-upload-area">
<div class="avatar-preview-wrap">
@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
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" class="avatar-preview" id="avatarPreview" alt="Avatar">
@endif

File diff suppressed because it is too large Load Diff

View File

@ -179,7 +179,7 @@
<div class="form-group">
<label class="form-label"><i class="bi bi-image"></i> Current Thumbnail</label>
<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">
<i class="bi bi-card-image"></i>
<span>No thumbnail set</span>
@ -305,8 +305,41 @@
</button>
</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 --}}
<div class="danger-zone">
<div class="danger-zone" style="margin-top:12px;">
<p>Danger Zone</p>
<form action="{{ route('videos.destroy', $video) }}" method="POST" id="delete-edit-form">
@csrf
@ -380,7 +413,7 @@
}
@else
// ── 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;
function _epSyncOrder() {
@ -536,6 +569,75 @@
btn.disabled = false;
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>
<x-image-cropper
id="thumb_edit_mobile"

View File

@ -14,28 +14,8 @@
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: 1fr; gap: 14px; } }
/* ── Playlist count badge (right strip on thumbnail) ── */
.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; }
/* ── Thumbnail orientation fix ── */
/* ── 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 ── */
.yt-video-card .yt-video-thumb img { object-fit: cover; }
@ -167,7 +147,7 @@
@include('components.video-card', ['video' => $entry['item']])
@else
@php $pl = $entry['item']; @endphp
@include('components.playlist-card', ['playlist' => $pl, 'showTypeBadge' => true])
@include('components.playlist-card', ['playlist' => $pl])
@endif
@endforeach
</div>

View File

@ -1,10 +1,10 @@
@php
$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;
$prevUrl = isset($previousVideo, $playlist) ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null;
$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

View File

@ -478,11 +478,15 @@
</button>
</form>
<!-- Edit Button - Only for video owner -->
<!-- Edit / Delete Buttons - Only for video owner -->
@if (Auth::id() === $video->user_id)
<button class="yt-action-btn" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
<i class="bi bi-pencil"></i> Edit
</button>
<button class="yt-action-btn" onclick="openVideoDeleteDialog()"
style="color:#ef4444;">
<i class="bi bi-trash"></i> Delete
</button>
@endif
@else
<a href="{{ route('login') }}" class="yt-action-btn">
@ -696,7 +700,7 @@
style="{{ $isCurrent ? 'cursor:default;' : 'cursor:pointer;' }}">
<div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
<img src="{{ route('media.thumbnail', $playlistVideo->thumbnail) }}"
alt="{{ $playlistVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
@ -751,7 +755,7 @@
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
@ -822,7 +826,12 @@
</button>
@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;">
<i class="bi bi-bell"></i><span>Subscribe</span>
</button>
@ -886,6 +895,113 @@
}
});
</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

View File

@ -455,7 +455,7 @@
onclick="window.location.href='{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
<img src="{{ route('media.thumbnail', $playlistVideo->thumbnail) }}"
alt="{{ $playlistVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
@ -507,7 +507,7 @@
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"

View File

@ -2543,7 +2543,7 @@
onclick="window.location.href='{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
<img src="{{ route('media.thumbnail', $playlistVideo->thumbnail) }}"
alt="{{ $playlistVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
@ -2595,7 +2595,7 @@
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"

View File

@ -483,7 +483,7 @@
onclick="window.location.href='{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
<img src="{{ route('media.thumbnail', $playlistVideo->thumbnail) }}"
alt="{{ $playlistVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
@ -535,7 +535,7 @@
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"

View File

@ -3,6 +3,7 @@
use App\Http\Controllers\CommentController;
use App\Http\Controllers\ImageUploadController;
use App\Http\Controllers\MatchEventController;
use App\Http\Controllers\MediaController;
use App\Http\Controllers\PostController;
use App\Http\Controllers\SuperAdminController;
use App\Http\Controllers\UserController;
@ -10,6 +11,12 @@ use App\Http\Controllers\VideoController;
use Illuminate\Support\Facades\Auth;
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
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::get('/videos/{video}/edit', [VideoController::class, 'edit'])->name('videos.edit');
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');
// Like/unlike routes
@ -197,6 +205,16 @@ Route::middleware(['auth', 'super_admin'])->prefix('admin')->name('admin.')->gro
// 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)