评论阶段一(前端):评论区 + 登录引导卡 + dev mock,经典头像留白修复
评论区(按已批准的设计文档,阶段一=前端;后端与多用户为阶段二/三)
- classic / vivid 共用同一份 DOM:CommentSection + CommentItem(回复一层、
自引用渲染,回复/编辑框模板只写一份)+ CommentMarkdown(唯一 v-html 出口,
必过 sanitizeHtml)。比计划里的两套组件少一半文件,也避免两边结构漂移
- 数据层:api.js 追加 readerApi(me/providers/logout/comments/thread/create/
edit/remove);src/reader.js 读者身份 reactive 单例(仿 site.js,
挂 one:unauthorized 清 user);composables/useComments 按文章实例化
(刻意不做单例,换文章必须重置),乐观插入按当前排序落位
- 交互:排序(默认/最新/最早)、发表、回复(一层 + 加载更多回复)、
10 分钟内编辑、软删、分页(显示更多 N 条)、骨架屏、空态、错误态、审核中徽章
- 登录引导卡:三种 provider(GitHub/Google/Telegram);redirect 类整页跳转,
widget 类(Telegram)点击时内联官方脚本(无跳转、无回调,官方按钮落位)
- 样式:styles.css 追加 .cm-* 基础段(经典 token,vivid 靠别名继承同一份规则);
ui/yohaku/comments.css 只写 vivid 差异(全前缀、特指度盖过基础版),
自己的窄屏/触屏规则写在文件内,不塞 mobile.css
- main.ts:comments.css 插在 pages 之后 mobile 之前(层叠契约不变);
dev mock 用 ?mock 门控动态 import,await 装完再挂载,否则首屏请求会打到真接口
修三处真问题
- vivid 的 a{color:inherit} 重置(特指度 0,1,2)把作者链接名拉回 body 色,
暗色下暗底暗字 —— 用 a.cm-name-a 压回
- classic 文章页整页掉进 236px 左列:PostView import 了 LeftNav/RightRail
却没渲染,补上(与 HomeView 三栏一致),文章页回到 640px
- classic 信息流头像贴死列边框:.row-feed 的负 margin 正好抵光左右内边距,
改 padding 14/16 -> 14/30(30 = 16 出血 + 14 留白,hover 底色与分隔线仍铺满整列)
mock 假数据
- 8 位作者(三种登录方式、不同色相头像、两个走首字母兜底)、
默认一屏 8 条各测一个排版点(代码块/引用/列表/已删除/审核中/已编辑/站长徽章/
加载更多回复),长列表态 26 条测分页;右下角浮层切 9 个状态(?mock&state=),
?ui=classic 可强制 classic 壳(平时渲染不出来,没法做探针)
验证(headless Chromium + CDP 真实时间截图;virtual-time 对含动态 import 的页面
截图时机不稳,会截出全白,已换 CDP 方案)
- 头部计数 13px/tabular-nums、.seg 3 选 1;头像 50% 圆、36/24px;
气泡圆角 3,20,20,20(缺角);回复缩进 36px 且无二层嵌套;360 宽无横向溢出
- 暗色:气泡/输入框/名字全部适配(量的时候被 a 的 150ms 颜色 transition 骗过,
t0 与 t400 两次测量值不同才看清,稳态正确)
- 产物:CSS 顺序 styles→pages→comments→mobile;49 条评论规则全带
html[data-ui=vivid] 前缀;grep __COMMENT_MOCK__ 为空、无 mock chunk —— 生产零 mock
This commit is contained in:
1 parent
8fd8674910
commit
815eff9f9a
15 files changed
+1351
-3
No files matched your search
@@ -0,0 +1,228 @@
|
||||
import { readerApi } from '../api'
|
||||
import { site } from '../site'
|
||||
import { reader } from '../reader'
|
||||
import { ME, OWNER, GUEST, PROVIDERS, baseItems, longItems, mk } from './fixtures'
|
||||
|
||||
// ============================================================================
|
||||
// 评论区的 dev mock(仅开发用)
|
||||
// ----------------------------------------------------------------------------
|
||||
// 由 main.ts 在 `import.meta.env.DEV && ?mock` 时动态 import。
|
||||
// build 时 DEV 被替换成 false,整个分支连同这个 chunk 一起被丢掉 ——
|
||||
// 产物里 grep __COMMENT_MOCK__ 必须为空,这是阶段一验证的一条硬指标。
|
||||
//
|
||||
// 用「覆写 readerApi 的方法」而不是另起一套假 API:组件里的代码路径在
|
||||
// mock 与真实模式下逐字节相同,不会出现「mock 能跑、真接口挂」。
|
||||
//
|
||||
// 用法:/post/<slug>?mock → 默认一屏(含各种状态)
|
||||
// /post/<slug>?mock&state=empty → 指定状态
|
||||
// 右下角浮层可逐个切换(切换会 reload,保持 URL 可分享)
|
||||
// ============================================================================
|
||||
|
||||
const SENTINEL = '__COMMENT_MOCK__'
|
||||
|
||||
const STATES = [
|
||||
['default', '默认一屏'],
|
||||
['loggedout', '未登录'],
|
||||
['empty', '空'],
|
||||
['loading', '加载中'],
|
||||
['pending', '审核中'],
|
||||
['deleted', '已删除'],
|
||||
['replies', '有回复'],
|
||||
['long', '长列表'],
|
||||
['error', '报错']
|
||||
]
|
||||
|
||||
const delay = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
let items = []
|
||||
|
||||
function applyState(name) {
|
||||
reader.user = null
|
||||
switch (name) {
|
||||
case 'loggedout':
|
||||
items = baseItems()
|
||||
break
|
||||
case 'empty':
|
||||
reader.user = ME
|
||||
items = []
|
||||
break
|
||||
case 'loading':
|
||||
reader.user = ME
|
||||
items = baseItems()
|
||||
break
|
||||
case 'pending':
|
||||
reader.user = ME
|
||||
items = [mk({ author: ME, ago: 60e3, state: 'pending', html: '<p>刚发出去,还在等审核。</p>' })]
|
||||
break
|
||||
case 'deleted':
|
||||
reader.user = ME
|
||||
items = [mk({ author: GUEST, ago: 30 * 60e3, is_deleted: true, html: '' })]
|
||||
break
|
||||
case 'replies': {
|
||||
reader.user = ME
|
||||
const c = mk({ author: GUEST, ago: 3 * 3600e3, html: '<p>回复都在这一条下面。</p>' })
|
||||
c.replies = [
|
||||
mk({ author: ME, ago: 2 * 3600e3, parent_id: c.id, root_id: c.id, html: '<p>第一条回复。</p>' }),
|
||||
mk({ author: OWNER, ago: 3600e3, parent_id: c.id, root_id: c.id, html: '<p>第二条,站长回的。</p>' })
|
||||
]
|
||||
c.reply_count = 6
|
||||
items = [c]
|
||||
break
|
||||
}
|
||||
case 'long':
|
||||
reader.user = ME
|
||||
items = longItems()
|
||||
break
|
||||
case 'error':
|
||||
reader.user = ME
|
||||
items = []
|
||||
break
|
||||
default:
|
||||
reader.user = ME
|
||||
items = baseItems()
|
||||
}
|
||||
}
|
||||
|
||||
function pageOf(opt = {}) {
|
||||
const size = opt.size || 10
|
||||
const page = opt.page || 1
|
||||
const sorted = items.slice()
|
||||
if (opt.sort === 'oldest') sorted.reverse()
|
||||
return {
|
||||
items: sorted.slice(0, page * size),
|
||||
total: sorted.length,
|
||||
page,
|
||||
pages: Math.max(1, Math.ceil(sorted.length / size)),
|
||||
has_more: sorted.length > page * size
|
||||
}
|
||||
}
|
||||
|
||||
export function install() {
|
||||
const q = new URLSearchParams(location.search)
|
||||
const state = q.get('state') || 'default'
|
||||
applyState(state)
|
||||
|
||||
// 评审用:?ui=classic 强制走 classic 那套壳。站点平时是 vivid,classic 分支
|
||||
// 平时根本渲染不出来,不加这个就没法给它截图 / 做探针。
|
||||
// 改 ui_id 会触发 site.js 里的 watcher,applyTheme 由它调。
|
||||
const ui = q.get('ui')
|
||||
if (ui === 'classic' || ui === 'vivid') site.ui_id = ui
|
||||
|
||||
Object.assign(readerApi, {
|
||||
me: async () => ({ user: reader.user }),
|
||||
providers: async () => ({ providers: PROVIDERS }),
|
||||
logout: async () => {
|
||||
reader.user = null
|
||||
return { ok: true }
|
||||
},
|
||||
comments: async (_postId, opt) => {
|
||||
if (state === 'error') throw new Error('评论加载失败(mock 的错误态)')
|
||||
if (state === 'loading') {
|
||||
// 把骨架屏留在屏幕上足够看清
|
||||
await delay(30000)
|
||||
} else {
|
||||
await delay(180)
|
||||
}
|
||||
return pageOf(opt)
|
||||
},
|
||||
create: async (_postId, bodyMd, parentId) => {
|
||||
await delay(300)
|
||||
const text = `<p>${bodyMd.replace(/[<>&]/g, '')}</p>`
|
||||
const c = mk({ author: reader.user || ME, ago: 0, html: text, state: 'pending' })
|
||||
if (parentId) {
|
||||
c.parent_id = parentId
|
||||
c.root_id = parentId
|
||||
const root = items.find((x) => x.id === parentId) || items.find((x) => (x.replies || []).some((r) => r.id === parentId))
|
||||
if (root) {
|
||||
root.replies = (root.replies || []).concat(c)
|
||||
root.reply_count = (root.reply_count || 0) + 1
|
||||
}
|
||||
} else {
|
||||
items.unshift(c)
|
||||
}
|
||||
return c
|
||||
},
|
||||
edit: async (id, bodyMd) => {
|
||||
await delay(200)
|
||||
const found = findComment(id)
|
||||
if (found) {
|
||||
found.body_html = `<p>${bodyMd.replace(/[<>&]/g, '')}</p>`
|
||||
found.edited_at = new Date().toISOString()
|
||||
}
|
||||
return found || {}
|
||||
},
|
||||
remove: async (id) => {
|
||||
await delay(200)
|
||||
const found = findComment(id)
|
||||
if (found) {
|
||||
found.is_deleted = true
|
||||
found.body_html = ''
|
||||
}
|
||||
return { ok: true }
|
||||
},
|
||||
thread: async (rootId) => {
|
||||
await delay(200)
|
||||
const root = items.find((x) => x.id === rootId)
|
||||
if (!root) return { items: [], cursor: '', reply_count: 0 }
|
||||
const extra = [
|
||||
mk({ author: GUEST, ago: 45 * 60e3, parent_id: rootId, root_id: rootId, html: '<p>后面补上的回复。</p>' })
|
||||
]
|
||||
root.replies = (root.replies || []).concat(extra)
|
||||
return { items: extra, cursor: '', reply_count: root.reply_count }
|
||||
}
|
||||
})
|
||||
|
||||
function findComment(id) {
|
||||
for (const c of items) {
|
||||
if (c.id === id) return c
|
||||
const r = (c.replies || []).find((x) => x.id === id)
|
||||
if (r) return r
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
mountSwitcher(state)
|
||||
// 哨兵:验证产物里没有 mock 时 grep 这个串
|
||||
console.info(`[${SENTINEL}] comments mock installed, state =`, state)
|
||||
}
|
||||
|
||||
// 右下角状态浮层。用原生 DOM + 内联样式 —— 它永远不进产物,不值得占 CSS 文件。
|
||||
function mountSwitcher(current) {
|
||||
if (document.getElementById('cm-mock-switch')) return
|
||||
const box = document.createElement('div')
|
||||
box.id = 'cm-mock-switch'
|
||||
box.setAttribute(
|
||||
'style',
|
||||
'position:fixed;z-index:9999;right:12px;bottom:12px;max-width:190px;padding:10px;' +
|
||||
'font:12px/1.6 ui-sans-serif,system-ui;color:#111;background:rgba(255,255,255,.94);' +
|
||||
'border:1px solid #ddd;border-radius:10px;box-shadow:0 6px 24px rgba(0,0,0,.16)'
|
||||
)
|
||||
const title = document.createElement('div')
|
||||
title.textContent = '评论 mock 状态'
|
||||
title.setAttribute('style', 'font-weight:700;margin-bottom:6px')
|
||||
box.appendChild(title)
|
||||
|
||||
const wrap = document.createElement('div')
|
||||
wrap.setAttribute('style', 'display:flex;flex-wrap:wrap;gap:4px')
|
||||
for (const [value, label] of STATES) {
|
||||
const b = document.createElement('button')
|
||||
b.type = 'button'
|
||||
b.textContent = label
|
||||
b.setAttribute(
|
||||
'style',
|
||||
'font:inherit;padding:3px 7px;border-radius:6px;cursor:pointer;' +
|
||||
(value === current
|
||||
? 'background:#111;color:#fff;border:1px solid #111'
|
||||
: 'background:#fff;color:#333;border:1px solid #ddd')
|
||||
)
|
||||
b.onclick = () => {
|
||||
const q = new URLSearchParams(location.search)
|
||||
q.set('mock', '1')
|
||||
q.set('state', value)
|
||||
location.search = q.toString()
|
||||
}
|
||||
wrap.appendChild(b)
|
||||
}
|
||||
box.appendChild(wrap)
|
||||
document.body.appendChild(box)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// 评论 mock 的假数据(仅 dev,见 mocks/comments.js)。
|
||||
// 字段要和后端 model.Comment 对齐 —— 真接口落地后,这里的差别就是前端要改的地方。
|
||||
// 内容按真实博客评论的口吻写的:提问 / 挑错 / 补充 / 串门,别改成「好文,学习了」那种。
|
||||
|
||||
// 头像:内联 SVG(离线可用),每个作者一个色相,顺便把 <img> 那条分支走通
|
||||
const AV =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' fill='%23__COLOR__'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23fff' opacity='.92'/%3E%3Cpath d='M12 64c0-12 9-20 20-20s20 8 20 20z' fill='%23fff' opacity='.92'/%3E%3C/svg%3E"
|
||||
const avatar = (hex) => AV.replace('__COLOR__', hex)
|
||||
|
||||
export const ME = {
|
||||
id: 3,
|
||||
name: 'hegu',
|
||||
handle: 'cnhegu',
|
||||
avatar_url: avatar('69a6cc'),
|
||||
url: '',
|
||||
provider: 'github',
|
||||
role: 'reader',
|
||||
is_owner: false
|
||||
}
|
||||
|
||||
export const OWNER = {
|
||||
id: 1,
|
||||
name: 'ONE',
|
||||
handle: 'one-blog',
|
||||
avatar_url: '',
|
||||
url: 'https://example.com',
|
||||
provider: 'github',
|
||||
role: 'admin',
|
||||
is_owner: true
|
||||
}
|
||||
|
||||
export const USERS = {
|
||||
me: ME,
|
||||
owner: OWNER,
|
||||
lin: { id: 9, name: '林间', handle: 'linjian-dev', avatar_url: avatar('8a7f6b'), url: '', provider: 'google', role: 'reader', is_owner: false },
|
||||
yuki: { id: 12, name: 'Yuki', handle: 'yuki0x0', avatar_url: avatar('6b8f9e'), url: 'https://yuki.example.dev', provider: 'github', role: 'member', is_owner: false },
|
||||
awei: { id: 21, name: '阿伟', handle: '', avatar_url: '', url: '', provider: 'telegram', role: 'reader', is_owner: false },
|
||||
mason: { id: 30, name: 'Mason', handle: 'masoncodes', avatar_url: avatar('7d6f9c'), url: '', provider: 'github', role: 'member', is_owner: false },
|
||||
noli: { id: 44, name: 'noli', handle: '', avatar_url: '', url: '', provider: 'google', role: 'reader', is_owner: false },
|
||||
tg: { id: 11, name: 'TG 用户', handle: '', avatar_url: '', url: '', provider: 'telegram', role: 'reader', is_owner: false }
|
||||
}
|
||||
// 旧名字留个别名,mock 主文件里还在用
|
||||
export const GUEST = USERS.lin
|
||||
export const TGUSER = USERS.tg
|
||||
|
||||
export const PROVIDERS = [
|
||||
{ id: 'github', label: 'GitHub', kind: 'redirect' },
|
||||
{ id: 'google', label: 'Google', kind: 'redirect' },
|
||||
{ id: 'telegram', label: 'Telegram', kind: 'widget', login: 'example_bot' }
|
||||
]
|
||||
|
||||
const MIN = 60e3
|
||||
const HOUR = 60 * MIN
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
let seq = 100
|
||||
export function mk({ author = AUTHORS.lin, html, ago = HOUR, state = 'published', ...rest }) {
|
||||
const text = html || '<p>写点什么。</p>'
|
||||
return {
|
||||
id: ++seq,
|
||||
parent_id: 0,
|
||||
root_id: 0,
|
||||
body_html: text,
|
||||
body_md: text.replace(/<[^>]+>/g, ''),
|
||||
state,
|
||||
created_at: new Date(Date.now() - ago).toISOString(),
|
||||
edited_at: '',
|
||||
is_deleted: false,
|
||||
reply_count: 0,
|
||||
replies: [],
|
||||
author,
|
||||
...rest
|
||||
}
|
||||
}
|
||||
|
||||
export function reply(parent, opts = {}) {
|
||||
const r = mk(opts)
|
||||
r.parent_id = parent.id
|
||||
r.root_id = parent.id
|
||||
return r
|
||||
}
|
||||
|
||||
// ---- 一屏的量:覆盖各种状态和排版(徽章 / 缺角气泡 / 代码 / 引用 / 列表 / 删除)----
|
||||
export function baseItems() {
|
||||
// 主楼:最长的讨论串(5 条回复、reply_count 同值,测「加载更多回复」的按钮态)
|
||||
const c1 = mk({
|
||||
author: USERS.lin,
|
||||
ago: 6 * HOUR,
|
||||
html:
|
||||
'<p>三栏那个侧边目录会跟着滚动高亮,这个细节做得好。<code>IntersectionObserver</code> 还是算 scroll 位置?</p>' +
|
||||
'<p>另外时间线里「更早 / 上一篇」这组标签,是文章自己的字段还是按位置算的?</p>'
|
||||
})
|
||||
c1.replies = [
|
||||
reply(c1, { author: ME, ago: 5 * HOUR, html: '<p>算 scroll 位置,没用 observer —— 目录只有几条,没必要。</p>' }),
|
||||
reply(c1, { author: OWNER, ago: 4 * HOUR + 20 * MIN, html: '<p>后面会换成 observer,等条目多了再说。</p>' }),
|
||||
reply(c1, { author: USERS.yuki, ago: 3 * HOUR, html: '<p>标签是按位置算的:找到当前篇,前后各两篇贴上「上一篇 / 下一篇 / 更早 / 更新」。</p>' }),
|
||||
reply(c1, { author: USERS.mason, ago: 2 * HOUR, html: '<p>学到了。</p>' }),
|
||||
reply(c1, { author: USERS.noli, ago: 80 * MIN, html: '<p>同问 observer,楼上已经答了,收了。</p>' })
|
||||
]
|
||||
c1.reply_count = 8 // 只展开 5 条,剩 3 条走「加载更多回复」(thread 接口)
|
||||
|
||||
const c2 = mk({
|
||||
author: USERS.yuki,
|
||||
ago: 5 * HOUR,
|
||||
html:
|
||||
'<p>挑个刺:<code>.proj-grid</code> 的轨道下限要是裸 <code>300px</code>,320px 的屏会溢出吧?</p>' +
|
||||
'<pre><code>grid-template-columns: repeat(auto-fill, minmax(min(300px,100%), 1fr));</code></pre>' +
|
||||
'<p>这样写就稳了。</p>'
|
||||
})
|
||||
|
||||
const c3 = mk({
|
||||
author: OWNER,
|
||||
ago: 2 * DAY,
|
||||
html:
|
||||
'<p>补一句:留白的尺度是 <strong>28 / 48 / 60</strong>,不要随手改成整十数,视觉节奏会散。</p>' +
|
||||
'<blockquote><p>间距是设计的一部分,不是留白的一部分。</p></blockquote>'
|
||||
})
|
||||
|
||||
const c4 = mk({
|
||||
author: USERS.awei,
|
||||
ago: 40 * HOUR,
|
||||
html:
|
||||
'<p>手机上那个 ☰ 菜单挺好用的。要是能把 <strong>主题切换</strong> 也放进去就完美了 —— 现在得退回顶栏才切得动。</p>' +
|
||||
'<ul><li>时间线 / 归档 / 标签 / 作品 / 关于</li><li>底部:主题</li></ul>'
|
||||
})
|
||||
|
||||
const c5 = mk({ author: USERS.noli, ago: 26 * HOUR, is_deleted: true, html: '' })
|
||||
|
||||
const c6 = mk({
|
||||
author: ME,
|
||||
ago: 20 * HOUR,
|
||||
state: 'pending',
|
||||
html: '<p>这条在等审核(<code>comment_review=1</code> 时会走这个状态),只有我自己看得见。</p>'
|
||||
})
|
||||
|
||||
const c7 = mk({
|
||||
author: USERS.tg,
|
||||
ago: 14 * HOUR,
|
||||
html: '<p>手机上那个 ☰ 菜单挺好用的。Telegram 登录什么时候开?</p>'
|
||||
})
|
||||
|
||||
const c8 = mk({
|
||||
author: USERS.mason,
|
||||
ago: 8 * HOUR,
|
||||
edited_at: new Date(Date.now() - 6 * HOUR).toISOString(),
|
||||
html:
|
||||
'<p>已编辑过的评论会带一个小标记。顺手贴个我抄走的 <code>color-mix</code> 用法:</p>' +
|
||||
'<blockquote><p>color-mix(in srgb, var(--ink) 6%, transparent)</p></blockquote>'
|
||||
})
|
||||
|
||||
return [c8, c7, c6, c2, c4, c5, c3, c1]
|
||||
}
|
||||
|
||||
// ---- 长列表:把 baseItems 铺开再加一批,专门测「显示更多」分页 ----
|
||||
export function longItems() {
|
||||
const out = baseItems()
|
||||
const lines = [
|
||||
['收藏了,回头照着做一遍。', USERS.lin],
|
||||
['暗色下那套 <code>--v-*</code> 是怎么对齐的?手调的还是工具生成的?', USERS.mason],
|
||||
['正文行宽 728 是算出来的还是试出来的?', USERS.awei],
|
||||
['首页那个「做过的东西」排版舒服。', USERS.yuki],
|
||||
['<p>提个需求:评论能不能支持 <em>表情回应</em>?不用点赞那么复杂,几个 emoji 就够。</p>', USERS.awei],
|
||||
['RSS 里能带上评论数吗?', USERS.noli],
|
||||
['<p>照着把 <code>mobile.css</code> 的思路搬回我们项目了,一处收口确实比到处写媒体查询干净。</p>', USERS.mason],
|
||||
['字体是思源宋体?正文里中英混排的字距很舒服。', USERS.lin],
|
||||
['试了下 320px 宽,没横向滚动,赞。', USERS.yuki],
|
||||
['<p>后台那个命令面板(<code>⌘K</code>)是现成的还是自己实现的?</p>', USERS.awei],
|
||||
['归档页按年份分组比无限流舒服。', USERS.noli],
|
||||
['希望以后能匿名评论 —— 不想为了一条评论去连 GitHub。', USERS.tg],
|
||||
['<p>时间线那条渐变竖线:</p><pre><code>background: linear-gradient(180deg, var(--accent), transparent);</code></pre><p>stolen, thanks.</p>', USERS.yuki],
|
||||
['文章里的首字下沉在暗色下会不会太跳?', USERS.mason],
|
||||
['关注了。', USERS.lin],
|
||||
['<p>标签页那组 <strong>写 / 设 / 篇 / 辑</strong> 的措辞是故意的吧,挺妙。</p>', USERS.awei],
|
||||
['页脚那句引言换过几次了?', USERS.tg],
|
||||
['<p>RSS 阅读器里看到的排版比网页还干净,罕见。</p>', USERS.yuki]
|
||||
]
|
||||
lines.forEach(([html, author], i) => {
|
||||
out.push(mk({ author, ago: 2 * DAY + (i + 1) * 7 * HOUR, html }))
|
||||
})
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user