Compare commits
6 Commits
6fd1acc999
...
2feb02becf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2feb02becf | ||
|
|
ab12b0717f | ||
|
|
9a46aead11 | ||
|
|
289d81180d | ||
|
|
69953c43cf | ||
|
|
25f999cb1f |
17
index.html
17
index.html
@ -1,13 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>client</title>
|
||||
<meta name="theme-color" content="#faf9f7" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>掐日子 - AI 纪念日提醒</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
console.log('SW registration failed');
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
4311
package-lock.json
generated
4311
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -42,6 +42,7 @@
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
"vite": "^7.2.4",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
24
public/manifest.json
Normal file
24
public/manifest.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "掐日子 - AI 纪念日提醒",
|
||||
"short_name": "掐日子",
|
||||
"description": "智能纪念日和提醒管理应用",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#faf9f7",
|
||||
"theme_color": "#faf9f7",
|
||||
"orientation": "portrait-primary",
|
||||
"scope": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/favicon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
76
public/sw-register.ts
Normal file
76
public/sw-register.ts
Normal file
@ -0,0 +1,76 @@
|
||||
// Service Worker 注册与生命周期管理
|
||||
|
||||
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
console.warn('Service Worker 不支持');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js', {
|
||||
scope: '/',
|
||||
});
|
||||
|
||||
console.log('SW 注册成功:', registration.scope);
|
||||
|
||||
// 检查更新
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
if (newWorker) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
// 新版本可用,通知用户刷新
|
||||
console.log('新版本 Service Worker 可用');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return registration;
|
||||
} catch (error) {
|
||||
console.error('SW 注册失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterServiceWorker(): Promise<boolean> {
|
||||
if (!('serviceWorker' in navigator)) return false;
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
return registration.unregister();
|
||||
} catch (error) {
|
||||
console.error('SW 注销失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startPeriodicSync(): Promise<void> {
|
||||
// 发送消息启动定时检查
|
||||
if (navigator.serviceWorker.controller) {
|
||||
navigator.serviceWorker.controller.postMessage({ type: 'START_CHECK' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopPeriodicSync(): Promise<void> {
|
||||
// 发送消息停止定时检查
|
||||
if (navigator.serviceWorker.controller) {
|
||||
navigator.serviceWorker.controller.postMessage({ type: 'STOP_CHECK' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendEventsToSW(events: any[]): Promise<void> {
|
||||
if (!navigator.serviceWorker.controller) return;
|
||||
|
||||
navigator.serviceWorker.controller.postMessage({
|
||||
type: 'UPDATE_EVENTS',
|
||||
events,
|
||||
});
|
||||
}
|
||||
|
||||
export async function skipWaiting(): Promise<void> {
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
if (registration?.waiting) {
|
||||
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||
}
|
||||
}
|
||||
268
public/sw.js
Normal file
268
public/sw.js
Normal file
@ -0,0 +1,268 @@
|
||||
// Service Worker - 纯 JavaScript 版本
|
||||
|
||||
// 定时检查间隔(毫秒)
|
||||
const CHECK_INTERVAL = 30 * 1000; // 30秒检查一次(更频繁)
|
||||
|
||||
// 已发送的通知标签集合(避免重复发送)
|
||||
const sentNotifications = new Set();
|
||||
|
||||
// 从 IndexedDB 加载提醒事件
|
||||
async function loadEvents() {
|
||||
try {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction('events', 'readonly');
|
||||
const store = tx.objectStore('events');
|
||||
const request = store.getAll();
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 打开 IndexedDB
|
||||
function openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('qia-notifications', 1);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains('events')) {
|
||||
db.createObjectStore('events', { keyPath: 'id' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('sent-notifications')) {
|
||||
db.createObjectStore('sent-notifications', { keyPath: 'tag' });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 检查是否有提醒时间到达
|
||||
function checkReminders(events) {
|
||||
const now = new Date();
|
||||
const notifications = [];
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type !== 'reminder') continue;
|
||||
if (event.is_completed) continue;
|
||||
if (!event.reminder_times || event.reminder_times.length === 0) continue;
|
||||
|
||||
for (const reminderTime of event.reminder_times) {
|
||||
const rt = new Date(reminderTime);
|
||||
// 检查是否在最近10分钟内(宽限期,处理后台运行不稳定的情况)
|
||||
const diffMs = now.getTime() - rt.getTime();
|
||||
const diffMinutes = diffMs / (1000 * 60);
|
||||
|
||||
// 跳过还未到的提醒
|
||||
if (diffMinutes < 0) continue;
|
||||
|
||||
// 如果在10分钟宽限期内,且还没发送过通知
|
||||
if (diffMinutes < 10) {
|
||||
const tag = `reminder-${event.id}-${reminderTime}`;
|
||||
|
||||
// 避免重复发送同一通知
|
||||
if (!sentNotifications.has(tag)) {
|
||||
sentNotifications.add(tag);
|
||||
notifications.push({
|
||||
id: `${event.id}-${Date.now()}`,
|
||||
title: event.title,
|
||||
body: event.content || getDefaultBodyText(event.date),
|
||||
tag,
|
||||
data: { eventId: event.id, reminderTime },
|
||||
timestamp: now.getTime(),
|
||||
});
|
||||
console.log('SW: 找到到期提醒:', event.title, '- 差值(分钟):', diffMinutes.toFixed(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return notifications;
|
||||
}
|
||||
|
||||
// 获取默认通知正文
|
||||
function getDefaultBodyText(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 显示浏览器通知
|
||||
async function showNotification(notification) {
|
||||
if (Notification.permission !== 'granted') return;
|
||||
|
||||
try {
|
||||
await self.registration.showNotification(notification.title, {
|
||||
body: notification.body,
|
||||
icon: '/favicon.png',
|
||||
badge: '/favicon.png',
|
||||
tag: notification.tag,
|
||||
data: notification.data,
|
||||
requireInteraction: true,
|
||||
actions: [
|
||||
{ action: 'view', title: '查看' },
|
||||
{ action: 'dismiss', title: '关闭' },
|
||||
],
|
||||
});
|
||||
console.log('SW: 通知已发送:', notification.title);
|
||||
} catch (error) {
|
||||
console.error('SW: Failed to show notification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 主检查函数
|
||||
async function performCheck() {
|
||||
try {
|
||||
const events = await loadEvents();
|
||||
const notifications = checkReminders(events);
|
||||
|
||||
if (notifications.length > 0) {
|
||||
console.log('SW: 检查提醒,已加载事件数:', events.length, '将发送通知数:', notifications.length);
|
||||
for (const notification of notifications) {
|
||||
await showNotification(notification);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('SW: Reminder check failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 定时检查
|
||||
let checkTimer = null;
|
||||
|
||||
function startPeriodicCheck() {
|
||||
if (checkTimer) clearInterval(checkTimer);
|
||||
// 立即执行一次
|
||||
performCheck();
|
||||
// 然后定期执行
|
||||
checkTimer = self.setInterval(performCheck, CHECK_INTERVAL);
|
||||
console.log('SW: 定时检查已启动,间隔', CHECK_INTERVAL / 1000, '秒');
|
||||
}
|
||||
|
||||
function stopPeriodicCheck() {
|
||||
if (checkTimer) {
|
||||
clearInterval(checkTimer);
|
||||
checkTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 消息处理
|
||||
self.addEventListener('message', (event) => {
|
||||
console.log('SW: 收到消息', event.data);
|
||||
|
||||
if (event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
|
||||
if (event.data.type === 'START_CHECK') {
|
||||
startPeriodicCheck();
|
||||
}
|
||||
|
||||
if (event.data.type === 'STOP_CHECK') {
|
||||
stopPeriodicCheck();
|
||||
}
|
||||
|
||||
if (event.data.type === 'UPDATE_EVENTS') {
|
||||
// 事件更新,保存到 IndexedDB 并立即检查
|
||||
saveEventsToDB(event.data.events).then(() => {
|
||||
console.log('SW: 数据已更新,立即检查...');
|
||||
performCheck();
|
||||
});
|
||||
}
|
||||
|
||||
if (event.data.type === 'TRIGGER_CHECK') {
|
||||
console.log('SW: 收到 TRIGGER_CHECK,手动执行检查');
|
||||
performCheck();
|
||||
}
|
||||
|
||||
if (event.data.type === 'GET_SENT_TAGS') {
|
||||
if (event.ports && event.ports[0]) {
|
||||
event.ports[0].postMessage({
|
||||
type: 'SENT_TAGS',
|
||||
tags: Array.from(sentNotifications)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (event.data.type === 'CLEAR_SENT_NOTIFICATIONS') {
|
||||
sentNotifications.clear();
|
||||
console.log('SW: 已清除已发送通知记录');
|
||||
}
|
||||
});
|
||||
|
||||
// 保存事件到 IndexedDB
|
||||
async function saveEventsToDB(events) {
|
||||
try {
|
||||
const db = await openDB();
|
||||
const tx = db.transaction('events', 'readwrite');
|
||||
const store = tx.objectStore('events');
|
||||
await store.clear();
|
||||
for (const event of events) {
|
||||
await store.put(event);
|
||||
}
|
||||
console.log('SW: 已保存', events.length, '个事件到 IndexedDB');
|
||||
} catch (error) {
|
||||
console.error('SW: Failed to save events:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 安装
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('SW: Installing...');
|
||||
event.waitUntil(self.skipWaiting());
|
||||
});
|
||||
|
||||
// 激活
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('SW: Activating...');
|
||||
event.waitUntil(
|
||||
self.clients.claim().then(() => {
|
||||
console.log('SW: 已获取控制权,启动检查...');
|
||||
startPeriodicCheck();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// 通知点击处理
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'view') {
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(self.registration.scope) && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow('/');
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 定期清理过期的 sentNotifications(保留24小时)
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
// 简单清理:只保留最近的通知记录
|
||||
if (sentNotifications.size > 1000) {
|
||||
const iterator = sentNotifications.keys();
|
||||
while (sentNotifications.size > 500) {
|
||||
sentNotifications.delete(iterator.next().value);
|
||||
}
|
||||
console.log('SW: 已清理过期的通知记录');
|
||||
}
|
||||
}, 60 * 60 * 1000); // 每小时清理一次
|
||||
|
||||
// 启动定时检查
|
||||
startPeriodicCheck();
|
||||
@ -26,6 +26,7 @@ import { AnniversaryList } from '../components/anniversary/AnniversaryList';
|
||||
import { ReminderList } from '../components/reminder/ReminderList';
|
||||
import { NoteEditor } from '../components/note/NoteEditor';
|
||||
import { FloatingAIChat } from '../components/ai/FloatingAIChat';
|
||||
import appIcon from '../assets/icon.png';
|
||||
import type { Event, EventType, RepeatType, PriorityType } from '../types';
|
||||
import { calculateNextReminderDate, getReminderOptions, getDefaultReminderValue, calculateReminderTimes, getReminderValueFromTimes, formatReminderTimeDisplay } from '../utils/repeatCalculator';
|
||||
|
||||
@ -307,6 +308,16 @@ export function HomePage() {
|
||||
<Container size="xl" py="md" h="100vh" style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<Group justify="space-between" mb="md" style={{ flexShrink: 0 }}>
|
||||
<Group gap="sm">
|
||||
<img
|
||||
src={appIcon}
|
||||
alt="掐日子"
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
/>
|
||||
<Title
|
||||
order={2}
|
||||
style={{
|
||||
@ -318,6 +329,7 @@ export function HomePage() {
|
||||
>
|
||||
掐日子
|
||||
</Title>
|
||||
</Group>
|
||||
<Group>
|
||||
{/* 设置入口 */}
|
||||
<Button
|
||||
|
||||
@ -22,12 +22,10 @@ function ZenBackground() {
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
// 简化的伪随机
|
||||
const random = (min = 0, max = 1) => {
|
||||
return min + Math.random() * (max - min);
|
||||
};
|
||||
|
||||
// 简化的噪声函数
|
||||
const noise = (x: number, y: number, t: number) => {
|
||||
return (Math.sin(x * 0.01 + t) * Math.cos(y * 0.01 + t * 0.7) + 1) * 0.5;
|
||||
};
|
||||
@ -48,9 +46,8 @@ function ZenBackground() {
|
||||
let strokes: InkStroke[] = [];
|
||||
let time = 0;
|
||||
let lastStrokeTime = 0;
|
||||
const strokeInterval = 30; // 每隔一段时间生成新笔触
|
||||
const strokeInterval = 30;
|
||||
|
||||
// 生成随机笔触
|
||||
const createStroke = (): InkStroke | null => {
|
||||
const angle = random(0, Math.PI * 2);
|
||||
const radius = random(canvas.width * 0.1, canvas.width * 0.4);
|
||||
@ -65,19 +62,17 @@ function ZenBackground() {
|
||||
speed: random(0.4, 1.0),
|
||||
inkAlpha: random(10, 30),
|
||||
baseWeight: random(0.3, 1.5),
|
||||
maxLength: random(30, 90), // 缩短笔触长度,加快消失
|
||||
maxLength: random(30, 90),
|
||||
currentLength: 0,
|
||||
complete: false,
|
||||
};
|
||||
};
|
||||
|
||||
// 初始化一些笔触
|
||||
const initStrokes = () => {
|
||||
strokes = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const stroke = createStroke();
|
||||
if (stroke) {
|
||||
// 随机偏移起始位置
|
||||
stroke.currentLength = random(0, stroke.maxLength * 0.5);
|
||||
strokes.push(stroke);
|
||||
}
|
||||
@ -86,25 +81,20 @@ function ZenBackground() {
|
||||
|
||||
initStrokes();
|
||||
|
||||
// 清空画布
|
||||
ctx.fillStyle = '#faf9f7';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const animate = () => {
|
||||
time += 0.008;
|
||||
|
||||
// 快速淡出背景 - 让水墨痕迹更快消失
|
||||
if (Math.floor(time * 125) % 3 === 0) {
|
||||
ctx.fillStyle = 'rgba(250, 249, 247, 0.12)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
// 定期生成新笔触
|
||||
if (time - lastStrokeTime > strokeInterval * 0.01) {
|
||||
lastStrokeTime = time;
|
||||
// 移除已完成太久的笔触
|
||||
strokes = strokes.filter(s => !s.complete || s.currentLength < s.maxLength);
|
||||
// 添加新笔触(随机数量)
|
||||
const newCount = Math.floor(random(0, 2));
|
||||
for (let i = 0; i < newCount; i++) {
|
||||
const stroke = createStroke();
|
||||
@ -112,22 +102,18 @@ function ZenBackground() {
|
||||
}
|
||||
}
|
||||
|
||||
// 更新和绘制笔触
|
||||
for (const stroke of strokes) {
|
||||
if (stroke.complete) continue;
|
||||
|
||||
// 噪声驱动
|
||||
const n = noise(stroke.x, stroke.y, time);
|
||||
stroke.angle += (n - 0.5) * 0.12;
|
||||
|
||||
// 呼吸感 - 更柔和
|
||||
const breath = Math.sin(time * 1.5 + stroke.x * 0.01) * 0.25;
|
||||
const currentSpeed = stroke.speed * (1 + breath * 0.2);
|
||||
|
||||
stroke.x += Math.cos(stroke.angle) * currentSpeed;
|
||||
stroke.y += Math.sin(stroke.angle) * currentSpeed;
|
||||
|
||||
// 笔触粗细 - 模拟提按
|
||||
const progress = stroke.currentLength / stroke.maxLength;
|
||||
const weightVar = Math.sin(progress * Math.PI) * 1.0;
|
||||
const weight = Math.max(0.2, stroke.baseWeight + weightVar);
|
||||
@ -143,16 +129,13 @@ function ZenBackground() {
|
||||
stroke.complete = true;
|
||||
}
|
||||
|
||||
// 绘制 - 水墨晕染效果
|
||||
if (stroke.points.length > 1) {
|
||||
for (let i = 1; i < stroke.points.length; i++) {
|
||||
const p1 = stroke.points[i - 1];
|
||||
const p2 = stroke.points[i];
|
||||
// 渐变透明度
|
||||
const alpha = stroke.inkAlpha * (1 - i / stroke.points.length * 0.4) / 100;
|
||||
const size = p2.weight * random(0.8, 1.2);
|
||||
|
||||
// 绘制柔和的笔触
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p1.x, p1.y);
|
||||
ctx.lineTo(p2.x, p2.y);
|
||||
@ -161,7 +144,6 @@ function ZenBackground() {
|
||||
ctx.lineCap = 'round';
|
||||
ctx.stroke();
|
||||
|
||||
// 添加淡淡的墨点晕染
|
||||
if (random(0, 1) < 0.3) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(p2.x, p2.y, size * random(0.5, 1.5), 0, Math.PI * 2);
|
||||
@ -172,12 +154,11 @@ function ZenBackground() {
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制圆相(Ensō)- 缓慢呼吸
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
const breathScale = 1 + Math.sin(time * 0.3) * 0.015;
|
||||
const radius = Math.min(canvas.width, canvas.height) * 0.18 * breathScale;
|
||||
const gap = Math.PI * 0.18; // 开口
|
||||
const gap = Math.PI * 0.18;
|
||||
const startAngle = -Math.PI / 2 + time * 0.05;
|
||||
|
||||
ctx.beginPath();
|
||||
@ -323,6 +304,7 @@ export function LandingPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 主标题 */}
|
||||
<Title
|
||||
order={1}
|
||||
style={{
|
||||
@ -330,30 +312,39 @@ export function LandingPage() {
|
||||
fontSize: 'clamp(2.2rem, 7vw, 3.2rem)',
|
||||
letterSpacing: '0.25em',
|
||||
color: '#1a1a1a',
|
||||
fontFamily: 'Noto Serif SC, serif',
|
||||
fontFamily: 'Georgia, serif',
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
掐日子
|
||||
</Title>
|
||||
|
||||
{/* 副标题 - 增强可读性 */}
|
||||
<Text
|
||||
size="sm"
|
||||
size="lg"
|
||||
style={{
|
||||
letterSpacing: '0.35em',
|
||||
color: '#888',
|
||||
fontWeight: 300,
|
||||
color: '#444',
|
||||
fontWeight: 400,
|
||||
fontSize: '1rem',
|
||||
fontFamily: 'Noto Serif SC, serif',
|
||||
padding: '0.4rem 1rem',
|
||||
background: 'rgba(250, 250, 250, 0.8)',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
AI 纪念日 · 提醒
|
||||
</Text>
|
||||
|
||||
{/* 描述文案 */}
|
||||
<Text
|
||||
size="xs"
|
||||
style={{
|
||||
color: '#999',
|
||||
maxWidth: 300,
|
||||
lineHeight: 1.9,
|
||||
color: '#777',
|
||||
maxWidth: 320,
|
||||
lineHeight: 2,
|
||||
fontWeight: 300,
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
轻便、灵活的倒数日和提醒应用
|
||||
@ -361,7 +352,7 @@ export function LandingPage() {
|
||||
让每一个重要的日子都被铭记
|
||||
</Text>
|
||||
|
||||
<Group gap="md" mt="lg">
|
||||
<Group gap="md" mt="md">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => navigate('/login')}
|
||||
@ -393,7 +384,7 @@ export function LandingPage() {
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group gap={40} mt={50} style={{ opacity: 0.7 }}>
|
||||
<Group gap={40} mt={40} style={{ opacity: 0.6 }}>
|
||||
<Stack gap={3} align="center">
|
||||
<Text size="xs" fw={300} c="#444">◯</Text>
|
||||
<Text size="xs" c="#666" style={{ letterSpacing: '0.1em' }}>纪念日</Text>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
@ -8,15 +8,21 @@ import {
|
||||
Paper,
|
||||
Group,
|
||||
Button,
|
||||
Loader,
|
||||
} from '@mantine/core';
|
||||
import { IconArrowLeft, IconSettings } from '@tabler/icons-react';
|
||||
import { IconArrowLeft, IconSettings, IconBell, IconRefresh, IconBellCheck } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../stores';
|
||||
import { requestNotificationPermission, getNotificationPermission, isNotificationSupported } from '../services/notification';
|
||||
import { syncRemindersToSW, triggerSWCheck } from '../services/swSync';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
export function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const settings = useAppStore((state) => state.settings);
|
||||
const updateSettings = useAppStore((state) => state.updateSettings);
|
||||
const [permissionStatus, setPermissionStatus] = useState<'granted' | 'denied' | 'default'>('default');
|
||||
const [isRequesting, setIsRequesting] = useState(false);
|
||||
|
||||
// 页面加载时检查登录状态
|
||||
useEffect(() => {
|
||||
@ -26,6 +32,97 @@ export function SettingsPage() {
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
// 初始化权限状态
|
||||
useEffect(() => {
|
||||
if (isNotificationSupported()) {
|
||||
setPermissionStatus(getNotificationPermission());
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 处理浏览器通知开关
|
||||
const handleBrowserNotificationToggle = async (enabled: boolean) => {
|
||||
if (!isNotificationSupported()) {
|
||||
notifications.show({
|
||||
title: '不支持通知',
|
||||
message: '您的浏览器不支持通知功能',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
// 请求权限
|
||||
setIsRequesting(true);
|
||||
const permission = await requestNotificationPermission();
|
||||
setIsRequesting(false);
|
||||
setPermissionStatus(permission);
|
||||
|
||||
if (permission === 'granted') {
|
||||
updateSettings({ browserNotifications: true });
|
||||
// 同步所有提醒到 Service Worker
|
||||
await syncRemindersToSW();
|
||||
notifications.show({
|
||||
title: '通知已开启',
|
||||
message: '您将收到浏览器的提醒通知',
|
||||
color: 'green',
|
||||
});
|
||||
} else {
|
||||
// 权限被拒绝
|
||||
notifications.show({
|
||||
title: '无法开启通知',
|
||||
message: '请在浏览器设置中允许通知权限',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
updateSettings({ browserNotifications: false });
|
||||
}
|
||||
};
|
||||
|
||||
// 同步提醒到 SW
|
||||
const handleSyncReminders = async () => {
|
||||
await syncRemindersToSW();
|
||||
notifications.show({
|
||||
title: '同步完成',
|
||||
message: '提醒已同步到 Service Worker',
|
||||
color: 'green',
|
||||
});
|
||||
};
|
||||
|
||||
// 手动触发 SW 检查
|
||||
const handleTriggerCheck = async () => {
|
||||
await triggerSWCheck();
|
||||
notifications.show({
|
||||
title: '检查已触发',
|
||||
message: 'Service Worker 将立即检查提醒',
|
||||
color: 'blue',
|
||||
});
|
||||
};
|
||||
|
||||
// 发送测试通知
|
||||
const handleTestNotification = async () => {
|
||||
if (Notification.permission !== 'granted') {
|
||||
notifications.show({
|
||||
title: '请先开启通知',
|
||||
message: '需要先允许通知权限',
|
||||
color: 'yellow',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
new Notification('测试通知', {
|
||||
body: '这是一条测试通知,通知功能正常工作',
|
||||
icon: '/favicon.png',
|
||||
tag: 'test-notification',
|
||||
});
|
||||
|
||||
notifications.show({
|
||||
title: '测试通知已发送',
|
||||
message: '请查看浏览器通知',
|
||||
color: 'blue',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@ -84,6 +181,76 @@ export function SettingsPage() {
|
||||
color="#1a1a1a"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* 浏览器通知设置 */}
|
||||
{isNotificationSupported() && (
|
||||
<Group justify="space-between" style={{ marginTop: 16 }}>
|
||||
<Group gap="sm">
|
||||
<IconBell size={18} color="#666" />
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={400} style={{ letterSpacing: '0.05em', color: '#1a1a1a' }}>
|
||||
浏览器通知
|
||||
</Text>
|
||||
<Text size="xs" c="#999" style={{ letterSpacing: '0.03em' }}>
|
||||
{permissionStatus === 'granted'
|
||||
? '已开启 - 通过系统通知提醒您的重要事项'
|
||||
: permissionStatus === 'denied'
|
||||
? '已拒绝 - 请在浏览器设置中允许通知'
|
||||
: '通过系统通知提醒您的重要事项'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
{isRequesting ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={settings.browserNotifications && permissionStatus === 'granted'}
|
||||
onChange={(e) => handleBrowserNotificationToggle(e.currentTarget.checked)}
|
||||
disabled={permissionStatus === 'denied'}
|
||||
size="sm"
|
||||
color="#1a1a1a"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* 通知功能测试按钮 */}
|
||||
{settings.browserNotifications && permissionStatus === 'granted' && (
|
||||
<Stack gap="sm" style={{ marginTop: 16 }}>
|
||||
<Text size="sm" fw={400} style={{ letterSpacing: '0.05em', color: '#1a1a1a' }}>
|
||||
通知测试
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
onClick={handleSyncReminders}
|
||||
style={{ letterSpacing: '0.05em' }}
|
||||
>
|
||||
同步提醒
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconBellCheck size={14} />}
|
||||
onClick={handleTriggerCheck}
|
||||
style={{ letterSpacing: '0.05em' }}
|
||||
>
|
||||
立即检查
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconBell size={14} />}
|
||||
onClick={handleTestNotification}
|
||||
style={{ letterSpacing: '0.05em' }}
|
||||
>
|
||||
测试通知
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
|
||||
89
src/services/notification.ts
Normal file
89
src/services/notification.ts
Normal file
@ -0,0 +1,89 @@
|
||||
// 浏览器通知权限管理服务
|
||||
|
||||
export type NotificationPermission = 'granted' | 'denied' | 'default';
|
||||
|
||||
// 检查当前权限状态
|
||||
export function getNotificationPermission(): NotificationPermission {
|
||||
if (!('Notification' in window)) {
|
||||
return 'denied';
|
||||
}
|
||||
return Notification.permission as NotificationPermission;
|
||||
}
|
||||
|
||||
// 请求通知权限
|
||||
export async function requestNotificationPermission(): Promise<NotificationPermission> {
|
||||
if (!('Notification' in window)) {
|
||||
console.warn('浏览器不支持通知 API');
|
||||
return 'denied';
|
||||
}
|
||||
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
return permission as NotificationPermission;
|
||||
} catch (error) {
|
||||
console.error('请求通知权限失败:', error);
|
||||
return 'denied';
|
||||
}
|
||||
}
|
||||
|
||||
// 发送前台通知
|
||||
export function showForegroundNotification(
|
||||
title: string,
|
||||
options?: NotificationOptions
|
||||
): Notification | null {
|
||||
const permission = getNotificationPermission();
|
||||
|
||||
if (permission !== 'granted') {
|
||||
console.warn('通知权限未授予');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const notification = new Notification(title, {
|
||||
icon: '/favicon.png',
|
||||
badge: '/favicon.png',
|
||||
requireInteraction: true,
|
||||
...options,
|
||||
});
|
||||
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
notification.close();
|
||||
};
|
||||
|
||||
return notification;
|
||||
} catch (error) {
|
||||
console.error('发送通知失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查通知支持
|
||||
export function isNotificationSupported(): boolean {
|
||||
return 'Notification' in window;
|
||||
}
|
||||
|
||||
// 获取权限状态描述
|
||||
export function getPermissionStatusText(permission: NotificationPermission): string {
|
||||
switch (permission) {
|
||||
case 'granted':
|
||||
return '已开启';
|
||||
case 'denied':
|
||||
return '已拒绝';
|
||||
default:
|
||||
return '未设置';
|
||||
}
|
||||
}
|
||||
|
||||
// 发送提醒通知
|
||||
export function sendReminderNotification(
|
||||
title: string,
|
||||
body: string,
|
||||
tag?: string
|
||||
): Notification | null {
|
||||
return showForegroundNotification(title, {
|
||||
body,
|
||||
tag: tag || `reminder-${Date.now()}`,
|
||||
requireInteraction: true,
|
||||
});
|
||||
}
|
||||
98
src/services/swSync.ts
Normal file
98
src/services/swSync.ts
Normal file
@ -0,0 +1,98 @@
|
||||
// Service Worker 同步服务
|
||||
|
||||
/**
|
||||
* 同步提醒事件到 Service Worker 的 IndexedDB
|
||||
*/
|
||||
export async function syncRemindersToSW(): Promise<void> {
|
||||
try {
|
||||
// 动态导入 api
|
||||
const { api } = await import('./api');
|
||||
|
||||
// 获取当前用户的所有提醒事件
|
||||
const events = await api.events.list('reminder');
|
||||
|
||||
// 获取 SW 注册
|
||||
if (!navigator.serviceWorker || !navigator.serviceWorker.controller) {
|
||||
console.warn('SW: Service Worker 未注册,无法同步');
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送事件数据到 SW
|
||||
navigator.serviceWorker.controller.postMessage({
|
||||
type: 'UPDATE_EVENTS',
|
||||
events: events
|
||||
});
|
||||
|
||||
console.log('SW: 已同步', events.length, '个提醒事件到 SW');
|
||||
} catch (error) {
|
||||
console.error('SW: 同步失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 SW 中已发送的通知标签
|
||||
*/
|
||||
export async function getSentNotificationTags(): Promise<Set<string>> {
|
||||
try {
|
||||
if (!navigator.serviceWorker || !navigator.serviceWorker.controller) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.onmessage = (e) => {
|
||||
resolve(new Set(e.data.tags || []));
|
||||
};
|
||||
|
||||
navigator.serviceWorker.controller.postMessage(
|
||||
{ type: 'GET_SENT_TAGS' },
|
||||
[channel.port2]
|
||||
);
|
||||
|
||||
// 超时返回空集合
|
||||
setTimeout(() => resolve(new Set()), 1000);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('SW: 获取已发送标签失败', error);
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 SW 中的已发送通知记录(用于测试)
|
||||
*/
|
||||
export async function clearSentNotifications(): Promise<void> {
|
||||
try {
|
||||
if (!navigator.serviceWorker || !navigator.serviceWorker.controller) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.serviceWorker.controller.postMessage({
|
||||
type: 'CLEAR_SENT_NOTIFICATIONS'
|
||||
});
|
||||
|
||||
console.log('SW: 已清除已发送通知记录');
|
||||
} catch (error) {
|
||||
console.error('SW: 清除失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发 SW 检查
|
||||
*/
|
||||
export async function triggerSWCheck(): Promise<void> {
|
||||
try {
|
||||
if (!navigator.serviceWorker || !navigator.serviceWorker.controller) {
|
||||
console.warn('SW: Service Worker 未注册');
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.serviceWorker.controller.postMessage({
|
||||
type: 'TRIGGER_CHECK'
|
||||
});
|
||||
|
||||
console.log('SW: 已触发检查');
|
||||
} catch (error) {
|
||||
console.error('SW: 触发检查失败', error);
|
||||
}
|
||||
}
|
||||
@ -2,16 +2,19 @@ import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { User, Event, Note, AIConversation, EventType, RepeatType } from '../types';
|
||||
import { api } from '../services/api';
|
||||
import { syncRemindersToSW } from '../services/swSync';
|
||||
import { calculateNextReminderDate, findNextValidReminderDate, isDuplicateReminder, createNextRecurringEventData } from '../utils/repeatCalculator';
|
||||
|
||||
// 应用设置类型
|
||||
interface AppSettings {
|
||||
showHolidays: boolean; // 是否显示节假日
|
||||
browserNotifications: boolean; // 是否启用浏览器通知
|
||||
}
|
||||
|
||||
// 默认设置
|
||||
const defaultSettings: AppSettings = {
|
||||
showHolidays: true,
|
||||
browserNotifications: false,
|
||||
};
|
||||
|
||||
interface AppState {
|
||||
@ -127,6 +130,12 @@ export const useAppStore = create<AppState>()(
|
||||
try {
|
||||
const newEvent = await api.events.create(event);
|
||||
set((state) => ({ events: [...state.events, newEvent] }));
|
||||
|
||||
// 如果是提醒事件,同步到 Service Worker
|
||||
if (event.type === 'reminder' && event.reminder_times && event.reminder_times.length > 0) {
|
||||
await syncRemindersToSW();
|
||||
}
|
||||
|
||||
return { error: null };
|
||||
} catch (error: any) {
|
||||
return { error: error.message || '创建失败' };
|
||||
@ -211,6 +220,11 @@ export const useAppStore = create<AppState>()(
|
||||
await api.events.update(id, event);
|
||||
}
|
||||
|
||||
// 如果更新涉及提醒设置,同步到 Service Worker
|
||||
if (event.reminder_times || event.type === 'reminder') {
|
||||
await syncRemindersToSW();
|
||||
}
|
||||
|
||||
return { error: null };
|
||||
} catch (error: any) {
|
||||
// 失败时回滚,重新获取数据
|
||||
|
||||
115
src/utils/reminderChecker.ts
Normal file
115
src/utils/reminderChecker.ts
Normal file
@ -0,0 +1,115 @@
|
||||
// 提醒时间检查核心逻辑
|
||||
|
||||
import type { Event } from '../types';
|
||||
|
||||
interface ReminderCheckResult {
|
||||
dueReminders: Event[];
|
||||
now: Date;
|
||||
}
|
||||
|
||||
interface FormattedReminderNotification {
|
||||
title: string;
|
||||
body: string;
|
||||
tag: string;
|
||||
data?: { eventId: string };
|
||||
}
|
||||
|
||||
export function checkDueReminders(events: Event[]): ReminderCheckResult {
|
||||
const now = new Date();
|
||||
const dueReminders: Event[] = [];
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type !== 'reminder') continue;
|
||||
if (event.is_completed) continue;
|
||||
if (!event.reminder_times || event.reminder_times.length === 0) continue;
|
||||
|
||||
for (const reminderTime of event.reminder_times) {
|
||||
const rt = new Date(reminderTime);
|
||||
|
||||
// 检查是否在当前时间点(允许1分钟内的误差)
|
||||
const diffMs = now.getTime() - rt.getTime();
|
||||
const diffMinutes = diffMs / (1000 * 60);
|
||||
|
||||
// 已到期的提醒(过去1分钟内)
|
||||
if (diffMinutes >= 0 && diffMinutes < 1) {
|
||||
if (!dueReminders.find(r => r.id === event.id)) {
|
||||
dueReminders.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { dueReminders, now };
|
||||
}
|
||||
|
||||
// 格式化提醒通知内容
|
||||
export function formatReminderNotification(event: Event, reminderTime?: string): FormattedReminderNotification {
|
||||
const date = new Date(event.date);
|
||||
const dateStr = date.toLocaleString('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
return {
|
||||
title: event.title,
|
||||
body: event.content
|
||||
? `${dateStr}\n${event.content}`
|
||||
: dateStr,
|
||||
tag: `reminder-${event.id}-${reminderTime || Date.now()}`,
|
||||
data: { eventId: event.id },
|
||||
};
|
||||
}
|
||||
|
||||
// 获取即将到来的提醒数量
|
||||
export function getUpcomingCount(events: Event[], minutes: number = 60): number {
|
||||
const now = new Date();
|
||||
let count = 0;
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type !== 'reminder' || event.is_completed) continue;
|
||||
if (!event.reminder_times || event.reminder_times.length === 0) continue;
|
||||
|
||||
for (const reminderTime of event.reminder_times) {
|
||||
const rt = new Date(reminderTime);
|
||||
const diffMinutes = (rt.getTime() - now.getTime()) / (1000 * 60);
|
||||
|
||||
if (diffMinutes > 0 && diffMinutes <= minutes) {
|
||||
count++;
|
||||
break; // 只计算一次
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
// 检查是否有即将到来的提醒(用于显示状态)
|
||||
export function hasUpcomingReminders(events: Event[], minutes: number = 60): boolean {
|
||||
return getUpcomingCount(events, minutes) > 0;
|
||||
}
|
||||
|
||||
// 获取所有即将到来的提醒
|
||||
export function getUpcomingReminders(events: Event[], minutes: number = 60): Event[] {
|
||||
const now = new Date();
|
||||
const upcoming: Event[] = [];
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type !== 'reminder' || event.is_completed) continue;
|
||||
if (!event.reminder_times || event.reminder_times.length === 0) continue;
|
||||
|
||||
for (const reminderTime of event.reminder_times) {
|
||||
const rt = new Date(reminderTime);
|
||||
const diffMinutes = (rt.getTime() - now.getTime()) / (1000 * 60);
|
||||
|
||||
if (diffMinutes > 0 && diffMinutes <= minutes) {
|
||||
upcoming.push(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return upcoming;
|
||||
}
|
||||
@ -1,9 +1,44 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.png'],
|
||||
manifest: {
|
||||
name: '掐日子 - AI 纪念日提醒',
|
||||
short_name: '掐日子',
|
||||
description: '智能纪念日和提醒管理应用',
|
||||
theme_color: '#faf9f7',
|
||||
background_color: '#faf9f7',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
scope: '/',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{
|
||||
src: '/favicon.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/favicon.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
// 只缓存静态资源,不缓存 API 请求
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user