Better new file handeling and offline notifications.

This commit is contained in:
2026-06-19 13:33:17 +02:00 Verified
parent 6bbab9aa48
commit ead6b5a9f7
4 changed files with 157 additions and 55 deletions
+33
View File
@@ -0,0 +1,33 @@
// app/actions/files.ts
'use server';
import fs from 'fs';
import fsPromises from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
const generateFileHash = (filePath: string): Promise<string> => {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5');
const stream = fs.createReadStream(filePath);
stream.on('error', (err) => reject(err));
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex').substring(0, 8)));
});
};
export async function getLocalFileMeta(fileUrl: string) {
try {
const cleanPath = fileUrl.startsWith('/') ? fileUrl.substring(1) : fileUrl;
const fullPath = path.join(process.cwd(), 'public', cleanPath);
const stats = await fsPromises.stat(fullPath);
const sizeMb = (stats.size / (1024 * 1024)).toFixed(2) + ' MB';
const fileHash = await generateFileHash(fullPath);
return { size: sizeMb, version: fileHash };
} catch (error) {
console.error(`Kunde inte läsa filen: ${fileUrl}`, error);
return { size: "Okänd", version: "v1" };
}
}
+59 -10
View File
@@ -19,27 +19,76 @@ export const NoticeTab = ({ isOffline }: { isOffline: boolean }) => {
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
useEffect(() => { useEffect(() => {
let isMounted = true;
const fetchNotice = async () => { const fetchNotice = async () => {
const res = await readJsonFile('notice.json'); const cached = localStorage.getItem('admin_notice_cache');
if (res.success && res.data) { if (cached) {
setNotice(res.data); setNotice(JSON.parse(cached));
setIsLoading(false);
}
const fetchWithTimeout = new Promise<any>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Timeout')), 5000);
readJsonFile('notice.json').then(res => {
clearTimeout(timer);
resolve(res);
}).catch(err => {
clearTimeout(timer);
reject(err);
});
});
try {
if (navigator.onLine) {
const res = await fetchWithTimeout;
if (res.success && res.data && isMounted) {
setNotice(res.data);
localStorage.setItem('admin_notice_cache', JSON.stringify(res.data));
}
}
} catch (error) {
console.warn("Kunde inte hämta färsk notice.json (Liar-Fi), använder cache.");
} finally {
if (isMounted) setIsLoading(false);
} }
setIsLoading(false);
}; };
fetchNotice(); fetchNotice();
return () => { isMounted = false; };
}, []); }, []);
const handleSave = async () => { const handleSave = async () => {
setIsSaving(true); setIsSaving(true);
setSaveStatus('idle'); setSaveStatus('idle');
const res = await writeJsonFile('notice.json', notice);
if (res.success) { try {
setSaveStatus('success'); const saveWithTimeout = new Promise<any>((resolve, reject) => {
setTimeout(() => setSaveStatus('idle'), 3000); const timer = setTimeout(() => reject(new Error('Timeout')), 8000);
} else { writeJsonFile('notice.json', notice).then(res => {
clearTimeout(timer);
resolve(res);
}).catch(err => {
clearTimeout(timer);
reject(err);
});
});
const res = await saveWithTimeout;
if (res.success) {
setSaveStatus('success');
localStorage.setItem('admin_notice_cache', JSON.stringify(notice));
setTimeout(() => setSaveStatus('idle'), 3000);
} else {
setSaveStatus('error');
}
} catch (error) {
console.error("Liar-Fi: Sparande tog för lång tid", error);
setSaveStatus('error'); setSaveStatus('error');
} finally {
setIsSaving(false);
} }
setIsSaving(false);
}; };
if (isLoading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-slate-teal" size={32} /></div>; if (isLoading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-slate-teal" size={32} /></div>;
+26 -14
View File
@@ -8,6 +8,7 @@ import { readJsonFile } from '../actions/jsonEditor';
import { DocumentCard, DocumentItem, ExternalLinkCard, LinkItem } from '../components/ui/Cards'; import { DocumentCard, DocumentItem, ExternalLinkCard, LinkItem } from '../components/ui/Cards';
import { OfflineBadge } from '../components/ui/OfflineBadge'; import { OfflineBadge } from '../components/ui/OfflineBadge';
import { PageHeader } from '../components/ui/PageHeader'; import { PageHeader } from '../components/ui/PageHeader';
import { getLocalFileMeta } from '../actions/files';
const IconMap: Record<string, any> = { const IconMap: Record<string, any> = {
"Map": Map, "Map": Map,
@@ -18,18 +19,25 @@ const IconMap: Record<string, any> = {
"FileText": FileText "FileText": FileText
}; };
const fetchFileSize = async (url: string) => { const fetchFileMeta = async (url: string) => {
try { try {
const res = await fetch(url, { method: 'HEAD' }); const res = await fetch(url, { method: 'HEAD', cache: 'no-store' });
const bytes = res.headers.get('content-length'); const bytes = res.headers.get('content-length');
const lastModified = res.headers.get('last-modified');
const etag = res.headers.get('etag');
let sizeStr = "Okänd";
if (bytes) { if (bytes) {
const mb = (parseInt(bytes) / (1024 * 1024)).toFixed(2); const mb = (parseInt(bytes) / (1024 * 1024)).toFixed(2);
return `${mb} MB`; sizeStr = `${mb} MB`;
} }
const version = etag ? etag.replace(/"/g, '') :
(lastModified ? new Date(lastModified).getTime().toString() : 'v1');
return { size: sizeStr, version };
} catch (error) { } catch (error) {
console.error("Kunde inte hämta filstorlek för", url); return { size: "Okänd", version: "v1" };
} }
return "Okänd";
}; };
export default function Documents() { export default function Documents() {
@@ -62,22 +70,26 @@ export default function Documents() {
if (navigator.onLine) { if (navigator.onLine) {
const res = await readJsonFile('documents.json'); const res = await readJsonFile('documents.json');
if (res.success && res.data) { if (res.success && res.data) {
const docsWithSizes = await Promise.all( const docsWithMeta = await Promise.all(
(res.data.docs || []).map(async (doc: DocumentItem) => { (res.data.docs || []).map(async (doc: DocumentItem) => {
if (!doc.size || doc.size === "") { const meta = await getLocalFileMeta(doc.file);
const calculatedSize = await fetchFileSize(doc.file); const versionedUrl = `${doc.file}?v=${meta.version}`;
return { ...doc, size: calculatedSize };
} return {
return doc; ...doc,
size: (!doc.size || doc.size === "Okänd") ? meta.size : doc.size,
originalFile: doc.file,
file: versionedUrl
};
}) })
); );
setDocs(docsWithSizes); setDocs(docsWithMeta);
setLinks(res.data.links || []); setLinks(res.data.links || []);
const dataToCache = { ...res.data, docs: docsWithSizes }; const dataToCache = { ...res.data, docs: docsWithMeta };
localStorage.setItem('kullaberg_documents_cache', JSON.stringify(dataToCache)); localStorage.setItem('kullaberg_documents_cache', JSON.stringify(dataToCache));
checkCacheStatus(docsWithSizes); checkCacheStatus(docsWithMeta);
} }
} else { } else {
const cachedData = localStorage.getItem('kullaberg_documents_cache'); const cachedData = localStorage.getItem('kullaberg_documents_cache');
+39 -31
View File
@@ -26,20 +26,43 @@ export default function Home() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
let isMounted = true;
const loadNotice = async () => { const loadNotice = async () => {
if (navigator.onLine) { const cached = localStorage.getItem('kullaberg_notice_cache');
const res = await readJsonFile('notice.json'); if (cached) {
if (res.success && res.data) { setNotice(JSON.parse(cached));
setNotice(res.data); setIsLoading(false);
localStorage.setItem('kullaberg_notice_cache', JSON.stringify(res.data)); }
const fetchWithTimeout = new Promise<any>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Timeout')), 5000);
readJsonFile('notice.json').then(res => {
clearTimeout(timer);
resolve(res);
}).catch(err => {
clearTimeout(timer);
reject(err);
});
});
try {
if (navigator.onLine) {
const res = await fetchWithTimeout;
if (res.success && res.data && isMounted) {
setNotice(res.data);
localStorage.setItem('kullaberg_notice_cache', JSON.stringify(res.data));
}
} }
} else { } catch (error) {
const cached = localStorage.getItem('kullaberg_notice_cache'); console.warn("Kunde inte hämta nytt meddelande (Liar-Fi), behåller cache.");
if (cached) setNotice(JSON.parse(cached)); } finally {
if (isMounted) setIsLoading(false);
} }
setIsLoading(false);
}; };
loadNotice(); loadNotice();
return () => { isMounted = false; };
}, []); }, []);
const nConfig = notice ? getNoticeConfig(notice.type) : null; const nConfig = notice ? getNoticeConfig(notice.type) : null;
@@ -66,32 +89,17 @@ export default function Home() {
) )
)} )}
{/* NÖDKNAPPEN - Ligger utanför griddet så den alltid är fullbredd och i fokus */} {/* NÖDKNAPPEN */}
<EmergencyButton <EmergencyButton href="/emergency" title="Nödsituation" />
href="/emergency"
title="Nödsituation"
/>
{/* Quick Action Cards now use the glass style */} {/* Quick Action Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ActionLinkCard <ActionLinkCard href="/schedule" title="Ditt Schema" description="Kolla dina arbetspass och dagliga rundor snabbt." />
href="/schedule" <ActionLinkCard href="/faq" title="Vanliga Frågor" description="Snabba svar på turisternas vanligaste funderingar." />
title="Ditt Schema"
description="Kolla dina arbetspass och dagliga rundor snabbt."
/>
<ActionLinkCard
href="/faq"
title="Vanliga Frågor"
description="Snabba svar på turisternas vanligaste funderingar."
/>
</div> </div>
{/* Contact Card updated to 'glass' variant */} {/* Contact Card */}
<SectionCard <SectionCard title="Snabbkontakt" icon={PhoneCall}>
title="Snabbkontakt"
icon={PhoneCall}
>
<div className="space-y-4 pt-2"> <div className="space-y-4 pt-2">
<div className="flex flex-col md:flex-row md:justify-between md:items-center pb-4 border-b border-white gap-2"> <div className="flex flex-col md:flex-row md:justify-between md:items-center pb-4 border-b border-white gap-2">
<span className="text-sm font-bold text-ebony">William Söderberg</span> <span className="text-sm font-bold text-ebony">William Söderberg</span>