feat: implement WebGL shader background, add UI components, and configure project deployment environment

This commit is contained in:
2026-05-25 19:01:50 +07:00 Unverified
parent 89fb9a9b2b
commit c6a3f231c1
22 changed files with 1358 additions and 90 deletions
+191
View File
@@ -0,0 +1,191 @@
// components/ShaderBackground.tsx
/** biome-ignore-all lint/style/noNonNullAssertion: why */
"use client";
import { useEffect, useRef, useState } from "react";
export default function ShaderBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (!canvasRef.current) return;
const canvas = canvasRef.current;
const gl = canvas.getContext("webgl");
if (!gl) return;
const vertexShaderSrc = `
attribute vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`;
const fragmentShaderSrc = `
precision mediump float;
uniform vec2 iResolution;
uniform float iTime;
float distLine(vec2 p, vec2 a, vec2 b) {
vec2 ap = p - a;
vec2 ab = b - a;
float aDotB = clamp(dot(ap, ab) / dot(ab, ab), 0.0, 1.0);
return length(ap - ab * aDotB);
}
float drawLine(vec2 uv, vec2 a, vec2 b) {
float line = smoothstep(0.014, 0.01, distLine(uv, a, b));
float dist = length(b-a);
return line * (smoothstep(1.3, 0.8, dist) * 0.5 + smoothstep(0.04, 0.03, abs(dist - 0.75)));
}
float n21(vec2 i) {
i += fract(i * vec2(223.64, 823.12));
i += dot(i, i + 23.14);
return fract(i.x * i.y);
}
vec2 n22(vec2 i) {
float x = n21(i);
return vec2(x, n21(i+x));
}
vec2 getPoint (vec2 id, vec2 offset) {
return offset + sin(n22(id + offset) * iTime * 1.0) * 0.4;
}
float layer (vec2 uv) {
float m = 0.0;
float t = iTime * 2.0;
vec2 gv = fract(uv) - 0.5;
vec2 id = floor(uv) - 0.5;
vec2 p[9];
// Hardcoded population to ensure compatibility with all WebGL 1.0 implementations
p[0] = getPoint(id, vec2(-1.0, -1.0));
p[1] = getPoint(id, vec2(0.0, -1.0));
p[2] = getPoint(id, vec2(1.0, -1.0));
p[3] = getPoint(id, vec2(-1.0, 0.0));
p[4] = getPoint(id, vec2(0.0, 0.0));
p[5] = getPoint(id, vec2(1.0, 0.0));
p[6] = getPoint(id, vec2(-1.0, 1.0));
p[7] = getPoint(id, vec2(0.0, 1.0));
p[8] = getPoint(id, vec2(1.0, 1.0));
for (int i = 0; i < 9; i++) {
m += drawLine(gv, p[4], p[i]);
float sparkle = 1.0 / pow(length(gv - p[i]), 1.5) * 0.005;
m += sparkle * (sin(t + fract(p[i].x) * 12.23) * 0.4 + 0.6);
}
m += drawLine(gv, p[1], p[3]);
m += drawLine(gv, p[1], p[5]);
m += drawLine(gv, p[7], p[3]);
m += drawLine(gv, p[7], p[5]);
return m;
}
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * iResolution.xy) / iResolution.y;
vec3 c = sin(iTime * 2.0 * vec3(.234, .324, .768)) * 0.4 + 0.6;
vec3 col = vec3(0.0);
// FFT mocked as a constant since we don't have audio input
float fft = 0.7;
c.x += (uv.x + 0.5);
col += pow(-uv.y + 0.5, 5.0) * fft * c;
float m = 0.0;
float x = sin(iTime * 0.1);
float y = cos(iTime * 0.2);
mat2 rotMat = mat2(x, y, -y, x);
uv *= rotMat;
for (float i = 0.0; i <= 1.0; i += 0.25) {
float z = fract(i + iTime * 0.05);
float size = mix(15.0, 0.1, z) * 1.50;
float fade = smoothstep(0.0, 1.0, z) * smoothstep(1.0, 0.9, z);
m += layer((size * uv) + i * 10.0) * fade;
}
col += m * c;
gl_FragColor = vec4(col, 1.0);
}
`;
function compileShader(type: number, source: string) {
// for gods sake typescript
if (!gl) throw "GL not found";
const shader = gl.createShader(type)!;
gl!.shaderSource(shader, source);
gl!.compileShader(shader);
if (!gl!.getShaderParameter(shader, gl!.COMPILE_STATUS)) {
console.error(gl!.getShaderInfoLog(shader));
}
return shader;
}
const vs = compileShader(gl.VERTEX_SHADER, vertexShaderSrc);
const fs = compileShader(gl.FRAGMENT_SHADER, fragmentShaderSrc);
const program = gl.createProgram()!;
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
// biome-ignore lint/correctness/useHookAtTopLevel: false, not a hook
gl.useProgram(program);
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
gl.STATIC_DRAW,
);
const positionLoc = gl.getAttribLocation(program, "aPosition");
gl.enableVertexAttribArray(positionLoc);
gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);
const iResolution = gl.getUniformLocation(program, "iResolution");
const iTime = gl.getUniformLocation(program, "iTime");
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
gl!.viewport(0, 0, canvas.width, canvas.height);
}
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
const start = performance.now();
let animationFrameId: number;
function render() {
const time = (performance.now() - start) * 0.001;
gl!.uniform2f(iResolution, canvas.width, canvas.height);
gl!.uniform1f(iTime, time);
gl!.drawArrays(gl!.TRIANGLES, 0, 6);
animationFrameId = requestAnimationFrame(render);
}
render();
setLoaded(true);
return () => {
window.removeEventListener("resize", resizeCanvas);
if (animationFrameId) cancelAnimationFrame(animationFrameId);
};
}, []);
return (
<canvas
ref={canvasRef}
className={`fixed top-0 left-0 w-full h-full -z-10 ${loaded ? "opacity-30" : "opacity-0"} transition-opacity duration-3000`}
/>
);
}
+65
View File
@@ -0,0 +1,65 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-4xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-9",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+100
View File
@@ -0,0 +1,100 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-6 overflow-hidden rounded-2xl bg-card py-6 text-sm text-card-foreground ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-2 rounded-t-xl px-6 group-data-[size=sm]/card:px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("font-heading text-base font-medium", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6 group-data-[size=sm]/card:px-4", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}