fix: adjust contact and calendar page for mobile layout #103

This commit is contained in:
Linus Rath
2026-03-26 16:30:20 +01:00
parent 05f848ee23
commit 9b25b6d03e
23 changed files with 272 additions and 180 deletions
+1 -1
View File
@@ -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) [![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) [![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) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
</div> </div>
+1 -1
View File
@@ -1 +1 @@
1.4.8 1.4.9
+34 -5
View File
@@ -90,6 +90,9 @@ export default function CalendarPage() {
const [pendingPreview, setPendingPreview] = useState<PendingEventPreview | null>(null); const [pendingPreview, setPendingPreview] = useState<PendingEventPreview | null>(null);
const [showTaskModal, setShowTaskModal] = useState(false); const [showTaskModal, setShowTaskModal] = useState(false);
const [editTask, setEditTask] = useState<import("@/lib/jmap/types").CalendarTask | null>(null); const [editTask, setEditTask] = useState<import("@/lib/jmap/types").CalendarTask | null>(null);
const [mobileReturnToMonth, setMobileReturnToMonth] = useState(false);
const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null);
const [swipeKey, setSwipeKey] = useState(0);
const hasFetched = useRef(false); const hasFetched = useRef(false);
// Sidebar resize state // Sidebar resize state
@@ -230,6 +233,8 @@ export default function CalendarPage() {
const handleTouchEnd = useCallback((e: ReactTouchEvent) => { const handleTouchEnd = useCallback((e: ReactTouchEvent) => {
if (!touchStartRef.current || !isMobile) return; 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 touch = e.changedTouches[0];
const dx = touch.clientX - touchStartRef.current.x; const dx = touch.clientX - touchStartRef.current.x;
const dy = touch.clientY - touchStartRef.current.y; 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 // 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 (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5 && elapsed < 400) {
if (dx > 0) navigatePrev(); if (dx > 0) {
else navigateNext(); 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) => { const handleSelectDate = useCallback((date: Date) => {
setSelectedDate(date); setSelectedDate(date);
setMiniMonth(date); setMiniMonth(date);
// On mobile month view, tapping a date switches to day view // On mobile month view, tapping a date switches to day view
if (isMobile && normalizedViewMode === "month") { if (isMobile && normalizedViewMode === "month") {
setMobileReturnToMonth(true);
setViewMode("day"); setViewMode("day");
} }
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]); }, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
const navigateBackToMonth = useCallback(() => {
setMobileReturnToMonth(false);
setViewMode("month");
}, [setViewMode]);
const handleMiniMonthChange = useCallback((date: Date) => { const handleMiniMonthChange = useCallback((date: Date) => {
setMiniMonth(date); setMiniMonth(date);
setSelectedDate(date); setSelectedDate(date);
@@ -887,11 +906,12 @@ export default function CalendarPage() {
onPrev={navigatePrev} onPrev={navigatePrev}
onNext={navigateNext} onNext={navigateNext}
onToday={goToToday} onToday={goToToday}
onViewModeChange={setViewMode} onViewModeChange={(mode) => { setMobileReturnToMonth(false); setViewMode(mode); }}
onCreateEvent={() => openCreateModal()} onCreateEvent={() => openCreateModal()}
onImport={() => setShowImportModal(true)} onImport={() => setShowImportModal(true)}
onSubscribe={() => setShowSubscriptionModal(true)} onSubscribe={() => setShowSubscriptionModal(true)}
isMobile={isMobile} isMobile={isMobile}
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
calendars={calendars} calendars={calendars}
selectedCalendarIds={selectedCalendarIds} selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility} onToggleVisibility={toggleCalendarVisibility}
@@ -904,7 +924,16 @@ export default function CalendarPage() {
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
> >
{renderView()} <div
key={swipeKey}
className={cn(
"flex flex-1 min-w-0",
isMobile && swipeDirection === 'left' && "animate-slide-in-right",
isMobile && swipeDirection === 'right' && "animate-slide-in-left",
)}
>
{renderView()}
</div>
{/* Desktop event panel */} {/* Desktop event panel */}
{!isMobile && showEventModal && ( {!isMobile && showEventModal && (
+2 -2
View File
@@ -601,7 +601,7 @@ export default function ContactsPage() {
</div> </div>
)} )}
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0 min-h-0">
{inlineApp && ( {inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} /> <InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
)} )}
@@ -701,7 +701,7 @@ export default function ContactsPage() {
className="touch-manipulation" className="touch-manipulation"
> >
<ArrowLeft className="w-4 h-4 mr-2" /> <ArrowLeft className="w-4 h-4 mr-2" />
{t("back_to_mail")} {t("back_to_contacts")}
</Button> </Button>
</div> </div>
)} )}
+1 -1
View File
@@ -16,7 +16,7 @@ import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
const APP_VERSION = "1.4.7"; const APP_VERSION = "1.4.9";
const THEME_OPTIONS = [ const THEME_OPTIONS = [
{ value: "light" as const, icon: Sun, label: "Light" }, { value: "light" as const, icon: Sun, label: "Light" },
+31
View File
@@ -529,6 +529,37 @@ body {
animation: slide-in-from-left 0.3s ease-out; 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 */ /* Reduced motion: respect user OS preference */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
+128 -129
View File
@@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { Button } from "@/components/ui/button"; 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 { addDays, startOfWeek } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store"; import type { CalendarViewMode } from "@/stores/calendar-store";
@@ -40,6 +40,7 @@ export function CalendarToolbar({
onSubscribe, onSubscribe,
isMobile, isMobile,
firstDayOfWeek = 1, firstDayOfWeek = 1,
onNavigateBack,
calendars, calendars,
selectedCalendarIds, selectedCalendarIds,
onToggleVisibility, onToggleVisibility,
@@ -97,8 +98,7 @@ export function CalendarToolbar({
const [showImportDropdown, setShowImportDropdown] = useState(false); const [showImportDropdown, setShowImportDropdown] = useState(false);
const importDropdownRef = useRef<HTMLDivElement>(null); const importDropdownRef = useRef<HTMLDivElement>(null);
const [showViewDropdown, setShowViewDropdown] = useState(false);
const viewDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!showImportDropdown) return; if (!showImportDropdown) return;
@@ -111,109 +111,76 @@ export function CalendarToolbar({
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showImportDropdown]); }, [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 ( return (
<div className={cn("flex items-center gap-1.5 px-2 py-2 border-b border-border flex-wrap", !isMobile && "px-4 py-3 gap-2")}> <div className={cn("border-b border-border", !isMobile && "flex items-center gap-2 px-4 py-3")}>
{/* ── MOBILE TOOLBAR ── */}
{isMobile && ( {isMobile && (
<div className="flex items-center gap-0.5"> <div className="flex flex-col gap-1 px-2 py-2">
<button onClick={onPrev} className="p-2 rounded hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_prev")}> {/* Row 1: Back / Date nav / Today */}
<ChevronLeft className="w-4 h-4" /> <div className="flex items-center gap-1">
</button> {onNavigateBack && (
<span className={cn("text-sm font-medium text-center min-w-[80px]")}> <button
{getDateLabel()} onClick={onNavigateBack}
</span> className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
<button onClick={onNext} className="p-2 rounded hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}> aria-label={t("back_to_month")}
<ChevronRight className="w-4 h-4" /> >
</button> <ArrowLeft className="w-4 h-4" />
</div> </button>
)} )}
<button onClick={onPrev} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-semibold text-center flex-1 select-none truncate">
{getDateLabel()}
</span>
<button onClick={onNext} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</button>
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ml-0.5">
{t("views.today")}
</Button>
</div>
<Button variant="outline" size="sm" onClick={onToday} className="touch-manipulation"> {/* Row 2: View switcher pills + calendar toggle */}
{t("views.today")} <div className="flex items-center gap-1.5">
</Button> <div className="flex flex-1 border border-border rounded-md overflow-hidden">
{views.map((v) => (
<button
key={v}
onClick={() => onViewModeChange(v)}
className={cn(
"flex-1 py-1.5 text-[11px] font-medium transition-colors touch-manipulation",
v === viewMode
? "bg-primary text-primary-foreground"
: "text-muted-foreground active:bg-muted"
)}
>
{t(`views.${v}`)}
</button>
))}
</div>
{!isMobile && ( {calendars && selectedCalendarIds && onToggleVisibility && (
<div className="flex items-center gap-1"> <div className="relative" ref={dropdownRef}>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}> <button
<ChevronLeft className="w-4 h-4" /> onClick={() => setShowCalendarDropdown((v) => !v)}
</Button> className={cn(
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}> "p-1.5 rounded-md border border-border transition-colors touch-manipulation",
<ChevronRight className="w-4 h-4" /> showCalendarDropdown ? "bg-muted" : "hover:bg-muted"
</Button> )}
<span className="text-base font-semibold ml-2 select-none"> aria-label={t("my_calendars")}
{getDateLabel()} >
</span> <CalendarDays className="w-4 h-4" />
</div> </button>
)} {showCalendarDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
{isMobile && calendars && selectedCalendarIds && onToggleVisibility && ( <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
<div className="relative" ref={dropdownRef}> {t("my_calendars")}
<Button
variant="outline"
size="sm"
onClick={() => setShowCalendarDropdown((v) => !v)}
aria-label={t("my_calendars")}
className="touch-manipulation"
>
<CalendarDays className="w-4 h-4" />
</Button>
{showCalendarDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
{t("my_calendars")}
</h3>
<div className="space-y-0.5">
{calendars.filter(c => !c.isShared).map((cal) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6";
return (
<button
key={cal.id}
onClick={() => onToggleVisibility(cal.id)}
className={cn(
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
"hover:bg-muted"
)}
>
<span
className={cn(
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
</button>
);
})}
</div>
{(() => {
const shared = calendars.filter(c => c.isShared);
const groups = new Map<string, { accountName: string; cals: typeof shared }>();
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) => (
<div key={group.accountName} className="mt-2">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
{group.accountName}
</h3> </h3>
<div className="space-y-0.5"> <div className="space-y-0.5">
{group.cals.map((cal) => { {calendars.filter(c => !c.isShared).map((cal) => {
const isVisible = selectedCalendarIds.includes(cal.id); const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6"; const color = cal.color || "#3b82f6";
return ( return (
@@ -239,44 +206,76 @@ export function CalendarToolbar({
); );
})} })}
</div> </div>
{(() => {
const shared = calendars.filter(c => c.isShared);
const groups = new Map<string, { accountName: string; cals: typeof shared }>();
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) => (
<div key={group.accountName} className="mt-2">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
{group.accountName}
</h3>
<div className="space-y-0.5">
{group.cals.map((cal) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6";
return (
<button
key={cal.id}
onClick={() => onToggleVisibility(cal.id)}
className={cn(
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
"hover:bg-muted"
)}
>
<span
className={cn(
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
</button>
);
})}
</div>
</div>
));
})()}
</div> </div>
)); )}
})()} </div>
</div> )}
)} </div>
</div> </div>
)} )}
{isMobile && ( {/* ── DESKTOP TOOLBAR ── */}
<div className="relative" ref={viewDropdownRef}> {!isMobile && (
<Button <div className="flex items-center gap-1">
variant="outline" <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
size="sm" <ChevronLeft className="w-4 h-4" />
onClick={() => setShowViewDropdown((v) => !v)}
className="touch-manipulation capitalize text-xs"
>
{t(`views.${viewMode}`)}
<ChevronLeft className="w-3 h-3 ml-1 rotate-[-90deg]" />
</Button> </Button>
{showViewDropdown && ( <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-1 min-w-[120px]"> <ChevronRight className="w-4 h-4" />
{views.map((v) => ( </Button>
<button <span className="text-base font-semibold ml-2 select-none">
key={v} {getDateLabel()}
onClick={() => { onViewModeChange(v); setShowViewDropdown(false); }} </span>
className={cn(
"flex items-center w-full px-3 py-2 rounded-md text-sm transition-colors touch-manipulation",
v === viewMode ? "bg-primary text-primary-foreground" : "hover:bg-muted text-foreground"
)}
>
{t(`views.${v}`)}
</button>
))}
</div>
)}
</div> </div>
)} )}
<div className="flex-1" /> <div className="flex-1" />
{!isMobile && ( {!isMobile && (
+31 -14
View File
@@ -53,16 +53,13 @@ export function CalendarWeekView({
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const intlFormatter = useFormatter(); const intlFormatter = useFormatter();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1; const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
const weekDays = useMemo(() => { 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 }); const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
return Array.from({ length: 7 }, (_, i) => addDays(start, i)); return Array.from({ length: 7 }, (_, i) => addDays(start, i));
}, [selectedDate, weekStart, isMobile]); }, [selectedDate, weekStart]);
const calendarMap = useMemo(() => { const calendarMap = useMemo(() => {
const map = new Map<string, Calendar>(); const map = new Map<string, Calendar>();
@@ -137,6 +134,17 @@ export function CalendarWeekView({
const now = new Date(); const now = new Date();
scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT); 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(() => { const [nowMinutes, setNowMinutes] = useState(() => {
@@ -175,20 +183,28 @@ export function CalendarWeekView({
return format(new Date(2000, 0, 1, h), "HH:mm"); return format(new Date(2000, 0, 1, h), "HH:mm");
}; };
const colCount = isMobile ? 3 : 7; const colCount = 7;
return ( return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}> <div
{hasAllDay && ( ref={rootRef}
className={cn(
"flex flex-col flex-1",
isMobile ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden"
)}
role="grid"
aria-label={t("views.week")}
>
<div className={cn("flex flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
<div className="flex border-b border-border"> <div className="flex border-b border-border">
<div <div
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10" : "w-14")} className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }} style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
> >
{t("events.all_day")} {t("events.all_day")}
</div> </div>
<div <div
className={cn("flex-1 relative grid gap-px bg-border", isMobile ? "grid-cols-3" : "grid-cols-7")} className="flex-1 relative grid gap-px bg-border grid-cols-7"
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }} style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
> >
{weekDays.map((day) => ( {weekDays.map((day) => (
@@ -271,8 +287,8 @@ export function CalendarWeekView({
)} )}
<div className="flex border-b border-border" role="row"> <div className="flex border-b border-border" role="row">
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")} /> <div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")} />
<div className={cn("flex-1 border-l border-border", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}> <div className="flex-1 border-l border-border grid grid-cols-7">
{weekDays.map((day) => { {weekDays.map((day) => {
const todayCol = isToday(day); const todayCol = isToday(day);
const selected = isSameDay(day, selectedDate); const selected = isSameDay(day, selectedDate);
@@ -307,7 +323,7 @@ export function CalendarWeekView({
<div ref={scrollRef} className="flex-1 overflow-y-auto"> <div ref={scrollRef} className="flex-1 overflow-y-auto">
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}> <div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")}> <div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}>
{HOURS.map((h) => ( {HOURS.map((h) => (
<div <div
key={h} key={h}
@@ -323,7 +339,7 @@ export function CalendarWeekView({
))} ))}
</div> </div>
<div className={cn("flex-1 border-l border-border relative", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}> <div className="flex-1 border-l border-border relative grid grid-cols-7">
{weekDays.map((day) => { {weekDays.map((day) => {
const key = format(day, "yyyy-MM-dd"); const key = format(day, "yyyy-MM-dd");
const dayEvents = timedEvents.get(key) || []; const dayEvents = timedEvents.get(key) || [];
@@ -480,5 +496,6 @@ export function CalendarWeekView({
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }
+8 -8
View File
@@ -4635,13 +4635,13 @@ export function EmailViewer({
{/* Mobile bottom action bar */} {/* Mobile bottom action bar */}
{isMobile && ( {isMobile && (
<nav className="fixed bottom-0 left-0 right-0 z-[50] bg-background border-t border-border sm:hidden"> <nav className="fixed bottom-0 left-0 right-0 z-[50] bg-background border-t border-border sm:hidden overflow-hidden">
<div className="flex items-center justify-around"> <div className="flex items-center overflow-x-auto mobile-scroll-hidden">
<button <button
onClick={onNavigatePrev} onClick={onNavigatePrev}
disabled={!onNavigatePrev} disabled={!onNavigatePrev}
className={cn( className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] transition-colors duration-150", "flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150",
onNavigatePrev ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30" onNavigatePrev ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
)} )}
aria-label={t('tooltips.previous')} aria-label={t('tooltips.previous')}
@@ -4652,7 +4652,7 @@ export function EmailViewer({
{isDraft && onEditDraft ? ( {isDraft && onEditDraft ? (
<button <button
onClick={onEditDraft} onClick={onEditDraft}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-primary active:text-primary/80 transition-colors duration-150" className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-primary active:text-primary/80 transition-colors duration-150"
aria-label={t('tooltips.edit_draft')} aria-label={t('tooltips.edit_draft')}
> >
<EditIcon className="w-5 h-5" /> <EditIcon className="w-5 h-5" />
@@ -4662,7 +4662,7 @@ export function EmailViewer({
<> <>
<button <button
onClick={() => onReply?.()} onClick={() => onReply?.()}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-muted-foreground active:text-foreground transition-colors duration-150" className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.reply')} aria-label={t('tooltips.reply')}
> >
<Reply className="w-5 h-5" /> <Reply className="w-5 h-5" />
@@ -4670,7 +4670,7 @@ export function EmailViewer({
</button> </button>
<button <button
onClick={onReplyAll} onClick={onReplyAll}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-muted-foreground active:text-foreground transition-colors duration-150" className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.reply_all')} aria-label={t('tooltips.reply_all')}
> >
<ReplyAll className="w-5 h-5" /> <ReplyAll className="w-5 h-5" />
@@ -4678,7 +4678,7 @@ export function EmailViewer({
</button> </button>
<button <button
onClick={onForward} onClick={onForward}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-muted-foreground active:text-foreground transition-colors duration-150" className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.forward')} aria-label={t('tooltips.forward')}
> >
<Forward className="w-5 h-5" /> <Forward className="w-5 h-5" />
@@ -4689,7 +4689,7 @@ export function EmailViewer({
onClick={onNavigateNext} onClick={onNavigateNext}
disabled={!onNavigateNext} disabled={!onNavigateNext}
className={cn( className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] transition-colors duration-150", "flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150",
onNavigateNext ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30" onNavigateNext ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
)} )}
aria-label={t('tooltips.next')} aria-label={t('tooltips.next')}
+1 -1
View File
@@ -207,7 +207,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
)} /> )} />
)} )}
<span className="text-[10px] text-muted-foreground truncate"> <span className="text-[10px] text-muted-foreground truncate">
{new URL(account.serverUrl).hostname} {(() => { try { return new URL(account.serverUrl).hostname; } catch { return account.serverUrl; } })()}
</span> </span>
</div> </div>
</div> </div>
+4 -4
View File
@@ -188,7 +188,7 @@ export function NavigationRail({
if (orientation === "horizontal") { if (orientation === "horizontal") {
return ( return (
<nav <nav
className={cn("flex items-center justify-around bg-background border-t border-border shrink-0", className)} className={cn("flex items-center bg-background border-t border-border shrink-0 overflow-x-auto mobile-scroll-hidden", className)}
role="navigation" role="navigation"
aria-label={t("nav_label")} aria-label={t("nav_label")}
> >
@@ -201,7 +201,7 @@ export function NavigationRail({
href={item.href} href={item.href}
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined} onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
className={cn( className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]", "flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
"transition-colors duration-150", "transition-colors duration-150",
isActive isActive
? "text-primary" ? "text-primary"
@@ -242,7 +242,7 @@ export function NavigationRail({
} }
}} }}
className={cn( className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]", "flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
"transition-colors duration-150", "transition-colors duration-150",
isActive isActive
? "text-primary" ? "text-primary"
@@ -265,7 +265,7 @@ export function NavigationRail({
href="/settings" href="/settings"
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined} onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
className={cn( className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]", "flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
"transition-colors duration-150", "transition-colors duration-150",
isSettingsActive isSettingsActive
? "text-primary" ? "text-primary"
+9 -2
View File
@@ -7,8 +7,15 @@
/** Generate a unique, deterministic account ID from username and server URL */ /** Generate a unique, deterministic account ID from username and server URL */
export function generateAccountId(username: string, serverUrl: string): string { export function generateAccountId(username: string, serverUrl: string): string {
const host = new URL(serverUrl).hostname; try {
return `${username}@${host}`; const host = new URL(serverUrl).hostname;
return `${username}@${host}`;
} catch {
// Relative URL (e.g. /api/dev-jmap) use current origin as base
const base = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
const host = new URL(serverUrl, base).hostname;
return `${username}@${host}`;
}
} }
/** Deterministic avatar/accent color from an email string */ /** Deterministic avatar/accent color from an email string */
+2 -1
View File
@@ -1576,7 +1576,7 @@
"delete_confirm_title": "Kontakt löschen", "delete_confirm_title": "Kontakt löschen",
"delete_confirm": "Möchten Sie diesen Kontakt wirklich löschen?", "delete_confirm": "Möchten Sie diesen Kontakt wirklich löschen?",
"local_mode": "Kontakte werden lokal gespeichert (Server unterstützt kein JMAP Contacts)", "local_mode": "Kontakte werden lokal gespeichert (Server unterstützt kein JMAP Contacts)",
"back_to_mail": "Zurück zur E-Mail", "back_to_contacts": "Zurück zu Kontakten",
"tabs": { "tabs": {
"all": "Alle", "all": "Alle",
"groups": "Gruppen" "groups": "Gruppen"
@@ -1803,6 +1803,7 @@
"calendar": { "calendar": {
"title": "Kalender", "title": "Kalender",
"back_to_email": "Zurück zu E-Mails", "back_to_email": "Zurück zu E-Mails",
"back_to_month": "Zurück zur Monatsansicht",
"my_calendars": "Meine Kalender", "my_calendars": "Meine Kalender",
"mini_calendar_change": "Klicken, um den Monat zu wechseln", "mini_calendar_change": "Klicken, um den Monat zu wechseln",
"views": { "views": {
+2 -1
View File
@@ -1584,7 +1584,7 @@
"delete_confirm_title": "Delete contact", "delete_confirm_title": "Delete contact",
"delete_confirm": "Are you sure you want to delete this contact?", "delete_confirm": "Are you sure you want to delete this contact?",
"local_mode": "Contacts are stored locally (server does not support JMAP Contacts)", "local_mode": "Contacts are stored locally (server does not support JMAP Contacts)",
"back_to_mail": "Back to mail", "back_to_contacts": "Back to contacts",
"tabs": { "tabs": {
"all": "All", "all": "All",
"groups": "Groups" "groups": "Groups"
@@ -1811,6 +1811,7 @@
"calendar": { "calendar": {
"title": "Calendar", "title": "Calendar",
"back_to_email": "Back to email", "back_to_email": "Back to email",
"back_to_month": "Back to month",
"my_calendars": "Calendars", "my_calendars": "Calendars",
"mini_calendar_change": "Click to change month", "mini_calendar_change": "Click to change month",
"views": { "views": {
+2 -1
View File
@@ -1576,7 +1576,7 @@
"delete_confirm_title": "Eliminar contacto", "delete_confirm_title": "Eliminar contacto",
"delete_confirm": "¿Estás seguro de que quieres eliminar este contacto?", "delete_confirm": "¿Estás seguro de que quieres eliminar este contacto?",
"local_mode": "Los contactos se almacenan localmente (el servidor no soporta JMAP Contacts)", "local_mode": "Los contactos se almacenan localmente (el servidor no soporta JMAP Contacts)",
"back_to_mail": "Volver al correo", "back_to_contacts": "Volver a contactos",
"tabs": { "tabs": {
"all": "Todos", "all": "Todos",
"groups": "Grupos" "groups": "Grupos"
@@ -1803,6 +1803,7 @@
"calendar": { "calendar": {
"title": "Calendario", "title": "Calendario",
"back_to_email": "Volver al correo", "back_to_email": "Volver al correo",
"back_to_month": "Volver al mes",
"my_calendars": "Mis calendarios", "my_calendars": "Mis calendarios",
"mini_calendar_change": "Clic para cambiar de mes", "mini_calendar_change": "Clic para cambiar de mes",
"views": { "views": {
+2 -1
View File
@@ -1576,7 +1576,7 @@
"delete_confirm_title": "Supprimer le contact", "delete_confirm_title": "Supprimer le contact",
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ce contact ?", "delete_confirm": "Êtes-vous sûr de vouloir supprimer ce contact ?",
"local_mode": "Les contacts sont stockés localement (le serveur ne prend pas en charge JMAP Contacts)", "local_mode": "Les contacts sont stockés localement (le serveur ne prend pas en charge JMAP Contacts)",
"back_to_mail": "Retour aux e-mails", "back_to_contacts": "Retour aux contacts",
"tabs": { "tabs": {
"all": "Tous", "all": "Tous",
"groups": "Groupes" "groups": "Groupes"
@@ -1803,6 +1803,7 @@
"calendar": { "calendar": {
"title": "Calendrier", "title": "Calendrier",
"back_to_email": "Retour aux e-mails", "back_to_email": "Retour aux e-mails",
"back_to_month": "Retour au mois",
"my_calendars": "Mes calendriers", "my_calendars": "Mes calendriers",
"mini_calendar_change": "Cliquer pour changer de mois", "mini_calendar_change": "Cliquer pour changer de mois",
"views": { "views": {
+2 -1
View File
@@ -1576,7 +1576,7 @@
"delete_confirm_title": "Elimina contatto", "delete_confirm_title": "Elimina contatto",
"delete_confirm": "Sei sicuro di voler eliminare questo contatto?", "delete_confirm": "Sei sicuro di voler eliminare questo contatto?",
"local_mode": "I contatti sono salvati localmente (il server non supporta JMAP Contacts)", "local_mode": "I contatti sono salvati localmente (il server non supporta JMAP Contacts)",
"back_to_mail": "Torna alla posta", "back_to_contacts": "Torna ai contatti",
"tabs": { "tabs": {
"all": "Tutti", "all": "Tutti",
"groups": "Gruppi" "groups": "Gruppi"
@@ -1803,6 +1803,7 @@
"calendar": { "calendar": {
"title": "Calendario", "title": "Calendario",
"back_to_email": "Torna alla posta", "back_to_email": "Torna alla posta",
"back_to_month": "Torna al mese",
"my_calendars": "I miei calendari", "my_calendars": "I miei calendari",
"mini_calendar_change": "Clicca per cambiare mese", "mini_calendar_change": "Clicca per cambiare mese",
"views": { "views": {
+2 -1
View File
@@ -1576,7 +1576,7 @@
"delete_confirm_title": "連絡先を削除", "delete_confirm_title": "連絡先を削除",
"delete_confirm": "この連絡先を削除してもよろしいですか?", "delete_confirm": "この連絡先を削除してもよろしいですか?",
"local_mode": "連絡先はローカルに保存されています(サーバーがJMAPコンタクトをサポートしていません)", "local_mode": "連絡先はローカルに保存されています(サーバーがJMAPコンタクトをサポートしていません)",
"back_to_mail": "メールに戻る", "back_to_contacts": "連絡先に戻る",
"tabs": { "tabs": {
"all": "すべて", "all": "すべて",
"groups": "グループ" "groups": "グループ"
@@ -1803,6 +1803,7 @@
"calendar": { "calendar": {
"title": "カレンダー", "title": "カレンダー",
"back_to_email": "メールに戻る", "back_to_email": "メールに戻る",
"back_to_month": "月表示に戻る",
"my_calendars": "マイカレンダー", "my_calendars": "マイカレンダー",
"mini_calendar_change": "クリックで月を変更", "mini_calendar_change": "クリックで月を変更",
"views": { "views": {
+2 -1
View File
@@ -1576,7 +1576,7 @@
"delete_confirm_title": "Contact verwijderen", "delete_confirm_title": "Contact verwijderen",
"delete_confirm": "Weet u zeker dat u dit contact wilt verwijderen?", "delete_confirm": "Weet u zeker dat u dit contact wilt verwijderen?",
"local_mode": "Contacten worden lokaal opgeslagen (server ondersteunt geen JMAP Contacts)", "local_mode": "Contacten worden lokaal opgeslagen (server ondersteunt geen JMAP Contacts)",
"back_to_mail": "Terug naar e-mail", "back_to_contacts": "Terug naar contacten",
"tabs": { "tabs": {
"all": "Alle", "all": "Alle",
"groups": "Groepen" "groups": "Groepen"
@@ -1803,6 +1803,7 @@
"calendar": { "calendar": {
"title": "Agenda", "title": "Agenda",
"back_to_email": "Terug naar e-mail", "back_to_email": "Terug naar e-mail",
"back_to_month": "Terug naar maand",
"my_calendars": "Mijn agenda's", "my_calendars": "Mijn agenda's",
"mini_calendar_change": "Klik om van maand te wisselen", "mini_calendar_change": "Klik om van maand te wisselen",
"views": { "views": {
+2 -1
View File
@@ -1576,7 +1576,7 @@
"delete_confirm_title": "Excluir contato", "delete_confirm_title": "Excluir contato",
"delete_confirm": "Tem certeza de que deseja excluir este contato?", "delete_confirm": "Tem certeza de que deseja excluir este contato?",
"local_mode": "Os contatos são armazenados localmente (o servidor não suporta JMAP Contacts)", "local_mode": "Os contatos são armazenados localmente (o servidor não suporta JMAP Contacts)",
"back_to_mail": "Voltar ao e-mail", "back_to_contacts": "Voltar aos contatos",
"tabs": { "tabs": {
"all": "Todos", "all": "Todos",
"groups": "Grupos" "groups": "Grupos"
@@ -1803,6 +1803,7 @@
"calendar": { "calendar": {
"title": "Calendário", "title": "Calendário",
"back_to_email": "Voltar ao e-mail", "back_to_email": "Voltar ao e-mail",
"back_to_month": "Voltar ao mês",
"my_calendars": "Meus calendários", "my_calendars": "Meus calendários",
"mini_calendar_change": "Clique para mudar o mês", "mini_calendar_change": "Clique para mudar o mês",
"views": { "views": {
+2 -1
View File
@@ -1576,7 +1576,7 @@
"delete_confirm_title": "Удалить контакт", "delete_confirm_title": "Удалить контакт",
"delete_confirm": "Вы уверены, что хотите удалить этот контакт?", "delete_confirm": "Вы уверены, что хотите удалить этот контакт?",
"local_mode": "Контакты хранятся локально (сервер не поддерживает JMAP Contacts)", "local_mode": "Контакты хранятся локально (сервер не поддерживает JMAP Contacts)",
"back_to_mail": "Вернуться к почте", "back_to_contacts": "Вернуться к контактам",
"tabs": { "tabs": {
"all": "Все", "all": "Все",
"groups": "Группы" "groups": "Группы"
@@ -1803,6 +1803,7 @@
"calendar": { "calendar": {
"title": "Календарь", "title": "Календарь",
"back_to_email": "Вернуться к почте", "back_to_email": "Вернуться к почте",
"back_to_month": "Вернуться к месяцу",
"my_calendars": "Календари", "my_calendars": "Календари",
"mini_calendar_change": "Нажмите для смены месяца", "mini_calendar_change": "Нажмите для смены месяца",
"views": { "views": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.4.8", "version": "1.4.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.4.8", "version": "1.4.9",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@tanstack/react-virtual": "^3.13.18", "@tanstack/react-virtual": "^3.13.18",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "bulwark-webmail", "name": "bulwark-webmail",
"version": "1.4.8", "version": "1.4.9",
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server", "description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>", "author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",