feat: enhance mobile responsiveness across calendar and contact components
- Added `isMobile` prop to `CalendarWeekView`, `EventDetailPopover`, `ContactDetail`, `ContactGroupDetail`, and `EmailComposer` components for improved mobile layout. - Adjusted layout and styles in `CalendarWeekView` to display a 3-day view on mobile. - Updated `EventDetailPopover` to adapt its size and layout for mobile devices. - Modified `ContactDetail` and `ContactGroupDetail` to adjust padding and font sizes based on mobile view. - Enhanced `EmailComposer` with mobile-friendly header and field layouts, including auto-saving draft functionality. - Added confirmation dialog for unsaved changes when closing the email composer. - Updated localization files to include new strings for draft management.
This commit is contained in:
@@ -17,6 +17,7 @@ interface CalendarDayViewProps {
|
||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||
timeFormat?: "12h" | "24h";
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
const HOUR_HEIGHT = 64;
|
||||
@@ -29,6 +30,7 @@ export function CalendarDayView({
|
||||
onSelectEvent,
|
||||
onCreateAtTime,
|
||||
timeFormat = "24h",
|
||||
isMobile,
|
||||
}: CalendarDayViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
@@ -110,9 +112,12 @@ export function CalendarDayView({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<h3 className={cn("text-lg font-semibold", today && "text-primary")}>
|
||||
{intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}
|
||||
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
|
||||
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
|
||||
{isMobile
|
||||
? intlFormatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" })
|
||||
: intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })
|
||||
}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -138,15 +143,15 @@ export function CalendarDayView({
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
||||
<div className="w-16 flex-shrink-0">
|
||||
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
|
||||
{HOURS.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="relative text-muted-foreground text-right pr-3"
|
||||
className="relative text-muted-foreground text-right pr-2"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
>
|
||||
{h > 0 && (
|
||||
<span className="absolute top-0 right-3 -translate-y-1/2 text-xs leading-none">
|
||||
<span className={cn("absolute top-0 right-2 -translate-y-1/2 leading-none", isMobile ? "text-[10px]" : "text-xs")}>
|
||||
{formatHour(h)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface CalendarMonthViewProps {
|
||||
onSelectDate: (date: Date) => void;
|
||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
firstDayOfWeek?: number;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export function CalendarMonthView({
|
||||
@@ -30,6 +31,7 @@ export function CalendarMonthView({
|
||||
onSelectDate,
|
||||
onSelectEvent,
|
||||
firstDayOfWeek = 1,
|
||||
isMobile,
|
||||
}: CalendarMonthViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
@@ -125,22 +127,28 @@ export function CalendarMonthView({
|
||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { month: "long", year: "numeric" })}>
|
||||
<div className="grid grid-cols-7 border-b border-border" role="row">
|
||||
{dayHeaders.map((d) => (
|
||||
<div key={d} role="columnheader" className="text-center text-xs font-medium text-muted-foreground py-2 border-r border-border last:border-r-0">
|
||||
{t(`days.${d}`)}
|
||||
<div key={d} role="columnheader" className={cn(
|
||||
"text-center text-xs font-medium text-muted-foreground py-2 border-r border-border last:border-r-0",
|
||||
isMobile && "py-1.5 text-[11px]"
|
||||
)}>
|
||||
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
{weeks.map((week, wi) => (
|
||||
<div key={wi} className="grid grid-cols-7 flex-1 min-h-[100px] border-b border-border last:border-b-0" role="row">
|
||||
<div key={wi} className={cn(
|
||||
"grid grid-cols-7 flex-1 border-b border-border last:border-b-0",
|
||||
isMobile ? "min-h-[52px]" : "min-h-[100px]"
|
||||
)} role="row">
|
||||
{week.map((day) => {
|
||||
const inMonth = isSameMonth(day, selectedDate);
|
||||
const selected = isSameDay(day, selectedDate);
|
||||
const today = isToday(day);
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const dayEvents = eventsByDate.get(key) || [];
|
||||
const maxVisible = 3;
|
||||
const maxVisible = isMobile ? 0 : 3;
|
||||
const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
||||
|
||||
return (
|
||||
@@ -154,16 +162,18 @@ export function CalendarMonthView({
|
||||
onDragLeave={handleCellDragLeave}
|
||||
onDrop={(e) => handleCellDrop(e, day)}
|
||||
className={cn(
|
||||
"border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors",
|
||||
"border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors touch-manipulation",
|
||||
!inMonth && "bg-muted/30",
|
||||
"hover:bg-muted/50",
|
||||
selected && isMobile && "bg-primary/10",
|
||||
dropDayKey === key && "ring-2 ring-inset ring-primary bg-primary/10"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center mb-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center w-6 h-6 text-xs rounded-full",
|
||||
"inline-flex items-center justify-center rounded-full",
|
||||
isMobile ? "w-7 h-7 text-xs" : "w-6 h-6 text-xs",
|
||||
today && !selected && "bg-primary text-primary-foreground font-bold",
|
||||
selected && "bg-primary text-primary-foreground font-bold",
|
||||
!inMonth && !selected && !today && "text-muted-foreground/50",
|
||||
@@ -173,26 +183,48 @@ export function CalendarMonthView({
|
||||
{format(day, "d")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{dayEvents.slice(0, maxVisible).map((ev) => {
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
return (
|
||||
<EventCard
|
||||
key={ev.id}
|
||||
event={ev}
|
||||
calendar={calendarMap.get(calId)}
|
||||
variant="chip"
|
||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||
draggable
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{dayEvents.length > maxVisible && (
|
||||
<div className="text-[10px] text-muted-foreground px-1">
|
||||
{t("events.more", { count: dayEvents.length - maxVisible })}
|
||||
{isMobile ? (
|
||||
dayEvents.length > 0 && (
|
||||
<div className="flex items-center justify-center gap-0.5 flex-wrap">
|
||||
{dayEvents.slice(0, 3).map((ev) => {
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
const cal = calendarMap.get(calId);
|
||||
const evColor = ev.color || cal?.color || "#3b82f6";
|
||||
return (
|
||||
<span
|
||||
key={ev.id}
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: evColor }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{dayEvents.length > 3 && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{dayEvents.slice(0, maxVisible).map((ev) => {
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
return (
|
||||
<EventCard
|
||||
key={ev.id}
|
||||
event={ev}
|
||||
calendar={calendarMap.get(calId)}
|
||||
variant="chip"
|
||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||
draggable
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{dayEvents.length > maxVisible && (
|
||||
<div className="text-[10px] text-muted-foreground px-1">
|
||||
{t("events.more", { count: dayEvents.length - maxVisible })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight, Plus, Upload } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays } from "lucide-react";
|
||||
import { addDays, startOfWeek } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
|
||||
interface CalendarToolbarProps {
|
||||
selectedDate: Date;
|
||||
@@ -19,6 +21,9 @@ interface CalendarToolbarProps {
|
||||
isMobile?: boolean;
|
||||
firstDayOfWeek?: number;
|
||||
onNavigateBack?: () => void;
|
||||
calendars?: Calendar[];
|
||||
selectedCalendarIds?: string[];
|
||||
onToggleVisibility?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function CalendarToolbar({
|
||||
@@ -32,18 +37,39 @@ export function CalendarToolbar({
|
||||
onImport,
|
||||
isMobile,
|
||||
firstDayOfWeek = 1,
|
||||
calendars,
|
||||
selectedCalendarIds,
|
||||
onToggleVisibility,
|
||||
}: CalendarToolbarProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const formatter = useFormatter();
|
||||
const views: CalendarViewMode[] = ["month", "week", "day", "agenda"];
|
||||
const [showCalendarDropdown, setShowCalendarDropdown] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCalendarDropdown) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setShowCalendarDropdown(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [showCalendarDropdown]);
|
||||
|
||||
const getDateLabel = (): string => {
|
||||
switch (viewMode) {
|
||||
case "month":
|
||||
return formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
||||
return isMobile
|
||||
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
||||
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
||||
case "week": {
|
||||
const ws = startOfWeek(selectedDate, { weekStartsOn: firstDayOfWeek as 0 | 1 });
|
||||
const we = addDays(ws, 6);
|
||||
if (isMobile) {
|
||||
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { day: "numeric" })}`;
|
||||
}
|
||||
const sameMonth = ws.getMonth() === we.getMonth();
|
||||
if (sameMonth) {
|
||||
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { day: "numeric" })}, ${we.getFullYear()}`;
|
||||
@@ -51,30 +77,126 @@ export function CalendarToolbar({
|
||||
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { month: "short", day: "numeric" })}, ${we.getFullYear()}`;
|
||||
}
|
||||
case "day":
|
||||
return formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
||||
return isMobile
|
||||
? formatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" })
|
||||
: formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
||||
case "agenda":
|
||||
return formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
||||
return isMobile
|
||||
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
||||
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
||||
}
|
||||
};
|
||||
|
||||
const [showViewDropdown, setShowViewDropdown] = useState(false);
|
||||
const viewDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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="flex items-center gap-2 px-4 py-3 border-b border-border flex-wrap">
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={onPrev} className="p-1.5 rounded hover:bg-muted transition-colors" aria-label={t("nav_prev")}>
|
||||
<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="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="text-sm font-medium min-w-[140px] text-center">
|
||||
<span className={cn("text-sm font-medium text-center", isMobile ? "min-w-[80px]" : "min-w-[140px]")}>
|
||||
{getDateLabel()}
|
||||
</span>
|
||||
<button onClick={onNext} className="p-1.5 rounded hover:bg-muted transition-colors" aria-label={t("nav_next")}>
|
||||
<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>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={onToday}>
|
||||
<Button variant="outline" size="sm" onClick={onToday} className="touch-manipulation">
|
||||
{t("views.today")}
|
||||
</Button>
|
||||
|
||||
{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.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>
|
||||
)}
|
||||
|
||||
{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]" />
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{!isMobile && (
|
||||
@@ -97,17 +219,19 @@ export function CalendarToolbar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onImport && (
|
||||
{onImport && !isMobile && (
|
||||
<Button variant="outline" size="sm" onClick={onImport}>
|
||||
<Upload className="w-4 h-4 mr-1" />
|
||||
{!isMobile && t("import.title")}
|
||||
{t("import.title")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button size="sm" onClick={onCreateEvent}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{!isMobile && t("events.create")}
|
||||
</Button>
|
||||
{!isMobile && (
|
||||
<Button size="sm" onClick={onCreateEvent}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t("events.create")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface CalendarWeekViewProps {
|
||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||
firstDayOfWeek?: number;
|
||||
timeFormat?: "12h" | "24h";
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
const HOUR_HEIGHT = 60;
|
||||
@@ -35,6 +36,7 @@ export function CalendarWeekView({
|
||||
onCreateAtTime,
|
||||
firstDayOfWeek = 1,
|
||||
timeFormat = "24h",
|
||||
isMobile,
|
||||
}: CalendarWeekViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
@@ -42,9 +44,13 @@ export function CalendarWeekView({
|
||||
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]);
|
||||
}, [selectedDate, weekStart, isMobile]);
|
||||
|
||||
const calendarMap = useMemo(() => {
|
||||
const map = new Map<string, Calendar>();
|
||||
@@ -132,14 +138,16 @@ export function CalendarWeekView({
|
||||
return format(new Date(2000, 0, 1, h), "HH:mm");
|
||||
};
|
||||
|
||||
const colCount = isMobile ? 3 : 7;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}>
|
||||
{hasAllDay && (
|
||||
<div className="flex border-b border-border">
|
||||
<div className="w-14 flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right">
|
||||
<div className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10" : "w-14")}>
|
||||
{t("events.all_day")}
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-7 gap-px bg-border">
|
||||
<div className={cn("flex-1 grid gap-px bg-border", isMobile ? "grid-cols-3" : "grid-cols-7")}>
|
||||
{weekDays.map((day) => {
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const dayAllDay = allDayEvents.get(key) || [];
|
||||
@@ -165,8 +173,8 @@ export function CalendarWeekView({
|
||||
)}
|
||||
|
||||
<div className="flex border-b border-border" role="row">
|
||||
<div className="w-14 flex-shrink-0" />
|
||||
<div className="flex-1 grid grid-cols-7 border-l border-border">
|
||||
<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")}>
|
||||
{weekDays.map((day) => {
|
||||
const todayCol = isToday(day);
|
||||
const selected = isSameDay(day, selectedDate);
|
||||
@@ -178,7 +186,7 @@ export function CalendarWeekView({
|
||||
role="columnheader"
|
||||
aria-label={fullLabel}
|
||||
className={cn(
|
||||
"text-center py-2 text-sm border-r border-border last:border-r-0 transition-colors",
|
||||
"text-center py-2 text-sm border-r border-border last:border-r-0 transition-colors touch-manipulation",
|
||||
"hover:bg-muted/50",
|
||||
todayCol && "font-bold",
|
||||
)}
|
||||
@@ -201,7 +209,7 @@ export function CalendarWeekView({
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
|
||||
<div className="w-14 flex-shrink-0">
|
||||
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")}>
|
||||
{HOURS.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
@@ -209,7 +217,7 @@ export function CalendarWeekView({
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
>
|
||||
{h > 0 && (
|
||||
<span className="absolute top-0 right-2 -translate-y-1/2 text-[10px] leading-none">
|
||||
<span className={cn("absolute top-0 right-2 -translate-y-1/2 leading-none", isMobile ? "text-[9px]" : "text-[10px]")}>
|
||||
{formatHour(h)}
|
||||
</span>
|
||||
)}
|
||||
@@ -217,7 +225,7 @@ export function CalendarWeekView({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 grid grid-cols-7 border-l border-border relative">
|
||||
<div className={cn("flex-1 border-l border-border relative", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}>
|
||||
{weekDays.map((day) => {
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const dayEvents = timedEvents.get(key) || [];
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Pencil, Trash2, Copy, Send, Check,
|
||||
} from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import {
|
||||
@@ -30,6 +31,7 @@ interface EventDetailPopoverProps {
|
||||
onRsvp?: (status: CalendarParticipant["participationStatus"]) => void;
|
||||
currentUserEmails?: string[];
|
||||
timeFormat?: "12h" | "24h";
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
const POPOVER_WIDTH = 360;
|
||||
@@ -119,6 +121,7 @@ export function EventDetailPopover({
|
||||
onRsvp,
|
||||
currentUserEmails = [],
|
||||
timeFormat = "24h",
|
||||
isMobile,
|
||||
}: EventDetailPopoverProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
@@ -249,8 +252,16 @@ export function EventDetailPopover({
|
||||
ref={popoverRef}
|
||||
role="dialog"
|
||||
aria-label={event.title || t("events.no_title")}
|
||||
className="fixed z-[60] bg-background border border-border rounded-lg shadow-xl overflow-hidden transition-[opacity,transform] duration-150 ease-out"
|
||||
style={{
|
||||
className={cn(
|
||||
"fixed z-[60] bg-background border border-border shadow-xl overflow-hidden transition-[opacity,transform] duration-150 ease-out",
|
||||
isMobile
|
||||
? "inset-0 rounded-none flex flex-col"
|
||||
: "rounded-lg"
|
||||
)}
|
||||
style={isMobile ? {
|
||||
opacity: 1,
|
||||
transform: "none",
|
||||
} : {
|
||||
width: POPOVER_WIDTH,
|
||||
maxHeight: MAX_HEIGHT,
|
||||
top: position?.top ?? -9999,
|
||||
@@ -301,7 +312,10 @@ export function EventDetailPopover({
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 py-2 space-y-2.5 overflow-y-auto" style={{ maxHeight: MAX_HEIGHT - 140 }}>
|
||||
<div className={cn(
|
||||
"px-4 py-2 space-y-2.5 overflow-y-auto",
|
||||
isMobile ? "flex-1" : ""
|
||||
)} style={isMobile ? undefined : { maxHeight: MAX_HEIGHT - 140 }}>
|
||||
{/* Date & Time */}
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
|
||||
Reference in New Issue
Block a user