feat: add calendar task list view and shared calendar grouping
- Add TaskListView component for displaying calendar tasks - Group shared calendars by account in sidebar panel - Add task view toggle to calendar toolbar - Extend calendar store with task-related state
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Globe, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { Globe, Plus, RefreshCw, Share2, Trash2 } from "lucide-react";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||
@@ -42,6 +42,20 @@ export function CalendarSidebarPanel({
|
||||
const colorPickerRef = useRef<HTMLDivElement>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
|
||||
const sharedAccountGroups = useMemo(() => {
|
||||
const shared = calendars.filter(c => c.isShared);
|
||||
const groups = new Map<string, { accountName: string; calendars: Calendar[] }>();
|
||||
for (const cal of shared) {
|
||||
const key = cal.accountId!;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, { accountName: cal.accountName || key, calendars: [] });
|
||||
}
|
||||
groups.get(key)!.calendars.push(cal);
|
||||
}
|
||||
return Array.from(groups.values());
|
||||
}, [calendars]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!colorPickerId && !contextMenuCalId) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
@@ -97,108 +111,122 @@ export function CalendarSidebarPanel({
|
||||
|
||||
if (calendars.length === 0 && !onSubscribe) return null;
|
||||
|
||||
const renderCalendarItem = (cal: Calendar) => {
|
||||
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||
const color = cal.color || "#3b82f6";
|
||||
|
||||
return (
|
||||
<div key={cal.id} className="relative">
|
||||
<button
|
||||
onClick={() => onToggleVisibility(cal.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
if (isSubscriptionCalendar(cal.id) && client) {
|
||||
setContextMenuCalId(contextMenuCalId === cal.id ? null : cal.id);
|
||||
setColorPickerId(null);
|
||||
} else if (onColorChange) {
|
||||
setColorPickerId(colorPickerId === cal.id ? null : cal.id);
|
||||
setContextMenuCalId(null);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<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"
|
||||
)}
|
||||
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
|
||||
/>
|
||||
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
|
||||
{cal.name}
|
||||
</span>
|
||||
{isSubscriptionCalendar(cal.id) && (
|
||||
<>
|
||||
<Globe className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
{refreshingSubId === getSubscriptionForCalendar(cal.id)?.id && (
|
||||
<RefreshCw className="w-3 h-3 text-muted-foreground flex-shrink-0 animate-spin" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Subscription context menu on right-click */}
|
||||
{contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => {
|
||||
const sub = getSubscriptionForCalendar(cal.id);
|
||||
if (!sub) return null;
|
||||
return (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
|
||||
>
|
||||
<button
|
||||
onClick={() => handleRefreshSubscription(sub.id)}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
{tSub('refresh')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUnsubscribe(sub.id)}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{tSub('unsubscribe')}
|
||||
</button>
|
||||
{sub.lastRefreshed && (
|
||||
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
|
||||
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Color picker popover on right-click */}
|
||||
{colorPickerId === cal.id && onColorChange && (
|
||||
<div
|
||||
ref={colorPickerRef}
|
||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
|
||||
>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">{t("management.change_color")}</p>
|
||||
<CalendarColorPicker
|
||||
value={color}
|
||||
onChange={(c) => {
|
||||
onColorChange(cal.id, c);
|
||||
setColorPickerId(null);
|
||||
}}
|
||||
allowCustom
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<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 (
|
||||
<div key={cal.id} className="relative">
|
||||
<button
|
||||
onClick={() => onToggleVisibility(cal.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
if (isSubscriptionCalendar(cal.id) && client) {
|
||||
setContextMenuCalId(contextMenuCalId === cal.id ? null : cal.id);
|
||||
setColorPickerId(null);
|
||||
} else if (onColorChange) {
|
||||
setColorPickerId(colorPickerId === cal.id ? null : cal.id);
|
||||
setContextMenuCalId(null);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<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"
|
||||
)}
|
||||
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
|
||||
/>
|
||||
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
|
||||
{cal.name}
|
||||
</span>
|
||||
{isSubscriptionCalendar(cal.id) && (
|
||||
<>
|
||||
<Globe className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
{refreshingSubId === getSubscriptionForCalendar(cal.id)?.id && (
|
||||
<RefreshCw className="w-3 h-3 text-muted-foreground flex-shrink-0 animate-spin" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Subscription context menu on right-click */}
|
||||
{contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => {
|
||||
const sub = getSubscriptionForCalendar(cal.id);
|
||||
if (!sub) return null;
|
||||
return (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
|
||||
>
|
||||
<button
|
||||
onClick={() => handleRefreshSubscription(sub.id)}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
{tSub('refresh')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUnsubscribe(sub.id)}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{tSub('unsubscribe')}
|
||||
</button>
|
||||
{sub.lastRefreshed && (
|
||||
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
|
||||
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Color picker popover on right-click */}
|
||||
{colorPickerId === cal.id && onColorChange && (
|
||||
<div
|
||||
ref={colorPickerRef}
|
||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
|
||||
>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">{t("management.change_color")}</p>
|
||||
<CalendarColorPicker
|
||||
value={color}
|
||||
onChange={(c) => {
|
||||
onColorChange(cal.id, c);
|
||||
setColorPickerId(null);
|
||||
}}
|
||||
allowCustom
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{personalCalendars.map(renderCalendarItem)}
|
||||
</div>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user