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
+128 -129
View File
@@ -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<HTMLDivElement>(null);
const [showViewDropdown, setShowViewDropdown] = useState(false);
const viewDropdownRef = useRef<HTMLDivElement>(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 (
<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 && (
<div className="flex items-center gap-0.5">
<button onClick={onPrev} className="p-2 rounded hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</button>
<span className={cn("text-sm font-medium text-center min-w-[80px]")}>
{getDateLabel()}
</span>
<button onClick={onNext} className="p-2 rounded hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
<div className="flex flex-col gap-1 px-2 py-2">
{/* Row 1: Back / Date nav / Today */}
<div className="flex items-center gap-1">
{onNavigateBack && (
<button
onClick={onNavigateBack}
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
aria-label={t("back_to_month")}
>
<ArrowLeft className="w-4 h-4" />
</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">
{t("views.today")}
</Button>
{/* Row 2: View switcher pills + calendar toggle */}
<div className="flex items-center gap-1.5">
<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 && (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</Button>
<span className="text-base font-semibold ml-2 select-none">
{getDateLabel()}
</span>
</div>
)}
{isMobile && calendars && selectedCalendarIds && onToggleVisibility && (
<div className="relative" ref={dropdownRef}>
<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}
{calendars && selectedCalendarIds && onToggleVisibility && (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowCalendarDropdown((v) => !v)}
className={cn(
"p-1.5 rounded-md border border-border transition-colors touch-manipulation",
showCalendarDropdown ? "bg-muted" : "hover:bg-muted"
)}
aria-label={t("my_calendars")}
>
<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">
{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({
);
})}
</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>
)}
{isMobile && (
<div className="relative" ref={viewDropdownRef}>
<Button
variant="outline"
size="sm"
onClick={() => setShowViewDropdown((v) => !v)}
className="touch-manipulation capitalize text-xs"
>
{t(`views.${viewMode}`)}
<ChevronLeft className="w-3 h-3 ml-1 rotate-[-90deg]" />
{/* ── DESKTOP TOOLBAR ── */}
{!isMobile && (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</Button>
{showViewDropdown && (
<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]">
{views.map((v) => (
<button
key={v}
onClick={() => { onViewModeChange(v); setShowViewDropdown(false); }}
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>
)}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</Button>
<span className="text-base font-semibold ml-2 select-none">
{getDateLabel()}
</span>
</div>
)}
<div className="flex-1" />
{!isMobile && (
+31 -14
View File
@@ -53,16 +53,13 @@ export function CalendarWeekView({
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
const scrollRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(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<string, Calendar>();
@@ -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 (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}>
{hasAllDay && (
<div
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={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) }}
>
{t("events.all_day")}
</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) }}
>
{weekDays.map((day) => (
@@ -271,8 +287,8 @@ export function CalendarWeekView({
)}
<div className="flex border-b border-border" role="row">
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")} />
<div className={cn("flex-1 border-l border-border", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}>
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")} />
<div className="flex-1 border-l border-border grid grid-cols-7">
{weekDays.map((day) => {
const todayCol = isToday(day);
const selected = isSameDay(day, selectedDate);
@@ -307,7 +323,7 @@ export function CalendarWeekView({
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<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) => (
<div
key={h}
@@ -323,7 +339,7 @@ export function CalendarWeekView({
))}
</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) => {
const key = format(day, "yyyy-MM-dd");
const dayEvents = timedEvents.get(key) || [];
@@ -480,5 +496,6 @@ export function CalendarWeekView({
</div>
</div>
</div>
</div>
);
}
+8 -8
View File
@@ -4635,13 +4635,13 @@ export function EmailViewer({
{/* Mobile bottom action bar */}
{isMobile && (
<nav className="fixed bottom-0 left-0 right-0 z-[50] bg-background border-t border-border sm:hidden">
<div className="flex items-center justify-around">
<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 overflow-x-auto mobile-scroll-hidden">
<button
onClick={onNavigatePrev}
disabled={!onNavigatePrev}
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"
)}
aria-label={t('tooltips.previous')}
@@ -4652,7 +4652,7 @@ export function EmailViewer({
{isDraft && onEditDraft ? (
<button
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')}
>
<EditIcon className="w-5 h-5" />
@@ -4662,7 +4662,7 @@ export function EmailViewer({
<>
<button
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')}
>
<Reply className="w-5 h-5" />
@@ -4670,7 +4670,7 @@ export function EmailViewer({
</button>
<button
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')}
>
<ReplyAll className="w-5 h-5" />
@@ -4678,7 +4678,7 @@ export function EmailViewer({
</button>
<button
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')}
>
<Forward className="w-5 h-5" />
@@ -4689,7 +4689,7 @@ export function EmailViewer({
onClick={onNavigateNext}
disabled={!onNavigateNext}
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"
)}
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">
{new URL(account.serverUrl).hostname}
{(() => { try { return new URL(account.serverUrl).hostname; } catch { return account.serverUrl; } })()}
</span>
</div>
</div>
+4 -4
View File
@@ -188,7 +188,7 @@ export function NavigationRail({
if (orientation === "horizontal") {
return (
<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"
aria-label={t("nav_label")}
>
@@ -201,7 +201,7 @@ export function NavigationRail({
href={item.href}
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
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",
isActive
? "text-primary"
@@ -242,7 +242,7 @@ export function NavigationRail({
}
}}
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",
isActive
? "text-primary"
@@ -265,7 +265,7 @@ export function NavigationRail({
href="/settings"
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
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",
isSettingsActive
? "text-primary"