Merge branch 'main' into narl.io

This commit is contained in:
nvrl committed 2026-02-18 08:17:23 +01:00
commit a94e364b1e
2 files changed
+210 -9

No files matched your search

@@ -0,0 +1,200 @@
import React, { useRef, useEffect } from 'react';
import { alpha, useTheme } from '@mui/material';
interface GenerativeBannerProps {
username: string;
palette: string[];
ratingsCount: number;
followersCount: number;
}
const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette, ratingsCount, followersCount }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const getSeed = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let animationFrameId: number;
const seed = getSeed(username);
const nodeCount = Math.min(12 + Math.floor(ratingsCount / 4), 30);
const speedMultiplier = 0.15 + (Math.min(followersCount, 50) / 120);
const colors = palette.length > 0 ? palette : [theme.palette.primary.main, theme.palette.secondary.main];
interface Node {
x: number;
y: number;
vx: number;
vy: number;
color: string;
size: number;
}
const nodes: Node[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
const resize = () => {
const parent = canvas.parentElement;
if (parent) {
canvas.width = parent.clientWidth;
canvas.height = parent.clientHeight;
}
};
window.addEventListener('resize', resize);
resize();
for (let i = 0; i < nodeCount; i++) {
nodes.push({
x: pseudoRandom(i) * canvas.width,
y: pseudoRandom(i + nodeCount) * canvas.height,
vx: (pseudoRandom(i + 2) - 0.5) * speedMultiplier,
vy: (pseudoRandom(i + 3) - 0.5) * speedMultiplier,
color: colors[i % colors.length],
size: 1 + pseudoRandom(i + 4) * 3
});
}
let mouse = { x: -1000, y: -1000, down: false };
const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
};
const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length > 0) {
const rect = canvas.getBoundingClientRect();
mouse.x = e.touches[0].clientX - rect.left;
mouse.y = e.touches[0].clientY - rect.top;
}
};
const handleMouseDown = () => mouse.down = true;
const handleMouseUp = () => mouse.down = false;
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('touchmove', handleTouchMove);
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('mouseup', handleMouseUp);
canvas.addEventListener('touchstart', (e) => { mouse.down = true; handleTouchMove(e); });
canvas.addEventListener('touchend', handleMouseUp);
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
nodes.forEach((node) => {
node.x += node.vx;
node.y += node.vy;
if (node.x < 0 || node.x > canvas.width) node.vx *= -1;
if (node.y < 0 || node.y > canvas.height) node.vy *= -1;
const dx = mouse.x - node.x;
const dy = mouse.y - node.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const maxDist = mouse.down ? 400 : 200;
if (dist < maxDist) {
const force = (maxDist - dist) / maxDist;
const strength = mouse.down ? 0.2 : 0.08;
// Pull when down, repel when hovering
if (mouse.down) {
node.x += dx * force * strength;
node.y += dy * force * strength;
} else {
node.x -= dx * force * strength;
node.y -= dy * force * strength;
}
}
});
// Draw connections and triangles
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const ddist = Math.sqrt(Math.pow(nodes[i].x - nodes[j].x, 2) + Math.pow(nodes[i].y - nodes[j].y, 2));
if (ddist < 150) {
// Lines
ctx.beginPath();
ctx.moveTo(nodes[i].x, nodes[i].y);
ctx.lineTo(nodes[j].x, nodes[j].y);
ctx.strokeStyle = alpha(nodes[i].color, (1 - ddist / 150) * 0.15);
ctx.stroke();
// Search for a third point to make a triangle
for (let k = j + 1; k < nodes.length; k++) {
const ddist2 = Math.sqrt(Math.pow(nodes[j].x - nodes[k].x, 2) + Math.pow(nodes[j].y - nodes[k].y, 2));
const ddist3 = Math.sqrt(Math.pow(nodes[i].x - nodes[k].x, 2) + Math.pow(nodes[i].y - nodes[k].y, 2));
if (ddist2 < 150 && ddist3 < 150) {
ctx.beginPath();
ctx.moveTo(nodes[i].x, nodes[i].y);
ctx.lineTo(nodes[j].x, nodes[j].y);
ctx.lineTo(nodes[k].x, nodes[k].y);
ctx.closePath();
ctx.fillStyle = alpha(nodes[i].color, 0.03);
ctx.fill();
}
}
}
}
}
nodes.forEach(node => {
ctx.beginPath();
ctx.arc(node.x, node.y, node.size, 0, Math.PI * 2);
ctx.fillStyle = alpha(node.color, 0.4);
ctx.fill();
});
animationFrameId = requestAnimationFrame(draw);
};
draw();
return () => {
window.removeEventListener('resize', resize);
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('touchmove', handleTouchMove);
canvas.removeEventListener('mousedown', handleMouseDown);
canvas.removeEventListener('mouseup', handleMouseUp);
canvas.removeEventListener('touchend', handleMouseUp);
cancelAnimationFrame(animationFrameId);
};
}, [username, palette, ratingsCount, followersCount, theme]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'auto'
}}
/>
);
};
export default GenerativeBanner;
+10 -9
View File
@@ -34,6 +34,7 @@ import { useTitle } from '../hooks/useTitle';
import { useTranslation } from 'react-i18next';
import RatingBadge from '../components/RatingBadge';
import RichText from '../components/RichText';
import GenerativeBanner from '../components/GenerativeBanner';
import { formatDate } from '../utils/date';
interface Rating {
@@ -272,15 +273,15 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
height: { xs: 120, sm: 180 },
background: abstractBanner,
position: 'relative',
'&::after': {
content: '""',
position: 'absolute',
top: 0, left: 0, right: 0, bottom: 0,
backgroundImage: 'radial-gradient(circle at 2px 2px, rgba(255,255,255,0.1) 1px, transparent 0)',
backgroundSize: '32px 32px',
opacity: 0.5
}
}} />
overflow: 'hidden'
}}>
<GenerativeBanner
username={data.username}
palette={palette}
ratingsCount={data.ratings.length}
followersCount={data.followers_count}
/>
</Box>
<CardContent sx={{ pt: 0, px: { xs: 2, md: 5 }, pb: { xs: 2, md: 4 } }}>
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, alignItems: { xs: 'center', sm: 'flex-end' }, gap: { xs: 2, sm: 4 } }}>