diff --git a/README.md b/README.md index ad577541..22c6c75e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol. [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) -[![Version](https://img.shields.io/badge/version-1.4.8-green.svg?logo=git&logoColor=white)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.4.9-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) diff --git a/VERSION b/VERSION index b2e46d18..4ea2b1f4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.8 +1.4.9 diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 8b724919..289002be 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -90,6 +90,9 @@ export default function CalendarPage() { const [pendingPreview, setPendingPreview] = useState(null); const [showTaskModal, setShowTaskModal] = useState(false); const [editTask, setEditTask] = useState(null); + const [mobileReturnToMonth, setMobileReturnToMonth] = useState(false); + const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null); + const [swipeKey, setSwipeKey] = useState(0); const hasFetched = useRef(false); // Sidebar resize state @@ -230,6 +233,8 @@ export default function CalendarPage() { const handleTouchEnd = useCallback((e: ReactTouchEvent) => { if (!touchStartRef.current || !isMobile) return; + // Week view has its own horizontal scroll, skip swipe navigation + if (normalizedViewMode === 'week') { touchStartRef.current = null; return; } const touch = e.changedTouches[0]; const dx = touch.clientX - touchStartRef.current.x; const dy = touch.clientY - touchStartRef.current.y; @@ -238,20 +243,34 @@ export default function CalendarPage() { // Only trigger swipe if horizontal movement is dominant and fast enough if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5 && elapsed < 400) { - if (dx > 0) navigatePrev(); - else navigateNext(); + if (dx > 0) { + setSwipeDirection('right'); + navigatePrev(); + } else { + setSwipeDirection('left'); + navigateNext(); + } + setSwipeKey(k => k + 1); + // Clear direction after animation completes + setTimeout(() => setSwipeDirection(null), 250); } - }, [isMobile, navigatePrev, navigateNext]); + }, [isMobile, normalizedViewMode, navigatePrev, navigateNext]); const handleSelectDate = useCallback((date: Date) => { setSelectedDate(date); setMiniMonth(date); // On mobile month view, tapping a date switches to day view if (isMobile && normalizedViewMode === "month") { + setMobileReturnToMonth(true); setViewMode("day"); } }, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]); + const navigateBackToMonth = useCallback(() => { + setMobileReturnToMonth(false); + setViewMode("month"); + }, [setViewMode]); + const handleMiniMonthChange = useCallback((date: Date) => { setMiniMonth(date); setSelectedDate(date); @@ -887,11 +906,12 @@ export default function CalendarPage() { onPrev={navigatePrev} onNext={navigateNext} onToday={goToToday} - onViewModeChange={setViewMode} + onViewModeChange={(mode) => { setMobileReturnToMonth(false); setViewMode(mode); }} onCreateEvent={() => openCreateModal()} onImport={() => setShowImportModal(true)} onSubscribe={() => setShowSubscriptionModal(true)} isMobile={isMobile} + onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined} calendars={calendars} selectedCalendarIds={selectedCalendarIds} onToggleVisibility={toggleCalendarVisibility} @@ -904,7 +924,16 @@ export default function CalendarPage() { onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} > - {renderView()} +
+ {renderView()} +
{/* Desktop event panel */} {!isMobile && showEventModal && ( diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index 2c232552..9eeea0aa 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -601,7 +601,7 @@ export default function ContactsPage() { )} -
+
{inlineApp && ( )} @@ -701,7 +701,7 @@ export default function ContactsPage() { className="touch-manipulation" > - {t("back_to_mail")} + {t("back_to_contacts")}
)} diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 31ec1213..c18763c5 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -16,7 +16,7 @@ import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; -const APP_VERSION = "1.4.7"; +const APP_VERSION = "1.4.9"; const THEME_OPTIONS = [ { value: "light" as const, icon: Sun, label: "Light" }, diff --git a/app/globals.css b/app/globals.css index d0ac0078..9dae64fa 100644 --- a/app/globals.css +++ b/app/globals.css @@ -529,6 +529,37 @@ body { animation: slide-in-from-left 0.3s ease-out; } +/* Calendar swipe animations (subtler slide for view transitions) */ +@keyframes cal-slide-in-right { + from { + opacity: 0; + transform: translateX(30%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes cal-slide-in-left { + from { + opacity: 0; + transform: translateX(-30%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.animate-slide-in-right { + animation: cal-slide-in-right 0.25s ease-out; +} + +.animate-slide-in-left { + animation: cal-slide-in-left 0.25s ease-out; +} + /* Reduced motion: respect user OS preference */ @media (prefers-reduced-motion: reduce) { *, diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 396959f8..7e458bfe 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -3,7 +3,7 @@ import { useState, useRef, useEffect } from "react"; import { useTranslations, useFormatter } from "next-intl"; import { Button } from "@/components/ui/button"; -import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ListTodo } from "lucide-react"; +import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft } from "lucide-react"; import { addDays, startOfWeek } from "date-fns"; import { cn } from "@/lib/utils"; import type { CalendarViewMode } from "@/stores/calendar-store"; @@ -40,6 +40,7 @@ export function CalendarToolbar({ onSubscribe, isMobile, firstDayOfWeek = 1, + onNavigateBack, calendars, selectedCalendarIds, onToggleVisibility, @@ -97,8 +98,7 @@ export function CalendarToolbar({ const [showImportDropdown, setShowImportDropdown] = useState(false); const importDropdownRef = useRef(null); - const [showViewDropdown, setShowViewDropdown] = useState(false); - const viewDropdownRef = useRef(null); + useEffect(() => { if (!showImportDropdown) return; @@ -111,109 +111,76 @@ export function CalendarToolbar({ return () => document.removeEventListener("mousedown", handleClickOutside); }, [showImportDropdown]); - useEffect(() => { - if (!showViewDropdown) return; - function handleClickOutside(e: MouseEvent) { - if (viewDropdownRef.current && !viewDropdownRef.current.contains(e.target as Node)) { - setShowViewDropdown(false); - } - } - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [showViewDropdown]); + return ( -
+
+ {/* ── MOBILE TOOLBAR ── */} {isMobile && ( -
- - - {getDateLabel()} - - -
- )} +
+ {/* Row 1: Back / Date nav / Today */} +
+ {onNavigateBack && ( + + )} + + + {getDateLabel()} + + + +
- + {/* Row 2: View switcher pills + calendar toggle */} +
+
+ {views.map((v) => ( + + ))} +
- {!isMobile && ( -
- - - - {getDateLabel()} - -
- )} - - {isMobile && calendars && selectedCalendarIds && onToggleVisibility && ( -
- - {showCalendarDropdown && ( -
-

- {t("my_calendars")} -

-
- {calendars.filter(c => !c.isShared).map((cal) => { - const isVisible = selectedCalendarIds.includes(cal.id); - const color = cal.color || "#3b82f6"; - return ( - - ); - })} -
- {(() => { - const shared = calendars.filter(c => c.isShared); - const groups = new Map(); - for (const c of shared) { - const key = c.accountId || c.accountName || c.id; - if (!groups.has(key)) groups.set(key, { accountName: c.accountName || key, cals: [] }); - groups.get(key)!.cals.push(c); - } - return Array.from(groups.values()).map((group) => ( -
-

- {group.accountName} + {calendars && selectedCalendarIds && onToggleVisibility && ( +
+ + {showCalendarDropdown && ( +
+

+ {t("my_calendars")}

- {group.cals.map((cal) => { + {calendars.filter(c => !c.isShared).map((cal) => { const isVisible = selectedCalendarIds.includes(cal.id); const color = cal.color || "#3b82f6"; return ( @@ -239,44 +206,76 @@ export function CalendarToolbar({ ); })}
+ {(() => { + const shared = calendars.filter(c => c.isShared); + const groups = new Map(); + for (const c of shared) { + const key = c.accountId || c.accountName || c.id; + if (!groups.has(key)) groups.set(key, { accountName: c.accountName || key, cals: [] }); + groups.get(key)!.cals.push(c); + } + return Array.from(groups.values()).map((group) => ( +
+

+ {group.accountName} +

+
+ {group.cals.map((cal) => { + const isVisible = selectedCalendarIds.includes(cal.id); + const color = cal.color || "#3b82f6"; + return ( + + ); + })} +
+
+ )); + })()}
- )); - })()} -
- )} + )} +

+ )} +
)} - {isMobile && ( -
- - {showViewDropdown && ( -
- {views.map((v) => ( - - ))} -
- )} + + + {getDateLabel()} +
)} + + + +
{!isMobile && ( diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index cede7e4a..1269fe96 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -53,16 +53,13 @@ export function CalendarWeekView({ const t = useTranslations("calendar"); const intlFormatter = useFormatter(); const scrollRef = useRef(null); + const rootRef = useRef(null); const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1; const weekDays = useMemo(() => { - if (isMobile) { - // Show 3-day window centered on selected date - return Array.from({ length: 3 }, (_, i) => addDays(selectedDate, i - 1)); - } const start = startOfWeek(selectedDate, { weekStartsOn: weekStart }); return Array.from({ length: 7 }, (_, i) => addDays(start, i)); - }, [selectedDate, weekStart, isMobile]); + }, [selectedDate, weekStart]); const calendarMap = useMemo(() => { const map = new Map(); @@ -137,6 +134,17 @@ export function CalendarWeekView({ const now = new Date(); scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT); } + // On mobile, scroll horizontally to center today's column + if (isMobile && rootRef.current) { + const todayIdx = weekDays.findIndex(d => isToday(d)); + if (todayIdx >= 0) { + const gutter = 40; + const colWidth = (rootRef.current.scrollWidth - gutter) / 7; + const target = gutter + todayIdx * colWidth - rootRef.current.clientWidth / 2 + colWidth / 2; + rootRef.current.scrollLeft = Math.max(0, target); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const [nowMinutes, setNowMinutes] = useState(() => { @@ -175,20 +183,28 @@ export function CalendarWeekView({ return format(new Date(2000, 0, 1, h), "HH:mm"); }; - const colCount = isMobile ? 3 : 7; + const colCount = 7; return ( -
- {hasAllDay && ( +
+
{hasAllDay && (
{t("events.all_day")}
{weekDays.map((day) => ( @@ -271,8 +287,8 @@ export function CalendarWeekView({ )}
-
-
+
+
{weekDays.map((day) => { const todayCol = isToday(day); const selected = isSameDay(day, selectedDate); @@ -307,7 +323,7 @@ export function CalendarWeekView({
-
+
{HOURS.map((h) => (
-
+
{weekDays.map((day) => { const key = format(day, "yyyy-MM-dd"); const dayEvents = timedEvents.get(key) || []; @@ -480,5 +496,6 @@ export function CalendarWeekView({
+
); } diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0beae7dc..d633ea4a 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -4635,13 +4635,13 @@ export function EmailViewer({ {/* Mobile bottom action bar */} {isMobile && ( -