personal_website/src/components/HumanVerification.tsx
gpt-engineer-app[bot] 850630fbc6 Changes
2025-12-09 16:42:55 +00:00

463 lines
15 KiB
TypeScript

import { useState, useRef, useEffect, useCallback } from 'react';
import { motion } from 'framer-motion';
import { RefreshCw, ChevronRight } from 'lucide-react';
export const BYPASS_KEY = 'bypass-human-check';
export const VERIFIED_KEY = 'human-verified';
interface HumanVerificationProps {
onVerified: () => void;
}
const HumanVerification = ({ onVerified }: HumanVerificationProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [sliderX, setSliderX] = useState(0);
const [isVerified, setIsVerified] = useState(false);
const [error, setError] = useState(false);
const [puzzleImage, setPuzzleImage] = useState<ImageData | null>(null);
const [targetX, setTargetX] = useState(0);
const trackRef = useRef<HTMLDivElement>(null);
const CANVAS_WIDTH = 300;
const CANVAS_HEIGHT = 150;
const PIECE_WIDTH = 42;
const PIECE_HEIGHT = 42;
const NOTCH_SIZE = 10;
const TOLERANCE = 6;
// Check for bypass in URL on mount
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.has(BYPASS_KEY) || window.location.pathname.includes(BYPASS_KEY)) {
localStorage.setItem(VERIFIED_KEY, 'true');
onVerified();
}
}, [onVerified]);
const drawPuzzlePiecePath = (ctx: CanvasRenderingContext2D, x: number, y: number) => {
const w = PIECE_WIDTH;
const h = PIECE_HEIGHT;
const n = NOTCH_SIZE;
ctx.beginPath();
ctx.moveTo(x, y);
// Top edge
ctx.lineTo(x + w, y);
// Right edge with notch
ctx.lineTo(x + w, y + h * 0.35);
ctx.arc(x + w + n * 0.5, y + h * 0.5, n, -Math.PI * 0.5, Math.PI * 0.5, false);
ctx.lineTo(x + w, y + h);
// Bottom edge
ctx.lineTo(x, y + h);
// Left edge
ctx.lineTo(x, y);
ctx.closePath();
};
const generatePuzzle = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Get theme color
const computedStyle = getComputedStyle(document.documentElement);
const primaryHsl = computedStyle.getPropertyValue('--primary').trim();
const hslParts = primaryHsl.split(' ');
const h = parseFloat(hslParts[0]) || 120;
const s = parseFloat(hslParts[1]) || 100;
const l = parseFloat(hslParts[2]) || 50;
// Clear canvas
ctx.fillStyle = '#0d0d0d';
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// Create a visually interesting background pattern
// Gradient base
const gradient = ctx.createLinearGradient(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
gradient.addColorStop(0, `hsla(${h}, ${s}%, ${l * 0.15}%, 1)`);
gradient.addColorStop(0.5, `hsla(${h}, ${s}%, ${l * 0.08}%, 1)`);
gradient.addColorStop(1, `hsla(${h}, ${s}%, ${l * 0.12}%, 1)`);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// Draw circuit-like patterns
ctx.strokeStyle = `hsla(${h}, ${s}%, ${l}%, 0.15)`;
ctx.lineWidth = 1;
for (let i = 0; i < 12; i++) {
const startX = Math.random() * CANVAS_WIDTH;
const startY = Math.random() * CANVAS_HEIGHT;
ctx.beginPath();
ctx.moveTo(startX, startY);
// Draw horizontal then vertical line
const midX = startX + (Math.random() - 0.5) * 80;
ctx.lineTo(midX, startY);
ctx.lineTo(midX, startY + (Math.random() - 0.5) * 60);
ctx.stroke();
// Draw node dots
ctx.fillStyle = `hsla(${h}, ${s}%, ${l}%, 0.3)`;
ctx.beginPath();
ctx.arc(startX, startY, 2, 0, Math.PI * 2);
ctx.fill();
}
// Matrix-style falling characters
ctx.font = '11px monospace';
ctx.fillStyle = `hsla(${h}, ${s}%, ${l}%, 0.2)`;
const chars = '01アイウエオカキクケコ田由甲申電网';
for (let i = 0; i < 40; i++) {
const cx = Math.random() * CANVAS_WIDTH;
const cy = Math.random() * CANVAS_HEIGHT;
ctx.fillText(chars[Math.floor(Math.random() * chars.length)], cx, cy);
}
// Generate target position (where the piece should go)
const newTargetX = Math.floor(Math.random() * (CANVAS_WIDTH - PIECE_WIDTH - 100)) + 80;
const targetY = (CANVAS_HEIGHT - PIECE_HEIGHT) / 2;
setTargetX(newTargetX);
// Draw the target slot (dark cutout with glow)
ctx.save();
drawPuzzlePiecePath(ctx, newTargetX, targetY);
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fill();
ctx.strokeStyle = `hsla(${h}, ${s}%, ${l}%, 0.5)`;
ctx.lineWidth = 1.5;
ctx.stroke();
// Inner shadow effect
ctx.shadowColor = `hsla(${h}, ${s}%, ${l}%, 0.3)`;
ctx.shadowBlur = 4;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
ctx.stroke();
ctx.restore();
// Extract the puzzle piece image from a slightly different position for variation
const extractX = 10;
const extractY = targetY;
// Create puzzle piece appearance
const pieceCanvas = document.createElement('canvas');
pieceCanvas.width = PIECE_WIDTH + NOTCH_SIZE + 4;
pieceCanvas.height = PIECE_HEIGHT + 4;
const pieceCtx = pieceCanvas.getContext('2d');
if (pieceCtx) {
// Draw piece background with pattern
pieceCtx.fillStyle = `hsla(${h}, ${s}%, ${l}%, 0.9)`;
drawPuzzlePiecePath(pieceCtx, 2, 2);
pieceCtx.fill();
// Add texture
pieceCtx.strokeStyle = `hsla(${h}, ${s}%, ${l * 1.3}%, 0.6)`;
pieceCtx.lineWidth = 1.5;
drawPuzzlePiecePath(pieceCtx, 2, 2);
pieceCtx.stroke();
// Inner pattern
pieceCtx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
pieceCtx.lineWidth = 1;
pieceCtx.strokeRect(8, 8, PIECE_WIDTH - 12, PIECE_HEIGHT - 12);
// Arrow indicator
pieceCtx.fillStyle = 'rgba(0, 0, 0, 0.5)';
pieceCtx.beginPath();
const arrowCx = PIECE_WIDTH / 2 + 2;
const arrowCy = PIECE_HEIGHT / 2 + 2;
pieceCtx.moveTo(arrowCx - 6, arrowCy - 5);
pieceCtx.lineTo(arrowCx + 6, arrowCy);
pieceCtx.lineTo(arrowCx - 6, arrowCy + 5);
pieceCtx.closePath();
pieceCtx.fill();
setPuzzleImage(pieceCtx.getImageData(0, 0, pieceCanvas.width, pieceCanvas.height));
}
// Draw scanlines
ctx.fillStyle = 'rgba(0, 0, 0, 0.06)';
for (let y = 0; y < CANVAS_HEIGHT; y += 2) {
ctx.fillRect(0, y, CANVAS_WIDTH, 1);
}
// Border
ctx.strokeStyle = `hsla(${h}, ${s}%, ${l}%, 0.4)`;
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, CANVAS_WIDTH - 2, CANVAS_HEIGHT - 2);
setSliderX(0);
setError(false);
setIsVerified(false);
}, []);
useEffect(() => {
generatePuzzle();
}, [generatePuzzle]);
// Draw the puzzle piece at current position
useEffect(() => {
if (!canvasRef.current || !puzzleImage) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Regenerate base image
generatePuzzle();
}, []);
// Redraw canvas with piece at current slider position
const redrawWithPiece = useCallback(() => {
if (!canvasRef.current || !puzzleImage) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Get theme color
const computedStyle = getComputedStyle(document.documentElement);
const primaryHsl = computedStyle.getPropertyValue('--primary').trim();
const hslParts = primaryHsl.split(' ');
const h = parseFloat(hslParts[0]) || 120;
const s = parseFloat(hslParts[1]) || 100;
const l = parseFloat(hslParts[2]) || 50;
// Clear and redraw background
ctx.fillStyle = '#0d0d0d';
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
const gradient = ctx.createLinearGradient(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
gradient.addColorStop(0, `hsla(${h}, ${s}%, ${l * 0.15}%, 1)`);
gradient.addColorStop(0.5, `hsla(${h}, ${s}%, ${l * 0.08}%, 1)`);
gradient.addColorStop(1, `hsla(${h}, ${s}%, ${l * 0.12}%, 1)`);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// Circuit patterns
ctx.strokeStyle = `hsla(${h}, ${s}%, ${l}%, 0.15)`;
ctx.lineWidth = 1;
for (let i = 0; i < 12; i++) {
const startX = (i * 37) % CANVAS_WIDTH;
const startY = (i * 23) % CANVAS_HEIGHT;
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(startX + 40, startY);
ctx.lineTo(startX + 40, startY + 30);
ctx.stroke();
}
// Matrix chars
ctx.font = '11px monospace';
ctx.fillStyle = `hsla(${h}, ${s}%, ${l}%, 0.2)`;
const chars = '01アイウエオ田由甲申';
for (let i = 0; i < 30; i++) {
ctx.fillText(chars[i % chars.length], (i * 17) % CANVAS_WIDTH, (i * 13) % CANVAS_HEIGHT);
}
const pieceY = (CANVAS_HEIGHT - PIECE_HEIGHT) / 2;
// Draw target slot
ctx.save();
drawPuzzlePiecePath(ctx, targetX, pieceY);
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fill();
ctx.strokeStyle = `hsla(${h}, ${s}%, ${l}%, 0.5)`;
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.restore();
// Calculate piece position from slider
const maxSlide = trackRef.current ? trackRef.current.clientWidth - 48 : 240;
const pieceX = (sliderX / maxSlide) * (CANVAS_WIDTH - PIECE_WIDTH - NOTCH_SIZE - 10) + 5;
// Draw draggable piece with shadow/glow
ctx.save();
let pieceColor = `hsla(${h}, ${s}%, ${l}%, 0.95)`;
let borderColor = `hsla(${h}, ${s}%, ${l * 1.2}%, 1)`;
let glowColor = `hsla(${h}, ${s}%, ${l}%, 0.6)`;
if (error) {
pieceColor = 'hsla(0, 70%, 50%, 0.95)';
borderColor = 'hsl(0, 70%, 60%)';
glowColor = 'hsla(0, 70%, 50%, 0.6)';
} else if (isVerified) {
pieceColor = 'hsla(142, 76%, 40%, 0.95)';
borderColor = 'hsl(142, 76%, 55%)';
glowColor = 'hsla(142, 76%, 40%, 0.6)';
}
// Glow effect
ctx.shadowColor = glowColor;
ctx.shadowBlur = 12;
drawPuzzlePiecePath(ctx, pieceX, pieceY);
ctx.fillStyle = pieceColor;
ctx.fill();
ctx.strokeStyle = borderColor;
ctx.lineWidth = 2;
ctx.stroke();
ctx.shadowBlur = 0;
// Inner pattern on piece
ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
ctx.lineWidth = 1;
ctx.strokeRect(pieceX + 6, pieceY + 6, PIECE_WIDTH - 12, PIECE_HEIGHT - 12);
// Arrow
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.beginPath();
const arrowX = pieceX + PIECE_WIDTH / 2;
const arrowY = pieceY + PIECE_HEIGHT / 2;
ctx.moveTo(arrowX - 5, arrowY - 4);
ctx.lineTo(arrowX + 5, arrowY);
ctx.lineTo(arrowX - 5, arrowY + 4);
ctx.closePath();
ctx.fill();
ctx.restore();
// Scanlines
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
for (let y = 0; y < CANVAS_HEIGHT; y += 2) {
ctx.fillRect(0, y, CANVAS_WIDTH, 1);
}
// Border
ctx.strokeStyle = `hsla(${h}, ${s}%, ${l}%, 0.4)`;
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, CANVAS_WIDTH - 2, CANVAS_HEIGHT - 2);
}, [sliderX, error, isVerified, targetX, puzzleImage]);
useEffect(() => {
redrawWithPiece();
}, [sliderX, error, isVerified, redrawWithPiece]);
const handleMouseDown = () => {
setIsDragging(true);
setError(false);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging || !trackRef.current) return;
const rect = trackRef.current.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left - 24, rect.width - 48));
setSliderX(x);
};
const handleMouseUp = () => {
if (!isDragging || !trackRef.current) return;
setIsDragging(false);
const maxSlide = trackRef.current.clientWidth - 48;
const pieceX = (sliderX / maxSlide) * (CANVAS_WIDTH - PIECE_WIDTH - NOTCH_SIZE - 10) + 5;
const diff = Math.abs(pieceX - targetX);
if (diff <= TOLERANCE) {
setIsVerified(true);
localStorage.setItem(VERIFIED_KEY, 'true');
setTimeout(() => onVerified(), 500);
} else {
setError(true);
setTimeout(() => {
setSliderX(0);
setError(false);
}, 350);
}
};
const handleTouchStart = () => {
setIsDragging(true);
setError(false);
};
const handleTouchMove = (e: React.TouchEvent) => {
if (!isDragging || !trackRef.current) return;
const touch = e.touches[0];
const rect = trackRef.current.getBoundingClientRect();
const x = Math.max(0, Math.min(touch.clientX - rect.left - 24, rect.width - 48));
setSliderX(x);
};
return (
<div className="fixed inset-0 z-[100] bg-background flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full max-w-[340px] space-y-3"
>
{/* Header */}
<div className="text-center border border-primary/40 bg-primary/5 py-2.5 px-4">
<div className="text-primary font-mono text-sm tracking-wider font-bold">
SECURITY VERIFICATION
</div>
<div className="text-primary/50 font-mono text-[10px]">
ANTI-BOT PROTOCOL v3.0
</div>
</div>
{/* Canvas puzzle area */}
<div className="border border-primary/30 overflow-hidden">
<canvas
ref={canvasRef}
width={CANVAS_WIDTH}
height={CANVAS_HEIGHT}
className="w-full block"
/>
</div>
{/* Slider track */}
<div
ref={trackRef}
className={`relative h-11 border-2 ${
error ? 'border-destructive bg-destructive/10' :
isVerified ? 'border-green-500 bg-green-500/10' :
'border-primary/30 bg-primary/5'
} transition-colors duration-200 cursor-pointer select-none`}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onTouchMove={handleTouchMove}
onTouchEnd={handleMouseUp}
>
{/* Track label */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className={`font-mono text-xs tracking-wide ${
isVerified ? 'text-green-500' : 'text-primary/30'
}`}>
{isVerified ? '[ VERIFIED ]' : 'DRAG TO COMPLETE PUZZLE'}
</span>
</div>
{/* Slider handle */}
<motion.div
className={`absolute top-0 h-full w-12 flex items-center justify-center cursor-grab active:cursor-grabbing ${
error ? 'bg-destructive' :
isVerified ? 'bg-green-500' :
'bg-primary'
} transition-colors duration-200`}
style={{ left: sliderX }}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
animate={error ? { x: [0, -4, 4, -4, 4, 0] } : {}}
transition={{ duration: 0.2 }}
>
<ChevronRight className="w-5 h-5 text-background" />
</motion.div>
</div>
{/* Controls */}
<div className="flex justify-end">
<button
onClick={generatePuzzle}
className="flex items-center gap-1.5 text-xs font-mono text-primary/60 hover:text-primary transition-colors"
>
<RefreshCw className="w-3 h-3" />
Refresh
</button>
</div>
</motion.div>
</div>
);
};
export default HumanVerification;