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:
+109
@@ -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
@@ -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;
|
||||
@@ -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>,
|
||||
);
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user