fix: browser back gesture closes modal instead of navigating (#36)

* fix: browser back gesture closes modal instead of navigating

- Push history state when opening medication detail modal
- Handle popstate event to close modal on browser back
- Replace direct setSelectedMed(null) with closeMedDetail() helper
- Improves mobile UX: swiping back closes modal instead of leaving page

* feat: add back-swipe support for all modals

- Add history.pushState/popstate handling for all modal types
- Profile, ShareDialog, EditModal, RefillModal, ImageLightbox,
  ScheduleLightbox, UserFilter now all support browser back button
- Mobile users can now swipe back to close any modal instead of
  navigating away from the app
- ESC key also triggers proper history-based close for all modals
- Fix duplicate openShareDialog function
- Fix recursive call bug in openUserFilter

* fix: prevent past days count from wrapping to new line

- Add flex-wrap: nowrap to .past-days-toggle
- Add white-space: nowrap and flex-shrink: 0 to .past-days-count
- Ensures (7 Tage), (14 Tage) etc. stays on same line as label

* fix: improve schedule row layout for mobile screens

- Stack schedule label and value vertically on small screens (<400px)
- Add word-break for long text values
- Prevents 'Einnahmeprüfung' and '15 Min. vor geplanter Zeit' from overlapping

* feat: add back-swipe support for image lightbox on share page

- Add history.pushState/popstate handling for lightbox in SharedSchedule
- Mobile users can now swipe back to close image instead of navigating away
This commit is contained in:
Daniel Volz
2026-01-17 23:00:39 +01:00
committed by GitHub
parent 13c6430dee
commit 288e075786
2 changed files with 209 additions and 52 deletions
+187 -51
View File
@@ -535,26 +535,57 @@ function AppContent() {
if (userDropdownOpen) {
setUserDropdownOpen(false);
} else if (scheduleLightboxImage) {
setScheduleLightboxImage(null);
closeScheduleLightbox();
} else if (showImageLightbox) {
setShowImageLightbox(false);
closeImageLightbox();
} else if (showRefillModal) {
closeRefillModal();
} else if (showEditModal) {
setShowEditModal(false);
closeEditModal();
resetForm();
} else if (showShareDialog) {
setShowShareDialog(false);
closeShareDialog();
} else if (showProfile) {
setShowProfile(false);
closeProfile();
} else if (selectedUser) {
setSelectedUser(null);
closeUserFilter();
} else if (selectedMed) {
setSelectedMed(null);
closeMedDetail();
}
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showShareDialog, showEditModal, userDropdownOpen]);
}, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showShareDialog, showEditModal, showRefillModal, userDropdownOpen]);
// 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 (showRefillModal) {
setShowRefillModal(false);
} else if (showEditModal) {
setShowEditModal(false);
resetForm();
} else if (showShareDialog) {
resetShareDialogState();
} 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, showShareDialog, showEditModal, showRefillModal]);
// Close user dropdown when clicking outside
useEffect(() => {
@@ -860,7 +891,10 @@ function AppContent() {
// Reset refill form
setRefillPacks(1);
setRefillLoose(0);
setShowRefillModal(false);
// Close refill modal via history back for proper back-button support
if (showRefillModal) {
window.history.back();
}
// Reload medications to get updated stock
loadMeds();
// Reload refill history
@@ -878,6 +912,76 @@ function AppContent() {
setRefillHistory([]);
setRefillHistoryExpanded(false);
loadRefillHistory(med.id);
// Push history state so browser back closes modal instead of navigating
window.history.pushState({ modal: 'medDetail', medId: med.id }, '');
}
// Helper to close medication detail modal via history back
function closeMedDetail() {
if (selectedMed) {
window.history.back();
}
}
// Modal helper functions for browser back button support
function openImageLightbox() {
setShowImageLightbox(true);
window.history.pushState({ modal: 'imageLightbox' }, '');
}
function closeImageLightbox() {
if (showImageLightbox) {
window.history.back();
}
}
function openScheduleLightbox(imageUrl: string) {
setScheduleLightboxImage(imageUrl);
window.history.pushState({ modal: 'scheduleLightbox' }, '');
}
function closeScheduleLightbox() {
if (scheduleLightboxImage) {
window.history.back();
}
}
function openRefillModal() {
setShowRefillModal(true);
window.history.pushState({ modal: 'refill' }, '');
}
function closeRefillModal() {
if (showRefillModal) {
window.history.back();
}
}
function openEditModal() {
setShowEditModal(true);
window.history.pushState({ modal: 'edit' }, '');
}
function closeEditModal() {
if (showEditModal) {
window.history.back();
}
}
function openProfile() {
setShowProfile(true);
window.history.pushState({ modal: 'profile' }, '');
}
function closeProfile() {
if (showProfile) {
window.history.back();
}
}
function openUserFilter(person: string) {
setSelectedUser(person);
window.history.pushState({ modal: 'userFilter', person }, '');
}
function closeUserFilter() {
if (selectedUser) {
window.history.back();
}
}
async function testEmail() {
@@ -1159,7 +1263,7 @@ function AppContent() {
setOriginalForm(editForm);
// Show modal on mobile
if (window.innerWidth <= 768) {
setShowEditModal(true);
openEditModal();
}
}
@@ -1256,8 +1360,10 @@ function AppContent() {
if (!wasEditing) {
resetForm();
} else {
// Close modal on mobile after edit
setShowEditModal(false);
// Close modal on mobile after edit (via history back for proper back-button support)
if (showEditModal) {
window.history.back();
}
}
loadMeds();
@@ -1291,6 +1397,7 @@ function AppContent() {
// Share dialog functions
async function openShareDialog() {
setShowShareDialog(true);
window.history.pushState({ modal: 'share' }, '');
setShareLink(null);
setShareCopied(false);
setShareSelectedPerson("");
@@ -1344,6 +1451,13 @@ function AppContent() {
}
function closeShareDialog() {
if (showShareDialog) {
window.history.back();
}
}
// Internal function to reset share dialog state (called by popstate handler)
function resetShareDialogState() {
setShowShareDialog(false);
setShareLink(null);
setShareCopied(false);
@@ -1445,7 +1559,7 @@ function AppContent() {
<span className="dropdown-username">{user.username}</span>
</div>
<div className="dropdown-menu">
<button className="dropdown-item" onClick={() => { setShowProfile(true); setUserDropdownOpen(false); }}>
<button className="dropdown-item" onClick={() => { openProfile(); setUserDropdownOpen(false); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
{t('auth.profile', 'Profile')}
</button>
@@ -1466,10 +1580,10 @@ function AppContent() {
{/* Profile Modal */}
{showProfile && (
<div className="modal-overlay" onClick={() => setShowProfile(false)}>
<div className="modal-overlay" onClick={() => closeProfile()}>
<div className="modal-content profile-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={() => setShowProfile(false)}>×</button>
<UserProfile onClose={() => setShowProfile(false)} />
<button className="modal-close" onClick={() => closeProfile()}>×</button>
<UserProfile onClose={() => closeProfile()} />
</div>
</div>
)}
@@ -1544,7 +1658,7 @@ function AppContent() {
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
<span className="med-name-text">{row.name}</span>
{med?.takenBy && med.takenBy.length > 0 && med.takenBy.map((person) => (
<span key={person} className="taken-by-badge clickable" onClick={(e) => { e.stopPropagation(); setSelectedUser(person); }}>{person}</span>
<span key={person} className="taken-by-badge clickable" onClick={(e) => { e.stopPropagation(); openUserFilter(person); }}>{person}</span>
))}
{(med?.intakeRemindersEnabled || med?.notes) && (
<span className="med-icons">
@@ -1614,7 +1728,7 @@ function AppContent() {
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
<span className="med-name-text">{row.name}</span>
{med?.takenBy && med.takenBy.length > 0 && med.takenBy.map((person) => (
<span key={person} className="taken-by-badge clickable" onClick={(e) => { e.stopPropagation(); setSelectedUser(person); }}>{person}</span>
<span key={person} className="taken-by-badge clickable" onClick={(e) => { e.stopPropagation(); openUserFilter(person); }}>{person}</span>
))}
</span>
{(med?.intakeRemindersEnabled || med?.notes) && (
@@ -1739,7 +1853,7 @@ function AppContent() {
<div className="med-name">
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && setScheduleLightboxImage(`/api/images/${med.imageUrl}`)}
onClick={() => med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</div>
@@ -1763,7 +1877,7 @@ function AppContent() {
const isTaken = takenDoses.has(doseId);
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && <span className="person-name clickable" onClick={() => setSelectedUser(person)}>{person}</span>}
{person && <span className="person-name clickable" onClick={() => openUserFilter(person)}>{person}</span>}
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}></button>
) : (
@@ -1850,7 +1964,7 @@ function AppContent() {
<div className="med-name">
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && setScheduleLightboxImage(`/api/images/${med.imageUrl}`)}
onClick={() => med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</div>
@@ -1885,7 +1999,7 @@ function AppContent() {
const isTaken = takenDoses.has(doseId);
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && <span className="person-name clickable" onClick={() => setSelectedUser(person)}>{person}</span>}
{person && <span className="person-name clickable" onClick={() => openUserFilter(person)}>{person}</span>}
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}></button>
) : (
@@ -1952,7 +2066,7 @@ function AppContent() {
resetForm();
// On mobile, open the edit modal
if (window.innerWidth <= 768) {
setShowEditModal(true);
openEditModal();
}
}}
>
@@ -2900,7 +3014,7 @@ function AppContent() {
const isTaken = takenDoses.has(doseId);
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && <span className="person-name clickable" onClick={() => setSelectedUser(person)}>{person}</span>}
{person && <span className="person-name clickable" onClick={() => openUserFilter(person)}>{person}</span>}
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}></button>
) : (
@@ -2970,7 +3084,7 @@ function AppContent() {
const isOverdue = !isTaken && dose.when < now && !isPastDay;
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}>
{person && <span className="person-name clickable" onClick={() => setSelectedUser(person)}>{person}</span>}
{person && <span className="person-name clickable" onClick={() => openUserFilter(person)}>{person}</span>}
{isTaken ? (
<button className="dose-btn undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}></button>
) : (
@@ -2999,15 +3113,15 @@ function AppContent() {
{/* Medication Detail Modal */}
{selectedMed && (
<div className="modal-overlay" onClick={() => setSelectedMed(null)}>
<div className="modal-overlay" onClick={closeMedDetail}>
<div className="modal-content med-detail-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={() => setSelectedMed(null)}>×</button>
<button className="modal-close" onClick={closeMedDetail}>×</button>
<div className="med-detail-body">
<div className="med-detail-header">
<div
className={`med-detail-avatar-wrapper ${selectedMed.imageUrl ? 'clickable' : ''}`}
onClick={() => selectedMed.imageUrl && setShowImageLightbox(true)}
onClick={() => selectedMed.imageUrl && openImageLightbox()}
>
<MedicationAvatar name={selectedMed.name} imageUrl={selectedMed.imageUrl} size="lg" />
{selectedMed.imageUrl && <span className="expand-icon">🔍</span>}
@@ -3164,14 +3278,14 @@ function AppContent() {
</div>
<div className="med-detail-footer">
<button onClick={() => { setSelectedMed(null); setShowImageLightbox(false); }}>
<button onClick={closeMedDetail}>
{t('common.close')}
</button>
<div className="footer-actions">
<button className="success" onClick={() => setShowRefillModal(true)}>
<button className="success" onClick={openRefillModal}>
{t('refill.button')}
</button>
<button className="info" onClick={() => { setSelectedMed(null); setShowImageLightbox(false); navigate("/medications"); startEdit(selectedMed); }}>
<button className="info" onClick={() => { closeMedDetail(); navigate("/medications"); startEdit(selectedMed); }}>
{t('common.edit')}
</button>
{selectedMed.blisters.length > 0 && (
@@ -3185,8 +3299,8 @@ function AppContent() {
{/* Image Lightbox */}
{showImageLightbox && selectedMed.imageUrl && (
<div className="lightbox-overlay" onClick={(e) => { e.stopPropagation(); setShowImageLightbox(false); }}>
<button className="lightbox-close" onClick={(e) => { e.stopPropagation(); setShowImageLightbox(false); }}>×</button>
<div className="lightbox-overlay" onClick={(e) => { e.stopPropagation(); closeImageLightbox(); }}>
<button className="lightbox-close" onClick={(e) => { e.stopPropagation(); closeImageLightbox(); }}>×</button>
<img
src={`/api/images/${selectedMed.imageUrl}`}
alt={selectedMed.name}
@@ -3198,9 +3312,9 @@ function AppContent() {
{/* Refill Modal */}
{showRefillModal && (
<div className="modal-overlay" onClick={(e) => { e.stopPropagation(); setShowRefillModal(false); }}>
<div className="modal-overlay" onClick={(e) => { e.stopPropagation(); closeRefillModal(); }}>
<div className="modal-content refill-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={() => setShowRefillModal(false)}>×</button>
<button className="modal-close" onClick={closeRefillModal}>×</button>
<h2>{t('refill.title')}</h2>
<p className="refill-med-name">{selectedMed.name}</p>
@@ -3226,7 +3340,7 @@ function AppContent() {
</div>
<div className="modal-footer">
<button className="ghost" onClick={() => setShowRefillModal(false)}>
<button className="ghost" onClick={closeRefillModal}>
{t('common.cancel')}
</button>
<div className="refill-footer-right">
@@ -3250,9 +3364,9 @@ function AppContent() {
{/* User Medications Modal */}
{selectedUser && (
<div className="modal-overlay" onClick={() => setSelectedUser(null)}>
<div className="modal-overlay" onClick={() => closeUserFilter()}>
<div className="modal-content user-meds-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={() => setSelectedUser(null)}>×</button>
<button className="modal-close" onClick={() => closeUserFilter()}>×</button>
<div className="user-meds-header">
<div className="user-avatar">{selectedUser.charAt(0).toUpperCase()}</div>
@@ -3269,7 +3383,7 @@ function AppContent() {
<div
key={med.id}
className="user-med-item clickable"
onClick={() => { setSelectedUser(null); openMedDetail(med); }}
onClick={() => { closeUserFilter(); openMedDetail(med); }}
>
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
<div className="user-med-info">
@@ -3289,7 +3403,7 @@ function AppContent() {
</div>
<div className="user-meds-footer">
<button onClick={() => setSelectedUser(null)}>{t('common.close')}</button>
<button onClick={() => closeUserFilter()}>{t('common.close')}</button>
</div>
</div>
</div>
@@ -3373,8 +3487,8 @@ function AppContent() {
{/* Schedule Lightbox - for clicking medication images in schedule */}
{scheduleLightboxImage && (
<div className="lightbox-overlay" onClick={() => setScheduleLightboxImage(null)}>
<button className="lightbox-close" onClick={() => setScheduleLightboxImage(null)}>×</button>
<div className="lightbox-overlay" onClick={closeScheduleLightbox}>
<button className="lightbox-close" onClick={closeScheduleLightbox}>×</button>
<img
src={scheduleLightboxImage}
alt="Medication"
@@ -3386,13 +3500,13 @@ function AppContent() {
{/* Mobile Edit Modal */}
{showEditModal && (
<div className="modal-overlay" onClick={() => setShowEditModal(false)}>
<div className="modal-overlay" onClick={() => closeEditModal()}>
<div className="modal-content edit-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={() => { setShowEditModal(false); resetForm(); }}>×</button>
<button className="modal-close" onClick={() => { closeEditModal(); resetForm(); }}>×</button>
<div className="edit-modal-header">
<h2>{editingId ? t('form.editEntry') : t('form.newEntry')}</h2>
</div>
<form className="form-grid mobile-edit-form" onSubmit={(e) => { saveMedication(e); setShowEditModal(false); }}>
<form className="form-grid mobile-edit-form" onSubmit={(e) => { saveMedication(e); }}>
<label className={`full ${fieldErrors.name ? 'has-error' : ''}`}>
{t('form.commercialName')}
<input
@@ -3580,7 +3694,7 @@ function AppContent() {
</fieldset>
<div className="modal-footer">
<button type="button" className="ghost" onClick={() => { setShowEditModal(false); resetForm(); }}>
<button type="button" className="ghost" onClick={() => { closeEditModal(); resetForm(); }}>
{t('common.cancel')}
</button>
<button type="submit" disabled={saving || hasValidationErrors || (!formChanged && (formSaved || editingId))}>
@@ -4201,17 +4315,39 @@ function SharedSchedule() {
}
}
// Helper functions for lightbox with history support (mobile back swipe)
function openLightbox(url: string, name: string) {
setLightboxImage({ url, name });
window.history.pushState({ modal: 'lightbox' }, '');
}
function closeLightbox() {
if (lightboxImage) {
window.history.back();
}
}
// Close lightbox on Escape key
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && lightboxImage) {
setLightboxImage(null);
closeLightbox();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [lightboxImage]);
// Handle browser back button to close lightbox
useEffect(() => {
function handlePopState() {
if (lightboxImage) {
setLightboxImage(null);
}
}
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [lightboxImage]);
// Load taken doses from server with polling for real-time sync
useEffect(() => {
if (token) {
@@ -4625,7 +4761,7 @@ function SharedSchedule() {
<div className="med-name">
<span
className={med?.imageUrl ? 'clickable' : ''}
onClick={() => med?.imageUrl && setLightboxImage({ url: med.imageUrl, name: med.name })}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</span>
@@ -4744,7 +4880,7 @@ function SharedSchedule() {
<div className="med-name">
<span
className={med?.imageUrl ? 'clickable' : ''}
onClick={() => med?.imageUrl && setLightboxImage({ url: med.imageUrl, name: med.name })}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</span>
@@ -4811,8 +4947,8 @@ function SharedSchedule() {
{/* Image Lightbox */}
{lightboxImage && (
<div className="lightbox-overlay" onClick={() => setLightboxImage(null)}>
<button className="lightbox-close" onClick={() => setLightboxImage(null)}>×</button>
<div className="lightbox-overlay" onClick={closeLightbox}>
<button className="lightbox-close" onClick={closeLightbox}>×</button>
<img
src={`/api/images/${lightboxImage.url}`}
alt={lightboxImage.name}
+22 -1
View File
@@ -677,6 +677,7 @@ textarea.auto-resize {
.past-days-toggle {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
@@ -702,6 +703,7 @@ textarea.auto-resize {
.past-days-icon {
font-size: 0.7rem;
opacity: 0.6;
flex-shrink: 0;
}
.past-days-label {
font-weight: 500;
@@ -709,6 +711,8 @@ textarea.auto-resize {
.past-days-count {
opacity: 0.6;
font-size: 0.85rem;
white-space: nowrap;
flex-shrink: 0;
}
.past-days-warning {
margin-left: auto;
@@ -2055,7 +2059,8 @@ textarea.auto-resize {
.schedule-row {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
gap: 0.5rem;
padding: 0.35rem 0;
font-size: 0.85rem;
}
@@ -2068,11 +2073,27 @@ textarea.auto-resize {
.schedule-label {
color: var(--text-secondary);
flex-shrink: 0;
min-width: 0;
}
.schedule-value {
color: var(--text-primary);
font-weight: 500;
text-align: right;
word-break: break-word;
}
/* Mobile: stack schedule rows vertically when text is long */
@media (max-width: 400px) {
.schedule-row {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.schedule-value {
text-align: left;
}
}
/* Legacy support for old channel-btn (can remove later) */