342 lines
14 KiB
TypeScript
342 lines
14 KiB
TypeScript
"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, 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";
|
||
import type { Calendar } from "@/lib/jmap/types";
|
||
|
||
interface CalendarToolbarProps {
|
||
selectedDate: Date;
|
||
viewMode: CalendarViewMode;
|
||
onPrev: () => void;
|
||
onNext: () => void;
|
||
onToday: () => void;
|
||
onViewModeChange: (mode: CalendarViewMode) => void;
|
||
onCreateEvent: () => void;
|
||
onImport?: () => void;
|
||
onSubscribe?: () => void;
|
||
isMobile?: boolean;
|
||
firstDayOfWeek?: number;
|
||
onNavigateBack?: () => void;
|
||
calendars?: Calendar[];
|
||
selectedCalendarIds?: string[];
|
||
onToggleVisibility?: (id: string) => void;
|
||
enableCalendarTasks?: boolean;
|
||
}
|
||
|
||
export function CalendarToolbar({
|
||
selectedDate,
|
||
viewMode,
|
||
onPrev,
|
||
onNext,
|
||
onToday,
|
||
onViewModeChange,
|
||
onCreateEvent,
|
||
onImport,
|
||
onSubscribe,
|
||
isMobile,
|
||
firstDayOfWeek = 1,
|
||
onNavigateBack,
|
||
calendars,
|
||
selectedCalendarIds,
|
||
onToggleVisibility,
|
||
enableCalendarTasks,
|
||
}: CalendarToolbarProps) {
|
||
const t = useTranslations("calendar");
|
||
const formatter = useFormatter();
|
||
const views: CalendarViewMode[] = enableCalendarTasks
|
||
? ["month", "week", "day", "agenda", "tasks"]
|
||
: ["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 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()}`;
|
||
}
|
||
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { month: "short", day: "numeric" })}, ${we.getFullYear()}`;
|
||
}
|
||
case "day":
|
||
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 isMobile
|
||
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
||
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
||
case "tasks":
|
||
return t("views.tasks");
|
||
}
|
||
};
|
||
|
||
const [showImportDropdown, setShowImportDropdown] = useState(false);
|
||
const importDropdownRef = useRef<HTMLDivElement>(null);
|
||
|
||
|
||
useEffect(() => {
|
||
if (!showImportDropdown) return;
|
||
function handleClickOutside(e: MouseEvent) {
|
||
if (importDropdownRef.current && !importDropdownRef.current.contains(e.target as Node)) {
|
||
setShowImportDropdown(false);
|
||
}
|
||
}
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||
}, [showImportDropdown]);
|
||
|
||
|
||
|
||
return (
|
||
<div className={cn("border-b border-border", !isMobile && "flex items-center gap-2 px-4 py-3")}>
|
||
{/* ── MOBILE TOOLBAR ── */}
|
||
{isMobile && (
|
||
<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>
|
||
|
||
{/* 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>
|
||
|
||
{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">
|
||
{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}
|
||
</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>
|
||
)}
|
||
|
||
{/* ── 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>
|
||
<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 && (
|
||
<div className="flex border border-border rounded-md overflow-hidden">
|
||
{views.map((v) => (
|
||
<button
|
||
key={v}
|
||
onClick={() => onViewModeChange(v)}
|
||
title={t(`views.${v}_hint`)}
|
||
className={cn(
|
||
"px-3 py-1.5 text-xs font-medium transition-colors",
|
||
v === viewMode
|
||
? "bg-primary text-primary-foreground"
|
||
: "hover:bg-muted text-muted-foreground"
|
||
)}
|
||
>
|
||
{t(`views.${v}`)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{(onImport || onSubscribe) && !isMobile && (
|
||
<div className="relative" ref={importDropdownRef}>
|
||
<Button variant="outline" size="sm" onClick={() => setShowImportDropdown((v) => !v)}>
|
||
<Upload className="w-4 h-4 mr-1" />
|
||
{t("import.title")}
|
||
<ChevronDown className="w-3 h-3 ml-1" />
|
||
</Button>
|
||
{showImportDropdown && (
|
||
<div className="absolute top-full right-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]">
|
||
{onImport && (
|
||
<button
|
||
onClick={() => { onImport(); setShowImportDropdown(false); }}
|
||
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-muted transition-colors text-foreground"
|
||
>
|
||
<Upload className="w-4 h-4" />
|
||
{t("import.title")}
|
||
</button>
|
||
)}
|
||
{onSubscribe && (
|
||
<button
|
||
onClick={() => { onSubscribe(); setShowImportDropdown(false); }}
|
||
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-muted transition-colors text-foreground"
|
||
>
|
||
<Globe className="w-4 h-4" />
|
||
{t("subscription.title")}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{!isMobile && (
|
||
<Button size="sm" onClick={onCreateEvent} data-tour="create-event-button">
|
||
<Plus className="w-4 h-4 mr-1" />
|
||
{t("events.create")}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|