mirror of
https://github.com/Tony0410/quietthanks.git
synced 2026-05-25 13:51:39 +08:00
Streamline save button and add calendar view with search
- 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>
This commit is contained in:
155
src/components/CalendarView.tsx
Normal file
155
src/components/CalendarView.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user