blog / src /components /widget /Calendar.astro
cacode's picture
Upload 434 files
96dd062 verified
---
import { Icon } from "astro-icon/components";
import WidgetLayout from "@/components/common/WidgetLayout.astro";
import { siteConfig } from "@/config/siteConfig";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import { url } from "@/utils/url-utils";
interface Props {
class?: string;
style?: string;
}
const { class: className, style } = Astro.props;
// 月份名称(使用 i18n)
const monthNames = [
i18n(I18nKey.calendarJanuary),
i18n(I18nKey.calendarFebruary),
i18n(I18nKey.calendarMarch),
i18n(I18nKey.calendarApril),
i18n(I18nKey.calendarMay),
i18n(I18nKey.calendarJune),
i18n(I18nKey.calendarJuly),
i18n(I18nKey.calendarAugust),
i18n(I18nKey.calendarSeptember),
i18n(I18nKey.calendarOctober),
i18n(I18nKey.calendarNovember),
i18n(I18nKey.calendarDecember),
];
// 星期名称(简写,使用 i18n)
const weekDays = [
i18n(I18nKey.calendarSunday),
i18n(I18nKey.calendarMonday),
i18n(I18nKey.calendarTuesday),
i18n(I18nKey.calendarWednesday),
i18n(I18nKey.calendarThursday),
i18n(I18nKey.calendarFriday),
i18n(I18nKey.calendarSaturday),
];
// 年份文本
const yearText = i18n(I18nKey.year);
// 获取当前语言
const currentLang = siteConfig.lang || "en";
const calendarDataUrl = url("/api/calendar.json");
const postUrlPrefix = url("/posts/");
---
<WidgetLayout id="calendar-widget" class={className} style={style}>
<div class="calendar-container">
<div class="flex justify-between items-center mb-4">
<button id="prev-month-btn" class="btn-plain rounded-lg w-8 h-8 flex items-center justify-center hover:bg-(--btn-plain-bg-hover) transition-colors" aria-label="Previous Month">
<Icon name="fa7-solid:chevron-left" class="text-sm" />
</button>
<div id="current-month-display" class="text-lg font-bold text-neutral-900 dark:text-neutral-100 cursor-pointer hover:text-(--primary) transition-colors select-none"></div>
<div class="flex gap-2">
<button id="reset-month-btn" class="btn-plain rounded-lg w-8 h-8 flex items-center justify-center hover:bg-(--btn-plain-bg-hover) transition-colors" aria-label="Back to Today">
<Icon name="fa7-solid:arrow-rotate-left" class="text-sm" />
</button>
<button id="next-month-btn" class="btn-plain rounded-lg w-8 h-8 flex items-center justify-center hover:bg-(--btn-plain-bg-hover) transition-colors" aria-label="Next Month">
<Icon name="fa7-solid:chevron-right" class="text-sm" />
</button>
</div>
</div>
<!-- 日历视图容器 -->
<div id="calendar-view-container">
<!-- 星期标题 -->
<div class="weekdays grid grid-cols-7 gap-1 mb-2">
{weekDays.map(day => (
<div class="text-center text-xs text-neutral-500 dark:text-neutral-400 font-medium">
{day}
</div>
))}
</div>
<!-- 日历格子(由客户端动态生成) -->
<div class="calendar-grid grid grid-cols-7 gap-1" id="calendar-grid">
<!-- 将由 JavaScript 填充 -->
</div>
</div>
<!-- 月份选择视图 -->
<div id="month-view-container" class="hidden grid grid-cols-3 gap-2">
<!-- 将由 JavaScript 填充 -->
</div>
<!-- 年份选择视图 -->
<div id="year-view-container" class="hidden grid grid-cols-3 gap-2">
<!-- 将由 JavaScript 填充 -->
</div>
<!-- 文章列表 -->
<div id="calendar-posts" class="mt-3">
<div class="border-t border-neutral-200 dark:border-neutral-700 mb-2" id="calendar-posts-divider" style="display: none;"></div>
<div class="flex flex-col gap-1" id="calendar-posts-list">
<!-- 将由 JavaScript 填充 -->
</div>
</div>
</div>
</WidgetLayout>
<script is:inline define:vars={{ monthNames, weekDays, yearText, currentLang, calendarDataUrl, postUrlPrefix }}>
// State variables
let displayYear = new Date().getFullYear();
let displayMonth = new Date().getMonth();
let currentView = 'day'; // 'day' | 'month' | 'year'
let postDateMap = {};
let allPostsData = [];
let availableYears = [];
async function fetchData() {
try {
const response = await fetch(calendarDataUrl);
allPostsData = await response.json();
// Reconstruct postDateMap and availableYears
postDateMap = {};
const yearsSet = new Set();
allPostsData.forEach(post => {
const date = new Date(post.published);
const dateKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
if (!postDateMap[dateKey]) {
postDateMap[dateKey] = [];
}
postDateMap[dateKey].push({ id: post.id, title: post.title, published: post.published });
yearsSet.add(date.getFullYear());
});
availableYears = Array.from(yearsSet).sort((a, b) => b - a);
renderCalendar();
} catch (error) {
console.error("Failed to fetch calendar data", error);
}
}
// 客户端动态渲染日历
function renderCalendar() {
const container = document.getElementById('calendar-view-container');
const monthContainer = document.getElementById('month-view-container');
const yearContainer = document.getElementById('year-view-container');
const postsContainer = document.getElementById('calendar-posts');
// Update visibility
if (container) container.style.display = currentView === 'day' ? 'block' : 'none';
if (monthContainer) monthContainer.style.display = currentView === 'month' ? 'grid' : 'none';
if (yearContainer) yearContainer.style.display = currentView === 'year' ? 'grid' : 'none';
if (postsContainer) postsContainer.style.display = currentView === 'day' ? 'block' : 'none';
updateHeader();
if (currentView === 'day') {
renderDayView();
} else if (currentView === 'month') {
renderMonthView();
} else if (currentView === 'year') {
renderYearView();
}
}
function updateHeader() {
const navDisplay = document.getElementById('current-month-display');
const resetBtn = document.getElementById('reset-month-btn');
const prevBtn = document.getElementById('prev-month-btn');
const nextBtn = document.getElementById('next-month-btn');
if (navDisplay) {
if (currentView === 'day') {
if (currentLang.startsWith('zh') || currentLang.startsWith('ja')) {
navDisplay.textContent = `${displayYear}${yearText}${monthNames[displayMonth]}`;
} else {
navDisplay.textContent = `${monthNames[displayMonth]} ${displayYear}`;
}
} else if (currentView === 'month') {
navDisplay.textContent = `${displayYear}${yearText}`;
} else if (currentView === 'year') {
navDisplay.textContent = yearText;
}
}
if (resetBtn) {
const now = new Date();
const isCurrent = displayYear === now.getFullYear() && displayMonth === now.getMonth();
resetBtn.style.display = (currentView === 'day' && isCurrent) ? 'none' : 'flex';
}
// Hide prev/next buttons in year view as we show all years
if (prevBtn) prevBtn.style.visibility = currentView === 'year' ? 'hidden' : 'visible';
if (nextBtn) nextBtn.style.visibility = currentView === 'year' ? 'hidden' : 'visible';
}
function renderDayView() {
const now = new Date();
const currentYear = displayYear;
const currentMonth = displayMonth;
const currentDate = now.getDate();
const isCurrentMonth = currentYear === now.getFullYear() && currentMonth === now.getMonth();
// 获取月份的第一天是星期几
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay();
// 获取当月天数
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
// 生成日历格子
const calendarGrid = document.getElementById('calendar-grid');
if (!calendarGrid) return;
const calendarDays = [];
// 添加空白格子(月初空白)
for (let i = 0; i < firstDayOfMonth; i++) {
calendarDays.push({ day: null, hasPost: false, count: 0, dateKey: "" });
}
// 添加每一天
for (let day = 1; day <= daysInMonth; day++) {
const dateKey = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const posts = postDateMap[dateKey] || [];
const count = posts.length;
calendarDays.push({
day,
hasPost: count > 0,
count,
dateKey
});
}
// 渲染日历格子
calendarGrid.innerHTML = calendarDays.map(({day, hasPost, count, dateKey}) => {
const isToday = day === currentDate && isCurrentMonth;
const classes = [
"calendar-day aspect-square flex items-center justify-center rounded text-sm relative cursor-pointer"
];
if (!day) {
classes.push("text-neutral-400 dark:text-neutral-600");
} else if (!hasPost) {
classes.push("text-neutral-700 dark:text-neutral-300");
} else {
classes.push("text-neutral-900 dark:text-neutral-100 font-bold");
}
if (isToday) {
classes.push("ring-2 ring-(--primary)");
}
return `
<div
class="${classes.join(' ')}"
data-date="${dateKey}"
data-has-post="${hasPost}"
>
${day || ''}
${hasPost ? '<span class="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-(--primary)"></span>' : ''}
${hasPost && count > 1 ? `<span class="absolute top-0 right-0 text-[10px] text-(--primary) font-bold">${count}</span>` : ''}
</div>
`;
}).join('');
// 获取当月所有文章
const currentMonthPosts = allPostsData.filter(post => {
const date = new Date(post.published);
return date.getFullYear() === currentYear && date.getMonth() === currentMonth;
});
// 显示当月文章列表
showMonthlyPosts(currentMonthPosts);
// 添加点击事件监听
setupClickHandlers(currentMonthPosts);
}
function renderMonthView() {
const container = document.getElementById('month-view-container');
if (!container) return;
// Calculate which months have posts for the currently displayed year
const monthsWithPosts = new Set();
allPostsData.forEach(post => {
const date = new Date(post.published);
if (date.getFullYear() === displayYear) {
monthsWithPosts.add(date.getMonth());
}
});
container.innerHTML = monthNames.map((name, index) => {
const isCurrent = index === displayMonth;
const hasPost = monthsWithPosts.has(index);
const classes = [
"p-2 text-center text-sm rounded cursor-pointer hover:bg-(--btn-plain-bg-hover) transition-colors relative"
];
if (isCurrent) {
classes.push("text-(--primary) font-bold bg-(--btn-plain-bg-hover)");
} else {
classes.push("text-neutral-700 dark:text-neutral-300");
}
const dotHtml = hasPost ? '<span class="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-(--primary)"></span>' : '';
return `<div class="${classes.join(' ')}" data-month="${index}">${name}${dotHtml}</div>`;
}).join('');
container.querySelectorAll('[data-month]').forEach(el => {
el.addEventListener('click', () => {
displayMonth = parseInt(el.getAttribute('data-month'));
currentView = 'day';
renderCalendar();
});
});
}
function renderYearView() {
const container = document.getElementById('year-view-container');
if (!container) return;
container.innerHTML = availableYears.map(year => {
const isCurrent = year === displayYear;
const classes = [
"p-2 text-center text-sm rounded cursor-pointer hover:bg-(--btn-plain-bg-hover) transition-colors"
];
if (isCurrent) {
classes.push("text-(--primary) font-bold bg-(--btn-plain-bg-hover)");
} else {
classes.push("text-neutral-700 dark:text-neutral-300");
}
return `<div class="${classes.join(' ')}" data-year="${year}">${year}</div>`;
}).join('');
container.querySelectorAll('[data-year]').forEach(el => {
el.addEventListener('click', () => {
displayYear = parseInt(el.getAttribute('data-year'));
currentView = 'month';
renderCalendar();
});
});
}
// 显示当月所有文章
function showMonthlyPosts(currentMonthPosts) {
const postsList = document.getElementById('calendar-posts-list');
const divider = document.getElementById('calendar-posts-divider');
if (postsList) {
postsList.innerHTML = currentMonthPosts.map(post => {
const date = new Date(post.published);
const dateStr = `${date.getMonth() + 1}-${date.getDate()}`;
return `
<a href="${postUrlPrefix}${post.id}/" class="flex justify-between items-center text-sm text-neutral-700 dark:text-neutral-300 hover:text-(--primary) dark:hover:text-(--primary) transition-colors px-2 py-1 rounded hover:bg-(--btn-plain-bg-hover)">
<span class="truncate">${post.title}</span>
<span class="text-xs text-neutral-500 dark:text-neutral-400 ml-2 whitespace-nowrap">${dateStr}</span>
</a>
`}).join('');
// 显示/隐藏分割线
if (divider) {
divider.style.display = currentMonthPosts.length > 0 ? 'block' : 'none';
}
}
}
// 设置日历格子点击事件
function setupClickHandlers(currentMonthPosts) {
const calendarDays = document.querySelectorAll('.calendar-day[data-date]');
const postsList = document.getElementById('calendar-posts-list');
const divider = document.getElementById('calendar-posts-divider');
let currentSelectedDay = null;
calendarDays.forEach(dayElement => {
dayElement.addEventListener('click', () => {
const dateKey = dayElement.getAttribute('data-date');
const hasPost = dayElement.getAttribute('data-has-post') === 'true';
if (!hasPost || !dateKey) return;
// 切换选中状态
if (currentSelectedDay === dayElement) {
// 取消选中,恢复显示当月所有文章
dayElement.classList.remove('calendar-day-selected');
currentSelectedDay = null;
showMonthlyPosts(currentMonthPosts);
return;
}
// 移除之前选中的样式
if (currentSelectedDay) {
currentSelectedDay.classList.remove('calendar-day-selected');
}
// 添加选中样式
dayElement.classList.add('calendar-day-selected');
currentSelectedDay = dayElement;
// 获取该日期的文章
const posts = postDateMap[dateKey] || [];
if (posts.length > 0 && postsList) {
// 渲染文章列表
postsList.innerHTML = posts.map(post => {
const date = new Date(post.published);
const dateStr = `${date.getMonth() + 1}-${date.getDate()}`;
return `
<a href="${postUrlPrefix}${post.id}/" class="flex justify-between items-center text-sm text-neutral-700 dark:text-neutral-300 hover:text-(--primary) dark:hover:text-(--primary) transition-colors px-2 py-1 rounded hover:bg-(--btn-plain-bg-hover)">
<span class="truncate">${post.title}</span>
<span class="text-xs text-neutral-500 dark:text-neutral-400 ml-2 whitespace-nowrap">${dateStr}</span>
</a>
`}).join('');
// 显示分割线
if (divider) {
divider.style.display = 'block';
}
}
});
});
}
function changeMonth(delta) {
if (currentView === 'day') {
displayMonth += delta;
if (displayMonth > 11) {
displayMonth = 0;
displayYear++;
} else if (displayMonth < 0) {
displayMonth = 11;
displayYear--;
}
} else if (currentView === 'month') {
displayYear += delta;
}
renderCalendar();
}
function resetToToday() {
const now = new Date();
displayYear = now.getFullYear();
displayMonth = now.getMonth();
currentView = 'day';
renderCalendar();
}
function initCalendar() {
// Reset to current date on init
const now = new Date();
displayYear = now.getFullYear();
displayMonth = now.getMonth();
fetchData();
// Bind events
const prevBtn = document.getElementById('prev-month-btn');
const nextBtn = document.getElementById('next-month-btn');
const resetBtn = document.getElementById('reset-month-btn');
const navDisplay = document.getElementById('current-month-display');
if (prevBtn) prevBtn.onclick = () => changeMonth(-1);
if (nextBtn) nextBtn.onclick = () => changeMonth(1);
if (resetBtn) resetBtn.onclick = () => resetToToday();
if (navDisplay) {
navDisplay.onclick = () => {
if (currentView === 'day') {
currentView = 'month';
} else if (currentView === 'month') {
currentView = 'year';
}
renderCalendar();
};
}
}
// 页面加载时渲染日历
initCalendar();
// 页面切换时重新渲染
document.addEventListener("swup:contentReplaced", () => {
setTimeout(initCalendar, 100);
});
</script>
<style>
.calendar-day {
transition: all 0.2s ease;
min-height: 32px;
}
.calendar-day[data-has-post="true"]:hover {
transform: translateY(-2px);
}
/* 自定义选中日期样式,替代 bg-opacity-10 */
.calendar-day-selected {
background-color: oklch(from var(--primary) l c h / 0.1);
}
</style>