本页出处
设计风格资料库
内容来自 luckymaomi 的 UI 风格库(复刻版),以 MIT 许可 提供。本站镜像收录,仅作学习参考;这一页的原始地址是 原站页面。
← 回到导览访问原站仓库
返回模板目录
dashboardmaterial-design

日历排程

日历排程页面,包含迷你日历、日视图事件列表、颜色分类和快速创建。

独立打开

完整模板源代码

查看 1187 行代码
"use client";

export const dynamic = "force-static";

import { useState, useMemo, useCallback } from "react";
import {
  Calendar,
  ChevronLeft,
  ChevronRight,
  Clock,
  MapPin,
  Menu,
  Plus,
  Users,
  X,
} from "@/components/IconShim";
import { TemplateBackLink } from "@/components/templates/TemplateBackLink";
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const DAYS_FULL = [
  "Sunday",
  "Monday",
  "Tuesday",
  "Wednesday",
  "Thursday",
  "Friday",
  "Saturday",
];
const MONTHS = [
  "January",
  "February",
  "March",
  "April",
  "May",
  "June",
  "July",
  "August",
  "September",
  "October",
  "November",
  "December",
];
const WORK_HOURS = [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18];
const EVENT_COLORS = [
  "bg-blue-500",
  "bg-purple-500",
  "bg-green-500",
  "bg-orange-500",
  "bg-pink-500",
  "bg-indigo-500",
  "bg-teal-500",
  "bg-violet-500",
  "bg-red-500",
  "bg-amber-500",
];

type ViewMode = "day" | "week" | "month";

interface CalendarEvent {
  id: number;
  title: string;
  time: string;
  duration: string;
  color: string;
  location?: string;
  attendees?: number;
  day: number;
}

interface NewEventFormData {
  title: string;
  day: string;
  time: string;
  duration: string;
  color: string;
  location: string;
}

const INITIAL_EVENTS: CalendarEvent[] = [
  {
    id: 1,
    title: "Team Standup",
    time: "9:00 AM",
    duration: "30m",
    color: "bg-blue-500",
    location: "Zoom",
    attendees: 8,
    day: 18,
  },
  {
    id: 2,
    title: "Design Review",
    time: "10:30 AM",
    duration: "1h",
    color: "bg-purple-500",
    location: "Room A",
    attendees: 5,
    day: 18,
  },
  {
    id: 3,
    title: "Lunch with Sarah",
    time: "12:00 PM",
    duration: "1h",
    color: "bg-green-500",
    location: "Cafe",
    day: 18,
  },
  {
    id: 4,
    title: "Sprint Planning",
    time: "2:00 PM",
    duration: "1.5h",
    color: "bg-orange-500",
    location: "Zoom",
    attendees: 12,
    day: 18,
  },
  {
    id: 5,
    title: "1:1 with Manager",
    time: "4:00 PM",
    duration: "30m",
    color: "bg-pink-500",
    location: "Office",
    day: 18,
  },
  {
    id: 6,
    title: "Product Sync",
    time: "10:00 AM",
    duration: "45m",
    color: "bg-indigo-500",
    attendees: 6,
    day: 19,
  },
  {
    id: 7,
    title: "Code Review Session",
    time: "2:00 PM",
    duration: "1h",
    color: "bg-teal-500",
    attendees: 4,
    day: 19,
  },
  {
    id: 8,
    title: "Workshop: React Patterns",
    time: "11:00 AM",
    duration: "2h",
    color: "bg-violet-500",
    location: "Auditorium",
    attendees: 25,
    day: 20,
  },
  {
    id: 9,
    title: "Client Presentation",
    time: "3:00 PM",
    duration: "1h",
    color: "bg-red-500",
    location: "Board Room",
    attendees: 8,
    day: 20,
  },
  {
    id: 10,
    title: "Team Retro",
    time: "10:00 AM",
    duration: "1h",
    color: "bg-amber-500",
    location: "Zoom",
    attendees: 10,
    day: 21,
  },
  {
    id: 11,
    title: "Marketing Sync",
    time: "9:00 AM",
    duration: "30m",
    color: "bg-blue-500",
    location: "Zoom",
    attendees: 4,
    day: 16,
  },
  {
    id: 12,
    title: "Investor Update",
    time: "1:00 PM",
    duration: "1h",
    color: "bg-red-500",
    location: "Board Room",
    attendees: 6,
    day: 16,
  },
  {
    id: 13,
    title: "Engineering All-Hands",
    time: "3:00 PM",
    duration: "1h",
    color: "bg-purple-500",
    location: "Auditorium",
    attendees: 40,
    day: 17,
  },
  {
    id: 14,
    title: "Yoga Class",
    time: "8:00 AM",
    duration: "1h",
    color: "bg-green-500",
    location: "Gym",
    day: 22,
  },
  {
    id: 15,
    title: "Quarterly Review",
    time: "11:00 AM",
    duration: "2h",
    color: "bg-orange-500",
    location: "Board Room",
    attendees: 15,
    day: 24,
  },
  {
    id: 16,
    title: "Hackathon Kickoff",
    time: "9:00 AM",
    duration: "1h",
    color: "bg-teal-500",
    location: "Main Hall",
    attendees: 50,
    day: 25,
  },
  {
    id: 17,
    title: "Coffee Chat",
    time: "3:00 PM",
    duration: "30m",
    color: "bg-amber-500",
    location: "Cafe",
    day: 23,
  },
];

function buildMiniCalendarDays(): { day: number; current: boolean }[] {
  const year = 2026;
  const month = 1;
  const firstDay = new Date(year, month, 1).getDay();
  const daysInMonth = new Date(year, month + 1, 0).getDate();
  const prevMonthDays = new Date(year, month, 0).getDate();
  const days: { day: number; current: boolean }[] = [];
  for (let i = firstDay - 1; i >= 0; i--) {
    days.push({ day: prevMonthDays - i, current: false });
  }
  for (let i = 1; i <= daysInMonth; i++) {
    days.push({ day: i, current: true });
  }
  const remaining = 42 - days.length;
  for (let i = 1; i <= remaining; i++) {
    days.push({ day: i, current: false });
  }
  return days;
}

const miniCalendarDays = buildMiniCalendarDays();

function parseTimeToHour(time: string): number {
  const match = time.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
  if (!match) return 9;
  let hour = parseInt(match[1], 10);
  const minute = parseInt(match[2], 10);
  const period = match[3].toUpperCase();
  if (period === "PM" && hour !== 12) hour += 12;
  if (period === "AM" && hour === 12) hour = 0;
  return hour + minute / 60;
}

function parseDurationToHours(duration: string): number {
  const match = duration.match(/^([\d.]+)\s*(h|m)$/i);
  if (!match) return 1;
  const value = parseFloat(match[1]);
  return match[2].toLowerCase() === "m" ? value / 60 : value;
}

function formatHour(hour: number): string {
  if (hour === 0 || hour === 24) return "12 AM";
  if (hour === 12) return "12 PM";
  if (hour < 12) return `${hour} AM`;
  return `${hour - 12} PM`;
}

function getWeekDays(selectedDay: number): number[] {
  const date = new Date(2026, 1, selectedDay);
  const dayOfWeek = date.getDay();
  const startDay = selectedDay - dayOfWeek;
  const daysInMonth = new Date(2026, 2, 0).getDate();
  return Array.from({ length: 7 }, (_, i) => {
    const d = startDay + i;
    if (d < 1 || d > daysInMonth) return d;
    return d;
  });
}

/* ------------------------------------------------------------------ */
/* NewEventModal                                                      */
/* ------------------------------------------------------------------ */

function NewEventModal({
  onClose,
  onSave,
  initialDay,
}: {
  onClose: () => void;
  onSave: (data: NewEventFormData) => void;
  initialDay: number;
}) {
  const [form, setForm] = useState<NewEventFormData>({
    title: "",
    day: String(initialDay),
    time: "9:00 AM",
    duration: "30m",
    color: "bg-blue-500",
    location: "",
  });

  const updateField = <K extends keyof NewEventFormData>(
    key: K,
    value: NewEventFormData[K],
  ) => {
    setForm((prev) => ({ ...prev, [key]: value }));
  };

  const handleSubmit = () => {
    if (!form.title.trim()) return;
    onSave(form);
  };

  const timeOptions = useMemo(() => {
    const opts: string[] = [];
    for (let h = 8; h <= 18; h++) {
      const period = h < 12 ? "AM" : "PM";
      const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
      opts.push(`${h12}:00 ${period}`, `${h12}:30 ${period}`);
    }
    return opts;
  }, []);

  const durationOptions = ["15m", "30m", "45m", "1h", "1.5h", "2h", "3h"];
  const inputCls =
    "w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent";

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
      <div
        className="absolute inset-0 bg-black/40"
        role="presentation"
        onClick={onClose}
      />
      <div className="relative max-h-[calc(100vh-2rem)] w-full max-w-md overflow-y-auto rounded-2xl bg-white shadow-2xl">
        <div className="flex items-center justify-between px-6 py-4 border border-gray-100">
          <h2 className="text-lg font-semibold text-gray-900">New Event</h2>
          <button
            onClick={onClose}
            className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
            aria-label="Close modal"
          >
            <X className="w-5 h-5 text-gray-400" />
          </button>
        </div>
        <div className="px-6 py-5 space-y-4">
          <div>
            <label
              htmlFor="ui-page-field-1"
              className="block text-sm font-medium text-gray-700 mb-1"
            >
              Title
            </label>
            <input
              id="ui-page-field-1"
              type="text"
              value={form.title}
              onChange={(e) => updateField("title", e.target.value)}
              placeholder="Event title"
              className={inputCls}
            />
          </div>
          <div>
            <label
              htmlFor="ui-page-field-2"
              className="block text-sm font-medium text-gray-700 mb-1"
            >
              Date (February 2026)
            </label>
            <select
              id="ui-page-field-2"
              value={form.day}
              onChange={(e) => updateField("day", e.target.value)}
              className={`${inputCls} bg-white`}
            >
              {Array.from({ length: 28 }, (_, i) => i + 1).map((d) => (
                <option key={d} value={String(d)}>
                  February {d}, 2026 ({DAYS_FULL[new Date(2026, 1, d).getDay()]}
                  )
                </option>
              ))}
            </select>
          </div>
          <div className="grid grid-cols-2 gap-3">
            <div>
              <label
                htmlFor="ui-page-field-3"
                className="block text-sm font-medium text-gray-700 mb-1"
              >
                Time
              </label>
              <select
                id="ui-page-field-3"
                value={form.time}
                onChange={(e) => updateField("time", e.target.value)}
                className={`${inputCls} bg-white`}
              >
                {timeOptions.map((t) => (
                  <option key={t} value={t}>
                    {t}
                  </option>
                ))}
              </select>
            </div>
            <div>
              <label
                htmlFor="ui-page-field-4"
                className="block text-sm font-medium text-gray-700 mb-1"
              >
                Duration
              </label>
              <select
                id="ui-page-field-4"
                value={form.duration}
                onChange={(e) => updateField("duration", e.target.value)}
                className={`${inputCls} bg-white`}
              >
                {durationOptions.map((d) => (
                  <option key={d} value={d}>
                    {d}
                  </option>
                ))}
              </select>
            </div>
          </div>
          <div>
            <label
              htmlFor="ui-page-field-5"
              className="block text-sm font-medium text-gray-700 mb-1"
            >
              Location
            </label>
            <input
              id="ui-page-field-5"
              type="text"
              value={form.location}
              onChange={(e) => updateField("location", e.target.value)}
              placeholder="Room, Zoom link, etc."
              className={inputCls}
            />
          </div>
          <fieldset>
            <legend className="block text-sm font-medium text-gray-700 mb-1.5">
              Color
            </legend>
            <div className="flex flex-wrap gap-2">
              {EVENT_COLORS.map((c) => (
                <button
                  key={c}
                  onClick={() => updateField("color", c)}
                  aria-label={`Select color ${c}`}
                  aria-pressed={form.color === c}
                  className={`w-7 h-7 rounded-full ${c} transition-all ${form.color === c ? "ring-2 ring-offset-2 ring-blue-500 scale-110" : "hover:scale-105"}`}
                />
              ))}
            </div>
          </fieldset>
        </div>
        <div className="px-6 py-4 border border-gray-100 flex justify-end gap-2">
          <button
            onClick={onClose}
            className="px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
          >
            Cancel
          </button>
          <button
            onClick={handleSubmit}
            disabled={!form.title.trim()}
            className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
          >
            Create Event
          </button>
        </div>
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* DayView                                                            */
/* ------------------------------------------------------------------ */

function DayView({
  events,
  selectedDay,
}: {
  events: CalendarEvent[];
  selectedDay: number;
}) {
  const todayEvents = events.filter((e) => e.day === selectedDay);
  const upcomingEvents = events
    .filter((e) => e.day > selectedDay)
    .sort(
      (a, b) =>
        a.day - b.day || parseTimeToHour(a.time) - parseTimeToHour(b.time),
    )
    .slice(0, 3);

  return (
    <div className="flex-1 p-4 md:p-6 overflow-y-auto">
      <div className="mb-8">
        <h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-4">
          Schedule for {MONTHS[1]} {selectedDay}
        </h2>
        {todayEvents.length === 0 ? (
          <div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
            <Calendar className="w-10 h-10 text-gray-300 mx-auto mb-3" />
            <p className="text-gray-500 text-sm">
              No events scheduled for this day
            </p>
          </div>
        ) : (
          <div className="space-y-3">
            {todayEvents.map((event) => (
              <div
                key={event.id}
                className="flex min-w-0 items-start gap-3 rounded-xl border border-gray-200 bg-white p-4 transition-shadow hover:shadow-md"
              >
                <span
                  className={`mt-1.5 h-3 w-3 shrink-0 rounded-full ${event.color}`}
                />
                <div className="flex-1 min-w-0">
                  <h3 className="font-semibold text-gray-900">
                    {event.title}
                  </h3>
                  <div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm text-gray-500">
                    <span className="flex items-center gap-1">
                      <Clock className="w-3.5 h-3.5" />
                      {event.time} ({event.duration})
                    </span>
                    {event.location && (
                      <span className="flex items-center gap-1">
                        <MapPin className="w-3.5 h-3.5" />
                        {event.location}
                      </span>
                    )}
                    {event.attendees && (
                      <span className="flex items-center gap-1">
                        <Users className="w-3.5 h-3.5" />
                        {event.attendees} attendees
                      </span>
                    )}
                  </div>
                </div>
              </div>
            ))}
          </div>
        )}
      </div>

      {upcomingEvents.length > 0 && (
        <div>
          <h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-4">
            Upcoming
          </h2>
          <div className="space-y-2">
            {upcomingEvents.map((event) => (
              <div
                key={event.id}
                className="bg-white rounded-xl border border-gray-200 p-3 flex items-center gap-3 hover:shadow-sm transition-shadow"
              >
                <div
                  className={`w-2 h-2 rounded-full ${event.color} shrink-0`}
                />
                <div className="flex-1 min-w-0">
                  <span className="text-sm font-medium text-gray-900 truncate block">
                    {event.title}
                  </span>
                  <span className="text-xs text-gray-500">
                    {MONTHS[1]} {event.day} &middot; {event.time}
                  </span>
                </div>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* WeekView                                                           */
/* ------------------------------------------------------------------ */

function WeekView({
  events,
  selectedDay,
  onSelectDay,
}: {
  events: CalendarEvent[];
  selectedDay: number;
  onSelectDay: (day: number) => void;
}) {
  const weekDays = useMemo(() => getWeekDays(selectedDay), [selectedDay]);
  const daysInMonth = 28;

  return (
    <>
      <div className="space-y-4 p-4 md:hidden">
        {weekDays.map((day, index) => {
          const inMonth = day >= 1 && day <= daysInMonth;
          if (!inMonth) return null;

          const dayEvents = events
            .filter((event) => event.day === day)
            .sort(
              (a, b) => parseTimeToHour(a.time) - parseTimeToHour(b.time),
            );

          return (
            <section key={day} className="space-y-2">
              <button
                onClick={() => onSelectDay(day)}
                className="flex w-full items-center justify-between rounded-lg bg-white px-4 py-3 text-left"
              >
                <span className="text-sm font-semibold text-gray-900">
                  {DAYS[index]}, {MONTHS[1]} {day}
                </span>
                <span className="text-xs text-gray-500">
                  {dayEvents.length} events
                </span>
              </button>
              {dayEvents.length === 0 ? (
                <p className="px-4 py-2 text-xs text-gray-400">No events</p>
              ) : (
                dayEvents.map((event) => (
                  <button
                    key={event.id}
                    onClick={() => onSelectDay(event.day)}
                    className="flex w-full min-w-0 items-start gap-3 rounded-xl border border-gray-200 bg-white p-4 text-left"
                  >
                    <span
                      className={`mt-1 h-3 w-3 shrink-0 rounded-full ${event.color}`}
                    />
                    <span className="min-w-0">
                      <span className="block truncate text-sm font-medium text-gray-900">
                        {event.title}
                      </span>
                      <span className="mt-1 block text-xs text-gray-500">
                        {event.time} ({event.duration})
                        {event.location ? ` · ${event.location}` : ""}
                      </span>
                    </span>
                  </button>
                ))
              )}
            </section>
          );
        })}
      </div>

      <div className="hidden flex-1 overflow-auto md:block">
      <div className="min-w-[640px]">
        {/* Day headers */}
        <div className="grid grid-cols-[60px_repeat(7,1fr)] border border-gray-200 bg-white sticky top-0 z-10">
          <div className="p-2" />
          {weekDays.map((day, i) => {
            const inMonth = day >= 1 && day <= daysInMonth;
            return (
              <button
                key={i}
                onClick={() => inMonth && onSelectDay(day)}
                disabled={!inMonth}
                className={`p-2 text-center border border-gray-100 transition-colors ${inMonth ? "hover:bg-blue-50 cursor-pointer" : "opacity-40"}`}
              >
                <div className="text-xs text-gray-500 font-medium">
                  {DAYS[i]}
                </div>
                <div
                  className={`text-lg font-semibold mt-0.5 w-8 h-8 mx-auto flex items-center justify-center rounded-full ${day === selectedDay ? "bg-blue-600 text-white" : "text-gray-900"}`}
                >
                  {inMonth ? day : ""}
                </div>
              </button>
            );
          })}
        </div>

        {/* Time grid */}
        <div className="grid grid-cols-[60px_repeat(7,1fr)]">
          {WORK_HOURS.map((hour) => (
            <div key={hour} className="contents">
              <div className="h-16 flex items-start justify-end pr-2 pt-0.5">
                <span className="text-[11px] text-gray-400 font-medium">
                  {formatHour(hour)}
                </span>
              </div>
              {weekDays.map((day, dayIdx) => {
                const dayEvents = events.filter(
                  (e) =>
                    e.day === day &&
                    Math.floor(parseTimeToHour(e.time)) === hour,
                );
                return (
                  <div
                    key={dayIdx}
                    className="h-16 border border border-gray-100 relative"
                  >
                    {dayEvents.map((event) => {
                      const startHour = parseTimeToHour(event.time);
                      const durationHours = parseDurationToHours(
                        event.duration,
                      );
                      const topOffset = (startHour - hour) * 64;
                      const height = Math.max(durationHours * 64, 24);
                      return (
                        <button
                          key={event.id}
                          onClick={() => onSelectDay(event.day)}
                          className={`absolute left-0.5 right-0.5 ${event.color} z-10 cursor-pointer overflow-hidden rounded-md px-1.5 py-0.5 text-left text-white shadow-sm transition-shadow hover:shadow-md`}
                          style={{
                            top: `${topOffset}px`,
                            height: `${height}px`,
                            minHeight: "24px",
                          }}
                          title={`${event.title} - ${event.time} (${event.duration})`}
                        >
                          <div className="text-[11px] font-semibold truncate leading-tight">
                            {event.title}
                          </div>
                          {height >= 40 && (
                            <div className="text-[10px] opacity-90 truncate">
                              {event.time}
                            </div>
                          )}
                          {height >= 56 && event.location && (
                            <div className="text-[10px] opacity-80 truncate">
                              {event.location}
                            </div>
                          )}
                        </button>
                      );
                    })}
                  </div>
                );
              })}
            </div>
          ))}
        </div>
      </div>
      </div>
    </>
  );
}

/* ------------------------------------------------------------------ */
/* MonthView                                                          */
/* ------------------------------------------------------------------ */

function MonthView({
  events,
  selectedDay,
  onSelectDay,
}: {
  events: CalendarEvent[];
  selectedDay: number;
  onSelectDay: (day: number) => void;
}) {
  const eventsByDay = useMemo(() => {
    const map = new Map<number, CalendarEvent[]>();
    for (const event of events) {
      const list = map.get(event.day) ?? [];
      list.push(event);
      map.set(event.day, list);
    }
    return map;
  }, [events]);

  const selectedDayEvents = events
    .filter((e) => e.day === selectedDay)
    .sort((a, b) => parseTimeToHour(a.time) - parseTimeToHour(b.time));

  return (
    <div className="flex-1 p-4 md:p-6 overflow-y-auto">
      <div className="bg-white rounded-xl border border-gray-200 overflow-hidden mb-6">
        <div className="grid grid-cols-7 border border-gray-200">
          {DAYS.map((day) => (
            <div
              key={day}
              className="py-2.5 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider"
            >
              {day}
            </div>
          ))}
        </div>
        <div className="grid grid-cols-7">
          {miniCalendarDays.map((d, i) => {
            const isCurrent = d.current;
            const isSelected = isCurrent && d.day === selectedDay;
            const dayEvents = isCurrent ? (eventsByDay.get(d.day) ?? []) : [];
            const count = dayEvents.length;
            return (
              <button
                key={i}
                onClick={() => isCurrent && onSelectDay(d.day)}
                disabled={!isCurrent}
                className={`min-h-[80px] md:min-h-[100px] p-2 border border border-gray-100 text-left transition-colors relative ${
                  isSelected
                    ? "bg-blue-50 ring-2 ring-inset ring-blue-500"
                    : isCurrent
                      ? "hover:bg-gray-50 cursor-pointer"
                      : "bg-gray-50/50"
                } ${i % 7 === 6 ? "border-0" : ""}`}
              >
                <span
                  className={`text-sm font-medium inline-flex items-center justify-center w-7 h-7 rounded-full ${
                    isSelected
                      ? "bg-blue-600 text-white"
                      : isCurrent
                        ? "text-gray-900"
                        : "text-gray-300"
                  }`}
                >
                  {d.day}
                </span>
                {count > 0 && (
                  <div className="mt-1 space-y-0.5">
                    {dayEvents.slice(0, 2).map((evt) => (
                      <div
                        key={evt.id}
                        className={`${evt.color} text-white text-[10px] px-1.5 py-0.5 rounded truncate leading-tight hidden md:block`}
                      >
                        {evt.title}
                      </div>
                    ))}
                    {count > 2 && (
                      <div className="text-[10px] text-gray-500 px-1.5 hidden md:block">
                        +{count - 2} more
                      </div>
                    )}
                    <div className="flex gap-0.5 md:hidden">
                      {dayEvents.slice(0, 4).map((evt) => (
                        <span
                          key={evt.id}
                          className={`w-1.5 h-1.5 rounded-full ${evt.color}`}
                        />
                      ))}
                      {count > 4 && (
                        <span className="text-[9px] text-gray-400 leading-none">
                          +{count - 4}
                        </span>
                      )}
                    </div>
                  </div>
                )}
              </button>
            );
          })}
        </div>
      </div>

      <div>
        <h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3">
          {MONTHS[1]} {selectedDay} &mdash;{" "}
          {DAYS_FULL[new Date(2026, 1, selectedDay).getDay()]}
        </h2>
        {selectedDayEvents.length === 0 ? (
          <div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
            <Calendar className="w-8 h-8 text-gray-300 mx-auto mb-2" />
            <p className="text-gray-500 text-sm">No events on this day</p>
          </div>
        ) : (
          <div className="space-y-2">
            {selectedDayEvents.map((event) => (
              <div
                key={event.id}
                className="bg-white rounded-xl border border-gray-200 p-3 flex items-center gap-3 hover:shadow-sm transition-shadow"
              >
                <span
                  className={`h-3 w-3 shrink-0 rounded-full ${event.color}`}
                />
                <div className="flex-1 min-w-0">
                  <span className="text-sm font-medium text-gray-900 block truncate">
                    {event.title}
                  </span>
                  <div className="flex flex-wrap gap-x-3 text-xs text-gray-500 mt-0.5">
                    <span className="flex items-center gap-1">
                      <Clock className="w-3 h-3" />
                      {event.time} ({event.duration})
                    </span>
                    {event.location && (
                      <span className="flex items-center gap-1">
                        <MapPin className="w-3 h-3" />
                        {event.location}
                      </span>
                    )}
                  </div>
                </div>
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* SidebarContent                                                     */
/* ------------------------------------------------------------------ */

function SidebarContent({
  selectedDay,
  setSelectedDay,
  events,
}: {
  selectedDay: number;
  setSelectedDay: (d: number) => void;
  events: CalendarEvent[];
}) {
  const hasEvent = (day: number) => events.some((e) => e.day === day);

  return (
    <div className="flex-1 flex flex-col p-4">
      <div className="mb-6">
        <div className="flex items-center justify-between mb-3">
          <span className="font-semibold text-sm text-gray-900">
            February 2026
          </span>
        </div>
        <div className="grid grid-cols-7 gap-0.5 text-center">
          {DAYS.map((d) => (
            <div key={d} className="text-[10px] font-medium text-gray-400 py-1">
              {d}
            </div>
          ))}
          {miniCalendarDays.map((d, i) => (
            <button
              key={i}
              onClick={() => d.current && setSelectedDay(d.day)}
              disabled={!d.current}
              className={`text-xs py-1.5 rounded-lg relative transition-colors ${
                d.current && d.day === selectedDay
                  ? "bg-blue-600 text-white font-bold"
                  : d.current && d.day === 18
                    ? "bg-blue-50 text-blue-600 font-semibold"
                    : d.current
                      ? "text-gray-700 hover:bg-gray-100"
                      : "text-gray-300"
              }`}
            >
              {d.day}
              {d.current && hasEvent(d.day) && d.day !== selectedDay && (
                <span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 bg-blue-500 rounded-full" />
              )}
            </button>
          ))}
        </div>
      </div>

      <div className="mb-6">
        <h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">
          My Calendars
        </h3>
        <div className="space-y-2">
          {[
            { name: "Personal", color: "bg-blue-500" },
            { name: "Work", color: "bg-purple-500" },
            { name: "Team Events", color: "bg-green-500" },
            { name: "Reminders", color: "bg-orange-500" },
          ].map((cal) => (
            <label
              key={cal.name}
              className="flex items-center gap-2.5 text-sm cursor-pointer"
            >
              <span className={`w-3 h-3 rounded ${cal.color}`} />
              <span className="text-gray-700">{cal.name}</span>
            </label>
          ))}
        </div>
      </div>

    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Main Page Component                                                */
/* ------------------------------------------------------------------ */

export default function CalendarScheduleTemplate() {
  const [selectedDay, setSelectedDay] = useState(18);
  const [sidebarOpen, setSidebarOpen] = useState(false);
  const [viewMode, setViewMode] = useState<ViewMode>("day");
  const [showNewEvent, setShowNewEvent] = useState(false);
  const [events, setEvents] = useState<CalendarEvent[]>(INITIAL_EVENTS);
  const [nextId, setNextId] = useState(INITIAL_EVENTS.length + 1);

  const handleSaveEvent = useCallback(
    (data: NewEventFormData) => {
      const newEvent: CalendarEvent = {
        id: nextId,
        title: data.title.trim(),
        time: data.time,
        duration: data.duration,
        color: data.color,
        location: data.location.trim() || undefined,
        day: parseInt(data.day, 10),
      };
      setEvents((prev) => [...prev, newEvent]);
      setNextId((prev) => prev + 1);
      setShowNewEvent(false);
      setSelectedDay(newEvent.day);
    },
    [nextId],
  );

  const handleNavigateDay = (direction: -1 | 1) => {
    const daysInMonth = 28;
    const step = viewMode === "week" ? 7 : 1;
    if (viewMode === "month") return;
    const next = selectedDay + direction * step;
    if (next >= 1 && next <= daysInMonth) setSelectedDay(next);
  };

  const headerTitle = useMemo(() => {
    if (viewMode === "day") return `${MONTHS[1]} ${selectedDay}, 2026`;
    if (viewMode === "week") {
      const wd = getWeekDays(selectedDay).filter((d) => d >= 1 && d <= 28);
      return `${MONTHS[1]} ${wd[0]} - ${wd[wd.length - 1]}, 2026`;
    }
    return `${MONTHS[1]} 2026`;
  }, [viewMode, selectedDay]);

  return (
    <div className="min-h-screen bg-gray-50 flex">
      {/* Mobile Sidebar */}
      {sidebarOpen && (
        <div className="fixed inset-0 z-50 lg:hidden">
          <div
            className="absolute inset-0 bg-black/40"
            role="presentation"
            onClick={() => setSidebarOpen(false)}
          />
          <aside className="absolute left-0 top-0 bottom-0 w-72 bg-white shadow-xl overflow-y-auto">
            <div className="flex items-center justify-between p-4 border border-gray-100">
              <span className="font-bold">Calendar</span>
              <button onClick={() => setSidebarOpen(false)} aria-label="Close">
                <X className="w-5 h-5 text-gray-400" />
              </button>
            </div>
            <SidebarContent
              selectedDay={selectedDay}
              setSelectedDay={(d) => {
                setSelectedDay(d);
                setSidebarOpen(false);
              }}
              events={events}
            />
          </aside>
        </div>
      )}

      {/* Desktop Sidebar */}
      <aside className="hidden lg:flex w-72 xl:w-80 shrink-0 bg-white border border-gray-200 flex-col sticky top-0 h-screen overflow-y-auto">
        <div className="p-5 border border-gray-100">
          <div className="flex items-center gap-2">
            <Calendar className="w-5 h-5 text-blue-600" />
            <span className="text-lg font-bold text-gray-900">CalendarKit</span>
          </div>
        </div>
        <SidebarContent
          selectedDay={selectedDay}
          setSelectedDay={setSelectedDay}
          events={events}
        />
      </aside>

      {/* Main */}
      <main className="flex-1 min-w-0 flex flex-col">
        <header className="sticky top-0 z-30 flex flex-wrap items-center gap-3 border border-gray-200 bg-white px-4 py-3 md:px-6">
          <div className="flex w-full min-w-0 items-center gap-2 lg:w-auto">
            <button
              className="rounded-lg p-2 transition-colors hover:bg-gray-100 lg:hidden"
              onClick={() => setSidebarOpen(true)}
              aria-label="Open sidebar"
            >
              <Menu className="h-5 w-5" />
            </button>
            <div className="flex min-w-0 flex-1 items-center justify-center gap-1 lg:flex-none lg:gap-2">
              <button
              className="shrink-0 rounded-lg p-1.5 transition-colors hover:bg-gray-100"
              onClick={() => handleNavigateDay(-1)}
              aria-label="Previous"
            >
              <ChevronLeft className="w-4 h-4" />
            </button>
            <h1 className="min-w-0 truncate text-base font-semibold text-gray-900 sm:text-lg">
              {headerTitle}
            </h1>
            <button
              className="shrink-0 rounded-lg p-1.5 transition-colors hover:bg-gray-100"
              onClick={() => handleNavigateDay(1)}
              aria-label="Next"
            >
              <ChevronRight className="w-4 h-4" />
            </button>
            </div>
            <button
              onClick={() => setShowNewEvent(true)}
              className="flex min-h-10 min-w-10 shrink-0 items-center justify-center gap-2 rounded-lg bg-blue-600 p-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 sm:px-4 sm:py-2"
              aria-label="New event"
            >
              <Plus className="w-4 h-4" />
              <span className="hidden sm:inline">New Event</span>
            </button>
          </div>
          <div className="flex w-full items-center justify-between gap-2 lg:ml-auto lg:w-auto">
            <button
              onClick={() => setSelectedDay(18)}
              className="rounded-lg px-3 py-1.5 text-sm font-medium text-blue-600 transition-colors hover:bg-blue-50"
            >
              Today
            </button>
            <div className="flex rounded-lg bg-gray-100 p-0.5">
              {(["day", "week", "month"] as const).map((mode) => (
                <button
                  key={mode}
                  onClick={() => setViewMode(mode)}
                  className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${viewMode === mode ? "bg-white shadow-sm text-gray-900" : "text-gray-500 hover:text-gray-700"}`}
                >
                  {mode.charAt(0).toUpperCase() + mode.slice(1)}
                </button>
              ))}
            </div>
          </div>
        </header>

        {viewMode === "day" && (
          <DayView events={events} selectedDay={selectedDay} />
        )}
        {viewMode === "week" && (
          <WeekView
            events={events}
            selectedDay={selectedDay}
            onSelectDay={(d) => {
              setSelectedDay(d);
              setViewMode("day");
            }}
          />
        )}
        {viewMode === "month" && (
          <MonthView
            events={events}
            selectedDay={selectedDay}
            onSelectDay={setSelectedDay}
          />
        )}
      </main>

      {showNewEvent && (
        <NewEventModal
          onClose={() => setShowNewEvent(false)}
          onSave={handleSaveEvent}
          initialDay={selectedDay}
        />
      )}
      <TemplateBackLink />
    </div>
  );
}

查看关联风格