cursor done.
This commit is contained in:
101
public/index.html
Normal file
101
public/index.html
Normal file
@@ -0,0 +1,101 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>任务日历系统</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- 登录/注册模态框 -->
|
||||
<div id="authModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="authTitle">登录</h2>
|
||||
<button class="modal-close" id="closeAuthModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="authForm">
|
||||
<div class="form-group">
|
||||
<label for="authServer">服务器地址:</label>
|
||||
<input type="text" id="authServer" placeholder="例如:http://localhost:3000" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="authUsername">用户名:</label>
|
||||
<input type="text" id="authUsername" placeholder="至少3个字符" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="authPassword">密码:</label>
|
||||
<input type="password" id="authPassword" placeholder="至少6个字符" />
|
||||
</div>
|
||||
<div id="authMessage" class="auth-message"></div>
|
||||
<div class="form-actions">
|
||||
<button id="loginBtn" class="btn-primary">登录</button>
|
||||
<button id="registerBtn" class="btn-secondary">注册</button>
|
||||
</div>
|
||||
<div class="auth-switch">
|
||||
<span id="switchToRegister">还没有账号?点击注册</span>
|
||||
<span id="switchToLogin" style="display: none;">已有账号?点击登录</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主应用容器 -->
|
||||
<div class="container" id="mainContainer" style="display: none;">
|
||||
<header>
|
||||
<div class="user-info">
|
||||
<span id="userDisplay">未登录</span>
|
||||
<button id="logoutBtn" class="btn-logout">退出</button>
|
||||
</div>
|
||||
<h1>任务日历</h1>
|
||||
<div class="controls">
|
||||
<button id="prevMonth" class="btn-nav">‹</button>
|
||||
<span id="currentMonth" class="month-display"></span>
|
||||
<button id="nextMonth" class="btn-nav">›</button>
|
||||
<button id="todayBtn" class="btn-today">今天</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item">
|
||||
<span class="mark completed">✓</span>
|
||||
<span>任务已完成</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="mark partial">○</span>
|
||||
<span>部分未完成</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="mark none"></span>
|
||||
<span>未标记</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calendar" id="calendar"></div>
|
||||
|
||||
<div class="instructions">
|
||||
<p>💡 点击日期可切换标记状态:未标记 → 已完成 → 部分完成 → 未标记</p>
|
||||
<p>📌 选择服务器后,数据会自动加载和保存</p>
|
||||
</div>
|
||||
|
||||
<div class="server-section">
|
||||
<label for="serverInput">API服务器地址:</label>
|
||||
<div class="server-input-group">
|
||||
<input type="text" id="serverInput" placeholder="例如:http://localhost:3000" />
|
||||
<button id="addServerBtn" class="btn-add">添加</button>
|
||||
<select id="serverSelect" class="server-select">
|
||||
<option value="">选择服务器</option>
|
||||
</select>
|
||||
<button id="removeServerBtn" class="btn-remove">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右上角提示消息 -->
|
||||
<div id="statusMessage" class="status-message-toast"></div>
|
||||
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
66
public/js/api.js
Normal file
66
public/js/api.js
Normal file
@@ -0,0 +1,66 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(message, status) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request(server, path, { method = 'GET', body, token } = {}) {
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${server}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok || data.success === false) {
|
||||
const message = data.error || data.message || response.statusText;
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ApiError('无法连接到服务器,请确认服务已启动', 0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function register(server, username, password) {
|
||||
return request(server, '/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: { username, password }
|
||||
});
|
||||
}
|
||||
|
||||
export async function login(server, username, password) {
|
||||
return request(server, '/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: { username, password }
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCurrentUser(server, token) {
|
||||
return request(server, '/api/auth/me', { token });
|
||||
}
|
||||
|
||||
export async function fetchCalendar(server, token) {
|
||||
return request(server, '/api/calendar', { token });
|
||||
}
|
||||
|
||||
export async function saveCalendar(server, token, markedDates) {
|
||||
return request(server, '/api/calendar', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { markedDates }
|
||||
});
|
||||
}
|
||||
343
public/js/auth.js
Normal file
343
public/js/auth.js
Normal file
@@ -0,0 +1,343 @@
|
||||
import state from './state.js';
|
||||
import {
|
||||
getToken,
|
||||
setToken,
|
||||
getUser,
|
||||
setUser,
|
||||
getCurrentServer,
|
||||
setCurrentServer as persistCurrentServer,
|
||||
clearAuth
|
||||
} from './storage.js';
|
||||
import { showToast } from './toast.js';
|
||||
import { login, register, ApiError } from './api.js';
|
||||
|
||||
export default class AuthManager {
|
||||
constructor(calendarView, serverManager) {
|
||||
this.calendarView = calendarView;
|
||||
this.serverManager = serverManager;
|
||||
|
||||
this.authModal = document.getElementById('authModal');
|
||||
this.mainContainer = document.getElementById('mainContainer');
|
||||
this.authTitle = document.getElementById('authTitle');
|
||||
this.authUsername = document.getElementById('authUsername');
|
||||
this.authPassword = document.getElementById('authPassword');
|
||||
this.authServer = document.getElementById('authServer');
|
||||
this.authMessage = document.getElementById('authMessage');
|
||||
this.loginBtn = document.getElementById('loginBtn');
|
||||
this.registerBtn = document.getElementById('registerBtn');
|
||||
this.switchToRegister = document.getElementById('switchToRegister');
|
||||
this.switchToLogin = document.getElementById('switchToLogin');
|
||||
this.closeModalBtn = document.getElementById('closeAuthModal');
|
||||
this.logoutBtn = document.getElementById('logoutBtn');
|
||||
this.userDisplay = document.getElementById('userDisplay');
|
||||
}
|
||||
|
||||
init() {
|
||||
this.bindEvents();
|
||||
this.updateAuthUI();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
if (this.loginBtn) {
|
||||
this.loginBtn.addEventListener('click', () => this.handleLogin());
|
||||
}
|
||||
|
||||
if (this.registerBtn) {
|
||||
this.registerBtn.addEventListener('click', () => this.handleRegister());
|
||||
}
|
||||
|
||||
if (this.switchToRegister) {
|
||||
this.switchToRegister.addEventListener('click', () => {
|
||||
state.isLoginMode = false;
|
||||
this.updateAuthUI();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.switchToLogin) {
|
||||
this.switchToLogin.addEventListener('click', () => {
|
||||
state.isLoginMode = true;
|
||||
this.updateAuthUI();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.closeModalBtn) {
|
||||
this.closeModalBtn.addEventListener('click', () => {
|
||||
if (!state.token) {
|
||||
showToast('请先登录后再关闭', 'error');
|
||||
return;
|
||||
}
|
||||
this.hideAuthModal();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.logoutBtn) {
|
||||
this.logoutBtn.addEventListener('click', () => this.logout());
|
||||
}
|
||||
|
||||
if (this.authPassword) {
|
||||
this.authPassword.addEventListener('keypress', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
state.isLoginMode ? this.handleLogin() : this.handleRegister();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handleLogin() {
|
||||
const server = this.getServerInput();
|
||||
const username = (this.authUsername?.value || '').trim();
|
||||
const password = this.authPassword?.value || '';
|
||||
|
||||
if (!server || !username || !password) {
|
||||
this.showAuthMessage('请输入服务器、用户名和密码');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.showAuthMessage('正在登录...', 'info');
|
||||
const result = await login(server, username, password);
|
||||
this.afterAuthSuccess(server, result.token, {
|
||||
username: result.username,
|
||||
userId: result.userId
|
||||
});
|
||||
showToast('登录成功', 'success');
|
||||
} catch (error) {
|
||||
this.handleAuthError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async handleRegister() {
|
||||
const server = this.getServerInput();
|
||||
const username = (this.authUsername?.value || '').trim();
|
||||
const password = this.authPassword?.value || '';
|
||||
|
||||
if (!server || !username || !password) {
|
||||
this.showAuthMessage('请输入服务器、用户名和密码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (username.length < 3) {
|
||||
this.showAuthMessage('用户名至少需要3个字符');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
this.showAuthMessage('密码至少需要6个字符');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.showAuthMessage('正在注册...', 'info');
|
||||
const result = await register(server, username, password);
|
||||
this.afterAuthSuccess(server, result.token, {
|
||||
username: result.username,
|
||||
userId: result.userId
|
||||
});
|
||||
showToast('注册成功', 'success');
|
||||
} catch (error) {
|
||||
this.handleAuthError(error);
|
||||
}
|
||||
}
|
||||
|
||||
getServerInput() {
|
||||
const value = this.authServer?.value.trim();
|
||||
if (!value) {
|
||||
this.showAuthMessage('请输入服务器地址');
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(value);
|
||||
return value;
|
||||
} catch (error) {
|
||||
this.showAuthMessage('无效的服务器地址格式');
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
afterAuthSuccess(server, token, user) {
|
||||
state.currentServer = server;
|
||||
state.token = token;
|
||||
state.user = user;
|
||||
|
||||
persistCurrentServer(server);
|
||||
setToken(server, token);
|
||||
setUser(server, user);
|
||||
|
||||
if (this.serverManager) {
|
||||
this.serverManager.ensureServerInList(server);
|
||||
}
|
||||
|
||||
this.hideAuthModal();
|
||||
this.showMainContainer();
|
||||
this.updateUserDisplay();
|
||||
|
||||
if (this.serverManager) {
|
||||
this.serverManager.setCurrentServer(server);
|
||||
}
|
||||
|
||||
this.calendarView.loadData();
|
||||
this.clearAuthForm();
|
||||
this.clearAuthMessage();
|
||||
}
|
||||
|
||||
handleAuthError(error) {
|
||||
if (error instanceof ApiError) {
|
||||
this.showAuthMessage(error.message || '操作失败');
|
||||
return;
|
||||
}
|
||||
this.showAuthMessage('操作失败,请稍后再试');
|
||||
}
|
||||
|
||||
clearAuthForm() {
|
||||
if (this.authPassword) this.authPassword.value = '';
|
||||
}
|
||||
|
||||
showAuthMessage(message, type = 'error') {
|
||||
if (!this.authMessage) return;
|
||||
this.authMessage.textContent = message;
|
||||
this.authMessage.className = `auth-message ${type}`;
|
||||
}
|
||||
|
||||
clearAuthMessage() {
|
||||
if (!this.authMessage) return;
|
||||
this.authMessage.textContent = '';
|
||||
this.authMessage.className = 'auth-message';
|
||||
}
|
||||
|
||||
updateAuthUI() {
|
||||
if (!this.authTitle || !this.loginBtn || !this.registerBtn || !this.switchToLogin || !this.switchToRegister) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.isLoginMode) {
|
||||
this.authTitle.textContent = '登录';
|
||||
this.loginBtn.style.display = 'block';
|
||||
this.registerBtn.style.display = 'none';
|
||||
this.switchToRegister.style.display = 'block';
|
||||
this.switchToLogin.style.display = 'none';
|
||||
} else {
|
||||
this.authTitle.textContent = '注册';
|
||||
this.loginBtn.style.display = 'none';
|
||||
this.registerBtn.style.display = 'block';
|
||||
this.switchToRegister.style.display = 'none';
|
||||
this.switchToLogin.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
showAuthModal(prefillServer) {
|
||||
if (this.authModal) {
|
||||
this.authModal.style.display = 'flex';
|
||||
}
|
||||
if (this.mainContainer) {
|
||||
this.mainContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
const serverValue = prefillServer || state.currentServer || window.location.origin;
|
||||
if (this.authServer) {
|
||||
this.authServer.value = serverValue;
|
||||
}
|
||||
|
||||
state.isLoginMode = true;
|
||||
this.updateAuthUI();
|
||||
this.clearAuthMessage();
|
||||
}
|
||||
|
||||
hideAuthModal() {
|
||||
if (this.authModal) {
|
||||
this.authModal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
showMainContainer() {
|
||||
if (this.mainContainer) {
|
||||
this.mainContainer.style.display = 'block';
|
||||
}
|
||||
this.hideAuthModal();
|
||||
}
|
||||
|
||||
hideMainContainer() {
|
||||
if (this.mainContainer) {
|
||||
this.mainContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
updateUserDisplay() {
|
||||
if (this.userDisplay) {
|
||||
this.userDisplay.textContent = state.user ? `用户: ${state.user.username}` : '未登录';
|
||||
}
|
||||
}
|
||||
|
||||
async handleServerSelection(server) {
|
||||
if (!server) {
|
||||
persistCurrentServer('');
|
||||
state.currentServer = '';
|
||||
state.token = '';
|
||||
state.user = null;
|
||||
this.calendarView.clearData();
|
||||
this.hideMainContainer();
|
||||
this.showAuthModal();
|
||||
this.updateUserDisplay();
|
||||
return;
|
||||
}
|
||||
|
||||
persistCurrentServer(server);
|
||||
state.currentServer = server;
|
||||
|
||||
const storedToken = getToken(server);
|
||||
const storedUser = getUser(server);
|
||||
|
||||
if (storedToken && storedUser) {
|
||||
state.token = storedToken;
|
||||
state.user = storedUser;
|
||||
this.showMainContainer();
|
||||
this.updateUserDisplay();
|
||||
this.calendarView.loadData();
|
||||
this.hideAuthModal();
|
||||
} else {
|
||||
state.token = '';
|
||||
state.user = null;
|
||||
this.calendarView.clearData();
|
||||
this.updateUserDisplay();
|
||||
this.showAuthModal(server);
|
||||
}
|
||||
}
|
||||
|
||||
logout() {
|
||||
if (state.currentServer) {
|
||||
clearAuth(state.currentServer);
|
||||
}
|
||||
state.token = '';
|
||||
state.user = null;
|
||||
this.calendarView.clearData();
|
||||
this.updateUserDisplay();
|
||||
showToast('已退出登录', 'success');
|
||||
this.hideMainContainer();
|
||||
this.showAuthModal(state.currentServer || window.location.origin);
|
||||
}
|
||||
|
||||
checkExistingSession() {
|
||||
const storedServer = getCurrentServer();
|
||||
if (storedServer) {
|
||||
state.currentServer = storedServer;
|
||||
if (this.serverManager) {
|
||||
this.serverManager.ensureServerInList(storedServer);
|
||||
this.serverManager.setCurrentServer(storedServer);
|
||||
}
|
||||
}
|
||||
|
||||
const storedToken = storedServer ? getToken(storedServer) : '';
|
||||
const storedUser = storedServer ? getUser(storedServer) : null;
|
||||
|
||||
if (storedServer && storedToken && storedUser) {
|
||||
state.token = storedToken;
|
||||
state.user = storedUser;
|
||||
this.showMainContainer();
|
||||
this.updateUserDisplay();
|
||||
this.calendarView.loadData();
|
||||
} else {
|
||||
this.hideMainContainer();
|
||||
this.showAuthModal(storedServer || window.location.origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
244
public/js/calendar.js
Normal file
244
public/js/calendar.js
Normal file
@@ -0,0 +1,244 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
state.markedDates = 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 = {};
|
||||
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');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
dayEl.addEventListener('click', () => {
|
||||
if (!isOtherMonth) {
|
||||
this.toggleDateStatus(date);
|
||||
}
|
||||
});
|
||||
|
||||
return dayEl;
|
||||
}
|
||||
|
||||
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];
|
||||
} else {
|
||||
state.markedDates[dateKey] = nextStatus;
|
||||
}
|
||||
|
||||
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');
|
||||
} 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';
|
||||
}
|
||||
|
||||
isToday(date) {
|
||||
const today = new Date();
|
||||
return date.getFullYear() === today.getFullYear() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getDate() === today.getDate();
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
24
public/js/main.js
Normal file
24
public/js/main.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import CalendarView from './calendar.js';
|
||||
import ServerManager from './serverManager.js';
|
||||
import AuthManager from './auth.js';
|
||||
|
||||
let authManagerRef = null;
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const calendarView = new CalendarView({
|
||||
onUnauthorized: () => authManagerRef && authManagerRef.logout()
|
||||
});
|
||||
calendarView.init();
|
||||
|
||||
const serverManager = new ServerManager();
|
||||
serverManager.init();
|
||||
|
||||
authManagerRef = new AuthManager(calendarView, serverManager);
|
||||
authManagerRef.init();
|
||||
|
||||
serverManager.onChange((server) => {
|
||||
authManagerRef.handleServerSelection(server);
|
||||
});
|
||||
|
||||
authManagerRef.checkExistingSession();
|
||||
});
|
||||
166
public/js/serverManager.js
Normal file
166
public/js/serverManager.js
Normal file
@@ -0,0 +1,166 @@
|
||||
import state from './state.js';
|
||||
import {
|
||||
getServers,
|
||||
saveServers,
|
||||
getCurrentServer,
|
||||
setCurrentServer as persistCurrentServer,
|
||||
clearAuth
|
||||
} from './storage.js';
|
||||
import { showToast } from './toast.js';
|
||||
|
||||
export default class ServerManager {
|
||||
constructor() {
|
||||
this.serverInput = document.getElementById('serverInput');
|
||||
this.addBtn = document.getElementById('addServerBtn');
|
||||
this.removeBtn = document.getElementById('removeServerBtn');
|
||||
this.selectEl = document.getElementById('serverSelect');
|
||||
this.servers = [];
|
||||
this.changeHandler = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.servers = getServers();
|
||||
this.renderOptions();
|
||||
|
||||
const storedCurrent = getCurrentServer();
|
||||
if (storedCurrent) {
|
||||
this.setCurrentServer(storedCurrent);
|
||||
state.currentServer = storedCurrent;
|
||||
}
|
||||
|
||||
if (this.addBtn) {
|
||||
this.addBtn.addEventListener('click', () => this.handleAdd());
|
||||
}
|
||||
|
||||
if (this.removeBtn) {
|
||||
this.removeBtn.addEventListener('click', () => this.handleRemove());
|
||||
}
|
||||
|
||||
if (this.selectEl) {
|
||||
this.selectEl.addEventListener('change', () => this.handleSelect());
|
||||
}
|
||||
}
|
||||
|
||||
onChange(handler) {
|
||||
this.changeHandler = handler;
|
||||
}
|
||||
|
||||
handleAdd() {
|
||||
const server = (this.serverInput?.value || '').trim();
|
||||
|
||||
if (!server) {
|
||||
showToast('请输入服务器地址', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isValidUrl(server)) {
|
||||
showToast('无效的服务器地址格式', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.servers.includes(server)) {
|
||||
showToast('服务器已存在', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.servers.push(server);
|
||||
saveServers(this.servers);
|
||||
this.renderOptions();
|
||||
this.setCurrentServer(server);
|
||||
persistCurrentServer(server);
|
||||
showToast('服务器已添加', 'success');
|
||||
|
||||
if (this.changeHandler) {
|
||||
this.changeHandler(server);
|
||||
}
|
||||
if (this.serverInput) {
|
||||
this.serverInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
handleRemove() {
|
||||
const server = this.selectEl?.value;
|
||||
if (!server) {
|
||||
showToast('请选择要删除的服务器', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`确定要删除服务器 "${server}" 吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.servers = this.servers.filter(item => item !== server);
|
||||
saveServers(this.servers);
|
||||
clearAuth(server);
|
||||
|
||||
if (state.currentServer === server) {
|
||||
state.currentServer = '';
|
||||
}
|
||||
|
||||
persistCurrentServer('');
|
||||
this.renderOptions();
|
||||
showToast('服务器已删除', 'success');
|
||||
|
||||
if (this.changeHandler) {
|
||||
this.changeHandler('');
|
||||
}
|
||||
}
|
||||
|
||||
handleSelect() {
|
||||
const server = this.selectEl?.value || '';
|
||||
persistCurrentServer(server);
|
||||
state.currentServer = server;
|
||||
|
||||
if (this.changeHandler) {
|
||||
this.changeHandler(server);
|
||||
}
|
||||
}
|
||||
|
||||
ensureServerInList(server) {
|
||||
if (!server) return;
|
||||
if (!this.servers.includes(server)) {
|
||||
this.servers.push(server);
|
||||
saveServers(this.servers);
|
||||
this.renderOptions();
|
||||
}
|
||||
this.setCurrentServer(server);
|
||||
}
|
||||
|
||||
setCurrentServer(server) {
|
||||
if (!this.selectEl) return;
|
||||
const optionExists = Array.from(this.selectEl.options).some(opt => opt.value === server);
|
||||
if (!optionExists && server) {
|
||||
const option = document.createElement('option');
|
||||
option.value = server;
|
||||
option.textContent = server;
|
||||
this.selectEl.appendChild(option);
|
||||
}
|
||||
this.selectEl.value = server || '';
|
||||
}
|
||||
|
||||
renderOptions() {
|
||||
if (!this.selectEl) return;
|
||||
|
||||
this.selectEl.innerHTML = '';
|
||||
const placeholder = document.createElement('option');
|
||||
placeholder.value = '';
|
||||
placeholder.textContent = '选择服务器';
|
||||
this.selectEl.appendChild(placeholder);
|
||||
|
||||
this.servers.forEach(server => {
|
||||
const option = document.createElement('option');
|
||||
option.value = server;
|
||||
option.textContent = server;
|
||||
this.selectEl.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
isValidUrl(value) {
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
public/js/state.js
Normal file
11
public/js/state.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const state = {
|
||||
currentDate: new Date(),
|
||||
markedDates: {},
|
||||
currentServer: '',
|
||||
token: '',
|
||||
user: null,
|
||||
saveTimeoutId: null,
|
||||
isLoginMode: true
|
||||
};
|
||||
|
||||
export default state;
|
||||
53
public/js/storage.js
Normal file
53
public/js/storage.js
Normal file
@@ -0,0 +1,53 @@
|
||||
const SERVERS_KEY = 'apiServers';
|
||||
const CURRENT_SERVER_KEY = 'currentServer';
|
||||
const TOKEN_PREFIX = 'token_';
|
||||
const USER_PREFIX = 'user_';
|
||||
|
||||
export function getServers() {
|
||||
const servers = localStorage.getItem(SERVERS_KEY);
|
||||
return servers ? JSON.parse(servers) : [];
|
||||
}
|
||||
|
||||
export function saveServers(servers) {
|
||||
localStorage.setItem(SERVERS_KEY, JSON.stringify(servers));
|
||||
}
|
||||
|
||||
export function getCurrentServer() {
|
||||
return localStorage.getItem(CURRENT_SERVER_KEY) || '';
|
||||
}
|
||||
|
||||
export function setCurrentServer(server) {
|
||||
if (server) {
|
||||
localStorage.setItem(CURRENT_SERVER_KEY, server);
|
||||
} else {
|
||||
localStorage.removeItem(CURRENT_SERVER_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken(server) {
|
||||
return server ? localStorage.getItem(`${TOKEN_PREFIX}${server}`) || '' : '';
|
||||
}
|
||||
|
||||
export function setToken(server, token) {
|
||||
if (server) {
|
||||
localStorage.setItem(`${TOKEN_PREFIX}${server}`, token);
|
||||
}
|
||||
}
|
||||
|
||||
export function getUser(server) {
|
||||
if (!server) return null;
|
||||
const value = localStorage.getItem(`${USER_PREFIX}${server}`);
|
||||
return value ? JSON.parse(value) : null;
|
||||
}
|
||||
|
||||
export function setUser(server, user) {
|
||||
if (server) {
|
||||
localStorage.setItem(`${USER_PREFIX}${server}`, JSON.stringify(user));
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAuth(server) {
|
||||
if (!server) return;
|
||||
localStorage.removeItem(`${TOKEN_PREFIX}${server}`);
|
||||
localStorage.removeItem(`${USER_PREFIX}${server}`);
|
||||
}
|
||||
22
public/js/toast.js
Normal file
22
public/js/toast.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const toastEl = document.getElementById('statusMessage');
|
||||
|
||||
export function showToast(message, type = 'info', duration = null) {
|
||||
if (!toastEl) return;
|
||||
|
||||
toastEl.textContent = message;
|
||||
toastEl.className = `status-message-toast ${type}`;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toastEl.classList.add('show');
|
||||
});
|
||||
|
||||
const timeout = duration !== null ? duration : type === 'info' ? 4000 : 3000;
|
||||
|
||||
setTimeout(() => {
|
||||
toastEl.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
toastEl.textContent = '';
|
||||
toastEl.className = 'status-message-toast';
|
||||
}, 300);
|
||||
}, timeout);
|
||||
}
|
||||
617
public/style.css
Normal file
617
public/style.css
Normal file
@@ -0,0 +1,617 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 模态框样式 */
|
||||
.modal {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(-50px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
color: #667eea;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 28px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary {
|
||||
flex: 1;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #5568d3;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #059669;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.auth-message {
|
||||
margin-bottom: 15px;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.auth-message.error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.auth-message.success {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.auth-switch {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #667eea;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-switch:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
padding: 10px 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.user-info span {
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
padding: 6px 12px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
background: #dc2626;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 2.5em;
|
||||
color: #667eea;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.server-section {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 0;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
border-top: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.server-section label {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.server-input-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#serverInput {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
#serverInput:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.server-select {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.server-select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.btn-add, .btn-remove {
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background: #059669;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
background: #dc2626;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
/* 右上角 Toast 提示消息 */
|
||||
.status-message-toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 14px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
min-width: 200px;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 10000;
|
||||
opacity: 0;
|
||||
transform: translateX(400px);
|
||||
transition: all 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.status-message-toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.status-message-toast.success {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-message-toast.error {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-message-toast.info {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-nav {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-nav:hover {
|
||||
background: #5568d3;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.btn-nav:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.month-display {
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.btn-today {
|
||||
background: #764ba2;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-today:hover {
|
||||
background: #653a8a;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(118, 75, 162, 0.4);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 30px;
|
||||
margin-bottom: 25px;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.mark.completed {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mark.partial {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mark.none {
|
||||
background: #e5e7eb;
|
||||
border: 2px solid #d1d5db;
|
||||
}
|
||||
|
||||
.calendar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
background: #f0f4ff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.day {
|
||||
aspect-ratio: 1;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: white;
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.day:hover {
|
||||
border-color: #667eea;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.day.other-month {
|
||||
opacity: 0.4;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.day.today {
|
||||
border-color: #667eea;
|
||||
background: #f0f4ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.day.completed {
|
||||
background: #d1fae5;
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.day.completed .day-number {
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.day.partial {
|
||||
background: #fef3c7;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.day.partial .day-number {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.day-mark {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.day.completed .day-mark {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.day.partial .day-mark {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
background: #f0f4ff;
|
||||
border-radius: 10px;
|
||||
color: #667eea;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.instructions p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.month-display {
|
||||
font-size: 1.2em;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.calendar {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.day {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.day-mark {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.server-input-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#serverInput, .server-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-add, .btn-remove {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.status-message-toast {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
max-width: none;
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user