From 598bdc6f3a01498e2a86ded847c2d3a565c9a7d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Sat, 27 Sep 2025 15:20:27 +0200 Subject: [PATCH] Caching and such --- .../controllers/service-worker_controller.js | 321 +++++++++++++++++- config/packages/asset_caching.yaml | 16 + config/packages/framework.yaml | 2 +- config/services.yaml | 1 - public/robots.txt | 18 + public/service-worker.js | 308 ++++++++++++++++- src/Controller/ArticleController.php | 26 -- src/Controller/DefaultController.php | 6 - src/Controller/StaticController.php | 24 +- templates/admin/cache.html.twig | 154 +++++++++ templates/admin/transactions.html.twig | 2 +- .../components/Organisms/ZineList.html.twig | 4 +- templates/pages/journals.html.twig | 0 13 files changed, 834 insertions(+), 48 deletions(-) create mode 100644 config/packages/asset_caching.yaml create mode 100644 public/robots.txt create mode 100644 templates/admin/cache.html.twig delete mode 100644 templates/pages/journals.html.twig diff --git a/assets/controllers/service-worker_controller.js b/assets/controllers/service-worker_controller.js index b2bba42..f87aeb1 100644 --- a/assets/controllers/service-worker_controller.js +++ b/assets/controllers/service-worker_controller.js @@ -1,11 +1,326 @@ import { Controller } from '@hotwired/stimulus' export default class extends Controller { + static targets = ['status'] + static values = { + staticUrls: Array, + cacheName: String + } + connect() { + // Check if we've shown an update prompt recently (within 1 hour) + const lastPrompt = localStorage.getItem('sw-update-prompt-shown'); + const oneHourAgo = Date.now() - (60 * 60 * 1000); + + if (lastPrompt && parseInt(lastPrompt) > oneHourAgo) { + this.updatePromptShown = true; + } else { + this.updatePromptShown = false; + } + if ('serviceWorker' in navigator) { - navigator.serviceWorker.register('/service-worker.js') - .then(reg => console.log('SW registered:', reg)) - .catch(err => console.error('SW failed:', err)); + this.loadStaticRoutes().then(() => { + this.registerServiceWorker(); + this.setupServiceWorkerEventListeners(); + }); + } else { + this.updateStatus('Service Worker not supported'); } } + + async loadStaticRoutes() { + try { + const response = await fetch('/api/static-routes'); + if (response.ok) { + const data = await response.json(); + this.staticUrlsValue = data.routes; + this.cacheNameValue = data.cacheName; + console.log('Loaded static routes for controller:', this.staticUrlsValue); + } else { + console.warn('Failed to load static routes from API'); + } + } catch (error) { + console.error('Error loading static routes:', error); + } + } + + async registerServiceWorker() { + try { + const registration = await navigator.serviceWorker.register('/service-worker.js'); + console.log('SW registered:', registration); + + // Check if there's actually a waiting service worker with new content + if (registration.waiting && !this.updatePromptShown) { + this.showUpdateAvailable(registration); + } + + // Listen for service worker updates, but be more selective + registration.addEventListener('updatefound', () => { + const newWorker = registration.installing; + + newWorker.addEventListener('statechange', () => { + // Only show update if: + // 1. The worker is installed + // 2. We have an active controller (not first install) + // 3. Haven't shown prompt recently + if (newWorker.state === 'installed' && + navigator.serviceWorker.controller && + !this.updatePromptShown) { + + // Double-check we haven't prompted recently + const lastPrompt = localStorage.getItem('sw-update-prompt-shown'); + const thirtyMinutesAgo = Date.now() - (30 * 60 * 1000); + + if (!lastPrompt || parseInt(lastPrompt) < thirtyMinutesAgo) { + // Add a delay to avoid immediate prompts during development + setTimeout(() => { + if (!this.updatePromptShown && registration.waiting) { + this.showUpdateAvailable(registration); + } + }, 3000); + } + } + }); + }); + + this.updateStatus('Service Worker registered successfully'); + await this.checkCacheStatus(); + } catch (error) { + console.error('SW failed:', error); + this.updateStatus('Service Worker registration failed'); + } + } + + setupServiceWorkerEventListeners() { + // Listen for messages from the service worker + navigator.serviceWorker.addEventListener('message', (event) => { + if (event.data && event.data.type) { + switch (event.data.type) { + case 'CACHE_UPDATED': + this.updateStatus('Cache updated successfully'); + break; + case 'CACHE_ERROR': + this.updateStatus('Cache update failed'); + break; + } + } + }); + } + + async checkCacheStatus() { + if ('caches' in window) { + try { + const cacheNames = await caches.keys(); + const cacheName = this.cacheNameValue || 'newsroom-static'; + const hasStaticCache = cacheNames.some(name => name.includes(cacheName.split('-')[0] + '-' + cacheName.split('-')[1])); + + if (hasStaticCache) { + const cache = await caches.open(cacheName); + const cachedRequests = await cache.keys(); + this.updateStatus(`${cachedRequests.length} static pages cached`); + } else { + this.updateStatus('Static pages not yet cached'); + } + } catch (error) { + console.error('Cache status check failed:', error); + } + } + } + + showUpdateAvailable(registration) { + // Prevent multiple prompts + if (this.updatePromptShown) { + return; + } + + this.updatePromptShown = true; + + // Store the timestamp to avoid showing again too soon + localStorage.setItem('sw-update-prompt-shown', Date.now().toString()); + + // Use a more user-friendly notification + const shouldUpdate = confirm('A new version of the app is available. Would you like to update now?'); + + if (shouldUpdate) { + if (registration.waiting) { + registration.waiting.postMessage({ type: 'SKIP_WAITING' }); + registration.waiting.addEventListener('statechange', (e) => { + if (e.target.state === 'activated') { + window.location.reload(); + } + }); + } + } else { + // If user declines, don't ask again for 2 hours + localStorage.setItem('sw-update-prompt-shown', (Date.now() + (2 * 60 * 60 * 1000)).toString()); + + // Reset the flag after 2 hours + setTimeout(() => { + this.updatePromptShown = false; + }, 2 * 60 * 60 * 1000); + } + } + + async clearCache() { + try { + const cacheNames = await caches.keys(); + await Promise.all( + cacheNames.map(cacheName => caches.delete(cacheName)) + ); + this.updateStatus('All caches cleared'); + console.log('All caches cleared'); + } catch (error) { + console.error('Failed to clear caches:', error); + this.updateStatus('Failed to clear caches'); + } + } + + async refreshCache() { + if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage({ + type: 'REFRESH_CACHE' + }); + this.updateStatus('Cache refresh requested'); + } + } + + async getCacheStatus() { + if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { + return new Promise((resolve) => { + const messageChannel = new MessageChannel(); + messageChannel.port1.onmessage = (event) => { + if (event.data.type === 'CACHE_STATUS') { + resolve(event.data.status); + } else { + resolve(null); + } + }; + + navigator.serviceWorker.controller.postMessage( + { type: 'GET_CACHE_STATUS' }, + [messageChannel.port2] + ); + }); + } + return null; + } + + async displayCacheInfo() { + const status = await this.getCacheStatus(); + if (status) { + console.log('Cache Status:', status); + let message = 'Cache Status:\n'; + for (const [cacheName, count] of Object.entries(status)) { + message += `${cacheName}: ${count} items\n`; + } + this.updateStatus(message.trim()); + } + } + + async preloadCriticalAssets() { + if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { + // Get the manifest to find critical assets + try { + const manifestResponse = await fetch('/assets/manifest.json'); + if (manifestResponse.ok) { + const manifest = await manifestResponse.json(); + + // Critical assets that should be preloaded + const criticalAssets = [ + 'app.js', + 'bootstrap.js', + 'styles/app.css', + 'styles/theme.css', + 'styles/layout.css', + 'styles/fonts.css' + ]; + + const assetsToPreload = criticalAssets + .map(asset => manifest[asset]) + .filter(Boolean); + + // Trigger preloading by making requests + const preloadPromises = assetsToPreload.map(assetUrl => + fetch(assetUrl, { mode: 'no-cors' }).catch(() => { + console.warn('Failed to preload:', assetUrl); + }) + ); + + await Promise.all(preloadPromises); + this.updateStatus(`Preloaded ${assetsToPreload.length} critical assets`); + console.log('Preloaded assets:', assetsToPreload); + } + } catch (error) { + console.error('Failed to preload critical assets:', error); + } + } + } + + // Enhanced cache management methods + async clearAssetsCache() { + if ('caches' in window) { + try { + const deleted = await caches.delete('newsroom-assets-v1'); + if (deleted) { + this.updateStatus('Assets cache cleared'); + console.log('Assets cache cleared'); + } else { + this.updateStatus('Assets cache not found'); + } + } catch (error) { + console.error('Failed to clear assets cache:', error); + this.updateStatus('Failed to clear assets cache'); + } + } + } + + async clearStaticCache() { + if ('caches' in window) { + try { + const deleted = await caches.delete('newsroom-static-v1'); + if (deleted) { + this.updateStatus('Static cache cleared'); + console.log('Static cache cleared'); + } else { + this.updateStatus('Static cache not found'); + } + } catch (error) { + console.error('Failed to clear static cache:', error); + this.updateStatus('Failed to clear static cache'); + } + } + } + + updateStatus(message) { + if (this.hasStatusTarget) { + this.statusTarget.textContent = message; + console.log('SW Status:', message); + } + } + + // Action methods that can be called from the template + clearCacheAction() { + this.clearCache(); + } + + refreshCacheAction() { + this.refreshCache(); + } + + // Enhanced action methods + clearAssetsCacheAction() { + this.clearAssetsCache(); + } + + clearStaticCacheAction() { + this.clearStaticCache(); + } + + displayCacheInfoAction() { + this.displayCacheInfo(); + } + + preloadCriticalAssetsAction() { + this.preloadCriticalAssets(); + } } diff --git a/config/packages/asset_caching.yaml b/config/packages/asset_caching.yaml new file mode 100644 index 0000000..a9f30bf --- /dev/null +++ b/config/packages/asset_caching.yaml @@ -0,0 +1,16 @@ +# Asset caching configuration for better browser caching +framework: + assets: + # AssetMapper already handles versioning via manifest.json + # No need to set version_strategy as it conflicts with json_manifest_path + json_manifest_path: '%kernel.project_dir%/public/assets/manifest.json' + +# Configure HTTP cache headers for static assets +when@prod: + framework: + http_cache: + # Cache static assets for longer periods + default_ttl: 3600 # 1 hour default + private_headers: ['authorization', 'cookie'] + allow_reload: false + allow_revalidate: false diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 0d3f118..462bae8 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -8,7 +8,7 @@ framework: handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler cookie_secure: auto cookie_samesite: lax - cookie_lifetime: 0 # integer, lifetime in seconds, 0 means 'valid for the length of the browser session' + cookie_lifetime: 2678400 # integer, lifetime in seconds, 0 means 'valid for the length of the browser session' trusted_proxies: '%env(TRUSTED_PROXIES)%' trusted_headers: ['forwarded', 'x-forwarded-for', 'x-forwarded-proto'] # trusted_proxies: '%env(TRUSTED_PROXIES)%' diff --git a/config/services.yaml b/config/services.yaml index bd1b4aa..5a2397f 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -5,7 +5,6 @@ # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: encryption_key: '%env(APP_ENCRYPTION_KEY)%' - nsec: '%env(APP_NSEC)%' services: # default configuration for services in *this* file diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..e24185a --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,18 @@ +# Robots.txt + +# Disallow all by default +User-agent: * +Disallow: / + +# Allow only specific public content +Allow: /assets/ +Allow: /icons/ +Allow: /$ +Allow: /unfold +Allow: /about +Allow: /mag/ +Allow: /p/ +Allow: /list/ + +# Crawl delay +Crawl-delay: 1 diff --git a/public/service-worker.js b/public/service-worker.js index f88e4c1..b255411 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,11 +1,309 @@ +// Define cache names and versions +const CACHE_NAME = 'newsroom-v1'; +const STATIC_CACHE = 'newsroom-static-v1'; +const ASSETS_CACHE = 'newsroom-assets-v1'; +const RUNTIME_CACHE = 'newsroom-runtime-v1'; + +// Assets to cache immediately on install +const PRECACHE_ASSETS = [ + '/', + '/assets/app.js', + '/assets/bootstrap.js', + '/assets/styles/app.css', + '/assets/styles/theme.css', + '/assets/styles/layout.css', + '/assets/styles/fonts.css', + '/assets/icons/favicon.ico', + '/assets/icons/web-app-manifest-192x192.png', + '/assets/icons/web-app-manifest-512x512.png' +]; + +// Define what should be cached with different strategies +const CACHE_STRATEGIES = { + // CSS files - cache first (they change infrequently) + css: { + pattern: /\.css$/, + strategy: 'cacheFirst', + cacheName: ASSETS_CACHE, + maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days + }, + // JS files - cache first with network fallback + js: { + pattern: /\.js$/, + strategy: 'cacheFirst', + cacheName: ASSETS_CACHE, + maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days + }, + // Fonts - cache first (rarely change) + fonts: { + pattern: /\.(woff2?|ttf|eot)$/, + strategy: 'cacheFirst', + cacheName: ASSETS_CACHE, + maxAge: 365 * 24 * 60 * 60 * 1000 // 1 year + }, + // Images and icons - cache first + images: { + pattern: /\.(png|jpg|jpeg|gif|svg|ico)$/, + strategy: 'cacheFirst', + cacheName: ASSETS_CACHE, + maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days + }, + // Static pages - network first with cache fallback + pages: { + pattern: /^https?.*\/(about|roadmap|tos|landing|unfold)$/, + strategy: 'networkFirst', + cacheName: STATIC_CACHE, + maxAge: 24 * 60 * 60 * 1000 // 1 day + }, + // API calls - network first + api: { + pattern: /\/api\//, + strategy: 'networkFirst', + cacheName: RUNTIME_CACHE, + maxAge: 5 * 60 * 1000 // 5 minutes + } +}; + self.addEventListener('install', async (event) => { - // Activate the service worker immediately after install - await self.skipWaiting(); + console.log('Service Worker installing...'); + + event.waitUntil( + (async () => { + try { + // Cache core assets immediately + const cache = await caches.open(ASSETS_CACHE); + console.log('Precaching core assets...'); + + // Get the current asset manifest to cache the actual versioned files + const manifestResponse = await fetch('/assets/manifest.json'); + if (manifestResponse.ok) { + const manifest = await manifestResponse.json(); + const versionedAssets = PRECACHE_ASSETS.map(asset => { + const logicalPath = asset.replace('/assets/', ''); + return manifest[logicalPath] || asset; + }); + + await cache.addAll(versionedAssets); + console.log('Precached assets:', versionedAssets); + } else { + // Fallback to original assets if manifest not available + await cache.addAll(PRECACHE_ASSETS); + } + + // Activate immediately + await self.skipWaiting(); + } catch (error) { + console.error('Precaching failed:', error); + } + })() + ); }); self.addEventListener('activate', (event) => { - // Take control of all clients immediately - event.waitUntil(self.clients.claim()); + console.log('Service Worker activating...'); + + event.waitUntil( + (async () => { + // Clean up old caches + const cacheNames = await caches.keys(); + const oldCaches = cacheNames.filter(name => + name.startsWith('newsroom-') && + ![CACHE_NAME, STATIC_CACHE, ASSETS_CACHE, RUNTIME_CACHE].includes(name) + ); + + await Promise.all(oldCaches.map(name => caches.delete(name))); + console.log('Cleaned up old caches:', oldCaches); + + // Take control of all clients + await self.clients.claim(); + })() + ); +}); + +self.addEventListener('fetch', (event) => { + const request = event.request; + const url = new URL(request.url); + + // Skip non-GET requests + if (request.method !== 'GET') return; + + // Skip chrome-extension and other non-http requests + if (!url.protocol.startsWith('http')) return; + + // Find matching cache strategy + const strategy = findCacheStrategy(request.url); + + if (strategy) { + event.respondWith(handleRequest(request, strategy)); + } }); -// No fetch or cache handlers — fully fall-through +self.addEventListener('message', (event) => { + const { type, data } = event.data || {}; + + switch (type) { + case 'SKIP_WAITING': + self.skipWaiting(); + break; + + case 'REFRESH_CACHE': + handleCacheRefresh(); + break; + + case 'GET_CACHE_STATUS': + handleCacheStatus(event); + break; + } +}); + +function findCacheStrategy(url) { + for (const [name, config] of Object.entries(CACHE_STRATEGIES)) { + if (config.pattern.test(url)) { + return config; + } + } + return null; +} + +async function handleRequest(request, strategy) { + const cache = await caches.open(strategy.cacheName); + + switch (strategy.strategy) { + case 'cacheFirst': + return cacheFirst(request, cache, strategy); + case 'networkFirst': + return networkFirst(request, cache, strategy); + case 'staleWhileRevalidate': + return staleWhileRevalidate(request, cache, strategy); + default: + return fetch(request); + } +} + +async function cacheFirst(request, cache, strategy) { + try { + // Check cache first + const cachedResponse = await cache.match(request); + if (cachedResponse && !isExpired(cachedResponse, strategy.maxAge)) { + return cachedResponse; + } + + // Fetch from network + const networkResponse = await fetch(request); + + if (networkResponse.ok) { + // Clone and cache the response + const responseToCache = networkResponse.clone(); + await cache.put(request, responseToCache); + } + + return networkResponse; + } catch (error) { + console.error('Cache first strategy failed:', error); + // Return cached version even if expired as fallback + const cachedResponse = await cache.match(request); + if (cachedResponse) { + return cachedResponse; + } + throw error; + } +} + +async function networkFirst(request, cache, strategy) { + try { + // Try network first + const networkResponse = await fetch(request); + + if (networkResponse.ok) { + // Cache successful responses + const responseToCache = networkResponse.clone(); + await cache.put(request, responseToCache); + } + + return networkResponse; + } catch (error) { + console.log('Network failed, trying cache:', error.message); + // Fallback to cache + const cachedResponse = await cache.match(request); + if (cachedResponse) { + return cachedResponse; + } + throw error; + } +} + +async function staleWhileRevalidate(request, cache, strategy) { + const cachedResponse = await cache.match(request); + + // Always fetch in background to update cache + const fetchPromise = fetch(request).then(networkResponse => { + if (networkResponse.ok) { + cache.put(request, networkResponse.clone()); + } + return networkResponse; + }).catch(() => { + // Ignore background fetch failures + }); + + // Return cached version immediately if available + if (cachedResponse && !isExpired(cachedResponse, strategy.maxAge)) { + return cachedResponse; + } + + // Wait for network if no cache or expired + return fetchPromise; +} + +function isExpired(response, maxAge) { + if (!maxAge) return false; + + const dateHeader = response.headers.get('date'); + if (!dateHeader) return false; + + const responseTime = new Date(dateHeader).getTime(); + return (Date.now() - responseTime) > maxAge; +} + +async function handleCacheRefresh() { + try { + // Clear all caches + const cacheNames = await caches.keys(); + await Promise.all(cacheNames.map(name => caches.delete(name))); + + // Reinstall with fresh cache + const cache = await caches.open(ASSETS_CACHE); + await cache.addAll(PRECACHE_ASSETS); + + // Notify all clients + const clients = await self.clients.matchAll(); + clients.forEach(client => { + client.postMessage({ type: 'CACHE_UPDATED' }); + }); + + console.log('Cache refreshed successfully'); + } catch (error) { + console.error('Cache refresh failed:', error); + // Notify clients of error + const clients = await self.clients.matchAll(); + clients.forEach(client => { + client.postMessage({ type: 'CACHE_ERROR', error: error.message }); + }); + } +} + +async function handleCacheStatus(event) { + try { + const cacheNames = await caches.keys(); + const status = {}; + + for (const cacheName of cacheNames) { + const cache = await caches.open(cacheName); + const keys = await cache.keys(); + status[cacheName] = keys.length; + } + + event.ports[0].postMessage({ type: 'CACHE_STATUS', status }); + } catch (error) { + event.ports[0].postMessage({ type: 'CACHE_STATUS_ERROR', error: error.message }); + } +} diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 2913cb3..27dac1f 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -324,32 +324,6 @@ class ArticleController extends AbstractController } } - private function verifyNostrSignature(array $event): bool - { - try { - // Reconstruct the event ID - $serializedEvent = json_encode([ - 0, - $event['pubkey'], - $event['created_at'], - $event['kind'], - $event['tags'], - $event['content'] - ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - - $eventId = hash('sha256', $serializedEvent); - - // Verify the event ID matches - if ($eventId !== $event['id']) { - return false; - } - - return (new SchnorrSignature())->verify($event['pubkey'], $event['sig'], $event['id']); - } catch (\Exception $e) { - return false; - } - } - private function extractArticleDataFromEvent(array $event, array $formData): array { $data = [ diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index f56a06d..ba95c9c 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -24,15 +24,9 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Contracts\Cache\CacheInterface; use Psr\Log\LoggerInterface; -use Symfony\Contracts\Cache\ItemInterface; class DefaultController extends AbstractController { - public function __construct( - private readonly FinderInterface $finder, - private readonly CacheInterface $redisCache) - { - } /** * @throws Exception diff --git a/src/Controller/StaticController.php b/src/Controller/StaticController.php index 16d5a4b..7fed7a3 100644 --- a/src/Controller/StaticController.php +++ b/src/Controller/StaticController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -52,9 +53,26 @@ class StaticController extends AbstractController return $this->render('static/unfold.html.twig'); } - #[Route('/journals', name: 'journals_index')] - public function journalsIndex(): Response + #[Route('/api/static-routes', name: 'api_static_routes', methods: ['GET'])] + public function getStaticRoutes(): JsonResponse { - return $this->render('pages/journals.html.twig'); + $staticRoutes = [ + '/about', + '/roadmap', + '/tos', + '/landing', + '/unfold', + ]; + + return new JsonResponse([ + 'routes' => $staticRoutes, + 'cacheName' => 'newsroom-static-v1' + ]); + } + + #[Route('/admin/cache', name: 'admin_cache', methods: ['GET'])] + public function cacheManagement(): Response + { + return $this->render('admin/cache.html.twig'); } } diff --git a/templates/admin/cache.html.twig b/templates/admin/cache.html.twig new file mode 100644 index 0000000..91424bd --- /dev/null +++ b/templates/admin/cache.html.twig @@ -0,0 +1,154 @@ +{% extends 'base.html.twig' %} + +{% block title %}Cache Management - {{ parent() }}{% endblock %} + +{% block stylesheets %} + {{ parent() }} + +{% endblock %} + +{% block layout %} +
+

Cache Management

+ +
+

Caching Strategy Overview

+

Your newsroom application uses a multi-layered caching strategy:

+
    +
  • Service Worker Cache - Caches JS, CSS, fonts, and images for offline access
  • +
  • Browser Cache - HTTP cache headers for optimal browser caching
  • +
  • Asset Versioning - Content-hashed filenames for cache busting
  • +
+
+ +
+ Loading cache status... +
+ +
+ + + + + + + + + + + +
+ +
+

Cache Types Explained

+
+
Assets Cache (newsroom-assets-v1)
+
Stores JS files, CSS files, fonts, and images. Uses "cache-first" strategy for fast loading.
+ +
Static Cache (newsroom-static-v1)
+
Stores static pages like About, Roadmap, etc. Uses "network-first" strategy for fresh content.
+ +
Runtime Cache (newsroom-runtime-v1)
+
Stores API responses and dynamic content with short expiration times.
+
+
+ +
+

Asset Caching Details

+

The following asset types are automatically cached:

+
    +
  • JavaScript files (.js) - Cached for 30 days
  • +
  • CSS files (.css) - Cached for 30 days
  • +
  • Font files (.woff2, .woff, .ttf) - Cached for 1 year
  • +
  • Images (.png, .jpg, .svg, .ico) - Cached for 30 days
  • +
  • Static pages - Cached for 1 day with network-first strategy
  • +
+
+ +
+

Performance Benefits

+
    +
  • Faster page loads after first visit
  • +
  • Reduced bandwidth usage
  • +
  • Better offline experience
  • +
  • Automatic cache invalidation when assets change
  • +
+
+
+{% endblock %} diff --git a/templates/admin/transactions.html.twig b/templates/admin/transactions.html.twig index e59325b..f136151 100644 --- a/templates/admin/transactions.html.twig +++ b/templates/admin/transactions.html.twig @@ -20,7 +20,7 @@ {% for tx in transactions %} {{ tx.id }} - {{ tx.npub|shortenNpub }} + {{ tx.type }} {{ tx.amount }} {{ tx.reason ?: '—' }} diff --git a/templates/components/Organisms/ZineList.html.twig b/templates/components/Organisms/ZineList.html.twig index b3d54d8..191201b 100644 --- a/templates/components/Organisms/ZineList.html.twig +++ b/templates/components/Organisms/ZineList.html.twig @@ -3,7 +3,7 @@ {% for item in nzines %}
-

+

{% if item and item.title %} {{ item.title }} @@ -13,7 +13,7 @@ Untitled Magazine {% endif %} -

+ {% if item and item.summary %}

{{ item.summary }}

{% endif %} diff --git a/templates/pages/journals.html.twig b/templates/pages/journals.html.twig deleted file mode 100644 index e69de29..0000000