From ead6b5a9f7221070a8dad5868d63d711d95d8e0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20S=C3=B6derberg?= Date: Fri, 19 Jun 2026 13:33:17 +0200 Subject: [PATCH] Better new file handeling and offline notifications. --- app/actions/files.ts | 33 +++++++++++++++++++ app/admin/NoticeTab.tsx | 69 ++++++++++++++++++++++++++++++++++------ app/documents/page.tsx | 40 ++++++++++++++--------- app/page.tsx | 70 +++++++++++++++++++++++------------------ 4 files changed, 157 insertions(+), 55 deletions(-) create mode 100644 app/actions/files.ts diff --git a/app/actions/files.ts b/app/actions/files.ts new file mode 100644 index 0000000..715f755 --- /dev/null +++ b/app/actions/files.ts @@ -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 => { + 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" }; + } +} \ No newline at end of file diff --git a/app/admin/NoticeTab.tsx b/app/admin/NoticeTab.tsx index 38bf6e4..1d69455 100644 --- a/app/admin/NoticeTab.tsx +++ b/app/admin/NoticeTab.tsx @@ -19,27 +19,76 @@ export const NoticeTab = ({ isOffline }: { isOffline: boolean }) => { const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle'); useEffect(() => { + let isMounted = true; + const fetchNotice = async () => { - const res = await readJsonFile('notice.json'); - if (res.success && res.data) { - setNotice(res.data); + const cached = localStorage.getItem('admin_notice_cache'); + if (cached) { + setNotice(JSON.parse(cached)); + setIsLoading(false); + } + + const fetchWithTimeout = new Promise((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(); + return () => { isMounted = false; }; }, []); const handleSave = async () => { setIsSaving(true); setSaveStatus('idle'); - const res = await writeJsonFile('notice.json', notice); - if (res.success) { - setSaveStatus('success'); - setTimeout(() => setSaveStatus('idle'), 3000); - } else { + + try { + const saveWithTimeout = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Timeout')), 8000); + 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'); + } finally { + setIsSaving(false); } - setIsSaving(false); }; if (isLoading) return
; diff --git a/app/documents/page.tsx b/app/documents/page.tsx index 78dcf13..6aa18ed 100644 --- a/app/documents/page.tsx +++ b/app/documents/page.tsx @@ -8,6 +8,7 @@ import { readJsonFile } from '../actions/jsonEditor'; import { DocumentCard, DocumentItem, ExternalLinkCard, LinkItem } from '../components/ui/Cards'; import { OfflineBadge } from '../components/ui/OfflineBadge'; import { PageHeader } from '../components/ui/PageHeader'; +import { getLocalFileMeta } from '../actions/files'; const IconMap: Record = { "Map": Map, @@ -18,18 +19,25 @@ const IconMap: Record = { "FileText": FileText }; -const fetchFileSize = async (url: string) => { +const fetchFileMeta = async (url: string) => { 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 lastModified = res.headers.get('last-modified'); + const etag = res.headers.get('etag'); + + let sizeStr = "Okänd"; if (bytes) { 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) { - console.error("Kunde inte hämta filstorlek för", url); + return { size: "Okänd", version: "v1" }; } - return "Okänd"; }; export default function Documents() { @@ -62,22 +70,26 @@ export default function Documents() { if (navigator.onLine) { const res = await readJsonFile('documents.json'); if (res.success && res.data) { - const docsWithSizes = await Promise.all( + const docsWithMeta = await Promise.all( (res.data.docs || []).map(async (doc: DocumentItem) => { - if (!doc.size || doc.size === "") { - const calculatedSize = await fetchFileSize(doc.file); - return { ...doc, size: calculatedSize }; - } - return doc; + const meta = await getLocalFileMeta(doc.file); + const versionedUrl = `${doc.file}?v=${meta.version}`; + + return { + ...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 || []); - const dataToCache = { ...res.data, docs: docsWithSizes }; + const dataToCache = { ...res.data, docs: docsWithMeta }; localStorage.setItem('kullaberg_documents_cache', JSON.stringify(dataToCache)); - checkCacheStatus(docsWithSizes); + checkCacheStatus(docsWithMeta); } } else { const cachedData = localStorage.getItem('kullaberg_documents_cache'); diff --git a/app/page.tsx b/app/page.tsx index 1cd59e4..07aa425 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -26,20 +26,43 @@ export default function Home() { const [isLoading, setIsLoading] = useState(true); useEffect(() => { + let isMounted = true; + const loadNotice = async () => { - if (navigator.onLine) { - const res = await readJsonFile('notice.json'); - if (res.success && res.data) { - setNotice(res.data); - localStorage.setItem('kullaberg_notice_cache', JSON.stringify(res.data)); + const cached = localStorage.getItem('kullaberg_notice_cache'); + if (cached) { + setNotice(JSON.parse(cached)); + setIsLoading(false); + } + + const fetchWithTimeout = new Promise((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 { - const cached = localStorage.getItem('kullaberg_notice_cache'); - if (cached) setNotice(JSON.parse(cached)); + } catch (error) { + console.warn("Kunde inte hämta nytt meddelande (Liar-Fi), behåller cache."); + } finally { + if (isMounted) setIsLoading(false); } - setIsLoading(false); }; + loadNotice(); + return () => { isMounted = false; }; }, []); 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 */} + - {/* Quick Action Cards now use the glass style */} + {/* Quick Action Cards */}
- - - + +
- {/* Contact Card updated to 'glass' variant */} - + {/* Contact Card */} +
William Söderberg