mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
finalize initial starters
This commit is contained in:
@@ -6,7 +6,7 @@ import { typewriter, wait } from "cli/utils/cli";
|
|||||||
import { exec, getVersion } from "cli/utils/sys";
|
import { exec, getVersion } from "cli/utils/sys";
|
||||||
import { Option } from "commander";
|
import { Option } from "commander";
|
||||||
import color from "picocolors";
|
import color from "picocolors";
|
||||||
import { updateBkndPackages } from "./npm";
|
import { overridePackageJson, updateBkndPackages } from "./npm";
|
||||||
import { type Template, templates } from "./templates";
|
import { type Template, templates } from "./templates";
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
@@ -36,16 +36,21 @@ export const create: CliCommand = (program) => {
|
|||||||
.action(action);
|
.action(action);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function errorOutro() {
|
||||||
|
$p.outro(color.red("Failed to create project."));
|
||||||
|
console.log(
|
||||||
|
color.yellow("Sorry that this happened. If you think this is a bug, please report it at: ") +
|
||||||
|
color.cyan("https://github.com/bknd-io/bknd/issues")
|
||||||
|
);
|
||||||
|
console.log("");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
async function action(options: { template?: string; dir?: string; integration?: string }) {
|
async function action(options: { template?: string; dir?: string; integration?: string }) {
|
||||||
const _config = {
|
console.log("");
|
||||||
defaultDir: process.env.LOCAL ? "./.template" : "./",
|
|
||||||
speed: {
|
|
||||||
typewriter: process.env.LOCAL ? 0 : 20,
|
|
||||||
wait: process.env.LOCAL ? 0 : 250
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const downloadOpts = {
|
const downloadOpts = {
|
||||||
dir: options.dir || _config.defaultDir,
|
dir: options.dir || "./",
|
||||||
clean: false
|
clean: false
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,12 +61,8 @@ async function action(options: { template?: string; dir?: string; integration?:
|
|||||||
|
|
||||||
await $p.stream.message(
|
await $p.stream.message(
|
||||||
(async function* () {
|
(async function* () {
|
||||||
yield* typewriter(
|
yield* typewriter("Thanks for choosing to create a new project with bknd!", color.dim);
|
||||||
"Thanks for choosing to create a new project with bknd!",
|
await wait();
|
||||||
_config.speed.typewriter,
|
|
||||||
color.dim
|
|
||||||
);
|
|
||||||
await wait(_config.speed.wait);
|
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -90,6 +91,12 @@ async function action(options: { template?: string; dir?: string; integration?:
|
|||||||
downloadOpts.clean = clean;
|
downloadOpts.clean = clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let name = downloadOpts.dir.includes("/")
|
||||||
|
? downloadOpts.dir.split("/").pop()
|
||||||
|
: downloadOpts.dir.replace(/[./]/g, "");
|
||||||
|
|
||||||
|
if (!name || name.length === 0) name = "bknd";
|
||||||
|
|
||||||
let template: Template | undefined;
|
let template: Template | undefined;
|
||||||
if (options.template) {
|
if (options.template) {
|
||||||
template = templates.find((t) => t.key === options.template) as Template;
|
template = templates.find((t) => t.key === options.template) as Template;
|
||||||
@@ -102,14 +109,10 @@ async function action(options: { template?: string; dir?: string; integration?:
|
|||||||
if (!integration) {
|
if (!integration) {
|
||||||
await $p.stream.info(
|
await $p.stream.info(
|
||||||
(async function* () {
|
(async function* () {
|
||||||
yield* typewriter("Ready? ", _config.speed.typewriter * 1.5, color.bold);
|
yield* typewriter("Ready? ", color.bold, 1.5);
|
||||||
await wait(500);
|
await wait(2);
|
||||||
yield* typewriter(
|
yield* typewriter("Let's find the perfect template for you.", color.dim);
|
||||||
"Let's find the perfect template for you.",
|
await wait(2);
|
||||||
_config.speed.typewriter,
|
|
||||||
color.dim
|
|
||||||
);
|
|
||||||
await wait(500);
|
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -169,12 +172,13 @@ async function action(options: { template?: string; dir?: string; integration?:
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = { template, dir: downloadOpts.dir };
|
const ctx = { template, dir: downloadOpts.dir, name };
|
||||||
|
|
||||||
{
|
{
|
||||||
// @todo: only while in PR
|
const ref = process.env.BKND_CLI_CREATE_REF ?? `v${version}`;
|
||||||
const ref = "feat/cli-starters";
|
if (process.env.BKND_CLI_CREATE_REF) {
|
||||||
//const ref = `v${version}`;
|
$p.log.warn(color.dim("[DEV] Using local ref: ") + color.yellow(ref));
|
||||||
|
}
|
||||||
|
|
||||||
const prefix =
|
const prefix =
|
||||||
template.ref === true
|
template.ref === true
|
||||||
@@ -187,11 +191,21 @@ async function action(options: { template?: string; dir?: string; integration?:
|
|||||||
//console.log("url", url);
|
//console.log("url", url);
|
||||||
const s = $p.spinner();
|
const s = $p.spinner();
|
||||||
s.start("Downloading template...");
|
s.start("Downloading template...");
|
||||||
const result = await downloadTemplate(url, {
|
try {
|
||||||
dir: ctx.dir,
|
await downloadTemplate(url, {
|
||||||
force: downloadOpts.clean ? "clean" : true
|
dir: ctx.dir,
|
||||||
});
|
force: downloadOpts.clean ? "clean" : true
|
||||||
//console.log("result", result);
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
s.stop("Failed to download template: " + color.red(e.message), 1);
|
||||||
|
} else {
|
||||||
|
console.error(e);
|
||||||
|
s.stop("Failed to download template. Check logs above.", 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
errorOutro();
|
||||||
|
}
|
||||||
|
|
||||||
s.stop("Template downloaded.");
|
s.stop("Template downloaded.");
|
||||||
await updateBkndPackages(ctx.dir);
|
await updateBkndPackages(ctx.dir);
|
||||||
@@ -201,6 +215,16 @@ async function action(options: { template?: string; dir?: string; integration?:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// update package name
|
||||||
|
await overridePackageJson(
|
||||||
|
(pkg) => ({
|
||||||
|
...pkg,
|
||||||
|
name: ctx.name
|
||||||
|
}),
|
||||||
|
{ dir: ctx.dir }
|
||||||
|
);
|
||||||
|
$p.log.success(`Updated package name to ${color.cyan(ctx.name)}`);
|
||||||
|
|
||||||
{
|
{
|
||||||
const install = await $p.confirm({
|
const install = await $p.confirm({
|
||||||
message: "Install dependencies?"
|
message: "Install dependencies?"
|
||||||
@@ -209,9 +233,23 @@ async function action(options: { template?: string; dir?: string; integration?:
|
|||||||
if ($p.isCancel(install)) {
|
if ($p.isCancel(install)) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} else if (install) {
|
} else if (install) {
|
||||||
|
const install_cmd = template.scripts?.install || "npm install";
|
||||||
|
|
||||||
const s = $p.spinner();
|
const s = $p.spinner();
|
||||||
s.start("Installing dependencies...");
|
s.start("Installing dependencies...");
|
||||||
exec(`cd ${ctx.dir} && npm install`, { silent: true });
|
try {
|
||||||
|
exec(`cd ${ctx.dir} && ${install_cmd}`, { silent: true });
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
s.stop("Failed to install: " + color.red(e.message), 1);
|
||||||
|
} else {
|
||||||
|
console.error(e);
|
||||||
|
s.stop("Failed to install. Check logs above.", 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
errorOutro();
|
||||||
|
}
|
||||||
|
|
||||||
s.stop("Dependencies installed.");
|
s.stop("Dependencies installed.");
|
||||||
|
|
||||||
if (template!.postinstall) {
|
if (template!.postinstall) {
|
||||||
@@ -220,12 +258,12 @@ async function action(options: { template?: string; dir?: string; integration?:
|
|||||||
} else {
|
} else {
|
||||||
await $p.stream.warn(
|
await $p.stream.warn(
|
||||||
(async function* () {
|
(async function* () {
|
||||||
yield* typewriter("Remember to run ", _config.speed.typewriter, color.dim);
|
yield* typewriter(
|
||||||
await wait(_config.speed.typewriter);
|
color.dim("Remember to run ") +
|
||||||
yield* typewriter("npm install", _config.speed.typewriter, color.cyan);
|
color.cyan("npm install") +
|
||||||
await wait(_config.speed.typewriter);
|
color.dim(" after setup")
|
||||||
yield* typewriter(" after setup", _config.speed.typewriter, color.dim);
|
);
|
||||||
await wait(_config.speed.wait / 2);
|
await wait();
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -237,17 +275,16 @@ async function action(options: { template?: string; dir?: string; integration?:
|
|||||||
|
|
||||||
await $p.stream.success(
|
await $p.stream.success(
|
||||||
(async function* () {
|
(async function* () {
|
||||||
yield* typewriter("That's it! ", _config.speed.typewriter);
|
yield* typewriter("That's it! ");
|
||||||
await wait(_config.speed.wait / 2);
|
await wait(0.5);
|
||||||
yield "🎉";
|
yield "🎉";
|
||||||
await wait(_config.speed.wait);
|
await wait();
|
||||||
yield "\n\n";
|
yield "\n\n";
|
||||||
yield* typewriter(
|
yield* typewriter(
|
||||||
`Enter your project's directory using ${color.cyan("cd " + ctx.dir)}
|
`Enter your project's directory using ${color.cyan("cd " + ctx.dir)}
|
||||||
If you need help, check ${color.cyan("https://docs.bknd.io")} or join our Discord!`,
|
If you need help, check ${color.cyan("https://docs.bknd.io")} or join our Discord!`
|
||||||
_config.speed.typewriter
|
|
||||||
);
|
);
|
||||||
await wait(_config.speed.wait * 2);
|
await wait(2);
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { readFile, writeFile } from "node:fs/promises";
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { getVersion as sysGetVersion } from "cli/utils/sys";
|
||||||
|
|
||||||
export type TPackageJson = Partial<{
|
export type TPackageJson = Partial<{
|
||||||
name: string;
|
name: string;
|
||||||
@@ -67,7 +68,7 @@ export async function replacePackageJsonVersions(
|
|||||||
|
|
||||||
export async function updateBkndPackages(dir?: string, map?: Record<string, string>) {
|
export async function updateBkndPackages(dir?: string, map?: Record<string, string>) {
|
||||||
const versions = {
|
const versions = {
|
||||||
bknd: "^" + (await getVersion("bknd")),
|
bknd: "^" + (await sysGetVersion()),
|
||||||
...(map ?? {})
|
...(map ?? {})
|
||||||
};
|
};
|
||||||
await replacePackageJsonVersions(
|
await replacePackageJsonVersions(
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { uuid } from "core/utils";
|
|||||||
import color from "picocolors";
|
import color from "picocolors";
|
||||||
import type { Template, TemplateSetupCtx } from ".";
|
import type { Template, TemplateSetupCtx } from ".";
|
||||||
|
|
||||||
|
const WRANGLER_FILE = "wrangler.json";
|
||||||
|
|
||||||
export const cloudflare = {
|
export const cloudflare = {
|
||||||
key: "cloudflare",
|
key: "cloudflare",
|
||||||
title: "Cloudflare Basic",
|
title: "Cloudflare Basic",
|
||||||
@@ -12,11 +14,12 @@ export const cloudflare = {
|
|||||||
path: "gh:bknd-io/bknd/examples/cloudflare-worker",
|
path: "gh:bknd-io/bknd/examples/cloudflare-worker",
|
||||||
ref: true,
|
ref: true,
|
||||||
setup: async (ctx) => {
|
setup: async (ctx) => {
|
||||||
// overwrite assets directory
|
// overwrite assets directory & name
|
||||||
await overrideJson(
|
await overrideJson(
|
||||||
"wrangler.json",
|
WRANGLER_FILE,
|
||||||
(json) => ({
|
(json) => ({
|
||||||
...json,
|
...json,
|
||||||
|
name,
|
||||||
assets: {
|
assets: {
|
||||||
directory: "node_modules/bknd/dist/static"
|
directory: "node_modules/bknd/dist/static"
|
||||||
}
|
}
|
||||||
@@ -70,7 +73,7 @@ async function createD1(ctx: TemplateSetupCtx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await overrideJson(
|
await overrideJson(
|
||||||
"wrangler.json",
|
WRANGLER_FILE,
|
||||||
(json) => ({
|
(json) => ({
|
||||||
...json,
|
...json,
|
||||||
d1_databases: [
|
d1_databases: [
|
||||||
@@ -90,7 +93,7 @@ async function createD1(ctx: TemplateSetupCtx) {
|
|||||||
|
|
||||||
async function createLibsql(ctx: TemplateSetupCtx) {
|
async function createLibsql(ctx: TemplateSetupCtx) {
|
||||||
await overrideJson(
|
await overrideJson(
|
||||||
"wrangler.json",
|
WRANGLER_FILE,
|
||||||
(json) => ({
|
(json) => ({
|
||||||
...json,
|
...json,
|
||||||
vars: {
|
vars: {
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { cloudflare } from "./cloudflare";
|
|||||||
export type TemplateSetupCtx = {
|
export type TemplateSetupCtx = {
|
||||||
template: Template;
|
template: Template;
|
||||||
dir: string;
|
dir: string;
|
||||||
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Integration = "node" | "bun" | "cloudflare" | "nextjs" | "remix" | "astro" | "custom";
|
export type Integration = "node" | "bun" | "cloudflare" | "nextjs" | "remix" | "astro" | "custom";
|
||||||
|
|
||||||
|
type TemplateScripts = "install" | "dev" | "build" | "start";
|
||||||
export type Template = {
|
export type Template = {
|
||||||
/**
|
/**
|
||||||
* unique key for the template
|
* unique key for the template
|
||||||
@@ -23,12 +25,13 @@ export type Template = {
|
|||||||
* adds a ref "#{ref}" to the path. If "true", adds the current version of bknd
|
* adds a ref "#{ref}" to the path. If "true", adds the current version of bknd
|
||||||
*/
|
*/
|
||||||
ref?: true | string;
|
ref?: true | string;
|
||||||
|
scripts?: Partial<Record<TemplateScripts, string>>;
|
||||||
preinstall?: (ctx: TemplateSetupCtx) => Promise<void>;
|
preinstall?: (ctx: TemplateSetupCtx) => Promise<void>;
|
||||||
postinstall?: (ctx: TemplateSetupCtx) => Promise<void>;
|
postinstall?: (ctx: TemplateSetupCtx) => Promise<void>;
|
||||||
setup?: (ctx: TemplateSetupCtx) => Promise<void>;
|
setup?: (ctx: TemplateSetupCtx) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const templates = [
|
export const templates: Template[] = [
|
||||||
{
|
{
|
||||||
key: "node",
|
key: "node",
|
||||||
title: "Node.js Basic",
|
title: "Node.js Basic",
|
||||||
@@ -45,5 +48,33 @@ export const templates = [
|
|||||||
path: "gh:bknd-io/bknd/examples/bun",
|
path: "gh:bknd-io/bknd/examples/bun",
|
||||||
ref: true
|
ref: true
|
||||||
},
|
},
|
||||||
cloudflare
|
cloudflare,
|
||||||
] as const satisfies Template[];
|
{
|
||||||
|
key: "remix",
|
||||||
|
title: "Remix Basic",
|
||||||
|
integration: "remix",
|
||||||
|
description: "A basic bknd Remix starter",
|
||||||
|
path: "gh:bknd-io/bknd/examples/remix",
|
||||||
|
ref: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// @todo: add `concurrently`?
|
||||||
|
key: "nextjs",
|
||||||
|
title: "Next.js Basic",
|
||||||
|
integration: "nextjs",
|
||||||
|
description: "A basic bknd Next.js starter",
|
||||||
|
path: "gh:bknd-io/bknd/examples/nextjs",
|
||||||
|
scripts: {
|
||||||
|
install: "npm install --force"
|
||||||
|
},
|
||||||
|
ref: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "astro",
|
||||||
|
title: "Astro Basic",
|
||||||
|
integration: "astro",
|
||||||
|
description: "A basic bknd Astro starter",
|
||||||
|
path: "gh:bknd-io/bknd/examples/astro",
|
||||||
|
ref: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
|
import color from "picocolors";
|
||||||
import * as commands from "./commands";
|
import * as commands from "./commands";
|
||||||
import { getVersion } from "./utils/sys";
|
import { getVersion } from "./utils/sys";
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
export async function main() {
|
export async function main() {
|
||||||
|
const version = await getVersion();
|
||||||
program
|
program
|
||||||
.name("bknd")
|
.name("bknd")
|
||||||
.description("bknd cli")
|
.description(color.yellowBright("⚡") + " bknd cli " + color.bold(color.cyan(`v${version}`)))
|
||||||
.version(await getVersion());
|
.version(version);
|
||||||
|
|
||||||
// register commands
|
// register commands
|
||||||
for (const command of Object.values(commands)) {
|
for (const command of Object.values(commands)) {
|
||||||
|
|||||||
@@ -1,14 +1,56 @@
|
|||||||
export async function wait(ms: number) {
|
const _SPEEDUP = process.env.LOCAL;
|
||||||
|
|
||||||
|
const DEFAULT_WAIT = _SPEEDUP ? 0 : 250;
|
||||||
|
export async function wait(factor: number = 1, strict?: boolean) {
|
||||||
|
const ms = strict === true ? factor : DEFAULT_WAIT * factor;
|
||||||
return new Promise((r) => setTimeout(r, ms));
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// from https://github.com/chalk/ansi-regex/blob/main/index.js
|
||||||
|
export default function ansiRegex({ onlyFirst = false } = {}) {
|
||||||
|
// Valid string terminator sequences are BEL, ESC\, and 0x9c
|
||||||
|
const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)";
|
||||||
|
const pattern = [
|
||||||
|
`[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?${ST})`,
|
||||||
|
"(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))"
|
||||||
|
].join("|");
|
||||||
|
|
||||||
|
return new RegExp(pattern, onlyFirst ? undefined : "g");
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_WAIT_WRITER = _SPEEDUP ? 0 : 20;
|
||||||
export async function* typewriter(
|
export async function* typewriter(
|
||||||
text: string,
|
text: string,
|
||||||
delay: number,
|
transform?: (char: string) => string,
|
||||||
transform?: (char: string) => string
|
_delay?: number
|
||||||
) {
|
) {
|
||||||
for (const char of text) {
|
const delay = DEFAULT_WAIT_WRITER * (_delay ?? 1);
|
||||||
yield transform ? transform(char) : char;
|
const regex = ansiRegex();
|
||||||
await wait(delay);
|
const parts: string[] = [];
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
// Extract ANSI escape sequences as standalone units
|
||||||
|
// biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
if (lastIndex < match.index) {
|
||||||
|
parts.push(...text.slice(lastIndex, match.index).split(""));
|
||||||
|
}
|
||||||
|
parts.push(match[0]); // Add the ANSI escape sequence as a full chunk
|
||||||
|
lastIndex = regex.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any remaining characters after the last ANSI sequence
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push(...text.slice(lastIndex).split(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield characters or ANSI sequences in order
|
||||||
|
for (const chunk of parts) {
|
||||||
|
yield transform ? transform(chunk) : chunk;
|
||||||
|
// Delay only for normal characters, not ANSI codes
|
||||||
|
if (!regex.test(chunk)) {
|
||||||
|
await wait(delay, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ export function getRelativeDistPath() {
|
|||||||
return path.relative(process.cwd(), getDistPath());
|
return path.relative(process.cwd(), getDistPath());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getVersion() {
|
export async function getVersion(_path: string = "") {
|
||||||
try {
|
try {
|
||||||
const resolved = path.resolve(getRootPath(), "package.json");
|
const resolved = path.resolve(getRootPath(), path.join(_path, "package.json"));
|
||||||
const pkg = await readFile(resolved, "utf-8");
|
const pkg = await readFile(resolved, "utf-8");
|
||||||
if (pkg) {
|
if (pkg) {
|
||||||
return JSON.parse(pkg).version ?? "preview";
|
return JSON.parse(pkg).version ?? "preview";
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"isbot": "^5.1.18",
|
"isbot": "^5.1.18",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"remix-utils": "^8.0.0"
|
"remix-utils": "^7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@remix-run/dev": "^2.15.2",
|
"@remix-run/dev": "^2.15.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user