mirror of
https://github.com/Tony0410/quietthanks.git
synced 2026-05-24 21:31:41 +08:00
- Remove redundant Save button, keep only Save & New - Add calendar view to timeline showing days with entries - Add search functionality with highlighted matches - Add date filtering by clicking calendar days - Show results count when filtering Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
156 lines
5.0 KiB
TypeScript
156 lines
5.0 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo } from "react";
|
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
|
|
interface CalendarViewProps {
|
|
entryDates: Set<string>; // YYYY-MM-DD format
|
|
selectedDate: string | null;
|
|
onSelectDate: (date: string | null) => void;
|
|
currentMonth: Date;
|
|
onChangeMonth: (date: Date) => void;
|
|
}
|
|
|
|
export function CalendarView({
|
|
entryDates,
|
|
selectedDate,
|
|
onSelectDate,
|
|
currentMonth,
|
|
onChangeMonth,
|
|
}: CalendarViewProps) {
|
|
const { days, monthLabel } = useMemo(() => {
|
|
const year = currentMonth.getFullYear();
|
|
const month = currentMonth.getMonth();
|
|
|
|
// First day of month
|
|
const firstDay = new Date(year, month, 1);
|
|
const startDayOfWeek = firstDay.getDay();
|
|
|
|
// Days in month
|
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
|
|
// Build calendar grid
|
|
const days: { date: string; day: number; isCurrentMonth: boolean; hasEntry: boolean }[] = [];
|
|
|
|
// Previous month padding
|
|
const prevMonth = new Date(year, month, 0);
|
|
const prevMonthDays = prevMonth.getDate();
|
|
for (let i = startDayOfWeek - 1; i >= 0; i--) {
|
|
const day = prevMonthDays - i;
|
|
const date = `${prevMonth.getFullYear()}-${String(prevMonth.getMonth() + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
days.push({ date, day, isCurrentMonth: false, hasEntry: entryDates.has(date) });
|
|
}
|
|
|
|
// Current month
|
|
for (let day = 1; day <= daysInMonth; day++) {
|
|
const date = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
days.push({ date, day, isCurrentMonth: true, hasEntry: entryDates.has(date) });
|
|
}
|
|
|
|
// Next month padding
|
|
const remaining = 42 - days.length; // 6 rows * 7 days
|
|
for (let day = 1; day <= remaining; day++) {
|
|
const nextMonth = new Date(year, month + 2, 0);
|
|
const date = `${nextMonth.getFullYear()}-${String(month + 2).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
days.push({ date, day, isCurrentMonth: false, hasEntry: entryDates.has(date) });
|
|
}
|
|
|
|
const monthLabel = firstDay.toLocaleDateString("en-US", { month: "long", year: "numeric" });
|
|
|
|
return { days, monthLabel };
|
|
}, [currentMonth, entryDates]);
|
|
|
|
const goToPrevMonth = () => {
|
|
onChangeMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1));
|
|
};
|
|
|
|
const goToNextMonth = () => {
|
|
onChangeMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1));
|
|
};
|
|
|
|
const goToToday = () => {
|
|
onChangeMonth(new Date());
|
|
onSelectDate(null);
|
|
};
|
|
|
|
return (
|
|
<div className="p-4 bg-surface border border-border rounded-xl">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<button
|
|
onClick={goToPrevMonth}
|
|
className="p-1 text-muted hover:text-foreground"
|
|
>
|
|
<ChevronLeft size={20} />
|
|
</button>
|
|
<div className="flex items-center gap-3">
|
|
<span className="font-medium">{monthLabel}</span>
|
|
<button
|
|
onClick={goToToday}
|
|
className="text-xs text-accent hover:underline"
|
|
>
|
|
Today
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={goToNextMonth}
|
|
className="p-1 text-muted hover:text-foreground"
|
|
>
|
|
<ChevronRight size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Day headers */}
|
|
<div className="grid grid-cols-7 gap-1 mb-1">
|
|
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((day) => (
|
|
<div key={day} className="text-center text-xs text-muted py-1">
|
|
{day}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Calendar grid */}
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{days.map(({ date, day, isCurrentMonth, hasEntry }) => {
|
|
const isSelected = selectedDate === date;
|
|
const isToday = date === new Date().toISOString().split("T")[0];
|
|
|
|
return (
|
|
<button
|
|
key={date}
|
|
onClick={() => onSelectDate(isSelected ? null : date)}
|
|
className={`
|
|
relative aspect-square flex items-center justify-center text-sm rounded-lg transition-colors
|
|
${!isCurrentMonth ? "text-muted/30" : ""}
|
|
${isSelected ? "bg-accent text-white" : "hover:bg-border"}
|
|
${isToday && !isSelected ? "ring-1 ring-accent" : ""}
|
|
`}
|
|
>
|
|
{day}
|
|
{hasEntry && !isSelected && (
|
|
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-accent rounded-full" />
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Legend */}
|
|
<div className="mt-3 flex items-center gap-4 text-xs text-muted">
|
|
<div className="flex items-center gap-1">
|
|
<span className="w-1.5 h-1.5 bg-accent rounded-full" />
|
|
<span>Has entry</span>
|
|
</div>
|
|
{selectedDate && (
|
|
<button
|
|
onClick={() => onSelectDate(null)}
|
|
className="text-accent hover:underline"
|
|
>
|
|
Clear filter
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|