Compare commits
@@ -0,0 +1,37 @@
|
|||||||
|
// app/actions/files.ts
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import fs from 'fs';
|
||||||
|
import fsPromises from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
|
const DATA_DIR = isProd ? "/app/data" : path.join(process.cwd(), "app/data");
|
||||||
|
const FILES_DIR = path.join(DATA_DIR, "files");
|
||||||
|
|
||||||
|
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 cleanName = decodeURIComponent(fileUrl.split('/').pop() || '');
|
||||||
|
const fullPath = path.join(FILES_DIR, cleanName);
|
||||||
|
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 (actions): ${fileUrl}`, error);
|
||||||
|
return { size: "Okänd", version: "v1" };
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
-10
@@ -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>;
|
||||||
|
|||||||
+46
-27
@@ -12,7 +12,7 @@ export const useAdminState = () => {
|
|||||||
const [periods, setPeriods] = useState<Period[]>([]);
|
const [periods, setPeriods] = useState<Period[]>([]);
|
||||||
const [attendance, setAttendance] = useState<AttendanceDataMap>({});
|
const [attendance, setAttendance] = useState<AttendanceDataMap>({});
|
||||||
const [scheduleData, setScheduleData] = useState<any[]>([]);
|
const [scheduleData, setScheduleData] = useState<any[]>([]);
|
||||||
const [dailyLogs, setDailyLogs] = useState<Record<string, string>>({}); // NYTT
|
const [dailyLogs, setDailyLogs] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const [activePeriodId, setActivePeriodId] = useState<string>('');
|
const [activePeriodId, setActivePeriodId] = useState<string>('');
|
||||||
const [isLoadingData, setIsLoadingData] = useState<boolean>(true);
|
const [isLoadingData, setIsLoadingData] = useState<boolean>(true);
|
||||||
@@ -56,7 +56,6 @@ export const useAdminState = () => {
|
|||||||
return nextAttendance;
|
return nextAttendance;
|
||||||
};
|
};
|
||||||
|
|
||||||
// NYTT: Hantera offfline-kö för dagböcker
|
|
||||||
const applyQueueToLogs = async (baseLogs: Record<string, string>) => {
|
const applyQueueToLogs = async (baseLogs: Record<string, string>) => {
|
||||||
const queue = await localforage.getItem<any[]>('sync-queue') || [];
|
const queue = await localforage.getItem<any[]>('sync-queue') || [];
|
||||||
const nextLogs = { ...baseLogs };
|
const nextLogs = { ...baseLogs };
|
||||||
@@ -85,20 +84,57 @@ export const useAdminState = () => {
|
|||||||
const refreshData = useCallback(async (isInitialLoad = false) => {
|
const refreshData = useCallback(async (isInitialLoad = false) => {
|
||||||
if (!isInitialLoad) setIsSyncing(true);
|
if (!isInitialLoad) setIsSyncing(true);
|
||||||
|
|
||||||
|
if (isInitialLoad) {
|
||||||
|
try {
|
||||||
|
const cachedSched = await localforage.getItem<any[]>('cachedScheduleData');
|
||||||
|
const cachedData = await localforage.getItem<any[]>('cachedAdminData');
|
||||||
|
const cachedLogs = await localforage.getItem<Record<string, string>>('cachedDailyLogs');
|
||||||
|
|
||||||
|
if (cachedData) {
|
||||||
|
const { loadedPeriods, loadedAttendance } = mapDbDataToUI(cachedData);
|
||||||
|
const finalAttendance = await applyQueueToAttendance(loadedAttendance);
|
||||||
|
const finalLogs = await applyQueueToLogs(cachedLogs || {});
|
||||||
|
|
||||||
|
if (cachedSched) setScheduleData(cachedSched);
|
||||||
|
setPeriods(loadedPeriods);
|
||||||
|
setAttendance(finalAttendance);
|
||||||
|
setDailyLogs(finalLogs);
|
||||||
|
|
||||||
|
if (loadedPeriods.length > 0) {
|
||||||
|
const today = toIsoDate(new Date());
|
||||||
|
const activePeriods = loadedPeriods.filter(p => today >= p.startDate && today <= p.endDate);
|
||||||
|
if (activePeriods.length > 0) setActivePeriodId(activePeriods[0].id);
|
||||||
|
else setActivePeriodId(loadedPeriods[0].id);
|
||||||
|
}
|
||||||
|
setIsLoadingData(false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Kunde inte ladda lokal cache", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const withTimeout = <T>(promise: Promise<T>, ms: number = 5000): Promise<T> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error('NETWORK_TIMEOUT')), ms);
|
||||||
|
promise
|
||||||
|
.then(val => { clearTimeout(timer); resolve(val); })
|
||||||
|
.catch(err => { clearTimeout(timer); reject(err); });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (navigator.onLine) await flushOfflineQueue();
|
if (navigator.onLine) await flushOfflineQueue();
|
||||||
|
|
||||||
const schedRes = await readJsonFile('schedule.json');
|
const schedRes = await withTimeout(readJsonFile('schedule.json'));
|
||||||
if (schedRes.success && schedRes.data) {
|
if (schedRes.success && schedRes.data) {
|
||||||
setScheduleData(schedRes.data);
|
setScheduleData(schedRes.data);
|
||||||
await localforage.setItem('cachedScheduleData', schedRes.data);
|
await localforage.setItem('cachedScheduleData', schedRes.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbPeriods = await getAdminData();
|
const dbPeriods = await withTimeout(getAdminData());
|
||||||
await localforage.setItem('cachedAdminData', dbPeriods);
|
await localforage.setItem('cachedAdminData', dbPeriods);
|
||||||
|
|
||||||
// NYTT: Ladda in loggarna
|
const dbLogs = await withTimeout(getDailyLogsDb());
|
||||||
const dbLogs = await getDailyLogsDb();
|
|
||||||
const mappedLogs: Record<string, string> = {};
|
const mappedLogs: Record<string, string> = {};
|
||||||
dbLogs.forEach((l: { date: string | number; content: string; }) => mappedLogs[l.date] = l.content);
|
dbLogs.forEach((l: { date: string | number; content: string; }) => mappedLogs[l.date] = l.content);
|
||||||
await localforage.setItem('cachedDailyLogs', mappedLogs);
|
await localforage.setItem('cachedDailyLogs', mappedLogs);
|
||||||
@@ -113,35 +149,20 @@ export const useAdminState = () => {
|
|||||||
setAttendance(finalAttendance);
|
setAttendance(finalAttendance);
|
||||||
setDailyLogs(finalLogs);
|
setDailyLogs(finalLogs);
|
||||||
|
|
||||||
if (isInitialLoad && loadedPeriods.length > 0) {
|
if (isInitialLoad && periods.length === 0 && loadedPeriods.length > 0) {
|
||||||
const today = toIsoDate(new Date());
|
const today = toIsoDate(new Date());
|
||||||
const activePeriods = loadedPeriods.filter(p => today >= p.startDate && today <= p.endDate);
|
const activePeriods = loadedPeriods.filter(p => today >= p.startDate && today <= p.endDate);
|
||||||
if (activePeriods.length > 0) setActivePeriodId(activePeriods[0].id);
|
if (activePeriods.length > 0) setActivePeriodId(activePeriods[0].id);
|
||||||
else setActivePeriodId(loadedPeriods[0].id);
|
else setActivePeriodId(loadedPeriods[0].id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.warn("Nätverkstimeout eller offline - faller tillbaka på cache.", error);
|
||||||
setIsOffline(true);
|
setIsOffline(true);
|
||||||
const cachedSched = await localforage.getItem<any[]>('cachedScheduleData');
|
|
||||||
if (cachedSched) setScheduleData(cachedSched);
|
|
||||||
|
|
||||||
const cachedData = await localforage.getItem<any[]>('cachedAdminData');
|
|
||||||
if (cachedData) {
|
|
||||||
const { loadedPeriods, loadedAttendance } = mapDbDataToUI(cachedData);
|
|
||||||
const finalAttendance = await applyQueueToAttendance(loadedAttendance);
|
|
||||||
setPeriods(loadedPeriods);
|
|
||||||
setAttendance(finalAttendance);
|
|
||||||
|
|
||||||
const cachedLogs = await localforage.getItem<Record<string, string>>('cachedDailyLogs') || {};
|
|
||||||
const finalLogs = await applyQueueToLogs(cachedLogs);
|
|
||||||
setDailyLogs(finalLogs);
|
|
||||||
|
|
||||||
if (isInitialLoad && loadedPeriods.length > 0) setActivePeriodId(loadedPeriods[0].id);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingData(false);
|
setIsLoadingData(false);
|
||||||
setIsSyncing(false);
|
setIsSyncing(false);
|
||||||
}
|
}
|
||||||
}, [flushOfflineQueue]);
|
}, [flushOfflineQueue, periods.length]);
|
||||||
|
|
||||||
useEffect(() => { refreshData(true); }, [refreshData]);
|
useEffect(() => { refreshData(true); }, [refreshData]);
|
||||||
|
|
||||||
@@ -162,7 +183,6 @@ export const useAdminState = () => {
|
|||||||
const addToOfflineQueue = async (action: any) => {
|
const addToOfflineQueue = async (action: any) => {
|
||||||
const queue = await localforage.getItem<any[]>('sync-queue') || [];
|
const queue = await localforage.getItem<any[]>('sync-queue') || [];
|
||||||
|
|
||||||
// Undvik dubbletter för just detta datum och denna åtgärd
|
|
||||||
let filteredQueue = queue;
|
let filteredQueue = queue;
|
||||||
if (action.type === 'SET_DAILY_LOG') {
|
if (action.type === 'SET_DAILY_LOG') {
|
||||||
filteredQueue = queue.filter(q => !(q.type === 'SET_DAILY_LOG' && q.payload.date === action.payload.date));
|
filteredQueue = queue.filter(q => !(q.type === 'SET_DAILY_LOG' && q.payload.date === action.payload.date));
|
||||||
@@ -242,7 +262,6 @@ export const useAdminState = () => {
|
|||||||
else await removeAttendanceDb(date, youthId, shiftId).catch(async () => { await addToOfflineQueue({ type: 'REMOVE_ATTENDANCE', payload: { date, youthId, shiftId } }); });
|
else await removeAttendanceDb(date, youthId, shiftId).catch(async () => { await addToOfflineQueue({ type: 'REMOVE_ATTENDANCE', payload: { date, youthId, shiftId } }); });
|
||||||
};
|
};
|
||||||
|
|
||||||
// NYTT: Funktion för att spara dagbok lokalt och i databasen
|
|
||||||
const setDailyLog = async (date: string, content: string) => {
|
const setDailyLog = async (date: string, content: string) => {
|
||||||
setDailyLogs(prev => ({ ...prev, [date]: content }));
|
setDailyLogs(prev => ({ ...prev, [date]: content }));
|
||||||
|
|
||||||
@@ -257,7 +276,7 @@ export const useAdminState = () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
periods, attendance, activePeriodId, setActivePeriodId, isLoadingData, isOffline, isSyncing, scheduleData,
|
periods, attendance, activePeriodId, setActivePeriodId, isLoadingData, isOffline, isSyncing, scheduleData,
|
||||||
dailyLogs, setDailyLog, // <-- Expotera här
|
dailyLogs, setDailyLog,
|
||||||
createPeriod, deletePeriod, bulkAddYouth, removeYouth,
|
createPeriod, deletePeriod, bulkAddYouth, removeYouth,
|
||||||
setManualAttendance, bulkSetManualAttendance, addPendingAttendance, removeAttendanceEntry, refreshData
|
setManualAttendance, bulkSetManualAttendance, addPendingAttendance, removeAttendanceEntry, refreshData
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export function DocumentCard({ doc, isOffline, isCached }: DocumentCardProps) {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={`group flex items-center justify-between p-4 rounded-2xl border transition-colors ${isOffline && !isCached
|
className={`group flex items-center justify-between p-4 rounded-2xl border transition-colors ${isOffline && !isCached
|
||||||
? 'opacity-50 grayscale cursor-not-allowed pointer-events-none border-transparent bg-eggshell/50'
|
? 'opacity-50 grayscale cursor-not-allowed pointer-events-none border-transparent bg-eggshell/50'
|
||||||
: 'bg-white/50 hover:bg-white border-slate-teal/10'
|
: 'bg-white/50 hover:bg-white border-slate-teal/10'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center text-slate-teal">
|
<div className="flex items-center text-slate-teal">
|
||||||
@@ -74,8 +74,8 @@ export function ExternalLinkCard({ link, isOffline }: ExternalLinkCardProps) {
|
|||||||
target={isOffline ? '_self' : '_blank'}
|
target={isOffline ? '_self' : '_blank'}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={`group flex items-center justify-between p-4 rounded-2xl border transition-colors ${isOffline
|
className={`group flex items-center justify-between p-4 rounded-2xl border transition-colors ${isOffline
|
||||||
? 'opacity-40 grayscale cursor-not-allowed pointer-events-none bg-eggshell/50 border-transparent'
|
? 'opacity-40 grayscale cursor-not-allowed pointer-events-none bg-eggshell/50 border-transparent'
|
||||||
: 'bg-white/50 hover:bg-white border-slate-teal/10'
|
: 'bg-white/50 hover:bg-white border-slate-teal/10'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<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>
|
||||||
|
|||||||
+20
-6
@@ -3,32 +3,42 @@
|
|||||||
{
|
{
|
||||||
"title": "Turistkarta",
|
"title": "Turistkarta",
|
||||||
"icon": "Map",
|
"icon": "Map",
|
||||||
"file": "/files/Turistkarta - Kullaberg.pdf"
|
"file": "Turistkarta - Kullaberg.pdf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Orienteringskarta",
|
"title": "Orienteringskarta",
|
||||||
"icon": "MapPlus",
|
"icon": "MapPlus",
|
||||||
"file": "/files/Orienteringskarta - Kullaberg.pdf"
|
"file": "Orienteringskarta - Kullaberg.pdf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Badplatser att besöka",
|
"title": "Badplatser att besöka",
|
||||||
"icon": "WavesLadder",
|
"icon": "WavesLadder",
|
||||||
"file": "/files/Badplatser på Kullaberg.pdf"
|
"file": "Badplatser på Kullaberg.pdf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Vandringsrutter",
|
"title": "Vandringsrutter",
|
||||||
"icon": "MapPinned",
|
"icon": "MapPinned",
|
||||||
"file": "/files/Vandringsrutter.pdf"
|
"file": "Vandringsrutter.pdf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Underlag för guidediplomering",
|
"title": "Underlag för guidediplomering",
|
||||||
"icon": "BookCopy",
|
"icon": "BookCopy",
|
||||||
"file": "/files/Underlag för guidediplomering.pdf"
|
"file": "Underlag för guidediplomering.pdf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Destinationskunskap Kullahalvön",
|
"title": "Destinationskunskap Kullahalvön",
|
||||||
"icon": "BookCopy",
|
"icon": "BookCopy",
|
||||||
"file": "/files/Destinationskunskap.pdf"
|
"file": "Destinationskunskap.pdf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Tjänstgöringsrapport",
|
||||||
|
"icon": "BookCopy",
|
||||||
|
"file": "Tjänstgöringsrapport 2026.pdf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Information om ditt sommarjobb",
|
||||||
|
"icon": "BookCopy",
|
||||||
|
"file": "Information om ditt sommarjobb 2026.pdf"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": [
|
"links": [
|
||||||
@@ -48,6 +58,10 @@
|
|||||||
"title": "Skåneleden",
|
"title": "Skåneleden",
|
||||||
"url": "https://www.skaneleden.se/en"
|
"url": "https://www.skaneleden.se/en"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"title": "RSNV Brandriskprognos",
|
||||||
|
"url": "https://rsnv.se/brandriskprognos/"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"title": "Väderprognos Mölle",
|
"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"
|
"url": "https://www.smhi.se/vader/prognoser-och-varningar/vaderprognos/q/H%C3%B6gan%C3%A4s/M%C3%B6lle/2691501"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+15
-23
@@ -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,20 +19,6 @@ const IconMap: Record<string, any> = {
|
|||||||
"FileText": FileText
|
"FileText": FileText
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchFileSize = async (url: string) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, { method: 'HEAD' });
|
|
||||||
const bytes = res.headers.get('content-length');
|
|
||||||
if (bytes) {
|
|
||||||
const mb = (parseInt(bytes) / (1024 * 1024)).toFixed(2);
|
|
||||||
return `${mb} MB`;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Kunde inte hämta filstorlek för", url);
|
|
||||||
}
|
|
||||||
return "Okänd";
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Documents() {
|
export default function Documents() {
|
||||||
const [isHydrated, setIsHydrated] = useState(false);
|
const [isHydrated, setIsHydrated] = useState(false);
|
||||||
const [isOffline, setIsOffline] = useState(false);
|
const [isOffline, setIsOffline] = useState(false);
|
||||||
@@ -62,22 +49,27 @@ 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 fileNameOnly = doc.file.split('/').pop();
|
||||||
return { ...doc, size: calculatedSize };
|
const versionedUrl = `/files/${fileNameOnly}?v=${meta.version}`;
|
||||||
}
|
|
||||||
return doc;
|
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 || []);
|
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');
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// app/files/[...slug]/route.ts
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
|
const DATA_DIR = isProd ? "/app/data" : path.join(process.cwd(), "app/data");
|
||||||
|
const FILES_DIR = path.join(DATA_DIR, "files");
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ slug: string[] }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const filename = decodeURIComponent(resolvedParams.slug[resolvedParams.slug.length - 1]);
|
||||||
|
const filePath = path.join(FILES_DIR, filename);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
console.error(`404 - Filen finns inte på disk: ${filePath}`);
|
||||||
|
return new NextResponse('Filen hittades inte', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileBuffer = fs.readFileSync(filePath);
|
||||||
|
|
||||||
|
let contentType = 'application/pdf';
|
||||||
|
if (filename.toLowerCase().endsWith('.png')) contentType = 'image/png';
|
||||||
|
else if (filename.toLowerCase().endsWith('.jpg') || filename.toLowerCase().endsWith('.jpeg')) contentType = 'image/jpeg';
|
||||||
|
else if (filename.toLowerCase().endsWith('.doc') || filename.toLowerCase().endsWith('.docx')) contentType = 'application/msword';
|
||||||
|
|
||||||
|
return new NextResponse(fileBuffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Content-Disposition': `inline; filename="${filename}"`
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fel vid fildelning:", error);
|
||||||
|
return new NextResponse('Ett internt serverfel uppstod', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-31
@@ -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>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const nextConfig: NextConfig = {
|
|||||||
output: "standalone",
|
output: "standalone",
|
||||||
allowedDevOrigins: [
|
allowedDevOrigins: [
|
||||||
'10.10.0.121',
|
'10.10.0.121',
|
||||||
|
'10.11.0.122',
|
||||||
'localhost',
|
'localhost',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
// prisma.config.ts
|
// prisma.config.ts
|
||||||
|
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { defineConfig, env } from "prisma/config";
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: "prisma/schema.prisma",
|
schema: "prisma/schema.prisma",
|
||||||
|
|||||||
Reference in New Issue
Block a user