Files
timeline/public/js/calendar.js
licsber 96042461f9
Some checks failed
Build and Push Docker Image / buildx (push) Has been cancelled
feat: update time tooltip.
2025-11-11 16:42:49 +08:00

422 lines
13 KiB
JavaScript

import state from './state.js';
import { fetchCalendar, saveCalendar, ApiError } from './api.js';
import { showToast } from './toast.js';
export default class CalendarView {
constructor({ onUnauthorized } = {}) {
this.calendarEl = document.getElementById('calendar');
this.monthDisplayEl = document.getElementById('currentMonth');
this.prevBtn = document.getElementById('prevMonth');
this.nextBtn = document.getElementById('nextMonth');
this.todayBtn = document.getElementById('todayBtn');
this.weekdays = ['日', '一', '二', '三', '四', '五', '六'];
this.monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
this.onUnauthorized = onUnauthorized;
this.saveDelay = 500;
this.pendingTooltipDate = null; // Track date that needs tooltip after render
}
init() {
this.bindNavigation();
this.renderCalendar();
}
bindNavigation() {
if (this.prevBtn) {
this.prevBtn.addEventListener('click', () => {
state.currentDate.setMonth(state.currentDate.getMonth() - 1);
this.renderCalendar();
});
}
if (this.nextBtn) {
this.nextBtn.addEventListener('click', () => {
state.currentDate.setMonth(state.currentDate.getMonth() + 1);
this.renderCalendar();
});
}
if (this.todayBtn) {
this.todayBtn.addEventListener('click', () => {
state.currentDate = new Date();
this.renderCalendar();
});
}
}
async loadData() {
if (!state.currentServer || !state.token) {
return;
}
try {
showToast('正在加载...', 'info');
const result = await fetchCalendar(state.currentServer, state.token);
// Convert the new data structure to the old one for backward compatibility
const markedDates = {};
for (const [date, data] of Object.entries(result.markedDates || {})) {
if (typeof data === 'string') {
// Old format - just status
markedDates[date] = data;
} else if (data && typeof data === 'object') {
// New format - object with status and updatedAt
markedDates[date] = data.status;
}
}
state.markedDates = markedDates;
// Store the full data including timestamps
state.markedDatesWithTimestamps = result.markedDates || {};
this.renderCalendar();
showToast('数据加载成功', 'success');
} catch (error) {
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
showToast('认证已过期,请重新登录', 'error');
if (this.onUnauthorized) {
this.onUnauthorized();
}
return;
}
showToast(error.message || '加载失败', 'error');
}
}
clearData() {
state.markedDates = {};
state.markedDatesWithTimestamps = {};
this.renderCalendar();
}
renderCalendar() {
if (!this.calendarEl || !this.monthDisplayEl) return;
const currentDate = state.currentDate;
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
this.calendarEl.innerHTML = '';
this.weekdays.forEach(day => {
const weekdayEl = document.createElement('div');
weekdayEl.className = 'weekday';
weekdayEl.textContent = day;
this.calendarEl.appendChild(weekdayEl);
});
this.monthDisplayEl.textContent = `${year}${this.monthNames[month]}`;
const firstDay = this.getFirstDayOfMonth(currentDate);
const daysInMonth = this.getDaysInMonth(currentDate);
const prevMonthDate = new Date(year, month, 0);
const daysInPrevMonth = prevMonthDate.getDate();
for (let i = firstDay - 1; i >= 0; i--) {
const day = daysInPrevMonth - i;
const date = new Date(year, month - 1, day);
this.calendarEl.appendChild(this.createDayElement(date, day, true));
}
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month, day);
this.calendarEl.appendChild(this.createDayElement(date, day, false));
}
const totalCells = this.calendarEl.children.length - this.weekdays.length;
const remainingCells = 42 - totalCells;
for (let day = 1; day <= remainingCells; day++) {
const date = new Date(year, month + 1, day);
this.calendarEl.appendChild(this.createDayElement(date, day, true));
}
}
createDayElement(date, dayNumber, isOtherMonth) {
const dayEl = document.createElement('div');
dayEl.className = 'day';
if (isOtherMonth) {
dayEl.classList.add('other-month');
}
// Check if the day is in the future
if (this.isFutureDate(date)) {
dayEl.classList.add('future-day');
}
if (this.isToday(date)) {
dayEl.classList.add('today');
}
const status = this.getDateStatus(date);
if (status !== 'none') {
dayEl.classList.add(status);
}
const dayNumberEl = document.createElement('div');
dayNumberEl.className = 'day-number';
dayNumberEl.textContent = dayNumber;
dayEl.appendChild(dayNumberEl);
if (status !== 'none') {
const markEl = document.createElement('div');
markEl.className = 'day-mark';
markEl.textContent = status === 'completed' ? '✓' : '○';
dayEl.appendChild(markEl);
}
// Add timestamp tooltip functionality
const timestamp = this.getDateTimestamp(date);
if (timestamp) {
// Store the ISO timestamp
dayEl.dataset.modificationTime = timestamp;
// Add hover events for custom tooltip
dayEl.addEventListener('mouseenter', (e) => {
const isoTime = e.currentTarget.dataset.modificationTime;
// Convert to user's local timezone
const localTime = new Date(isoTime).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
this.showTooltip(e, `修改时间: ${localTime}`);
});
dayEl.addEventListener('mouseleave', () => {
this.hideTooltip();
});
}
dayEl.addEventListener('click', (e) => {
if (!isOtherMonth) {
this.toggleDateStatus(date);
}
});
return dayEl;
}
// Tooltip methods
showTooltip(event, content) {
// Remove any existing tooltips completely
const existingTooltips = document.querySelectorAll('.tooltip');
existingTooltips.forEach(tip => {
if (tip.parentNode) {
tip.parentNode.removeChild(tip);
}
});
// Create new tooltip element with unique ID
const tooltip = document.createElement('div');
const uniqueId = 'tooltip-' + Date.now();
tooltip.id = uniqueId;
tooltip.className = 'tooltip';
tooltip.textContent = content;
document.body.appendChild(tooltip);
// Position tooltip relative to the target element
const rect = event.currentTarget.getBoundingClientRect();
tooltip.style.left = (rect.left + rect.width / 2) + 'px';
tooltip.style.top = (rect.top - 10) + 'px';
tooltip.style.transform = 'translate(-50%, -100%)';
// Show tooltip with a slight delay to ensure position is set
requestAnimationFrame(() => {
tooltip.classList.add('show');
});
}
hideTooltip() {
const tooltips = document.querySelectorAll('.tooltip');
tooltips.forEach(tooltip => {
tooltip.classList.remove('show');
// Remove tooltip element after transition
setTimeout(() => {
if (tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
}
}, 200);
});
}
refreshTooltip(event, element) {
// Check if element has timestamp data
const isoTime = element.dataset.modificationTime;
if (isoTime) {
// Convert to user's local timezone
const localTime = new Date(isoTime).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
this.showTooltip(event, `修改时间: ${localTime}`);
} else {
// No timestamp, hide tooltip
this.hideTooltip();
}
}
showTooltipForDate(dateKey) {
// Find the day element for this date
const dayElements = this.calendarEl.querySelectorAll('.day:not(.other-month)');
for (const dayEl of dayElements) {
if (dayEl.dataset.modificationTime) {
// Extract date from the element's position
const dayNumber = dayEl.querySelector('.day-number')?.textContent;
if (dayNumber) {
const year = state.currentDate.getFullYear();
const month = state.currentDate.getMonth();
const testDate = new Date(year, month, parseInt(dayNumber));
if (this.formatDate(testDate) === dateKey) {
// Found the element, show tooltip
const isoTime = dayEl.dataset.modificationTime;
const localTime = new Date(isoTime).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
// Create a synthetic event for positioning
const rect = dayEl.getBoundingClientRect();
const syntheticEvent = {
currentTarget: dayEl
};
this.showTooltip(syntheticEvent, `修改时间: ${localTime}`);
break;
}
}
}
}
}
toggleDateStatus(date) {
if (!state.currentServer || !state.token) {
showToast('请先选择服务器并登录', 'error');
return;
}
const dateKey = this.formatDate(date);
const currentStatus = state.markedDates[dateKey] || 'none';
let nextStatus;
switch (currentStatus) {
case 'none':
nextStatus = 'completed';
break;
case 'completed':
nextStatus = 'partial';
break;
case 'partial':
default:
nextStatus = 'none';
break;
}
if (nextStatus === 'none') {
delete state.markedDates[dateKey];
// Clear pending tooltip for removed marks
this.pendingTooltipDate = null;
this.hideTooltip();
} else {
state.markedDates[dateKey] = nextStatus;
// Store the date for tooltip after save completes
this.pendingTooltipDate = dateKey;
}
this.renderCalendar();
this.scheduleSave();
}
scheduleSave() {
if (!state.currentServer || !state.token) return;
if (state.saveTimeoutId) {
clearTimeout(state.saveTimeoutId);
}
state.saveTimeoutId = setTimeout(() => {
this.persistMarkedDates();
}, this.saveDelay);
}
async persistMarkedDates() {
state.saveTimeoutId = null;
try {
await saveCalendar(state.currentServer, state.token, state.markedDates);
showToast('数据已保存', 'success');
// Reload data to get updated timestamps
await this.loadData();
// Show tooltip for the pending date after data is loaded
if (this.pendingTooltipDate) {
this.showTooltipForDate(this.pendingTooltipDate);
this.pendingTooltipDate = null;
}
} catch (error) {
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
showToast('认证已过期,请重新登录', 'error');
if (this.onUnauthorized) {
this.onUnauthorized();
}
return;
}
showToast(error.message || '保存失败', 'error');
}
}
getFirstDayOfMonth(date) {
return new Date(date.getFullYear(), date.getMonth(), 1).getDay();
}
getDaysInMonth(date) {
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
}
getDateStatus(date) {
const dateKey = this.formatDate(date);
return state.markedDates[dateKey] || 'none';
}
getDateTimestamp(date) {
const dateKey = this.formatDate(date);
const dateData = state.markedDatesWithTimestamps[dateKey];
if (dateData && typeof dateData === 'object' && dateData.updatedAt) {
return dateData.updatedAt;
}
return null;
}
isToday(date) {
const today = new Date();
return date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
}
// Check if a date is in the future
isFutureDate(date) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const checkDate = new Date(date);
checkDate.setHours(0, 0, 0, 0);
return checkDate > today;
}
formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
}