603 lines
18 KiB
TypeScript
603 lines
18 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
|
import {
|
|
AboutModal,
|
|
Lightbox,
|
|
MedDetailModal,
|
|
ProfileModal,
|
|
ShareDialog,
|
|
SharedSchedule,
|
|
UserFilterModal,
|
|
} from "./components";
|
|
import { AppHeader } from "./components/AppHeader";
|
|
import { AuthPage, AuthProvider, useAuth } from "./components/Auth";
|
|
import { AppProvider, UnsavedChangesProvider, useAppContext, useShareContext } from "./context";
|
|
import { useScrollLock } from "./hooks/useScrollLock";
|
|
import { DashboardPage, MedicationsPage, PlannerPage, SchedulePage, SettingsPage, SharedOverviewPage } from "./pages";
|
|
|
|
// Vite injects this at build time from package.json
|
|
declare const __APP_VERSION__: string;
|
|
export const FRONTEND_VERSION = typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "unknown";
|
|
const GITHUB_REPO = "DanielVolz/medassist-ng";
|
|
export const GITHUB_URL = `https://github.com/${GITHUB_REPO}`;
|
|
|
|
// =============================================================================
|
|
// Main App Wrapper with Auth
|
|
// =============================================================================
|
|
export default function App() {
|
|
return (
|
|
<AuthProvider>
|
|
<Routes>
|
|
{/* Public share route - accessible without auth */}
|
|
<Route path="/share/:token/overview" element={<SharedOverviewPage />} />
|
|
<Route path="/share/:token" element={<SharedSchedule />} />
|
|
{/* All other routes go through AppRouter */}
|
|
<Route path="*" element={<AppRouter />} />
|
|
</Routes>
|
|
</AuthProvider>
|
|
);
|
|
}
|
|
|
|
function getInitialAuthTheme(): "light" | "dark" {
|
|
if (typeof window === "undefined") return "dark";
|
|
|
|
const stored = localStorage.getItem("theme");
|
|
if (stored === "light" || stored === "dark") {
|
|
return stored;
|
|
}
|
|
|
|
if (stored === "system") {
|
|
return window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
|
}
|
|
|
|
return window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
|
}
|
|
|
|
function AppRouter() {
|
|
const { user, authState, loading, authError } = useAuth();
|
|
const authTheme = getInitialAuthTheme();
|
|
|
|
// Show loading while checking auth state
|
|
if (loading) {
|
|
return (
|
|
<div className="auth-container" data-theme={authTheme}>
|
|
<div className="auth-card" style={{ textAlign: "center" }}>
|
|
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
|
<p>Loading...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Show error if we couldn't connect to the server
|
|
if (authError) {
|
|
return (
|
|
<div className="auth-container" data-theme={authTheme}>
|
|
<div className="auth-card" style={{ textAlign: "center" }}>
|
|
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
|
<div className="auth-error" style={{ marginBottom: "1rem" }}>
|
|
<strong>Connection Error</strong>
|
|
<br />
|
|
{authError}
|
|
</div>
|
|
<p style={{ fontSize: "0.9rem", color: "var(--text-muted)" }}>
|
|
Please check if the server is running and try again.
|
|
</p>
|
|
<button className="btn btn-primary" onClick={() => window.location.reload()} style={{ marginTop: "1rem" }}>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// If auth state is null (shouldn't happen after loading, but be safe)
|
|
if (!authState) {
|
|
return (
|
|
<div className="auth-container" data-theme={authTheme}>
|
|
<div className="auth-card" style={{ textAlign: "center" }}>
|
|
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
|
<p>Initializing...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// If auth is enabled
|
|
if (authState.authEnabled) {
|
|
// Need to register first user
|
|
if (authState.needsSetup) {
|
|
return <AuthPage />;
|
|
}
|
|
// Not logged in
|
|
if (!user) {
|
|
return <AuthPage />;
|
|
}
|
|
}
|
|
|
|
// Auth disabled or user is logged in - show main app
|
|
return (
|
|
<UnsavedChangesProvider>
|
|
<AppProvider>
|
|
<AppContent />
|
|
</AppProvider>
|
|
</UnsavedChangesProvider>
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Main App Content
|
|
// =============================================================================
|
|
|
|
function AppContent() {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
// Get shared state from AppContext
|
|
const ctx = useAppContext();
|
|
const shareCtx = useShareContext();
|
|
const {
|
|
// Medications
|
|
meds,
|
|
loadMeds,
|
|
// Refill
|
|
showRefillModal,
|
|
setShowRefillModal,
|
|
refillPacks,
|
|
setRefillPacks,
|
|
refillLoose,
|
|
setRefillLoose,
|
|
usePrescriptionRefill,
|
|
setUsePrescriptionRefill,
|
|
refillSaving,
|
|
refillHistory,
|
|
refillHistoryExpanded,
|
|
setRefillHistoryExpanded,
|
|
showEditStockModal,
|
|
setShowEditStockModal,
|
|
editStockFullBlisters,
|
|
setEditStockFullBlisters,
|
|
editStockPartialBlisterPills,
|
|
setEditStockPartialBlisterPills,
|
|
editStockLoosePills,
|
|
setEditStockLoosePills,
|
|
editStockSaving,
|
|
editStockMedication,
|
|
openRefillModal,
|
|
closeRefillModal,
|
|
openEditStockModal,
|
|
closeEditStockModal,
|
|
// Computed
|
|
coverage,
|
|
// Modal state
|
|
selectedMed,
|
|
setSelectedMed,
|
|
showImageLightbox,
|
|
setShowImageLightbox,
|
|
scheduleLightboxImage,
|
|
setScheduleLightboxImage,
|
|
selectedUser,
|
|
setSelectedUser,
|
|
// Modal helpers
|
|
openMedDetail,
|
|
closeMedDetail,
|
|
openImageLightbox,
|
|
closeImageLightbox,
|
|
closeScheduleLightbox,
|
|
closeUserFilter,
|
|
} = ctx;
|
|
|
|
const {
|
|
showShareDialog,
|
|
sharePeople,
|
|
shareSelectedPerson,
|
|
setShareSelectedPerson,
|
|
shareSelectedDays,
|
|
setShareSelectedDays,
|
|
shareGenerating,
|
|
shareLink,
|
|
setShareLink,
|
|
shareCopied,
|
|
setShareCopied,
|
|
generateShareLink,
|
|
copyShareLink,
|
|
closeShareDialog,
|
|
resetShareDialogState,
|
|
} = shareCtx;
|
|
|
|
// Local-only state (not shared across components)
|
|
const [showProfile, setShowProfile] = useState(false);
|
|
const [showAbout, setShowAbout] = useState(false);
|
|
const [routeTransitionMaskActive, setRouteTransitionMaskActive] = useState(false);
|
|
const routeTransitionMinEndRef = useRef(0);
|
|
const routeTransitionFallbackTimerRef = useRef<number | null>(null);
|
|
const closeProfile = useCallback(() => {
|
|
if (showProfile) {
|
|
window.history.back();
|
|
}
|
|
}, [showProfile]);
|
|
|
|
const closeAbout = useCallback(() => {
|
|
if (showAbout) {
|
|
window.history.back();
|
|
}
|
|
}, [showAbout]);
|
|
|
|
// Get centralized stockThresholds from context
|
|
const { stockThresholds } = ctx;
|
|
|
|
// Handle browser back button to close modals (in priority order)
|
|
useEffect(() => {
|
|
const handlePopState = () => {
|
|
// Close modals in order of priority (topmost first)
|
|
// NOTE: This handler MUST NOT call history.back() or it will cause infinite loops
|
|
// Only use direct state setters here
|
|
if (showImageLightbox) {
|
|
setShowImageLightbox(false);
|
|
} else if (scheduleLightboxImage) {
|
|
setScheduleLightboxImage(null);
|
|
} else if (showEditStockModal) {
|
|
setShowEditStockModal(false);
|
|
} else if (showRefillModal) {
|
|
setShowRefillModal(false);
|
|
} else if (showShareDialog) {
|
|
resetShareDialogState();
|
|
} else if (showAbout) {
|
|
setShowAbout(false);
|
|
} else if (showProfile) {
|
|
setShowProfile(false);
|
|
} else if (selectedUser) {
|
|
setSelectedUser(null);
|
|
} else if (selectedMed) {
|
|
setSelectedMed(null);
|
|
}
|
|
};
|
|
window.addEventListener("popstate", handlePopState);
|
|
return () => window.removeEventListener("popstate", handlePopState);
|
|
}, [
|
|
selectedMed,
|
|
showImageLightbox,
|
|
scheduleLightboxImage,
|
|
selectedUser,
|
|
showProfile,
|
|
showAbout,
|
|
showShareDialog,
|
|
showRefillModal,
|
|
showEditStockModal,
|
|
resetShareDialogState,
|
|
setScheduleLightboxImage,
|
|
setSelectedMed,
|
|
setSelectedUser,
|
|
setShowEditStockModal,
|
|
setShowImageLightbox,
|
|
setShowRefillModal,
|
|
]);
|
|
|
|
// Close tooltips on scroll/touch (for mobile)
|
|
useEffect(() => {
|
|
const closeAllTooltips = () => {
|
|
document.querySelectorAll(".info-tooltip.tooltip-active, .tooltip-trigger.tooltip-active").forEach((el) => {
|
|
el.classList.remove("tooltip-active");
|
|
});
|
|
};
|
|
|
|
const handleTooltipClick = (e: Event) => {
|
|
const target = e.target as HTMLElement;
|
|
const tooltipTrigger = target.closest(".info-tooltip, .tooltip-trigger") as HTMLElement | null;
|
|
if (tooltipTrigger) {
|
|
// Close other tooltips first
|
|
closeAllTooltips();
|
|
// Toggle this one
|
|
tooltipTrigger.classList.add("tooltip-active");
|
|
// Position tooltip above the icon on mobile
|
|
if (window.innerWidth <= 640) {
|
|
const rect = tooltipTrigger.getBoundingClientRect();
|
|
// Place tooltip bottom edge just above the icon
|
|
tooltipTrigger.style.setProperty("--tooltip-bottom", `${window.innerHeight - rect.top + 8}px`);
|
|
}
|
|
} else {
|
|
closeAllTooltips();
|
|
}
|
|
};
|
|
|
|
const handleTouchMove = () => {
|
|
closeAllTooltips();
|
|
};
|
|
|
|
document.addEventListener("click", handleTooltipClick, { capture: true });
|
|
document.addEventListener("touchmove", handleTouchMove, { passive: true });
|
|
document.addEventListener("scroll", handleTouchMove, { passive: true });
|
|
return () => {
|
|
document.removeEventListener("click", handleTooltipClick, { capture: true });
|
|
document.removeEventListener("touchmove", handleTouchMove);
|
|
document.removeEventListener("scroll", handleTouchMove);
|
|
};
|
|
}, []);
|
|
|
|
// Global Escape handling in priority order.
|
|
// This keeps behavior consistent even when child modals are mocked in tests.
|
|
useEffect(() => {
|
|
const handleEscape = (e: KeyboardEvent) => {
|
|
if (e.key !== "Escape") return;
|
|
if (e.defaultPrevented) return;
|
|
|
|
if (scheduleLightboxImage) {
|
|
closeScheduleLightbox();
|
|
return;
|
|
}
|
|
if (showImageLightbox) {
|
|
closeImageLightbox();
|
|
return;
|
|
}
|
|
if (showEditStockModal) {
|
|
closeEditStockModal();
|
|
return;
|
|
}
|
|
if (showRefillModal) {
|
|
closeRefillModal();
|
|
return;
|
|
}
|
|
if (showShareDialog) {
|
|
closeShareDialog();
|
|
return;
|
|
}
|
|
if (showAbout) {
|
|
closeAbout();
|
|
return;
|
|
}
|
|
if (showProfile) {
|
|
closeProfile();
|
|
return;
|
|
}
|
|
if (selectedUser) {
|
|
closeUserFilter();
|
|
return;
|
|
}
|
|
if (selectedMed) {
|
|
closeMedDetail();
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", handleEscape);
|
|
return () => document.removeEventListener("keydown", handleEscape);
|
|
}, [
|
|
showImageLightbox,
|
|
scheduleLightboxImage,
|
|
showEditStockModal,
|
|
showRefillModal,
|
|
showShareDialog,
|
|
showAbout,
|
|
showProfile,
|
|
selectedUser,
|
|
selectedMed,
|
|
closeImageLightbox,
|
|
closeScheduleLightbox,
|
|
closeEditStockModal,
|
|
closeRefillModal,
|
|
closeShareDialog,
|
|
closeAbout,
|
|
closeProfile,
|
|
closeUserFilter,
|
|
closeMedDetail,
|
|
]);
|
|
|
|
// Prevent background scroll when any modal is open
|
|
useScrollLock(
|
|
!!(
|
|
selectedMed ||
|
|
selectedUser ||
|
|
showProfile ||
|
|
showAbout ||
|
|
showShareDialog ||
|
|
showRefillModal ||
|
|
showEditStockModal ||
|
|
showImageLightbox ||
|
|
scheduleLightboxImage
|
|
)
|
|
);
|
|
|
|
// Update selectedMed when meds change (e.g., after refill)
|
|
useEffect(() => {
|
|
if (selectedMed) {
|
|
const updated = meds.find((m) => m.id === selectedMed.id);
|
|
if (
|
|
updated &&
|
|
(updated.packCount !== selectedMed.packCount ||
|
|
updated.looseTablets !== selectedMed.looseTablets ||
|
|
updated.updatedAt !== selectedMed.updatedAt)
|
|
) {
|
|
setSelectedMed(updated);
|
|
}
|
|
}
|
|
}, [meds, selectedMed, setSelectedMed]);
|
|
|
|
const stockCorrectionMed = selectedMed ?? (showEditStockModal ? editStockMedication : null);
|
|
|
|
const handleSubmitStockCorrection = async (medId: number) => {
|
|
if (!stockCorrectionMed) return;
|
|
await ctx.submitStockCorrection(medId, stockCorrectionMed, loadMeds);
|
|
};
|
|
|
|
// For MedDetailModal: refill without form update (not editing)
|
|
const handleSubmitRefill = async (medId: number, usePrescription: boolean = false) => {
|
|
await ctx.submitRefill(medId, null, () => {}, loadMeds, usePrescription);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!routeTransitionMaskActive) return;
|
|
if (location.pathname !== "/medications") return;
|
|
|
|
const hasEditMedIdParam = new URLSearchParams(location.search).has("editMedId");
|
|
if (hasEditMedIdParam) return;
|
|
|
|
const remaining = Math.max(0, routeTransitionMinEndRef.current - performance.now());
|
|
const timer = window.setTimeout(() => setRouteTransitionMaskActive(false), remaining);
|
|
return () => window.clearTimeout(timer);
|
|
}, [location.pathname, location.search, routeTransitionMaskActive]);
|
|
|
|
useEffect(() => {
|
|
const handleEditTransitionReady = () => {
|
|
if (!routeTransitionMaskActive) return;
|
|
const remaining = Math.max(0, routeTransitionMinEndRef.current - performance.now());
|
|
window.setTimeout(() => {
|
|
setRouteTransitionMaskActive(false);
|
|
if (routeTransitionFallbackTimerRef.current !== null) {
|
|
window.clearTimeout(routeTransitionFallbackTimerRef.current);
|
|
routeTransitionFallbackTimerRef.current = null;
|
|
}
|
|
}, remaining);
|
|
};
|
|
|
|
window.addEventListener("medassist:edit-transition-ready", handleEditTransitionReady);
|
|
return () => {
|
|
window.removeEventListener("medassist:edit-transition-ready", handleEditTransitionReady);
|
|
};
|
|
}, [routeTransitionMaskActive]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (routeTransitionFallbackTimerRef.current !== null) {
|
|
window.clearTimeout(routeTransitionFallbackTimerRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const handleOpenMedicationEdit = () => {
|
|
if (!selectedMed) return;
|
|
const medId = selectedMed.id;
|
|
routeTransitionMinEndRef.current = performance.now() + 80;
|
|
setRouteTransitionMaskActive(true);
|
|
if (routeTransitionFallbackTimerRef.current !== null) {
|
|
window.clearTimeout(routeTransitionFallbackTimerRef.current);
|
|
}
|
|
routeTransitionFallbackTimerRef.current = window.setTimeout(() => {
|
|
setRouteTransitionMaskActive(false);
|
|
routeTransitionFallbackTimerRef.current = null;
|
|
}, 700);
|
|
setShowImageLightbox(false);
|
|
setShowRefillModal(false);
|
|
setShowEditStockModal(false);
|
|
setSelectedMed(null);
|
|
navigate(`/medications?editMedId=${medId}`);
|
|
};
|
|
|
|
const handleOpenEditStockFromDetail = () => {
|
|
if (!selectedMed) return;
|
|
openEditStockModal(selectedMed, coverage);
|
|
};
|
|
|
|
const openProfile = useCallback(() => {
|
|
setShowProfile(true);
|
|
window.history.pushState({ modal: "profile" }, "");
|
|
}, []);
|
|
|
|
const openAbout = useCallback(() => {
|
|
setShowAbout(true);
|
|
window.history.pushState({ modal: "about" }, "");
|
|
}, []);
|
|
|
|
return (
|
|
<main className="page">
|
|
<AppHeader onOpenProfile={openProfile} onOpenAbout={openAbout} />
|
|
|
|
{/* Profile Modal */}
|
|
<ProfileModal isOpen={showProfile} onClose={closeProfile} />
|
|
|
|
{/* About Modal */}
|
|
<AboutModal isOpen={showAbout} onClose={closeAbout} />
|
|
|
|
<Routes>
|
|
<Route path="/" element={<Navigate to={{ pathname: "/dashboard", search: location.search }} replace />} />
|
|
<Route path="/dashboard" element={<DashboardPage />} />
|
|
|
|
<Route path="/medications" element={<MedicationsPage />} />
|
|
|
|
<Route path="/planner" element={<PlannerPage />} />
|
|
|
|
<Route path="/settings" element={<SettingsPage />} />
|
|
|
|
<Route path="/schedule" element={<SchedulePage />} />
|
|
{/* Catch-all: redirect unknown routes to dashboard */}
|
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
|
</Routes>
|
|
|
|
{/* Medication Detail Modal */}
|
|
<MedDetailModal
|
|
selectedMed={stockCorrectionMed}
|
|
coverage={coverage}
|
|
settings={stockThresholds}
|
|
showImageLightbox={showImageLightbox}
|
|
showRefillModal={showRefillModal}
|
|
showEditStockModal={showEditStockModal}
|
|
editStockOnly={showEditStockModal && !selectedMed}
|
|
onClose={closeMedDetail}
|
|
onOpenImageLightbox={openImageLightbox}
|
|
onCloseImageLightbox={closeImageLightbox}
|
|
onOpenRefillModal={openRefillModal}
|
|
onCloseRefillModal={closeRefillModal}
|
|
onOpenMedicationEdit={handleOpenMedicationEdit}
|
|
onOpenEditStockModal={handleOpenEditStockFromDetail}
|
|
onCloseEditStockModal={closeEditStockModal}
|
|
refillPacks={refillPacks}
|
|
onRefillPacksChange={setRefillPacks}
|
|
refillLoose={refillLoose}
|
|
onRefillLooseChange={setRefillLoose}
|
|
usePrescriptionRefill={usePrescriptionRefill}
|
|
onUsePrescriptionRefillChange={setUsePrescriptionRefill}
|
|
refillSaving={refillSaving}
|
|
refillHistory={refillHistory}
|
|
refillHistoryExpanded={refillHistoryExpanded}
|
|
onRefillHistoryExpandedChange={setRefillHistoryExpanded}
|
|
onSubmitRefill={handleSubmitRefill}
|
|
editStockFullBlisters={editStockFullBlisters}
|
|
onEditStockFullBlistersChange={setEditStockFullBlisters}
|
|
editStockPartialBlisterPills={editStockPartialBlisterPills}
|
|
onEditStockPartialBlisterPillsChange={setEditStockPartialBlisterPills}
|
|
editStockLoosePills={editStockLoosePills}
|
|
onEditStockLoosePillsChange={setEditStockLoosePills}
|
|
editStockSaving={editStockSaving}
|
|
onSubmitStockCorrection={handleSubmitStockCorrection}
|
|
/>
|
|
|
|
{/* User Medications Modal */}
|
|
<UserFilterModal
|
|
selectedUser={selectedUser}
|
|
meds={meds}
|
|
coverage={coverage}
|
|
settings={stockThresholds}
|
|
onClose={closeUserFilter}
|
|
onClearUser={() => {
|
|
setSelectedUser(null);
|
|
// Replace the userFilter history entry so it doesn't remain on the stack
|
|
window.history.replaceState(null, "");
|
|
}}
|
|
onOpenMedDetail={openMedDetail}
|
|
/>
|
|
|
|
{/* Share Dialog Modal */}
|
|
<ShareDialog
|
|
show={showShareDialog}
|
|
sharePeople={sharePeople}
|
|
shareSelectedPerson={shareSelectedPerson}
|
|
onShareSelectedPersonChange={setShareSelectedPerson}
|
|
shareSelectedDays={shareSelectedDays}
|
|
onShareSelectedDaysChange={setShareSelectedDays}
|
|
shareGenerating={shareGenerating}
|
|
shareLink={shareLink}
|
|
onShareLinkChange={setShareLink}
|
|
shareCopied={shareCopied}
|
|
onShareCopiedChange={setShareCopied}
|
|
onClose={closeShareDialog}
|
|
onGenerateShareLink={generateShareLink}
|
|
onCopyShareLink={copyShareLink}
|
|
/>
|
|
|
|
{/* Schedule Lightbox - for clicking medication images in schedule */}
|
|
{scheduleLightboxImage && (
|
|
<Lightbox src={scheduleLightboxImage} alt="Medication" onClose={closeScheduleLightbox} />
|
|
)}
|
|
|
|
<div className={`route-transition-mask${routeTransitionMaskActive ? " active" : ""}`} aria-hidden="true" />
|
|
</main>
|
|
);
|
|
}
|