package updates, minor bug fixes, stack items, background animation fixes

This commit is contained in:
2026-06-06 12:15:01 +02:00
parent 65b9184a22
commit f5e8b87846
21 changed files with 775 additions and 359 deletions

View File

@@ -1,7 +1,8 @@
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useTheme } from "next-themes";
import type React from "react";
import { useCallback, useEffect, useRef } from "react";
const PALETTES = {
dark: {
@@ -37,6 +38,12 @@ interface Particle {
wobblePhase: number;
}
interface CanvasSize {
dpr: number;
height: number;
width: number;
}
interface AnimatedBackgroundContainerProps {
children: React.ReactNode;
className?: string;
@@ -48,6 +55,9 @@ interface AnimatedBackgroundContainerProps {
const DEFAULT_PARTICLE_COLORS: readonly string[] = PALETTES.dark.particles;
const PARTICLE_COLOR_COUNT = DEFAULT_PARTICLE_COLORS.length;
const EDGE_FADE_DISTANCE = 80;
const MAX_DEVICE_PIXEL_RATIO = 2;
const MOBILE_TARGET_DISTANCE = 30;
const rand = (min: number, max: number) => Math.random() * (max - min) + min;
@@ -87,15 +97,18 @@ export default function AnimatedBackgroundContainer({
const frameRef = useRef(0);
const animationFrameRef = useRef<number | null>(null);
const isMobileRef = useRef(false);
const isVisibleRef = useRef(true);
const prefersReducedMotionRef = useRef(false);
const particlesRef = useRef<Particle[]>([]);
const mousePosRef = useRef({ x: 0, y: 0 });
const smoothMouseRef = useRef({ x: 0, y: 0 });
const mobileAnchorRef = useRef({ x: 0, y: 0 });
const mobileTargetRef = useRef({ x: 0, y: 0 });
const canvasSizeRef = useRef<CanvasSize>({ dpr: 1, height: 0, width: 0 });
const containerRectRef = useRef<DOMRect | null>(null);
const followSpeedRef = useRef(followSpeed);
const mobileSpeedRef = useRef(mobileSpeed);
const particleColorsRef = useRef<readonly string[]>(DEFAULT_PARTICLE_COLORS);
const [mounted, setMounted] = useState(false);
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === undefined || resolvedTheme === "dark";
@@ -120,13 +133,13 @@ export default function AnimatedBackgroundContainer({
}, [particleCount, orbitRadius]);
const seedPositions = useCallback(() => {
const container = containerRef.current;
if (!container) {
const { height, width } = canvasSizeRef.current;
if (width === 0 || height === 0) {
return;
}
const centerX = container.clientWidth / 2;
const centerY = container.clientHeight / 2;
const centerX = width / 2;
const centerY = height / 2;
mousePosRef.current = { x: centerX, y: centerY };
smoothMouseRef.current = { x: centerX, y: centerY };
@@ -137,6 +150,13 @@ export default function AnimatedBackgroundContainer({
};
}, []);
const updateContainerRect = useCallback(() => {
const container = containerRef.current;
if (container) {
containerRectRef.current = container.getBoundingClientRect();
}
}, []);
const resizeCanvas = useCallback(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
@@ -146,10 +166,19 @@ export default function AnimatedBackgroundContainer({
const width = container.clientWidth;
const height = container.clientHeight;
const dpr = window.devicePixelRatio || 1;
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DEVICE_PIXEL_RATIO);
const nextWidth = Math.round(width * dpr);
const nextHeight = Math.round(height * dpr);
canvas.width = width * dpr;
canvas.height = height * dpr;
canvasSizeRef.current = { dpr, height, width };
updateContainerRect();
if (canvas.width !== nextWidth) {
canvas.width = nextWidth;
}
if (canvas.height !== nextHeight) {
canvas.height = nextHeight;
}
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
@@ -160,14 +189,10 @@ export default function AnimatedBackgroundContainer({
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
if (!mounted) {
seedPositions();
}
}, [mounted, seedPositions]);
seedPositions();
}, [seedPositions, updateContainerRect]);
useEffect(() => {
setMounted(true);
isMobileRef.current = isMobileDevice();
resizeCanvas();
@@ -176,25 +201,35 @@ export default function AnimatedBackgroundContainer({
resizeCanvas();
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [resizeCanvas]);
const resizeObserver =
"ResizeObserver" in window
? new ResizeObserver(() => {
handleResize();
})
: null;
useEffect(() => {
if (!mounted) {
return;
if (containerRef.current && resizeObserver) {
resizeObserver.observe(containerRef.current);
}
seedPositions();
}, [mounted, seedPositions]);
window.addEventListener("resize", handleResize);
window.addEventListener("scroll", updateContainerRect, {
capture: true,
passive: true,
});
return () => {
resizeObserver?.disconnect();
window.removeEventListener("resize", handleResize);
window.removeEventListener("scroll", updateContainerRect, { capture: true });
};
}, [resizeCanvas, updateContainerRect]);
const handleMouseMove = useCallback((event: MouseEvent) => {
const container = containerRef.current;
if (!container || isMobileRef.current) {
const rect = containerRectRef.current;
if (!rect || isMobileRef.current) {
return;
}
const rect = container.getBoundingClientRect();
mousePosRef.current = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
@@ -212,13 +247,8 @@ export default function AnimatedBackgroundContainer({
}, [handleMouseMove]);
useEffect(() => {
if (!mounted) {
return;
}
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) {
if (!canvas) {
return;
}
@@ -227,9 +257,25 @@ export default function AnimatedBackgroundContainer({
return;
}
const stopAnimation = () => {
if (animationFrameRef.current !== null) {
window.cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
const draw = () => {
const width = container.clientWidth;
const height = container.clientHeight;
if (!isVisibleRef.current || prefersReducedMotionRef.current) {
animationFrameRef.current = null;
return;
}
const { height, width } = canvasSizeRef.current;
if (width === 0 || height === 0) {
animationFrameRef.current = window.requestAnimationFrame(draw);
return;
}
frameRef.current += 1;
if (isMobileRef.current) {
@@ -241,14 +287,15 @@ export default function AnimatedBackgroundContainer({
const dx = mobileTargetRef.current.x - mobileAnchorRef.current.x;
const dy = mobileTargetRef.current.y - mobileAnchorRef.current.y;
if (Math.hypot(dx, dy) < 30) {
if (Math.hypot(dx, dy) < MOBILE_TARGET_DISTANCE) {
mobileTargetRef.current = {
x: rand(width * 0.15, width * 0.85),
y: rand(height * 0.15, height * 0.85),
};
}
smoothMouseRef.current = { ...mobileAnchorRef.current };
smoothMouseRef.current.x = mobileAnchorRef.current.x;
smoothMouseRef.current.y = mobileAnchorRef.current.y;
} else {
const desktopLerp = followSpeedRef.current;
smoothMouseRef.current.x +=
@@ -275,7 +322,13 @@ export default function AnimatedBackgroundContainer({
const edgeFade = Math.max(
0,
Math.min(x / 80, (width - x) / 80, y / 80, (height - y) / 80, 1),
Math.min(
x / EDGE_FADE_DISTANCE,
(width - x) / EDGE_FADE_DISTANCE,
y / EDGE_FADE_DISTANCE,
(height - y) / EDGE_FADE_DISTANCE,
1,
),
);
if (edgeFade <= 0) {
@@ -293,15 +346,51 @@ export default function AnimatedBackgroundContainer({
animationFrameRef.current = window.requestAnimationFrame(draw);
};
animationFrameRef.current = window.requestAnimationFrame(draw);
return () => {
if (animationFrameRef.current !== null) {
window.cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
const startAnimation = () => {
if (
animationFrameRef.current === null &&
isVisibleRef.current &&
!prefersReducedMotionRef.current
) {
animationFrameRef.current = window.requestAnimationFrame(draw);
}
};
}, [mounted]);
const handleVisibilityChange = () => {
isVisibleRef.current = document.visibilityState === "visible";
if (isVisibleRef.current) {
startAnimation();
} else {
stopAnimation();
}
};
const motionMedia = window.matchMedia("(prefers-reduced-motion: reduce)");
const handleMotionChange = () => {
prefersReducedMotionRef.current = motionMedia.matches;
if (prefersReducedMotionRef.current) {
stopAnimation();
ctx.clearRect(0, 0, canvasSizeRef.current.width, canvasSizeRef.current.height);
} else {
startAnimation();
}
};
isVisibleRef.current = document.visibilityState === "visible";
prefersReducedMotionRef.current = motionMedia.matches;
startAnimation();
document.addEventListener("visibilitychange", handleVisibilityChange);
motionMedia.addEventListener("change", handleMotionChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
motionMedia.removeEventListener("change", handleMotionChange);
stopAnimation();
};
}, []);
return (
<div

View File

@@ -0,0 +1,53 @@
import type { ComponentProps } from "react";
import type { Control, FieldValues, Path } from "react-hook-form";
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
import { Input } from "~/components/ui/input";
type IntInputFormFieldProps<T extends FieldValues> = Omit<
ComponentProps<typeof Input>,
"defaultValue" | "name" | "onChange" | "type" | "value"
> & {
control: Control<T>;
emptyValue?: null | undefined;
label: string;
name: Path<T>;
};
export default function IntInputFormField<T extends FieldValues>({
control,
emptyValue,
label,
name,
...inputProps
}: IntInputFormFieldProps<T>) {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>{label}</FormLabel>
<FormControl>
<Input
{...inputProps}
inputMode="numeric"
onBlur={field.onBlur}
onChange={(event) => {
const value = event.currentTarget.value;
field.onChange(
value === "" ? emptyValue : Number.parseInt(value, 10),
);
}}
placeholder={inputProps.placeholder ?? name}
ref={field.ref}
step={inputProps.step ?? 1}
type="number"
value={field.value ?? ""}
/>
</FormControl>
</FormItem>
)}
/>
);
}

View File

@@ -1,6 +1,7 @@
import type { CheckedState } from "@radix-ui/react-checkbox";
import { ChevronDownIcon } from "lucide-react";
import { createContext,useContext, useState } from "react";
import { ScrollArea } from "~/components/ui/scroll-area";
import { createContext,useContext, useState, type KeyboardEventHandler } from "react";
import { useFormContext, type Control, type ControllerRenderProps, type FieldValues, type Path } from "react-hook-form";
import type { Entries } from "type-fest";
import { Button } from "~/components/ui/button";
@@ -16,6 +17,7 @@ const MultiBooleanFieldContext = createContext<MultiBooleanFieldContextProps|und
function InnerMultiBooleanFormField(params: { options: string[], onChange: (arg0: string[]) => void }) {
const context = useContext(MultiBooleanFieldContext)
const [searchBuffer, setSearchBuffer] = useState<string>("")
if (context === undefined) {
return (<></>)
}
@@ -43,19 +45,40 @@ function InnerMultiBooleanFormField(params: { options: string[], onChange: (arg0
}
return context.checkedValues[key]
}
const handleKeyDown:KeyboardEventHandler = (e) => {
if (e.ctrlKey && e.key == "c") {
setSearchBuffer("")
} else if (e.code == "Backspace") {
setSearchBuffer((prev) => {
const newVal = prev.substring(0,prev.length - 2)
console.log(newVal)
return newVal
})
}
else if (e.key.length === 1) {
setSearchBuffer((prev) => {
const newVal = prev + e.key;
console.log(newVal)
return newVal;
})
}
}
return (
<>
<ScrollArea onKeyDown={handleKeyDown} className="flex h-60">
{
params.options.map((opt) => (
<FormItem key={opt}>
<div className="flex flex-row justify-between py-2 border-b-1">
opt.startsWith(searchBuffer) && <FormItem key={opt}>
<div className="flex flex-row justify-between py-2 border-b">
<FormLabel>{opt}</FormLabel>
<Checkbox data-testid="multiboolean-checkbox" checked={checked(opt)} onCheckedChange={onCheckedItemChange(opt)} />
</div>
</FormItem>
))
}
</>
</ScrollArea>
)
}
export default function MultiBooleanFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string, options: string[], defaultValues?: string[] }) {
@@ -76,9 +99,9 @@ export default function MultiBooleanFormField<T extends FieldValues>(params: { c
</PopoverTrigger>
<FormControl>
<PopoverContent data-testid="multiboolean-content">
<MultiBooleanFieldContext.Provider value={{checkedValues: checkedValues, setCheckedValue: setCheckedValues}}>
<InnerMultiBooleanFormField onChange={field.onChange} options={params.options} />
</MultiBooleanFieldContext.Provider>
<MultiBooleanFieldContext.Provider value={{checkedValues: checkedValues, setCheckedValue: setCheckedValues}}>
<InnerMultiBooleanFormField onChange={field.onChange} options={params.options} />
</MultiBooleanFieldContext.Provider>
</PopoverContent>
</FormControl>
</Popover>

View File

@@ -1,5 +1,6 @@
export { default as BooleanFormField } from './BooleanFormField'
export { default as TextInputFormField } from './TextInputFormField'
export { default as IntInputFormField } from './IntInputFormField'
export { default as MultiBooleanFormField } from './MultiBooleanFormField'
export { default as SelectFormField } from './SelectFormField'
export { default as MdeFormField } from './MdeFormField'