From 6ee60f66406f076e648f9c31d6c91313bb6b5dd2 Mon Sep 17 00:00:00 2001 From: Charlie Leopard Date: Sun, 21 Dec 2025 22:38:27 +0000 Subject: [PATCH] Add OfflineArticle component to display cached articles --- public/sw.js | 86 ++++++++++++++++- src/app/pages/OfflinePage/OfflineArticles.tsx | 58 ++++++++++++ src/app/pages/OfflinePage/OfflinePage.tsx | 2 + src/app/pages/OfflinePage/index.styles.ts | 92 +++++++++++++++++++ 4 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 src/app/pages/OfflinePage/OfflineArticles.tsx create mode 100644 src/app/pages/OfflinePage/index.styles.ts diff --git a/public/sw.js b/public/sw.js index 8d8282982be..bae7de65f31 100644 --- a/public/sw.js +++ b/public/sw.js @@ -10,6 +10,38 @@ const service = self.location.pathname.split('/')[1]; const hasOfflinePageFunctionality = true; const OFFLINE_PAGE = `/${service}/offline`; +const cacheOfflineArticles = async cache => { + // eslint-disable-next-line no-console + console.log(`Attempting to cache offline articles for service: ${service}`); + + const articleCachePromises = OFFLINE_ARTICLES.map(articleId => { + const articleJsonUrl = new URL( + `/${service}/articles/${articleId}.json`, + self.location.origin, + ).href; + + return fetch(articleJsonUrl) + .then(response => { + if (!response || !response.ok) { + throw new Error( + `Failed to fetch article ${articleId}: ${response.status} ${response.statusText}`, + ); + } + // eslint-disable-next-line no-console + console.log(`Successfully cached article: ${articleId}`); + return cache.put(articleJsonUrl, response); + }) + .catch(error => { + // eslint-disable-next-line no-console + console.error(`Failed to cache article ${articleId}: ${error.message}`); + }); + }); + + await Promise.all(articleCachePromises); + // eslint-disable-next-line no-console + console.log('Article caching complete'); +}; + self.addEventListener('install', event => { event.waitUntil( (async () => { @@ -24,6 +56,7 @@ self.addEventListener('install', event => { ); } await cache.put(offlinePageUrl, response); + await cacheOfflineArticles(cache); } catch (error) { // eslint-disable-next-line no-console console.error(`Failed to cache offline page: ${error.message}`); @@ -52,12 +85,28 @@ const CACHEABLE_FILES = [ const WEBP_IMAGE = /^https:\/\/ichef(\.test)?\.bbci\.co\.uk\/(news|images|ace\/(standard|ws))\/.+.webp$/; +const OFFLINE_ARTICLES = [ + 'cwl08rd38l6o', + 'cwkvd1410e9o', + 'crd2mn2lyqqo', + 'c1x0rq3r97ko', + 'c578zj113e9o', +]; + +const isOfflineArticleRequest = url => { + const articleJsonPattern = new RegExp( + `/${service}/articles/[a-z0-9]+\\.json$`, + ); + return articleJsonPattern.test(new URL(url).pathname); +}; + const fetchEventHandler = async event => { + const url = event.request.url; const isRequestForCacheableFile = CACHEABLE_FILES.some(cacheableFile => - new RegExp(cacheableFile).test(event.request.url), + new RegExp(cacheableFile).test(url), ); - const isRequestForWebpImage = WEBP_IMAGE.test(event.request.url); + const isRequestForWebpImage = WEBP_IMAGE.test(url); if (isRequestForWebpImage) { const req = event.request.clone(); @@ -84,12 +133,43 @@ const fetchEventHandler = async event => { const cache = await caches.open(cacheName); let response = await cache.match(event.request); if (!response) { - response = await fetch(event.request.url); + response = await fetch(url); cache.put(event.request, response.clone()); } return response; })(), ); + } else if (isOfflineArticleRequest(url)) { + event.respondWith( + (async () => { + try { + const cache = await caches.open(cacheName); + let response = await cache.match(url); + if (response) { + // eslint-disable-next-line no-console + console.log(`Serving article from cache: ${url}`); + return response; + } + + // eslint-disable-next-line no-console + console.log(`Article not in cache, trying network: ${url}`); + response = await fetch(event.request); + return response; + } catch (error) { + // eslint-disable-next-line no-console + console.error( + `Failed to fetch article JSON: ${error.message}`, + ); + return new Response( + JSON.stringify({ error: 'Article not available offline.' }), + { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + })(), + ); } else if (hasOfflinePageFunctionality && event.request.mode === 'navigate') { event.respondWith( (async () => { diff --git a/src/app/pages/OfflinePage/OfflineArticles.tsx b/src/app/pages/OfflinePage/OfflineArticles.tsx new file mode 100644 index 00000000000..6a8278cc822 --- /dev/null +++ b/src/app/pages/OfflinePage/OfflineArticles.tsx @@ -0,0 +1,58 @@ +/** @jsx jsx */ +/* @jsxFrag React.Fragment */ + +import { use } from 'react'; +import { jsx } from '@emotion/react'; +import { ServiceContext } from '#contexts/ServiceContext'; +import styles from './index.styles'; + + const OFFLINE_ARTICLE_IDS = [ + 'cwl08rd38l6o', + 'cwkvd1410e9o', + 'crd2mn2lyqqo', + 'c1x0rq3r97ko', + 'c578zj113e9o', +]; + +const OfflineArticles = () => { + const { service } = use(ServiceContext); + + const handleArticleClick = async (articleId: string) => { + // Pre-fetch and store article data in sessionStorage for offline access + try { + const url = `/${service}/articles/${articleId}.json`; + const response = await fetch(url); + if (response.ok) { + const data = await response.json(); + sessionStorage.setItem(`offlineArticle_${articleId}`, JSON.stringify(data)); + } + } catch (error) { + // eslint-disable-next-line no-console + console.warn(`Could not pre-fetch article ${articleId}:`, error); + } + }; + + return ( +
+

Available Offline Articles

+ +
+ ); +}; + +export default OfflineArticles; diff --git a/src/app/pages/OfflinePage/OfflinePage.tsx b/src/app/pages/OfflinePage/OfflinePage.tsx index 7da044f9697..eb428cae800 100644 --- a/src/app/pages/OfflinePage/OfflinePage.tsx +++ b/src/app/pages/OfflinePage/OfflinePage.tsx @@ -3,6 +3,7 @@ import path from 'ramda/src/path'; import Helmet from 'react-helmet'; import { ServiceContext } from '#contexts/ServiceContext'; import ErrorMain from '#components/ErrorMain'; +import OfflineArticles from './OfflineArticles'; const OfflinePage = () => { const { service, dir, script, translations } = use(ServiceContext); @@ -30,6 +31,7 @@ const OfflinePage = () => { script={script} service={service} /> + ); }; diff --git a/src/app/pages/OfflinePage/index.styles.ts b/src/app/pages/OfflinePage/index.styles.ts new file mode 100644 index 00000000000..4cdffd4a4f9 --- /dev/null +++ b/src/app/pages/OfflinePage/index.styles.ts @@ -0,0 +1,92 @@ +import { css } from '@emotion/react'; +import { + BLACK, + CLOUD_LIGHT, + LUNAR_LIGHT, + LUNAR, + GREY_3, + CLOUD_DARK, + SERVICE_NEUTRAL_CORE, +} from '#app/components/ThemeProvider/palette'; + +const styles = { + container: css` + margin: 2rem auto 0; + padding: 1.5rem; + border-top: 1px solid ${CLOUD_LIGHT}; + max-width: 1008px; + width: 100%; + box-sizing: border-box; + + @media (max-width: 1024px) { + max-width: 100%; + padding: 1.5rem 1rem; + } + + @media (max-width: 600px) { + padding: 1rem; + } + `, + heading: css` + font-size: 1.5rem; + font-weight: 700; + margin: 0 0 1.5rem 0; + padding: 0; + line-height: 1.25; + color: ${BLACK}; + `, + grid: css` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 1rem; + list-style: none; + padding: 0; + margin: 0; + + @media (max-width: 768px) { + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.75rem; + } + + @media (max-width: 480px) { + grid-template-columns: 1fr; + } + `, + articleBox: css` + display: flex; + flex-direction: column; + padding: 1rem; + border: 1px solid ${GREY_3}; + background-color: ${LUNAR_LIGHT}; + border-radius: 2px; + + &:hover { + background-color: ${LUNAR}; + border-color: ${CLOUD_DARK}; + } + + &:active { + background-color: ${GREY_3}; + } + `, + articleTitle: css` + font-size: 1rem; + font-weight: 700; + line-height: 1.4; + color: ${SERVICE_NEUTRAL_CORE}; + margin: 0; + padding: 0; + word-break: break-word; + + @media (max-width: 768px) { + font-size: 0.95rem; + } + `, + articleContent: css` + font-size: 0.75rem; + color: ${CLOUD_DARK}; + margin-top: 0.5rem; + `, +}; + +export default styles;