feat(ui): revamp sign-in/sign-up with animated bg and release highlights

This commit is contained in:
pedrooot
2026-04-17 12:53:26 +02:00
parent 536e90f2a5
commit 31639db7e4
4 changed files with 390 additions and 27 deletions
@@ -0,0 +1,274 @@
"use client";
import { useEffect, useRef } from "react";
const PARTICLE_DENSITY = 14000;
const PARTICLE_MIN_COUNT = 40;
const PARTICLE_MIN_RADIUS = 1.4;
const PARTICLE_RADIUS_RANGE = 1.8;
const PARTICLE_SPEED = 0.35;
const CONNECTION_DISTANCE = 140;
const CONNECTION_DISTANCE_SQ = CONNECTION_DISTANCE * CONNECTION_DISTANCE;
const LINE_WIDTH = 0.8;
const MOUSE_RADIUS = 160;
const MOUSE_RADIUS_SQ = MOUSE_RADIUS * MOUSE_RADIUS;
const MOUSE_FORCE = 2.2;
const PARTICLE_ALPHA = 0.75;
const CONNECTION_MAX_ALPHA = 0.35;
const ALPHA_TIER_COUNT = 3;
const MAX_DPR = 2;
const ACCENT_FALLBACK = "rgb(110, 231, 183)";
const ACCENT_VAR = "--bg-button-primary";
export const AnimatedDotsBackground = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const ac = new AbortController();
const { signal } = ac;
let animationId = 0;
let resizeFrameId = 0;
let width = 0;
let height = 0;
let accentColor = ACCENT_FALLBACK;
let count = 0;
let xs = new Float32Array(0);
let ys = new Float32Array(0);
let vxs = new Float32Array(0);
let vys = new Float32Array(0);
let rs = new Float32Array(0);
const tierBuckets: Float32Array[] = [];
const tierLengths = new Int32Array(ALPHA_TIER_COUNT);
const mouse = { x: -9999, y: -9999, active: false };
const readAccent = () => {
const value = getComputedStyle(document.documentElement)
.getPropertyValue(ACCENT_VAR)
.trim();
accentColor = value || ACCENT_FALLBACK;
};
const ensureTierCapacity = (n: number) => {
const maxPairs = (n * (n - 1)) / 2;
const floatsNeeded = maxPairs * 4;
for (let t = 0; t < ALPHA_TIER_COUNT; t++) {
if (!tierBuckets[t] || tierBuckets[t].length < floatsNeeded) {
tierBuckets[t] = new Float32Array(floatsNeeded);
}
}
};
const initParticles = () => {
count = Math.max(
PARTICLE_MIN_COUNT,
Math.floor((width * height) / PARTICLE_DENSITY),
);
xs = new Float32Array(count);
ys = new Float32Array(count);
vxs = new Float32Array(count);
vys = new Float32Array(count);
rs = new Float32Array(count);
for (let i = 0; i < count; i++) {
xs[i] = Math.random() * width;
ys[i] = Math.random() * height;
vxs[i] = (Math.random() - 0.5) * PARTICLE_SPEED;
vys[i] = (Math.random() - 0.5) * PARTICLE_SPEED;
rs[i] = Math.random() * PARTICLE_RADIUS_RANGE + PARTICLE_MIN_RADIUS;
}
ensureTierCapacity(count);
};
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
width = canvas.clientWidth;
height = canvas.clientHeight;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
initParticles();
};
const scheduleResize = () => {
if (resizeFrameId) return;
resizeFrameId = requestAnimationFrame(() => {
resizeFrameId = 0;
resize();
});
};
const onMove = (e: MouseEvent) => {
mouse.x = e.clientX;
mouse.y = e.clientY;
mouse.active = true;
};
const onLeave = () => {
mouse.active = false;
mouse.x = -9999;
mouse.y = -9999;
};
const drawFrame = () => {
ctx.clearRect(0, 0, width, height);
for (let i = 0; i < count; i++) {
let x = xs[i] + vxs[i];
let y = ys[i] + vys[i];
if (x < 0) {
x = 0;
vxs[i] = -vxs[i];
} else if (x > width) {
x = width;
vxs[i] = -vxs[i];
}
if (y < 0) {
y = 0;
vys[i] = -vys[i];
} else if (y > height) {
y = height;
vys[i] = -vys[i];
}
if (mouse.active) {
const dx = x - mouse.x;
const dy = y - mouse.y;
const dsq = dx * dx + dy * dy;
if (dsq < MOUSE_RADIUS_SQ && dsq > 0) {
const dist = Math.sqrt(dsq);
const force = (MOUSE_RADIUS - dist) / MOUSE_RADIUS;
const inv = 1 / dist;
x += dx * inv * force * MOUSE_FORCE;
y += dy * inv * force * MOUSE_FORCE;
}
}
xs[i] = x;
ys[i] = y;
}
for (let t = 0; t < ALPHA_TIER_COUNT; t++) tierLengths[t] = 0;
for (let i = 0; i < count; i++) {
const xi = xs[i];
const yi = ys[i];
for (let j = i + 1; j < count; j++) {
const dx = xi - xs[j];
const dy = yi - ys[j];
const dsq = dx * dx + dy * dy;
if (dsq >= CONNECTION_DISTANCE_SQ) continue;
const ratio = 1 - Math.sqrt(dsq) / CONNECTION_DISTANCE;
let tier = (ratio * ALPHA_TIER_COUNT) | 0;
if (tier >= ALPHA_TIER_COUNT) tier = ALPHA_TIER_COUNT - 1;
const bucket = tierBuckets[tier];
const off = tierLengths[tier];
bucket[off] = xi;
bucket[off + 1] = yi;
bucket[off + 2] = xs[j];
bucket[off + 3] = ys[j];
tierLengths[tier] = off + 4;
}
}
ctx.lineWidth = LINE_WIDTH;
ctx.strokeStyle = accentColor;
for (let t = 0; t < ALPHA_TIER_COUNT; t++) {
const len = tierLengths[t];
if (len === 0) continue;
ctx.globalAlpha = CONNECTION_MAX_ALPHA * ((t + 0.5) / ALPHA_TIER_COUNT);
ctx.beginPath();
const bucket = tierBuckets[t];
for (let k = 0; k < len; k += 4) {
ctx.moveTo(bucket[k], bucket[k + 1]);
ctx.lineTo(bucket[k + 2], bucket[k + 3]);
}
ctx.stroke();
}
ctx.fillStyle = accentColor;
ctx.globalAlpha = PARTICLE_ALPHA;
ctx.beginPath();
for (let i = 0; i < count; i++) {
ctx.moveTo(xs[i] + rs[i], ys[i]);
ctx.arc(xs[i], ys[i], rs[i], 0, Math.PI * 2);
}
ctx.fill();
ctx.globalAlpha = 1;
};
const loop = () => {
drawFrame();
animationId = requestAnimationFrame(loop);
};
const start = () => {
if (animationId) return;
animationId = requestAnimationFrame(loop);
};
const stop = () => {
if (!animationId) return;
cancelAnimationFrame(animationId);
animationId = 0;
};
const onVisibilityChange = () => {
if (document.hidden) stop();
else if (!reducedMotion.matches) start();
};
const onMotionChange = () => {
if (reducedMotion.matches) {
stop();
drawFrame();
} else {
start();
}
};
const themeObserver = new MutationObserver(readAccent);
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
readAccent();
resize();
if (reducedMotion.matches) drawFrame();
else start();
window.addEventListener("resize", scheduleResize, { signal });
window.addEventListener("mousemove", onMove, { signal, passive: true });
window.addEventListener("mouseleave", onLeave, { signal });
document.addEventListener("visibilitychange", onVisibilityChange, {
signal,
});
reducedMotion.addEventListener("change", onMotionChange, { signal });
return () => {
stop();
if (resizeFrameId) cancelAnimationFrame(resizeFrameId);
themeObserver.disconnect();
ac.abort();
};
}, []);
return (
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full"
/>
);
};
+15
View File
@@ -0,0 +1,15 @@
import { ReactNode } from "react";
import { cn } from "@/lib";
interface AuthCardProps {
className?: string;
children: ReactNode;
}
const CARD_BASE =
"rounded-large border-divider shadow-small dark:bg-background/80 relative flex w-full flex-col border bg-white/85 backdrop-blur-xl";
export const AuthCard = ({ className, children }: AuthCardProps) => (
<div className={cn(CARD_BASE, className)}>{children}</div>
);
+26 -27
View File
@@ -1,5 +1,8 @@
import { ReactNode } from "react";
import { AnimatedDotsBackground } from "@/components/auth/oss/animated-dots-background";
import { AuthCard } from "@/components/auth/oss/auth-card";
import { AuthReleaseHighlights } from "@/components/auth/oss/auth-release-highlights";
import { ProwlerExtended } from "@/components/icons";
import { ThemeSwitch } from "@/components/ThemeSwitch";
@@ -8,36 +11,32 @@ interface AuthLayoutProps {
children: ReactNode;
}
export const AuthLayout = ({ title, children }: AuthLayoutProps) => {
return (
<div className="relative flex min-h-screen w-full overflow-x-hidden overflow-y-auto">
<div className="relative flex w-full flex-col items-center justify-center px-4 py-32">
{/* Background Pattern */}
<div
className="absolute inset-0 mask-[radial-gradient(ellipse_50%_50%_at_50%_50%,#000_10%,transparent_80%)] bg-size-[16px_16px]"
style={{
backgroundImage:
"radial-gradient(var(--bg-button-primary) 1px, transparent 1px)",
}}
></div>
export const AuthLayout = ({ title, children }: AuthLayoutProps) => (
<main className="relative min-h-screen w-full overflow-hidden">
<AnimatedDotsBackground />
{/* Prowler Logo */}
<div className="relative z-10 mb-8 flex w-full max-w-[300px]">
<ProwlerExtended width={300} className="h-auto w-full" />
</div>
{/* Auth Form Container */}
<div className="rounded-large border-divider shadow-small dark:bg-background/85 relative z-10 flex w-full max-w-sm flex-col gap-4 border bg-white/90 px-8 py-10 md:max-w-md">
{/* Header with Title and Theme Toggle */}
<div className="flex items-center justify-between">
<p className="pb-2 text-xl font-medium">{title}</p>
<ThemeSwitch aria-label="Toggle theme" />
<div className="relative z-10 grid min-h-screen grid-cols-1 lg:grid-cols-2">
<div className="flex items-center justify-center px-6 py-10 sm:px-10">
<div className="flex w-full max-w-sm flex-col">
<div className="mb-6 flex w-full justify-center">
<ProwlerExtended
width={300}
className="h-auto w-[220px] max-w-full"
/>
</div>
{/* Content */}
{children}
<AuthCard className="gap-3 px-6 py-8">
<div className="flex items-center justify-between">
<p className="pb-1 text-lg font-medium">{title}</p>
<ThemeSwitch aria-label="Toggle theme" />
</div>
{children}
</AuthCard>
</div>
</div>
<AuthReleaseHighlights />
</div>
);
};
</main>
);
@@ -0,0 +1,75 @@
import { Icon } from "@iconify/react";
import { AuthCard } from "@/components/auth/oss/auth-card";
import { ProwlerShort } from "@/components/icons";
import { Button } from "@/components/shadcn";
const RELEASE = {
version: "5.24.0",
url: "https://github.com/prowler-cloud/prowler/releases/tag/5.24.0",
repoUrl: "https://github.com/prowler-cloud/prowler",
} as const;
const HIGHLIGHTS: readonly string[] = [
"Redesigned resources side drawer",
"New AWS Bedrock & IAM hardening checks",
"New Microsoft 365 Conditional Access coverage",
];
export const AuthReleaseHighlights = () => (
<aside
aria-label={`Prowler v${RELEASE.version} highlights`}
className="hidden items-center justify-center px-6 py-10 sm:px-10 lg:flex"
>
<div className="w-full max-w-md">
<AuthCard className="gap-6 px-7 py-8">
<div
aria-hidden="true"
className="absolute top-6 right-6 flex h-9 w-9 items-center justify-center rounded-md bg-emerald-400/15"
>
<ProwlerShort width={18} className="text-emerald-400" />
</div>
<div className="pr-14">
<p className="text-xs font-semibold text-emerald-400">
Prowler v{RELEASE.version}
</p>
<h2 className="mt-1 text-2xl leading-tight font-semibold">
Fresh off the branch
</h2>
<p className="text-default-500 mt-2 text-sm">
A quick look at what we just shipped in v{RELEASE.version}.
</p>
</div>
<ul className="flex flex-col gap-3">
{HIGHLIGHTS.map((label) => (
<li key={label} className="flex items-start gap-3">
<Icon
aria-hidden="true"
icon="mdi:check-circle"
width={20}
className="mt-0.5 shrink-0 text-emerald-400"
/>
<span className="text-sm">{label}</span>
</li>
))}
</ul>
<div className="flex items-center justify-center gap-3">
<Button asChild size="sm">
<a href={RELEASE.url} target="_blank" rel="noopener noreferrer">
See full release notes
</a>
</Button>
<Button asChild variant="outline" size="sm">
<a href={RELEASE.repoUrl} target="_blank" rel="noopener noreferrer">
<Icon aria-hidden="true" icon="mdi:github" width={16} />
GitHub
</a>
</Button>
</div>
</AuthCard>
</div>
</aside>
);