Build the Synapsis Tauri onboarding and authentication shell with the official wordmark and a fluid stable-topology signal mesh

Hop-State: A_06FPFZF1AB46ARJ4HD8ZQH8
Hop-Proposal: R_06FPFZEBFD47ZTA2XKJ2Q0G
Hop-Task: T_06FPFXR242APZTT77HV934R
Hop-Attempt: AT_06FPFXR241F9H1V2J0RCGGG
This commit is contained in:
2026-07-15 15:55:19 -07:00
committed by Hop
parent 85d5b6f7d2
commit 0695655ccf
74 changed files with 6433 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@600;700;800&family=Roboto+Mono:wght@500;600&display=swap');
:root { font-family: "DM Sans", -apple-system, BlinkMacSystemFont, sans-serif; color: #f5f5f2; background: #070808; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; }
* { box-sizing: border-box; }
html, body, #root { width: 100%; min-width: 720px; height: 100%; min-height: 560px; margin: 0; overflow: hidden; }
button, input { font: inherit; }
button { color: inherit; }
button:focus-visible, input:focus-visible { outline: 2px solid #fff; outline-offset: 3px; }
.onboarding-shell { width: 100%; height: 100%; display: grid; grid-template-columns: minmax(300px, 45%) minmax(420px, 55%); background: #0b0c0c; }
.signal-panel { position: relative; min-width: 0; overflow: hidden; border-right: 1px solid rgba(255,255,255,.1); }
.signal-field { position: absolute; inset: 0; width: 100%; height: 100%; }
.signal-shade { position: absolute; inset: 0; background: radial-gradient(circle at 55% 45%, transparent 0, rgba(0,0,0,.08) 38%, rgba(0,0,0,.74) 100%), linear-gradient(180deg, rgba(0,0,0,.12), rgba(0,0,0,.4)); pointer-events: none; }
.panel-brand, .sidebar-brand, .launch-mark { position: absolute; top: 52px; left: 48px; z-index: 2; display: flex; align-items: center; }
.wordmark { display: block; width: auto; object-fit: contain; }
.panel-wordmark { width: 146px; height: 28px; }
.signal-copy { position: absolute; z-index: 2; left: 48px; right: 36px; bottom: 104px; }
.eyebrow { margin: 0 0 25px; color: rgba(255,255,255,.62); font: 600 10px/1 "Roboto Mono", monospace; letter-spacing: 2.1px; }
.eyebrow i { display: inline-block; width: 5px; height: 5px; margin-right: 8px; border-radius: 50%; background: #fff; box-shadow: 0 0 9px #fff; vertical-align: 1px; }
.signal-copy h2 { margin: 0; max-width: 480px; font-family: Manrope, sans-serif; font-size: clamp(37px, 4vw, 61px); line-height: .99; letter-spacing: -3.4px; }
.panel-status { position: absolute; z-index: 2; bottom: 34px; left: 48px; right: 38px; display: flex; justify-content: space-between; color: rgba(255,255,255,.3); font: 500 9px "Roboto Mono", monospace; letter-spacing: 1.4px; }
.form-panel { position: relative; overflow-y: auto; background: radial-gradient(circle at 85% 10%, rgba(255,255,255,.035), transparent 31%), #0c0d0d; }
.drag-strip { position: fixed; z-index: 1; top: 0; right: 0; width: 55%; height: 36px; }
.back-button { position: absolute; z-index: 2; top: 46px; left: 46px; display: flex; align-items: center; gap: 7px; padding: 7px 0; border: 0; background: transparent; color: rgba(255,255,255,.55); font-size: 13px; cursor: pointer; }
.back-button:hover { color: #fff; }
.form-wrap { width: min(440px, calc(100% - 80px)); min-height: calc(100% - 66px); margin: 0 auto; display: flex; align-items: center; padding: 78px 0 68px; }
.form-wrap form { width: 100%; }
.step-label { display: flex; align-items: center; gap: 12px; margin-bottom: 35px; color: rgba(255,255,255,.43); font: 600 9px "Roboto Mono", monospace; letter-spacing: 1.6px; }
.step-label span:first-child { color: #fff; }
.step-label i { width: 27px; height: 1px; background: rgba(255,255,255,.22); }
.form-wrap h1 { margin: 0 0 20px; font-family: Manrope, sans-serif; font-size: clamp(44px, 5vw, 64px); line-height: .95; letter-spacing: -4px; }
.lede { max-width: 400px; margin: 0 0 42px; color: rgba(255,255,255,.5); font-size: 15px; line-height: 1.65; }
.lede.compact { margin-bottom: 27px; }
label { display: block; margin: 0 0 9px; color: rgba(255,255,255,.75); font-size: 12px; font-weight: 600; }
.url-field, .handle-field { display: flex; align-items: center; height: 56px; border: 1px solid rgba(255,255,255,.16); border-radius: 8px; background: rgba(255,255,255,.045); transition: border-color .2s, background .2s; }
.url-field:focus-within, .handle-field:focus-within { border-color: rgba(255,255,255,.72); background: rgba(255,255,255,.065); }
.url-field span, .handle-field span { padding-left: 17px; color: rgba(255,255,255,.35); font: 500 14px "Roboto Mono", monospace; }
.url-field input, .handle-field input { min-width: 0; flex: 1; height: 100%; padding: 0 17px 0 2px; border: 0; outline: 0; color: #fff; background: transparent; font: 500 14px "Roboto Mono", monospace; }
.text-field { width: 100%; height: 52px; margin: 0 0 17px; padding: 0 15px; border: 1px solid rgba(255,255,255,.14); border-radius: 8px; outline: 0; color: #fff; background: rgba(255,255,255,.045); transition: border-color .2s, background .2s; }
.text-field:focus { border-color: rgba(255,255,255,.68); background: rgba(255,255,255,.065); }
.text-field::placeholder { color: rgba(255,255,255,.24); }
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 13px; }
.handle-field { height: 52px; margin-bottom: 17px; }
.field-note { margin: -8px 0 18px; color: rgba(255,255,255,.34); font-size: 11px; }
.primary-button, .secondary-button { width: 100%; height: 54px; display: flex; align-items: center; justify-content: center; gap: 10px; border-radius: 8px; font-size: 13px; font-weight: 700; cursor: pointer; transition: transform .15s, background .15s, opacity .15s; }
.primary-button { margin-top: 18px; border: 0; color: #080909; background: #f4f4f0; }
.primary-button:hover:not(:disabled) { background: #fff; transform: translateY(-1px); }
.primary-button:active:not(:disabled) { transform: translateY(0); }
.primary-button:disabled { cursor: default; opacity: .48; }
.secondary-button { border: 1px solid rgba(255,255,255,.17); background: transparent; }
.secondary-button:hover { background: rgba(255,255,255,.06); border-color: rgba(255,255,255,.28); }
.privacy-note { margin: 20px 0 0; text-align: center; color: rgba(255,255,255,.28); font-size: 10px; }
.lock-dot { margin-right: 5px; font-size: 7px; }
.connection-label { display: flex; align-items: center; gap: 8px; margin-bottom: 31px; color: rgba(255,255,255,.48); font: 600 9px "Roboto Mono", monospace; letter-spacing: 1.2px; }
.connection-label > span { display: grid; place-items: center; width: 18px; height: 18px; border: 1px solid rgba(255,255,255,.4); border-radius: 50%; color: #fff; }
.divider { display: flex; align-items: center; gap: 12px; margin: 25px 0 18px; color: rgba(255,255,255,.27); font: 600 9px "Roboto Mono", monospace; letter-spacing: 1.5px; }
.divider i { flex: 1; height: 1px; background: rgba(255,255,255,.09); }
.error-message { margin: 13px 0 0; padding: 10px 12px; border: 1px solid rgba(255,112,112,.22); border-radius: 7px; color: #ff9c9c; background: rgba(255,60,60,.07); font-size: 12px; line-height: 1.4; }
.step-footer { position: absolute; left: 44px; right: 44px; bottom: 25px; display: flex; justify-content: space-between; color: rgba(255,255,255,.25); font: 500 8px "Roboto Mono", monospace; letter-spacing: 1.2px; }
.step-dots { display: flex; gap: 5px; }
.step-dots i { width: 17px; height: 2px; background: rgba(255,255,255,.12); }
.step-dots i.active { background: rgba(255,255,255,.75); }
.spinner { width: 17px; height: 17px; display: inline-block; border: 2px solid rgba(255,255,255,.2); border-top-color: #fff; border-radius: 50%; animation: spin .75s linear infinite; }
.spinner.dark { width: 15px; height: 15px; border-color: rgba(0,0,0,.18); border-top-color: #111; }
@keyframes spin { to { transform: rotate(360deg); } }
.launch-screen { position: relative; width: 100%; height: 100%; display: grid; place-items: center; background: #060707; }
.launch-screen::after { content: ""; position: absolute; inset: 0; background: radial-gradient(circle, transparent, rgba(0,0,0,.6)); }
.launch-mark { left: 50%; top: 50%; transform: translate(-50%, -50%); }
.launch-wordmark { width: 176px; height: 33px; }
.launch-screen > .spinner { position: absolute; z-index: 3; bottom: 12%; }
.app-shell { width: 100%; height: 100%; display: grid; grid-template-columns: 238px 1fr; background: #0a0b0b; }
.sidebar { position: relative; display: flex; flex-direction: column; padding: 104px 18px 18px; border-right: 1px solid rgba(255,255,255,.09); background: #080909; }
.sidebar-brand { top: 43px; left: 26px; }
.sidebar-wordmark { width: 137px; height: 26px; }
.sidebar nav { display: flex; flex-direction: column; gap: 4px; }
.nav-item, .node-switcher, .profile-button { display: flex; align-items: center; width: 100%; border: 0; border-radius: 8px; background: transparent; color: rgba(255,255,255,.52); cursor: pointer; }
.nav-item { gap: 13px; height: 44px; padding: 0 13px; font-size: 13px; }
.nav-item:hover, .nav-item.active { color: #fff; background: rgba(255,255,255,.055); }
.compose-button { height: 44px; display: flex; align-items: center; justify-content: center; gap: 9px; margin-top: 22px; border: 0; border-radius: 8px; color: #0a0b0b; background: #f4f4ef; font-size: 12px; font-weight: 700; cursor: pointer; }
.sidebar-bottom { margin-top: auto; }
.node-switcher { gap: 10px; padding: 11px 10px; }
.node-switcher:hover, .profile-button:hover { background: rgba(255,255,255,.05); }
.node-switcher > span, .profile-button > span:not(.avatar) { min-width: 0; flex: 1; display: flex; flex-direction: column; align-items: flex-start; }
.node-switcher strong, .profile-button strong { max-width: 130px; overflow: hidden; color: rgba(255,255,255,.83); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.node-switcher small, .profile-button small { max-width: 130px; overflow: hidden; color: rgba(255,255,255,.3); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.profile-button { gap: 10px; margin-top: 7px; padding: 9px; border-top: 1px solid rgba(255,255,255,.06); border-radius: 0 0 8px 8px; text-align: left; }
.avatar { width: 30px; height: 30px; display: grid; flex: 0 0 auto; place-items: center; border: 1px solid rgba(255,255,255,.22); border-radius: 50%; background: rgba(255,255,255,.08); font-size: 11px; font-weight: 700; }
.home-content { min-width: 0; display: flex; flex-direction: column; }
.home-header { height: 70px; display: flex; align-items: center; justify-content: space-between; padding: 18px 28px 0; border-bottom: 1px solid rgba(255,255,255,.08); }
.home-header h1 { margin: 0; font: 700 17px Manrope, sans-serif; }
.connected-pill { display: flex; align-items: center; gap: 7px; color: rgba(255,255,255,.4); font: 500 8px "Roboto Mono", monospace; letter-spacing: 1px; }
.connected-pill i { width: 5px; height: 5px; border-radius: 50%; background: #78dc9b; box-shadow: 0 0 8px rgba(120,220,155,.7); }
.home-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 35px; text-align: center; }
.home-empty .eyebrow { margin: 26px 0 17px; }
.home-empty h2 { margin: 0; font: 700 clamp(32px, 4vw, 50px)/1.05 Manrope, sans-serif; letter-spacing: -2.8px; }
.home-empty > p:last-child { max-width: 470px; margin: 20px 0 0; color: rgba(255,255,255,.4); font-size: 13px; line-height: 1.7; }
.home-empty strong { color: rgba(255,255,255,.68); }
.orbit-mark { position: relative; width: 76px; height: 76px; }
.orbit-mark span { position: absolute; inset: 8px; border: 1px solid rgba(255,255,255,.16); border-radius: 50%; }
.orbit-mark span:nth-child(2) { inset: 20px; border-color: rgba(255,255,255,.35); }
.orbit-mark i { position: absolute; left: 50%; top: 0; width: 5px; height: 5px; border-radius: 50%; background: #fff; box-shadow: 0 0 8px #fff; animation: orbit 5s linear infinite; transform-origin: 0 38px; }
@keyframes orbit { to { transform: rotate(360deg); } }
@media (max-width: 850px) { .onboarding-shell { grid-template-columns: 38% 62%; } .signal-copy h2 { font-size: 38px; letter-spacing: -2.4px; } .form-wrap { width: calc(100% - 54px); } .panel-brand, .signal-copy { left: 32px; } .panel-status { left: 32px; } }
@media (max-height: 670px) { .form-wrap { align-items: flex-start; padding-top: 78px; } .form-wrap h1 { font-size: 42px; } .lede { margin-bottom: 24px; } .signal-copy { bottom: 74px; } .signal-copy h2 { font-size: 39px; } .field-grid { gap: 9px; } .text-field { height: 46px; margin-bottom: 11px; } .handle-field { height: 46px; margin-bottom: 11px; } .primary-button { height: 49px; margin-top: 12px; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; } }
+449
View File
@@ -0,0 +1,449 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { invoke, isTauri } from "@tauri-apps/api/core";
import "./App.css";
type Screen = "node" | "login" | "register" | "home";
type NodeInfo = {
name?: string;
description?: string;
accentColor?: string;
domain?: string;
logoUrl?: string;
};
type User = {
id: string;
handle: string;
displayName?: string;
avatarUrl?: string;
};
type Connection = {
nodeUrl: string;
node: NodeInfo;
user: User | null;
};
type AuthResponse = { user: User };
const DEFAULT_NODE = "synapsis.social";
const SAVED_NODE_KEY = "synapsis.nodeUrl";
function readableError(error: unknown) {
if (typeof error === "string") return error;
if (error instanceof Error) return error.message;
return "Something went wrong. Please try again.";
}
function displayHost(url: string) {
try {
return new URL(url).host;
} catch {
return url.replace(/^https?:\/\//, "").replace(/\/$/, "");
}
}
function Icon({ name, size = 20 }: { name: string; size?: number }) {
const paths: Record<string, React.ReactNode> = {
arrow: <><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></>,
back: <><path d="m15 18-6-6 6-6"/></>,
check: <><path d="m5 12 4 4L19 6"/></>,
home: <><path d="m3 11 9-8 9 8"/><path d="M5 10v10h14V10"/><path d="M9 20v-6h6v6"/></>,
search: <><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></>,
bell: <><path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9"/><path d="M10 21h4"/></>,
message: <><path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z"/></>,
plus: <><path d="M12 5v14M5 12h14"/></>,
node: <><circle cx="12" cy="12" r="2"/><circle cx="5" cy="6" r="2"/><circle cx="19" cy="6" r="2"/><circle cx="5" cy="18" r="2"/><circle cx="19" cy="18" r="2"/><path d="m7 7 3.5 3M17 7l-3.5 3M7 17l3.5-3M17 17l-3.5-3"/></>,
logout: <><path d="M10 17l5-5-5-5M15 12H3"/><path d="M14 3h5a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-5"/></>,
};
return <svg aria-hidden="true" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">{paths[name]}</svg>;
}
function Wordmark({ className = "" }: { className?: string }) {
return <img className={`wordmark ${className}`} src="/logotext.svg" alt="Synapsis" />;
}
function SignalField() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let animation = 0;
let width = 0;
let height = 0;
let points: {
x: number;
y: number;
driftX: number;
driftY: number;
phaseX: number;
phaseY: number;
speedX: number;
speedY: number;
radius: number;
}[] = [];
let triangles: [number, number, number][] = [];
function random(seed: number) {
const value = Math.sin(seed * 999.17) * 43758.5453;
return value - Math.floor(value);
}
function resize() {
const scale = window.devicePixelRatio || 1;
width = canvas!.clientWidth;
height = canvas!.clientHeight;
canvas!.width = width * scale;
canvas!.height = height * scale;
context!.setTransform(scale, 0, 0, scale, 0, 0);
const count = Math.min(64, Math.max(34, Math.round(width / 18)));
points = Array.from({ length: count }, (_, index) => ({
x: random(index + 11) * width,
y: random(index + 97) * height,
driftX: 9 + random(index + 207) * 16,
driftY: 9 + random(index + 307) * 16,
phaseX: random(index + 507) * Math.PI * 2,
phaseY: random(index + 607) * Math.PI * 2,
speedX: 0.055 + random(index + 707) * 0.055,
speedY: 0.05 + random(index + 807) * 0.06,
radius: 0.7 + random(index + 407) * 1.15,
}));
// Keep the mesh topology stable. The previous version rebuilt the
// nearest-neighbour fan every frame, which made edges visibly pop.
triangles = [];
for (let anchor = 0; anchor < points.length; anchor += 3) {
const ring = points
.map((point, index) => ({
index,
distance: Math.hypot(point.x - points[anchor].x, point.y - points[anchor].y),
angle: Math.atan2(point.y - points[anchor].y, point.x - points[anchor].x),
}))
.filter((item) => item.index !== anchor && item.distance < 230)
.sort((left, right) => left.distance - right.distance)
.slice(0, 6)
.sort((left, right) => left.angle - right.angle);
for (let index = 0; index < ring.length - 1; index += 2) {
if (Math.hypot(
points[ring[index].index].x - points[ring[index + 1].index].x,
points[ring[index].index].y - points[ring[index + 1].index].y,
) < 220) {
triangles.push([anchor, ring[index].index, ring[index + 1].index]);
}
}
}
}
function draw(milliseconds: number) {
const time = reducedMotion ? 0 : milliseconds / 1000;
context!.clearRect(0, 0, width, height);
context!.fillStyle = "#060707";
context!.fillRect(0, 0, width, height);
const positions = points.map((point) => ({
...point,
px: point.x + Math.sin(time * point.speedX + point.phaseX) * point.driftX,
py: point.y + Math.sin(time * point.speedY + point.phaseY) * point.driftY,
}));
for (let left = 0; left < positions.length; left++) {
for (let right = left + 1; right < positions.length; right++) {
const dx = positions[left].px - positions[right].px;
const dy = positions[left].py - positions[right].py;
const distance = Math.hypot(dx, dy);
if (distance < 112) {
context!.strokeStyle = `rgba(255,255,255,${(1 - distance / 112) * 0.1})`;
context!.lineWidth = 0.7;
context!.beginPath();
context!.moveTo(positions[left].px, positions[left].py);
context!.lineTo(positions[right].px, positions[right].py);
context!.stroke();
}
}
}
const signals = [0, 1].map((index) => ({
x: width * (0.5 + 0.39 * Math.sin(time * (index ? 0.082 : 0.105) + index * 2.1)),
y: height * (0.5 + 0.4 * Math.sin(time * (index ? 0.112 : 0.078) + index * 1.3)),
intensity: 0.48 + 0.24 * Math.sin(time * 0.32 + index * Math.PI),
}));
triangles.forEach(([firstIndex, secondIndex, thirdIndex], triangleIndex) => {
const first = positions[firstIndex];
const second = positions[secondIndex];
const third = positions[thirdIndex];
const centerX = (first.px + second.px + third.px) / 3;
const centerY = (first.py + second.py + third.py) / 3;
const signalStrength = Math.max(...signals.map((signal) => {
const proximity = Math.max(0, 1 - Math.hypot(centerX - signal.x, centerY - signal.y) / 285);
const easedProximity = proximity * proximity * (3 - 2 * proximity);
return easedProximity * signal.intensity;
}));
const breath = 0.84 + 0.16 * Math.sin(time * 0.24 + triangleIndex * 0.41);
const alpha = signalStrength * breath;
if (alpha < 0.004) return;
context!.beginPath();
context!.moveTo(first.px, first.py);
context!.lineTo(second.px, second.py);
context!.lineTo(third.px, third.py);
context!.closePath();
context!.fillStyle = `rgba(255,255,255,${alpha * 0.03})`;
context!.strokeStyle = `rgba(255,255,255,${alpha * 0.38})`;
context!.lineWidth = 0.7;
context!.fill();
context!.stroke();
});
positions.forEach((point) => {
const glow = Math.max(...signals.map((signal) => signal.intensity * Math.max(0, 1 - Math.hypot(point.px - signal.x, point.py - signal.y) / 200)));
context!.fillStyle = `rgba(255,255,255,${0.28 + glow * 0.58})`;
context!.beginPath();
context!.arc(point.px, point.py, point.radius + glow * 0.8, 0, Math.PI * 2);
context!.fill();
});
if (!reducedMotion) animation = requestAnimationFrame(draw);
}
const observer = new ResizeObserver(resize);
observer.observe(canvas);
resize();
if (reducedMotion) draw(0);
else animation = requestAnimationFrame(draw);
return () => {
observer.disconnect();
cancelAnimationFrame(animation);
};
}, []);
return <canvas ref={canvasRef} className="signal-field" aria-hidden="true" />;
}
function App() {
const [screen, setScreen] = useState<Screen>("node");
const [nodeInput, setNodeInput] = useState(DEFAULT_NODE);
const [nodeUrl, setNodeUrl] = useState("");
const [node, setNode] = useState<NodeInfo>({ name: "Synapsis" });
const [user, setUser] = useState<User | null>(null);
const [working, setWorking] = useState(false);
const [bootstrapping, setBootstrapping] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const savedNode = localStorage.getItem(SAVED_NODE_KEY);
if (!savedNode || !isTauri()) {
setBootstrapping(false);
return;
}
invoke<Connection>("connect", { nodeUrl: savedNode })
.then((connection) => {
setNodeUrl(connection.nodeUrl);
setNode(connection.node);
setNodeInput(displayHost(connection.nodeUrl));
setUser(connection.user);
setScreen(connection.user ? "home" : "login");
})
.catch(() => setScreen("node"))
.finally(() => setBootstrapping(false));
}, []);
async function connect(event: FormEvent) {
event.preventDefault();
if (!isTauri()) {
setError("Open this screen with “bun run tauri dev” to connect to a node.");
return;
}
setWorking(true);
setError("");
try {
const connection = await invoke<Connection>("connect", { nodeUrl: nodeInput });
localStorage.setItem(SAVED_NODE_KEY, connection.nodeUrl);
setNodeUrl(connection.nodeUrl);
setNode(connection.node);
setUser(connection.user);
setScreen(connection.user ? "home" : "login");
} catch (caught) {
setError(readableError(caught));
} finally {
setWorking(false);
}
}
async function login(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setWorking(true);
setError("");
try {
const response = await invoke<AuthResponse>("login", {
nodeUrl,
email: data.get("email"),
password: data.get("password"),
});
setUser(response.user);
setScreen("home");
} catch (caught) {
setError(readableError(caught));
} finally {
setWorking(false);
}
}
async function register(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
const password = String(data.get("password"));
if (password !== String(data.get("confirmation"))) {
setError("Passwords do not match.");
return;
}
setWorking(true);
setError("");
try {
const response = await invoke<AuthResponse>("register", {
nodeUrl,
displayName: data.get("displayName"),
handle: data.get("handle"),
email: data.get("email"),
password,
});
setUser(response.user);
setScreen("home");
} catch (caught) {
setError(readableError(caught));
} finally {
setWorking(false);
}
}
async function logout() {
setWorking(true);
try {
await invoke("logout", { nodeUrl });
} finally {
setUser(null);
setScreen("login");
setWorking(false);
}
}
function chooseAnotherNode() {
setError("");
setScreen("node");
}
if (bootstrapping) {
return <main className="launch-screen"><SignalField/><div className="launch-mark"><Wordmark className="launch-wordmark"/></div><span className="spinner" aria-label="Loading Synapsis"/></main>;
}
if (screen === "home" && user) {
const name = user.displayName?.trim() || user.handle.replace(/^@/, "").split("@")[0];
return (
<main className="app-shell">
<aside className="sidebar" data-tauri-drag-region>
<div className="sidebar-brand"><Wordmark className="sidebar-wordmark"/></div>
<nav aria-label="Main navigation">
<button className="nav-item active"><Icon name="home"/><span>Home</span></button>
<button className="nav-item"><Icon name="search"/><span>Explore</span></button>
<button className="nav-item"><Icon name="bell"/><span>Notifications</span></button>
<button className="nav-item"><Icon name="message"/><span>Messages</span></button>
</nav>
<button className="compose-button"><Icon name="plus"/><span>New post</span></button>
<div className="sidebar-bottom">
<button className="node-switcher" onClick={chooseAnotherNode}><Icon name="node"/><span><strong>{node.name || "Synapsis"}</strong><small>{displayHost(nodeUrl)}</small></span></button>
<button className="profile-button" onClick={logout} disabled={working} title="Log out">
<span className="avatar">{name.slice(0, 1).toUpperCase()}</span><span><strong>{name}</strong><small>{user.handle.startsWith("@") ? user.handle : `@${user.handle}`}</small></span><Icon name="logout" size={17}/>
</button>
</div>
</aside>
<section className="home-content">
<header className="home-header" data-tauri-drag-region><h1>Home</h1><span className="connected-pill"><i/>Connected</span></header>
<div className="home-empty">
<div className="orbit-mark"><span/><span/><i/></div>
<p className="eyebrow">SESSION ESTABLISHED</p>
<h2>Welcome to the network,<br/>{name}.</h2>
<p>Your desktop session is connected to <strong>{displayHost(nodeUrl)}</strong>. The timeline is the next piece to bring over from iOS.</p>
</div>
</section>
</main>
);
}
return (
<main className="onboarding-shell">
<section className="signal-panel" data-tauri-drag-region>
<SignalField/>
<div className="signal-shade"/>
<div className="panel-brand"><Wordmark className="panel-wordmark"/></div>
<div className="signal-copy">
<p className="eyebrow"><i/> THE SIGNAL IS ALREADY MOVING</p>
<h2>Your node.<br/>Your network.<br/>Your identity.</h2>
</div>
<div className="panel-status"><span>DECENTRALIZED SOCIAL</span><span>DESKTOP / 01</span></div>
</section>
<section className="form-panel">
<div className="drag-strip" data-tauri-drag-region/>
{screen !== "node" && <button className="back-button" onClick={() => { setError(""); setScreen(screen === "register" ? "login" : "node"); }}><Icon name="back" size={18}/>Back</button>}
<div className="form-wrap">
{screen === "node" && (
<form onSubmit={connect}>
<div className="step-label"><span>01</span><i/><span>CONNECT</span></div>
<h1>Choose your<br/>node.</h1>
<p className="lede">Synapsis is a network of independent communities. Enter the node where your account lives.</p>
<label htmlFor="node-url">Node URL</label>
<div className="url-field"><span>https://</span><input id="node-url" value={nodeInput.replace(/^https?:\/\//, "")} onChange={(event) => setNodeInput(event.target.value)} autoCapitalize="none" autoCorrect="off" spellCheck={false} autoFocus/></div>
{error && <p className="error-message" role="alert">{error}</p>}
<button className="primary-button" disabled={!nodeInput.trim() || working}>{working ? <><span className="spinner dark"/>Connecting</> : <>Continue<Icon name="arrow" size={19}/></>}</button>
<p className="privacy-note"><span className="lock-dot"></span> Your credentials are sent only to the node you choose.</p>
</form>
)}
{screen === "login" && (
<form onSubmit={login}>
<div className="connection-label"><span><Icon name="check" size={12}/></span>CONNECTED / {displayHost(nodeUrl).toUpperCase()}</div>
<h1>Welcome<br/>back.</h1>
<p className="lede">Sign in to continue on this node.</p>
<label htmlFor="email">Email</label>
<input className="text-field" id="email" name="email" type="email" placeholder="you@example.com" autoComplete="username" required autoFocus/>
<label htmlFor="password">Password</label>
<input className="text-field" id="password" name="password" type="password" placeholder="Enter your password" autoComplete="current-password" required/>
<p className="field-note">Use the email associated with your username.</p>
{error && <p className="error-message" role="alert">{error}</p>}
<button className="primary-button" disabled={working}>{working ? <><span className="spinner dark"/>Signing in</> : <>Sign in<Icon name="arrow" size={19}/></>}</button>
<div className="divider"><i/><span>NEW HERE?</span><i/></div>
<button type="button" className="secondary-button" onClick={() => { setError(""); setScreen("register"); }}>Create account</button>
</form>
)}
{screen === "register" && (
<form onSubmit={register}>
<div className="connection-label"><span><Icon name="check" size={12}/></span>CONNECTED / {displayHost(nodeUrl).toUpperCase()}</div>
<h1>Create your<br/>account.</h1>
<p className="lede compact">Create your identity on this node.</p>
<div className="field-grid">
<div><label htmlFor="display-name">Display name</label><input className="text-field" id="display-name" name="displayName" autoComplete="name" required autoFocus/></div>
<div><label htmlFor="handle">Username</label><div className="handle-field"><span>@</span><input id="handle" name="handle" autoCapitalize="none" autoComplete="username" required/></div></div>
</div>
<label htmlFor="register-email">Email</label>
<input className="text-field" id="register-email" name="email" type="email" autoComplete="email" required/>
<div className="field-grid">
<div><label htmlFor="new-password">Password</label><input className="text-field" id="new-password" name="password" type="password" minLength={8} autoComplete="new-password" required/></div>
<div><label htmlFor="confirmation">Confirm</label><input className="text-field" id="confirmation" name="confirmation" type="password" minLength={8} autoComplete="new-password" required/></div>
</div>
<p className="field-note">Use at least 8 characters.</p>
{error && <p className="error-message" role="alert">{error}</p>}
<button className="primary-button" disabled={working}>{working ? <><span className="spinner dark"/>Creating</> : <>Create account<Icon name="arrow" size={19}/></>}</button>
</form>
)}
</div>
<div className="step-footer"><span>{screen === "node" ? "STEP 1 OF 2" : screen === "login" ? "STEP 2 OF 2" : "NEW ACCOUNT"}</span><span className="step-dots"><i className="active"/><i className={screen !== "node" ? "active" : ""}/></span></div>
</section>
</main>
);
}
export default App;
+9
View File
@@ -0,0 +1,9 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />