mirror of
https://github.com/Lissy93/web-check.git
synced 2025-04-29 11:54:32 +02:00
Adds interactive animated components for homepage
This commit is contained in:
parent
fa6ef6f929
commit
68f95d503c
94
src/components/homepage/AnimatedButton.astro
Normal file
94
src/components/homepage/AnimatedButton.astro
Normal file
@ -0,0 +1,94 @@
|
||||
---
|
||||
const buttonText = 'Analyze URL';
|
||||
const buttonType = 'submit';
|
||||
---
|
||||
|
||||
<button class="button" type={buttonType}>{buttonText}</button>
|
||||
|
||||
<style lang="scss">
|
||||
@property --angle {
|
||||
syntax: '<angle>';
|
||||
initial-value: 90deg;
|
||||
inherits: true;
|
||||
}
|
||||
|
||||
@property --gradX {
|
||||
syntax: '<percentage>';
|
||||
initial-value: 50%;
|
||||
inherits: true;
|
||||
}
|
||||
|
||||
@property --gradY {
|
||||
syntax: '<percentage>';
|
||||
initial-value: 0%;
|
||||
inherits: true;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 1rem;
|
||||
background: transparent;
|
||||
font-size: 2rem;
|
||||
color: var(--text-color-secondary);
|
||||
border: 2px solid var(--text-color-thirdly);
|
||||
border-radius: 2px;
|
||||
font-weight: bold;
|
||||
background: var(--background);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
--angle: 90deg;
|
||||
--gradX: 100%;
|
||||
--gradY: 50%;
|
||||
--c1: var(--primary);
|
||||
--c2: var(--text-color-thirdly);
|
||||
border-image: conic-gradient(from var(--angle), var(--c2), var(--c1) 0.5turn, var(--c1) 0.15turn, var(--c2) 0.25turn) 1;
|
||||
animation: borderRotate 3500ms linear infinite forwards;
|
||||
transition: border 0.3s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
border: 2px solid var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes borderRotate {
|
||||
0% { --angle: 90deg; }
|
||||
25% { --angle: 180deg; }
|
||||
50% { --angle: 270deg; }
|
||||
75% { --angle: 360deg; }
|
||||
100% { --angle: 450deg; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Workaround to add fancy border animation for users who use a REAL browser
|
||||
// Since CSS @property attribute isn't supported in Firefox yet
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const isFirefox = typeof (window as any).InstallTrigger !== 'undefined';
|
||||
if (isFirefox) {
|
||||
const button = document.querySelector('.button') as HTMLElement | null;
|
||||
if (button) {
|
||||
const duration = 3500;
|
||||
const startAngle = 90;
|
||||
const endAngle = 420;
|
||||
function animateAngle() {
|
||||
let start: number | null = null;
|
||||
function step(timestamp: number) {
|
||||
if (!start) start = timestamp;
|
||||
const progress = (timestamp - start) / duration;
|
||||
const angle = startAngle + progress * (endAngle - startAngle);
|
||||
|
||||
button && button.style.setProperty('--angle', `${angle}deg`);
|
||||
|
||||
if (progress < 1) {
|
||||
window.requestAnimationFrame(step);
|
||||
} else {
|
||||
start = null;
|
||||
window.requestAnimationFrame(step);
|
||||
}
|
||||
}
|
||||
window.requestAnimationFrame(step);
|
||||
}
|
||||
animateAngle();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
186
src/components/homepage/AnimatedInput.astro
Normal file
186
src/components/homepage/AnimatedInput.astro
Normal file
@ -0,0 +1,186 @@
|
||||
---
|
||||
const placeholders = [
|
||||
'duck.com',
|
||||
'github.com',
|
||||
'google.com',
|
||||
'x.com',
|
||||
'bbc.co.uk',
|
||||
'wikipedia.org',
|
||||
'openai.com',
|
||||
];
|
||||
---
|
||||
|
||||
|
||||
<div class="input-container">
|
||||
<input
|
||||
required
|
||||
id="url-input"
|
||||
type="url"
|
||||
name="url"
|
||||
placeholder="E.g. duck.com"
|
||||
/>
|
||||
<div class="placeholder-container">
|
||||
<span class="starter" aria-hidden="true">E.g.</span>
|
||||
{placeholders.map((placeholder, index) => (
|
||||
<span class={`placeholder ${index === 0 ? 'active' : ''}`} aria-hidden="true">{placeholder}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Grab the DOM elements we need
|
||||
const placeholders = document.querySelectorAll('.placeholder');
|
||||
const starter = document.querySelector('.starter') as HTMLElement;
|
||||
const inputField = document.getElementById('url-input') as HTMLInputElement;
|
||||
|
||||
// Variables for the configuring + tracking the placeholder animation
|
||||
let currentIndex = 0;
|
||||
const timeBetweenPlaceholders = 3000;
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
|
||||
// Function to change which placeholder is currently visible
|
||||
const changePlaceholder = () => {
|
||||
placeholders.forEach((el, index) => {
|
||||
starter.classList.remove('hide');
|
||||
if (index === currentIndex) {
|
||||
el.classList.add('active');
|
||||
el.classList.remove('inactive');
|
||||
el.classList.remove('still-inactive');
|
||||
} else {
|
||||
el.classList.add('inactive');
|
||||
el.classList.remove('active');
|
||||
setTimeout(() => {
|
||||
el.classList.add('still-inactive');
|
||||
}, timeBetweenPlaceholders / 5);
|
||||
}
|
||||
});
|
||||
currentIndex = (currentIndex + 1) % placeholders.length;
|
||||
};
|
||||
|
||||
// Begin the placeholder animation
|
||||
const startPlaceholderAnimation = () => {
|
||||
interval = setInterval(changePlaceholder, timeBetweenPlaceholders);
|
||||
};
|
||||
|
||||
// Stop the placeholder animation
|
||||
const stopPlaceholderAnimation = () => {
|
||||
clearInterval(interval);
|
||||
starter.classList.add('hide');
|
||||
placeholders.forEach((el) => {
|
||||
el.classList.remove('active');
|
||||
el.classList.add('inactive');
|
||||
});
|
||||
};
|
||||
|
||||
// When user focuses on the input field, stop the animation
|
||||
inputField.addEventListener('focus', () => {
|
||||
stopPlaceholderAnimation();
|
||||
});
|
||||
|
||||
// And, when user un-focuses on input, resume the placeholder animation
|
||||
inputField.addEventListener('blur', () => {
|
||||
if (!inputField.value) {
|
||||
startPlaceholderAnimation();
|
||||
}
|
||||
});
|
||||
|
||||
// If user types something, stop the animation
|
||||
inputField.addEventListener('input', () => {
|
||||
if (inputField.value) {
|
||||
stopPlaceholderAnimation();
|
||||
} else if (document.activeElement !== inputField) {
|
||||
startPlaceholderAnimation();
|
||||
}
|
||||
});
|
||||
|
||||
// Disable the input's placeholder attribute
|
||||
inputField.setAttribute('placeholder', '');
|
||||
|
||||
// Make visible the placeholder container
|
||||
(document.querySelector('.placeholder-container') as HTMLElement).style.display = 'flex';
|
||||
|
||||
// And finally, start the animation!
|
||||
startPlaceholderAnimation();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
@import '@styles/typography.scss';
|
||||
@import '@styles/global.scss';
|
||||
|
||||
.input-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: var(--background-50);
|
||||
font-size: 2rem;
|
||||
color: var(--text-color-secondary);
|
||||
border: 2px solid var(--text-color);
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease-in-out;
|
||||
&:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-container .placeholder-container {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
padding-left: 16px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.starter, .placeholder {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
font-size: 2rem;
|
||||
color: var(--text-color-thirdly);
|
||||
padding: 0.1rem 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.starter {
|
||||
transition: opacity 0.6s, transform 0.5s;
|
||||
&.hide {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
padding-left: 4rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: transform 0.5s ease-in-out, opacity 0.6s;
|
||||
opacity: 0;
|
||||
transform: translateY(-150%);
|
||||
}
|
||||
|
||||
.placeholder.active {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.placeholder.inactive {
|
||||
transform: translateY(150%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.placeholder.still-inactive {
|
||||
transform: translateY(-150%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
128
src/components/homepage/HeroForm.astro
Normal file
128
src/components/homepage/HeroForm.astro
Normal file
@ -0,0 +1,128 @@
|
||||
---
|
||||
import AnimatedButton from "./AnimatedButton.astro"
|
||||
import AnimatedInput from "./AnimatedInput.astro"
|
||||
---
|
||||
|
||||
<div class="left">
|
||||
<h1>
|
||||
<img src="/favicon.svg" alt="Check Web" width="64" />
|
||||
<span class="web">Web</span>
|
||||
<span class="check">Check</span>
|
||||
</h1>
|
||||
<div class="homepage-action-content">
|
||||
<h2>We give you X-Ray<br />Vision for your Website</h2>
|
||||
<h3>
|
||||
In just 20 seconds, you can see
|
||||
<span>what attackers already know</span>
|
||||
</h3>
|
||||
<form name="live-start" autocomplete="off" action="/check" class="live-start" id="live-start">
|
||||
<label for="url">Enter a URL to start 👇</label>
|
||||
<AnimatedInput />
|
||||
<AnimatedButton />
|
||||
</form>
|
||||
</div>
|
||||
<div>X</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* Form management actions (validation, submission, etc.)
|
||||
* We just use normal, old school JavaScript for this
|
||||
*/
|
||||
|
||||
// Select the form and input elements from the DOM
|
||||
const form = document.getElementById('live-start');
|
||||
const urlInput = document.getElementById('url-input') as HTMLInputElement;
|
||||
|
||||
// Submit Event - called when user submits form with a valid URL
|
||||
// Gets and checks the URL, then redirects user to /check/:url
|
||||
form?.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const url = urlInput.value.trim();
|
||||
if (url) {
|
||||
const encodedUrl = encodeURIComponent(url);
|
||||
window.location.href = `/check/${encodedUrl}`;
|
||||
}
|
||||
});
|
||||
|
||||
// User presses enter, forgets to add protocol
|
||||
// Will add https:// to the URL, and retry form submit
|
||||
urlInput?.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
const url = urlInput.value.trim();
|
||||
const urlWithoutProtocolRegex = /^[a-zA-Z0-9]+[a-zA-Z0-9.-]*\.[a-zA-Z]{2,}$/;
|
||||
if (url && !/^https?:\/\//i.test(url) && urlWithoutProtocolRegex.test(url)) {
|
||||
urlInput.value = 'https://' + url;
|
||||
form?.dispatchEvent(new Event('submit'));
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '@styles/global.scss';
|
||||
.left {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20vh;
|
||||
@include tablet-landscape-down {
|
||||
gap: 6rem;
|
||||
}
|
||||
@include mobile-down {
|
||||
gap: 4rem;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
fontis-size: 3em;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
gap: 0.1rem;
|
||||
align-items: center;
|
||||
img {
|
||||
vertical-align: middle;
|
||||
width: 3rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.web {
|
||||
color: var(--text-color);
|
||||
}
|
||||
.check {
|
||||
color: var(--primary);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
.homepage-action-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-width: 700px;
|
||||
z-index: 1;
|
||||
@include tablet-landscape-down {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 3rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
h3 {
|
||||
font-weight: normal;
|
||||
font-size: 1.3rem;
|
||||
span {
|
||||
color: var(--primary);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
form {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
label {
|
||||
font-size: 1.3rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
174
src/components/homepage/HomeBackground.tsx
Normal file
174
src/components/homepage/HomeBackground.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
// Global Animation Configuration Constants
|
||||
const dotSpacing = 32; // Number of px between each dot
|
||||
const meteorCount = 4; // Number of meteors to display at any given time
|
||||
const tailLength = 80; // Length of the meteor tail in px
|
||||
const distanceBase = 5; // Base distance for meteor to travel in grid units
|
||||
const distanceVariance = 5; // Variance for randomization to append to travel in grid units
|
||||
const durationBase = 1.5; // Base duration for meteor to travel in seconds
|
||||
const durationVariance = 1; // Variance for randomization to append to travel in seconds
|
||||
const delayBase = 500; // Base delay for meteor to respawn in milliseconds
|
||||
const delayVariance = 1500; // Variance for randomization to append to respawn in milliseconds
|
||||
const tailDuration = 0.25; // Duration for meteor tail to retract in seconds
|
||||
const headEasing = [0.8, 0.6, 1, 1]; // Easing for meteor head
|
||||
const tailEasing = [0.5, 0.6, 0.6, 1]; // Easing for meteor tail
|
||||
|
||||
const MeteorContainer = styled(motion.div)`
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background-color: #9fef00;
|
||||
top: 1px;
|
||||
`;
|
||||
|
||||
const Tail = styled(motion.div)`
|
||||
position: absolute;
|
||||
top: -80px;
|
||||
left: 1px;
|
||||
width: 2px;
|
||||
height: 80px;
|
||||
background: linear-gradient(to bottom, transparent, #9fef00);
|
||||
`;
|
||||
|
||||
const StyledSvg = styled.svg`
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
fill: rgba(100, 100, 100, 0.5);
|
||||
defs pattern circle {
|
||||
z-index: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledRect = styled.rect`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
stroke-width: 0;
|
||||
`;
|
||||
|
||||
const Container = styled.div`
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: radial-gradient(circle at center top, transparent, transparent 60%, var(--background) 100%);
|
||||
`;
|
||||
|
||||
const generateMeteor = (id: number, gridSizeX: number, gridSizeY: number) => {
|
||||
const column = Math.floor(Math.random() * gridSizeX);
|
||||
const startRow = Math.floor(Math.random() * (gridSizeY - 12));
|
||||
const travelDistance = distanceBase + Math.floor(Math.random() * distanceVariance);
|
||||
const duration = durationBase + Math.floor(Math.random() * durationVariance);
|
||||
|
||||
return {
|
||||
id,
|
||||
column,
|
||||
startRow,
|
||||
endRow: startRow + travelDistance,
|
||||
duration,
|
||||
tailVisible: true,
|
||||
animationStage: 'traveling',
|
||||
opacity: 1, // Initial opacity
|
||||
};
|
||||
};
|
||||
|
||||
const generateInitialMeteors = (gridSizeX: number, gridSizeY: number) => {
|
||||
const seen = new Set();
|
||||
return Array.from({ length: meteorCount }, (_, index) => generateMeteor(index, gridSizeX, gridSizeY))
|
||||
.filter(item => !seen.has(item.column) && seen.add(item.column));
|
||||
};
|
||||
|
||||
const WebCheckHomeBackground = () => {
|
||||
const [gridSizeX, setGridSizeX] = useState(Math.floor(window.innerWidth / dotSpacing));
|
||||
const [gridSizeY, setGridSizeY] = useState(Math.floor(window.innerHeight / dotSpacing));
|
||||
const [meteors, setMeteors] = useState(() => generateInitialMeteors(gridSizeX, gridSizeY));
|
||||
|
||||
const handleAnimationComplete = (id: number) => {
|
||||
setMeteors(current =>
|
||||
current.map(meteor => {
|
||||
if (meteor.id === id) {
|
||||
if (meteor.animationStage === 'traveling') {
|
||||
// Transition to retracting tail
|
||||
return { ...meteor, tailVisible: false, animationStage: 'retractingTail' };
|
||||
} else if (meteor.animationStage === 'retractingTail') {
|
||||
// Set to resetting and make invisible
|
||||
return { ...meteor, animationStage: 'resetting', opacity: 0 };
|
||||
} else if (meteor.animationStage === 'resetting') {
|
||||
// Respawn the meteor after a delay
|
||||
setTimeout(() => {
|
||||
setMeteors(current =>
|
||||
current.map(m => m.id === id ? generateMeteor(id, gridSizeX, gridSizeY) : m)
|
||||
);
|
||||
}, delayBase + Math.random() * delayVariance);
|
||||
}
|
||||
}
|
||||
return meteor;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setGridSizeX(Math.floor(window.innerWidth / dotSpacing));
|
||||
setGridSizeY(Math.floor(window.innerHeight / dotSpacing));
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container />
|
||||
<StyledSvg>
|
||||
<defs>
|
||||
<pattern id="dot-pattern" width={dotSpacing} height={dotSpacing} patternUnits="userSpaceOnUse">
|
||||
<circle cx={1} cy={1} r={2} />
|
||||
</pattern>
|
||||
</defs>
|
||||
<StyledRect fill="url(#dot-pattern)" />
|
||||
</StyledSvg>
|
||||
|
||||
{meteors.map(({ id, column, startRow, endRow, duration, tailVisible, animationStage, opacity }) => {
|
||||
return (
|
||||
<MeteorContainer
|
||||
key={id}
|
||||
initial={{
|
||||
x: column * dotSpacing,
|
||||
y: startRow * dotSpacing,
|
||||
opacity: 1,
|
||||
}}
|
||||
animate={{
|
||||
opacity: tailVisible ? 1 : 0,
|
||||
y: animationStage === 'resetting' ? startRow * dotSpacing : endRow * dotSpacing,
|
||||
}}
|
||||
transition={{
|
||||
duration: animationStage === 'resetting' ? 0 : duration,
|
||||
ease: headEasing,
|
||||
}}
|
||||
onAnimationComplete={() => handleAnimationComplete(id)}
|
||||
>
|
||||
<Tail
|
||||
initial={{ top: `-${tailLength}px`, height: `${tailLength}px` }}
|
||||
animate={{ top: tailVisible ? `-${tailLength}px` : 0, height: tailVisible ? `${tailLength}px` : 0 }}
|
||||
transition={{
|
||||
duration: tailDuration,
|
||||
ease: tailEasing,
|
||||
}}
|
||||
/>
|
||||
</MeteorContainer>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebCheckHomeBackground;
|
Loading…
Reference in New Issue
Block a user