Skip to content
Merged
68 changes: 68 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,67 @@
@import "./styles/layout.css";

.auth-shell {
position: relative;
isolation: isolate;
min-height: 100vh;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 24px;
overflow-x: clip;
background:
radial-gradient(circle at 50% 44%, color-mix(in srgb, var(--brand-primary) 8%, transparent), transparent 38%),
var(--surface-base);
}

.auth-shell::before,
.auth-shell::after {
position: absolute;
z-index: 0;
width: 34vw;
aspect-ratio: 1;
border-radius: 50%;
background: var(--accent-glow);
filter: blur(80px);
opacity: .22;
content: "";
pointer-events: none;
}

.auth-shell::before {
top: -18%;
left: -12%;
}

.auth-shell::after {
right: -12%;
bottom: -22%;
}

.auth-graph {
position: absolute;
z-index: -1;
inset: 0;
display: block;
width: 100%;
height: 100%;
pointer-events: none;
opacity: .9;
background:
radial-gradient(circle at 12% 18%, color-mix(in srgb, var(--brand-primary) 12%, transparent), transparent 24%),
radial-gradient(circle at 88% 82%, color-mix(in srgb, var(--brand-primary) 10%, transparent), transparent 26%);
}

.auth-card {
position: relative;
z-index: 1;
width: 100%;
max-width: 440px;
padding: 32px;
display: grid;
gap: 18px;
box-shadow: 0 24px 80px rgba(0, 0, 0, .34), 0 0 0 1px color-mix(in srgb, var(--brand-primary) 12%, transparent);
backdrop-filter: blur(18px);
}

.auth-card h1 {
Expand Down Expand Up @@ -45,6 +94,25 @@
color: var(--status-danger);
}

@media (max-width: 600px) {
.auth-shell {
padding: 16px;
}

.auth-card {
padding: 24px;
}

.auth-graph {
inset: -8% -28%;
opacity: .7;
}
}

@media (prefers-reduced-motion: reduce) {
.auth-graph { opacity: .72; }
}

button:focus-visible,
input:focus-visible,
select:focus-visible,
Expand Down
212 changes: 210 additions & 2 deletions frontend/src/views/auth/AuthView.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,217 @@
import { Button } from "../common/ui";
import { useEffect, useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { ApiError } from "../../api/api_auth";
import { useAuth } from "../../viewmodels/useAuthViewModel";
import { loginDestination, navigate, safeDestination } from "../../models/navigation";

type Particle = { x: number; y: number; vx: number; vy: number; radius: number };

function LoginGraphBackground() {
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)");
const pointer = { x: -1000, y: -1000 };
const isDocumentHidden = () => document.visibilityState === "hidden";
let particles: Particle[] = [];
let animationFrame: number | null = null;
let width = 0;
let height = 0;
let pixelRatio = 1;
let brandColor = "rgb(237, 106, 50)";
let highlightColor = "rgb(255, 173, 125)";
let brandColorWithAlpha = (alpha: number) => `rgba(237, 106, 50, ${alpha})`;

const parseColor = (value: string) => {
const hexMatch = value.match(/^#([\da-f]{3}|[\da-f]{6})$/i);
if (hexMatch) {
const hex = hexMatch[1].length === 3
? [...hexMatch[1]].map(part => part + part).join("")
: hexMatch[1];
return [0, 2, 4].map(offset => Number.parseInt(hex.slice(offset, offset + 2), 16));
}

const rgbMatch = value.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
if (rgbMatch) return rgbMatch.slice(1, 4).map(channel => Number.parseFloat(channel));
return [237, 106, 50];
};

const resize = () => {
const bounds = canvas.getBoundingClientRect();
width = bounds.width;
height = bounds.height;
pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
const configuredBrandColor = window.getComputedStyle(canvas).getPropertyValue("--brand-primary").trim();
const [red, green, blue] = parseColor(configuredBrandColor || "#ed6a32");
brandColor = `rgb(${red}, ${green}, ${blue})`;
highlightColor = `rgb(${Math.round(red + (255 - red) * .48)}, ${Math.round(green + (255 - green) * .48)}, ${Math.round(blue + (255 - blue) * .48)})`;
brandColorWithAlpha = (alpha: number) => `rgba(${red}, ${green}, ${blue}, ${alpha})`;
canvas.width = Math.floor(width * pixelRatio);
canvas.height = Math.floor(height * pixelRatio);
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
const count = Math.max(34, Math.min(78, Math.floor((width * height) / 15500)));
const columns = Math.max(5, Math.ceil(Math.sqrt(count * width / Math.max(height, 1))));
const rows = Math.ceil(count / columns);
particles = Array.from({ length: count }, (_, index) => ({
x: ((index % columns) + .5 + ((index * 17) % 7 - 3) * .045) * width / columns,
y: (Math.floor(index / columns) + .5 + ((index * 11) % 7 - 3) * .045) * height / rows,
vx: (index % 2 ? 1 : -1) * (0.08 + (index % 4) * 0.025),
vy: (index % 3 ? 1 : -1) * (0.06 + (index % 5) * 0.018),
radius: index % 7 === 0 ? 2.6 : 1.5,
}));
};

const drawScene = (advanceParticles: boolean) => {
context.clearRect(0, 0, width, height);
const linkDistance = Math.min(180, Math.max(135, width * 0.22));
const pointerRadius = 190;
const pointerActive = advanceParticles && pointer.x > -500;

if (pointerActive) {
const pointerGlow = context.createRadialGradient(
pointer.x,
pointer.y,
0,
pointer.x,
pointer.y,
pointerRadius,
);
pointerGlow.addColorStop(0, brandColorWithAlpha(.18));
pointerGlow.addColorStop(.45, brandColorWithAlpha(.06));
pointerGlow.addColorStop(1, brandColorWithAlpha(0));
context.fillStyle = pointerGlow;
context.beginPath();
context.arc(pointer.x, pointer.y, pointerRadius, 0, Math.PI * 2);
context.fill();
}

particles.forEach((particle, index) => {
if (advanceParticles) {
const dx = pointer.x - particle.x;
const dy = pointer.y - particle.y;
const distance = Math.hypot(dx, dy);
if (distance < pointerRadius && distance > 0) {
const influence = (1 - distance / pointerRadius) ** 2;
particle.vx += dx / distance * 0.0024 * influence;
particle.vy += dy / distance * 0.0024 * influence;
}
particle.x += particle.vx;
particle.y += particle.vy;
if (particle.x < -20 || particle.x > width + 20) particle.vx *= -1;
if (particle.y < -20 || particle.y > height + 20) particle.vy *= -1;
particle.vx = Math.max(-0.24, Math.min(0.24, particle.vx));
particle.vy = Math.max(-0.24, Math.min(0.24, particle.vy));
}

for (let nextIndex = index + 1; nextIndex < particles.length; nextIndex += 1) {
const other = particles[nextIndex];
const distance = Math.hypot(particle.x - other.x, particle.y - other.y);
if (distance > linkDistance) continue;
const pointerBoost = pointerActive && Math.hypot(pointer.x - particle.x, pointer.y - particle.y) < pointerRadius
? .14
: 0;
context.strokeStyle = brandColorWithAlpha(Math.min(.65, (1 - distance / linkDistance) * .32 + pointerBoost));
context.lineWidth = 0.7;
context.beginPath();
context.moveTo(particle.x, particle.y);
context.lineTo(other.x, other.y);
context.stroke();
}

context.fillStyle = index % 7 === 0 ? highlightColor : brandColor;
context.shadowColor = brandColor;
context.shadowBlur = pointerActive && Math.hypot(pointer.x - particle.x, pointer.y - particle.y) < pointerRadius ? 14 : 8;
context.beginPath();
context.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
context.fill();
context.shadowBlur = 0;
});
};

const cancelPendingFrame = () => {
if (animationFrame === null) return;
window.cancelAnimationFrame(animationFrame);
animationFrame = null;
};

function scheduleFrame() {
if (animationFrame !== null || reducedMotion.matches || isDocumentHidden()) return;
animationFrame = window.requestAnimationFrame(renderFrame);
}

function renderFrame() {
animationFrame = null;
const advanceParticles = !reducedMotion.matches && !isDocumentHidden();
drawScene(advanceParticles);
if (advanceParticles) scheduleFrame();
}

const movePointer = (event: MouseEvent) => {
if (reducedMotion.matches || isDocumentHidden()) return;
const bounds = canvas.getBoundingClientRect();
pointer.x = event.clientX - bounds.left;
pointer.y = event.clientY - bounds.top;
};
const resetPointer = () => {
pointer.x = -1000;
pointer.y = -1000;
};

const handleResize = () => {
resize();
if (reducedMotion.matches) drawScene(false);
else scheduleFrame();
};

const handleMotionPreferenceChange = () => {
resetPointer();
if (reducedMotion.matches) {
cancelPendingFrame();
drawScene(false);
} else {
scheduleFrame();
}
};

const handleVisibilityChange = () => {
resetPointer();
if (isDocumentHidden()) {
cancelPendingFrame();
} else if (reducedMotion.matches) {
drawScene(false);
} else {
scheduleFrame();
}
};

resize();
if (reducedMotion.matches) drawScene(false);
else scheduleFrame();
window.addEventListener("resize", handleResize);
window.addEventListener("mousemove", movePointer);
window.addEventListener("mouseleave", resetPointer);
reducedMotion.addEventListener("change", handleMotionPreferenceChange);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("resize", handleResize);
window.removeEventListener("mousemove", movePointer);
window.removeEventListener("mouseleave", resetPointer);
reducedMotion.removeEventListener("change", handleMotionPreferenceChange);
document.removeEventListener("visibilitychange", handleVisibilityChange);
cancelPendingFrame();
};
}, []);

return (
<canvas ref={canvasRef} className="auth-graph" aria-hidden="true" />
);
}

export function AuthGate({ children }: { children: ReactNode }) {
const { session, notice, restore, logout } = useAuth();
const [location, setLocation] = useState(() => window.location.href);
Expand Down Expand Up @@ -40,7 +248,6 @@ export function AuthGate({ children }: { children: ReactNode }) {

export function AuthScreen({ initialMode }: { initialMode: "login" | "register" }) {
const [mode, setMode] = useState<"login" | "register">(initialMode);

useEffect(() => {
setMode(initialMode);
}, [initialMode]);
Expand All @@ -52,6 +259,7 @@ export function AuthScreen({ initialMode }: { initialMode: "login" | "register"

return (
<main className="auth-shell">
<LoginGraphBackground />
<section className="ds-card ds-card--glass auth-card">
<div style={{ display: "flex", gap: "6px" }}>
{mode === "register" && (
Expand Down
Loading
Loading