mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat(calendar): fold calendar lists and show options on hover in the sidebar
The calendar list in the sidebar now works like Google Calendar's: - "My calendars" and a new "Other calendars" section (shared calendars, still grouped by owner) fold away, and stay folded - each calendar has a checkbox filled in its colour with a check mark - hovering a calendar shows an options button that opens the same menu as a right-click In the mini month, today is the filled circle and the selected day is tinted, so the two no longer look alike.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "@/components/icons";
|
||||
import { Check, ChevronDown, ChevronRight, Globe, ListTodo, MoreVertical, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "@/components/icons";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||
@@ -17,6 +17,7 @@ import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { displayNow } from "@/lib/timezone";
|
||||
import { readableTextOn } from "@/lib/color-transform";
|
||||
|
||||
/**
|
||||
* Split a per-account calendar list into "owned" (the user's own) and
|
||||
@@ -243,26 +244,33 @@ export function CalendarSidebarPanel({
|
||||
const hasMenu = isSubscriptionCalendar(cal.id) ? !!client : true;
|
||||
|
||||
return (
|
||||
<div key={cal.id} className="relative">
|
||||
<div key={cal.id} className="relative group/cal">
|
||||
<button
|
||||
onClick={() => onToggleVisibility(cal.id)}
|
||||
onContextMenu={hasMenu ? (e) => openContextMenu(e, cal) : undefined}
|
||||
role="checkbox"
|
||||
aria-checked={isVisible}
|
||||
data-testid="calendar-item"
|
||||
data-calendar-name={cal.name}
|
||||
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",
|
||||
"hover:bg-muted"
|
||||
"hover:bg-muted",
|
||||
hasMenu && "pe-7"
|
||||
)}
|
||||
>
|
||||
{/* A checkbox filled in the calendar colour, empty when hidden. */}
|
||||
<span
|
||||
className={cn(
|
||||
"w-3 h-3 rounded-sm border-2 flex-shrink-0 transition-colors",
|
||||
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
|
||||
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 flex items-center justify-center transition-colors",
|
||||
!isVisible && "border-muted-foreground/40 bg-transparent"
|
||||
)}
|
||||
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
|
||||
/>
|
||||
style={isVisible ? { backgroundColor: color, borderColor: color, color: readableTextOn(color) } : undefined}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isVisible && <Check className="w-2.5 h-2.5" stroke={3.5} />}
|
||||
</span>
|
||||
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
|
||||
{cal.name}
|
||||
</span>
|
||||
@@ -284,6 +292,21 @@ export function CalendarSidebarPanel({
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{hasMenu && (
|
||||
<button
|
||||
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",
|
||||
"opacity-0 group-hover/cal:opacity-100 focus-visible:opacity-100",
|
||||
contextMenu.isOpen && contextMenu.data?.id === cal.id && "opacity-100"
|
||||
)}
|
||||
aria-label={t('calendar_options', { name: cal.name })}
|
||||
title={t('calendar_options', { name: cal.name })}
|
||||
>
|
||||
<MoreVertical className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -396,6 +419,51 @@ export function CalendarSidebarPanel({
|
||||
);
|
||||
};
|
||||
|
||||
// "My calendars" / "Other calendars" fold away like the account groups,
|
||||
// and share their remembered state.
|
||||
const renderSectionHeader = (key: string, label: string, onAdd?: () => void) => {
|
||||
const expanded = !collapsedAccountGroups.has(key);
|
||||
return (
|
||||
<div className="group flex items-center gap-1 mb-1">
|
||||
<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"
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 ms-auto flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 ms-auto flex-shrink-0 rtl:rotate-180" />
|
||||
)}
|
||||
</button>
|
||||
{onAdd && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
className="p-0.5 rounded text-muted-foreground opacity-0 group-hover:opacity-100 focus-visible:opacity-100 hover:text-foreground hover:bg-muted transition-opacity"
|
||||
title={tMgmt('add_calendar')}
|
||||
aria-label={tMgmt('add_calendar')}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSection = (key: string, label: string, list: Calendar[], onAdd?: () => void) => (
|
||||
<div>
|
||||
{renderSectionHeader(key, label, onAdd)}
|
||||
{!collapsedAccountGroups.has(key) && (
|
||||
<div className="space-y-0.5">
|
||||
{list.map(renderCalendarItem)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
{enableCalendarTasks && (
|
||||
@@ -484,37 +552,29 @@ export function CalendarSidebarPanel({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-2 px-1 group">
|
||||
{onCreateCalendar ? (
|
||||
<button
|
||||
onClick={onCreateCalendar}
|
||||
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
|
||||
title={tMgmt('add_calendar')}
|
||||
>
|
||||
{t('my_calendars')}
|
||||
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
) : (
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{t('my_calendars')}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{personalCalendars.map(renderCalendarItem)}
|
||||
</div>
|
||||
{renderSection(
|
||||
'__mine__',
|
||||
t('my_calendars'),
|
||||
personalCalendars,
|
||||
onCreateCalendar,
|
||||
)}
|
||||
|
||||
{sharedAccountGroups.map((group) => (
|
||||
<div key={group.accountName} className="mt-4">
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1 flex items-center gap-1.5">
|
||||
<Share2 className="w-3 h-3" />
|
||||
{group.accountName}
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{group.calendars.map(renderCalendarItem)}
|
||||
</div>
|
||||
{sharedAccountGroups.length > 0 && (
|
||||
<div className="mt-4">
|
||||
{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">
|
||||
<Share2 className="w-3 h-3" />
|
||||
{group.accountName}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{group.calendars.map(renderCalendarItem)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -187,13 +187,16 @@ export function MiniCalendar({
|
||||
className={cn(
|
||||
"relative flex items-center justify-center w-7 h-7 text-xs rounded-full transition-colors",
|
||||
!inMonth && "text-muted-foreground/40",
|
||||
inMonth && !selected && "hover:bg-muted",
|
||||
today && !selected && "font-bold text-primary",
|
||||
selected && "bg-primary text-primary-foreground"
|
||||
// Today is the filled circle; the selected day is tinted.
|
||||
today && "bg-primary text-primary-foreground font-semibold",
|
||||
selected && !today && "bg-primary/15 text-primary font-semibold",
|
||||
!today && !selected && "hover:bg-muted"
|
||||
)}
|
||||
aria-current={today ? "date" : undefined}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
{formatDayNumber(day)}
|
||||
{hasEvent && !selected && (
|
||||
{hasEvent && !selected && !today && (
|
||||
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "إجراءات أخرى",
|
||||
"select_view": "تغيير العرض، الحالي: {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "تقويمات أخرى",
|
||||
"calendar_options": "خيارات {name}"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "شخص ما",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "Més accions",
|
||||
"select_view": "Canvia la vista, actualment {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "Altres calendaris",
|
||||
"calendar_options": "Opcions de {name}"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Algú",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "Další akce",
|
||||
"select_view": "Změnit zobrazení, nyní {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "Další kalendáře",
|
||||
"calendar_options": "Možnosti pro {name}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Pokročilé hledání",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "Flere handlinger",
|
||||
"select_view": "Skift visning, aktuelt {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "Andre kalendere",
|
||||
"calendar_options": "Indstillinger for {name}"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Nogen",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "Weitere Aktionen",
|
||||
"select_view": "Ansicht wechseln, aktuell {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "Weitere Kalender",
|
||||
"calendar_options": "Optionen für {name}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Erweiterte Suche",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "More actions",
|
||||
"select_view": "Change view, currently {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "Other calendars",
|
||||
"calendar_options": "Options for {name}"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Someone",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "Más acciones",
|
||||
"select_view": "Cambiar vista, actualmente {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "Otros calendarios",
|
||||
"calendar_options": "Opciones de {name}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Búsqueda avanzada",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "اقدامات بیشتر",
|
||||
"select_view": "تغییر نما، نمای فعلی: {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "تقویمهای دیگر",
|
||||
"calendar_options": "گزینههای {name}"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "کسی",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "Plus d'actions",
|
||||
"select_view": "Changer de vue, actuellement {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "Autres agendas",
|
||||
"calendar_options": "Options de {name}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Recherche avancée",
|
||||
|
||||
@@ -3364,7 +3364,9 @@
|
||||
"toolbar": {
|
||||
"more": "פעולות נוספות",
|
||||
"select_view": "החלפת תצוגה, כעת {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "יומנים אחרים",
|
||||
"calendar_options": "אפשרויות עבור {name}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "חיפוש מתקדם",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "További műveletek",
|
||||
"select_view": "Nézet váltása, jelenleg {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "Egyéb naptárak",
|
||||
"calendar_options": "{name} beállításai"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Valaki",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "Altre azioni",
|
||||
"select_view": "Cambia vista, attualmente {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "Altri calendari",
|
||||
"calendar_options": "Opzioni per {name}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Ricerca avanzata",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "その他の操作",
|
||||
"select_view": "表示を切り替え(現在: {view})"
|
||||
}
|
||||
},
|
||||
"other_calendars": "他のカレンダー",
|
||||
"calendar_options": "{name} のオプション"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "詳細検索",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "추가 작업",
|
||||
"select_view": "보기 변경, 현재 {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "다른 캘린더",
|
||||
"calendar_options": "{name} 옵션"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "상세 검색",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "Citas darbības",
|
||||
"select_view": "Mainīt skatu, pašlaik {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "Citi kalendāri",
|
||||
"calendar_options": "{name} opcijas"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Izvērstā meklēšana",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "Бусад үйлдэл",
|
||||
"select_view": "Харагдац солих, одоо {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "Бусад хуанли",
|
||||
"calendar_options": "{name}-ийн тохиргоо"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Хэн нэгэн",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "Flere handlinger",
|
||||
"select_view": "Bytt visning, nå {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "Andre kalendere",
|
||||
"calendar_options": "Alternativer for {name}"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Noen",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "Meer acties",
|
||||
"select_view": "Weergave wijzigen, nu {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "Andere agenda's",
|
||||
"calendar_options": "Opties voor {name}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Geavanceerd zoeken",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "Więcej działań",
|
||||
"select_view": "Zmień widok, obecnie {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "Inne kalendarze",
|
||||
"calendar_options": "Opcje dla {name}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Wyszukiwanie zaawansowane",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "Mais ações",
|
||||
"select_view": "Mudar vista, atualmente {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "Outros calendários",
|
||||
"calendar_options": "Opções de {name}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Pesquisa avançada",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "Mai multe acțiuni",
|
||||
"select_view": "Schimbă vizualizarea, acum {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "Alte calendare",
|
||||
"calendar_options": "Opțiuni pentru {name}"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Cineva",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "Другие действия",
|
||||
"select_view": "Сменить вид, сейчас {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "Другие календари",
|
||||
"calendar_options": "Параметры: {name}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Расширенный поиск",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "Ďalšie akcie",
|
||||
"select_view": "Zmeniť zobrazenie, teraz {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "Ďalšie kalendáre",
|
||||
"calendar_options": "Možnosti pre {name}"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Niekto",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "Diğer işlemler",
|
||||
"select_view": "Görünümü değiştir, şu an {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "Diğer takvimler",
|
||||
"calendar_options": "{name} seçenekleri"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Birisi",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "Інші дії",
|
||||
"select_view": "Змінити вигляд, зараз {view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "Інші календарі",
|
||||
"calendar_options": "Параметри: {name}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Розширений пошук",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "更多動作",
|
||||
"select_view": "切換檢視,目前為{view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "其他日曆",
|
||||
"calendar_options": "{name} 的選項"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "有人",
|
||||
|
||||
@@ -3440,7 +3440,9 @@
|
||||
"toolbar": {
|
||||
"more": "更多操作",
|
||||
"select_view": "切换视图,当前为{view}"
|
||||
}
|
||||
},
|
||||
"other_calendars": "其他日历",
|
||||
"calendar_options": "{name} 的选项"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "高级搜索",
|
||||
|
||||
Reference in New Issue
Block a user