diff --git a/public/sw.js b/public/sw.js index e9d05b59..53928091 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,11 +1,7 @@ -// Minimal service worker for the AtomCMS-Next PWA. Cache-first for immutable -// static assets, network-first for navigations (with a cached fallback so the -// shell still loads offline). Bump CACHE to invalidate. -const CACHE = "atom-v1"; +const CACHE = "atom-v2"; +const API_CACHE = "atom-api-v2"; -self.addEventListener("install", () => { - self.skipWaiting(); -}); +self.addEventListener("install", () => self.skipWaiting()); self.addEventListener("activate", (event) => { event.waitUntil( @@ -13,7 +9,7 @@ self.addEventListener("activate", (event) => { .keys() .then((keys) => Promise.all( - keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)), + keys.filter((k) => k !== CACHE && k !== API_CACHE).map((k) => caches.delete(k)), ), ) .then(() => self.clients.claim()), @@ -26,7 +22,6 @@ self.addEventListener("fetch", (event) => { const url = new URL(req.url); if (url.origin !== self.location.origin) return; - // Cache-first for immutable static assets. if ( url.pathname.startsWith("/assets/") || url.pathname.startsWith("/_next/static/") @@ -46,16 +41,30 @@ self.addEventListener("fetch", (event) => { return; } - // Network-first for page navigations; fall back to cache, then the shell. + if ( + url.pathname.startsWith("/api/home") || + url.pathname.startsWith("/api/online") || + url.pathname.startsWith("/api/radio/config") + ) { + event.respondWith( + caches.open(API_CACHE).then((cache) => + fetch(req) + .then((res) => { + if (res.ok) cache.put(req, res.clone()); + return res; + }) + .catch(() => cache.match(req)), + ), + ); + return; + } + if (req.mode === "navigate") { event.respondWith( fetch(req) .then((res) => { const copy = res.clone(); - caches - .open(CACHE) - .then((c) => c.put(req, copy)) - .catch(() => {}); + caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {}); return res; }) .catch(() => caches.match(req).then((hit) => hit || caches.match("/"))), diff --git a/src/app/(site)/news/[slug]/page.tsx b/src/app/(site)/news/[slug]/page.tsx index 8274d8b8..9879d3c1 100644 --- a/src/app/(site)/news/[slug]/page.tsx +++ b/src/app/(site)/news/[slug]/page.tsx @@ -9,9 +9,21 @@ import { ContentCard, EmptyState } from "@/components/public/ui"; import { auth } from "@/lib/auth"; import { excerpt } from "@/lib/format"; import { prisma } from "@/lib/prisma"; +import { cacheQuery } from "@/lib/prisma-cache"; import { sanitize } from "@/lib/sanitize"; -export const dynamic = "force-dynamic"; +export const revalidate = 60; + +export async function generateStaticParams() { + const slugs = await prisma.websiteArticles + .findMany({ + select: { slug: true }, + orderBy: { createdAt: "desc" }, + take: 20, + }) + .catch(() => []); + return slugs.map((a) => ({ slug: a.slug })); +} export async function generateMetadata({ params, @@ -55,21 +67,27 @@ export default async function ArticlePage({ const { slug } = await params; const t = await getTranslations("pages.article"); - const article = await prisma.websiteArticles - .findUnique({ - where: { slug }, - select: { - id: true, - slug: true, - title: true, - shortStory: true, - fullStory: true, - image: true, - createdAt: true, - updatedAt: true, - }, - }) - .catch(() => null); + const article = await cacheQuery( + "websiteArticles", + "findUnique", + { slug }, + () => + prisma.websiteArticles + .findUnique({ + where: { slug }, + select: { + id: true, + slug: true, + title: true, + shortStory: true, + fullStory: true, + image: true, + createdAt: true, + updatedAt: true, + }, + }) + .catch(() => null), + ); if (!article) notFound(); const articleId = article.id; diff --git a/src/lib/prisma-cache.ts b/src/lib/prisma-cache.ts new file mode 100644 index 00000000..20d080e5 --- /dev/null +++ b/src/lib/prisma-cache.ts @@ -0,0 +1,78 @@ +import "server-only"; + +import { redis } from "@/lib/redis"; + +type CacheableQuery = { + model: string; + action: string; + args: unknown; +}; + +const CACHE_TTL: Record = { + websiteArticles: 60, + websiteArticleComments: 10, + websiteHelpCenterCategories: 300, + websiteSettings: 600, + websiteBanners: 120, + websitePolls: 30, + websiteEvents: 30, + emulatorSettings: 300, + emulatorTexts: 300, + aclRoles: 600, + aclPermissions: 600, + users: 10, + ranks: 600, +}; + +const AVOID_CACHE = new Set(["count", "groupBy"]); + +function shouldCache(model: string, action: string): boolean { + if (AVOID_CACHE.has(action)) return false; + return model in CACHE_TTL; +} + +function queryKey(model: string, action: string, args: unknown): string { + const hash = JSON.stringify(args).replace(/["{}[\],]/g, "").slice(0, 120); + return `pq:${model}:${action}:${hash}`; +} + +export function cacheQuery( + model: string, + action: string, + args: unknown, + fn: () => Promise, +): Promise { + if (!shouldCache(model, action)) return fn(); + + const ttl = CACHE_TTL[model]; + const key = queryKey(model, action, args); + + return _cachedQuery(key, ttl, fn); +} + +async function _cachedQuery( + key: string, + ttl: number, + fn: () => Promise, +): Promise { + if (!redis) return fn(); + + try { + const cached = await redis.get(key); + if (cached !== null) { + return JSON.parse(cached) as T; + } + } catch { + // ignore + } + + const result = await fn(); + + try { + await redis.setex(key, ttl, JSON.stringify(result)); + } catch { + // ignore + } + + return result; +}