2026-06-29 13:28:26 -05:00
|
|
|
import { useState, useEffect } from "react";
|
|
|
|
|
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
|
|
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
|
import { registerWidget } from "./WidgetRegistry";
|
|
|
|
|
import type {
|
|
|
|
|
CalendarConfig,
|
|
|
|
|
WidgetComponentProps,
|
|
|
|
|
} from "@/types/homepage-types";
|
|
|
|
|
import { GRID_SIZE } from "@/types/homepage-types";
|
|
|
|
|
import { WidgetTitle } from "./WidgetTitle";
|
2026-07-19 12:29:52 -05:00
|
|
|
import { runVisibleInterval } from "../use-visible-interval";
|
2026-06-29 13:28:26 -05:00
|
|
|
|
|
|
|
|
function getMonthData(year: number, month: number, startOnMonday: boolean) {
|
|
|
|
|
const firstDay = new Date(year, month, 1).getDay();
|
|
|
|
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
|
|
|
const offset = startOnMonday ? (firstDay === 0 ? 6 : firstDay - 1) : firstDay;
|
|
|
|
|
return { daysInMonth, offset };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CalendarWidget({
|
|
|
|
|
widget,
|
|
|
|
|
config,
|
|
|
|
|
}: WidgetComponentProps<CalendarConfig>) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const { startOnMonday } = config;
|
|
|
|
|
const [now, setNow] = useState(new Date());
|
|
|
|
|
const [viewYear, setViewYear] = useState(now.getFullYear());
|
|
|
|
|
const [viewMonth, setViewMonth] = useState(now.getMonth());
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-07-19 12:29:52 -05:00
|
|
|
return runVisibleInterval(() => setNow(new Date()), 60_000);
|
2026-06-29 13:28:26 -05:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const { daysInMonth, offset } = getMonthData(
|
|
|
|
|
viewYear,
|
|
|
|
|
viewMonth,
|
|
|
|
|
startOnMonday,
|
|
|
|
|
);
|
|
|
|
|
const today = now;
|
|
|
|
|
const isCurrentMonth =
|
|
|
|
|
viewYear === today.getFullYear() && viewMonth === today.getMonth();
|
|
|
|
|
|
|
|
|
|
const dayNames = startOnMonday
|
|
|
|
|
? [
|
|
|
|
|
t("homepage.calMon"),
|
|
|
|
|
t("homepage.calTue"),
|
|
|
|
|
t("homepage.calWed"),
|
|
|
|
|
t("homepage.calThu"),
|
|
|
|
|
t("homepage.calFri"),
|
|
|
|
|
t("homepage.calSat"),
|
|
|
|
|
t("homepage.calSun"),
|
|
|
|
|
]
|
|
|
|
|
: [
|
|
|
|
|
t("homepage.calSun"),
|
|
|
|
|
t("homepage.calMon"),
|
|
|
|
|
t("homepage.calTue"),
|
|
|
|
|
t("homepage.calWed"),
|
|
|
|
|
t("homepage.calThu"),
|
|
|
|
|
t("homepage.calFri"),
|
|
|
|
|
t("homepage.calSat"),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const monthLabel = new Intl.DateTimeFormat("en-US", {
|
|
|
|
|
month: "long",
|
|
|
|
|
year: "numeric",
|
|
|
|
|
}).format(new Date(viewYear, viewMonth, 1));
|
|
|
|
|
|
|
|
|
|
const cells: (number | null)[] = [
|
|
|
|
|
...Array(offset).fill(null),
|
|
|
|
|
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const prevMonth = () => {
|
|
|
|
|
if (viewMonth === 0) {
|
|
|
|
|
setViewMonth(11);
|
|
|
|
|
setViewYear((y) => y - 1);
|
|
|
|
|
} else setViewMonth((m) => m - 1);
|
|
|
|
|
};
|
|
|
|
|
const nextMonth = () => {
|
|
|
|
|
if (viewMonth === 11) {
|
|
|
|
|
setViewMonth(0);
|
|
|
|
|
setViewYear((y) => y + 1);
|
|
|
|
|
} else setViewMonth((m) => m + 1);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const accent =
|
|
|
|
|
getComputedStyle(document.documentElement)
|
|
|
|
|
.getPropertyValue("--accent-brand")
|
|
|
|
|
.trim() || "#f59145";
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col w-full h-full overflow-hidden select-none">
|
|
|
|
|
<WidgetTitle title={widget.title} icon={<CalendarDays size={11} />} />
|
|
|
|
|
<div className="flex flex-col flex-1 p-2 gap-1 overflow-hidden">
|
|
|
|
|
<div className="flex items-center justify-between shrink-0">
|
|
|
|
|
<button
|
|
|
|
|
onClick={prevMonth}
|
|
|
|
|
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<ChevronLeft size={12} />
|
|
|
|
|
</button>
|
|
|
|
|
<span className="text-[10px] font-semibold text-foreground">
|
|
|
|
|
{monthLabel}
|
|
|
|
|
</span>
|
|
|
|
|
<button
|
|
|
|
|
onClick={nextMonth}
|
|
|
|
|
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<ChevronRight size={12} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-7 gap-0 shrink-0">
|
|
|
|
|
{dayNames.map((d) => (
|
|
|
|
|
<div
|
|
|
|
|
key={d}
|
|
|
|
|
className="text-center text-[8px] font-semibold text-muted-foreground/70 uppercase py-0.5"
|
|
|
|
|
>
|
|
|
|
|
{d}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-7 gap-0 flex-1 content-start">
|
|
|
|
|
{cells.map((day, i) => {
|
|
|
|
|
if (day === null) return <div key={`e-${i}`} />;
|
|
|
|
|
const isToday = isCurrentMonth && day === today.getDate();
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={`d-${day}`}
|
|
|
|
|
className="flex items-center justify-center aspect-square"
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
className="text-[9px] font-medium w-5 h-5 flex items-center justify-center rounded-full"
|
|
|
|
|
style={
|
|
|
|
|
isToday
|
|
|
|
|
? { background: accent, color: "#fff" }
|
|
|
|
|
: { color: "var(--text-primary, currentColor)" }
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{day}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
registerWidget<CalendarConfig>({
|
|
|
|
|
id: "calendar",
|
|
|
|
|
name: "Calendar",
|
|
|
|
|
description: "A month calendar with today highlighted",
|
|
|
|
|
category: "info",
|
|
|
|
|
icon: <CalendarDays size={14} />,
|
|
|
|
|
defaultConfig: { startOnMonday: true },
|
|
|
|
|
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 9 },
|
|
|
|
|
minSize: { w: GRID_SIZE * 7, h: GRID_SIZE * 7 },
|
|
|
|
|
component: CalendarWidget,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export { CalendarWidget };
|