mirror of
https://github.com/lifegpc/bookdownload.git
synced 2026-06-20 02:45:02 +08:00
支持插入新章节
This commit is contained in:
@@ -420,13 +420,8 @@ export class IndexedDb implements Db {
|
||||
}
|
||||
async getQdNewChapterId(): Promise<number> {
|
||||
const smallest_id = await get_data_with_convert<CompressedQdChapterInfo | QdChapterInfo, number>(this.qddb, 'chapters', undefined, async (key, data) => {
|
||||
if ('compressed' in data) {
|
||||
const decompressed = await decompress(data.compressed);
|
||||
const decoded = new TextDecoder().decode(decompressed);
|
||||
data = JSON.parse(decoded) as QdChapterInfo;
|
||||
}
|
||||
return data.id;
|
||||
}, 'id', 'prev');
|
||||
}, 'id', 'next');
|
||||
if (smallest_id === undefined || smallest_id >= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
67
src/manage/qd/BookNewChapter.module.css
Normal file
67
src/manage/qd/BookNewChapter.module.css
Normal file
@@ -0,0 +1,67 @@
|
||||
.c {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: calc(100vh - 40px);
|
||||
max-height: calc(100vh - 40px);
|
||||
align-items: stretch;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 3;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.editor {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.save {
|
||||
color: gray;
|
||||
cursor: pointer;
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.save:hover {
|
||||
color: #4096ff;
|
||||
}
|
||||
|
||||
.save.disabled {
|
||||
color: lightgray;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.save.disabled:hover {
|
||||
color: lightgray;
|
||||
}
|
||||
|
||||
.loc {
|
||||
color: gray;
|
||||
cursor: pointer;
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.loc:hover {
|
||||
color: #4096ff;
|
||||
}
|
||||
|
||||
.loc.unset {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.loc.unset:hover {
|
||||
color: #4096ff;
|
||||
}
|
||||
@@ -1,16 +1,31 @@
|
||||
import { useBookContext } from "./BookStatusProvider";
|
||||
import { loadChapterListsIfNeeded, useBookContext, useBookStatus } from "./BookStatusProvider";
|
||||
import type { QdChapterInfo } from "../../types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useDb } from "../dbProvider";
|
||||
import { useBookInfo } from "./BookInfoProvider";
|
||||
import { Result, Skeleton } from "antd";
|
||||
import { Result, Skeleton, Input, Tooltip, Modal } from "antd";
|
||||
import NewChapterEditor from "./NewChapterEditor";
|
||||
import styles from "./BookNewChapter.module.css";
|
||||
import Icon from "../../components/Icon";
|
||||
import SaveOutlined from "../../../node_modules/@material-icons/svg/svg/save/outline.svg";
|
||||
import ViewListOutlined from "../../../node_modules/@material-icons/svg/svg/view_list/outline.svg";
|
||||
import { ChapterShowMode, get_new_volumes } from "../../utils/qd";
|
||||
import VolumesList from "./VolumesList";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export default function BookNewChapter() {
|
||||
const setItems = useBookContext();
|
||||
const [chapter, setChapter] = useState<QdChapterInfo | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [content, setContent] = useState('');
|
||||
const [chapterName, setChapterName] = useState('');
|
||||
const [loc, setLoc] = useState<[number | null, number | null] | null>(null);
|
||||
const bookInfo = useBookInfo();
|
||||
const [bookStatus, setBookStatus] = useBookStatus();
|
||||
const db = useDb();
|
||||
const [openChapterList, setOpenChapterList] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
setItems([
|
||||
{ title: "新章节" },
|
||||
])
|
||||
@@ -154,8 +169,73 @@ export default function BookNewChapter() {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
loadChapterListsIfNeeded(bookInfo.id, bookStatus, setBookStatus, db).catch(e => {
|
||||
console.warn(e);
|
||||
});
|
||||
}, [bookInfo.id]);
|
||||
const vols = useMemo(() => {
|
||||
let vols = bookInfo.volumes;
|
||||
if (bookStatus.chapterLists) {
|
||||
vols = get_new_volumes(bookStatus.chapterLists, bookInfo.volumes, ChapterShowMode.All);
|
||||
if (vols.length > 0 && vols[0].id == 'vol_new') {
|
||||
vols.splice(0, 1);
|
||||
}
|
||||
}
|
||||
return vols;
|
||||
}, [bookInfo.volumes, bookStatus.chapterLists]);
|
||||
let saveClass = styles.save;
|
||||
const saveDisabled = !chapterName.trim() || !content.trim() || !loc;
|
||||
if (saveDisabled || isSaving) {
|
||||
saveClass += ` ${styles.disabled}`;
|
||||
}
|
||||
let locClass = styles.loc;
|
||||
if (!loc) {
|
||||
locClass += ` ${styles.unset}`;
|
||||
}
|
||||
async function handleSave() {
|
||||
if (saveDisabled) return;
|
||||
if (!chapter) return;
|
||||
if (!loc) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
chapter.contents = content.split('\n');
|
||||
chapter.chapterInfo.chapterName = chapterName;
|
||||
chapter.chapterInfo.prev = loc[0] ?? undefined;
|
||||
chapter.chapterInfo.next = loc[1] ?? undefined;
|
||||
await db.saveQdChapter(chapter);
|
||||
setBookStatus(prev => ({
|
||||
...prev,
|
||||
chapterLists: undefined,
|
||||
}));
|
||||
navigate(`/qd/book/${bookInfo.id}/chapter/${chapter.id}`);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
return (<>
|
||||
{err && <Result title="加载失败" status="error" subTitle={err} />}
|
||||
{!err && !chapter && <Skeleton active />}
|
||||
{chapter && !err && <div className={styles.c}>
|
||||
<div className={styles.top}>
|
||||
<Input placeholder="章节名称" value={chapterName} onChange={(e) => setChapterName(e.target.value)} className={styles.name} />
|
||||
<div className={styles.actions}>
|
||||
<Tooltip title="设置章节位置" placement="left"><Icon><ViewListOutlined fill="currentColor" className={locClass} onClick={() => setOpenChapterList(true)} /></Icon></Tooltip>
|
||||
<Tooltip title="保存新章节" placement="left"><Icon><SaveOutlined fill="currentColor" className={saveClass} onClick={saveDisabled || isSaving ? undefined : () => handleSave()} /></Icon></Tooltip>
|
||||
</div>
|
||||
<Modal title="选择章节位置" open={openChapterList} onCancel={() => setOpenChapterList(false)} footer={null} width={{
|
||||
sm: 400,
|
||||
md: 600,
|
||||
lg: 800,
|
||||
xl: 1000,
|
||||
xxl: 1400,
|
||||
xxxl: 1800,
|
||||
}}>
|
||||
<VolumesList bookId={bookInfo.id} volumes={vols} setLoc={setLoc} loc={loc} />
|
||||
</Modal>
|
||||
</div>
|
||||
<NewChapterEditor content={content} onContentChanged={setContent} className={styles.editor} />
|
||||
</div>}
|
||||
</>);
|
||||
}
|
||||
|
||||
60
src/manage/qd/NewChapterEditor.tsx
Normal file
60
src/manage/qd/NewChapterEditor.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Component, createRef } from "react";
|
||||
import MonacoEditor, { MonacoEditorHandle } from 'react-monaco-editor';
|
||||
|
||||
export interface NewChapterEditorProps {
|
||||
content: string;
|
||||
onContentChanged: (content: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export default class NewChapterEditor extends Component<NewChapterEditorProps, State> {
|
||||
ref;
|
||||
#editorContainerRef = createRef<HTMLDivElement>();
|
||||
#resizeObserver?: ResizeObserver;
|
||||
constructor(props: NewChapterEditorProps) {
|
||||
super(props);
|
||||
this.ref = createRef<MonacoEditorHandle>();
|
||||
this.state = { height: 0 };
|
||||
}
|
||||
componentDidMount() {
|
||||
if (this.#editorContainerRef.current) {
|
||||
this.#resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
this.setState({ height: entry.contentRect.height });
|
||||
}
|
||||
this.layout();
|
||||
});
|
||||
this.#resizeObserver.observe(this.#editorContainerRef.current);
|
||||
// 初始化高度
|
||||
this.setState({ height: this.#editorContainerRef.current.clientHeight });
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
this.#resizeObserver?.disconnect();
|
||||
}
|
||||
layout() {
|
||||
this.ref.current?.editor.layout();
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<div ref={this.#editorContainerRef} className={this.props.className}>
|
||||
<MonacoEditor
|
||||
ref={this.ref}
|
||||
value={this.props.content}
|
||||
language="plaintext"
|
||||
width="100%"
|
||||
height={this.state.height || '100%'}
|
||||
onChange={(newValue) => this.props.onContentChanged(newValue)}
|
||||
options={{
|
||||
wordWrap: 'on',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
import { Collapse, CollapseProps, Flex, Tooltip } from "antd";
|
||||
import { Collapse, CollapseProps, Flex, Tooltip, Alert } from "antd";
|
||||
import type { Volume } from "../../qdtypes";
|
||||
import styles from './VolumesList.module.css';
|
||||
import { Link } from "react-router";
|
||||
import { CheckCircleOutlined } from "@ant-design/icons";
|
||||
import OpenInNewTab from "../../../node_modules/@material-icons/svg/svg/open_in_new/twotone.svg";
|
||||
import Icon from "../../components/Icon";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import LocationSearchingTwotone from "../../../node_modules/@material-icons/svg/svg/location_searching/twotone.svg";
|
||||
import { generateId } from "../../utils";
|
||||
|
||||
export type VolumesListProps = {
|
||||
volumes: Volume[];
|
||||
bookId: number;
|
||||
oneLine?: boolean;
|
||||
current?: number;
|
||||
loc?: [number | null, number | null] | null;
|
||||
locTip?: string;
|
||||
setLoc?: (loc: [number | null, number | null] | null) => void;
|
||||
}
|
||||
|
||||
async function open_in_qidian(bookId: number, chapterId: number) {
|
||||
@@ -30,12 +34,13 @@ async function open_in_qidian(bookId: number, chapterId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
export default function VolumesList({ volumes, bookId, oneLine, current }: VolumesListProps) {
|
||||
export default function VolumesList({ volumes, bookId, oneLine, current, loc, locTip, setLoc }: VolumesListProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const currentVolumeId = useMemo(() => {
|
||||
if (!current) return null;
|
||||
return volumes.find(v => v.chapters.some(ch => ch.id === current))?.id ?? null;
|
||||
}, [volumes, current]);
|
||||
const name = useState(() => generateId())[0];
|
||||
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>([]);
|
||||
|
||||
@@ -66,18 +71,37 @@ export default function VolumesList({ volumes, bookId, oneLine, current }: Volum
|
||||
}
|
||||
};
|
||||
|
||||
const hasLoc = loc !== undefined;
|
||||
const items = useMemo<CollapseProps['items']>(() => volumes.map(v => {
|
||||
const children = v.chapters.map(chapter => (
|
||||
<Flex className={oneLine ? styles.chone : styles.ch} key={chapter.id} data-chapter-id={chapter.id}>
|
||||
<Link to={`/qd/book/${bookId}/chapter/${chapter.id}`}>{chapter.name}</Link>
|
||||
const chCount = v.chapters.length;
|
||||
const children = v.chapters.map((chapter, index) => {
|
||||
const id = hasLoc ? generateId() : undefined;
|
||||
const curId = hasLoc ? chapter.id : null;
|
||||
const preId = hasLoc && index > 0 ? v.chapters[index - 1].id : null;
|
||||
const checked = hasLoc && loc && loc[0] === preId && loc[1] === curId;
|
||||
return <Flex className={oneLine ? styles.chone : styles.ch} key={chapter.id} data-chapter-id={chapter.id} align="center">
|
||||
{hasLoc && <input type="radio" id={id} checked={checked ?? false} onChange={() => setLoc && setLoc([preId, curId])} data-loc-id={`${preId},${curId}`} name={name} />}
|
||||
{!hasLoc && <Link to={`/qd/book/${bookId}/chapter/${chapter.id}`}>{chapter.name}</Link>}
|
||||
{hasLoc && <label htmlFor={id}>{chapter.name}</label>}
|
||||
<Flex className={styles.action}>
|
||||
{chapter.isSaved && <CheckCircleOutlined className={styles.saved} />}
|
||||
<Tooltip title="在起点上查看(新标签页)">
|
||||
{chapter.id >= 0 && <Tooltip title="在起点上查看(新标签页)">
|
||||
<Icon><OpenInNewTab fill="currentColor" width="20" className={styles.open} onClick={() => open_in_qidian(bookId, chapter.id)} /></Icon>
|
||||
</Tooltip>
|
||||
</Tooltip>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
));
|
||||
});
|
||||
if (hasLoc) {
|
||||
const id = generateId();
|
||||
const curId = v.chapters[chCount - 1].id;
|
||||
const checked = loc && loc[0] === curId && loc[1] === null;
|
||||
children.push(
|
||||
<Flex className={oneLine ? styles.chone : styles.ch} key={`loc-${v.id}`}>
|
||||
<input type="radio" id={id} checked={checked ?? false} onChange={() => setLoc && setLoc([curId, null])} data-loc-id={`${curId},null`} name={name} />
|
||||
<label htmlFor={id}>插入到卷末</label>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
return {
|
||||
key: v.id,
|
||||
label: v.name,
|
||||
@@ -89,8 +113,22 @@ export default function VolumesList({ volumes, bookId, oneLine, current }: Volum
|
||||
{children}
|
||||
</Flex>
|
||||
}
|
||||
}), [volumes, bookId, oneLine]);
|
||||
}), [volumes, bookId, oneLine, hasLoc]);
|
||||
useEffect(() => {
|
||||
if (!loc) return;
|
||||
const el = containerRef.current?.querySelector(`input[data-loc-id="${loc[0]},${loc[1]}"]`) as HTMLInputElement | null;
|
||||
if (el) {
|
||||
setTimeout(() => {
|
||||
if (!el.checked) el.checked = true;
|
||||
}, 1);
|
||||
} else {
|
||||
if (setLoc) {
|
||||
setLoc(null);
|
||||
}
|
||||
}
|
||||
}, [loc]);
|
||||
return (<div className={styles.c} ref={containerRef}>
|
||||
{hasLoc && <Alert type="info" title={locTip ?? "新章节将插入到选中的位置之前"} />}
|
||||
<div className={styles.cl}><Collapse
|
||||
key={bookId}
|
||||
activeKey={activeKeys}
|
||||
|
||||
@@ -163,3 +163,10 @@ export function toHex(bytes: Uint8Array): string {
|
||||
|
||||
/**@ts-expect-error Detect ServiceWorkerGlobalScope */
|
||||
export const isServiceWorker = typeof ServiceWorkerGlobalScope !== 'undefined' && self instanceof ServiceWorkerGlobalScope;
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
export function generateId(prefix: string = 'id'): string {
|
||||
idCounter += 1;
|
||||
return `${prefix}_${idCounter}`;
|
||||
}
|
||||
|
||||
@@ -111,6 +111,14 @@ export default function t() {
|
||||
time: 11,
|
||||
prev: 3,
|
||||
},
|
||||
{
|
||||
primaryKey: 0,
|
||||
id: 5,
|
||||
name: 'Chapter 5',
|
||||
bookId: 0,
|
||||
time: 12,
|
||||
prev: 4,
|
||||
},
|
||||
{
|
||||
primaryKey: 0,
|
||||
id: 33,
|
||||
@@ -136,6 +144,7 @@ export default function t() {
|
||||
{ id: 1, name: 'Chapter 1', isSaved: true },
|
||||
{ id: 3, name: 'Chapter 3' },
|
||||
{ id: 4, name: 'Chapter 4', isSaved: true },
|
||||
{ id: 5, name: 'Chapter 5', isSaved: true },
|
||||
]
|
||||
}
|
||||
]);
|
||||
@@ -155,6 +164,7 @@ export default function t() {
|
||||
chapters: [
|
||||
{ id: 1, name: 'Chapter 1' },
|
||||
{ id: 4, name: 'Chapter 4' },
|
||||
{ id: 5, name: 'Chapter 5' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -36,6 +36,24 @@ export function hash_qdchapter_info(info: QdChapterInfo): string {
|
||||
return toHex(hash);
|
||||
}
|
||||
|
||||
enum MergeMode {
|
||||
PreOnly,
|
||||
NextOnly,
|
||||
All,
|
||||
AllWithNext,
|
||||
AllWithPrev,
|
||||
}
|
||||
|
||||
const SORT_MODE: MergeMode[] = [MergeMode.PreOnly, MergeMode.NextOnly, MergeMode.All, MergeMode.AllWithNext, MergeMode.AllWithPrev];
|
||||
|
||||
function NextMode(mode: MergeMode) {
|
||||
const index = SORT_MODE.indexOf(mode);
|
||||
if (index === -1) return null;
|
||||
if (index === SORT_MODE.length - 1) return null;
|
||||
return SORT_MODE[index + 1];
|
||||
}
|
||||
|
||||
|
||||
export function get_new_volumes(chapterLists: QdChapterSimpleInfo[], volumes: Volume[], keepMode: ChapterShowMode): Volume[] {
|
||||
const vols: Volume[] = [];
|
||||
if (keepMode == ChapterShowMode.All || keepMode == ChapterShowMode.SavedOnly) {
|
||||
@@ -57,28 +75,16 @@ export function get_new_volumes(chapterLists: QdChapterSimpleInfo[], volumes: Vo
|
||||
needed.push(ch);
|
||||
} else {
|
||||
chInfo.isSaved = true;
|
||||
chInfo.name = ch.name;
|
||||
}
|
||||
}
|
||||
let changed = false;
|
||||
let merge_mode = SORT_MODE[0];
|
||||
do {
|
||||
const current_len = needed.length;
|
||||
const not_found: QdChapterSimpleInfo[] = [];
|
||||
for (const ch of needed) {
|
||||
if (ch.prev) {
|
||||
const prevVol = volMap.get(ch.prev);
|
||||
if (prevVol) {
|
||||
const chIndex = prevVol.chapters.findIndex(c => c.id === ch.prev);
|
||||
if (chIndex !== -1) {
|
||||
prevVol.chapters.splice(chIndex + 1, 0, {
|
||||
id: ch.id,
|
||||
name: ch.name,
|
||||
isSaved: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ch.next) {
|
||||
if ((merge_mode === MergeMode.NextOnly && ch.next && !ch.prev) || (merge_mode === MergeMode.AllWithNext && ch.prev && ch.next)) {
|
||||
const nextVol = volMap.get(ch.next);
|
||||
if (nextVol) {
|
||||
const chIndex = nextVol.chapters.findIndex(c => c.id === ch.next);
|
||||
@@ -88,6 +94,39 @@ export function get_new_volumes(chapterLists: QdChapterSimpleInfo[], volumes: Vo
|
||||
name: ch.name,
|
||||
isSaved: true,
|
||||
});
|
||||
volMap.set(ch.id, nextVol);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((merge_mode === MergeMode.PreOnly && ch.prev && !ch.next) || (merge_mode === MergeMode.AllWithPrev && ch.prev && ch.next)) {
|
||||
const prevVol = volMap.get(ch.prev);
|
||||
if (prevVol) {
|
||||
const chIndex = prevVol.chapters.findIndex(c => c.id === ch.prev);
|
||||
if (chIndex !== -1) {
|
||||
prevVol.chapters.splice(chIndex + 1, 0, {
|
||||
id: ch.id,
|
||||
name: ch.name,
|
||||
isSaved: true,
|
||||
});
|
||||
volMap.set(ch.id, prevVol);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (merge_mode === MergeMode.All && ch.prev && ch.next) {
|
||||
const prevVol = volMap.get(ch.prev);
|
||||
const nextVol = volMap.get(ch.next);
|
||||
if (prevVol && nextVol && prevVol === nextVol) {
|
||||
const preIndex = prevVol.chapters.findIndex(c => c.id === ch.prev);
|
||||
const nextIndex = prevVol.chapters.findIndex(c => c.id === ch.next);
|
||||
if (preIndex !== -1 && nextIndex !== -1 && preIndex + 1 === nextIndex) {
|
||||
prevVol.chapters.splice(preIndex + 1, 0, {
|
||||
id: ch.id,
|
||||
name: ch.name,
|
||||
isSaved: true,
|
||||
});
|
||||
volMap.set(ch.id, prevVol);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -96,6 +135,13 @@ export function get_new_volumes(chapterLists: QdChapterSimpleInfo[], volumes: Vo
|
||||
}
|
||||
needed = not_found;
|
||||
changed = current_len !== needed.length;
|
||||
if (!changed) {
|
||||
const nextMode = NextMode(merge_mode);
|
||||
if (nextMode) {
|
||||
merge_mode = nextMode;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
} while (changed && needed.length > 0);
|
||||
for (const ch of needed) {
|
||||
volCh.push({
|
||||
@@ -117,6 +163,7 @@ export function get_new_volumes(chapterLists: QdChapterSimpleInfo[], volumes: Vo
|
||||
vol.chapters = vol.chapters.filter(ch => ch.isSaved);
|
||||
vol.chapters.forEach(ch => delete ch.isSaved);
|
||||
}
|
||||
return vols.filter(vol => vol.chapters.length > 0);
|
||||
}
|
||||
} else if (keepMode == ChapterShowMode.UnsavedOnly) {
|
||||
const chIds = new Set(chapterLists.map(ch => ch.id));
|
||||
|
||||
Reference in New Issue
Block a user