Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 83 additions & 3 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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}`);
Expand Down Expand Up @@ -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();
Expand All @@ -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 () => {
Expand Down
58 changes: 58 additions & 0 deletions src/app/pages/OfflinePage/OfflineArticles.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div css={styles.container}>
<h2 css={styles.heading}>Available Offline Articles</h2>
<ul css={styles.grid}>
{OFFLINE_ARTICLE_IDS.map(articleId => (
<li key={articleId}>
<a
css={styles.articleBox}
href={`/${service}/articles/${articleId}`}
onClick={() => handleArticleClick(articleId)}
>
<h3 css={styles.articleTitle}>
Article ${articleId}
</h3>
<span css={styles.articleContent}>.......</span>
</a>
</li>
))}
</ul>
</div>
);
};

export default OfflineArticles;
2 changes: 2 additions & 0 deletions src/app/pages/OfflinePage/OfflinePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -30,6 +31,7 @@ const OfflinePage = () => {
script={script}
service={service}
/>
<OfflineArticles />
</>
);
};
Expand Down
92 changes: 92 additions & 0 deletions src/app/pages/OfflinePage/index.styles.ts
Original file line number Diff line number Diff line change
@@ -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;