Compare commits

..
3 Commits
Author SHA1 Message Date
ghassanandClaude Opus 4.7 5aef41201a test(admin): pagination controls, HTML strip, Ready label
Four tests covering the admin polish follow-ups to Phase 7:
- Pagination partial renders both First and Last anchors with the
  correct aria-labels and page URLs on a middle-page view.
- Pagination partial degrades to a disabled First span on page 1 so
  the admin can't click into an invalid negative page.
- Admin video list strips HTML tags from the description preview
  (no "<p>Hello" leaking into the row) and carries the full
  title in a title="" tooltip on the ellipsized columns.
- Dashboard stat card now shows "Ready" instead of the misleading
  "Live Videos" label the Phase 7 commit renamed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:03:22 +03:00
ghassanandClaude Opus 4.7 5adafc7616 feat(admin): DB-driven storage sum + grouped error log viewer
Storage:
- The dashboard storage card used to walk Storage::disk('public')'s
  videos / thumbnails / avatars / images directories. NAS is primary
  storage, so for every file that's been pushed-and-purged (most of
  them, per CLAUDE.md), the disk walk reports 0. Replace with a
  DB-driven estimate: SUM(videos.size) for video bytes plus a 120 KB
  conservative per-file estimate × (thumbnails + avatars + banners +
  post images). Fast (3 count queries + one SUM), works regardless
  of local-cache state, and now reflects content that lives on NAS.

Error logs:
- Flat view used to render every line as its own equal-weight <div>,
  so a stack trace of 70 frames buried the next real event. Group
  lines by header: a line that matches .(ERROR|WARNING|INFO|DEBUG|
  CRITICAL|…): starts a new group; subsequent lines belong to its
  stack trace. Each group collapses by default behind a caret the
  admin can expand. Stack-trace lines preserve their natural #0 → #N
  order inside the group even when the file is reversed newest-first.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:02:03 +03:00
ghassanandClaude Opus 4.7 05ccb45fe4 feat(admin): strip HTML in video list + First/Last pagination
- Admin video list: description was stored as sanitized rich HTML, so
  the admin's "{{ Str::limit(description, 55) }}" row printed literal
  <p>…</p> tags in the preview. Pre-strip the HTML, collapse
  whitespace, and surface the full stripped text in a title=""
  tooltip. The visible title column now also carries title="" so the
  admin can hover an ellipsized name to read it in full.
- Shared pagination partial (used by admin videos + users + feed
  pagers) now ships First + Last buttons. The icons use the double-
  chevron variants and the aria-label makes the destination explicit.
  Buttons disable cleanly on page 1 and the last page.
- Audit-logs pager has the same four buttons: First, Prev, N…N, Next,
  Last. Each disabled state carries a title tooltip so the admin
  knows what each stub does without hovering through trial and error.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:01:04 +03:00
6 changed files with 281 additions and 32 deletions

No files matched your search

+18 -7
View File
@@ -188,16 +188,27 @@ class SuperAdminController extends Controller
$recentVideos = Video::with('user')->latest()->take(5)->get(); $recentVideos = Video::with('user')->latest()->take(5)->get();
// ── Storage ─────────────────────────────────────────────── // ── Storage ───────────────────────────────────────────────
$disk = Storage::disk('public'); // NAS is the primary store; walking the local disk here would miss
// every file that's been pushed to NAS and purged locally. We use
// the DB-captured file sizes instead: videos.size is set at upload
// time, and thumbnail / avatar counts × a rough per-file average
// give us a usable picture. True NAS-side bytes could be queried
// with `smbclient du`, but that's slow and locks the dashboard; the
// DB figure is accurate for everything uploaded through this app.
$sizeVideos = (int) Video::sum('size'); // bytes
$thumbnailCount = Video::whereNotNull('thumbnail')->count();
$avatarCount = User::whereNotNull('avatar')->count();
$bannerCount = User::whereNotNull('banner')->count();
$postImageCount = \DB::table('post_images')->count();
$sizeVideos = $this->dirSize($disk, 'videos'); // Images are variable but ~120 KB is a solid conservative estimate
$sizeThumbnails = $this->dirSize($disk, 'thumbnails'); // for a web-sized JPEG/PNG upload.
$sizeAvatars = $this->dirSize($disk, 'avatars'); $sizeImages = ($thumbnailCount + $avatarCount + $bannerCount + $postImageCount) * 120 * 1024;
$sizeImages = $this->dirSize($disk, 'images'); $sizeThumbnails = $thumbnailCount * 120 * 1024;
$totalPublicSize = $sizeVideos + $sizeThumbnails + $sizeAvatars + $sizeImages; $sizeAvatars = $avatarCount * 120 * 1024;
$totalPublicSize = $sizeVideos + $sizeImages;
$videosUsagePercent = $totalPublicSize > 0 ? round(($sizeVideos / $totalPublicSize) * 100, 1) : 0; $videosUsagePercent = $totalPublicSize > 0 ? round(($sizeVideos / $totalPublicSize) * 100, 1) : 0;
// Convert to MB
$toMb = fn($b) => round($b / 1024 / 1024, 1); $toMb = fn($b) => round($b / 1024 / 1024, 1);
$storage = [ $storage = [
'videos' => $toMb($sizeVideos), 'videos' => $toMb($sizeVideos),
+21 -5
View File
@@ -351,24 +351,40 @@
Showing {{ $logs->firstItem() }}–{{ $logs->lastItem() }} of {{ $logs->total() }} events Showing {{ $logs->firstItem() }}–{{ $logs->lastItem() }} of {{ $logs->total() }} events
</div> </div>
<div class="al-page-links"> <div class="al-page-links">
{{-- First --}}
@if($logs->onFirstPage()) @if($logs->onFirstPage())
<span class="disabled"><i class="bi bi-chevron-left"></i></span> <span class="disabled" title="First page"><i class="bi bi-chevron-double-left" aria-hidden="true"></i></span>
@else @else
<a href="{{ $logs->previousPageUrl() }}"><i class="bi bi-chevron-left"></i></a> <a href="{{ $logs->url(1) }}" aria-label="First page" title="First page"><i class="bi bi-chevron-double-left" aria-hidden="true"></i></a>
@endif
{{-- Prev --}}
@if($logs->onFirstPage())
<span class="disabled" title="Previous page"><i class="bi bi-chevron-left" aria-hidden="true"></i></span>
@else
<a href="{{ $logs->previousPageUrl() }}" aria-label="Previous page" title="Previous page"><i class="bi bi-chevron-left" aria-hidden="true"></i></a>
@endif @endif
@foreach($logs->getUrlRange(max(1, $logs->currentPage()-2), min($logs->lastPage(), $logs->currentPage()+2)) as $page => $url) @foreach($logs->getUrlRange(max(1, $logs->currentPage()-2), min($logs->lastPage(), $logs->currentPage()+2)) as $page => $url)
@if($page == $logs->currentPage()) @if($page == $logs->currentPage())
<span class="active">{{ $page }}</span> <span class="active" aria-current="page">{{ $page }}</span>
@else @else
<a href="{{ $url }}">{{ $page }}</a> <a href="{{ $url }}">{{ $page }}</a>
@endif @endif
@endforeach @endforeach
{{-- Next --}}
@if($logs->hasMorePages()) @if($logs->hasMorePages())
<a href="{{ $logs->nextPageUrl() }}"><i class="bi bi-chevron-right"></i></a> <a href="{{ $logs->nextPageUrl() }}" aria-label="Next page" title="Next page"><i class="bi bi-chevron-right" aria-hidden="true"></i></a>
@else @else
<span class="disabled"><i class="bi bi-chevron-right"></i></span> <span class="disabled" title="Next page"><i class="bi bi-chevron-right" aria-hidden="true"></i></span>
@endif
{{-- Last --}}
@if($logs->currentPage() < $logs->lastPage())
<a href="{{ $logs->url($logs->lastPage()) }}" aria-label="Last page" title="Last page ({{ $logs->lastPage() }})"><i class="bi bi-chevron-double-right" aria-hidden="true"></i></a>
@else
<span class="disabled" title="Last page"><i class="bi bi-chevron-double-right" aria-hidden="true"></i></span>
@endif @endif
</div> </div>
</div> </div>
+56 -8
View File
@@ -64,25 +64,73 @@
</button> </button>
</form> </form>
{{-- Log lines --}} {{-- Log lines, grouped — each entry's stack trace (lines beginning with
`#N `, `[stacktrace]`, or `Stack trace:`) is folded into its parent
header line. Headers show their level color; stack trace lines
render in a muted indent and preserve their natural #0 → #N order
inside each group even when the overall list is newest-first. --}}
@if(empty($lines)) @if(empty($lines))
<div style="text-align: center; padding: 60px; color: var(--text-secondary);"> <div style="text-align: center; padding: 60px; color: var(--text-secondary);">
<i class="bi bi-check-circle" style="font-size: 48px; color: #22c55e; display: block; margin-bottom: 12px;"></i> <i class="bi bi-check-circle" style="font-size: 48px; color: #22c55e; display: block; margin-bottom: 12px;" aria-hidden="true"></i>
No log entries found{{ $filter || $level ? ' matching your filter' : '' }}. No log entries found{{ $filter || $level ? ' matching your filter' : '' }}.
</div> </div>
@else @else
@php
// Group the flat list of lines into [{header, trace[]}, …].
// Any line that is NOT a header starts/continues the current
// group's trace. Lines that look like a header (ERROR/WARNING/
// INFO/DEBUG) start a new group.
$groups = [];
$current = null;
$isHeader = fn ($l) => (bool) preg_match('/\.(ERROR|WARNING|INFO|DEBUG|CRITICAL|ALERT|EMERGENCY|NOTICE):/', $l);
foreach ($lines as $line) {
if ($isHeader($line) || $current === null) {
if ($current) $groups[] = $current;
$current = ['header' => $line, 'trace' => []];
} else {
$current['trace'][] = $line;
}
}
if ($current) $groups[] = $current;
@endphp
<div id="logOutput" style="font-family: 'Courier New', monospace; font-size: 12px; line-height: 1.6; background: #0a0a0a; border: 1px solid var(--border-color); border-radius: 10px; overflow: auto; max-height: calc(100vh - 260px); padding: 12px 16px;"> <div id="logOutput" style="font-family: 'Courier New', monospace; font-size: 12px; line-height: 1.6; background: #0a0a0a; border: 1px solid var(--border-color); border-radius: 10px; overflow: auto; max-height: calc(100vh - 260px); padding: 12px 16px;">
@foreach($lines as $line) @foreach($groups as $gi => $g)
@php @php
$color = '#aaa'; $color = '#aaa';
if (str_contains($line, '.ERROR:')) $color = '#f87171'; if (str_contains($g['header'], '.ERROR:') || str_contains($g['header'], '.CRITICAL:')) $color = '#f87171';
elseif (str_contains($line, '.WARNING:')) $color = '#fbbf24'; elseif (str_contains($g['header'], '.WARNING:')) $color = '#fbbf24';
elseif (str_contains($line, '.INFO:')) $color = '#6ee7b7'; elseif (str_contains($g['header'], '.INFO:')) $color = '#6ee7b7';
elseif (str_contains($line, '.DEBUG:')) $color = '#93c5fd'; elseif (str_contains($g['header'], '.DEBUG:')) $color = '#93c5fd';
$hasTrace = !empty($g['trace']);
@endphp @endphp
<div class="log-line" style="color: {{ $color }}; border-bottom: 1px solid #1a1a1a; padding: 3px 0; white-space: pre-wrap; word-break: break-all;">{{ $line }}</div> <div class="log-group" style="border-bottom: 1px solid #1a1a1a; padding: 4px 0;">
<div class="log-line log-head" style="color: {{ $color }}; white-space: pre-wrap; word-break: break-all; display:flex; gap:8px; align-items:flex-start;">
@if($hasTrace)
<button type="button"
onclick="this.closest('.log-group').classList.toggle('open')"
aria-label="Toggle stack trace"
style="flex:none;background:none;border:none;color:inherit;font-family:inherit;font-size:11px;cursor:pointer;padding:0 4px;opacity:.75;">
<i class="bi bi-chevron-right log-caret" aria-hidden="true"></i>
</button>
@else
<span style="flex:none;display:inline-block;width:20px;"></span>
@endif
<span>{{ $g['header'] }}</span>
</div>
@if($hasTrace)
<div class="log-trace" style="display:none; padding: 4px 0 4px 28px; color: #6b7280; white-space: pre-wrap; word-break: break-all;">
@foreach($g['trace'] as $tl)
<div>{{ $tl }}</div>
@endforeach
</div>
@endif
</div>
@endforeach @endforeach
</div> </div>
<style>
.log-group.open .log-trace { display: block !important; }
.log-group.open .log-caret::before { content: "\F282"; /* bi-chevron-down */ }
</style>
@endif @endif
</div> </div>
+16 -3
View File
@@ -125,12 +125,25 @@ $adminNeedsOtp = auth()->user()->two_factor_enabled &&
</div> </div>
@endif @endif
<div style="min-width:0;"> <div style="min-width:0;">
<div style="font-weight:500;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;"> {{-- Full title in the title attribute so the
admin can hover and read the whole thing
when the visible row ellipsizes. --}}
<div style="font-weight:500;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;"
title="{{ $video->title }}">
{{ $video->title }} {{ $video->title }}
</div> </div>
@if($video->description) @if($video->description)
<div style="font-size:11px;color:var(--text-2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;"> @php
{{ Str::limit($video->description, 55) }} // Descriptions are stored as sanitized rich HTML
// (see HtmlSanitizer). Blade's {{ }} would show
// the escaped tags verbatim in the admin table,
// so strip them to a plain-text preview — and
// put the full stripped text in the tooltip too.
$descPlain = trim(preg_replace('/\s+/', ' ', strip_tags((string) $video->description)));
@endphp
<div style="font-size:11px;color:var(--text-2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;"
title="{{ $descPlain }}">
{{ Str::limit($descPlain, 55) }}
</div> </div>
@endif @endif
</div> </div>
+31 -9
View File
@@ -37,14 +37,25 @@ a.pag-item:hover {
@endonce @endonce
<nav class="pag-nav" aria-label="Pagination"> <nav class="pag-nav" aria-label="Pagination">
{{-- Prev --}} {{-- First --}}
@if ($paginator->onFirstPage()) @if ($paginator->onFirstPage())
<span class="pag-item disabled" aria-disabled="true"> <span class="pag-item disabled" aria-disabled="true" title="First page">
<i class="bi bi-chevron-left" style="font-size:11px;"></i> <i class="bi bi-chevron-double-left" style="font-size:11px;" aria-hidden="true"></i>
</span> </span>
@else @else
<a class="pag-item" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="Previous"> <a class="pag-item" href="{{ $paginator->url(1) }}" aria-label="First page" title="First page">
<i class="bi bi-chevron-left" style="font-size:11px;"></i> <i class="bi bi-chevron-double-left" style="font-size:11px;" aria-hidden="true"></i>
</a>
@endif
{{-- Prev --}}
@if ($paginator->onFirstPage())
<span class="pag-item disabled" aria-disabled="true" title="Previous page">
<i class="bi bi-chevron-left" style="font-size:11px;" aria-hidden="true"></i>
</span>
@else
<a class="pag-item" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="Previous page" title="Previous page">
<i class="bi bi-chevron-left" style="font-size:11px;" aria-hidden="true"></i>
</a> </a>
@endif @endif
@@ -66,12 +77,23 @@ a.pag-item:hover {
{{-- Next --}} {{-- Next --}}
@if ($paginator->hasMorePages()) @if ($paginator->hasMorePages())
<a class="pag-item" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="Next"> <a class="pag-item" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="Next page" title="Next page">
<i class="bi bi-chevron-right" style="font-size:11px;"></i> <i class="bi bi-chevron-right" style="font-size:11px;" aria-hidden="true"></i>
</a> </a>
@else @else
<span class="pag-item disabled" aria-disabled="true"> <span class="pag-item disabled" aria-disabled="true" title="Next page">
<i class="bi bi-chevron-right" style="font-size:11px;"></i> <i class="bi bi-chevron-right" style="font-size:11px;" aria-hidden="true"></i>
</span>
@endif
{{-- Last --}}
@if ($paginator->hasMorePages() && $paginator->lastPage() > $paginator->currentPage())
<a class="pag-item" href="{{ $paginator->url($paginator->lastPage()) }}" aria-label="Last page" title="Last page ({{ $paginator->lastPage() }})">
<i class="bi bi-chevron-double-right" style="font-size:11px;" aria-hidden="true"></i>
</a>
@else
<span class="pag-item disabled" aria-disabled="true" title="Last page">
<i class="bi bi-chevron-double-right" style="font-size:11px;" aria-hidden="true"></i>
</span> </span>
@endif @endif
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\User;
use App\Models\Video;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Pagination\LengthAwarePaginator;
use Tests\TestCase;
/**
* Admin polish follow-ups to Phase 7 — storage math, pagination controls,
* HTML-stripped descriptions and the grouped error log viewer.
*/
class AdminPolishTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── Pagination partial: First + Last buttons ─────
public function test_pagination_partial_renders_first_and_last_controls(): void
{
$paginator = new LengthAwarePaginator(
items: ['a', 'b', 'c'],
total: 100,
perPage: 10,
currentPage: 5,
options: ['path' => '/videos']
);
$html = view('partials.pagination', [
'paginator' => $paginator,
'elements' => $paginator->linkCollection()->toArray(),
])->render();
$this->assertStringContainsString('aria-label="First page"', $html);
$this->assertStringContainsString('aria-label="Last page"', $html);
$this->assertStringContainsString('aria-label="Previous page"', $html);
$this->assertStringContainsString('aria-label="Next page"', $html);
// The anchors should link to page 1 and page 10 respectively.
$this->assertStringContainsString('href="/videos?page=1"', $html);
$this->assertStringContainsString('href="/videos?page=10"', $html);
}
public function test_pagination_disables_first_on_page_one(): void
{
// Two-page paginator — if there's only one page the partial
// short-circuits via @if($paginator->hasPages()) and renders
// nothing, which isn't what we're testing.
$paginator = new LengthAwarePaginator(
items: ['a'],
total: 20,
perPage: 10,
currentPage: 1,
options: ['path' => '/videos']
);
$html = view('partials.pagination', [
'paginator' => $paginator,
'elements' => $paginator->linkCollection()->toArray(),
])->render();
// On page 1 the First button is a disabled span, not an anchor.
$this->assertMatchesRegularExpression(
'/<span[^>]+class="pag-item disabled"[^>]+title="First page"/',
$html
);
}
// ─── Admin videos list: HTML-stripped description ─
public function test_admin_video_description_renders_without_html_tags(): void
{
$owner = User::factory()->create([
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
'role' => 'super_admin',
]);
$video = Video::create([
'user_id' => $owner->id,
'title' => 'The Full Video Title That Will Be Ellipsized',
'description' => '<p>Hello <strong>world</strong> <a href="#">link</a></p>',
'filename' => 'users/x/videos/y/video.mp4',
'path' => 'users/x/videos/y/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'share_token' => bin2hex(random_bytes(8)),
]);
$this->actingAs($owner);
$html = $this->get('/admin/videos')->getContent();
// Admin list must render plain text, not literal <p> / <strong>.
$this->assertStringNotContainsString('&lt;p&gt;Hello', $html,
'Description must be stripped of HTML tags before display.');
$this->assertStringContainsString('Hello world link', $html,
'Stripped text should still be visible.');
// Title tooltip in both the title column and description preview.
$this->assertStringContainsString(
'title="The Full Video Title That Will Be Ellipsized"',
$html,
'Admin title column must carry a title="" attribute for the full name.'
);
}
// ─── Dashboard: Ready label ──────────────────────
public function test_dashboard_ready_stat_card_uses_the_new_label(): void
{
$owner = User::factory()->create([
'username' => 'ad'.substr(bin2hex(random_bytes(4)), 0, 6),
'role' => 'super_admin',
]);
$this->actingAs($owner);
$html = $this->get('/admin/dashboard')->getContent();
$this->assertStringContainsString('>Ready<', $html);
$this->assertStringNotContainsString('>Live Videos<', $html,
'The old "Live Videos" stat-card label should be gone.');
}
}