Merge branch 'main' into narl.io

This commit is contained in:
nvrl committed 2026-02-18 08:25:05 +01:00
commit 3ac2c203e1
2 files changed
+201 -68

No files matched your search

+81 -45
View File
@@ -30,10 +30,12 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
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);
// Dynamic count and speed
const nodeCount = Math.min(20 + Math.floor(ratingsCount / 2), 50);
const speedMultiplier = 0.25 + (Math.min(followersCount, 50) / 80);
const colors = palette.length > 0 ? palette : [theme.palette.primary.main, theme.palette.secondary.main];
// Prefer vibrant colors
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
interface Node {
x: number;
@@ -44,35 +46,50 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
size: number;
}
const nodes: Node[] = [];
let nodes: Node[] = [];
const pseudoRandom = (offset: number) => {
const x = Math.sin(seed + offset) * 10000;
return x - Math.floor(x);
};
const initNodes = (width: number, height: number) => {
const newNodes = [];
for (let i = 0; i < nodeCount; i++) {
newNodes.push({
x: pseudoRandom(i) * width,
y: pseudoRandom(i + 100) * height,
vx: (pseudoRandom(i + 200) - 0.5) * speedMultiplier,
vy: (pseudoRandom(i + 300) - 0.5) * speedMultiplier,
color: colors[i % colors.length],
size: 2 + pseudoRandom(i + 400) * 5
});
}
return newNodes;
};
const resize = () => {
const parent = canvas.parentElement;
if (parent) {
canvas.width = parent.clientWidth;
canvas.height = parent.clientHeight;
const rect = parent.getBoundingClientRect();
// Scale for high DPI
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
// Adjust CSS display size
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
nodes = initNodes(rect.width, rect.height);
}
};
window.addEventListener('resize', resize);
const resizeObserver = new ResizeObserver(() => resize());
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
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();
@@ -91,22 +108,30 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
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);
// Attach to window for better tracking
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('touchmove', handleTouchMove);
window.addEventListener('mousedown', handleMouseDown);
window.addEventListener('mouseup', handleMouseUp);
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Get CSS dimensions for drawing
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
animationFrameId = requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, width, 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;
if (node.x < 0 || node.x > width) node.vx *= -1;
if (node.y < 0 || node.y > height) node.vy *= -1;
const dx = mouse.x - node.x;
const dy = mouse.y - node.y;
@@ -116,7 +141,6 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
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;
@@ -127,31 +151,30 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
}
});
// Draw connections and triangles
// Draw mesh connections
ctx.lineWidth = 1.5;
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
if (ddist < 160) {
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.strokeStyle = alpha(nodes[i].color, (1 - ddist / 160) * 0.3);
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) {
if (ddist2 < 160 && ddist3 < 160) {
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.fillStyle = alpha(nodes[i].color, 0.06);
ctx.fill();
}
}
@@ -159,10 +182,24 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
}
}
// Draw nodes with high-visibility glow
nodes.forEach(node => {
// Core
ctx.beginPath();
ctx.arc(node.x, node.y, node.size, 0, Math.PI * 2);
ctx.fillStyle = alpha(node.color, 0.4);
ctx.fillStyle = alpha(node.color, 0.8);
ctx.fill();
// Inner Glow
ctx.beginPath();
ctx.arc(node.x, node.y, node.size * 2, 0, Math.PI * 2);
ctx.fillStyle = alpha(node.color, 0.2);
ctx.fill();
// Outer Atmosphere
ctx.beginPath();
ctx.arc(node.x, node.y, node.size * 4, 0, Math.PI * 2);
ctx.fillStyle = alpha(node.color, 0.05);
ctx.fill();
});
@@ -172,12 +209,11 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
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);
resizeObserver.disconnect();
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('mousedown', handleMouseDown);
window.removeEventListener('mouseup', handleMouseUp);
cancelAnimationFrame(animationFrameId);
};
}, [username, palette, ratingsCount, followersCount, theme]);
@@ -189,9 +225,9 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette,
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'auto'
pointerEvents: 'none',
zIndex: 10,
mixBlendMode: 'plus-lighter' // Makes it pop against dark backgrounds
}}
/>
);
+120 -23
View File
@@ -10,6 +10,7 @@ import {
Avatar,
Tabs,
Tab,
Paper,
Grid,
alpha,
useTheme,
@@ -26,6 +27,7 @@ import ShareIcon from '@mui/icons-material/Share';
import CommentIcon from '@mui/icons-material/Comment';
import VerifiedIcon from '@mui/icons-material/Verified';
import SearchIcon from '@mui/icons-material/Search';
import ColorThief from 'colorthief';
import { useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import api from '../api';
@@ -33,6 +35,7 @@ import { useTitle } from '../hooks/useTitle';
import RatingBadge from '../components/RatingBadge';
import MentionTextField from '../components/MentionTextField';
import RichText from '../components/RichText';
import GenerativeBanner from '../components/GenerativeBanner';
import { formatDate } from '../utils/date';
interface DashboardData {
@@ -52,6 +55,7 @@ const Dashboard: React.FC = () => {
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState(0);
const [palette, setPalette] = useState<string[]>([]);
// Filtering states for Explore New
const [exploreCategory, setExploreCategory] = useState<string>('All');
@@ -99,6 +103,42 @@ const Dashboard: React.FC = () => {
fetchData();
}, []);
useEffect(() => {
if (data?.user?.avatar) {
const img = new Image();
img.crossOrigin = 'Anonymous';
img.src = data.user.avatar;
img.onload = () => {
const colorThief = new ColorThief();
try {
const rawPalette = colorThief.getPalette(img, 10);
const processed = rawPalette.map(c => {
const r = c[0] / 255, g = c[1] / 255, b = c[2] / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const l = (max + min) / 2;
const s = max === min ? 0 : (l > 0.5 ? (max - min) / (2 - max - min) : (max - min) / (max + min));
return { rgb: `rgb(${c[0]}, ${c[1]}, ${c[2]})`, l, s };
});
const vibrant = processed
.filter(c => c.l > 0.2 && c.l < 0.85)
.sort((a, b) => b.s - a.s);
if (vibrant.length >= 2) {
setPalette([vibrant[0].rgb, vibrant[1].rgb]);
} else if (vibrant.length === 1) {
setPalette([vibrant[0].rgb, vibrant[0].rgb]);
} else {
setPalette([]);
}
} catch (e) {
setPalette([]);
}
};
} else {
setPalette([]);
}
}, [data?.user?.avatar]);
// Update Review Logic
const handleUpdateRating = async (ratingId: number) => {
try {
@@ -210,39 +250,96 @@ const Dashboard: React.FC = () => {
{/* Header Profile Info */}
<Card elevation={0} sx={{
borderRadius: 4,
borderRadius: 5,
mb: 4,
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.6),
backdropFilter: 'blur(12px)',
backdropFilter: 'blur(20px)',
border: '1px solid',
borderColor: 'divider'
borderColor: 'divider',
overflow: 'hidden',
position: 'relative'
}}>
<CardContent sx={{ p: { xs: 2, sm: 4 } }}>
<Grid container spacing={3} alignItems="center">
<Grid size={{ xs: 12, sm: 'auto' }}>
<Avatar src={data.user.avatar || undefined} sx={{ width: 100, height: 100, border: '4px solid', borderColor: 'primary.main' }}>
<Box sx={{
height: { xs: 100, sm: 140 },
background: `
radial-gradient(at 0% 0%, ${alpha(palette[0] || theme.palette.primary.main, 0.8)} 0px, transparent 55%),
radial-gradient(at 100% 0%, ${alpha(palette[1] || theme.palette.secondary.main, 0.7)} 0px, transparent 55%),
radial-gradient(at 50% 100%, ${alpha(theme.palette.primary.main, 0.4)} 0px, transparent 55%),
linear-gradient(135deg, ${alpha(palette[0] || theme.palette.primary.main, 0.2)} 0%, ${alpha(palette[1] || theme.palette.secondary.main, 0.2)} 100%)
`,
position: 'relative',
overflow: 'hidden'
}}>
<GenerativeBanner
username={data.user.username}
palette={palette}
ratingsCount={data.rated_count}
followersCount={0}
/>
</Box>
<CardContent sx={{ pt: 0, px: { xs: 2, sm: 4 }, pb: 4 }}>
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, alignItems: { xs: 'center', sm: 'flex-end' }, gap: { xs: 2, sm: 4 } }}>
<Box sx={{
position: 'relative',
mt: { xs: -6, sm: -8 },
p: 0.5,
borderRadius: '50%',
background: `linear-gradient(135deg, ${palette[0] || theme.palette.primary.main}, ${theme.palette.secondary.main})`,
boxShadow: '0 12px 48px rgba(0,0,0,0.25)',
display: 'flex'
}}>
<Avatar
src={data.user.avatar || undefined}
sx={{
width: { xs: 100, sm: 120 },
height: { xs: 100, sm: 120 },
border: '4px solid',
borderColor: (theme) => theme.palette.background.paper,
fontSize: '3rem',
bgcolor: (theme) => theme.palette.background.paper,
color: palette[0] || 'primary.main',
}}
>
{!data.user.avatar && data.user.username.charAt(0).toUpperCase()}
</Avatar>
</Grid>
<Grid size={{ xs: 12, sm: 8, md: 9 }}>
<Typography variant="h3" sx={{ fontWeight: '900', mb: 0.5 }}>{data.user.username}</Typography>
</Box>
<Box sx={{ flex: 1, textAlign: { xs: 'center', sm: 'left' }, mt: { xs: 1, sm: 4 } }}>
<Typography variant="h3" sx={{ fontWeight: '900', mb: 0.5, letterSpacing: -1 }}>{data.user.username}</Typography>
<Typography variant="subtitle1" color="text.secondary" gutterBottom>
{t('dashboard.welcome', { username: data.user.username })}
</Typography>
<Stack direction="row" spacing={3} sx={{ mt: 2 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: '900', color: 'primary.main' }}>{data.rated_count}</Typography>
<Typography variant="caption" sx={{ fontWeight: 'bold', textTransform: 'uppercase', letterSpacing: 1 }}>{t('dashboard.myRatings')}</Typography>
</Box>
<Divider orientation="vertical" flexItem />
<Box>
<Typography variant="h5" sx={{ fontWeight: '900' }}>{data.missing_count}</Typography>
<Typography variant="caption" sx={{ fontWeight: 'bold', textTransform: 'uppercase', letterSpacing: 1 }}>{t('dashboard.missing')}</Typography>
</Box>
</Stack>
</Grid>
</Grid>
<Box sx={{ mt: 2 }}>
<Paper
elevation={0}
sx={{
display: 'inline-flex',
bgcolor: (theme: any) => alpha(theme.palette.text.primary, 0.04),
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
overflow: 'hidden',
backdropFilter: 'blur(8px)'
}}
>
{[
{ label: t('dashboard.myRatings'), val: data.rated_count },
{ label: t('dashboard.missing'), val: data.missing_count }
].map((stat, i) => (
<React.Fragment key={stat.label}>
<Box sx={{ py: 1, width: { xs: 100, sm: 140 }, textAlign: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: '900', lineHeight: 1, color: 'text.primary' }}>{stat.val}</Typography>
<Typography variant="caption" sx={{ fontWeight: '900', textTransform: 'uppercase', opacity: 0.5, fontSize: '0.6rem', letterSpacing: 1 }}>{stat.label}</Typography>
</Box>
{i < 1 && <Divider orientation="vertical" flexItem sx={{ borderStyle: 'solid', opacity: 0.1 }} />}
</React.Fragment>
))}
</Paper>
</Box>
</Box>
</Box>
</CardContent>
</Card>