mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
387 lines
15 KiB
TypeScript
387 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import type { CSSProperties, PointerEvent } from "react";
|
|
import { useRef } from "react";
|
|
import { motion, useReducedMotion } from "motion/react";
|
|
import { BrandMark } from "@/components/brand";
|
|
import { Icon } from "@/components/icons";
|
|
|
|
type StageStyle = CSSProperties & {
|
|
"--pointer-x": string;
|
|
"--pointer-y": string;
|
|
};
|
|
|
|
const cycle = 10.5;
|
|
const rest = 1.2;
|
|
|
|
export function HeroIllustration() {
|
|
const stageRef = useRef<HTMLDivElement>(null);
|
|
const reduceMotion = useReducedMotion();
|
|
|
|
const updateParallax = (event: PointerEvent<HTMLDivElement>) => {
|
|
const stage = stageRef.current;
|
|
if (!stage || reduceMotion) return;
|
|
const bounds = stage.getBoundingClientRect();
|
|
const x = ((event.clientX - bounds.left) / bounds.width - 0.5) * 2;
|
|
const y = ((event.clientY - bounds.top) / bounds.height - 0.5) * 2;
|
|
stage.style.setProperty("--pointer-x", x.toFixed(3));
|
|
stage.style.setProperty("--pointer-y", y.toFixed(3));
|
|
};
|
|
|
|
const resetParallax = () => {
|
|
stageRef.current?.style.setProperty("--pointer-x", "0");
|
|
stageRef.current?.style.setProperty("--pointer-y", "0");
|
|
};
|
|
|
|
const repeating = { duration: cycle, repeat: Infinity, repeatDelay: rest };
|
|
|
|
return (
|
|
<figure className="hero-visual">
|
|
<div
|
|
ref={stageRef}
|
|
className="hero-stage"
|
|
style={{ "--pointer-x": "0", "--pointer-y": "0" } as StageStyle}
|
|
onPointerMove={updateParallax}
|
|
onPointerLeave={resetParallax}
|
|
aria-hidden="true"
|
|
>
|
|
<div className="hero-stage-grid" />
|
|
<div className="hero-stage-glow" />
|
|
<span className="stage-coordinate stage-coordinate-top">NODE / SEND · 01</span>
|
|
<span className="stage-coordinate stage-coordinate-side">SECURE CHANNEL</span>
|
|
|
|
<div className="desktop-product-shell">
|
|
<div className="desktop-product-window">
|
|
<div className="desktop-window-bar">
|
|
<span className="window-controls"><i /><i /><i /></span>
|
|
<span className="window-title">VniDrop · Send</span>
|
|
<span className="window-security"><Icon name="lock" /> Encrypted</span>
|
|
</div>
|
|
|
|
<div className="desktop-app-layout">
|
|
<aside className="desktop-sidebar">
|
|
<BrandMark className="product-brand-mark" />
|
|
<span className="desktop-sidebar-item is-active"><Icon name="file" /></span>
|
|
<span className="desktop-sidebar-item"><Icon name="devices" /></span>
|
|
<span className="desktop-sidebar-item"><Icon name="shield" /></span>
|
|
<span className="desktop-sidebar-spacer" />
|
|
<span className="desktop-profile">AH</span>
|
|
</aside>
|
|
|
|
<div className="desktop-transfer-view">
|
|
<div className="desktop-view-heading">
|
|
<div>
|
|
<small>OUTGOING TRANSFER</small>
|
|
<strong>Summer archive</strong>
|
|
</div>
|
|
<span className="sharing-pill"><i /> Sharing</span>
|
|
</div>
|
|
|
|
<div className="file-collection-card">
|
|
<div className="file-collage" aria-hidden="true">
|
|
<span /><span /><span /><span />
|
|
</div>
|
|
<div className="file-collection-copy">
|
|
<strong>summer-photos</strong>
|
|
<span>42 files · 486 MB</span>
|
|
<small><Icon name="folder" /> Structure preserved</small>
|
|
</div>
|
|
<span className="collection-menu">•••</span>
|
|
</div>
|
|
|
|
<div className="receiver-section-heading">
|
|
<span>Receivers</span>
|
|
<small>1 connected</small>
|
|
</div>
|
|
|
|
<div className="desktop-receiver-row">
|
|
<span className="receiver-avatar">M</span>
|
|
<span className="receiver-identity">
|
|
<strong>Maya's iPhone</strong>
|
|
<small>iOS · nearby device</small>
|
|
</span>
|
|
<span className="receiver-state-stack">
|
|
<motion.span
|
|
className="receiver-row-state is-waiting"
|
|
animate={
|
|
reduceMotion
|
|
? { opacity: 0 }
|
|
: { opacity: [1, 1, 0, 0] }
|
|
}
|
|
transition={{ ...repeating, times: [0, 0.4, 0.48, 1] }}
|
|
>
|
|
Approval required
|
|
</motion.span>
|
|
<motion.span
|
|
className="receiver-row-state is-sending"
|
|
animate={
|
|
reduceMotion
|
|
? { opacity: 0 }
|
|
: { opacity: [0, 0, 1, 1, 0, 0] }
|
|
}
|
|
transition={{ ...repeating, times: [0, 0.43, 0.5, 0.71, 0.78, 1] }}
|
|
>
|
|
Sending securely
|
|
</motion.span>
|
|
<motion.span
|
|
className="receiver-row-state is-complete"
|
|
animate={
|
|
reduceMotion
|
|
? { opacity: 1 }
|
|
: { opacity: [0, 0, 1, 1] }
|
|
}
|
|
transition={{ ...repeating, times: [0, 0.72, 0.79, 1] }}
|
|
>
|
|
<Icon name="verified" /> Verified
|
|
</motion.span>
|
|
</span>
|
|
<div className="receiver-progress-track">
|
|
<motion.span
|
|
animate={
|
|
reduceMotion
|
|
? { scaleX: 1 }
|
|
: { scaleX: [0, 0, 0.04, 0.72, 1, 1] }
|
|
}
|
|
transition={{ ...repeating, times: [0, 0.46, 0.51, 0.67, 0.76, 1] }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="desktop-transfer-foot">
|
|
<span><Icon name="shield" /> Approval by default</span>
|
|
<span><Icon name="lock" /> End-to-end encrypted</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<motion.div
|
|
className="approval-knock-card"
|
|
animate={
|
|
reduceMotion
|
|
? { opacity: 0, y: 8, scale: 0.98 }
|
|
: {
|
|
opacity: [0, 0, 1, 1, 0, 0],
|
|
y: [12, 12, 0, 0, -5, -5],
|
|
scale: [0.97, 0.97, 1, 1, 0.985, 0.985],
|
|
}
|
|
}
|
|
transition={{ ...repeating, times: [0, 0.24, 0.3, 0.42, 0.49, 1], ease: "easeInOut" }}
|
|
>
|
|
<div className="approval-knock-top">
|
|
<span className="receiver-avatar">M</span>
|
|
<span>
|
|
<small>NEW RECEIVER</small>
|
|
<strong>Maya's iPhone</strong>
|
|
</span>
|
|
<span className="approval-lock"><Icon name="lock" /></span>
|
|
</div>
|
|
<p>Wants to receive <strong>summer-photos</strong></p>
|
|
<div className="approval-knock-actions">
|
|
<span>Refuse</span>
|
|
<motion.span
|
|
className="approval-accept"
|
|
animate={reduceMotion ? undefined : { scale: [1, 1, 1.055, 1, 1] }}
|
|
transition={{ ...repeating, times: [0, 0.38, 0.42, 0.46, 1] }}
|
|
>
|
|
<Icon name="check" /> Approve
|
|
</motion.span>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
|
|
<svg className="hero-conduit" viewBox="0 0 760 620" fill="none">
|
|
<defs>
|
|
<linearGradient id="conduit-gradient" x1="410" y1="340" x2="650" y2="390" gradientUnits="userSpaceOnUse">
|
|
<stop stopColor="#C084FC" />
|
|
<stop offset="1" stopColor="#7C2AEF" />
|
|
</linearGradient>
|
|
<filter id="conduit-blur" x="-30%" y="-80%" width="160%" height="260%">
|
|
<feGaussianBlur stdDeviation="8" />
|
|
</filter>
|
|
</defs>
|
|
<path
|
|
d="M414 342C480 344 508 392 571 390C606 389 624 373 646 351"
|
|
stroke="#D9D0DE"
|
|
strokeWidth="14"
|
|
strokeLinecap="round"
|
|
/>
|
|
<motion.path
|
|
d="M414 342C480 344 508 392 571 390C606 389 624 373 646 351"
|
|
stroke="#A855F7"
|
|
strokeOpacity="0.24"
|
|
strokeWidth="28"
|
|
strokeLinecap="round"
|
|
filter="url(#conduit-blur)"
|
|
animate={
|
|
reduceMotion
|
|
? { opacity: 0.55, pathLength: 1 }
|
|
: { opacity: [0.05, 0.05, 0.55, 0.55, 0.15], pathLength: [0, 0, 1, 1, 1] }
|
|
}
|
|
transition={{ ...repeating, times: [0, 0.46, 0.7, 0.82, 1], ease: "easeInOut" }}
|
|
/>
|
|
<motion.path
|
|
d="M414 342C480 344 508 392 571 390C606 389 624 373 646 351"
|
|
stroke="url(#conduit-gradient)"
|
|
strokeWidth="6"
|
|
strokeLinecap="round"
|
|
animate={
|
|
reduceMotion
|
|
? { pathLength: 1, opacity: 0.85 }
|
|
: { pathLength: [0, 0, 1, 1, 1], opacity: [0.25, 0.25, 1, 1, 0.45] }
|
|
}
|
|
transition={{ ...repeating, times: [0, 0.46, 0.7, 0.82, 1], ease: "easeInOut" }}
|
|
/>
|
|
<motion.circle
|
|
r="8"
|
|
fill="#FCF9FF"
|
|
stroke="#A855F7"
|
|
strokeWidth="3"
|
|
animate={
|
|
reduceMotion
|
|
? { cx: 646, cy: 351, opacity: 0 }
|
|
: {
|
|
cx: [414, 414, 448, 505, 570, 620, 646, 646],
|
|
cy: [342, 342, 348, 382, 390, 372, 351, 351],
|
|
opacity: [0, 0, 1, 1, 1, 1, 0, 0],
|
|
scale: [0.7, 0.7, 1, 1, 1, 1, 0.7, 0.7],
|
|
}
|
|
}
|
|
transition={{ ...repeating, times: [0, 0.49, 0.53, 0.59, 0.65, 0.7, 0.74, 1], ease: "easeInOut" }}
|
|
/>
|
|
<motion.circle
|
|
r="5"
|
|
fill="#D8B4FE"
|
|
animate={
|
|
reduceMotion
|
|
? { cx: 646, cy: 351, opacity: 0 }
|
|
: {
|
|
cx: [414, 414, 448, 505, 570, 620, 646, 646],
|
|
cy: [342, 342, 348, 382, 390, 372, 351, 351],
|
|
opacity: [0, 0, 1, 1, 1, 1, 0, 0],
|
|
}
|
|
}
|
|
transition={{ ...repeating, times: [0, 0.56, 0.6, 0.66, 0.72, 0.77, 0.81, 1], ease: "easeInOut" }}
|
|
/>
|
|
</svg>
|
|
|
|
<div className="receiver-phone-shell">
|
|
<span className="phone-side-button phone-side-button-one" />
|
|
<span className="phone-side-button phone-side-button-two" />
|
|
<div className="receiver-phone-frame">
|
|
<div className="phone-screen">
|
|
<div className="phone-status-bar">
|
|
<span>9:41</span>
|
|
<span className="phone-island" />
|
|
<span className="phone-signal">● ◒</span>
|
|
</div>
|
|
<div className="phone-app-bar">
|
|
<BrandMark className="phone-brand-mark" />
|
|
<strong>Receive</strong>
|
|
<span><Icon name="shield" /></span>
|
|
</div>
|
|
<div className="phone-transfer-card">
|
|
<div className="phone-file-art"><Icon name="folder" /></div>
|
|
<small>INCOMING FROM ALEX</small>
|
|
<strong>summer-photos</strong>
|
|
<span>42 files · 486 MB</span>
|
|
</div>
|
|
<div className="phone-state-area">
|
|
<motion.div
|
|
className="phone-state is-waiting"
|
|
animate={
|
|
reduceMotion
|
|
? { opacity: 0 }
|
|
: { opacity: [1, 1, 0, 0] }
|
|
}
|
|
transition={{ ...repeating, times: [0, 0.43, 0.5, 1] }}
|
|
>
|
|
<span className="state-symbol"><Icon name="lock" /></span>
|
|
<strong>Waiting for approval</strong>
|
|
<small>The sender stays in control.</small>
|
|
</motion.div>
|
|
<motion.div
|
|
className="phone-state is-transferring"
|
|
animate={
|
|
reduceMotion
|
|
? { opacity: 0 }
|
|
: { opacity: [0, 0, 1, 1, 0, 0] }
|
|
}
|
|
transition={{ ...repeating, times: [0, 0.45, 0.51, 0.71, 0.78, 1] }}
|
|
>
|
|
<span className="transfer-percentage">68<span>%</span></span>
|
|
<strong>Receiving securely</strong>
|
|
<small>Direct path · 38 MB/s</small>
|
|
</motion.div>
|
|
<motion.div
|
|
className="phone-state is-verified"
|
|
animate={
|
|
reduceMotion
|
|
? { opacity: 1, scale: 1 }
|
|
: { opacity: [0, 0, 1, 1], scale: [0.92, 0.92, 1, 1] }
|
|
}
|
|
transition={{ ...repeating, times: [0, 0.72, 0.79, 1], ease: "easeOut" }}
|
|
>
|
|
<span className="state-symbol is-success"><Icon name="verified" /></span>
|
|
<strong>Received & verified</strong>
|
|
<small>Saved to Downloads</small>
|
|
</motion.div>
|
|
</div>
|
|
<div className="phone-home-indicator" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<motion.div
|
|
className="hero-route-card"
|
|
animate={
|
|
reduceMotion
|
|
? { opacity: 1, y: 0 }
|
|
: { opacity: [0, 1, 1, 1], y: [-8, 0, 0, 0] }
|
|
}
|
|
transition={{ ...repeating, times: [0, 0.08, 0.92, 1], ease: "easeOut" }}
|
|
>
|
|
<span className="route-pulse" />
|
|
<span>
|
|
<small>ROUTE</small>
|
|
<strong>Direct path established</strong>
|
|
</span>
|
|
<span className="route-latency">18 ms</span>
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
className="hero-verified-card"
|
|
animate={
|
|
reduceMotion
|
|
? { opacity: 1, y: 0, scale: 1 }
|
|
: {
|
|
opacity: [0, 0, 1, 1],
|
|
y: [8, 8, 0, 0],
|
|
scale: [0.96, 0.96, 1, 1],
|
|
}
|
|
}
|
|
transition={{ ...repeating, times: [0, 0.74, 0.81, 1], ease: "easeOut" }}
|
|
>
|
|
<span><Icon name="verified" /></span>
|
|
<span>
|
|
<strong>Content verified</strong>
|
|
<small>42 / 42 files match</small>
|
|
</span>
|
|
</motion.div>
|
|
|
|
<div className="hero-privacy-rail">
|
|
<span><Icon name="lock" /> E2E encrypted</span>
|
|
<i />
|
|
<span>No hosted copy</span>
|
|
<i />
|
|
<span>Approval required</span>
|
|
</div>
|
|
</div>
|
|
<figcaption className="sr-only">
|
|
A VniDrop transfer moves from a desktop sender to a phone after receiver approval, then is
|
|
verified on arrival.
|
|
</figcaption>
|
|
</figure>
|
|
);
|
|
}
|