feat: redesign homepage from flat card list to book-style pitch page
This commit is contained in:
+260
-14
@@ -2,38 +2,284 @@
|
||||
import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import NavBar from '../components/NavBar.astro';
|
||||
import ArticleCard from '../components/ArticleCard.astro';
|
||||
import { getReadingTime } from '../utils/reading-time';
|
||||
|
||||
const posts = await getCollection('posts');
|
||||
const sortedPosts = posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
||||
const base = import.meta.env.BASE_URL.replace(/\/?$/, '/');
|
||||
|
||||
const rageSeries = [
|
||||
{ title: 'Your Build Cache Is Lying to You', status: 'coming-soon' },
|
||||
{ title: 'Two-Phase Fingerprinting: BuildXL\u2019s Deep Magic', status: 'coming-soon' },
|
||||
{ title: 'Hooking Syscalls Without a Kernel Driver', status: 'coming-soon' },
|
||||
{ title: 'TypeScript 7 Makes the Build Harness More Important', status: 'coming-soon' },
|
||||
{ title: 'node_modules in a Content-Addressed Store', status: 'coming-soon' },
|
||||
{ title: 'What I Learned Maintaining Lage and Why I Rewrote It', status: 'coming-soon' },
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout title='Crash Test Dev'>
|
||||
<NavBar slot="nav" />
|
||||
<section class="post-list" aria-label="All posts">
|
||||
|
||||
<section class="hero" aria-label="Introduction">
|
||||
<h1 class="site-title">Crash Test Dev</h1>
|
||||
<p class="tagline">Engineering judgment, tested against reality.</p>
|
||||
<p class="byline">By Ken Hiatt</p>
|
||||
</section>
|
||||
|
||||
<hr class="divider" />
|
||||
|
||||
<section class="featured" aria-label="Featured excerpt">
|
||||
<p class="featured-label">From the latest</p>
|
||||
<blockquote class="excerpt">
|
||||
AI raises the floor dramatically. It does not flatten the ceiling. The profession that survives is the one hardest to demo without, not just hardest to do without. The professional sees second-order failures coming. The amateur with AI doesn't know there is a second order.
|
||||
</blockquote>
|
||||
<a class="featured-link" href={`${base}posts/ai-commoditizes-features-but-judgment-compounds/`}>
|
||||
Read "AI Commoditizes Features, But Judgment Compounds"
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<hr class="divider" />
|
||||
|
||||
<section class="series" aria-label="Rage series">
|
||||
<h2 class="section-heading">Rage: Rebuilding the Build System</h2>
|
||||
<p class="series-description">A six-part investigation into rage, a Rust monorepo build system that fuses BuildXL's provable correctness with lage's ergonomics. From syscall sandboxing to content-addressed storage.</p>
|
||||
<ol class="series-list">
|
||||
{rageSeries.map((item) => (
|
||||
<li class="series-item">
|
||||
<span class="series-item-title">{item.title}</span>
|
||||
<span class="series-item-status">Coming soon</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<hr class="divider" />
|
||||
|
||||
<section class="all-posts" aria-label="All posts">
|
||||
<h2 class="section-heading">All Posts</h2>
|
||||
{sortedPosts.map((post) => (
|
||||
<ArticleCard
|
||||
title={post.data.title}
|
||||
date={post.data.date}
|
||||
type={post.data.type}
|
||||
summary={post.data.summary}
|
||||
slug={post.id}
|
||||
readingTime={post.body ? getReadingTime(post.body) : undefined}
|
||||
/>
|
||||
<article class="post-entry">
|
||||
<a href={`${base}posts/${post.id}/`} class="post-entry-link">
|
||||
<span class="post-entry-badge">{post.data.type}</span>
|
||||
<span class="post-entry-title">{post.data.title}</span>
|
||||
{post.body && (
|
||||
<span class="post-entry-meta">{getReadingTime(post.body)} min</span>
|
||||
)}
|
||||
</a>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.post-list {
|
||||
/* Layout column */
|
||||
.hero,
|
||||
.featured,
|
||||
.series,
|
||||
.all-posts {
|
||||
max-width: var(--width-prose);
|
||||
margin-top: var(--space-6);
|
||||
margin-bottom: var(--space-12);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* Dividers */
|
||||
.divider {
|
||||
max-width: var(--width-prose);
|
||||
margin: var(--space-6) auto;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.hero {
|
||||
margin-top: var(--space-10);
|
||||
margin-bottom: var(--space-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero .site-title {
|
||||
font-family: var(--font-structure);
|
||||
font-size: var(--type-3xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.hero .tagline {
|
||||
font-family: var(--font-prose);
|
||||
font-size: var(--type-lg);
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-prose);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.hero .byline {
|
||||
font-family: var(--font-structure);
|
||||
font-size: var(--type-sm);
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Featured excerpt */
|
||||
.featured {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.featured-label {
|
||||
font-family: var(--font-structure);
|
||||
font-size: var(--type-xs);
|
||||
font-weight: 600;
|
||||
color: var(--accent-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.excerpt {
|
||||
font-family: var(--font-prose);
|
||||
font-size: var(--type-lg);
|
||||
color: var(--text-primary);
|
||||
line-height: var(--leading-prose);
|
||||
border-left: 3px solid var(--accent-primary);
|
||||
padding-left: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.featured-link {
|
||||
font-family: var(--font-structure);
|
||||
font-size: var(--type-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Series */
|
||||
.series {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
font-family: var(--font-structure);
|
||||
font-size: var(--type-xl);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: var(--leading-snug);
|
||||
margin-bottom: var(--space-1-5);
|
||||
}
|
||||
|
||||
.series-description {
|
||||
font-family: var(--font-prose);
|
||||
font-size: var(--type-base);
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-prose);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.series-list {
|
||||
list-style: none;
|
||||
counter-reset: series;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
gap: var(--space-1-5);
|
||||
}
|
||||
|
||||
.series-item {
|
||||
counter-increment: series;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: var(--space-1) var(--space-1-5);
|
||||
border-radius: var(--rounded-md);
|
||||
background-color: var(--surface-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.series-item::before {
|
||||
content: counter(series) ".";
|
||||
font-family: var(--font-evidence);
|
||||
font-size: var(--type-sm);
|
||||
color: var(--text-tertiary);
|
||||
margin-right: var(--space-1-5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.series-item-title {
|
||||
font-family: var(--font-structure);
|
||||
font-size: var(--type-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.series-item-status {
|
||||
font-family: var(--font-evidence);
|
||||
font-size: var(--type-xs);
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
|
||||
/* All posts */
|
||||
.all-posts {
|
||||
margin-bottom: var(--space-12);
|
||||
}
|
||||
|
||||
.all-posts .section-heading {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.post-entry {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.post-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.post-entry-link {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-1-5);
|
||||
padding: var(--space-1-5) 0;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: color var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
|
||||
.post-entry-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.post-entry-link:hover .post-entry-title {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.post-entry-badge {
|
||||
font-family: var(--font-structure);
|
||||
font-size: var(--type-xs);
|
||||
font-weight: 600;
|
||||
color: var(--accent-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.post-entry-title {
|
||||
font-family: var(--font-structure);
|
||||
font-size: var(--type-base);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
transition: color var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
|
||||
.post-entry-meta {
|
||||
font-family: var(--font-evidence);
|
||||
font-size: var(--type-xs);
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -35,39 +35,45 @@ describe('Clean build output', () => {
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 2. Index page structure
|
||||
// 2. Index page structure (book-style pitch page)
|
||||
// ============================================================
|
||||
describe('Index page', () => {
|
||||
const html = readDist('index.html');
|
||||
|
||||
test('contains two article cards', () => {
|
||||
const cardMatches = html.match(/<article[^>]*class="card"/g);
|
||||
assert.ok(cardMatches, 'No article cards found');
|
||||
assert.equal(cardMatches.length, 2, `Expected 2 article cards, got ${cardMatches.length}`);
|
||||
test('has hero section with site title', () => {
|
||||
assert.ok(html.includes('class="hero"') || html.includes('class=\"hero\"'),
|
||||
'Should have hero section');
|
||||
assert.ok(html.includes('Crash Test Dev'), 'Should have site title');
|
||||
});
|
||||
|
||||
test('card surface uses warm off-white (--surface-card)', () => {
|
||||
assert.ok(html.includes('var(--surface-card)'), 'Card should use --surface-card token');
|
||||
test('has featured excerpt section', () => {
|
||||
assert.ok(html.includes('class="featured"') || html.includes('class=\"featured\"'),
|
||||
'Should have featured section');
|
||||
assert.ok(html.includes('class="excerpt"') || html.includes('class=\"excerpt\"'),
|
||||
'Should have excerpt blockquote');
|
||||
});
|
||||
|
||||
test('card has subtle shadow (--shadow-sm)', () => {
|
||||
assert.ok(html.includes('var(--shadow-sm)'), 'Card should use --shadow-sm token');
|
||||
test('has series section with Rage', () => {
|
||||
assert.ok(html.includes('class="series"') || html.includes('class=\"series\"'),
|
||||
'Should have series section');
|
||||
assert.ok(html.includes('Rage'), 'Should mention Rage series');
|
||||
});
|
||||
|
||||
test('card hover shows deeper shadow (--shadow-md)', () => {
|
||||
assert.ok(html.includes('var(--shadow-md)'), 'Card hover should use --shadow-md token');
|
||||
test('has all-posts section with post entries', () => {
|
||||
assert.ok(html.includes('class="all-posts"') || html.includes('class=\"all-posts\"'),
|
||||
'Should have all-posts section');
|
||||
const entryMatches = html.match(/class="post-entry"/g);
|
||||
assert.ok(entryMatches, 'No post entries found');
|
||||
assert.ok(entryMatches.length >= 2, `Expected at least 2 post entries, got ${entryMatches.length}`);
|
||||
});
|
||||
|
||||
test('card hover shows terracotta title (--accent-primary)', () => {
|
||||
assert.ok(
|
||||
html.includes(':hover .title') || html.includes(':hover .title{'),
|
||||
'Card hover should style the title'
|
||||
);
|
||||
assert.ok(html.includes('var(--accent-primary)'), 'Title hover should use terracotta accent');
|
||||
test('uses terracotta accent (--accent-primary)', () => {
|
||||
// Astro scoped styles compile to external CSS, check source instead
|
||||
const source = readFileSync('src/pages/index.astro', 'utf-8');
|
||||
assert.ok(source.includes('var(--accent-primary)'), 'Should use terracotta accent in source');
|
||||
});
|
||||
|
||||
test('card links have correct URL format with slash separator', () => {
|
||||
// The base is /crashtestdev and posts must be at /crashtestdev/posts/...
|
||||
test('post links have correct URL format with slash separator', () => {
|
||||
const linkRegex = /href="([^"]*posts[^"]*)"/g;
|
||||
let match;
|
||||
let found = false;
|
||||
@@ -83,7 +89,7 @@ describe('Index page', () => {
|
||||
`URL "${url}" should not concatenate base and posts without slash`
|
||||
);
|
||||
}
|
||||
assert.ok(found, 'Should find article card links');
|
||||
assert.ok(found, 'Should find post links');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+87
-69
@@ -30,11 +30,6 @@ describe('Index page', () => {
|
||||
assert.match(content, /import\s+NavBar\s+from\s+['"].*NavBar\.astro['"]/, 'should import NavBar');
|
||||
});
|
||||
|
||||
it('should import ArticleCard', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /import\s+ArticleCard\s+from\s+['"].*ArticleCard\.astro['"]/, 'should import ArticleCard');
|
||||
});
|
||||
|
||||
it('should import getReadingTime', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /import\s+.*getReadingTime.*from\s+['"].*reading-time['"]/, 'should import getReadingTime');
|
||||
@@ -46,15 +41,89 @@ describe('Index page', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /getCollection\s*\(\s*['"]posts['"]\s*\)/, 'should call getCollection("posts")');
|
||||
});
|
||||
});
|
||||
|
||||
it('should sort posts descending by date', () => {
|
||||
describe('Hero section', () => {
|
||||
it('should have a hero section', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /sort\s*\(/, 'should call sort');
|
||||
assert.match(content, /b\.data\.date\.getTime\(\)\s*-\s*a\.data\.date\.getTime\(\)/, 'should sort descending by date');
|
||||
assert.match(content, /class="hero"/, 'should have a section with class hero');
|
||||
});
|
||||
|
||||
it('should have the site title Crash Test Dev as a heading', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /<h1[^>]*>Crash Test Dev<\/h1>/, 'should have h1 with site title');
|
||||
});
|
||||
|
||||
it('should have a tagline in prose font', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /class="tagline"/, 'should have a tagline element');
|
||||
});
|
||||
|
||||
it('should have an author byline', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /class="byline"/, 'should have a byline element');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Template', () => {
|
||||
describe('Featured excerpt section', () => {
|
||||
it('should have a featured excerpt section', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /class="featured"/, 'should have a section with class featured');
|
||||
});
|
||||
|
||||
it('should contain an excerpt blockquote', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /<blockquote[^>]*class="excerpt"/, 'should have a blockquote with class excerpt');
|
||||
});
|
||||
|
||||
it('should have a link to the full post', () => {
|
||||
const content = readPage();
|
||||
// The featured section should contain a link to read the full post
|
||||
assert.match(content, /class="featured-link"/, 'should have a featured-link to the full post');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Series section', () => {
|
||||
it('should have a series section', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /class="series"/, 'should have a section with class series');
|
||||
});
|
||||
|
||||
it('should mention the Rage series', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /Rage/, 'should mention Rage in the series section');
|
||||
});
|
||||
|
||||
it('should list planned posts', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /class="series-item"/, 'should have series items');
|
||||
});
|
||||
|
||||
it('should indicate coming soon items', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /coming soon/i, 'should indicate coming soon posts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('All posts section', () => {
|
||||
it('should have a posts listing section below the pitch', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /class="all-posts"/, 'should have a section with class all-posts');
|
||||
});
|
||||
|
||||
it('should map posts to compact entries', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /sortedPosts\.map/, 'should map sortedPosts');
|
||||
});
|
||||
|
||||
it('should show post titles as links', () => {
|
||||
const content = readPage();
|
||||
// Each post entry should link to its page
|
||||
assert.match(content, /posts\/\$\{post\.id\}/, 'should link to post pages');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Layout', () => {
|
||||
it('should use BaseLayout with title Crash Test Dev', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /<BaseLayout\s+title\s*=\s*['"]Crash Test Dev['"]/, 'should have BaseLayout with title');
|
||||
@@ -64,79 +133,28 @@ describe('Index page', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /<NavBar\s+slot\s*=\s*["']nav["']/, 'should have NavBar with slot="nav"');
|
||||
});
|
||||
|
||||
it('should have a section with class post-list and aria-label', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /<section\s+class\s*=\s*["']post-list["']/, 'should have section.post-list');
|
||||
assert.match(content, /aria-label\s*=\s*["']All posts["']/, 'should have aria-label="All posts"');
|
||||
});
|
||||
|
||||
it('should map sortedPosts to ArticleCard components', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /sortedPosts\.map/, 'should map sortedPosts');
|
||||
assert.match(content, /<ArticleCard/, 'should render ArticleCard');
|
||||
});
|
||||
|
||||
it('should pass title from post.data.title', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /title\s*=\s*\{post\.data\.title\}/, 'should pass title');
|
||||
});
|
||||
|
||||
it('should pass date from post.data.date', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /date\s*=\s*\{post\.data\.date\}/, 'should pass date');
|
||||
});
|
||||
|
||||
it('should pass type from post.data.type', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /type\s*=\s*\{post\.data\.type\}/, 'should pass type');
|
||||
});
|
||||
|
||||
it('should pass summary from post.data.summary', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /summary\s*=\s*\{post\.data\.summary\}/, 'should pass summary');
|
||||
});
|
||||
|
||||
it('should pass slug as post.id', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /slug\s*=\s*\{post\.id\}/, 'should pass slug as post.id');
|
||||
});
|
||||
|
||||
it('should compute and pass readingTime from post.body', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /getReadingTime\s*\(\s*post\.body/, 'should call getReadingTime(post.body...)');
|
||||
assert.match(content, /readingTime\s*=\s*\{/, 'should pass readingTime prop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Style', () => {
|
||||
it('should have .post-list with prose max-width', () => {
|
||||
it('should use --width-prose for content width', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /\.post-list\s*\{/, 'should have .post-list selector');
|
||||
assert.match(content, /max-width\s*:\s*var\(--width-prose\)/, 'should have prose max-width');
|
||||
assert.match(content, /max-width\s*:\s*var\(--width-prose\)/, 'should constrain content to prose width');
|
||||
});
|
||||
|
||||
it('should have space-6 top and space-12 bottom margins', () => {
|
||||
it('should use --font-structure for hero heading', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /margin-top\s*:\s*var\(--space-6\)/, 'should have space-6 top margin');
|
||||
assert.match(content, /margin-bottom\s*:\s*var\(--space-12\)/, 'should have space-12 bottom margin');
|
||||
assert.match(content, /\.hero[\s\S]*?font-family\s*:\s*var\(--font-structure\)/, 'should use structure font in hero');
|
||||
});
|
||||
|
||||
it('should have auto left and right margins', () => {
|
||||
it('should use --font-prose for excerpt', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /margin-left\s*:\s*auto/, 'should have auto left margin');
|
||||
assert.match(content, /margin-right\s*:\s*auto/, 'should have auto right margin');
|
||||
assert.match(content, /\.excerpt[\s\S]*?font-family\s*:\s*var\(--font-prose\)/, 'should use prose font for excerpt');
|
||||
});
|
||||
|
||||
it('should have flex column display', () => {
|
||||
it('should not have the old .post-list as the main layout', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /display\s*:\s*flex/, 'should have flex display');
|
||||
assert.match(content, /flex-direction\s*:\s*column/, 'should have column direction');
|
||||
});
|
||||
|
||||
it('should have space-2 gap', () => {
|
||||
const content = readPage();
|
||||
assert.match(content, /gap\s*:\s*var\(--space-2\)/, 'should have space-2 gap');
|
||||
// The old flat list structure should be gone
|
||||
assert.doesNotMatch(content, /<ArticleCard/, 'should not use ArticleCard component');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user