Optimized PDF's and better offline file handeling

This commit is contained in:
2026-03-17 00:52:48 +01:00 Verified
parent d5cc399f6b
commit 06c114497a
10 changed files with 154 additions and 48 deletions
+130 -17
View File
@@ -1,15 +1,23 @@
// app/documents/page.tsx // app/documents/page.tsx
import { BookCopy, Download, ExternalLink, Folder, Map, MapPinned, MapPlus, WavesLadder } from 'lucide-react'; "use client";
import { BookCopy, CheckCircle, CloudDownload, Download, ExternalLink, Folder, Loader2, Map, MapPinned, MapPlus, WavesLadder, WifiOff, Archive } from 'lucide-react';
import { useEffect, useState } from 'react';
export default function Documents() { export default function Documents() {
const [isHydrated, setIsHydrated] = useState(false);
const [isOffline, setIsOffline] = useState(false);
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'done'>('idle');
const [cachedFiles, setCachedFiles] = useState<Set<string>>(new Set());
const docs = [ const docs = [
{ title: 'Turistkarta', icon: <Map size={24} />, file: '/files/Turistkarta - Kullaberg.pdf', size: '2.51 MB' }, { title: 'Turistkarta', icon: <Map size={24} />, file: '/files/Turistkarta - Kullaberg.pdf', size: '2.51 MB' },
{ title: 'Orienteringskarta', icon: <MapPlus size={24} />, file: '/files/Orienteringskarta - Kullaberg.pdf', size: '7.23 MB' }, { title: 'Orienteringskarta', icon: <MapPlus size={24} />, file: '/files/Orienteringskarta - Kullaberg.pdf', size: '3.74 MB' },
{ title: 'Badplatser att besöka', icon: <WavesLadder size={24} />, file: '/files/Badplatser på Kullaberg.pdf', size: '7.07 MB' }, { title: 'Badplatser att besöka', icon: <WavesLadder size={24} />, file: '/files/Badplatser på Kullaberg.pdf', size: '1.88 MB' },
{ title: 'Vandringsrutter', icon: <MapPinned size={24} />, file: '/files/Vandringsrutter.pdf', size: '4.42 MB' }, { title: 'Vandringsrutter', icon: <MapPinned size={24} />, file: '/files/Vandringsrutter.pdf', size: '4.42 MB' },
{ title: 'Underlag för guidediplomering', icon: <BookCopy size={24} />, file: '/files/Underlag för guidediplomering.pptx', size: '3.83 MB' }, { title: 'Underlag för guidediplomering', icon: <BookCopy size={24} />, file: '/files/Underlag för guidediplomering.pdf', size: '3.18 MB' },
{ title: 'Destinationskunskap Kullahalvön', icon: <BookCopy size={24} />, file: '/files/Destinationskunskap 2026.pptx', size: '24.2 MB' } { title: 'Destinationskunskap Kullahalvön', icon: <BookCopy size={24} />, file: '/files/Destinationskunskap.pdf', size: '1.97 MB' }
]; ];
const links = [ const links = [
@@ -20,8 +28,66 @@ export default function Documents() {
{ title: 'Väderprognos Mölle', url: 'https://www.smhi.se/vader/prognoser-och-varningar/vaderprognos/q/H%C3%B6gan%C3%A4s/M%C3%B6lle/2691501' } { title: 'Väderprognos Mölle', url: 'https://www.smhi.se/vader/prognoser-och-varningar/vaderprognos/q/H%C3%B6gan%C3%A4s/M%C3%B6lle/2691501' }
]; ];
const checkCacheStatus = async () => {
if (!('caches' in window)) return;
const cached = new Set<string>();
try {
for (const doc of docs) {
const response = await caches.match(doc.file);
if (response) cached.add(doc.file);
}
setCachedFiles(cached);
if (cached.size === docs.length) setSyncStatus('done');
} catch (error) {
console.error("Cache check failed", error);
}
};
useEffect(() => {
setIsHydrated(true);
setIsOffline(!navigator.onLine);
const handleStatus = () => setIsOffline(!navigator.onLine);
window.addEventListener('online', handleStatus);
window.addEventListener('offline', handleStatus);
checkCacheStatus();
const conn = (navigator as any).connection;
if (conn && (conn.type === 'wifi' || conn.type === 'ethernet') && !conn.saveData) {
handleSyncAll();
}
return () => {
window.removeEventListener('online', handleStatus);
window.removeEventListener('offline', handleStatus);
};
}, []);
const handleSyncAll = async () => {
setSyncStatus('syncing');
for (const doc of docs) {
try {
await fetch(doc.file);
} catch (error) {
console.error("Kunde inte ladda ner:", doc.file);
}
}
await checkCacheStatus();
setSyncStatus('done');
};
if (!isHydrated) {
return (
<div className="flex justify-center py-20">
<Loader2 className="animate-spin text-slate-teal" size={40} />
</div>
);
}
return ( return (
<div className="space-y-8 animate-fade-in w-full"> <div className="space-y-8 animate-fade-in w-full">
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-4">
<div> <div>
<h1 className="text-3xl font-black text-slate-teal tracking-tight flex items-center"> <h1 className="text-3xl font-black text-slate-teal tracking-tight flex items-center">
<Folder className="mr-3 text-seafoam" size={32} /> <Folder className="mr-3 text-seafoam" size={32} />
@@ -30,40 +96,87 @@ export default function Documents() {
<p className="text-moss font-medium mt-2 ml-11">Allt du behöver för att guida besökarna.</p> <p className="text-moss font-medium mt-2 ml-11">Allt du behöver för att guida besökarna.</p>
</div> </div>
<div className="flex flex-col gap-2 items-start md:items-end">
{isOffline && (
<div className="bg-goldenrod/10 text-goldenrod border border-goldenrod/20 px-3 py-1.5 rounded-full flex items-center text-xs font-black uppercase tracking-tighter">
<WifiOff size={14} className="mr-2" /> Offline-läge
</div>
)}
{!isOffline && syncStatus !== 'done' && (
<button
onClick={handleSyncAll}
disabled={syncStatus === 'syncing'}
className="flex items-center gap-2 bg-seafoam/10 text-slate-teal border border-seafoam/20 hover:bg-seafoam/20 px-4 py-2 rounded-lg font-bold text-sm transition-colors"
>
{syncStatus === 'syncing' ? <Loader2 size={18} className="animate-spin" /> : <CloudDownload size={18} />}
{syncStatus === 'syncing' ? 'Laddar ner filer...' : 'Gör tillgänglig offline'}
</button>
)}
{syncStatus === 'done' && (
<div className="flex items-center gap-2 text-moss font-bold text-sm bg-moss/10 border border-moss/20 px-4 py-2 rounded-lg">
<CheckCircle size={18} /> Redo för offline
</div>
)}
</div>
</div>
<section> <section>
<h2 className="text-sm font-black text-ebony/50 uppercase tracking-widest mb-4">Nedladdningar</h2> <h2 className="text-sm font-black text-ebony/50 uppercase tracking-widest mb-4">Nedladdningar</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{docs.map((doc, idx) => ( {docs.map((doc, idx) => {
const isCached = cachedFiles.has(doc.file);
return (
<a key={idx} href={doc.file} target="_blank" rel="noopener noreferrer" <a key={idx} href={doc.file} target="_blank" rel="noopener noreferrer"
className="group flex items-center justify-between bg-white/60 backdrop-blur-sm p-5 rounded-2xl border border-white hover:-translate-y-1 hover:shadow-xl hover:shadow-moss/10 transition-all duration-300" className={`group flex items-center justify-between p-5 rounded-2xl border transition-all duration-300 ${isOffline && !isCached ? 'opacity-50 grayscale cursor-not-allowed pointer-events-none bg-white/20 border-transparent' : 'bg-white/60 border-white hover:-translate-y-1 hover:shadow-xl hover:shadow-moss/10 backdrop-blur-sm'}`}
> >
<div className="flex items-center text-slate-teal"> <div className="flex items-center text-slate-teal">
<div className="bg-linear-to-br from-seafoam to-slate-teal p-3 rounded-xl mr-4 text-eggshell shadow-inner shrink-0"> {/* FIX: Keep the original icon, but change the background to green when cached */}
<div className={`p-3 rounded-xl mr-4 text-eggshell shadow-inner shrink-0 transition-colors ${isCached ? 'bg-moss' : 'bg-linear-to-br from-seafoam to-slate-teal'}`}>
{doc.icon} {doc.icon}
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-bold text-sm leading-tight">{doc.title}</span> <span className="font-bold text-sm leading-tight">{doc.title}</span>
<span className="text-[10px] font-bold text-moss bg-moss/10 px-2 py-0.5 rounded-md w-fit mt-1.5 uppercase tracking-wider border border-moss/10"> <div className="flex flex-wrap items-center gap-2 mt-1.5">
<span className="text-[10px] font-bold text-moss bg-moss/10 px-2 py-0.5 rounded-md uppercase tracking-wider border border-moss/10">
{doc.size} {doc.size}
</span> </span>
{isCached && (
<span className="flex items-center gap-1 text-[10px] font-bold text-moss bg-moss/10 px-2 py-0.5 rounded-md uppercase tracking-wider">
<Archive size={12} /> Nerladdad
</span>
)}
</div> </div>
</div> </div>
<Download size={20} className="text-moss group-hover:text-slate-teal transition-colors shrink-0 ml-2" /> </div>
{/* FIX: Swap the Download icon for a Check icon when it is already saved on the phone */}
{isCached ? (
<CheckCircle size={20} className="text-moss shrink-0 ml-2" />
) : (
<Download size={20} className="text-slate-teal/50 group-hover:text-slate-teal transition-colors shrink-0 ml-2" />
)}
</a> </a>
))} );
})}
</div> </div>
</section> </section>
<section> <section>
<h2 className="text-sm font-black text-ebony/50 uppercase tracking-widest mb-4">Användbara Länkar</h2> <h2 className="text-sm font-black text-ebony/50 uppercase tracking-widest mb-4">
{isOffline ? "Användbara Länkar (Kräver anslutning)" : "Användbara Länkar"}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{links.map((link, idx) => ( {links.map((link, idx) => (
<a key={idx} href={link.url} target="_blank" rel="noopener noreferrer" <a key={idx} href={isOffline ? '#' : link.url} target={isOffline ? '_self' : '_blank'} rel="noopener noreferrer"
className="group flex items-center justify-between bg-white/60 backdrop-blur-sm p-5 rounded-2xl border border-white hover:-translate-y-1 hover:shadow-xl hover:shadow-moss/10 transition-all duration-300" className={`group flex items-center justify-between p-5 rounded-2xl border transition-all duration-300 ${isOffline ? 'opacity-40 grayscale cursor-not-allowed pointer-events-none bg-white/20 border-transparent' : 'bg-white/60 border-white hover:-translate-y-1 hover:shadow-xl hover:shadow-moss/10 backdrop-blur-sm'}`}
> >
<span className="font-bold text-slate-teal text-sm leading-tight pr-4">{link.title}</span> <span className="font-bold text-slate-teal text-sm leading-tight pr-4">{link.title}</span>
<div className="bg-eggshell rounded-full p-2 group-hover:bg-seafoam group-hover:text-eggshell transition-colors shrink-0"> <div className={`rounded-full p-2 transition-colors shrink-0 ${isOffline ? 'bg-ebony/5' : 'bg-eggshell group-hover:bg-seafoam'}`}>
<ExternalLink size={16} className="text-slate-teal group-hover:text-eggshell" /> <ExternalLink size={16} className={isOffline ? 'text-ebony/40' : 'text-slate-teal group-hover:text-eggshell'} />
</div> </div>
</a> </a>
))} ))}
@@ -71,4 +184,4 @@ export default function Documents() {
</section> </section>
</div> </div>
); );
}; }
+2 -11
View File
@@ -21,19 +21,10 @@ const serwist = new Serwist({
runtimeCaching: [ runtimeCaching: [
{ {
matcher: ({ url }) => url.pathname.startsWith("/files/"), matcher: ({ url }) => url.pathname.startsWith("/files/"),
handler: async ({ request, event }) => { handler: new CacheFirst({
const conn = navigator.connection;
const isStrictWifi = conn && (conn.type === 'wifi' || conn.type === 'ethernet');
const shouldCache = isStrictWifi && !conn?.saveData;
if (shouldCache) {
const cacheStrategy = new CacheFirst({
cacheName: "kullaberg-files-cache", cacheName: "kullaberg-files-cache",
plugins: [new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 2592000 })], plugins: [new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 2592000 })],
}); })
return cacheStrategy.handle({ request, event });
}
return fetch(request);
}
}, },
...defaultCache, ...defaultCache,
], ],
+3 -1
View File
@@ -12,8 +12,10 @@ const withSerwist = withSerwistInit({
cacheOnNavigation: true, cacheOnNavigation: true,
additionalPrecacheEntries: [ additionalPrecacheEntries: [
{ url: "/", revision: manualRevision }, { url: "/", revision: manualRevision },
{ url: "/emergency", revision: manualRevision }, { url: "/schedule", revision: manualRevision },
{ url: "/faq", revision: manualRevision }, { url: "/faq", revision: manualRevision },
{ url: "/info", revision: manualRevision },
{ url: "/emergency", revision: manualRevision },
], ],
}); });
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.