Compare commits

..

5 Commits

Author SHA1 Message Date
gpt-engineer-app[bot]
6b586236bc Improve human verification UX
Enhance HumanVerification to a Taobao-style slider puzzle and ensure the Sidebar's Access Granted folds in when space is limited without making the sidebar scrollable. Includes refinements to keep the UI responsive on mobile and clarify that the access text folds rather than scrolls.

X-Lovable-Edit-ID: edt-560d57af-660c-4447-8352-e12d8094d2c8
2025-12-09 16:42:56 +00:00
gpt-engineer-app[bot]
850630fbc6 Changes 2025-12-09 16:42:55 +00:00
gpt-engineer-app[bot]
21238dec45 Improve mobile UI and audio UX
- Detect and fold inaccessible sidebar sections when not fully visible
- Require at least 1 minute on page for visit-based achievements
- Implement overlay to resolve blocked AudioContext with user gesture
- Replace human verification canvas with Taobao-like slider puzzle for bot resistance

X-Lovable-Edit-ID: edt-51cce867-67fb-4b5b-b99d-55b173652a90
2025-12-09 16:21:02 +00:00
gpt-engineer-app[bot]
424e8f8277 Changes 2025-12-09 16:21:01 +00:00
2a8700af31 removed p achievement text 2025-12-09 16:06:06 +01:00
7 changed files with 709 additions and 350 deletions

View File

@ -0,0 +1,55 @@
import { motion } from 'framer-motion';
import { Volume2, VolumeX } from 'lucide-react';
interface AudioBlockedOverlayProps {
onEnableAudio: () => void;
onDisableAudio: () => void;
}
export const AudioBlockedOverlay = ({ onEnableAudio, onDisableAudio }: AudioBlockedOverlayProps) => {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="fixed inset-0 z-[9999] bg-background/95 backdrop-blur-sm flex items-center justify-center"
>
<div className="text-center space-y-6 p-8 max-w-md">
<div className="text-primary text-6xl mb-4">
<Volume2 className="w-16 h-16 mx-auto animate-pulse" />
</div>
<h2 className="text-2xl font-mono text-primary">
AUDIO INITIALIZATION REQUIRED
</h2>
<p className="text-muted-foreground font-mono text-sm">
Browser security policy requires user interaction to enable audio playback.
</p>
<div className="flex flex-col gap-3 mt-6">
<button
onClick={onEnableAudio}
className="w-full px-6 py-3 bg-primary/20 border border-primary text-primary font-mono
hover:bg-primary/30 transition-all duration-200 flex items-center justify-center gap-2"
>
<Volume2 className="w-5 h-5" />
ENABLE AUDIO
</button>
<button
onClick={onDisableAudio}
className="w-full px-6 py-3 bg-muted/20 border border-muted-foreground/30 text-muted-foreground font-mono
hover:bg-muted/30 transition-all duration-200 flex items-center justify-center gap-2"
>
<VolumeX className="w-5 h-5" />
DISABLE SOUNDS
</button>
</div>
<p className="text-muted-foreground/60 font-mono text-xs mt-4">
// Click anywhere or choose an option to continue
</p>
</div>
</motion.div>
);
};

View File

@ -1,49 +1,32 @@
import { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useState, useRef, useEffect, useCallback } from 'react';
import { motion } from 'framer-motion';
import { RefreshCw, ChevronRight } from 'lucide-react';
const BYPASS_KEY = 'bypass-human-check';
const VERIFIED_KEY = 'human-verified';
export const BYPASS_KEY = 'bypass-human-check';
export const VERIFIED_KEY = 'human-verified';
interface HumanVerificationProps {
onVerified: () => void;
}
// Generate a simple math equation
const generateEquation = () => {
const operators = ['+', '-', '*'] as const;
const operator = operators[Math.floor(Math.random() * operators.length)];
let a: number, b: number, answer: number;
switch (operator) {
case '+':
a = Math.floor(Math.random() * 50) + 1;
b = Math.floor(Math.random() * 50) + 1;
answer = a + b;
break;
case '-':
a = Math.floor(Math.random() * 50) + 20;
b = Math.floor(Math.random() * (a - 1)) + 1;
answer = a - b;
break;
case '*':
a = Math.floor(Math.random() * 12) + 2;
b = Math.floor(Math.random() * 12) + 2;
answer = a * b;
break;
}
return { equation: `${a} ${operator} ${b}`, answer };
};
const HumanVerification = ({ onVerified }: HumanVerificationProps) => {
const [{ equation, answer }, setEquation] = useState(generateEquation);
const [userAnswer, setUserAnswer] = useState('');
const [error, setError] = useState(false);
const [attempts, setAttempts] = useState(0);
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);
// Check for bypass in URL
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)) {
@ -52,213 +35,429 @@ const HumanVerification = ({ onVerified }: HumanVerificationProps) => {
}
}, [onVerified]);
// Draw equation on canvas for added security
useEffect(() => {
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 computed styles for theming
// Get theme color
const computedStyle = getComputedStyle(document.documentElement);
const primaryColor = computedStyle.getPropertyValue('--primary').trim();
const hslMatch = primaryColor.match(/[\d.]+/g);
const primaryRGB = hslMatch
? `hsl(${hslMatch[0]}, ${hslMatch[1]}%, ${hslMatch[2]}%)`
: '#00ff00';
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 = 'rgba(0, 0, 0, 0.8)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#0d0d0d';
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// Draw grid lines for terminal feel
ctx.strokeStyle = 'rgba(0, 255, 0, 0.1)';
// 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 < canvas.width; i += 20) {
for (let i = 0; i < 12; i++) {
const startX = Math.random() * CANVAS_WIDTH;
const startY = Math.random() * CANVAS_HEIGHT;
ctx.beginPath();
ctx.moveTo(i, 0);
ctx.lineTo(i, canvas.height);
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();
}
for (let i = 0; i < canvas.height; i += 20) {
// Draw node dots
ctx.fillStyle = `hsla(${h}, ${s}%, ${l}%, 0.3)`;
ctx.beginPath();
ctx.moveTo(0, i);
ctx.lineTo(canvas.width, i);
ctx.stroke();
ctx.arc(startX, startY, 2, 0, Math.PI * 2);
ctx.fill();
}
// Draw border
ctx.strokeStyle = primaryRGB;
// 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(2, 2, canvas.width - 4, canvas.height - 4);
ctx.strokeRect(1, 1, CANVAS_WIDTH - 2, CANVAS_HEIGHT - 2);
// Draw equation with slight random positioning for anti-bot
const offsetX = Math.random() * 10 - 5;
const offsetY = Math.random() * 6 - 3;
setSliderX(0);
setError(false);
setIsVerified(false);
}, []);
ctx.font = 'bold 48px monospace';
ctx.fillStyle = primaryRGB;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
useEffect(() => {
generatePuzzle();
}, [generatePuzzle]);
// Add subtle noise/distortion
const chars = equation.split('');
let xPos = canvas.width / 2 - (chars.length * 15) + offsetX;
// 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;
chars.forEach((char) => {
const yOffset = Math.random() * 4 - 2;
ctx.fillText(char, xPos, canvas.height / 2 + offsetY + yOffset);
xPos += 30;
});
// Regenerate base image
generatePuzzle();
}, []);
// Draw "= ?" at the end
ctx.fillText('= ?', xPos + 20, canvas.height / 2 + offsetY);
// 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;
// Add scanline effect
for (let y = 0; y < canvas.height; y += 3) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.15)';
ctx.fillRect(0, y, canvas.width, 1);
// 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();
}
}, [equation]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const parsed = parseInt(userAnswer, 10);
if (!isNaN(parsed) && parsed === answer) {
localStorage.setItem(VERIFIED_KEY, 'true');
onVerified();
} else {
setError(true);
setAttempts(prev => prev + 1);
setTimeout(() => {
setError(false);
// Generate new equation after 3 failed attempts
if (attempts >= 2) {
setEquation(generateEquation());
setAttempts(0);
}
}, 600);
setUserAnswer('');
// 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 handleNewEquation = () => {
setEquation(generateEquation());
setUserAnswer('');
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 (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="fixed inset-0 z-[100] bg-background flex items-center justify-center p-4"
>
<div className="max-w-lg w-full">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.2 }}
className="border-2 border-primary box-glow p-6 bg-background"
>
{/* Header */}
<div className="text-center mb-6">
<pre className="font-mono text-[10px] sm:text-xs text-primary leading-tight mb-4">
{`┌─────────────────────────────────────┐
SECURITY VERIFICATION v2.0
ANTI-BOT PROTOCOL
`}
</pre>
<p className="font-mono text-sm text-foreground/70">
{'>'} Solve the equation to verify humanity
</p>
<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>
{/* Canvas with equation */}
<div className="mb-6 flex justify-center">
<canvas
ref={canvasRef}
width={320}
height={100}
className="border border-primary/30 rounded"
/>
<div className="text-primary/50 font-mono text-[10px]">
ANTI-BOT PROTOCOL v3.0
</div>
{/* Input form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-primary font-mono">
{'>'}_
</span>
<input
type="text"
inputMode="numeric"
pattern="-?[0-9]*"
value={userAnswer}
onChange={(e) => setUserAnswer(e.target.value.replace(/[^0-9-]/g, ''))}
placeholder="Enter answer"
autoFocus
className={`w-full bg-background border-2 ${
error ? 'border-destructive animate-pulse' : 'border-primary/50'
} p-3 pl-12 font-mono text-lg text-foreground placeholder:text-foreground/30 focus:outline-none focus:border-primary transition-colors`}
/>
</div>
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="font-mono text-sm text-destructive flex items-center gap-2"
>
<span></span>
<span>INCORRECT - {3 - attempts} attempts remaining</span>
</motion.div>
)}
</AnimatePresence>
<div className="flex gap-3">
<button
type="submit"
disabled={!userAnswer}
className="flex-1 font-minecraft text-sm py-3 border-2 border-primary bg-primary/20 text-primary hover:bg-primary/40 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300 box-glow"
>
[VERIFY]
</button>
<button
type="button"
onClick={handleNewEquation}
className="px-4 py-3 border-2 border-primary/50 text-primary/70 hover:border-primary hover:text-primary transition-all font-mono text-sm"
>
</button>
</div>
</form>
{/* Footer */}
<div className="mt-6 pt-4 border-t border-primary/20 space-y-2">
<div className="flex justify-between text-[10px] font-mono text-foreground/40">
<span>Protocol: MATH-VERIFY-2.0</span>
<span>Encryption: ACTIVE</span>
</div>
<p className="font-mono text-[10px] text-foreground/30 text-center">
// This verification helps protect against automated access
</p>
</div>
</motion.div>
{/* Terminal decoration */}
<div className="mt-4 font-mono text-[10px] text-primary/50 space-y-1">
<p>{'>'} Awaiting verification input...</p>
<p className="animate-pulse">{'>'} _</p>
</div>
</div>
</motion.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;
export { VERIFIED_KEY, BYPASS_KEY };

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '@/lib/utils';
@ -22,16 +22,43 @@ const Sidebar = () => {
const location = useLocation();
const { playSound } = useSettings();
const [isExpanded, setIsExpanded] = useState(false);
const [showFooter, setShowFooter] = useState(true);
const navRef = useRef<HTMLElement>(null);
const footerRef = useRef<HTMLDivElement>(null);
const sidebarRef = useRef<HTMLElement>(null);
const currentPage = navItems.find(item => item.path === location.pathname)?.label || 'Menu';
// Check if footer is visible within the sidebar
useEffect(() => {
const checkFooterVisibility = () => {
if (!sidebarRef.current || !footerRef.current || !navRef.current) return;
const sidebarRect = sidebarRef.current.getBoundingClientRect();
const navHeight = navRef.current.scrollHeight;
const footerHeight = footerRef.current.offsetHeight;
const availableHeight = sidebarRect.height;
// Calculate if there's enough space for nav items + footer
const totalContentHeight = navHeight + footerHeight + 32; // 32px for padding
setShowFooter(totalContentHeight <= availableHeight);
};
checkFooterVisibility();
window.addEventListener('resize', checkFooterVisibility);
return () => window.removeEventListener('resize', checkFooterVisibility);
}, []);
const toggleMenu = () => {
setIsExpanded(!isExpanded);
playSound('click');
};
return (
<aside className="w-full md:w-[230px] lg:w-[250px] h-auto md:h-full flex flex-col border-b-2 md:border-b-0 md:border-r-2 border-primary bg-background/70 box-glow shrink-0">
<aside
ref={sidebarRef}
className="w-full md:w-[230px] lg:w-[250px] h-auto md:h-full flex flex-col border-b-2 md:border-b-0 md:border-r-2 border-primary bg-background/70 box-glow shrink-0"
>
{/* Mobile Toggle Header */}
<button
onClick={toggleMenu}
@ -50,7 +77,7 @@ const Sidebar = () => {
</button>
{/* Desktop Navigation - Always visible */}
<nav className="hidden md:block flex-grow overflow-hidden p-4 md:p-5">
<nav ref={navRef} className="hidden md:flex flex-col flex-grow p-4 md:p-5">
{navItems.map((item, index) => {
const isActive = location.pathname === item.path;
@ -80,6 +107,15 @@ const Sidebar = () => {
</motion.div>
);
})}
{/* Inline footer when sidebar is too short - folded into nav */}
{!showFooter && (
<div className="mt-auto pt-4 border-t border-primary/30">
<p className="font-pixel text-sm text-primary text-glow text-center">
Access Granted
</p>
</div>
)}
</nav>
{/* Mobile Navigation - Collapsible */}
@ -127,12 +163,14 @@ const Sidebar = () => {
)}
</AnimatePresence>
{/* Desktop Footer */}
<div className="hidden md:block p-4 border-t border-primary/30">
<p className="font-pixel text-sm text-primary text-glow text-center">
Access Granted
</p>
</div>
{/* Desktop Footer - Only shown if visible */}
{showFooter && (
<div ref={footerRef} className="hidden md:block p-4 border-t border-primary/30">
<p className="font-pixel text-sm text-primary text-glow text-center">
Access Granted
</p>
</div>
)}
</aside>
);
};

View File

@ -63,6 +63,25 @@ interface AchievementsContextType {
unlockMaxScore: () => void;
}
// Track time spent on each page
const PAGE_TIME_KEY = 'page-time-tracking';
const getPageTimeTracking = (): Record<string, number> => {
const saved = localStorage.getItem(PAGE_TIME_KEY);
return saved ? JSON.parse(saved) : {};
};
const updatePageTime = (path: string, seconds: number): void => {
const tracking = getPageTimeTracking();
tracking[path] = (tracking[path] || 0) + seconds;
localStorage.setItem(PAGE_TIME_KEY, JSON.stringify(tracking));
};
export const getPageTime = (path: string): number => {
const tracking = getPageTimeTracking();
return tracking[path] || 0;
};
const defaultAchievements: Achievement[] = [
// Discovery achievements
{ id: 'first_visit', name: 'Hello World', description: 'Visit the site for the first time', icon: '👋', unlocked: false },
@ -71,17 +90,17 @@ const defaultAchievements: Achievement[] = [
{ id: 'terminal_user', name: 'Terminal Jockey', description: 'Use the terminal command interface', icon: '💻', unlocked: false },
{ id: 'hint_seeker', name: 'Hint Seeker', description: 'Ask for a hint in the terminal', icon: '🔍', unlocked: false, secret: true },
// Navigation achievements
{ id: 'home_visitor', name: 'Home Base', description: 'Visit the home page', icon: '🏠', unlocked: false },
{ id: 'about_visitor', name: 'Getting Personal', description: 'Learn about the site owner', icon: '👤', unlocked: false },
{ id: 'projects_visitor', name: 'Project Explorer', description: 'Check out the projects', icon: '📁', unlocked: false },
{ id: 'resources_visitor', name: 'Resource Collector', description: 'Browse the resources page', icon: '📚', unlocked: false },
{ id: 'links_visitor', name: 'Link Crawler', description: 'Visit the links page', icon: '🔗', unlocked: false },
{ id: 'faq_visitor', name: 'Question Everything', description: 'Read the FAQ', icon: '❓', unlocked: false },
{ id: 'music_visitor', name: 'DJ Mode', description: 'Open the music player', icon: '🎵', unlocked: false },
{ id: 'ai_visitor', name: 'AI Whisperer', description: 'Chat with the AI', icon: '🤖', unlocked: false },
{ id: 'arcade_visitor', name: 'Arcade Enthusiast', description: 'Visit the arcade', icon: '🕹️', unlocked: false },
{ id: 'all_pages', name: 'Completionist', description: 'Visit every page on the site', icon: '🗺️', unlocked: false, secret: true },
// Navigation achievements - now require 1 minute on page
{ id: 'home_visitor', name: 'Home Base', description: 'Spend 1 minute on the home page', icon: '🏠', unlocked: false },
{ id: 'about_visitor', name: 'Getting Personal', description: 'Spend 1 minute learning about the owner', icon: '👤', unlocked: false },
{ id: 'projects_visitor', name: 'Project Explorer', description: 'Spend 1 minute exploring projects', icon: '📁', unlocked: false },
{ id: 'resources_visitor', name: 'Resource Collector', description: 'Spend 1 minute browsing resources', icon: '📚', unlocked: false },
{ id: 'links_visitor', name: 'Link Crawler', description: 'Spend 1 minute on the links page', icon: '🔗', unlocked: false },
{ id: 'faq_visitor', name: 'Question Everything', description: 'Spend 1 minute reading the FAQ', icon: '❓', unlocked: false },
{ id: 'music_visitor', name: 'DJ Mode', description: 'Spend 1 minute in the music player', icon: '🎵', unlocked: false },
{ id: 'ai_visitor', name: 'AI Whisperer', description: 'Spend 1 minute chatting with AI', icon: '🤖', unlocked: false },
{ id: 'arcade_visitor', name: 'Arcade Enthusiast', description: 'Spend 1 minute in the arcade', icon: '🕹️', unlocked: false },
{ id: 'all_pages', name: 'Completionist', description: 'Spend 1 minute on every page', icon: '🗺️', unlocked: false, secret: true },
// Time achievements
{ id: 'time_15min', name: 'Quick Visit', description: 'Spend 15 minutes on the site', icon: '⏱️', unlocked: false },
@ -183,9 +202,12 @@ export const AchievementsProvider = ({ children }: { children: ReactNode }) => {
if (hour >= 5 && hour < 7) unlockAchievement('early_bird');
}, []);
// Track page visits
// Track page visits and time spent
const [currentPath, setCurrentPath] = useState(location.pathname);
useEffect(() => {
const path = location.pathname;
setCurrentPath(path);
setVisitedPages(prev => {
const newSet = new Set(prev);
@ -194,30 +216,42 @@ export const AchievementsProvider = ({ children }: { children: ReactNode }) => {
return newSet;
});
// Page-specific achievements
if (path === '/') unlockAchievement('home_visitor');
if (path === '/about') unlockAchievement('about_visitor');
if (path === '/projects') unlockAchievement('projects_visitor');
if (path === '/resources') unlockAchievement('resources_visitor');
if (path === '/links') unlockAchievement('links_visitor');
if (path === '/faq') unlockAchievement('faq_visitor');
if (path === '/music') unlockAchievement('music_visitor');
if (path === '/ai') unlockAchievement('ai_visitor');
if (path === '/games') unlockAchievement('arcade_visitor');
// Track time on page - increment every second
const interval = setInterval(() => {
updatePageTime(path, 1);
const pageTime = getPageTime(path);
// Unlock achievements after 60 seconds (1 minute) on page
if (pageTime >= 60) {
if (path === '/') unlockAchievement('home_visitor');
if (path === '/about') unlockAchievement('about_visitor');
if (path === '/projects') unlockAchievement('projects_visitor');
if (path === '/resources') unlockAchievement('resources_visitor');
if (path === '/links') unlockAchievement('links_visitor');
if (path === '/faq') unlockAchievement('faq_visitor');
if (path === '/music') unlockAchievement('music_visitor');
if (path === '/ai') unlockAchievement('ai_visitor');
if (path === '/games') unlockAchievement('arcade_visitor');
}
}, 1000);
// Immediate unlocks for games and detail pages
if (path.startsWith('/projects/')) unlockAchievement('project_detail');
if (path === '/games/leaderboard') unlockAchievement('leaderboard_check');
if (path === '/games/tetris') unlockAchievement('tetris_played');
if (path === '/games/pacman') unlockAchievement('pacman_played');
if (path === '/games/snake') unlockAchievement('snake_played');
if (path === '/games/breakout') unlockAchievement('breakout_played');
return () => clearInterval(interval);
}, [location.pathname]);
// Check all pages visited
// Check all pages visited (1 min on each)
useEffect(() => {
const requiredPages = ['/', '/about', '/projects', '/resources', '/links', '/faq', '/music', '/ai', '/games'];
const allVisited = requiredPages.every(p => visitedPages.has(p));
if (allVisited) unlockAchievement('all_pages');
}, [visitedPages]);
const allVisitedLongEnough = requiredPages.every(p => getPageTime(p) >= 60);
if (allVisitedLongEnough) unlockAchievement('all_pages');
}, [visitedPages, timeOnSite]); // Check periodically with timeOnSite
// Check all games played
useEffect(() => {

View File

@ -7,7 +7,7 @@ interface SettingsContextType {
setCrtEnabled: (enabled: boolean) => void;
soundEnabled: boolean;
setSoundEnabled: (enabled: boolean) => void;
cryptoConsent: boolean | null; // null = never asked this session
cryptoConsent: boolean | null;
setCryptoConsent: (consent: boolean) => void;
playSound: (type: SoundType) => void;
hashrate: number;
@ -17,7 +17,12 @@ interface SettingsContextType {
acceptedHashes: number;
setAcceptedHashes: (hashes: number) => void;
audioBlocked: boolean;
resetAudioContext: () => void;
showAudioOverlay: boolean;
setShowAudioOverlay: (show: boolean) => void;
enableAudio: () => void;
disableAudio: () => void;
userInteracted: boolean;
setUserInteracted: (interacted: boolean) => void;
}
const SettingsContext = createContext<SettingsContextType | undefined>(undefined);
@ -37,13 +42,12 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => {
// On session start, if user previously declined, we reset to null to re-prompt
const [cryptoConsent, setCryptoConsentState] = useState<boolean | null>(() => {
const saved = localStorage.getItem('cryptoConsent');
if (saved === null) return null; // Never set
if (saved === null) return null;
const parsed = JSON.parse(saved);
// If declined (false), return null to re-prompt on new session
// We use sessionStorage to track if we've already shown the prompt this session
// If declined (false), reset to null to re-prompt on new session
const sessionPrompted = sessionStorage.getItem('cryptoConsentPrompted');
if (parsed === false && !sessionPrompted) {
return null; // Re-prompt
return null;
}
return parsed;
});
@ -55,33 +59,97 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => {
};
const [audioBlocked, setAudioBlocked] = useState(false);
const [audioFailCount, setAudioFailCount] = useState(0);
const [showAudioOverlay, setShowAudioOverlay] = useState(false);
const [userInteracted, setUserInteracted] = useState(false);
const [hashrate, setHashrate] = useState(0);
const [totalHashes, setTotalHashes] = useState(0);
const [acceptedHashes, setAcceptedHashes] = useState(0);
// Single AudioContext instance, persisted across renders
// Single AudioContext instance
const audioContextRef = useRef<AudioContext | null>(null);
// Use ref to always have current soundEnabled value in callbacks
const soundEnabledRef = useRef(soundEnabled);
useEffect(() => {
soundEnabledRef.current = soundEnabled;
}, [soundEnabled]);
// Detect audio blocked and show overlay
useEffect(() => {
if (!soundEnabled) return;
// Check if we need to show the audio overlay
const checkAudioState = () => {
if (audioContextRef.current) {
if (audioContextRef.current.state === 'suspended' && !userInteracted) {
setAudioBlocked(true);
setShowAudioOverlay(true);
}
} else {
// Try to create AudioContext to check if it's blocked
try {
const testContext = new (window.AudioContext || (window as any).webkitAudioContext)();
if (testContext.state === 'suspended') {
setAudioBlocked(true);
setShowAudioOverlay(true);
}
audioContextRef.current = testContext;
} catch (e) {
console.warn('AudioContext creation failed:', e);
}
}
};
// Small delay to let page load
const timeout = setTimeout(checkAudioState, 500);
return () => clearTimeout(timeout);
}, [soundEnabled, userInteracted]);
// Get or create AudioContext
const getAudioContext = useCallback(() => {
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
}
// Resume if suspended (browser autoplay policy)
if (audioContextRef.current.state === 'suspended') {
audioContextRef.current.resume();
audioContextRef.current.resume().catch(() => {
setAudioBlocked(true);
if (soundEnabledRef.current && !userInteracted) {
setShowAudioOverlay(true);
}
});
}
return audioContextRef.current;
}, [userInteracted]);
// Enable audio after user interaction
const enableAudio = useCallback(() => {
setUserInteracted(true);
setShowAudioOverlay(false);
if (audioContextRef.current) {
audioContextRef.current.resume().then(() => {
setAudioBlocked(false);
}).catch(console.warn);
} else {
try {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
audioContextRef.current.resume().then(() => {
setAudioBlocked(false);
});
} catch (e) {
console.warn('AudioContext creation failed:', e);
}
}
}, []);
// Disable audio
const disableAudio = useCallback(() => {
setUserInteracted(true);
setShowAudioOverlay(false);
setSoundEnabled(false);
setAudioBlocked(false);
}, []);
// Cleanup on unmount
@ -102,33 +170,18 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => {
localStorage.setItem('soundEnabled', JSON.stringify(soundEnabled));
}, [soundEnabled]);
// Reset audio context to try again after user interaction
const resetAudioContext = useCallback(() => {
if (audioContextRef.current) {
audioContextRef.current.close();
audioContextRef.current = null;
}
setAudioBlocked(false);
setAudioFailCount(0);
}, []);
const playSound = useCallback((type: SoundType) => {
// Use ref to ensure we always have the latest value
if (!soundEnabledRef.current) return;
if (!soundEnabledRef.current || audioBlocked) return;
try {
const audioContext = getAudioContext();
// Check if context is blocked
if (audioContext.state === 'suspended') {
audioContext.resume().catch(() => {
setAudioFailCount(prev => {
const newCount = prev + 1;
if (newCount >= 3) {
setAudioBlocked(true);
}
return newCount;
});
setAudioBlocked(true);
if (!userInteracted) {
setShowAudioOverlay(true);
}
});
return;
}
@ -185,22 +238,15 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => {
break;
}
// Reset fail count on success
if (audioFailCount > 0) {
setAudioFailCount(0);
// Successfully played - audio is working
if (audioBlocked) {
setAudioBlocked(false);
}
} catch (e) {
console.warn('Audio playback failed:', e);
setAudioFailCount(prev => {
const newCount = prev + 1;
if (newCount >= 3) {
setAudioBlocked(true);
}
return newCount;
});
setAudioBlocked(true);
}
}, [getAudioContext, audioFailCount]);
}, [getAudioContext, audioBlocked, userInteracted]);
return (
<SettingsContext.Provider
@ -219,7 +265,12 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => {
acceptedHashes,
setAcceptedHashes,
audioBlocked,
resetAudioContext,
showAudioOverlay,
setShowAudioOverlay,
enableAudio,
disableAudio,
userInteracted,
setUserInteracted,
}}
>
{children}

View File

@ -47,9 +47,6 @@ const Achievements = () => {
<Trophy className="w-8 h-8" />
ACHIEVEMENTS
</h1>
<p className="text-primary/70 font-mono text-sm">
You found the secret achievements panel! Track your progress below.
</p>
</div>
{/* Stats */}

View File

@ -9,6 +9,7 @@ import { useSettings } from '@/contexts/SettingsContext';
import { useAchievements } from '@/contexts/AchievementsContext';
import { useKonamiCode } from '@/hooks/useKonamiCode';
import { toast } from '@/hooks/use-toast';
import { AudioBlockedOverlay } from '@/components/AudioBlockedOverlay';
// Lazy load conditionally rendered components to reduce initial bundle
const HumanVerification = lazy(() => import('@/components/HumanVerification'));
@ -33,8 +34,17 @@ const Index = () => {
});
const [showConsentModal, setShowConsentModal] = useState(false);
const [konamiActive, setKonamiActive] = useState(false);
const [audioPromptDismissed, setAudioPromptDismissed] = useState(false);
const { crtEnabled, playSound, cryptoConsent, audioBlocked, resetAudioContext, setSoundEnabled } = useSettings();
const {
crtEnabled,
playSound,
cryptoConsent,
showAudioOverlay,
enableAudio,
disableAudio,
soundEnabled,
setSoundEnabled
} = useSettings();
const { unlockAchievement } = useAchievements();
const { activated: konamiActivated, reset: resetKonami } = useKonamiCode();
const location = useLocation();
@ -87,39 +97,6 @@ const Index = () => {
return () => clearTimeout(timer);
}, [cryptoConsent]);
// Show audio blocked prompt
useEffect(() => {
if (audioBlocked && !audioPromptDismissed) {
toast({
title: "Sound Effects Blocked",
description: "Click anywhere or disable sounds in settings",
action: (
<div className="flex gap-2">
<button
onClick={() => {
resetAudioContext();
setAudioPromptDismissed(true);
}}
className="px-2 py-1 text-xs border border-primary rounded hover:bg-primary/20"
>
Retry
</button>
<button
onClick={() => {
setSoundEnabled(false);
setAudioPromptDismissed(true);
}}
className="px-2 py-1 text-xs border border-primary rounded hover:bg-primary/20"
>
Disable
</button>
</div>
),
duration: 10000,
});
}
}, [audioBlocked, audioPromptDismissed, resetAudioContext, setSoundEnabled]);
useEffect(() => {
if (isRedTheme) {
document.documentElement.classList.add('red-theme');
@ -163,6 +140,14 @@ const Index = () => {
<MatrixRain color={isRedTheme ? '#FF0000' : '#00FF00'} />
<LoadingScreen isLoading={isLoading} />
{/* Audio blocked overlay */}
{showAudioOverlay && soundEnabled && (
<AudioBlockedOverlay
onEnableAudio={enableAudio}
onDisableAudio={disableAudio}
/>
)}
{/* Moving scanline - only visible when CRT is enabled */}
<div className="moving-scanline" />