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
2 changes: 2 additions & 0 deletions src/clients/ClientsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { PlanDialog } from "./PlanDialog";
import { RestoreAllDialog } from "./RestoreAllDialog";
import { MirroredDialog, RestartWslDialog } from "./WslDialogs";
import { hostOf, isLoopback, manualStatusOf, statusOf, type ClientState, type Status, type WslPlace } from "./status";
import { ClientsPageHints } from "@/guide/PageHints";

/** `env`:在哪个 WSL 发行版里(发行版的名字);这台电脑上的不带 */
type DialogState =
Expand Down Expand Up @@ -330,6 +331,7 @@ export default function ClientsPage({
}
/>

<ClientsPageHints upstreams={providers?.length} className="mb-3" />
{remote && data && (
<Banner layout="inline" tone="info" icon={<IconRemote />} className="mb-3">
{rt.clientsNote(remote.name, hostOf(data.gateway_base))}
Expand Down
39 changes: 39 additions & 0 deletions src/guide/FirstRequestHint.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useText } from "@/i18n";
import { useNav } from "@/nav";
import { Button } from "@/ui/button";
import { guideText } from "./guide.i18n";
import { Hint } from "./Hint";
import { useSetupSeen } from "./hints";
import { useFirstRequest } from "./useFirstRequest";

/**
* 「开始使用」走完的那一刻:第一条请求到了,指给人去看它。
*
* **只对看过「开始使用」的人说**(见 `hints.ts` 的 `setupSeen`)。按钮打开的是最新
* 那一条 —— 说完这句之前又来了几条也无妨,要看的就是一条真实的请求长什么样。
*/
export function FirstRequestHint({ when, className }: { when: boolean; className?: string }) {
const t = useText(guideText);
const nav = useNav();
const seen = useSetupSeen();
const { latestId } = useFirstRequest();
return (
<Hint
id="first-request"
when={when && seen && latestId !== null}
title={t.firstRequestTitle}
className={className}
action={
<Button
size="sm"
variant="outline"
onClick={() => latestId !== null && nav.open("requests", { request: latestId })}
>
{t.viewRequest}
</Button>
}
>
{t.firstRequestBody}
</Hint>
);
}
56 changes: 56 additions & 0 deletions src/guide/Hint.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { ReactNode } from "react";
import { LightbulbIcon } from "lucide-react";
import { useText } from "@/i18n";
import { Banner } from "@/ui/banner";
import { Button } from "@/ui/button";
import { guideText } from "./guide.i18n";
import { useHint, type HintId } from "./hints";

/**
* 一条引导提示:页内的一块(`Banner` 的 inline 摆法),灯泡图标,右边是下一步的按钮
* 和「不再显示」。
*
* **灯泡只用在这里**:一个图标一种意思,看到它就知道这是一句提示、不是故障。
* 「不再显示」写成字,不用 ×(× 只管关闭)。条件不再成立时它自己收起,点过「不再显示」
* 就不再出现(见 `hints.ts`)。
*/
export function Hint({
id,
when,
title,
children,
action,
className,
}: {
id: HintId;
/** 此刻该不该说:数据没到时传 false */
when: boolean;
title: ReactNode;
children?: ReactNode;
/** 下一步的那个按钮(`size="sm" variant="outline"`) */
action?: ReactNode;
className?: string;
}) {
const t = useText(guideText);
const { show, dismiss } = useHint(id, when);
return (
<Banner
show={show}
layout="inline"
tone="info"
icon={<LightbulbIcon />}
title={title}
className={className}
actions={
<>
{action}
<Button size="sm" variant="ghost" className="text-muted-foreground" onClick={dismiss}>
{t.hide}
</Button>
</>
}
>
{children}
</Banner>
);
}
106 changes: 106 additions & 0 deletions src/guide/PageHints.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { useClients } from "@/clients/data";
import { useText } from "@/i18n";
import { useNav } from "@/nav";
import { Button } from "@/ui/button";
import { guideText } from "./guide.i18n";
import { Hint } from "./Hint";
import { useFirstRequest } from "./useFirstRequest";

/**
* 各页的下一步提示。**条件都是此刻的事实**,事实一变(接进来了、请求到了)就自己收起;
* 每一条点过「不再显示」就不再出现。放在各页的 PageHeader 下面、内容上面。
*
* 「接进来了」的口径和概览的「开始使用」一致:接管的、按说明手动配了密钥的都算。
*/
function useConnected() {
const clients = useClients();
const d = clients.data;
if (d === undefined) return { known: false, adopted: [] as { name: string }[], any: false };
const adopted = d.clients.filter((c) => c.adopted_at_ms != null);
return { known: true, adopted, any: adopted.length > 0 || d.manual.some((m) => m.key != null) };
}

/** 上游页:有了上游,还没有客户端接进来,也还没有请求 */
export function NextClientsHint({ upstreams, className }: { upstreams: number; className?: string }) {
const t = useText(guideText);
const nav = useNav();
const connected = useConnected();
const { used } = useFirstRequest();
return (
<Hint
id="next-clients"
when={upstreams > 0 && connected.known && !connected.any && used === false}
title={t.nextClientsTitle}
className={className}
action={
<Button size="sm" variant="outline" onClick={() => nav.open("clients")}>
{t.goClients}
</Button>
}
>
{t.nextClientsBody}
</Hint>
);
}

/**
* 客户端页的两条,一次只说一条:还没有上游时先说上游;有了上游、接进来了、还没有
* 请求时,说去客户端里发一条消息。
*/
export function ClientsPageHints({ upstreams, className }: { upstreams: number | undefined; className?: string }) {
const t = useText(guideText);
const nav = useNav();
const connected = useConnected();
const { used } = useFirstRequest();
const names = connected.adopted.map((c) => c.name).join(t.listSep);
return (
<>
<Hint
id="next-upstream"
when={upstreams === 0}
title={t.nextUpstreamTitle}
className={className}
action={
<Button size="sm" variant="outline" onClick={() => nav.open("upstreams", { create: "upstream" })}>
{t.newUpstream}
</Button>
}
>
{t.nextUpstreamBody}
</Hint>
<Hint
id="next-request"
when={upstreams !== undefined && upstreams > 0 && connected.adopted.length > 0 && used === false}
title={t.nextRequestTitle}
className={className}
action={
<Button size="sm" variant="outline" onClick={() => nav.open("requests")}>
{t.goTraffic}
</Button>
}
>
{t.nextRequestBody(names)}
</Hint>
</>
);
}

/** 流量页:表里有请求了,说一句点开能看到什么 */
export function OpenRowHint({ when, className }: { when: boolean; className?: string }) {
const t = useText(guideText);
return (
<Hint id="traffic-open-row" when={when} title={t.openRowTitle} className={className}>
{t.openRowBody}
</Hint>
);
}

/** 安全页:有防护停在「观察」。`count` 是此刻停在那儿的项数 */
export function ObserveHint({ count, className }: { count: number; className?: string }) {
const t = useText(guideText);
return (
<Hint id="security-observe" when={count > 0} title={t.observeTitle(count)} className={className}>
{t.observeBody}
</Hint>
);
}
148 changes: 148 additions & 0 deletions src/guide/SetupGuide.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { useEffect, type ReactNode } from "react";
import { CheckIcon } from "lucide-react";
import { useClients } from "@/clients/data";
import { useText } from "@/i18n";
import { cn } from "@/lib/utils";
import { useNav } from "@/nav";
import { Button } from "@/ui/button";
import { ClientLogo } from "@/ui/logos";
import { StatusLabel } from "@/ui/status-dot";
import { guideText } from "./guide.i18n";
import { markSetupSeen } from "./hints";

type StepState = "done" | "current" | "later";

/**
* 概览上的「开始使用」:从装好到第一条请求经过网关的三步。
*
* 只在「从没用过」时出现(和原来的空状态同一个条件),第一条真正的请求一到就整块
* 让位给用量数字。**每一步的勾都按此刻的事实打**:上游有几个、哪些客户端接进来了,
* 不靠「点过那个按钮」—— 在别处加的上游、在客户端页接的客户端同样算数。
*
* 当前那一步(第一个没做完的)给按钮,后面的灰着不给:一次只指一个方向。第三步
* 没有按钮可按,它等的是客户端里的一条消息,给一个在等的状态。
*/
export function SetupGuide({ upstreams, probes }: { upstreams: number; probes: number }) {
const t = useText(guideText);
const nav = useNav();
const clients = useClients();

useEffect(() => markSetupSeen(), []);

const list = clients.data?.clients ?? [];
const adopted = list.filter((c) => c.adopted_at_ms != null);
const found = list.filter((c) => c.installed && c.adopted_at_ms == null && !c.managed);
const manual = (clients.data?.manual ?? []).filter((m) => m.key != null);
const names = (xs: { name: string }[]) => xs.map((x) => x.name).join(t.listSep);

const hasUpstream = upstreams > 0;
// 手动配的(Cursor 之类)也算接进来了;客户端的探测被网关答过,说明它也连上了
const hasClient = adopted.length > 0 || manual.length > 0 || probes > 0;
const done = [hasUpstream, hasClient, false];
const current = done.indexOf(false);
const state = (i: number): StepState => (done[i] ? "done" : i === current ? "current" : "later");

return (
<section aria-labelledby="setup-title" className="rounded-lg border border-border">
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
<h2 id="setup-title" className="tw-head">
{t.setupTitle}
</h2>
<span className="tw-label tw-num text-muted-foreground">
{t.setupProgress(done.filter(Boolean).length, done.length)}
</span>
</div>
<ol>
<Step
n={1}
state={state(0)}
title={t.stepUpstream}
detail={hasUpstream ? t.stepUpstreamDone(upstreams) : t.stepUpstreamTodo}
action={
<Button size="sm" onClick={() => nav.open("upstreams", { create: "upstream" })}>
{t.newUpstream}
</Button>
}
/>
<Step
n={2}
state={clients.data === undefined && !hasClient ? "later" : state(1)}
title={t.stepClient}
detail={
adopted.length > 0 ? (
t.stepClientDone(names(adopted))
) : manual.length > 0 ? (
t.stepClientManual
) : found.length > 0 ? (
<span className="inline-flex flex-wrap items-center gap-x-1.5">
<span className="inline-flex items-center gap-1" aria-hidden>
{found.slice(0, 4).map((c) => (
<ClientLogo key={c.id} id={c.id} name={c.name} />
))}
</span>
{t.stepClientFound(names(found))}
</span>
) : clients.data !== undefined ? (
t.stepClientNone
) : null
}
action={
<Button size="sm" onClick={() => nav.open("clients")}>
{t.goClients}
</Button>
}
/>
<Step
n={3}
state={state(2)}
title={t.stepRequest}
detail={
<>
{t.stepRequestTodo}
{probes > 0 && <span className="block">{t.probes(probes)}</span>}
</>
}
action={<StatusLabel tone="pending">{t.waiting}</StatusLabel>}
/>
</ol>
</section>
);
}

function Step({
n,
state,
title,
detail,
action,
}: {
n: number;
state: StepState;
title: string;
detail: ReactNode;
/** 只在当前那一步出现 */
action: ReactNode;
}) {
return (
<li
aria-current={state === "current" ? "step" : undefined}
className="flex items-start gap-3 border-b border-border px-4 py-3 last:border-b-0"
>
<span
className={cn(
"mt-px flex size-5 shrink-0 items-center justify-center rounded-full border tw-label tw-num",
state === "done" && "border-success/30 bg-success/10 text-success [&_svg]:size-3",
state === "current" && "border-foreground/60 text-foreground",
state === "later" && "border-border text-muted-foreground",
)}
>
{state === "done" ? <CheckIcon aria-hidden /> : n}
</span>
<div className="min-w-0 flex-1">
<p className={cn("tw-head", state === "later" && "text-muted-foreground")}>{title}</p>
{detail && <div className="mt-0.5 tw-body text-muted-foreground">{detail}</div>}
</div>
{state === "current" && <div className="-my-0.5 flex shrink-0 items-center">{action}</div>}
</li>
);
}
Loading
Loading