diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index acc82ae33..f3287fb92 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -27,8 +27,14 @@ class AppServiceProvider extends ServiceProvider { Paginator::useTailwind(); - // Share global data with all views using View Composer - view()->composer('*', \App\View\Composers\GlobalDataComposer::class); + // Share global data only with layouts and partials that need it + // (avoids running queries for emails, components, and other minimal views) + view()->composer([ + 'layouts.main', + 'layouts.admin', + 'layouts.guest', + 'layouts.app', + ], \App\View\Composers\GlobalDataComposer::class); Gate::define('viewPulse', function (User $user) { return $user->hasRole('Admin'); diff --git a/app/View/Composers/GlobalDataComposer.php b/app/View/Composers/GlobalDataComposer.php index 2d0c293a1..ed160db96 100644 --- a/app/View/Composers/GlobalDataComposer.php +++ b/app/View/Composers/GlobalDataComposer.php @@ -8,50 +8,62 @@ use App\Models\Content; use App\Models\Settings; use App\Models\User; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Cache; use Illuminate\View\View; class GlobalDataComposer { + /** + * Cache TTL in seconds (5 minutes). + */ + private const CACHE_TTL = 300; + /** * Bind data to the view. */ public function compose(View $view): void { - $viewName = $view->getName() ?? ''; - $isEmailView = str_starts_with($viewName, 'emails.'); - - // Load settings as array with type conversions (empty strings -> null, numeric strings -> numbers) - $siteArray = Settings::query() - ->pluck('value', 'name') - ->map(fn ($value) => Settings::convertValue($value)) - ->all(); + // Cached site settings (shared across all requests) + $siteArray = Cache::remember('site_settings_array', self::CACHE_TTL, function () { + return Settings::query() + ->pluck('value', 'name') + ->map(fn ($value) => Settings::convertValue($value)) + ->all(); + }); $viewData = [ 'serverroot' => url('/'), + 'site' => $siteArray, ]; - // Email views expect $site to be the string provided by the mailable - if (! $isEmailView) { - $viewData['site'] = $siteArray; // Now it's a proper array, not a Settings model - } - - // Load useful links for sidebar - $usefulLinks = Content::active() - ->ofType(Content::TYPE_USEFUL) - ->orderBy('ordinal') - ->get(); - $viewData['usefulLinks'] = $usefulLinks; + // Cached useful links for sidebar + $viewData['usefulLinks'] = Cache::remember('content_useful_links', self::CACHE_TTL, function () { + return Content::active() + ->ofType(Content::TYPE_USEFUL) + ->orderBy('ordinal') + ->get(); + }); if (Auth::check()) { - $userdata = User::find(Auth::id()); - $userdata->categoryexclusions = User::getCategoryExclusionById(Auth::id()); + $userId = Auth::id(); - // Update last login every 15 mins. + // User data with category exclusions (short cache per user) + $userdata = Cache::remember('composer_user_'.$userId, self::CACHE_TTL, function () use ($userId) { + $user = User::find($userId); + $user->categoryexclusions = User::getCategoryExclusionById($userId); + + return $user; + }); + + // Update last login every 3 hours (outside of cache to avoid stale checks) if (now()->subHours(3) > $userdata->lastlogin) { event(new UserLoggedIn($userdata)); } - $parentcatlist = Category::getForMenu($userdata->categoryexclusions); + // Cached menu categories per user (depends on their exclusions) + $parentcatlist = Cache::remember('menu_user_'.$userId, self::CACHE_TTL, function () use ($userdata) { + return Category::getForMenu($userdata->categoryexclusions); + }); $viewData = array_merge($viewData, [ 'userdata' => $userdata, diff --git a/composer.json b/composer.json index 0cb88249d..cb5e05a40 100644 --- a/composer.json +++ b/composer.json @@ -185,6 +185,18 @@ "post-create-project-cmd": [ "@php artisan key:generate --ansi" ], - "fix-style": "pint" + "fix-style": "pint", + "production-optimize": [ + "@php artisan config:cache", + "@php artisan route:cache", + "@php artisan view:cache", + "@php artisan event:cache" + ], + "production-clear": [ + "@php artisan config:clear", + "@php artisan route:clear", + "@php artisan view:clear", + "@php artisan event:clear" + ] } } diff --git a/resources/css/app.css b/resources/css/app.css index d76b17715..094f736d1 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -15,8 +15,11 @@ --radius-card: 0.75rem; } -/* Import FontAwesome */ -@import '@fortawesome/fontawesome-free/css/all.min.css'; +/* Import FontAwesome — only the weights we use (skips v4-compat font + shims) */ +@import '@fortawesome/fontawesome-free/css/fontawesome.min.css'; +@import '@fortawesome/fontawesome-free/css/solid.min.css'; +@import '@fortawesome/fontawesome-free/css/regular.min.css'; +@import '@fortawesome/fontawesome-free/css/brands.min.css'; /* Import custom CSP-safe styles */ @import './csp-safe.css'; diff --git a/resources/js/alpine/index.js b/resources/js/alpine/index.js index 85a8b02b9..404fd3991 100644 --- a/resources/js/alpine/index.js +++ b/resources/js/alpine/index.js @@ -1,6 +1,10 @@ /** * Alpine.js CSP-safe initialization - * Registers all stores and components, then starts Alpine. + * + * Core components are imported eagerly (used on every/most pages). + * Page-specific and heavy components are lazy-loaded: only the modules + * whose x-data names appear in the current page's DOM are fetched. + * This keeps the initial JS bundle small and improves TTI. */ import Alpine from '@alpinejs/csp'; @@ -12,7 +16,7 @@ import './stores/theme.js'; import './stores/toast.js'; import './stores/cart.js'; -// --- Core UI components --- +// --- Core UI components (used on every/most pages) --- import './components/theme-toggle.js'; import './components/back-to-top.js'; import './components/dropdown.js'; @@ -20,36 +24,12 @@ import './components/sidebar-toggle.js'; import './components/admin-submenu.js'; import './components/mobile-nav.js'; import './components/password-toggle.js'; - -// --- Modal components --- import './components/confirm-modal.js'; -import './components/nfo-modal.js'; -import './components/image-modal.js'; -import './components/preview-modal.js'; -import './components/mediainfo-modal.js'; -import './components/filelist-modal.js'; - -// --- Feature components --- +import './components/confirm-link.js'; +import './components/toast-notification.js'; import './components/tab-switcher.js'; import './components/cart-button.js'; -import './components/cart-page.js'; import './components/search-autocomplete.js'; -import './components/quality-filter.js'; -import './components/content-toggle.js'; -import './components/movies-layout.js'; -import './components/movies-page.js'; -import './components/confirm-link.js'; - -// --- Page-specific components --- -import './components/release-report.js'; -import './components/profile-edit.js'; -import './components/auth-page.js'; -import './components/toast-notification.js'; - -// --- Admin components --- -import './components/admin/dashboard.js'; -import './components/admin/groups.js'; -import './components/admin/features.js'; // --- Shared utility (backward compat) --- window.escapeHtml = function(text) { @@ -57,5 +37,6 @@ window.escapeHtml = function(text) { return String(text).replace(/[&<>"']/g, m => map[m]); }; -// Start Alpine -Alpine.start(); +// --- Lazy-load page-specific components, then start Alpine --- +import { loadAndStart } from './lazy-loader.js'; +loadAndStart(); diff --git a/resources/js/alpine/lazy-loader.js b/resources/js/alpine/lazy-loader.js new file mode 100644 index 000000000..81b95363a --- /dev/null +++ b/resources/js/alpine/lazy-loader.js @@ -0,0 +1,89 @@ +/** + * Alpine.js CSP-safe lazy component loader. + * + * Scans the DOM for x-data attributes that match known lazy components. + * Only imports the JS modules for components actually present on the page. + * After all needed modules are loaded, calls Alpine.start(). + * + * Dynamic import() is CSP-compliant (no eval / new Function). + */ +import Alpine from '@alpinejs/csp'; + +/** + * Map of Alpine component name → dynamic import function. + * Each module registers itself via Alpine.data() as a side effect. + */ +const lazyComponentMap = { + // --- Modal components (only on release pages) --- + 'nfoModal': () => import('./components/nfo-modal.js'), + 'filelistModal': () => import('./components/filelist-modal.js'), + 'previewModal': () => import('./components/preview-modal.js'), + 'mediainfoModal': () => import('./components/mediainfo-modal.js'), + 'imageModal': () => import('./components/image-modal.js'), + + // --- Page-specific components --- + 'moviesPage': () => import('./components/movies-page.js'), + 'moviesLayout': () => import('./components/movies-layout.js'), + 'qualityFilter': () => import('./components/quality-filter.js'), + 'contentToggle': () => import('./components/content-toggle.js'), + 'contentDelete': () => import('./components/content-toggle.js'), // same file + 'releaseReport': () => import('./components/release-report.js'), + 'adminReleaseReports': () => import('./components/release-report.js'), // same file + 'profileEdit': () => import('./components/profile-edit.js'), + 'profilePage': () => import('./components/profile-edit.js'), // same file + 'copyToClipboard': () => import('./components/profile-edit.js'), // same file + 'cartPage': () => import('./components/cart-page.js'), + 'authPage': () => import('./components/auth-page.js'), + 'otpInput': () => import('./components/auth-page.js'), // same file + + // --- Admin components (includes Chart.js — heavy) --- + 'adminDashboard': () => import('./components/admin/dashboard.js'), + 'recentActivity': () => import('./components/admin/dashboard.js'), // same file + 'adminGroups': () => import('./components/admin/groups.js'), + 'adminFeatures': () => import('./components/admin/features.js'), +}; + +/** + * Extract Alpine component names from all x-data attributes in the DOM. + * Handles: x-data="componentName", x-data="componentName()", x-data="{ ... }" (skipped). + */ +function getUsedComponentNames() { + const names = new Set(); + document.querySelectorAll('[x-data]').forEach(el => { + const raw = (el.getAttribute('x-data') || '').trim(); + // Skip inline objects like { show: true } + if (!raw || raw.startsWith('{')) return; + // Extract identifier before ( or whitespace + const match = raw.match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)/); + if (match) names.add(match[1]); + }); + return names; +} + +/** + * Load only the lazy components found on the current page, then start Alpine. + */ +export function loadAndStart() { + const usedNames = getUsedComponentNames(); + const imports = new Set(); // deduplicate (multiple names → same file) + + for (const name of usedNames) { + if (lazyComponentMap[name]) { + imports.add(lazyComponentMap[name]); + } + } + + if (imports.size === 0) { + Alpine.start(); + return; + } + + // Load all needed modules in parallel, then start Alpine + Promise.all([...imports].map(fn => fn())) + .then(() => Alpine.start()) + .catch(err => { + console.error('[lazy-loader] Failed to load component:', err); + // Start Alpine anyway so the page is usable + Alpine.start(); + }); +} diff --git a/resources/views/browse/index.blade.php b/resources/views/browse/index.blade.php index 9d95a7629..d23b3a572 100644 --- a/resources/views/browse/index.blade.php +++ b/resources/views/browse/index.blade.php @@ -1,5 +1,9 @@ @extends('layouts.main') +@push('modals') + @include('partials.release-modals') +@endpush + @section('content')