mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
feat(calendar): match the calendar layout to the rest of the app
The calendar bar spanned both the sidebar and the grid, and the sidebar opened with a full-width primary "Create event" button. Neither exists in mail or contacts, so the calendar looked like a different app. - the sidebar runs full height with a title row and a "+" button, like the contacts sidebar - the view bar sits over the grid only and uses the density header padding, so its border lines up with the sidebar header - calendar rows and section headers follow the contacts sidebar: full width 32px rows, chevron on the left
This commit is contained in:
@@ -75,6 +75,7 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { SchedulingDeniedError } from "@/lib/jmap/scheduling-error";
|
||||
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
|
||||
import { CalendarSidebarHeader } from "@/components/calendar/calendar-sidebar-header";
|
||||
import { getUserParticipantId, collectUserCalendarAddresses } from "@/lib/calendar-participants";
|
||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-calendar-colors";
|
||||
@@ -1774,15 +1775,13 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
|
||||
{!inlineApp && (
|
||||
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
||||
{/* Desktop: the bar spans the sidebar and the grid, as in Google Calendar. */}
|
||||
{!isMobile && calendarToolbar}
|
||||
<div className="flex flex-1 min-w-0 min-h-0">
|
||||
{/* Sidebar - in-flow when desktop pane, overlay when narrow */}
|
||||
{(isNarrow || !sidebarCollapsed) && (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"border-e border-border bg-secondary overflow-y-auto overflow-x-hidden flex-shrink-0 p-3",
|
||||
"border-e border-border bg-secondary flex flex-col overflow-hidden flex-shrink-0",
|
||||
!isResizing && "transition-[width] duration-300",
|
||||
isNarrow && cn(
|
||||
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||
@@ -1792,17 +1791,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
)}
|
||||
style={isNarrow ? undefined : { width: `${calSidebarWidth}px` }}
|
||||
>
|
||||
{!isMobile && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => { setNarrowSidebarOpen(false); openCreateModal(); }}
|
||||
data-tour={isNarrow ? undefined : "create-event-button"}
|
||||
className="w-full h-8 mb-3"
|
||||
>
|
||||
<Plus className="w-4 h-4 me-1" />
|
||||
{t("events.create")}
|
||||
</Button>
|
||||
)}
|
||||
<CalendarSidebarHeader
|
||||
onCreateEvent={() => { setNarrowSidebarOpen(false); openCreateModal(); }}
|
||||
tourTarget={!isNarrow}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden py-3">
|
||||
<div className="px-3">
|
||||
<MiniCalendar
|
||||
selectedDate={selectedDate}
|
||||
displayMonth={miniMonth}
|
||||
@@ -1812,6 +1806,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
showWeekNumbers={showWeekNumbers}
|
||||
/>
|
||||
</div>
|
||||
<CalendarSidebarPanel
|
||||
calendars={allCalendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
@@ -1878,6 +1873,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
client={client}
|
||||
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!isNarrow && (
|
||||
<ResizeHandle
|
||||
@@ -1894,7 +1890,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
||||
{isMobile && calendarToolbar}
|
||||
{calendarToolbar}
|
||||
|
||||
{/* isolate keeps the views' sticky headers (z-50) below the toolbar's
|
||||
import dropdown, which overlaps this area (#1049). */}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus } from "@/components/icons";
|
||||
|
||||
interface CalendarSidebarHeaderProps {
|
||||
onCreateEvent: () => void;
|
||||
/** Mark the button for the guided tour; off while the toolbar's own "+" is the visible one. */
|
||||
tourTarget?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Title row at the top of the calendar sidebar, the same shape as the
|
||||
* contacts sidebar header: the app name on the left and "+" on the right.
|
||||
*/
|
||||
export function CalendarSidebarHeader({ onCreateEvent, tourTarget = true }: CalendarSidebarHeaderProps) {
|
||||
const t = useTranslations("calendar");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="px-3 border-b border-border flex items-center justify-between flex-shrink-0"
|
||||
style={{ paddingBlock: "var(--density-header-py)" }}
|
||||
>
|
||||
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={onCreateEvent}
|
||||
className="h-8 w-8 flex-shrink-0"
|
||||
aria-label={t("events.create")}
|
||||
title={t("events.create")}
|
||||
data-tour={tourTarget ? "create-event-button" : undefined}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -255,10 +255,11 @@ export function CalendarSidebarPanel({
|
||||
data-account={cal.accountName ?? ''}
|
||||
data-visible={isVisible}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
|
||||
"flex items-center gap-2 w-full ps-5 pe-3 text-sm text-foreground/80 transition-colors duration-150",
|
||||
"hover:bg-muted",
|
||||
hasMenu && "pe-7"
|
||||
hasMenu && "pe-9"
|
||||
)}
|
||||
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
|
||||
>
|
||||
{/* A checkbox filled in the calendar colour, empty when hidden. */}
|
||||
<span
|
||||
@@ -297,7 +298,7 @@ export function CalendarSidebarPanel({
|
||||
type="button"
|
||||
onClick={(e) => openContextMenu(e, cal)}
|
||||
className={cn(
|
||||
"absolute end-1 top-1/2 -translate-y-1/2 p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-opacity",
|
||||
"absolute end-2 top-1/2 -translate-y-1/2 p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-opacity",
|
||||
"opacity-0 group-hover/cal:opacity-100 focus-visible:opacity-100",
|
||||
contextMenu.isOpen && contextMenu.data?.id === cal.id && "opacity-100"
|
||||
)}
|
||||
@@ -424,19 +425,19 @@ export function CalendarSidebarPanel({
|
||||
const renderSectionHeader = (key: string, label: string, onAdd?: () => void) => {
|
||||
const expanded = !collapsedAccountGroups.has(key);
|
||||
return (
|
||||
<div className="group flex items-center gap-1 mb-1">
|
||||
<div className="group flex items-center gap-1 pe-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAccountGroup(key)}
|
||||
aria-expanded={expanded}
|
||||
className="flex-1 min-w-0 flex items-center gap-1 px-1 py-1 rounded-sm text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||
className="flex-1 min-w-0 flex items-center gap-1 ps-3 py-1 text-start"
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 ms-auto flex-shrink-0" />
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 ms-auto flex-shrink-0 rtl:rotate-180" />
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0 rtl:rotate-180" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">{label}</span>
|
||||
</button>
|
||||
{onAdd && (
|
||||
<button
|
||||
@@ -457,7 +458,7 @@ export function CalendarSidebarPanel({
|
||||
<div>
|
||||
{renderSectionHeader(key, label, onAdd)}
|
||||
{!collapsedAccountGroups.has(key) && (
|
||||
<div className="space-y-0.5">
|
||||
<div>
|
||||
{list.map(renderCalendarItem)}
|
||||
</div>
|
||||
)}
|
||||
@@ -469,7 +470,8 @@ export function CalendarSidebarPanel({
|
||||
{enableCalendarTasks && (
|
||||
<button
|
||||
onClick={() => setViewMode('tasks')}
|
||||
className="flex items-center gap-2 w-full px-1.5 py-1.5 mb-3 rounded-md text-sm hover:bg-muted transition-colors"
|
||||
className="flex items-center gap-2 w-full px-3 mb-2 text-sm text-foreground/80 hover:bg-muted transition-colors"
|
||||
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
|
||||
>
|
||||
<ListTodo className="w-4 h-4 text-muted-foreground" />
|
||||
<span>{t('tasks.label')}</span>
|
||||
@@ -491,7 +493,7 @@ export function CalendarSidebarPanel({
|
||||
<div key={group.key} className={cn(idx === 0 ? "" : "mt-3")}>
|
||||
<button
|
||||
onClick={() => toggleAccountGroup(group.key)}
|
||||
className="group w-full flex items-center gap-1.5 px-1 py-1 rounded-sm hover:bg-muted/40 transition-colors"
|
||||
className="group w-full flex items-center gap-1.5 px-3 py-1 hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
@@ -522,24 +524,24 @@ export function CalendarSidebarPanel({
|
||||
)}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-1 ps-3">
|
||||
<div className="mt-1">
|
||||
{owned.length > 0 && (
|
||||
<div>
|
||||
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
|
||||
<div className="ps-5 pe-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
|
||||
{t('my_calendars')}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div>
|
||||
{owned.map(renderCalendarItem)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sharedGroups.map((sg) => (
|
||||
<div key={`${group.key}-shared-${sg.label}`} className="mt-2">
|
||||
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider flex items-center gap-1">
|
||||
<div className="ps-5 pe-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider flex items-center gap-1">
|
||||
<Share2 className="w-3 h-3" />
|
||||
{sg.label}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div>
|
||||
{sg.calendars.map(renderCalendarItem)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -560,15 +562,15 @@ export function CalendarSidebarPanel({
|
||||
)}
|
||||
|
||||
{sharedAccountGroups.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="mt-2">
|
||||
{renderSectionHeader('__others__', t('other_calendars'))}
|
||||
{!collapsedAccountGroups.has('__others__') && sharedAccountGroups.map((group) => (
|
||||
<div key={group.accountName} className="mt-2">
|
||||
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider flex items-center gap-1">
|
||||
<div className="ps-5 pe-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider flex items-center gap-1">
|
||||
<Share2 className="w-3 h-3" />
|
||||
{group.accountName}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div>
|
||||
{group.calendars.map(renderCalendarItem)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -332,7 +332,7 @@ export function CalendarToolbar({
|
||||
const currentViewLabel = t(`views.${viewMode}`);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-1 px-4 border-b border-border" style={{ paddingBlock: "var(--density-header-py)" }}>
|
||||
{onMenuClick && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
Reference in New Issue
Block a user