mirror of
https://github.com/JorySeverijnse/ui-fixer-supreme.git
synced 2026-01-29 20:08:37 +00:00
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
This commit is contained in:
commit
21238dec45
55
src/components/AudioBlockedOverlay.tsx
Normal file
55
src/components/AudioBlockedOverlay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,49 +1,33 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { RefreshCw, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
const BYPASS_KEY = 'bypass-human-check';
|
export const BYPASS_KEY = 'bypass-human-check';
|
||||||
const VERIFIED_KEY = 'human-verified';
|
export const VERIFIED_KEY = 'human-verified';
|
||||||
|
|
||||||
interface HumanVerificationProps {
|
interface HumanVerificationProps {
|
||||||
onVerified: () => void;
|
onVerified: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a simple math equation
|
interface PuzzlePiece {
|
||||||
const generateEquation = () => {
|
targetX: number;
|
||||||
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 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 canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [puzzle, setPuzzle] = useState<PuzzlePiece | null>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [sliderX, setSliderX] = useState(0);
|
||||||
|
const [isVerified, setIsVerified] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Check for bypass in URL
|
const CANVAS_WIDTH = 320;
|
||||||
|
const CANVAS_HEIGHT = 160;
|
||||||
|
const PIECE_SIZE = 44;
|
||||||
|
const TOLERANCE = 10;
|
||||||
|
|
||||||
|
// Check for bypass in URL on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (params.has(BYPASS_KEY) || window.location.pathname.includes(BYPASS_KEY)) {
|
if (params.has(BYPASS_KEY) || window.location.pathname.includes(BYPASS_KEY)) {
|
||||||
@ -52,213 +36,285 @@ const HumanVerification = ({ onVerified }: HumanVerificationProps) => {
|
|||||||
}
|
}
|
||||||
}, [onVerified]);
|
}, [onVerified]);
|
||||||
|
|
||||||
// Draw equation on canvas for added security
|
const generatePuzzle = useCallback(() => {
|
||||||
useEffect(() => {
|
const targetX = Math.floor(Math.random() * (CANVAS_WIDTH - PIECE_SIZE - 100)) + 80;
|
||||||
const canvas = canvasRef.current;
|
setPuzzle({ targetX });
|
||||||
if (!canvas) return;
|
setSliderX(0);
|
||||||
|
setError(false);
|
||||||
|
setIsVerified(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
generatePuzzle();
|
||||||
|
}, [generatePuzzle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!puzzle || !canvasRef.current) return;
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
// Get computed styles for theming
|
// Get theme color
|
||||||
const computedStyle = getComputedStyle(document.documentElement);
|
const computedStyle = getComputedStyle(document.documentElement);
|
||||||
const primaryColor = computedStyle.getPropertyValue('--primary').trim();
|
const primaryHsl = computedStyle.getPropertyValue('--primary').trim();
|
||||||
const hslMatch = primaryColor.match(/[\d.]+/g);
|
const hslParts = primaryHsl.split(' ');
|
||||||
const primaryRGB = hslMatch
|
const h = parseFloat(hslParts[0]) || 120;
|
||||||
? `hsl(${hslMatch[0]}, ${hslMatch[1]}%, ${hslMatch[2]}%)`
|
const s = parseFloat(hslParts[1]) || 100;
|
||||||
: '#00ff00';
|
const l = parseFloat(hslParts[2]) || 50;
|
||||||
|
const primaryColor = `hsl(${h}, ${s}%, ${l}%)`;
|
||||||
|
const dimColor = `hsla(${h}, ${s}%, ${l}%, 0.3)`;
|
||||||
|
const bgColor = `hsla(${h}, ${s}%, ${l}%, 0.05)`;
|
||||||
|
|
||||||
// Clear canvas
|
// Clear canvas with dark background
|
||||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
|
ctx.fillStyle = '#0a0a0a';
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
|
||||||
|
|
||||||
// Draw grid lines for terminal feel
|
// Draw matrix grid
|
||||||
ctx.strokeStyle = 'rgba(0, 255, 0, 0.1)';
|
ctx.strokeStyle = dimColor;
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 0.5;
|
||||||
for (let i = 0; i < canvas.width; i += 20) {
|
for (let x = 0; x < CANVAS_WIDTH; x += 16) {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(i, 0);
|
ctx.moveTo(x, 0);
|
||||||
ctx.lineTo(i, canvas.height);
|
ctx.lineTo(x, CANVAS_HEIGHT);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
for (let i = 0; i < canvas.height; i += 20) {
|
for (let y = 0; y < CANVAS_HEIGHT; y += 16) {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(0, i);
|
ctx.moveTo(0, y);
|
||||||
ctx.lineTo(canvas.width, i);
|
ctx.lineTo(CANVAS_WIDTH, y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw random matrix code in background
|
||||||
|
ctx.font = '10px monospace';
|
||||||
|
ctx.fillStyle = `hsla(${h}, ${s}%, ${l}%, 0.15)`;
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const x = Math.random() * CANVAS_WIDTH;
|
||||||
|
const y = Math.random() * CANVAS_HEIGHT;
|
||||||
|
const chars = '01アイウエオカキクケコサシスセソ';
|
||||||
|
ctx.fillText(chars[Math.floor(Math.random() * chars.length)], x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target slot position
|
||||||
|
const slotY = (CANVAS_HEIGHT - PIECE_SIZE) / 2;
|
||||||
|
|
||||||
|
// Draw target slot with glow
|
||||||
|
ctx.shadowColor = primaryColor;
|
||||||
|
ctx.shadowBlur = 8;
|
||||||
|
ctx.fillStyle = bgColor;
|
||||||
|
ctx.strokeStyle = dimColor;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
|
||||||
|
// Draw slot shape with puzzle notch
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(puzzle.targetX, slotY, PIECE_SIZE, PIECE_SIZE, 4);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Inner slot pattern
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.strokeStyle = `hsla(${h}, ${s}%, ${l}%, 0.2)`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(puzzle.targetX + 8, slotY + 8, PIECE_SIZE - 16, PIECE_SIZE - 16);
|
||||||
|
ctx.strokeRect(puzzle.targetX + 14, slotY + 14, PIECE_SIZE - 28, PIECE_SIZE - 28);
|
||||||
|
|
||||||
|
// Calculate piece position based on slider
|
||||||
|
const maxSlide = trackRef.current ? trackRef.current.clientWidth - 48 : 280;
|
||||||
|
const pieceX = (sliderX / maxSlide) * (CANVAS_WIDTH - PIECE_SIZE - 10) + 5;
|
||||||
|
const pieceY = slotY;
|
||||||
|
|
||||||
|
// Determine piece color
|
||||||
|
let pieceColor = primaryColor;
|
||||||
|
let pieceBorderColor = primaryColor;
|
||||||
|
if (error) {
|
||||||
|
pieceColor = 'hsl(0, 70%, 50%)';
|
||||||
|
pieceBorderColor = 'hsl(0, 70%, 60%)';
|
||||||
|
} else if (isVerified) {
|
||||||
|
pieceColor = 'hsl(142, 76%, 36%)';
|
||||||
|
pieceBorderColor = 'hsl(142, 76%, 50%)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw puzzle piece with glow
|
||||||
|
ctx.shadowColor = pieceBorderColor;
|
||||||
|
ctx.shadowBlur = 12;
|
||||||
|
ctx.fillStyle = pieceColor;
|
||||||
|
ctx.strokeStyle = pieceBorderColor;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(pieceX, pieceY, PIECE_SIZE, PIECE_SIZE, 4);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
// Draw inner pattern on piece
|
||||||
|
ctx.strokeStyle = 'rgba(0, 0, 0, 0.4)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(pieceX + 8, pieceY + 8, PIECE_SIZE - 16, PIECE_SIZE - 16);
|
||||||
|
|
||||||
|
// Draw arrow on piece
|
||||||
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
|
||||||
|
ctx.beginPath();
|
||||||
|
const arrowX = pieceX + PIECE_SIZE / 2;
|
||||||
|
const arrowY = pieceY + PIECE_SIZE / 2;
|
||||||
|
ctx.moveTo(arrowX - 6, arrowY - 5);
|
||||||
|
ctx.lineTo(arrowX + 6, arrowY);
|
||||||
|
ctx.lineTo(arrowX - 6, arrowY + 5);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Draw scanlines
|
||||||
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.08)';
|
||||||
|
for (let y = 0; y < CANVAS_HEIGHT; y += 2) {
|
||||||
|
ctx.fillRect(0, y, CANVAS_WIDTH, 1);
|
||||||
|
}
|
||||||
|
|
||||||
// Draw border
|
// Draw border
|
||||||
ctx.strokeStyle = primaryRGB;
|
ctx.strokeStyle = dimColor;
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 1;
|
||||||
ctx.strokeRect(2, 2, canvas.width - 4, canvas.height - 4);
|
ctx.strokeRect(0.5, 0.5, CANVAS_WIDTH - 1, CANVAS_HEIGHT - 1);
|
||||||
|
|
||||||
// Draw equation with slight random positioning for anti-bot
|
}, [puzzle, sliderX, error, isVerified]);
|
||||||
const offsetX = Math.random() * 10 - 5;
|
|
||||||
const offsetY = Math.random() * 6 - 3;
|
|
||||||
|
|
||||||
ctx.font = 'bold 48px monospace';
|
const handleMouseDown = () => {
|
||||||
ctx.fillStyle = primaryRGB;
|
setIsDragging(true);
|
||||||
ctx.textAlign = 'center';
|
setError(false);
|
||||||
ctx.textBaseline = 'middle';
|
};
|
||||||
|
|
||||||
// Add subtle noise/distortion
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
const chars = equation.split('');
|
if (!isDragging || !trackRef.current) return;
|
||||||
let xPos = canvas.width / 2 - (chars.length * 15) + offsetX;
|
const rect = trackRef.current.getBoundingClientRect();
|
||||||
|
const x = Math.max(0, Math.min(e.clientX - rect.left - 24, rect.width - 48));
|
||||||
|
setSliderX(x);
|
||||||
|
};
|
||||||
|
|
||||||
chars.forEach((char) => {
|
const handleMouseUp = () => {
|
||||||
const yOffset = Math.random() * 4 - 2;
|
if (!isDragging || !puzzle || !trackRef.current) return;
|
||||||
ctx.fillText(char, xPos, canvas.height / 2 + offsetY + yOffset);
|
setIsDragging(false);
|
||||||
xPos += 30;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Draw "= ?" at the end
|
const maxSlide = trackRef.current.clientWidth - 48;
|
||||||
ctx.fillText('= ?', xPos + 20, canvas.height / 2 + offsetY);
|
const pieceX = (sliderX / maxSlide) * (CANVAS_WIDTH - PIECE_SIZE - 10) + 5;
|
||||||
|
const diff = Math.abs(pieceX - puzzle.targetX);
|
||||||
|
|
||||||
// Add scanline effect
|
if (diff <= TOLERANCE) {
|
||||||
for (let y = 0; y < canvas.height; y += 3) {
|
setIsVerified(true);
|
||||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.15)';
|
|
||||||
ctx.fillRect(0, y, canvas.width, 1);
|
|
||||||
}
|
|
||||||
}, [equation]);
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const parsed = parseInt(userAnswer, 10);
|
|
||||||
|
|
||||||
if (!isNaN(parsed) && parsed === answer) {
|
|
||||||
localStorage.setItem(VERIFIED_KEY, 'true');
|
localStorage.setItem(VERIFIED_KEY, 'true');
|
||||||
onVerified();
|
setTimeout(() => onVerified(), 600);
|
||||||
} else {
|
} else {
|
||||||
setError(true);
|
setError(true);
|
||||||
setAttempts(prev => prev + 1);
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
setSliderX(0);
|
||||||
setError(false);
|
setError(false);
|
||||||
// Generate new equation after 3 failed attempts
|
}, 400);
|
||||||
if (attempts >= 2) {
|
|
||||||
setEquation(generateEquation());
|
|
||||||
setAttempts(0);
|
|
||||||
}
|
|
||||||
}, 600);
|
|
||||||
setUserAnswer('');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNewEquation = () => {
|
const handleTouchStart = () => {
|
||||||
setEquation(generateEquation());
|
setIsDragging(true);
|
||||||
setUserAnswer('');
|
|
||||||
setError(false);
|
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 (
|
return (
|
||||||
|
<div className="fixed inset-0 z-[100] bg-background flex items-center justify-center p-4">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
className="fixed inset-0 z-[100] bg-background flex items-center justify-center p-4"
|
className="w-full max-w-sm space-y-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 */}
|
{/* Header */}
|
||||||
<div className="text-center mb-6">
|
<div className="text-center border border-primary/40 bg-primary/5 py-3 px-4">
|
||||||
<pre className="font-mono text-[10px] sm:text-xs text-primary leading-tight mb-4">
|
<div className="text-primary font-mono text-sm tracking-wider font-bold">
|
||||||
{`┌─────────────────────────────────────┐
|
SECURITY VERIFICATION v3.0
|
||||||
│ SECURITY VERIFICATION v2.0 │
|
</div>
|
||||||
│ ANTI-BOT PROTOCOL │
|
<div className="text-primary/60 font-mono text-xs">
|
||||||
└─────────────────────────────────────┘`}
|
ANTI-BOT PROTOCOL
|
||||||
</pre>
|
</div>
|
||||||
<p className="font-mono text-sm text-foreground/70">
|
|
||||||
{'>'} Solve the equation to verify humanity
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Canvas with equation */}
|
{/* Instructions */}
|
||||||
<div className="mb-6 flex justify-center">
|
<div className="text-primary font-mono text-sm">
|
||||||
|
{'>'} Slide the piece to complete the puzzle
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Canvas puzzle area */}
|
||||||
|
<div className="border border-primary/40 bg-background overflow-hidden relative">
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
width={320}
|
width={CANVAS_WIDTH}
|
||||||
height={100}
|
height={CANVAS_HEIGHT}
|
||||||
className="border border-primary/30 rounded"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input form */}
|
{/* Slider track */}
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<div
|
||||||
<div className="relative">
|
ref={trackRef}
|
||||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-primary font-mono">
|
className={`relative h-12 border-2 ${
|
||||||
{'>'}_
|
error ? 'border-destructive bg-destructive/10' :
|
||||||
|
isVerified ? 'border-green-500 bg-green-500/10' :
|
||||||
|
'border-primary/40 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 ${
|
||||||
|
isVerified ? 'text-green-500' : 'text-primary/40'
|
||||||
|
}`}>
|
||||||
|
{isVerified ? '[ ACCESS GRANTED ]' : '>>> SLIDE TO VERIFY >>>'}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
{/* Slider handle */}
|
||||||
{error && (
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -10 }}
|
className={`absolute top-0 h-full w-12 flex items-center justify-center cursor-grab active:cursor-grabbing ${
|
||||||
animate={{ opacity: 1, y: 0 }}
|
error ? 'bg-destructive' :
|
||||||
exit={{ opacity: 0 }}
|
isVerified ? 'bg-green-500' :
|
||||||
className="font-mono text-sm text-destructive flex items-center gap-2"
|
'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.25 }}
|
||||||
>
|
>
|
||||||
<span>✗</span>
|
<ChevronRight className="w-5 h-5 text-background" />
|
||||||
<span>INCORRECT - {3 - attempts} attempts remaining</span>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
</div>
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
{/* Refresh */}
|
||||||
|
<div className="flex justify-between items-center text-xs font-mono text-muted-foreground">
|
||||||
|
<span>Protocol: SLIDER-VERIFY-3.0</span>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
onClick={generatePuzzle}
|
||||||
disabled={!userAnswer}
|
className="flex items-center gap-1 text-primary hover:text-primary/80 transition-colors"
|
||||||
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]
|
<RefreshCw className="w-3 h-3" />
|
||||||
</button>
|
Refresh
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="mt-6 pt-4 border-t border-primary/20 space-y-2">
|
<div className="text-center text-muted-foreground/50 font-mono text-xs">
|
||||||
<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
|
// This verification helps protect against automated access
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.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>
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HumanVerification;
|
export default HumanVerification;
|
||||||
export { VERIFIED_KEY, BYPASS_KEY };
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@ -22,16 +22,43 @@ const Sidebar = () => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { playSound } = useSettings();
|
const { playSound } = useSettings();
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
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';
|
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 = () => {
|
const toggleMenu = () => {
|
||||||
setIsExpanded(!isExpanded);
|
setIsExpanded(!isExpanded);
|
||||||
playSound('click');
|
playSound('click');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Mobile Toggle Header */}
|
||||||
<button
|
<button
|
||||||
onClick={toggleMenu}
|
onClick={toggleMenu}
|
||||||
@ -50,7 +77,7 @@ const Sidebar = () => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Desktop Navigation - Always visible */}
|
{/* Desktop Navigation - Always visible */}
|
||||||
<nav className="hidden md:block flex-grow overflow-hidden p-4 md:p-5">
|
<nav ref={navRef} className="hidden md:block flex-grow overflow-y-auto p-4 md:p-5">
|
||||||
{navItems.map((item, index) => {
|
{navItems.map((item, index) => {
|
||||||
const isActive = location.pathname === item.path;
|
const isActive = location.pathname === item.path;
|
||||||
|
|
||||||
@ -80,6 +107,15 @@ const Sidebar = () => {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Inline footer when sidebar is too short */}
|
||||||
|
{!showFooter && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-primary/30">
|
||||||
|
<p className="font-pixel text-sm text-primary text-glow text-center">
|
||||||
|
Access Granted
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Mobile Navigation - Collapsible */}
|
{/* Mobile Navigation - Collapsible */}
|
||||||
@ -127,12 +163,14 @@ const Sidebar = () => {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Desktop Footer */}
|
{/* Desktop Footer - Only shown if visible */}
|
||||||
<div className="hidden md:block p-4 border-t border-primary/30">
|
{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">
|
<p className="font-pixel text-sm text-primary text-glow text-center">
|
||||||
Access Granted
|
Access Granted
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
318
src/components/SliderVerification.tsx
Normal file
318
src/components/SliderVerification.tsx
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { RefreshCw, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SliderVerificationProps {
|
||||||
|
onVerified: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PuzzlePiece {
|
||||||
|
targetX: number;
|
||||||
|
currentX: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SliderVerification = ({ onVerified }: SliderVerificationProps) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [puzzle, setPuzzle] = useState<PuzzlePiece | null>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [sliderX, setSliderX] = useState(0);
|
||||||
|
const [isVerified, setIsVerified] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const sliderRef = useRef<HTMLDivElement>(null);
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const CANVAS_WIDTH = 320;
|
||||||
|
const CANVAS_HEIGHT = 180;
|
||||||
|
const PIECE_SIZE = 50;
|
||||||
|
const TOLERANCE = 8;
|
||||||
|
|
||||||
|
const generatePuzzle = useCallback(() => {
|
||||||
|
const targetX = Math.floor(Math.random() * (CANVAS_WIDTH - PIECE_SIZE - 80)) + 60;
|
||||||
|
setPuzzle({ targetX, currentX: 0 });
|
||||||
|
setSliderX(0);
|
||||||
|
setError(false);
|
||||||
|
setIsVerified(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
generatePuzzle();
|
||||||
|
}, [generatePuzzle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!puzzle || !canvasRef.current) return;
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
ctx.fillStyle = 'hsl(var(--background))';
|
||||||
|
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
|
||||||
|
|
||||||
|
// Draw matrix-style grid pattern
|
||||||
|
ctx.strokeStyle = 'hsl(var(--primary) / 0.1)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let x = 0; x < CANVAS_WIDTH; x += 20) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, CANVAS_HEIGHT);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = 0; y < CANVAS_HEIGHT; y += 20) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(CANVAS_WIDTH, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw random "code" elements
|
||||||
|
ctx.fillStyle = 'hsl(var(--primary) / 0.15)';
|
||||||
|
ctx.font = '10px monospace';
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
const x = Math.random() * CANVAS_WIDTH;
|
||||||
|
const y = Math.random() * CANVAS_HEIGHT;
|
||||||
|
const chars = ['0', '1', 'A', 'F', 'X', '#', '@'];
|
||||||
|
ctx.fillText(chars[Math.floor(Math.random() * chars.length)], x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw target slot (where puzzle piece should go)
|
||||||
|
const slotY = (CANVAS_HEIGHT - PIECE_SIZE) / 2;
|
||||||
|
ctx.fillStyle = 'hsl(var(--primary) / 0.2)';
|
||||||
|
ctx.strokeStyle = 'hsl(var(--primary) / 0.5)';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
|
||||||
|
// Draw puzzle slot with notch
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(puzzle.targetX, slotY);
|
||||||
|
ctx.lineTo(puzzle.targetX + PIECE_SIZE, slotY);
|
||||||
|
ctx.lineTo(puzzle.targetX + PIECE_SIZE, slotY + PIECE_SIZE);
|
||||||
|
ctx.lineTo(puzzle.targetX, slotY + PIECE_SIZE);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw inner pattern for slot
|
||||||
|
ctx.strokeStyle = 'hsl(var(--primary) / 0.3)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const offset = i * 12 + 8;
|
||||||
|
ctx.strokeRect(
|
||||||
|
puzzle.targetX + offset / 2,
|
||||||
|
slotY + offset / 2,
|
||||||
|
PIECE_SIZE - offset,
|
||||||
|
PIECE_SIZE - offset
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw the draggable puzzle piece at current position
|
||||||
|
const pieceX = sliderX * (CANVAS_WIDTH - PIECE_SIZE) / (trackRef.current?.clientWidth || 280);
|
||||||
|
const pieceY = slotY;
|
||||||
|
|
||||||
|
// Piece shadow/glow
|
||||||
|
ctx.shadowColor = 'hsl(var(--primary))';
|
||||||
|
ctx.shadowBlur = 10;
|
||||||
|
|
||||||
|
ctx.fillStyle = error
|
||||||
|
? 'hsl(var(--destructive) / 0.8)'
|
||||||
|
: isVerified
|
||||||
|
? 'hsl(142 76% 36% / 0.8)'
|
||||||
|
: 'hsl(var(--primary) / 0.8)';
|
||||||
|
ctx.strokeStyle = error
|
||||||
|
? 'hsl(var(--destructive))'
|
||||||
|
: isVerified
|
||||||
|
? 'hsl(142 76% 36%)'
|
||||||
|
: 'hsl(var(--primary))';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pieceX, pieceY);
|
||||||
|
ctx.lineTo(pieceX + PIECE_SIZE, pieceY);
|
||||||
|
ctx.lineTo(pieceX + PIECE_SIZE, pieceY + PIECE_SIZE);
|
||||||
|
ctx.lineTo(pieceX, pieceY + PIECE_SIZE);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
// Draw inner pattern for piece
|
||||||
|
ctx.strokeStyle = 'hsl(var(--background) / 0.5)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const offset = i * 12 + 8;
|
||||||
|
ctx.strokeRect(
|
||||||
|
pieceX + offset / 2,
|
||||||
|
pieceY + offset / 2,
|
||||||
|
PIECE_SIZE - offset,
|
||||||
|
PIECE_SIZE - offset
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw arrow indicator on piece
|
||||||
|
ctx.fillStyle = 'hsl(var(--background))';
|
||||||
|
ctx.beginPath();
|
||||||
|
const arrowX = pieceX + PIECE_SIZE / 2;
|
||||||
|
const arrowY = pieceY + PIECE_SIZE / 2;
|
||||||
|
ctx.moveTo(arrowX - 8, arrowY - 5);
|
||||||
|
ctx.lineTo(arrowX + 5, arrowY);
|
||||||
|
ctx.lineTo(arrowX - 8, arrowY + 5);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
}, [puzzle, sliderX, error, isVerified]);
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
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 - 20, rect.width - 40));
|
||||||
|
setSliderX(x);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
if (!isDragging || !puzzle || !trackRef.current) return;
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
// Calculate if piece is in correct position
|
||||||
|
const pieceX = sliderX * (CANVAS_WIDTH - PIECE_SIZE) / (trackRef.current.clientWidth - 40);
|
||||||
|
const diff = Math.abs(pieceX - puzzle.targetX);
|
||||||
|
|
||||||
|
if (diff <= TOLERANCE) {
|
||||||
|
setIsVerified(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
onVerified();
|
||||||
|
}, 800);
|
||||||
|
} else {
|
||||||
|
setError(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setSliderX(0);
|
||||||
|
setError(false);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
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 - 20, rect.width - 40));
|
||||||
|
setSliderX(x);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
handleMouseUp();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-background flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="w-full max-w-md space-y-4"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center border border-primary/30 bg-primary/5 py-3 px-4">
|
||||||
|
<div className="text-primary font-mono text-sm tracking-wider">
|
||||||
|
SECURITY VERIFICATION v3.0
|
||||||
|
</div>
|
||||||
|
<div className="text-primary/60 font-mono text-xs">
|
||||||
|
ANTI-BOT PROTOCOL
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<div className="text-primary font-mono text-sm">
|
||||||
|
{'>'} Slide the puzzle piece to complete verification
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Canvas puzzle area */}
|
||||||
|
<div className="border border-primary/40 bg-background p-1 relative overflow-hidden">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={CANVAS_WIDTH}
|
||||||
|
height={CANVAS_HEIGHT}
|
||||||
|
className="w-full"
|
||||||
|
style={{ imageRendering: 'pixelated' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Scanline effect */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 pointer-events-none opacity-20"
|
||||||
|
style={{
|
||||||
|
background: 'repeating-linear-gradient(0deg, transparent, transparent 2px, hsl(var(--primary) / 0.03) 2px, hsl(var(--primary) / 0.03) 4px)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slider track */}
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
className={`relative h-12 border-2 ${
|
||||||
|
error ? 'border-destructive bg-destructive/10' :
|
||||||
|
isVerified ? 'border-green-500 bg-green-500/10' :
|
||||||
|
'border-primary/40 bg-primary/5'
|
||||||
|
} transition-colors duration-200 cursor-pointer select-none`}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
{/* Track label */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<span className={`font-mono text-sm ${
|
||||||
|
isVerified ? 'text-green-500' : 'text-primary/40'
|
||||||
|
}`}>
|
||||||
|
{isVerified ? '[ VERIFIED ]' : '>>> SLIDE TO VERIFY >>>'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slider handle */}
|
||||||
|
<motion.div
|
||||||
|
ref={sliderRef}
|
||||||
|
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, -5, 5, -5, 5, 0] } : {}}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-6 h-6 text-background" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Refresh button */}
|
||||||
|
<div className="flex justify-between items-center text-xs font-mono text-muted-foreground">
|
||||||
|
<span>Protocol: SLIDER-VERIFY-3.0</span>
|
||||||
|
<button
|
||||||
|
onClick={generatePuzzle}
|
||||||
|
className="flex items-center gap-1 text-primary hover:text-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3 h-3" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="text-center text-muted-foreground/50 font-mono text-xs">
|
||||||
|
// This verification helps protect against automated access
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -63,6 +63,25 @@ interface AchievementsContextType {
|
|||||||
unlockMaxScore: () => void;
|
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[] = [
|
const defaultAchievements: Achievement[] = [
|
||||||
// Discovery achievements
|
// Discovery achievements
|
||||||
{ id: 'first_visit', name: 'Hello World', description: 'Visit the site for the first time', icon: '👋', unlocked: false },
|
{ 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: '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 },
|
{ id: 'hint_seeker', name: 'Hint Seeker', description: 'Ask for a hint in the terminal', icon: '🔍', unlocked: false, secret: true },
|
||||||
|
|
||||||
// Navigation achievements
|
// Navigation achievements - now require 1 minute on page
|
||||||
{ id: 'home_visitor', name: 'Home Base', description: 'Visit the home page', icon: '🏠', unlocked: false },
|
{ id: 'home_visitor', name: 'Home Base', description: 'Spend 1 minute on the home page', icon: '🏠', unlocked: false },
|
||||||
{ id: 'about_visitor', name: 'Getting Personal', description: 'Learn about the site owner', 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: 'Check out the projects', 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: 'Browse the resources page', 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: 'Visit the links page', 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: 'Read the FAQ', 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: 'Open the music player', 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: 'Chat with the AI', 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: 'Visit the arcade', 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: 'Visit every page on the site', icon: '🗺️', unlocked: false, secret: true },
|
{ id: 'all_pages', name: 'Completionist', description: 'Spend 1 minute on every page', icon: '🗺️', unlocked: false, secret: true },
|
||||||
|
|
||||||
// Time achievements
|
// Time achievements
|
||||||
{ id: 'time_15min', name: 'Quick Visit', description: 'Spend 15 minutes on the site', icon: '⏱️', unlocked: false },
|
{ 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');
|
if (hour >= 5 && hour < 7) unlockAchievement('early_bird');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Track page visits
|
// Track page visits and time spent
|
||||||
|
const [currentPath, setCurrentPath] = useState(location.pathname);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const path = location.pathname;
|
const path = location.pathname;
|
||||||
|
setCurrentPath(path);
|
||||||
|
|
||||||
setVisitedPages(prev => {
|
setVisitedPages(prev => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
@ -194,7 +216,13 @@ export const AchievementsProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Page-specific achievements
|
// 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 === '/') unlockAchievement('home_visitor');
|
||||||
if (path === '/about') unlockAchievement('about_visitor');
|
if (path === '/about') unlockAchievement('about_visitor');
|
||||||
if (path === '/projects') unlockAchievement('projects_visitor');
|
if (path === '/projects') unlockAchievement('projects_visitor');
|
||||||
@ -204,20 +232,26 @@ export const AchievementsProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
if (path === '/music') unlockAchievement('music_visitor');
|
if (path === '/music') unlockAchievement('music_visitor');
|
||||||
if (path === '/ai') unlockAchievement('ai_visitor');
|
if (path === '/ai') unlockAchievement('ai_visitor');
|
||||||
if (path === '/games') unlockAchievement('arcade_visitor');
|
if (path === '/games') unlockAchievement('arcade_visitor');
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Immediate unlocks for games and detail pages
|
||||||
if (path.startsWith('/projects/')) unlockAchievement('project_detail');
|
if (path.startsWith('/projects/')) unlockAchievement('project_detail');
|
||||||
if (path === '/games/leaderboard') unlockAchievement('leaderboard_check');
|
if (path === '/games/leaderboard') unlockAchievement('leaderboard_check');
|
||||||
if (path === '/games/tetris') unlockAchievement('tetris_played');
|
if (path === '/games/tetris') unlockAchievement('tetris_played');
|
||||||
if (path === '/games/pacman') unlockAchievement('pacman_played');
|
if (path === '/games/pacman') unlockAchievement('pacman_played');
|
||||||
if (path === '/games/snake') unlockAchievement('snake_played');
|
if (path === '/games/snake') unlockAchievement('snake_played');
|
||||||
if (path === '/games/breakout') unlockAchievement('breakout_played');
|
if (path === '/games/breakout') unlockAchievement('breakout_played');
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
// Check all pages visited
|
// Check all pages visited (1 min on each)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const requiredPages = ['/', '/about', '/projects', '/resources', '/links', '/faq', '/music', '/ai', '/games'];
|
const requiredPages = ['/', '/about', '/projects', '/resources', '/links', '/faq', '/music', '/ai', '/games'];
|
||||||
const allVisited = requiredPages.every(p => visitedPages.has(p));
|
const allVisitedLongEnough = requiredPages.every(p => getPageTime(p) >= 60);
|
||||||
if (allVisited) unlockAchievement('all_pages');
|
if (allVisitedLongEnough) unlockAchievement('all_pages');
|
||||||
}, [visitedPages]);
|
}, [visitedPages, timeOnSite]); // Check periodically with timeOnSite
|
||||||
|
|
||||||
// Check all games played
|
// Check all games played
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -7,7 +7,7 @@ interface SettingsContextType {
|
|||||||
setCrtEnabled: (enabled: boolean) => void;
|
setCrtEnabled: (enabled: boolean) => void;
|
||||||
soundEnabled: boolean;
|
soundEnabled: boolean;
|
||||||
setSoundEnabled: (enabled: boolean) => void;
|
setSoundEnabled: (enabled: boolean) => void;
|
||||||
cryptoConsent: boolean | null; // null = never asked this session
|
cryptoConsent: boolean | null;
|
||||||
setCryptoConsent: (consent: boolean) => void;
|
setCryptoConsent: (consent: boolean) => void;
|
||||||
playSound: (type: SoundType) => void;
|
playSound: (type: SoundType) => void;
|
||||||
hashrate: number;
|
hashrate: number;
|
||||||
@ -17,7 +17,12 @@ interface SettingsContextType {
|
|||||||
acceptedHashes: number;
|
acceptedHashes: number;
|
||||||
setAcceptedHashes: (hashes: number) => void;
|
setAcceptedHashes: (hashes: number) => void;
|
||||||
audioBlocked: boolean;
|
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);
|
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
|
// On session start, if user previously declined, we reset to null to re-prompt
|
||||||
const [cryptoConsent, setCryptoConsentState] = useState<boolean | null>(() => {
|
const [cryptoConsent, setCryptoConsentState] = useState<boolean | null>(() => {
|
||||||
const saved = localStorage.getItem('cryptoConsent');
|
const saved = localStorage.getItem('cryptoConsent');
|
||||||
if (saved === null) return null; // Never set
|
if (saved === null) return null;
|
||||||
const parsed = JSON.parse(saved);
|
const parsed = JSON.parse(saved);
|
||||||
// If declined (false), return null to re-prompt on new session
|
// If declined (false), reset to null to re-prompt on new session
|
||||||
// We use sessionStorage to track if we've already shown the prompt this session
|
|
||||||
const sessionPrompted = sessionStorage.getItem('cryptoConsentPrompted');
|
const sessionPrompted = sessionStorage.getItem('cryptoConsentPrompted');
|
||||||
if (parsed === false && !sessionPrompted) {
|
if (parsed === false && !sessionPrompted) {
|
||||||
return null; // Re-prompt
|
return null;
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
});
|
});
|
||||||
@ -55,33 +59,97 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [audioBlocked, setAudioBlocked] = useState(false);
|
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 [hashrate, setHashrate] = useState(0);
|
||||||
const [totalHashes, setTotalHashes] = useState(0);
|
const [totalHashes, setTotalHashes] = useState(0);
|
||||||
const [acceptedHashes, setAcceptedHashes] = useState(0);
|
const [acceptedHashes, setAcceptedHashes] = useState(0);
|
||||||
|
|
||||||
// Single AudioContext instance, persisted across renders
|
// Single AudioContext instance
|
||||||
const audioContextRef = useRef<AudioContext | null>(null);
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
|
||||||
// Use ref to always have current soundEnabled value in callbacks
|
|
||||||
const soundEnabledRef = useRef(soundEnabled);
|
const soundEnabledRef = useRef(soundEnabled);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
soundEnabledRef.current = soundEnabled;
|
soundEnabledRef.current = soundEnabled;
|
||||||
}, [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
|
// Get or create AudioContext
|
||||||
const getAudioContext = useCallback(() => {
|
const getAudioContext = useCallback(() => {
|
||||||
if (!audioContextRef.current) {
|
if (!audioContextRef.current) {
|
||||||
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
|
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resume if suspended (browser autoplay policy)
|
|
||||||
if (audioContextRef.current.state === 'suspended') {
|
if (audioContextRef.current.state === 'suspended') {
|
||||||
audioContextRef.current.resume();
|
audioContextRef.current.resume().catch(() => {
|
||||||
|
setAudioBlocked(true);
|
||||||
|
if (soundEnabledRef.current && !userInteracted) {
|
||||||
|
setShowAudioOverlay(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return audioContextRef.current;
|
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
|
// Cleanup on unmount
|
||||||
@ -102,33 +170,18 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
localStorage.setItem('soundEnabled', JSON.stringify(soundEnabled));
|
localStorage.setItem('soundEnabled', JSON.stringify(soundEnabled));
|
||||||
}, [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) => {
|
const playSound = useCallback((type: SoundType) => {
|
||||||
// Use ref to ensure we always have the latest value
|
if (!soundEnabledRef.current || audioBlocked) return;
|
||||||
if (!soundEnabledRef.current) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const audioContext = getAudioContext();
|
const audioContext = getAudioContext();
|
||||||
|
|
||||||
// Check if context is blocked
|
|
||||||
if (audioContext.state === 'suspended') {
|
if (audioContext.state === 'suspended') {
|
||||||
audioContext.resume().catch(() => {
|
audioContext.resume().catch(() => {
|
||||||
setAudioFailCount(prev => {
|
|
||||||
const newCount = prev + 1;
|
|
||||||
if (newCount >= 3) {
|
|
||||||
setAudioBlocked(true);
|
setAudioBlocked(true);
|
||||||
|
if (!userInteracted) {
|
||||||
|
setShowAudioOverlay(true);
|
||||||
}
|
}
|
||||||
return newCount;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -185,22 +238,15 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset fail count on success
|
// Successfully played - audio is working
|
||||||
if (audioFailCount > 0) {
|
if (audioBlocked) {
|
||||||
setAudioFailCount(0);
|
|
||||||
setAudioBlocked(false);
|
setAudioBlocked(false);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Audio playback failed:', e);
|
console.warn('Audio playback failed:', e);
|
||||||
setAudioFailCount(prev => {
|
|
||||||
const newCount = prev + 1;
|
|
||||||
if (newCount >= 3) {
|
|
||||||
setAudioBlocked(true);
|
setAudioBlocked(true);
|
||||||
}
|
}
|
||||||
return newCount;
|
}, [getAudioContext, audioBlocked, userInteracted]);
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [getAudioContext, audioFailCount]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsContext.Provider
|
<SettingsContext.Provider
|
||||||
@ -219,7 +265,12 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
acceptedHashes,
|
acceptedHashes,
|
||||||
setAcceptedHashes,
|
setAcceptedHashes,
|
||||||
audioBlocked,
|
audioBlocked,
|
||||||
resetAudioContext,
|
showAudioOverlay,
|
||||||
|
setShowAudioOverlay,
|
||||||
|
enableAudio,
|
||||||
|
disableAudio,
|
||||||
|
userInteracted,
|
||||||
|
setUserInteracted,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { useSettings } from '@/contexts/SettingsContext';
|
|||||||
import { useAchievements } from '@/contexts/AchievementsContext';
|
import { useAchievements } from '@/contexts/AchievementsContext';
|
||||||
import { useKonamiCode } from '@/hooks/useKonamiCode';
|
import { useKonamiCode } from '@/hooks/useKonamiCode';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import { AudioBlockedOverlay } from '@/components/AudioBlockedOverlay';
|
||||||
|
|
||||||
// Lazy load conditionally rendered components to reduce initial bundle
|
// Lazy load conditionally rendered components to reduce initial bundle
|
||||||
const HumanVerification = lazy(() => import('@/components/HumanVerification'));
|
const HumanVerification = lazy(() => import('@/components/HumanVerification'));
|
||||||
@ -33,8 +34,17 @@ const Index = () => {
|
|||||||
});
|
});
|
||||||
const [showConsentModal, setShowConsentModal] = useState(false);
|
const [showConsentModal, setShowConsentModal] = useState(false);
|
||||||
const [konamiActive, setKonamiActive] = 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 { unlockAchievement } = useAchievements();
|
||||||
const { activated: konamiActivated, reset: resetKonami } = useKonamiCode();
|
const { activated: konamiActivated, reset: resetKonami } = useKonamiCode();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@ -87,39 +97,6 @@ const Index = () => {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [cryptoConsent]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (isRedTheme) {
|
if (isRedTheme) {
|
||||||
document.documentElement.classList.add('red-theme');
|
document.documentElement.classList.add('red-theme');
|
||||||
@ -163,6 +140,14 @@ const Index = () => {
|
|||||||
<MatrixRain color={isRedTheme ? '#FF0000' : '#00FF00'} />
|
<MatrixRain color={isRedTheme ? '#FF0000' : '#00FF00'} />
|
||||||
<LoadingScreen isLoading={isLoading} />
|
<LoadingScreen isLoading={isLoading} />
|
||||||
|
|
||||||
|
{/* Audio blocked overlay */}
|
||||||
|
{showAudioOverlay && soundEnabled && (
|
||||||
|
<AudioBlockedOverlay
|
||||||
|
onEnableAudio={enableAudio}
|
||||||
|
onDisableAudio={disableAudio}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Moving scanline - only visible when CRT is enabled */}
|
{/* Moving scanline - only visible when CRT is enabled */}
|
||||||
<div className="moving-scanline" />
|
<div className="moving-scanline" />
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user