Unify website and documentation pages with a consistent terminal aesthetic
Extract shared canvas animation module, refactor docs page to a terminal man-page style, and align overall visual language. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 6def8112-39d2-4641-b93b-f39108179f33 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 87e11a5a-0718-4680-a1d4-0d2c471eca80 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/42ae33dd-8759-4196-85a5-434465c72ece/6def8112-39d2-4641-b93b-f39108179f33/Mumw6Ni Replit-Helium-Checkpoint-Created: true
This commit is contained in:
172
docs.html
172
docs.html
@@ -3,101 +3,111 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Documentation - Terminal Portfolio</title>
|
||||
<title>man ssh — keshavanand.net</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<a href="/" class="back-link">$ cd ..</a>
|
||||
<h1>Documentation</h1>
|
||||
<p class="subtitle">The who, what, when, where, why</p>
|
||||
<body class="docs">
|
||||
<canvas id="field" aria-hidden="true"></canvas>
|
||||
|
||||
<main class="page">
|
||||
<a href="/" class="back">$ cd ..</a>
|
||||
|
||||
<header class="head">
|
||||
<h1>man <span class="accent">ssh</span></h1>
|
||||
<p class="sub">portfolio(1)<span class="dot">·</span>keshavanand.net<span class="dot">·</span>v1</p>
|
||||
</header>
|
||||
|
||||
<div class="rule" aria-hidden="true"></div>
|
||||
|
||||
<div class="command-box" id="commandBox">
|
||||
<span class="line"><span class="dollar">$ </span><span class="typed">ssh portfolio@keshavanand.net</span></span>
|
||||
<button class="action-btn" id="copyBtn" aria-label="Copy command">
|
||||
<svg id="copyIcon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path id="copyPath" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="command-reference">
|
||||
<div class="command-box">
|
||||
<code id="sshCommand">ssh portfolio@keshavanand.net</code>
|
||||
<button class="copy-btn" id="copyBtn" aria-label="Copy command">
|
||||
<svg class="copy-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<svg class="check-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<section class="block">
|
||||
<h2><span class="hash">#</span> name</h2>
|
||||
<p>
|
||||
A keystroke-driven portfolio. The command above opens a real SSH session into a restricted, passwordless
|
||||
account on a homeserver and drops you into a custom C++/FTXUI interface. <span class="fg">No web app, no JS, no installer — just the shell you already have.</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="sections">
|
||||
<section class="section">
|
||||
<h2>What is it?</h2>
|
||||
<p>
|
||||
When executed in your terminal, this command renders a fully interactive shell portfolio experience.
|
||||
Much like a digital resume or personal site, it allows you to explore my work and background through
|
||||
a safe, <strong>secure</strong>, and purely text-based interface.
|
||||
<strong>It is completely harmless to your system.</strong>
|
||||
</p>
|
||||
</section>
|
||||
<section class="block">
|
||||
<h2><span class="hash">#</span> synopsis</h2>
|
||||
<pre class="code"><span class="dim">$</span> ssh <span class="accent">portfolio</span>@<span class="accent">keshavanand.net</span></pre>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Command Breakdown</h2>
|
||||
<div class="breakdown-grid">
|
||||
<div class="breakdown-item">
|
||||
<h3>ssh →</h3>
|
||||
<p><strong>Secure Shell:</strong> The standard terminal protocol for logging into and controlling remote computers safely.</p>
|
||||
</div>
|
||||
<div class="breakdown-item">
|
||||
<h3>portfolio →</h3>
|
||||
<p><strong>User:</strong> A passwordless, restricted user on my homeserver meant for public access.</p>
|
||||
</div>
|
||||
<div class="breakdown-item">
|
||||
<h3>keshavanand.net →</h3>
|
||||
<p><strong>Domain:</strong> Points to a web record that directs your request to my server's public IP (gateway).</p>
|
||||
</div>
|
||||
<section class="block">
|
||||
<h2><span class="hash">#</span> arguments</h2>
|
||||
<dl class="defs">
|
||||
<div class="def">
|
||||
<dt><span class="accent">ssh</span></dt>
|
||||
<dd>secure shell — the standard remote-login protocol. ubiquitous on macOS, linux, and modern windows.</dd>
|
||||
</div>
|
||||
</section>
|
||||
<div class="def">
|
||||
<dt><span class="accent">portfolio</span></dt>
|
||||
<dd>a passwordless, restricted user account. the only thing it can do is launch the portfolio binary.</dd>
|
||||
</div>
|
||||
<div class="def">
|
||||
<dt><span class="accent">keshavanand.net</span></dt>
|
||||
<dd>resolves to my homeserver's public IP. the gateway forwards port 22 to the box.</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>How to Run</h2>
|
||||
<ol class="steps">
|
||||
<li>Copy the command above using the clipboard tool.</li>
|
||||
<li>Open your native terminal (Terminal on Mac/Linux, PowerShell on Windows).</li>
|
||||
<li>Paste the command and accept the host identity by typing 'yes' if prompted.</li>
|
||||
<li>Press Enter to launch the interactive environment.</li>
|
||||
<li>Terminate the process at any time by pressing <kbd>Ctrl + C</kbd>.</li>
|
||||
</ol>
|
||||
</section>
|
||||
<section class="block">
|
||||
<h2><span class="hash">#</span> usage</h2>
|
||||
<ol class="steps">
|
||||
<li><span class="step">01</span> copy the command above (click the box, or the icon).</li>
|
||||
<li><span class="step">02</span> open your terminal — Terminal, iTerm, Alacritty, Windows Terminal, anything.</li>
|
||||
<li><span class="step">03</span> paste, hit return. accept the host fingerprint with <kbd>yes</kbd> on first connect.</li>
|
||||
<li><span class="step">04</span> navigate with arrow keys + return. quit any time with <kbd>Ctrl</kbd>+<kbd>C</kbd>.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Code Logic</h2>
|
||||
<p>
|
||||
This command gives authenticated access to the <strong>'portfolio'</strong> user account on my private home server.
|
||||
</p>
|
||||
<p>
|
||||
Instead of a standard bash or zsh shell, the user session triggers a custom-coded <strong>C++ executable</strong>.
|
||||
This binary utilizes <strong>FTXUI</strong>—a sophisticated functional terminal user interface library—to handle
|
||||
real-time rendering and input.
|
||||
</p>
|
||||
</section>
|
||||
<section class="block">
|
||||
<h2><span class="hash">#</span> implementation</h2>
|
||||
<p>
|
||||
The <span class="fg">portfolio</span> user's login shell isn't bash — it's a custom C++ binary that uses
|
||||
<a href="https://github.com/ArthurSonzogni/FTXUI" target="_blank" rel="noopener noreferrer">FTXUI</a>
|
||||
for layout, focus management, and rendering. Input is captured raw, output is repainted on a frame loop.
|
||||
<span class="fg">It is sandboxed, read-only, and harmless to your machine.</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Resources</h2>
|
||||
<div class="resources-grid">
|
||||
<a href="https://git.keshavanand.net/KeshavAnandCode/terminal-portfolio" target="_blank" rel="noopener noreferrer" class="resource-card">
|
||||
<span>View Source Code</span>
|
||||
</a>
|
||||
<section class="block">
|
||||
<h2><span class="hash">#</span> see also</h2>
|
||||
<ul class="links">
|
||||
<li>
|
||||
<a href="https://git.keshavanand.net/KeshavAnandCode/terminal-portfolio" target="_blank" rel="noopener noreferrer">
|
||||
<span class="arrow">→</span> source — git.keshavanand.net
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/webshell">
|
||||
<span class="arrow">→</span> run in browser — /webshell
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<a href="/webshell" class="resource-card">
|
||||
<span>Run Web Shell</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="foot">
|
||||
<span>keshavanand.net</span>
|
||||
<span class="dot">·</span>
|
||||
<span>portfolio(1)</span>
|
||||
<span class="dot">·</span>
|
||||
<span>last updated 2026</span>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<div class="byline">// vibe coded to present human code</div>
|
||||
|
||||
<script type="module" src="/src/docs.ts"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
438
src/docs.css
438
src/docs.css
@@ -1,11 +1,13 @@
|
||||
:root {
|
||||
--base: #000000;
|
||||
--text: #cdd6f4;
|
||||
--subtext0: #a6adc8;
|
||||
--surface0: #1a1a1a;
|
||||
--surface1: #2a2a2a;
|
||||
--green: #a6e3a1;
|
||||
--blue: #89b4fa;
|
||||
--bg: #0b0b0d;
|
||||
--fg: #e6e6e6;
|
||||
--dim: #6a6a72;
|
||||
--dimmer: #4a4a52;
|
||||
--line: #1c1c20;
|
||||
--accent: #a6e3a1;
|
||||
--accent-soft: rgba(166, 227, 161, 0.22);
|
||||
--accent-strong: rgba(166, 227, 161, 0.55);
|
||||
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -14,238 +16,320 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
background-color: var(--base);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
padding: 2rem 1.5rem;
|
||||
html { height: 100%; }
|
||||
|
||||
body.docs {
|
||||
font-family: var(--mono);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
min-height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 650px;
|
||||
#field {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.page {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 1.75rem 6rem;
|
||||
opacity: 0;
|
||||
animation: fade 500ms ease-out 60ms forwards;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
@keyframes fade { to { opacity: 1; } }
|
||||
|
||||
.back-link {
|
||||
color: var(--subtext0);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 2rem;
|
||||
/* back link — same accent treatment as the home dollar */
|
||||
.back {
|
||||
display: inline-block;
|
||||
transition: color 0.2s ease;
|
||||
color: var(--dim);
|
||||
text-decoration: none;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.02em;
|
||||
padding: 0.15rem 0;
|
||||
transition: color 180ms ease, transform 180ms ease;
|
||||
}
|
||||
.back:hover {
|
||||
color: var(--accent);
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--green);
|
||||
.head {
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--text);
|
||||
font-size: 2.5rem;
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
font-weight: 600;
|
||||
font-size: 1.65rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--subtext0);
|
||||
font-size: 1rem;
|
||||
h1 .accent { color: var(--accent); }
|
||||
|
||||
.sub {
|
||||
margin-top: 0.45rem;
|
||||
color: var(--dim);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.command-reference {
|
||||
margin: 2rem 0 3rem 0;
|
||||
.sub .dot {
|
||||
margin: 0 0.55rem;
|
||||
color: var(--dimmer);
|
||||
}
|
||||
|
||||
.rule {
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
margin: 1.6rem 0 1.5rem;
|
||||
}
|
||||
|
||||
/* command box — visually identical to the home version, sans caret/input */
|
||||
.command-box {
|
||||
background: rgba(26, 26, 26, 0.8);
|
||||
border: 1px solid var(--green);
|
||||
padding: 1.25rem;
|
||||
width: 100%;
|
||||
background: rgba(10, 10, 12, 0.55);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
padding: 1.05rem 1.15rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
color: var(--fg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
transition: border-color 0.2s ease;
|
||||
gap: 0;
|
||||
transition: border-color 200ms ease, box-shadow 200ms ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.command-box:hover {
|
||||
border-color: var(--blue);
|
||||
border-color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.command-box code {
|
||||
color: var(--green);
|
||||
font-size: 1.1rem;
|
||||
flex: 1;
|
||||
.command-box.copied {
|
||||
border-color: var(--accent-strong);
|
||||
box-shadow: 0 0 0 1px rgba(166, 227, 161, 0.10), 0 0 24px rgba(166, 227, 161, 0.10);
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--subtext0);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
color: var(--green);
|
||||
background-color: var(--surface0);
|
||||
}
|
||||
|
||||
.copy-btn .check-icon {
|
||||
display: none;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.copy-btn.copied .copy-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.copy-btn.copied .check-icon {
|
||||
.line {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3rem;
|
||||
.dollar {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section {
|
||||
.typed { color: var(--fg); }
|
||||
|
||||
.action-btn {
|
||||
margin-left: 0.6rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--dim);
|
||||
cursor: pointer;
|
||||
padding: 0.2rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
transition: color 180ms ease, transform 180ms ease;
|
||||
}
|
||||
.action-btn:hover { color: var(--fg); }
|
||||
.action-btn svg { width: 16px; height: 16px; }
|
||||
|
||||
/* sections — terminal-style "# heading" then prose */
|
||||
.block {
|
||||
margin-top: 2.6rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: var(--green);
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--fg);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: lowercase;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
h2 .hash {
|
||||
color: var(--accent);
|
||||
margin-right: 0.4rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--subtext0);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
.block p {
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.block p .fg { color: var(--fg); }
|
||||
|
||||
.breakdown-grid {
|
||||
.block p a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed transparent;
|
||||
transition: border-color 180ms ease;
|
||||
}
|
||||
.block p a:hover { border-bottom-color: var(--accent-soft); }
|
||||
|
||||
/* code block — quieter, leaner than the command box */
|
||||
.code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.92rem;
|
||||
background: rgba(10, 10, 12, 0.4);
|
||||
border-left: 1px solid var(--accent-soft);
|
||||
padding: 0.8rem 1rem;
|
||||
color: var(--fg);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.code .dim { color: var(--dim); margin-right: 0.5rem; }
|
||||
.code .accent { color: var(--accent); }
|
||||
|
||||
/* arguments — definition list, mono leader */
|
||||
.defs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.def {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.breakdown-item {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid rgba(166, 227, 161, 0.1);
|
||||
}
|
||||
|
||||
.breakdown-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.breakdown-item h3 {
|
||||
color: var(--blue);
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.breakdown-item p {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
grid-template-columns: minmax(160px, 200px) 1fr;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px dashed var(--line);
|
||||
align-items: baseline;
|
||||
}
|
||||
.def:last-child { border-bottom: none; }
|
||||
.def dt {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.def dd {
|
||||
color: var(--dim);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* steps — numbered, monospace numerals */
|
||||
.steps {
|
||||
list-style: none;
|
||||
counter-reset: step;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.steps li {
|
||||
counter-increment: step;
|
||||
margin-bottom: 1.25rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
color: var(--subtext0);
|
||||
font-size: 0.95rem;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.steps li::before {
|
||||
content: counter(step, decimal-leading-zero);
|
||||
color: var(--green);
|
||||
font-weight: 600;
|
||||
min-width: 2rem;
|
||||
.steps li {
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.steps .step {
|
||||
display: inline-block;
|
||||
color: var(--accent);
|
||||
margin-right: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
kbd {
|
||||
background-color: var(--surface0);
|
||||
border: 1px solid var(--surface1);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
font-size: 0.9em;
|
||||
font-family: inherit;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78em;
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
margin: 0 0.1rem;
|
||||
}
|
||||
|
||||
.resources-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
/* see also — link list */
|
||||
.links {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.resource-card {
|
||||
display: block;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(26, 26, 26, 0.5);
|
||||
border: 1px solid rgba(166, 227, 161, 0.15);
|
||||
.links a {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.55rem;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
margin: 0.5rem 0;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
font-size: 0.88rem;
|
||||
padding: 0.25rem 0;
|
||||
transition: color 180ms ease, transform 180ms ease;
|
||||
}
|
||||
.links a:hover {
|
||||
color: var(--accent);
|
||||
transform: translateX(3px);
|
||||
}
|
||||
.links .arrow { color: var(--accent); }
|
||||
|
||||
/* footer — quiet, like a man-page footer */
|
||||
.foot {
|
||||
margin-top: 4rem;
|
||||
padding-top: 1.2rem;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--dimmer);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.foot .dot {
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.resource-card:hover {
|
||||
border-color: var(--green);
|
||||
background: rgba(26, 26, 26, 0.7);
|
||||
/* whisper-quiet byline, identical to home */
|
||||
.byline {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 1rem;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgba(106, 106, 114, 0.28);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
animation: fadeWhisper 900ms ease-out 1500ms forwards;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-card span {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@keyframes fadeWhisper { to { opacity: 1; } }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.breakdown-grid,
|
||||
.resources-grid {
|
||||
@media (max-width: 640px) {
|
||||
.page { padding: 2rem 1.25rem 5rem; }
|
||||
h1 { font-size: 1.35rem; }
|
||||
.command-box { font-size: 0.92rem; padding: 0.9rem 1rem; }
|
||||
.def {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.byline { font-size: 0.56rem; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page, .byline { animation: none; opacity: 1; }
|
||||
}
|
||||
|
||||
51
src/docs.ts
51
src/docs.ts
@@ -1,16 +1,41 @@
|
||||
import './docs.css';
|
||||
import { initField } from './field';
|
||||
|
||||
const copyBtn = document.getElementById('copyBtn') as HTMLButtonElement;
|
||||
const command = 'ssh portfolio@keshavanand.net';
|
||||
initField('field');
|
||||
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
copyBtn.classList.add('copied');
|
||||
setTimeout(() => copyBtn.classList.remove('copied'), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
const COMMAND = 'ssh portfolio@keshavanand.net';
|
||||
|
||||
const copyBtn = document.getElementById('copyBtn') as HTMLButtonElement | null;
|
||||
const commandBox = document.getElementById('commandBox') as HTMLDivElement | null;
|
||||
const copyPath = document.getElementById('copyPath') as unknown as SVGPathElement | null;
|
||||
|
||||
const COPY_PATH =
|
||||
'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z';
|
||||
const CHECK_PATH = 'M5 13l4 4L19 7';
|
||||
|
||||
let resetTimer: number | undefined;
|
||||
|
||||
async function copy() {
|
||||
if (!commandBox || !copyPath) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(COMMAND);
|
||||
copyPath.setAttribute('d', CHECK_PATH);
|
||||
commandBox.classList.add('copied');
|
||||
window.clearTimeout(resetTimer);
|
||||
resetTimer = window.setTimeout(() => {
|
||||
copyPath.setAttribute('d', COPY_PATH);
|
||||
commandBox.classList.remove('copied');
|
||||
}, 1400);
|
||||
} catch {
|
||||
/* clipboard blocked */
|
||||
}
|
||||
}
|
||||
|
||||
copyBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
copy();
|
||||
});
|
||||
commandBox?.addEventListener('click', (e) => {
|
||||
if ((e.target as HTMLElement).closest('.action-btn')) return;
|
||||
copy();
|
||||
});
|
||||
|
||||
205
src/field.ts
Normal file
205
src/field.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
type Dot = { bx: number; by: number; ox: number; oy: number };
|
||||
type Ripple = { x: number; y: number; born: number };
|
||||
|
||||
const SPACING = 26;
|
||||
const RADIUS = 170;
|
||||
const PUSH = 18;
|
||||
|
||||
const RIPPLE_LIFE = 900;
|
||||
const RIPPLE_MAX = 520;
|
||||
const RIPPLE_BAND = 70;
|
||||
const RIPPLE_PUSH = 36;
|
||||
|
||||
const BASE_RGB = '230, 230, 230';
|
||||
const ACCENT_RGB = '166, 227, 161';
|
||||
const BASE_ALPHA = 0.11;
|
||||
const PEAK_ALPHA = 0.9;
|
||||
|
||||
let canvas: HTMLCanvasElement | null = null;
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
|
||||
let dots: Dot[] = [];
|
||||
const ripples: Ripple[] = [];
|
||||
|
||||
let dpr = 1;
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
|
||||
let targetX = -9999;
|
||||
let targetY = -9999;
|
||||
let curX = -9999;
|
||||
let curY = -9999;
|
||||
let active = false;
|
||||
|
||||
let started = false;
|
||||
|
||||
function build() {
|
||||
if (!canvas || !ctx) return;
|
||||
dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
w = window.innerWidth;
|
||||
h = window.innerHeight;
|
||||
canvas.width = Math.floor(w * dpr);
|
||||
canvas.height = Math.floor(h * dpr);
|
||||
canvas.style.width = w + 'px';
|
||||
canvas.style.height = h + 'px';
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
dots = [];
|
||||
const pad = SPACING;
|
||||
for (let y = -pad; y < h + pad; y += SPACING) {
|
||||
for (let x = -pad; x < w + pad; x += SPACING) {
|
||||
dots.push({ bx: x, by: y, ox: 0, oy: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onMove(x: number, y: number) {
|
||||
targetX = x;
|
||||
targetY = y;
|
||||
active = true;
|
||||
}
|
||||
|
||||
export function spawnRipple(x: number, y: number) {
|
||||
ripples.push({ x, y, born: performance.now() });
|
||||
if (ripples.length > 8) ripples.shift();
|
||||
}
|
||||
|
||||
function frame() {
|
||||
if (!ctx) return;
|
||||
const now = performance.now();
|
||||
|
||||
if (!active) {
|
||||
targetX = -9999;
|
||||
targetY = -9999;
|
||||
}
|
||||
curX += (targetX - curX) * 0.18;
|
||||
curY += (targetY - curY) * 0.18;
|
||||
|
||||
for (let i = ripples.length - 1; i >= 0; i--) {
|
||||
if (now - ripples[i].born > RIPPLE_LIFE) ripples.splice(i, 1);
|
||||
}
|
||||
|
||||
const rippleState = ripples.map((rp) => {
|
||||
const p = (now - rp.born) / RIPPLE_LIFE;
|
||||
return { x: rp.x, y: rp.y, radius: p * RIPPLE_MAX, fade: 1 - p };
|
||||
});
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const r2 = RADIUS * RADIUS;
|
||||
|
||||
for (let i = 0; i < dots.length; i++) {
|
||||
const d = dots[i];
|
||||
|
||||
const dx = d.bx - curX;
|
||||
const dy = d.by - curY;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
|
||||
let tx = 0;
|
||||
let ty = 0;
|
||||
let strength = 0;
|
||||
|
||||
if (d2 < r2) {
|
||||
const dist = Math.sqrt(d2) || 0.0001;
|
||||
const t = 1 - dist / RADIUS;
|
||||
strength = t * t;
|
||||
tx = (dx / dist) * strength * PUSH;
|
||||
ty = (dy / dist) * strength * PUSH;
|
||||
}
|
||||
|
||||
let rippleStrength = 0;
|
||||
for (let k = 0; k < rippleState.length; k++) {
|
||||
const rp = rippleState[k];
|
||||
const rdx = d.bx - rp.x;
|
||||
const rdy = d.by - rp.y;
|
||||
const rdist = Math.sqrt(rdx * rdx + rdy * rdy) || 0.0001;
|
||||
const delta = Math.abs(rdist - rp.radius);
|
||||
if (delta < RIPPLE_BAND) {
|
||||
const ringT = 1 - delta / RIPPLE_BAND;
|
||||
const s = ringT * ringT * rp.fade;
|
||||
tx += (rdx / rdist) * s * RIPPLE_PUSH;
|
||||
ty += (rdy / rdist) * s * RIPPLE_PUSH;
|
||||
if (s > rippleStrength) rippleStrength = s;
|
||||
}
|
||||
}
|
||||
|
||||
d.ox += (tx - d.ox) * 0.22;
|
||||
d.oy += (ty - d.oy) * 0.22;
|
||||
|
||||
const px = d.bx + d.ox;
|
||||
const py = d.by + d.oy;
|
||||
|
||||
const visStrength = Math.min(1, strength + rippleStrength);
|
||||
const alpha = BASE_ALPHA + (PEAK_ALPHA - BASE_ALPHA) * visStrength;
|
||||
const size = 1 + visStrength * 1.6;
|
||||
|
||||
if (visStrength > 0.04) {
|
||||
const blend = visStrength;
|
||||
const r = Math.round(230 * (1 - blend) + 166 * blend);
|
||||
const g = Math.round(230 * (1 - blend) + 227 * blend);
|
||||
const b = Math.round(230 * (1 - blend) + 161 * blend);
|
||||
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
} else {
|
||||
ctx.fillStyle = `rgba(${BASE_RGB}, ${alpha})`;
|
||||
}
|
||||
|
||||
ctx.fillRect(px - size / 2, py - size / 2, size, size);
|
||||
}
|
||||
|
||||
if (active) {
|
||||
const grad = ctx.createRadialGradient(curX, curY, 0, curX, curY, RADIUS);
|
||||
grad.addColorStop(0, `rgba(${ACCENT_RGB}, 0.10)`);
|
||||
grad.addColorStop(1, `rgba(${ACCENT_RGB}, 0)`);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(curX - RADIUS, curY - RADIUS, RADIUS * 2, RADIUS * 2);
|
||||
}
|
||||
|
||||
for (let k = 0; k < rippleState.length; k++) {
|
||||
const rp = rippleState[k];
|
||||
if (rp.radius < 4) continue;
|
||||
ctx.strokeStyle = `rgba(${ACCENT_RGB}, ${0.16 * rp.fade})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(rp.x, rp.y, rp.radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
export function initField(canvasId = 'field') {
|
||||
if (started) return;
|
||||
const el = document.getElementById(canvasId) as HTMLCanvasElement | null;
|
||||
if (!el) return;
|
||||
canvas = el;
|
||||
ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
started = true;
|
||||
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
build();
|
||||
|
||||
window.addEventListener('mousemove', (e) => onMove(e.clientX, e.clientY), { passive: true });
|
||||
window.addEventListener('mouseleave', () => { active = false; });
|
||||
window.addEventListener('click', (e) => spawnRipple(e.clientX, e.clientY));
|
||||
window.addEventListener('touchmove', (e) => {
|
||||
if (e.touches[0]) onMove(e.touches[0].clientX, e.touches[0].clientY);
|
||||
}, { passive: true });
|
||||
window.addEventListener('touchstart', (e) => {
|
||||
if (e.touches[0]) spawnRipple(e.touches[0].clientX, e.touches[0].clientY);
|
||||
}, { passive: true });
|
||||
window.addEventListener('touchend', () => { active = false; });
|
||||
window.addEventListener('resize', build);
|
||||
window.addEventListener('scroll', () => {
|
||||
// keep ripples / dots independent of page scroll — recompute baselines
|
||||
}, { passive: true });
|
||||
|
||||
if (reduceMotion) {
|
||||
if (!ctx) return;
|
||||
ctx.fillStyle = `rgba(${BASE_RGB}, ${BASE_ALPHA})`;
|
||||
for (const d of dots) ctx.fillRect(d.bx, d.by, 1, 1);
|
||||
} else {
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
}
|
||||
186
src/main.ts
186
src/main.ts
@@ -1,4 +1,5 @@
|
||||
import './style.css';
|
||||
import { initField, spawnRipple } from './field';
|
||||
|
||||
const COMMAND = 'ssh portfolio@keshavanand.net';
|
||||
|
||||
@@ -280,189 +281,6 @@ docsLink.addEventListener('click', (e) => {
|
||||
|
||||
/* ---------- cursor-reactive dot field + click ripples ---------- */
|
||||
|
||||
const canvas = document.getElementById('field') as HTMLCanvasElement;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
type Dot = { bx: number; by: number; ox: number; oy: number };
|
||||
|
||||
let dots: Dot[] = [];
|
||||
let dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
|
||||
const SPACING = 26;
|
||||
const RADIUS = 170;
|
||||
const PUSH = 18;
|
||||
|
||||
let targetX = -9999;
|
||||
let targetY = -9999;
|
||||
let curX = -9999;
|
||||
let curY = -9999;
|
||||
let active = false;
|
||||
|
||||
type Ripple = { x: number; y: number; born: number };
|
||||
const ripples: Ripple[] = [];
|
||||
const RIPPLE_LIFE = 900;
|
||||
const RIPPLE_MAX = 520;
|
||||
const RIPPLE_BAND = 70;
|
||||
const RIPPLE_PUSH = 36;
|
||||
|
||||
function build() {
|
||||
dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
w = window.innerWidth;
|
||||
h = window.innerHeight;
|
||||
canvas.width = Math.floor(w * dpr);
|
||||
canvas.height = Math.floor(h * dpr);
|
||||
canvas.style.width = w + 'px';
|
||||
canvas.style.height = h + 'px';
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
dots = [];
|
||||
const pad = SPACING;
|
||||
for (let y = -pad; y < h + pad; y += SPACING) {
|
||||
for (let x = -pad; x < w + pad; x += SPACING) {
|
||||
dots.push({ bx: x, by: y, ox: 0, oy: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onMove(x: number, y: number) {
|
||||
targetX = x;
|
||||
targetY = y;
|
||||
active = true;
|
||||
}
|
||||
|
||||
function spawnRipple(x: number, y: number) {
|
||||
ripples.push({ x, y, born: performance.now() });
|
||||
if (ripples.length > 8) ripples.shift();
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', (e) => onMove(e.clientX, e.clientY), { passive: true });
|
||||
window.addEventListener('mouseleave', () => { active = false; });
|
||||
window.addEventListener('click', (e) => spawnRipple(e.clientX, e.clientY));
|
||||
window.addEventListener('touchmove', (e) => {
|
||||
if (e.touches[0]) onMove(e.touches[0].clientX, e.touches[0].clientY);
|
||||
}, { passive: true });
|
||||
window.addEventListener('touchstart', (e) => {
|
||||
if (e.touches[0]) spawnRipple(e.touches[0].clientX, e.touches[0].clientY);
|
||||
}, { passive: true });
|
||||
window.addEventListener('touchend', () => { active = false; });
|
||||
window.addEventListener('resize', build);
|
||||
|
||||
build();
|
||||
|
||||
const BASE_RGB = '230, 230, 230';
|
||||
const ACCENT_RGB = '166, 227, 161';
|
||||
const BASE_ALPHA = 0.11;
|
||||
const PEAK_ALPHA = 0.9;
|
||||
|
||||
function frame() {
|
||||
const now = performance.now();
|
||||
|
||||
if (!active) {
|
||||
targetX = -9999;
|
||||
targetY = -9999;
|
||||
}
|
||||
curX += (targetX - curX) * 0.18;
|
||||
curY += (targetY - curY) * 0.18;
|
||||
|
||||
for (let i = ripples.length - 1; i >= 0; i--) {
|
||||
if (now - ripples[i].born > RIPPLE_LIFE) ripples.splice(i, 1);
|
||||
}
|
||||
|
||||
const rippleState = ripples.map((rp) => {
|
||||
const p = (now - rp.born) / RIPPLE_LIFE;
|
||||
return { x: rp.x, y: rp.y, radius: p * RIPPLE_MAX, fade: 1 - p };
|
||||
});
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const r2 = RADIUS * RADIUS;
|
||||
|
||||
for (let i = 0; i < dots.length; i++) {
|
||||
const d = dots[i];
|
||||
|
||||
const dx = d.bx - curX;
|
||||
const dy = d.by - curY;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
|
||||
let tx = 0;
|
||||
let ty = 0;
|
||||
let strength = 0;
|
||||
|
||||
if (d2 < r2) {
|
||||
const dist = Math.sqrt(d2) || 0.0001;
|
||||
const t = 1 - dist / RADIUS;
|
||||
strength = t * t;
|
||||
tx = (dx / dist) * strength * PUSH;
|
||||
ty = (dy / dist) * strength * PUSH;
|
||||
}
|
||||
|
||||
let rippleStrength = 0;
|
||||
for (let k = 0; k < rippleState.length; k++) {
|
||||
const rp = rippleState[k];
|
||||
const rdx = d.bx - rp.x;
|
||||
const rdy = d.by - rp.y;
|
||||
const rdist = Math.sqrt(rdx * rdx + rdy * rdy) || 0.0001;
|
||||
const delta = Math.abs(rdist - rp.radius);
|
||||
if (delta < RIPPLE_BAND) {
|
||||
const ringT = 1 - delta / RIPPLE_BAND;
|
||||
const s = ringT * ringT * rp.fade;
|
||||
tx += (rdx / rdist) * s * RIPPLE_PUSH;
|
||||
ty += (rdy / rdist) * s * RIPPLE_PUSH;
|
||||
if (s > rippleStrength) rippleStrength = s;
|
||||
}
|
||||
}
|
||||
|
||||
d.ox += (tx - d.ox) * 0.22;
|
||||
d.oy += (ty - d.oy) * 0.22;
|
||||
|
||||
const px = d.bx + d.ox;
|
||||
const py = d.by + d.oy;
|
||||
|
||||
const visStrength = Math.min(1, strength + rippleStrength);
|
||||
const alpha = BASE_ALPHA + (PEAK_ALPHA - BASE_ALPHA) * visStrength;
|
||||
const size = 1 + visStrength * 1.6;
|
||||
|
||||
if (visStrength > 0.04) {
|
||||
const blend = visStrength;
|
||||
const r = Math.round(230 * (1 - blend) + 166 * blend);
|
||||
const g = Math.round(230 * (1 - blend) + 227 * blend);
|
||||
const b = Math.round(230 * (1 - blend) + 161 * blend);
|
||||
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
} else {
|
||||
ctx.fillStyle = `rgba(${BASE_RGB}, ${alpha})`;
|
||||
}
|
||||
|
||||
ctx.fillRect(px - size / 2, py - size / 2, size, size);
|
||||
}
|
||||
|
||||
if (active) {
|
||||
const grad = ctx.createRadialGradient(curX, curY, 0, curX, curY, RADIUS);
|
||||
grad.addColorStop(0, `rgba(${ACCENT_RGB}, 0.10)`);
|
||||
grad.addColorStop(1, `rgba(${ACCENT_RGB}, 0)`);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(curX - RADIUS, curY - RADIUS, RADIUS * 2, RADIUS * 2);
|
||||
}
|
||||
|
||||
for (let k = 0; k < rippleState.length; k++) {
|
||||
const rp = rippleState[k];
|
||||
if (rp.radius < 4) continue;
|
||||
ctx.strokeStyle = `rgba(${ACCENT_RGB}, ${0.16 * rp.fade})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(rp.x, rp.y, rp.radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
if (!reduceMotion) {
|
||||
requestAnimationFrame(frame);
|
||||
} else {
|
||||
ctx.fillStyle = `rgba(${BASE_RGB}, ${BASE_ALPHA})`;
|
||||
for (const d of dots) ctx.fillRect(d.bx, d.by, 1, 1);
|
||||
}
|
||||
initField('field');
|
||||
|
||||
void caretEl;
|
||||
|
||||
Reference in New Issue
Block a user