Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/app/[[...mdxPath]]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { FC } from 'react';
import { generateStaticParamsFor, importPage } from 'nextra/pages';
import { removeLinks } from 'nextra/remove-links';
import { ArticleNavigation } from '@/components/ArticleNavigation';
import { useMDXComponents as getMDXComponents } from '../../mdx-components';

export const generateStaticParams = generateStaticParamsFor('mdxPath');
Expand All @@ -24,6 +26,9 @@ const Page: FC<PageProps> = async (props) => {
const { default: MDXContent, toc, metadata, sourceCode } = result;
return (
<Wrapper toc={toc} metadata={metadata} sourceCode={sourceCode}>
{(params.mdxPath?.length ?? 0) > 0 && toc.length > 0 && (
<ArticleNavigation headings={toc.map(({ id, depth, value }) => ({ id, depth, title: removeLinks(value) }))} />
)}
<MDXContent {...props} params={params} />
</Wrapper>
);
Expand Down
196 changes: 196 additions & 0 deletions src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -857,3 +857,199 @@ html:has(.hero-brand):not([data-brand-docked='true']) a:has(> .navbar-brand) {
width: 20px;
height: 20px;
}

.article-navigation {
display: none;
}

@media (width < 1280px) {
html:has(.article-navigation),
html:has(.article-navigation):not(:has(*:focus)) {
scroll-padding-top: calc(var(--site-header-height) + 4.5rem);
}

html:has(.article-navigation) .nextra-sidebar {
display: none;
}

article:has(.article-navigation) {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
column-gap: 1rem;
align-content: start;
align-items: center;
}

article:has(.article-navigation) > * {
grid-column: 1 / -1;
min-width: 0;
}

article:has(.article-navigation) > .nextra-breadcrumb {
grid-column: 1;
grid-row: 1;
margin: 0;
}

article:has(.article-navigation) > .nextra-breadcrumb > span:last-child {
overflow: hidden;
text-overflow: ellipsis;
}

article:has(.article-navigation) > .nextra-border:has(> button) {
grid-column: 2;
grid-row: 1;
float: none;
margin: 0;
}

article:has(.article-navigation) > #nextra-skip-nav {
position: absolute;
}

article:has(.article-navigation) > main {
margin-top: 1rem;
}

.article-navigation {
display: block;
position: sticky;
top: calc(var(--site-header-height) + 0.5rem);
z-index: 20;
margin-bottom: 1.5rem;
font-family: var(--font-source-sans), Arial, sans-serif;
}

.article-navigation details {
position: relative;
}

.article-navigation summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-height: 44px;
padding: 0.625rem 1rem;
border: 1px solid #8885;
border-radius: 0.5rem;
background: rgb(var(--nextra-bg));
font-weight: 600;
cursor: pointer;
list-style: none;
}

.article-navigation summary::-webkit-details-marker {
display: none;
}

.article-navigation details[open] summary svg {
transform: rotate(180deg);
}

.article-navigation-panel {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
gap: 1.5rem;
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
right: 0;
max-height: calc(100dvh - var(--site-header-height) - 5.5rem);
overflow-y: auto;
overscroll-behavior: contain;
padding: 1rem;
border: 1px solid #8885;
border-radius: 0.75rem;
background: rgb(var(--nextra-bg));
box-shadow: 0 8px 24px #0003;
}

.article-navigation h2 {
margin: 0 0 0.75rem;
font-size: 1rem;
font-weight: 700;
}

.article-navigation h3 {
margin: 1rem 0 0.375rem;
font-size: 0.8125rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.7;
}

.article-navigation ol,
.article-navigation ul {
margin: 0;
padding: 0;
list-style: none;
}

.article-navigation-panel a {
display: flex;
align-items: center;
min-height: 44px;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
color: inherit;
font-size: 0.9375rem;
line-height: 1.4;
text-decoration: none;
}

.article-navigation-panel a:hover,
.article-navigation-panel a[aria-current] {
background: #8882;
}

.article-navigation-panel a[aria-current] {
color: #1d4ed8;
font-weight: 600;
}

.dark .article-navigation-panel a[aria-current] {
color: #93c5fd;
}

.article-subsection {
padding-left: 0.75rem;
}

.article-guide-navigation {
border-left: 1px solid #8884;
padding-left: 1rem;
}

.article-navigation-mobile-label {
display: none;
}

.article-navigation summary:focus-visible,
.article-navigation a:focus-visible {
outline: 3px solid #438be5;
outline-offset: 3px;
}
}

@media (width < 768px) {
.article-navigation-panel {
grid-template-columns: minmax(0, 1fr);
}

.article-guide-navigation,
.article-navigation-tablet-label {
display: none;
}

.article-navigation-mobile-label {
display: inline;
}
}

@media print {
.article-navigation {
display: none;
}
}
132 changes: 132 additions & 0 deletions src/components/ArticleNavigation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
'use client';

import type { ReactNode } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { guideGroups } from '@/navigation/guides';

type ArticleHeading = Readonly<{
id: string;
depth: number;
title: ReactNode;
}>;

type ArticleNavigationProps = Readonly<{
headings: readonly ArticleHeading[];
}>;

/** Keep article sections within reach when the desktop sidebars do not fit. */
export function ArticleNavigation({ headings }: ArticleNavigationProps) {
const pathname = usePathname();
const detailsRef = useRef<HTMLDetailsElement>(null);
const [activeId, setActiveId] = useState(headings[0]?.id);

useEffect(() => {
let frame = 0;
const update = () => {
frame = 0;
const navigationBottom = detailsRef.current?.getBoundingClientRect().bottom ?? 0;
let current = headings[0]?.id;
for (const heading of headings) {
const element = document.getElementById(heading.id);
if (element && element.getBoundingClientRect().top <= navigationBottom + 24) {
current = heading.id;
}
}
setActiveId(current);
};
const schedule = () => {
if (!frame) {
frame = window.requestAnimationFrame(update);
}
};
schedule();
window.addEventListener('scroll', schedule, { passive: true });
window.addEventListener('resize', schedule);
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener('scroll', schedule);
window.removeEventListener('resize', schedule);
};
}, [headings]);

useEffect(() => {
const details = detailsRef.current;
if (!details) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
details.open = false;
details.querySelector('summary')?.focus();
}
};
const onFocusOut = (event: FocusEvent) => {
if (!(event.relatedTarget instanceof Node) || !details.contains(event.relatedTarget)) {
details.open = false;
}
};
details.addEventListener('keydown', onKeyDown);
details.addEventListener('focusout', onFocusOut);
return () => {
details.removeEventListener('keydown', onKeyDown);
details.removeEventListener('focusout', onFocusOut);
};
}, []);

const close = () => {
if (detailsRef.current) {
detailsRef.current.open = false;
}
};

return (
<div className="article-navigation" data-pagefind-ignore>
<details ref={detailsRef}>
<summary>
<span className="article-navigation-tablet-label">Guides &amp; contents</span>
<span className="article-navigation-mobile-label">In this article</span>
<svg width="16" height="16" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2 4 4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</summary>
<div className="article-navigation-panel">
<nav aria-label="Article sections">
<h2>In this article</h2>
<ol>
{headings.map(({ id, depth, title }) => (
<li key={id} className={depth > 2 ? 'article-subsection' : undefined}>
<a href={`#${id}`} aria-current={activeId === id ? 'location' : undefined} onClick={close}>
{title}
</a>
</li>
))}
</ol>
</nav>
<nav className="article-guide-navigation" aria-label="Other guides">
<h2>Browse guides</h2>
<Link href="/guides/" onClick={close}>All guides →</Link>
{guideGroups.map(group => (
<section key={group.title}>
<h3>{group.title}</h3>
<ul>
{group.guides.map(({ slug, title }) => {
const href = `/guides/${slug}/`;
return (
<li key={slug}>
<Link href={href} aria-current={pathname === href ? 'page' : undefined} onClick={close}>
{title}
</Link>
</li>
);
})}
</ul>
</section>
))}
</nav>
</div>
</details>
</div>
);
}
2 changes: 1 addition & 1 deletion src/content/guides/add-host.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ You'll need a reachable server with SSH enabled, its hostname or IP address, you

After saving, tap the host in the Host List to connect. On your first connection, compare the displayed host-key fingerprint with the fingerprint supplied by your server administrator before accepting it. Then enter your password, or use the SSH key authorized for your account on the server.

If you use a key, import your private key into ConnectBot's key manager and select it for the host. The corresponding public key must already be authorized on the server.
If you use a key, import your private key into ConnectBot's key manager and select it for the host. The corresponding public key must already be authorized on the server. See [SSH key algorithms and biometric protection](/guides/ssh-keys/) for help choosing and protecting a key.

Once connected, try `whoami` to check which account you're using. [Keep special keys visible](/guides/special-keys/) for easier terminal input. If the connection fails, [check these common causes](/bugs/#connection-help).
14 changes: 14 additions & 0 deletions src/content/guides/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ Start with a working connection, then make terminal input comfortable. Existing
- [Set up Mosh for changing networks](./mosh/).
- [Troubleshoot a connection](/bugs/#connection-help).

## SSH keys

### [Which key algorithm should I use, and what does biometric protection do?](./ssh-keys/)

Compare RSA, EC/ECDSA, Ed25519, and DSA. Learn how fingerprint-protected Android Keystore keys differ from passphrases and confirmation prompts.

## Keyboard

### [How do I keep the special keys visible?](./special-keys/)
Expand All @@ -28,6 +34,14 @@ Keep Ctrl, Esc, arrows, and other terminal keys on screen, even when you are not

Save your connection details so your server is ready to open from the Host List.

### [How do local, remote, and dynamic port forwards differ?](./port-forwarding/)

Choose a tunnel type, check OpenSSH or Dropbear permissions, and troubleshoot listening ports, destination addresses, and SOCKS settings.

### [How do I automate my post-login routine?](./post-login-automation/)

Combine text matching, commands, special keys, waits, and port-forward controls into a repeatable startup sequence. Learn how to handle failures and take over manually.

## Appearance

### [How do I change terminal colors and font size?](./terminal-appearance/)
Expand Down
2 changes: 1 addition & 1 deletion src/content/guides/mosh.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,6 @@ Allow inbound UDP traffic for the Mosh ports on the server's firewall and any in

If SSH login works but Mosh stalls, check that `mosh-server` is installed and available on the server's command path, and that UDP traffic can reach it. A network that permits SSH may still block UDP.

Mosh supports interactive terminals; use SSH connections for port forwarding. Mosh can recover while the server process and client session remain alive. For work that should survive closing the app or a server-side session ending, use a terminal multiplexer such as `tmux` on the server.
Mosh supports interactive terminals; use [SSH connections for port forwarding](/guides/port-forwarding/). Mosh can recover while the server process and client session remain alive. For work that should survive closing the app or a server-side session ending, use a terminal multiplexer such as `tmux` on the server.

Read more about roaming and network requirements in the [official Mosh documentation](https://mosh.org/), or see [connection help](/bugs/#connection-help).
Loading
Loading