diff --git a/.dockerignore b/.dockerignore index 157b9b4..b6200cb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,10 @@ + +# Ignore the heavy PDFs during build +public/files/* + +# (Optional) Keep a placeholder file so the directory structure isn't completely lost locally +!public/files/.gitkeep + # Dependency directories node_modules npm-debug.log diff --git a/.gitignore b/.gitignore index cf1bcbb..00e79b8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ generated/ *.db-journal *.sqlite *.sqlite3 -data/ + # PWA / Serwist generated files public/sw.js diff --git a/Dockerfile b/Dockerfile index bce14d4..db8cb76 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,7 @@ ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 RUN mkdir -p data +RUN mkdir -p public/files COPY --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ diff --git a/app/actions/jsonEditor.ts b/app/actions/jsonEditor.ts new file mode 100644 index 0000000..26c5506 --- /dev/null +++ b/app/actions/jsonEditor.ts @@ -0,0 +1,33 @@ +// app/actions/jsonEditor.ts + +"use server"; + +import fs 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"); + +export async function readJsonFile(filename: string) { + try { + const filePath = path.join(DATA_DIR, filename); + const fileContent = await fs.readFile(filePath, 'utf-8'); + return { success: true, data: JSON.parse(fileContent) }; + } catch (error) { + console.error(`Kunde inte läsa ${filename}:`, error); + return { success: false, data: null }; + } +} + +export async function writeJsonFile(filename: string, data: any) { + try { + const filePath = path.join(DATA_DIR, filename); + await fs.mkdir(DATA_DIR, { recursive: true }); + + await fs.writeFile(filePath, JSON.stringify(data, null, 4), 'utf-8'); + return { success: true }; + } catch (error) { + console.error(`Kunde inte spara ${filename}:`, error); + return { success: false, error: "Kunde inte spara filen." }; + } +} \ No newline at end of file diff --git a/app/admin/AttendanceTab.tsx b/app/admin/AttendanceTab.tsx index 05ec251..fef803a 100644 --- a/app/admin/AttendanceTab.tsx +++ b/app/admin/AttendanceTab.tsx @@ -1,10 +1,8 @@ // app/admin/AttendanceTab.tsx - "use client"; import { CalendarRange, CheckCircle, ChevronLeft, ChevronRight } from 'lucide-react'; import React, { useEffect, useState } from 'react'; -import scheduleData from '../data/schedule.json'; import { type AttendanceDataMap, getAttendanceKey, type Period, toIsoDate } from './adminTypes'; import { TeamAttendanceCard } from './TeamAttendanceCard'; @@ -15,6 +13,7 @@ interface Props { addPendingAttendance: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void; removeAttendanceEntry: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void; activePeriodId: string; setActivePeriodId: (id: string) => void; + scheduleData: any[]; } export const getTeamShiftInfo = (daySchedule: any, team: 'PF' | 'TU') => { @@ -33,7 +32,7 @@ const getDaysInPeriod = (start: string, end: string) => { return days; }; -const getDailyCompletionStats = (date: string, period: Period, attendance: AttendanceDataMap) => { +const getDailyCompletionStats = (date: string, period: Period, attendance: AttendanceDataMap, scheduleData: any[]) => { const dayNameStr = new Date(date + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase(); const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr); if (!daySchedule) return { expected: 0, completed: 0, isComplete: true, hasWork: false }; @@ -52,7 +51,7 @@ const getDailyCompletionStats = (date: string, period: Period, attendance: Atten return { expected, completed, isComplete: expected > 0 && completed >= expected, hasWork }; }; -export const AttendanceTab: React.FC = ({ periods, attendance, setManualAttendance, bulkSetManualAttendance, addPendingAttendance, removeAttendanceEntry, activePeriodId, setActivePeriodId }) => { +export const AttendanceTab: React.FC = ({ periods, attendance, setManualAttendance, bulkSetManualAttendance, addPendingAttendance, removeAttendanceEntry, activePeriodId, setActivePeriodId, scheduleData }) => { const activePeriod = periods.find(p => p.id === activePeriodId); const [currentDate, setCurrentDate] = useState(activePeriod ? activePeriod.startDate : toIsoDate(new Date())); @@ -76,7 +75,7 @@ export const AttendanceTab: React.FC = ({ periods, attendance, setManualA const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr.toLowerCase()); const timelineDays = getDaysInPeriod(activePeriod.startDate, activePeriod.endDate); const todayIso = toIsoDate(new Date()); - const currentDayStats = getDailyCompletionStats(currentDate, activePeriod, attendance); + const currentDayStats = getDailyCompletionStats(currentDate, activePeriod, attendance, scheduleData); const teamsToRender = ['PF', 'TU'].sort((a, b) => { const timeA = getTeamShiftInfo(daySchedule, a as 'PF' | 'TU').time; @@ -99,7 +98,7 @@ export const AttendanceTab: React.FC = ({ periods, attendance, setManualA
{timelineDays.map(day => { - const stats = getDailyCompletionStats(day, activePeriod, attendance); + const stats = getDailyCompletionStats(day, activePeriod, attendance, scheduleData); const isSelected = day === currentDate; let bgClass = "bg-white text-ebony border-slate-teal/10"; if (!stats.hasWork) bgClass = "bg-slate-teal/5 text-ebony/40 border-transparent"; diff --git a/app/admin/ReportTab.tsx b/app/admin/ReportTab.tsx index c446c6f..dbae17f 100644 --- a/app/admin/ReportTab.tsx +++ b/app/admin/ReportTab.tsx @@ -9,11 +9,12 @@ import { YouthReportCard } from './YouthReportCard'; interface Props { periods: Period[]; attendance: AttendanceDataMap; activePeriodId: string; setActivePeriodId: (id: string) => void; currentUserRole: Role; + scheduleData: any[]; } const POT_HOUR_LIMIT = 90; -export const ReportTab: React.FC = ({ periods, attendance, activePeriodId, setActivePeriodId, currentUserRole }) => { +export const ReportTab: React.FC = ({ periods, attendance, activePeriodId, setActivePeriodId, currentUserRole, scheduleData }) => { const activePeriod = periods.find(p => p.id === activePeriodId); const [expandedYouthId, setExpandedYouthId] = useState(null); @@ -109,6 +110,7 @@ export const ReportTab: React.FC = ({ periods, attendance, activePeriodId isExpanded={expandedYouthId === youth.id} onToggle={() => setExpandedYouthId(expandedYouthId === youth.id ? null : youth.id)} timeline={getTimelineForYouth(youth.id)} + scheduleData={scheduleData} /> ))}
diff --git a/app/admin/YouthReportCard.tsx b/app/admin/YouthReportCard.tsx index 87c4efa..9c7835f 100644 --- a/app/admin/YouthReportCard.tsx +++ b/app/admin/YouthReportCard.tsx @@ -7,7 +7,6 @@ import Image from 'next/image'; import React from 'react'; import falconIcon from '../assets/falcon.svg'; import porpoiseIcon from '../assets/porpoise.svg'; -import scheduleData from '../data/schedule.json'; import { formatTimeHHMM, isWeekend, type Youth } from './adminTypes'; import { getTeamShiftInfo } from './AttendanceTab'; @@ -18,6 +17,7 @@ interface YouthReportCardProps { isExpanded: boolean; onToggle: () => void; timeline: any[]; + scheduleData: any[]; } const formatHours = (h: number) => Number(h.toFixed(1)).toString(); @@ -28,7 +28,8 @@ export const YouthReportCard: React.FC = ({ potHourLimit, isExpanded, onToggle, - timeline + timeline, + scheduleData }) => { const warningStatus = totalHours > potHourLimit ? 'red' : (totalHours >= potHourLimit - 10 ? 'yellow' : 'none'); @@ -96,23 +97,17 @@ export const YouthReportCard: React.FC = ({ const shiftLabel = entry.shiftId === 'MORNING' ? 'Morgon' : 'Eftermiddag'; return ( -
+
{entry.date} | {capitalizedShortDay} - - {/* 1. Shift Label */} {!isWknd && ( {shiftLabel} )} - - {/* 2. Weekend Tag */} {isWknd && Helg} - - {/* 3. Extra Tag (Moved to end, made bolder) */} {isExtra && ( Extra diff --git a/app/admin/page.tsx b/app/admin/page.tsx index a574b6a..88d6a02 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -142,7 +142,7 @@ export default function Admin() {
- {adminState.isLoadingData && adminState.periods.length === 0 ? ( + {adminState.isLoadingData && (adminState.periods.length === 0 || adminState.scheduleData.length === 0) ? (

Hämtar data...

@@ -174,6 +174,7 @@ export default function Admin() { removeAttendanceEntry={adminState.removeAttendanceEntry} activePeriodId={adminState.activePeriodId} setActivePeriodId={adminState.setActivePeriodId} + scheduleData={adminState.scheduleData} /> )} {activeTab === 'report' && ( @@ -183,6 +184,7 @@ export default function Admin() { activePeriodId={adminState.activePeriodId} setActivePeriodId={adminState.setActivePeriodId} currentUserRole={currentUser.role} + scheduleData={adminState.scheduleData} /> )} diff --git a/app/admin/useAdminState.ts b/app/admin/useAdminState.ts index e5f3ae4..308ed65 100644 --- a/app/admin/useAdminState.ts +++ b/app/admin/useAdminState.ts @@ -2,20 +2,21 @@ "use client"; -import { useState, useEffect, useCallback } from 'react'; import localforage from 'localforage'; -import { Period, AttendanceDataMap, Youth, toIsoDate, isWeekend, getAttendanceKey } from './adminTypes'; -import { getAdminData, createPeriodDb, deletePeriodDb, bulkAddYouthDb, removeYouthDb, setAttendanceDb, removeAttendanceDb, syncOfflineQueueDb, bulkSetAttendanceDb } from '../actions/admin'; +import { useCallback, useEffect, useState } from 'react'; +import { bulkAddYouthDb, bulkSetAttendanceDb, createPeriodDb, deletePeriodDb, getAdminData, removeAttendanceDb, removeYouthDb, setAttendanceDb, syncOfflineQueueDb } from '../actions/admin'; +import { readJsonFile } from '../actions/jsonEditor'; +import { AttendanceDataMap, Period, Youth, getAttendanceKey, isWeekend, toIsoDate } from './adminTypes'; export const useAdminState = () => { const [periods, setPeriods] = useState([]); const [attendance, setAttendance] = useState({}); + const [scheduleData, setScheduleData] = useState([]); const [activePeriodId, setActivePeriodId] = useState(''); const [isLoadingData, setIsLoadingData] = useState(true); const [isOffline, setIsOffline] = useState(false); const [isSyncing, setIsSyncing] = useState(false); - // --- 1. DATA MAPPING --- const mapDbDataToUI = (dbPeriods: any[]) => { const loadedPeriods: Period[] = []; const loadedAttendance: AttendanceDataMap = {}; @@ -44,9 +45,7 @@ export const useAdminState = () => { for (const action of queue) { if (action.type === 'SET_ATTENDANCE') { const { date, youthId, shiftId, hoursWorked, weightedHours, status, note } = action.payload; - nextAttendance[getAttendanceKey(date, youthId, shiftId)] = { - date, youthId, shiftId, hoursWorked, weightedHours, status, note - }; + nextAttendance[getAttendanceKey(date, youthId, shiftId)] = { date, youthId, shiftId, hoursWorked, weightedHours, status, note }; } else if (action.type === 'REMOVE_ATTENDANCE') { const { date, youthId, shiftId } = action.payload; delete nextAttendance[getAttendanceKey(date, youthId, shiftId)]; @@ -55,7 +54,6 @@ export const useAdminState = () => { return nextAttendance; }; - // --- 2. OFFLINE QUEUE FLUSHER --- const flushOfflineQueue = useCallback(async () => { try { const queue = await localforage.getItem('sync-queue') || []; @@ -70,13 +68,17 @@ export const useAdminState = () => { return false; }, []); - // --- 3. MAIN DATA FETCHING --- const refreshData = useCallback(async (isInitialLoad = false) => { if (!isInitialLoad) setIsSyncing(true); try { - if (navigator.onLine) { - await flushOfflineQueue(); + if (navigator.onLine) await flushOfflineQueue(); + + // NYTT: Hämta schemat via filsystemet + const schedRes = await readJsonFile('schedule.json'); + if (schedRes.success && schedRes.data) { + setScheduleData(schedRes.data); + await localforage.setItem('cachedScheduleData', schedRes.data); } const dbPeriods = await getAdminData(); @@ -92,24 +94,20 @@ export const useAdminState = () => { if (isInitialLoad && 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); - } + if (activePeriods.length > 0) setActivePeriodId(activePeriods[0].id); + else setActivePeriodId(loadedPeriods[0].id); } } catch (error) { setIsOffline(true); - const cachedData = await localforage.getItem('cachedAdminData'); + const cachedSched = await localforage.getItem('cachedScheduleData'); + if (cachedSched) setScheduleData(cachedSched); + const cachedData = await localforage.getItem('cachedAdminData'); if (cachedData) { const { loadedPeriods, loadedAttendance } = mapDbDataToUI(cachedData); const finalAttendance = await applyQueueToAttendance(loadedAttendance); - setPeriods(loadedPeriods); setAttendance(finalAttendance); - if (isInitialLoad && loadedPeriods.length > 0) setActivePeriodId(loadedPeriods[0].id); } } finally { @@ -118,62 +116,30 @@ export const useAdminState = () => { } }, [flushOfflineQueue]); - useEffect(() => { - refreshData(true); - }, [refreshData]); + useEffect(() => { refreshData(true); }, [refreshData]); useEffect(() => { - const interval = setInterval(() => { - if (navigator.onLine) refreshData(); - }, 10000); - - const handleVisibilityChange = () => { - if (document.visibilityState === 'visible' && navigator.onLine) { - refreshData(); - } - }; - + const interval = setInterval(() => { if (navigator.onLine) refreshData(); }, 10000); + const handleVisibilityChange = () => { if (document.visibilityState === 'visible' && navigator.onLine) refreshData(); }; document.addEventListener('visibilitychange', handleVisibilityChange); window.addEventListener('focus', handleVisibilityChange); - - return () => { - clearInterval(interval); - document.removeEventListener('visibilitychange', handleVisibilityChange); - window.removeEventListener('focus', handleVisibilityChange); - }; + return () => { clearInterval(interval); document.removeEventListener('visibilitychange', handleVisibilityChange); window.removeEventListener('focus', handleVisibilityChange); }; }, [refreshData]); - // --- 4. OFFLINE/ONLINE EVENT LISTENERS --- useEffect(() => { - const handleOnline = async () => { - setIsOffline(false); - const didSync = await flushOfflineQueue(); - if (didSync) refreshData(); - }; - - window.addEventListener('online', handleOnline); - window.addEventListener('offline', () => setIsOffline(true)); - - return () => { - window.removeEventListener('online', handleOnline); - window.removeEventListener('offline', () => setIsOffline(true)); - }; + const handleOnline = async () => { setIsOffline(false); const didSync = await flushOfflineQueue(); if (didSync) refreshData(); }; + window.addEventListener('online', handleOnline); window.addEventListener('offline', () => setIsOffline(true)); + return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', () => setIsOffline(true)); }; }, [flushOfflineQueue, refreshData]); const addToOfflineQueue = async (action: any) => { const queue = await localforage.getItem('sync-queue') || []; - const filteredQueue = queue.filter(q => - !(q.payload.date === action.payload.date && - q.payload.youthId === action.payload.youthId && - q.payload.shiftId === action.payload.shiftId) - ); - + const filteredQueue = queue.filter(q => !(q.payload.date === action.payload.date && q.payload.youthId === action.payload.youthId && q.payload.shiftId === action.payload.shiftId)); filteredQueue.push(action); await localforage.setItem('sync-queue', filteredQueue); setIsOffline(true); }; - // --- 5. DATABASE WRITE ACTIONS --- const createPeriod = async (name: string, startDateStr: string) => { setIsLoadingData(true); const start = new Date(startDateStr + 'T12:00:00'); @@ -183,11 +149,7 @@ export const useAdminState = () => { setActivePeriodId(newPeriod.id); }; - const deletePeriod = async (periodId: string) => { - setPeriods(periods.filter(p => p.id !== periodId)); - await deletePeriodDb(periodId); - await refreshData(); - }; + const deletePeriod = async (periodId: string) => { setPeriods(periods.filter(p => p.id !== periodId)); await deletePeriodDb(periodId); await refreshData(); }; const bulkAddYouth = async (periodId: string, text: string, defaultTeam: 'PF' | 'TU') => { setIsLoadingData(true); @@ -199,89 +161,55 @@ export const useAdminState = () => { await refreshData(); }; - const removeYouth = async (periodId: string, youthId: string) => { - setPeriods(periods.map(p => p.id === periodId ? { ...p, youthList: p.youthList.filter(y => y.id !== youthId) } : p)); - await removeYouthDb(youthId); - await refreshData(); - }; + const removeYouth = async (periodId: string, youthId: string) => { setPeriods(periods.map(p => p.id === periodId ? { ...p, youthList: p.youthList.filter(y => y.id !== youthId) } : p)); await removeYouthDb(youthId); await refreshData(); }; - // --- 6. ATTENDANCE ACTIONS --- const setManualAttendance = async (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON', status: 'Absent' | 'Late' | 'Present', hoursManual: number = 0, note?: string) => { const weight = isWeekend(date) ? 1.5 : 1.0; let weightedHours = status === 'Late' || status === 'Present' ? hoursManual * weight : 0; let hoursWorked = status === 'Late' || status === 'Present' ? hoursManual : 0; const noteStr = note || ''; - setAttendance(prev => ({ - ...prev, [getAttendanceKey(date, youthId, shiftId)]: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: noteStr } - })); + setAttendance(prev => ({ ...prev, [getAttendanceKey(date, youthId, shiftId)]: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: noteStr } })); - if (!navigator.onLine) { - await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: noteStr } }); - } else { - await setAttendanceDb(date, youthId, shiftId, hoursWorked, weightedHours, status, noteStr).catch(async () => { - await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: noteStr } }); - }); - } + if (!navigator.onLine) await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: noteStr } }); + else await setAttendanceDb(date, youthId, shiftId, hoursWorked, weightedHours, status, noteStr).catch(async () => { await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: noteStr } }); }); }; const bulkSetManualAttendance = async (records: { date: string; youthId: string; shiftId: 'MORNING' | 'AFTERNOON'; hoursWorked: number; weightedHours: number; status: 'Absent' | 'Late' | 'Present'; note: string }[]) => { - setAttendance(prev => { - const next = { ...prev }; - records.forEach(record => { - next[getAttendanceKey(record.date, record.youthId, record.shiftId)] = record; - }); - return next; - }); + setAttendance(prev => { const next = { ...prev }; records.forEach(record => { next[getAttendanceKey(record.date, record.youthId, record.shiftId)] = record; }); return next; }); const processOfflineQueue = async () => { const queue = await localforage.getItem('sync-queue') || []; records.forEach(record => { const idx = queue.findIndex(q => q.payload.date === record.date && q.payload.youthId === record.youthId && q.payload.shiftId === record.shiftId); const action = { type: 'SET_ATTENDANCE', payload: record }; - if (idx > -1) queue[idx] = action; - else queue.push(action); + if (idx > -1) queue[idx] = action; else queue.push(action); }); await localforage.setItem('sync-queue', queue); setIsOffline(true); }; - if (!navigator.onLine) { - await processOfflineQueue(); - } else { - await bulkSetAttendanceDb(records).catch(async () => { - await processOfflineQueue(); - }); - } + if (!navigator.onLine) await processOfflineQueue(); + else await bulkSetAttendanceDb(records).catch(async () => { await processOfflineQueue(); }); }; const addPendingAttendance = async (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => { setAttendance(prev => ({ ...prev, [getAttendanceKey(date, youthId, shiftId)]: { date, youthId, shiftId, hoursWorked: 0, weightedHours: 0, status: 'Pending', note: '' } })); - if (!navigator.onLine) { - await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked: 0, weightedHours: 0, status: 'Pending', note: '' } }); - } else { - await setAttendanceDb(date, youthId, shiftId, 0, 0, 'Pending', '').catch(async () => { - await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked: 0, weightedHours: 0, status: 'Pending', note: '' } }); - }); - } + if (!navigator.onLine) await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked: 0, weightedHours: 0, status: 'Pending', note: '' } }); + else await setAttendanceDb(date, youthId, shiftId, 0, 0, 'Pending', '').catch(async () => { await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked: 0, weightedHours: 0, status: 'Pending', note: '' } }); }); }; const removeAttendanceEntry = async (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => { setAttendance(prev => { const next = { ...prev }; delete next[getAttendanceKey(date, youthId, shiftId)]; return next; }); - if (!navigator.onLine) { - 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 } }); - }); - } + if (!navigator.onLine) 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 } }); }); }; return { - periods, attendance, activePeriodId, setActivePeriodId, isLoadingData, isOffline, isSyncing, + periods, attendance, activePeriodId, setActivePeriodId, isLoadingData, isOffline, isSyncing, scheduleData, createPeriod, deletePeriod, bulkAddYouth, removeYouth, - setManualAttendance, bulkSetManualAttendance, addPendingAttendance, removeAttendanceEntry + setManualAttendance, bulkSetManualAttendance, addPendingAttendance, removeAttendanceEntry, refreshData }; }; \ No newline at end of file diff --git a/app/data/documents.json b/app/data/documents.json new file mode 100644 index 0000000..b0ea946 --- /dev/null +++ b/app/data/documents.json @@ -0,0 +1,62 @@ +{ + "docs": [ + { + "title": "Turistkarta", + "icon": "Map", + "file": "/files/Turistkarta - Kullaberg.pdf", + "size": "2.51 MB" + }, + { + "title": "Orienteringskarta", + "icon": "MapPlus", + "file": "/files/Orienteringskarta - Kullaberg.pdf", + "size": "3.74 MB" + }, + { + "title": "Badplatser att besöka", + "icon": "WavesLadder", + "file": "/files/Badplatser på Kullaberg.pdf", + "size": "1.88 MB" + }, + { + "title": "Vandringsrutter", + "icon": "MapPinned", + "file": "/files/Vandringsrutter.pdf", + "size": "4.42 MB" + }, + { + "title": "Underlag för guidediplomering", + "icon": "BookCopy", + "file": "/files/Underlag för guidediplomering.pdf", + "size": "3.18 MB" + }, + { + "title": "Destinationskunskap Kullahalvön", + "icon": "BookCopy", + "file": "/files/Destinationskunskap.pdf", + "size": "1.97 MB" + } + ], + "links": [ + { + "title": "Kullabergs Naturreservat", + "url": "https://www.kullabergsnatur.se/" + }, + { + "title": "Vandra på Kullahalvön", + "url": "https://www.kullahalvon.com/upptacka--uppleva/friluftsliv--natur/vandra-pa-kullahalvon.html" + }, + { + "title": "Naturkartan", + "url": "https://www.naturkartan.se/en/explore" + }, + { + "title": "Skåneleden", + "url": "https://www.skaneleden.se/en" + }, + { + "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" + } + ] +} \ No newline at end of file diff --git a/app/documents/page.tsx b/app/documents/page.tsx index 2f14e12..0f0be9f 100644 --- a/app/documents/page.tsx +++ b/app/documents/page.tsx @@ -2,52 +2,69 @@ "use client"; -import { BookCopy, CheckCircle, CloudDownload, Folder, Loader2, Map, MapPinned, MapPlus, WavesLadder } from 'lucide-react'; +import { BookCopy, CheckCircle, CloudDownload, FileText, Folder, Loader2, Map, MapPinned, MapPlus, WavesLadder } from 'lucide-react'; import { useEffect, useState } from 'react'; -import { DocumentCard, ExternalLinkCard, DocumentItem, LinkItem } from '../components/ui/Cards'; +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'; +const IconMap: Record = { + "Map": Map, + "MapPlus": MapPlus, + "WavesLadder": WavesLadder, + "MapPinned": MapPinned, + "BookCopy": BookCopy, + "FileText": FileText +}; + 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>(new Set()); const [showNotification, setShowNotification] = useState(false); + const [docs, setDocs] = useState([]); + const [links, setLinks] = useState([]); + const [isLoadingData, setIsLoadingData] = useState(true); - const docs: DocumentItem[] = [ - { title: 'Turistkarta', icon: , file: '/files/Turistkarta - Kullaberg.pdf', size: '2.51 MB' }, - { title: 'Orienteringskarta', icon: , file: '/files/Orienteringskarta - Kullaberg.pdf', size: '3.74 MB' }, - { title: 'Badplatser att besöka', icon: , file: '/files/Badplatser på Kullaberg.pdf', size: '1.88 MB' }, - { title: 'Vandringsrutter', icon: , file: '/files/Vandringsrutter.pdf', size: '4.42 MB' }, - { title: 'Underlag för guidediplomering', icon: , file: '/files/Underlag för guidediplomering.pdf', size: '3.18 MB' }, - { title: 'Destinationskunskap Kullahalvön', icon: , file: '/files/Destinationskunskap.pdf', size: '1.97 MB' } - ]; - - const links: LinkItem[] = [ - { title: 'Kullabergs Naturreservat', url: 'https://www.kullabergsnatur.se/' }, - { title: 'Vandra på Kullahalvön', url: 'https://www.kullahalvon.com/upptacka--uppleva/friluftsliv--natur/vandra-pa-kullahalvon.html' }, - { title: 'Naturkartan', url: 'https://www.naturkartan.se/en/explore' }, - { title: 'Skåneleden', url: 'https://www.skaneleden.se/en' }, - { 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 checkCacheStatus = async (currentDocs: DocumentItem[]) => { + if (!('caches' in window) || currentDocs.length === 0) return; const cached = new Set(); try { - for (const doc of docs) { + for (const doc of currentDocs) { const response = await caches.match(doc.file); if (response) cached.add(doc.file); } setCachedFiles(cached); - if (cached.size === docs.length) setSyncStatus('done'); + if (cached.size === currentDocs.length) setSyncStatus('done'); else setSyncStatus('idle'); } catch (error) { console.error("Cache check failed", error); } }; + const loadDynamicData = async () => { + if (navigator.onLine) { + const res = await readJsonFile('documents.json'); + if (res.success && res.data) { + setDocs(res.data.docs || []); + setLinks(res.data.links || []); + localStorage.setItem('kullaberg_documents_cache', JSON.stringify(res.data)); + checkCacheStatus(res.data.docs || []); + } + } else { + const cachedData = localStorage.getItem('kullaberg_documents_cache'); + if (cachedData) { + const parsed = JSON.parse(cachedData); + setDocs(parsed.docs || []); + setLinks(parsed.links || []); + checkCacheStatus(parsed.docs || []); + } + } + setIsLoadingData(false); + }; + useEffect(() => { setIsHydrated(true); setIsOffline(!navigator.onLine); @@ -56,13 +73,13 @@ export default function Documents() { window.addEventListener('online', handleStatus); window.addEventListener('offline', handleStatus); - checkCacheStatus(); + loadDynamicData(); const handleVisibility = () => { - if (document.visibilityState === 'visible') checkCacheStatus(); + if (document.visibilityState === 'visible') checkCacheStatus(docs); }; document.addEventListener('visibilitychange', handleVisibility); - window.addEventListener('focus', checkCacheStatus); + window.addEventListener('focus', () => checkCacheStatus(docs)); const conn = (navigator as any).connection; if (conn && (conn.type === 'wifi' || conn.type === 'ethernet') && !conn.saveData) { @@ -73,11 +90,12 @@ export default function Documents() { window.removeEventListener('online', handleStatus); window.removeEventListener('offline', handleStatus); document.removeEventListener('visibilitychange', handleVisibility); - window.removeEventListener('focus', checkCacheStatus); + window.removeEventListener('focus', () => checkCacheStatus(docs)); }; }, []); const handleSyncAll = async () => { + if (docs.length === 0) return; setSyncStatus('syncing'); const updatedCache = new Set(cachedFiles); @@ -96,13 +114,13 @@ export default function Documents() { } } - await checkCacheStatus(); + await checkCacheStatus(docs); setSyncStatus('done'); setShowNotification(true); setTimeout(() => setShowNotification(false), 4000); }; - if (!isHydrated) return
; + if (!isHydrated || isLoadingData) return
; return (
@@ -113,7 +131,7 @@ export default function Documents() { actions={ <> {isOffline && } - {!isOffline && syncStatus !== 'done' && ( + {!isOffline && syncStatus !== 'done' && docs.length > 0 && (