feat(todo): 新增 /todo slash command,支持 add/done/list/reopen/rm
- todo/todo.json:结构化 JSON 真相源(id/标题/重要度3级/平台标签/版本) - todo/todo.mjs:Node 零依赖单文件 CLI,写操作自动重渲染 HTML - todo/todo.html:重新设计布局(未完成置顶/已完成置底/sticky 筛选栏) - .claude/commands/todo.md:/todo slash command - CLAUDE.md:追加 TODO 管理规范(唯一真相源、完成即标记、禁止使用其它 todo 工具) 旧 todo.html 备份为 todo.legacy.html;存量 6 条待办已导入 todo.json Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+504
@@ -0,0 +1,504 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* todo/todo.mjs — 项目 TODO 管理 CLI(零依赖,Node.js >= 18)
|
||||
*
|
||||
* 子命令:
|
||||
* add --title "..." [--level high|mid|low] [--tags a,b] [--desc "..."]
|
||||
* done <id> [--version vX.Y.Z]
|
||||
* reopen <id>
|
||||
* rm <id>
|
||||
* list
|
||||
* render
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const DB = resolve(__dir, 'todo.json');
|
||||
const HTML = resolve(__dir, 'todo.html');
|
||||
|
||||
// ─── 数据读写 ────────────────────────────────────────────────────────────────
|
||||
|
||||
function load() {
|
||||
if (!existsSync(DB)) {
|
||||
return { meta: { title: '酒库管理系统 — 项目 TODO', updated_at: now() }, seq: 0, items: [] };
|
||||
}
|
||||
return JSON.parse(readFileSync(DB, 'utf8'));
|
||||
}
|
||||
|
||||
function save(db) {
|
||||
db.meta.updated_at = now();
|
||||
writeFileSync(DB, JSON.stringify(db, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
function gitTag() {
|
||||
try {
|
||||
return execSync('git describe --tags --abbrev=0', { encoding: 'utf8', stdio: ['ignore','pipe','ignore'] }).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 终端 summary ────────────────────────────────────────────────────────────
|
||||
|
||||
const LEVEL_LABEL = { high: '高', mid: '中', low: '低' };
|
||||
const LEVEL_ORDER = { high: 0, mid: 1, low: 2 };
|
||||
|
||||
function printSummary(db) {
|
||||
const pending = db.items.filter(i => !i.done)
|
||||
.sort((a,b) => (LEVEL_ORDER[a.level]??9) - (LEVEL_ORDER[b.level]??9) || a.id - b.id);
|
||||
const done = db.items.filter(i => i.done)
|
||||
.sort((a,b) => (b.completed_at||'') > (a.completed_at||'') ? 1 : -1);
|
||||
|
||||
console.log(`\nTODO 共 ${db.items.length} · 未完成 ${pending.length} · 已完成 ${done.length}`);
|
||||
|
||||
if (pending.length) {
|
||||
console.log('未完成(按重要度):');
|
||||
for (const i of pending) {
|
||||
const tags = i.tags && i.tags.length ? i.tags.join('/') : '';
|
||||
const lvl = LEVEL_LABEL[i.level] || i.level;
|
||||
console.log(` [${lvl}] #${i.id} ${i.title}${tags ? ' ' + tags : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (done.length) {
|
||||
console.log('已完成(最近 5 条):');
|
||||
for (const i of done.slice(0, 5)) {
|
||||
const ver = i.version ? `(${i.version})` : '';
|
||||
console.log(` ✅ #${i.id} ${i.title}${ver}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// ─── HTML 渲染 ───────────────────────────────────────────────────────────────
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
/** 把描述里的 `code` 渲染成 <code> */
|
||||
function renderDesc(s) {
|
||||
if (!s) return '';
|
||||
return esc(s).replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
}
|
||||
|
||||
function levelClass(level) {
|
||||
return { high: 't-block', mid: 't-high', low: 't-low' }[level] || 't-low';
|
||||
}
|
||||
function levelText(level) {
|
||||
return { high: '高优 · 紧急', mid: '重要', low: '一般 / 优化' }[level] || level;
|
||||
}
|
||||
|
||||
function renderItems(items) {
|
||||
if (!items.length) return '<p class="empty-tip">暂无条目</p>';
|
||||
return items.map(i => {
|
||||
const tags = (i.tags||[]).map(t => `<span class="tag t-tag" data-tag="${esc(t)}">${esc(t)}</span>`).join(' ');
|
||||
const desc = i.desc ? `<div class="item-desc">${renderDesc(i.desc)}</div>` : '';
|
||||
const meta = `<div class="item-meta">
|
||||
<span class="meta-date">🕐 ${fmtDate(i.created_at)}</span>
|
||||
${i.done ? `<span class="meta-date">✅ 完成 ${fmtDate(i.completed_at)}${i.version ? ' · <span class="ver-badge">'+esc(i.version)+'</span>' : ''}</span>` : ''}
|
||||
</div>`;
|
||||
const lvlCls = levelClass(i.level);
|
||||
return `
|
||||
<li class="todo-card${i.done ? ' done' : ''}"
|
||||
data-id="${i.id}"
|
||||
data-level="${esc(i.level)}"
|
||||
data-tags="${esc((i.tags||[]).join(','))}"
|
||||
data-done="${i.done ? '1' : '0'}">
|
||||
<div class="card-header">
|
||||
<span class="item-title">${esc(i.title)}</span>
|
||||
<span class="tag ${lvlCls}">${levelText(i.level)}</span>
|
||||
</div>
|
||||
${desc}
|
||||
<div class="card-footer">
|
||||
<div class="tag-row">${tags}</div>
|
||||
${meta}
|
||||
</div>
|
||||
</li>`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function buildHtml(db) {
|
||||
const allTags = [...new Set(db.items.flatMap(i => i.tags||[]))].sort();
|
||||
const pending = db.items.filter(i => !i.done)
|
||||
.sort((a,b) => (LEVEL_ORDER[a.level]??9) - (LEVEL_ORDER[b.level]??9) || a.id - b.id);
|
||||
const done = db.items.filter(i => i.done)
|
||||
.sort((a,b) => (b.completed_at||'') > (a.completed_at||'') ? 1 : -1);
|
||||
|
||||
const tagChips = allTags.map(t =>
|
||||
`<button class="filter-chip" data-filter-tag="${esc(t)}">${esc(t)}</button>`
|
||||
).join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${esc(db.meta.title)}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; padding: 0 0 80px;
|
||||
font-family: "Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #1f2933; background: #f7f9fb; line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
header {
|
||||
background: linear-gradient(135deg, #0A1F3B 0%, #15407D 100%);
|
||||
color: #fff; padding: 36px 32px 28px;
|
||||
}
|
||||
header h1 { margin: 0 0 6px; font-size: 24px; font-weight: 700; }
|
||||
header .header-meta { color: #ADC9EA; font-size: 13px; margin-top: 4px; }
|
||||
.stats { display: flex; gap: 16px; margin-top: 14px; }
|
||||
.stat-pill {
|
||||
background: rgba(255,255,255,0.12); border-radius: 20px;
|
||||
padding: 4px 14px; font-size: 13px; color: #fff;
|
||||
}
|
||||
.stat-pill strong { font-size: 18px; font-weight: 700; margin-right: 2px; }
|
||||
|
||||
/* ── 筛选栏 ── */
|
||||
.filter-bar {
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
background: #fff; border-bottom: 1px solid #e4e9ef;
|
||||
padding: 10px 28px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
|
||||
}
|
||||
.filter-group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.filter-label { font-size: 12px; color: #7b8794; white-space: nowrap; }
|
||||
.filter-chip, .filter-btn {
|
||||
padding: 3px 12px; border-radius: 14px; border: 1.5px solid #d1d9e6;
|
||||
background: #f7f9fb; color: #52606d; font-size: 12.5px; cursor: pointer;
|
||||
transition: all .15s; white-space: nowrap;
|
||||
}
|
||||
.filter-chip:hover, .filter-btn:hover { border-color: #2563AC; color: #2563AC; }
|
||||
.filter-chip.active, .filter-btn.active {
|
||||
background: #2563AC; border-color: #2563AC; color: #fff;
|
||||
}
|
||||
.filter-sep { width: 1px; height: 20px; background: #e4e9ef; margin: 0 4px; }
|
||||
|
||||
/* ── 内容区 ── */
|
||||
.wrap { max-width: 980px; margin: 0 auto; padding: 0 24px; }
|
||||
.section-title {
|
||||
font-size: 16px; font-weight: 700; color: #0A1F3B;
|
||||
border-left: 4px solid #2563AC; padding-left: 12px;
|
||||
margin: 28px 0 12px;
|
||||
}
|
||||
.section-title.done-title { border-color: #6cbb8a; }
|
||||
ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
.todo-card {
|
||||
background: #fff; border: 1px solid #e4e9ef; border-radius: 10px;
|
||||
padding: 14px 18px; margin-bottom: 10px;
|
||||
}
|
||||
.todo-card.done { background: #f3faf5; border-color: #b7e4c7; opacity: .75; }
|
||||
.card-header { display: flex; align-items: flex-start; gap: 10px; flex-wrap: wrap; }
|
||||
.item-title { font-weight: 600; font-size: 15px; flex: 1; }
|
||||
.todo-card.done .item-title { text-decoration: line-through; color: #52606d; }
|
||||
.item-desc { font-size: 13.5px; color: #52606d; margin-top: 6px; }
|
||||
.item-desc code {
|
||||
background: #f0f2f5; padding: 1px 5px; border-radius: 3px;
|
||||
font-family: "JetBrains Mono", monospace; font-size: 12.5px; color: #b91c1c;
|
||||
}
|
||||
.card-footer { display: flex; align-items: center; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; flex: 1; }
|
||||
.item-meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: #8aa3c4; }
|
||||
.meta-date { white-space: nowrap; }
|
||||
|
||||
/* ── tags ── */
|
||||
.tag { display: inline-block; padding: 2px 9px; border-radius: 10px; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
||||
.t-block { background: #fde8e8; color: #b91c1c; }
|
||||
.t-high { background: #fef0d8; color: #9a6700; }
|
||||
.t-low { background: #e6f0fb; color: #1d4ed8; }
|
||||
.t-tag { background: #efefef; color: #555; cursor: pointer; transition: .12s; }
|
||||
.t-tag:hover { background: #d9e8fb; color: #1d4ed8; }
|
||||
.ver-badge { font-weight: 700; color: #1f7a44; }
|
||||
|
||||
/* ── 折叠 ── */
|
||||
.done-section { margin-bottom: 24px; }
|
||||
.done-toggle {
|
||||
background: none; border: 1.5px solid #d1d9e6; border-radius: 6px;
|
||||
padding: 4px 14px; cursor: pointer; font-size: 13px; color: #52606d;
|
||||
margin-bottom: 10px; transition: .15s;
|
||||
}
|
||||
.done-toggle:hover { border-color: #6cbb8a; color: #1f7a44; }
|
||||
.empty-tip { font-size: 14px; color: #8aa3c4; margin: 8px 0 24px; }
|
||||
|
||||
/* 隐藏 */
|
||||
.hidden { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="wrap">
|
||||
<h1>${esc(db.meta.title)}</h1>
|
||||
<div class="header-meta">生成于 ${fmtDate(db.meta.updated_at)} · 真相源 todo/todo.json</div>
|
||||
<div class="stats">
|
||||
<div class="stat-pill"><strong>${db.items.length}</strong>全部</div>
|
||||
<div class="stat-pill"><strong>${pending.length}</strong>未完成</div>
|
||||
<div class="stat-pill"><strong>${done.length}</strong>已完成</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="filter-bar" id="filterBar">
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">重要度</span>
|
||||
<button class="filter-btn active" data-filter-level="all">全部</button>
|
||||
<button class="filter-btn" data-filter-level="high">高优</button>
|
||||
<button class="filter-btn" data-filter-level="mid">重要</button>
|
||||
<button class="filter-btn" data-filter-level="low">一般</button>
|
||||
</div>
|
||||
<div class="filter-sep"></div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">状态</span>
|
||||
<button class="filter-btn active" data-filter-done="all">全部</button>
|
||||
<button class="filter-btn" data-filter-done="0">未完成</button>
|
||||
<button class="filter-btn" data-filter-done="1">已完成</button>
|
||||
</div>
|
||||
${allTags.length ? `<div class="filter-sep"></div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">平台 / 标签</span>
|
||||
${tagChips}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<div class="section-title">📋 未完成(${pending.length})</div>
|
||||
<ul class="todo-list" id="pendingList">
|
||||
${renderItems(pending)}
|
||||
</ul>
|
||||
|
||||
<div class="done-section">
|
||||
<div class="section-title done-title">✅ 已完成(${done.length})</div>
|
||||
<button class="done-toggle" id="doneToggle">▾ 展开已完成列表</button>
|
||||
<ul class="todo-list hidden" id="doneList">
|
||||
${renderItems(done)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// 筛选状态
|
||||
let curLevel = 'all';
|
||||
let curDone = 'all';
|
||||
let curTags = new Set();
|
||||
|
||||
function applyFilter() {
|
||||
document.querySelectorAll('.todo-card').forEach(card => {
|
||||
const lvl = card.dataset.level;
|
||||
const dn = card.dataset.done;
|
||||
const tags = card.dataset.tags ? card.dataset.tags.split(',') : [];
|
||||
|
||||
let show = true;
|
||||
if (curLevel !== 'all' && lvl !== curLevel) show = false;
|
||||
if (curDone !== 'all' && dn !== curDone) show = false;
|
||||
if (curTags.size > 0 && ![...curTags].some(t => tags.includes(t))) show = false;
|
||||
|
||||
card.classList.toggle('hidden', !show);
|
||||
});
|
||||
}
|
||||
|
||||
// 重要度
|
||||
document.querySelectorAll('[data-filter-level]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
curLevel = btn.dataset.filterLevel;
|
||||
document.querySelectorAll('[data-filter-level]').forEach(b => b.classList.toggle('active', b === btn));
|
||||
applyFilter();
|
||||
});
|
||||
});
|
||||
|
||||
// 状态
|
||||
document.querySelectorAll('[data-filter-done]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
curDone = btn.dataset.filterDone;
|
||||
document.querySelectorAll('[data-filter-done]').forEach(b => b.classList.toggle('active', b === btn));
|
||||
applyFilter();
|
||||
});
|
||||
});
|
||||
|
||||
// 标签(多选)
|
||||
document.querySelectorAll('[data-filter-tag]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const t = btn.dataset.filterTag;
|
||||
if (curTags.has(t)) { curTags.delete(t); btn.classList.remove('active'); }
|
||||
else { curTags.add(t); btn.classList.add('active'); }
|
||||
applyFilter();
|
||||
});
|
||||
});
|
||||
|
||||
// 点击卡片标签 → 快速筛选
|
||||
document.querySelectorAll('.t-tag').forEach(tag => {
|
||||
tag.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const t = tag.dataset.tag;
|
||||
const chip = document.querySelector('[data-filter-tag="'+t+'"]');
|
||||
if (chip) chip.click();
|
||||
});
|
||||
});
|
||||
|
||||
// 已完成折叠
|
||||
const toggle = document.getElementById('doneToggle');
|
||||
const doneList = document.getElementById('doneList');
|
||||
if (toggle && doneList) {
|
||||
toggle.addEventListener('click', () => {
|
||||
const collapsed = doneList.classList.toggle('hidden');
|
||||
toggle.textContent = collapsed ? '▾ 展开已完成列表' : '▴ 收起已完成列表';
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ─── CLI 解析 ────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
let pos = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i].startsWith('--')) {
|
||||
const key = argv[i].slice(2);
|
||||
const val = argv[i+1] && !argv[i+1].startsWith('--') ? argv[++i] : true;
|
||||
args[key] = val;
|
||||
} else {
|
||||
pos.push(argv[i]);
|
||||
}
|
||||
}
|
||||
args._ = pos;
|
||||
return args;
|
||||
}
|
||||
|
||||
function findItem(db, id) {
|
||||
const n = parseInt(id, 10);
|
||||
const item = db.items.find(i => i.id === n);
|
||||
if (!item) { console.error(`错误:找不到 id=${n} 的条目`); process.exit(1); }
|
||||
return item;
|
||||
}
|
||||
|
||||
// ─── 子命令 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function cmdAdd(db, args) {
|
||||
const title = args.title || (args._.join(' ') || '').trim();
|
||||
if (!title) { console.error('错误:--title 不能为空'); process.exit(1); }
|
||||
|
||||
const level = ['high','mid','low'].includes(args.level) ? args.level : 'mid';
|
||||
const tags = args.tags ? args.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||
const desc = args.desc || null;
|
||||
|
||||
db.seq++;
|
||||
const item = {
|
||||
id: db.seq,
|
||||
title,
|
||||
desc,
|
||||
level,
|
||||
tags,
|
||||
created_at: now(),
|
||||
done: false,
|
||||
completed_at: null,
|
||||
version: null,
|
||||
};
|
||||
db.items.push(item);
|
||||
save(db);
|
||||
writeFileSync(HTML, buildHtml(db), 'utf8');
|
||||
|
||||
console.log(`✅ 已添加 #${item.id} [${levelText(level)}] ${title}${tags.length ? ' ' + tags.join('/') : ''}`);
|
||||
printSummary(db);
|
||||
}
|
||||
|
||||
function cmdDone(db, args) {
|
||||
const id = args._[0];
|
||||
const item = findItem(db, id);
|
||||
|
||||
let version = args.version || null;
|
||||
if (!version) {
|
||||
version = gitTag();
|
||||
if (!version) console.warn('⚠️ 无法从 git 获取版本号,version 将为空');
|
||||
}
|
||||
|
||||
item.done = true;
|
||||
item.completed_at = now();
|
||||
item.version = version;
|
||||
|
||||
save(db);
|
||||
writeFileSync(HTML, buildHtml(db), 'utf8');
|
||||
|
||||
console.log(`✅ #${item.id}「${item.title}」已标记完成${version ? ',记入版本 ' + version : ''}`);
|
||||
printSummary(db);
|
||||
}
|
||||
|
||||
function cmdReopen(db, args) {
|
||||
const id = args._[0];
|
||||
const item = findItem(db, id);
|
||||
|
||||
item.done = false;
|
||||
item.completed_at = null;
|
||||
item.version = null;
|
||||
|
||||
save(db);
|
||||
writeFileSync(HTML, buildHtml(db), 'utf8');
|
||||
|
||||
console.log(`🔄 #${item.id}「${item.title}」已重新开启`);
|
||||
printSummary(db);
|
||||
}
|
||||
|
||||
function cmdRm(db, args) {
|
||||
const id = parseInt(args._[0], 10);
|
||||
const idx = db.items.findIndex(i => i.id === id);
|
||||
if (idx < 0) { console.error(`错误:找不到 id=${id}`); process.exit(1); }
|
||||
|
||||
const [removed] = db.items.splice(idx, 1);
|
||||
save(db);
|
||||
writeFileSync(HTML, buildHtml(db), 'utf8');
|
||||
|
||||
console.log(`🗑️ #${removed.id}「${removed.title}」已删除`);
|
||||
printSummary(db);
|
||||
}
|
||||
|
||||
function cmdList(db) {
|
||||
// 确保 HTML 最新
|
||||
writeFileSync(HTML, buildHtml(db), 'utf8');
|
||||
printSummary(db);
|
||||
console.log(`📄 HTML:todo/todo.html`);
|
||||
}
|
||||
|
||||
function cmdRender(db) {
|
||||
writeFileSync(HTML, buildHtml(db), 'utf8');
|
||||
console.log('🖨️ todo/todo.html 已重新渲染');
|
||||
}
|
||||
|
||||
// ─── 入口 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const [,, subcmd, ...rest] = process.argv;
|
||||
const db = load();
|
||||
const args = parseArgs(rest);
|
||||
|
||||
switch (subcmd) {
|
||||
case 'add': cmdAdd(db, args); break;
|
||||
case 'done': cmdDone(db, args); break;
|
||||
case 'reopen': cmdReopen(db, args); break;
|
||||
case 'rm': cmdRm(db, args); break;
|
||||
case 'list':
|
||||
case undefined:
|
||||
case '': cmdList(db); break;
|
||||
case 'render': cmdRender(db); break;
|
||||
default:
|
||||
console.error(`未知子命令:${subcmd}\n用法:node todo.mjs add|done|reopen|rm|list|render`);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user