Files
SnapGo/promo/landing/src/ShowcaseVisual.tsx
T

436 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, type PointerEvent } from "react";
type ShowcaseVisualProps = {
activeIndex: number;
};
type CaptureTool = "rect" | "arrow" | "pen" | "mosaic";
type OcrLanguage = "zh" | "en" | "auto";
type UploadDestination = {
id: "obs" | "ssh" | "ftp";
label: string;
detail: string;
};
const destinations: UploadDestination[] = [
{ id: "obs", label: "OBS", detail: "Bucket" },
{ id: "ssh", label: "SSH", detail: "Server" },
{ id: "ftp", label: "FTP", detail: "Remote" },
];
const uploadRoutes: Record<UploadDestination["id"], string> = {
obs: "M 50 30 C 59 30 61 17 71 17",
ssh: "M 50 30 C 58 30 63 30 71 30",
ftp: "M 50 30 C 59 30 61 43 71 43",
};
const ocrModes: Array<{
id: OcrLanguage;
label: string;
name: string;
lines: string[];
characters: number;
confidence: string;
}> = [
{
id: "zh",
label: "中",
name: "中文",
lines: ["让每一次截图直接进入下一步。"],
characters: 15,
confidence: "99.5%",
},
{
id: "en",
label: "EN",
name: "English",
lines: ["Capture, understand, share."],
characters: 27,
confidence: "99.1%",
},
{
id: "auto",
label: "AUTO",
name: "中英混排",
lines: ["Capture, understand, share.", "让每一次截图直接进入下一步。"],
characters: 42,
confidence: "99.2%",
},
];
const aiModes = [
{
id: "vision",
label: "识图",
result: "检测到系统权限弹窗,截图权限尚未开启。",
score: "96%",
},
{
id: "summary",
label: "总结",
result: "当前页面包含 3 个设置项,建议先开启屏幕录制权限。",
score: "93%",
},
{
id: "question",
label: "问答",
result: "前往系统设置中的隐私与安全性即可完成授权。",
score: "98%",
},
];
const captureTools: Array<{ id: CaptureTool; label: string }> = [
{ id: "rect", label: "矩形" },
{ id: "arrow", label: "箭头" },
{ id: "pen", label: "画笔" },
{ id: "mosaic", label: "马赛克" },
];
function stopSceneDrag(event: PointerEvent<HTMLElement>) {
event.stopPropagation();
}
function UploadScene() {
const [selected, setSelected] = useState<UploadDestination>(destinations[0]);
const routePath = uploadRoutes[selected.id];
return (
<div className={`visual-scene upload-scene target-${selected.id}`}>
<div className="upload-file-card">
<div className="upload-file-preview" aria-hidden="true">
<div className="screenshot-window-bar">
<span><i /><i /><i /></span>
<b className="screenshot-address-bar" />
</div>
<div className="screenshot-window-body">
<div className="screenshot-sidebar"><i /><i /><i /><i /></div>
<div className="screenshot-main">
<span className="screenshot-heading" />
<span className="screenshot-row row-long" />
<span className="screenshot-row row-medium" />
<div className="screenshot-card-grid">
<i /><i /><i />
</div>
<div className="screenshot-copy-lines">
<i /><i /><i />
</div>
</div>
</div>
<div className="upload-capture-frame">
<i className="upload-capture-corner corner-nw" />
<i className="upload-capture-corner corner-ne" />
<i className="upload-capture-corner corner-sw" />
<i className="upload-capture-corner corner-se" />
</div>
</div>
<div className="upload-file-copy">
<span>SCREENSHOT-1428.PNG</span>
</div>
</div>
<svg
className="upload-route"
viewBox="0 0 100 60"
preserveAspectRatio="none"
aria-hidden="true"
>
<path
key={`route-${selected.id}`}
className="upload-route-line"
d={routePath}
vectorEffect="non-scaling-stroke"
/>
<path
key={`shimmer-${selected.id}`}
className="upload-route-shimmer"
d={routePath}
pathLength="100"
strokeDasharray="16 84"
vectorEffect="non-scaling-stroke"
/>
</svg>
<span key={`packet-${selected.id}`} className={`upload-packet packet-${selected.id}`} aria-hidden="true">
<svg viewBox="0 0 20 20">
<path d="M 5 10 H 14 M 10.5 6.5 L 14 10 L 10.5 13.5" />
</svg>
</span>
<div className="upload-destinations" role="group" aria-label="上传目标">
{destinations.map((destination) => (
<button
key={destination.id}
type="button"
className={destination.id === selected.id ? "is-selected" : ""}
aria-pressed={destination.id === selected.id}
aria-label={`选择 ${destination.label} 上传目标`}
onPointerDown={stopSceneDrag}
onClick={() => setSelected(destination)}
>
<span className="destination-status" />
<strong>{destination.label}</strong>
<small>{destination.detail}</small>
</button>
))}
</div>
<div className="upload-result scene-float-delayed" aria-live="polite">
<span className="result-check" aria-hidden="true"></span>
<div>
<small>LINK COPIED</small>
<strong>cdn.snapgo.dev/{selected.id}/...</strong>
</div>
</div>
</div>
);
}
function IdentificationScene() {
const [mode, setMode] = useState(aiModes[0]);
return (
<div className="visual-scene identification-scene">
<div className="ai-source-card">
<div className="mini-window-bar" aria-hidden="true">
<span />
<span />
<span />
</div>
<div className="ai-source-content">
<span className="source-sidebar" />
<div className="source-settings">
<i />
<i />
<i />
</div>
<div className="source-dialog">
<span className="dialog-icon">!</span>
<div>
<strong>Permission required</strong>
<small>Screen Recording</small>
</div>
</div>
</div>
<span className="ai-scan-line" aria-hidden="true" />
</div>
<div className="ai-core" aria-hidden="true">
<span>AI</span>
<i className="ai-orbit orbit-one" />
<i className="ai-orbit orbit-two" />
</div>
<div className="ai-result-card scene-float-soft" aria-live="polite">
<div className="ai-result-heading">
<span className="ai-spark" aria-hidden="true"></span>
<strong>SnapGo Vision</strong>
<i />
</div>
<p>{mode.result}</p>
<div className="confidence-row">
<span><i /></span>
<small>{mode.score} confidence</small>
</div>
</div>
<div className="ai-mode-switch" role="group" aria-label="AI 处理模式">
{aiModes.map((item) => (
<button
key={item.id}
type="button"
className={item.id === mode.id ? "is-selected" : ""}
aria-pressed={item.id === mode.id}
onPointerDown={stopSceneDrag}
onClick={() => setMode(item)}
>
{item.label}
</button>
))}
</div>
</div>
);
}
function OcrScene() {
const [copied, setCopied] = useState(false);
const [language, setLanguage] = useState<OcrLanguage>("auto");
const activeMode = ocrModes.find((mode) => mode.id === language) ?? ocrModes[2];
function selectLanguage(mode: OcrLanguage) {
setLanguage(mode);
setCopied(false);
}
return (
<div className="visual-scene ocr-scene">
<div className="ocr-document">
<div className="document-header">
<span />
<div><i /><i /><i /></div>
</div>
<div className="document-copy">
<strong>Release notes</strong>
<span className="text-line line-long" />
<span className="text-line line-medium" />
<span className="text-line line-highlight"><i>Capture, understand, share.</i></span>
<span className="text-line line-long" />
<span className="text-line line-short" />
<span className="text-line line-highlight secondary"><i></i></span>
</div>
<span className="ocr-scan-bar" aria-hidden="true" />
</div>
<div className="ocr-selection-card scene-float-delayed">
<div className="selection-heading">
<span>OCR</span>
<small>{activeMode.name}</small>
</div>
<p>
{activeMode.lines.map((line) => <span key={line}>{line}</span>)}
</p>
<div className="selection-meta">
<span>{activeMode.characters} characters</span>
<strong>{activeMode.confidence}</strong>
</div>
<button
type="button"
className={copied ? "is-copied" : ""}
aria-pressed={copied}
onPointerDown={stopSceneDrag}
onClick={() => setCopied((current) => !current)}
>
<span aria-hidden="true">{copied ? "✓" : "⌘C"}</span>
{copied ? "已复制" : "复制文本"}
</button>
</div>
<div className="ocr-language-pills" role="group" aria-label="OCR 识别语言">
{ocrModes.map((mode) => (
<button
key={mode.id}
type="button"
className={mode.id === language ? "is-selected" : ""}
aria-pressed={mode.id === language}
onPointerDown={stopSceneDrag}
onClick={() => selectLanguage(mode.id)}
>
{mode.label}
</button>
))}
</div>
</div>
);
}
function ToolGlyph({ tool }: { tool: CaptureTool }) {
if (tool === "rect") {
return <svg viewBox="0 0 20 20" aria-hidden="true"><rect x="4" y="4" width="12" height="12" rx="2" /></svg>;
}
if (tool === "arrow") {
return <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 15 15 4M9 4h6v6" /></svg>;
}
if (tool === "pen") {
return <svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 15 2.2-4.7L13.8 3.7l2.5 2.5-6.6 6.6L5 15Z" /><path d="m11.8 5.7 2.5 2.5" /></svg>;
}
return (
<svg viewBox="0 0 20 20" aria-hidden="true">
<rect x="3" y="3" width="5" height="5" />
<rect x="12" y="3" width="5" height="5" />
<rect x="3" y="12" width="5" height="5" />
<rect x="12" y="12" width="5" height="5" />
</svg>
);
}
function AnnotationPreview({ tool }: { tool: CaptureTool }) {
if (tool === "arrow") {
return (
<svg className="annotation-arrow" viewBox="0 0 180 110" aria-hidden="true">
<path d="M28 84C66 78 91 56 139 31" />
<path d="m121 27 20 3-8 18" />
</svg>
);
}
if (tool === "pen") {
return (
<svg className="annotation-pen" viewBox="0 0 180 110" aria-hidden="true">
<path d="M24 76c20-42 36 25 57-6s33-33 48-4c9 18 21 13 29-7" />
</svg>
);
}
if (tool === "mosaic") {
return <div className="annotation-mosaic" aria-hidden="true">{Array.from({ length: 20 }, (_, index) => <span key={index} />)}</div>;
}
return <div className="annotation-rect" aria-hidden="true"><span /><span /><span /><span /></div>;
}
function CaptureScene() {
const [tool, setTool] = useState<CaptureTool>("rect");
return (
<div className="visual-scene capture-scene">
<div className="capture-canvas">
<div className="canvas-app-bar">
<span />
<strong>Project overview</strong>
<i />
</div>
<div className="canvas-layout" aria-hidden="true">
<span className="canvas-sidebar" />
<div className="canvas-content">
<i className="canvas-heading" />
<i className="canvas-line" />
<i className="canvas-line short" />
<div className="canvas-chart"><span /><span /><span /><span /><span /></div>
</div>
</div>
<div className="capture-selection">
<span className="selection-size">1280 × 720</span>
<i className="selection-handle handle-nw" />
<i className="selection-handle handle-ne" />
<i className="selection-handle handle-sw" />
<i className="selection-handle handle-se" />
<AnnotationPreview tool={tool} />
</div>
</div>
<div className="capture-toolbar" role="toolbar" aria-label="截图标注工具">
{captureTools.map((item) => (
<button
key={item.id}
type="button"
className={item.id === tool ? "is-selected" : ""}
aria-label={item.label}
aria-pressed={item.id === tool}
title={item.label}
onPointerDown={stopSceneDrag}
onClick={() => setTool(item.id)}
>
<ToolGlyph tool={item.id} />
</button>
))}
<span className="toolbar-divider" />
<span className="toolbar-color" aria-hidden="true" />
</div>
</div>
);
}
const scenes = [UploadScene, IdentificationScene, OcrScene, CaptureScene];
export default function ShowcaseVisual({ activeIndex }: ShowcaseVisualProps) {
const Scene = scenes[activeIndex] ?? UploadScene;
return (
<div className="scene-parallax" data-testid="showcase-visual">
<Scene />
</div>
);
}