perbaiki halaamn tentang

This commit is contained in:
krizzn65 2026-02-17 14:25:03 +07:00
parent a55559a202
commit 21065b0ab3
12 changed files with 772 additions and 216 deletions

BIN
frontend/public/Gambar1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

@ -4,6 +4,8 @@ import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import { ScrollParticles } from './components/ui/scroll-particles';
// Pages
import LandingPage from './pages/LandingPage';
import UserDashboard from './pages/UserDashboard';
@ -13,6 +15,7 @@ import OperatorDashboard from './pages/OperatorDashboard';
function App() {
return (
<ThemeProvider>
<ScrollParticles />
<AuthProvider>
<Router>
<Routes>
@ -22,36 +25,36 @@ function App() {
<Route path="/services" element={<LandingPage />} />
<Route path="/articles" element={<LandingPage />} />
<Route path="/contact" element={<LandingPage />} />
{/* Protected Dashboard Routes */}
<Route
path="/user/dashboard"
<Route
path="/user/dashboard"
element={
<ProtectedRoute requiredRole="user">
<UserDashboard />
</ProtectedRoute>
}
}
/>
<Route
path="/konselor/dashboard"
<Route
path="/konselor/dashboard"
element={
<ProtectedRoute requiredRole="konselor">
<KonselorDashboard />
</ProtectedRoute>
}
}
/>
<Route
path="/operator/dashboard"
<Route
path="/operator/dashboard"
element={
<ProtectedRoute requiredRole="operator">
<OperatorDashboard />
</ProtectedRoute>
}
}
/>
{/* Redirect */}
<Route path="/redirect" element={<RedirectDashboard />} />
{/* 404 */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@ -64,11 +67,11 @@ function App() {
// Redirect component for role-based navigation
const RedirectDashboard = () => {
const { user, isAuthenticated } = useAuth();
if (!isAuthenticated || !user) {
return <Navigate to="/" replace />;
}
switch (user.role) {
case 'user':
return <Navigate to="/user/dashboard" replace />;

View File

@ -1,200 +1,5 @@
import React from 'react';
import { motion } from 'framer-motion';
import { fadeIn, slideUp, slideLeft, staggerChildren } from '../utils/motionVariants';
import AboutSection from "@/components/ui/about-section";
const About = () => {
const features = [
{
icon: (
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z" />
</svg>
),
title: 'Perlindungan',
description: 'Melindungi korban dan saksi dari segala bentuk ancaman atau intimidasi'
},
{
icon: (
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
),
title: 'Pendampingan',
description: 'Memberikan dukungan psikologis dan hukum yang dibutuhkan'
},
{
icon: (
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" />
</svg>
),
title: 'Kerahasiaan',
description: 'Menjaga identitas dan informasi pelapor dengan ketat'
},
{
icon: (
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
),
title: 'Keadilan',
description: 'Memastikan proses yang adil dan transparan untuk semua pihak'
}
];
return (
<section id="about" className="py-20 bg-gradient-to-br from-slate-50 via-indigo-50/60 to-purple-50 relative overflow-hidden">
{/* Background Pattern */}
<div className="absolute inset-0 opacity-5">
<div className="absolute top-10 right-10 w-64 h-64 bg-primary rounded-full blur-3xl"></div>
<div className="absolute bottom-10 left-10 w-96 h-96 bg-accent rounded-full blur-3xl"></div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
{/* Section Header */}
<motion.div
className="text-center mb-20"
variants={fadeIn}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
>
<motion.h2
className="text-4xl md:text-5xl font-bold text-[#191970] mb-6"
variants={slideUp}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
>
Tentang <span className="text-indigo-500">Satgas PPKPT</span>
</motion.h2>
<motion.p
className="text-xl text-slate-500 max-w-3xl mx-auto leading-relaxed"
variants={fadeIn}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
transition={{ delay: 0.2 }}
>
Satuan Tugas Pencegahan dan Penanganan Kekerasan Seksual Politeknik Negeri Jember
</motion.p>
</motion.div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-start">
{/* Left Content - Logo and Description */}
<motion.div
className="space-y-8"
variants={slideLeft}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
>
<motion.div
className="space-y-6"
variants={fadeIn}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
transition={{ delay: 0.3 }}
>
<h3 className="text-2xl font-bold text-[#191970]">
Layanan Pengaduan dan Pendampingan Terpercaya
</h3>
<p className="text-lg text-slate-500 leading-relaxed">
PolijeCare merupakan kanal resmi pengaduan Satgas PPKPT Politeknik Negeri Jember yang menangani laporan kekerasan seksual secara empati, profesional, dan menjaga kerahasiaan.
</p>
<p className="text-lg text-slate-500 leading-relaxed">
Kami berkomitmen untuk menciptakan lingkungan kampus yang <span className="text-[#191970] font-semibold">aman</span>, <span className="text-[#191970] font-semibold">mendukung</span>, dan <span className="text-[#191970] font-semibold">bebas dari kekerasan seksual</span> bagi seluruh sivitas akademika.
</p>
</motion.div>
{/* Stats */}
<motion.div
className="grid grid-cols-2 gap-6 pt-6"
variants={fadeIn}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
transition={{ delay: 0.4 }}
>
<div className="text-center p-4 bg-white/70 backdrop-blur-sm rounded-2xl shadow-sm border border-white/50">
<div className="text-3xl font-bold text-[#191970] mb-2">24/7</div>
<div className="text-sm text-slate-500 font-medium">Layanan Darurat</div>
</div>
<div className="text-center p-4 bg-white/70 backdrop-blur-sm rounded-2xl shadow-sm border border-white/50">
<div className="text-3xl font-bold text-indigo-500 mb-2">100%</div>
<div className="text-sm text-slate-500 font-medium">Rahasia Terjamin</div>
</div>
</motion.div>
</motion.div>
{/* Right Content - Features Grid */}
<motion.div
className="space-y-6"
variants={fadeIn}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
transition={{ delay: 0.2 }}
>
<motion.div
className="grid grid-cols-1 sm:grid-cols-2 gap-6"
variants={staggerChildren}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
>
{features.map((feature, index) => (
<motion.div
key={feature.title}
className="bg-white/70 backdrop-blur-sm rounded-2xl p-6 shadow-sm border border-white/50 hover:shadow-md hover:bg-white/90 transition-all duration-300 hover:-translate-y-1 group"
variants={fadeIn}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
transition={{ delay: 0.1 * index }}
whileHover={{ scale: 1.02 }}
>
<div className="flex items-start space-x-4">
<div className="flex-shrink-0 w-12 h-12 bg-gradient-to-br from-indigo-100 to-purple-100 rounded-xl flex items-center justify-center text-[#191970] group-hover:from-indigo-200 group-hover:to-purple-200 transition-all duration-300">
{feature.icon}
</div>
<div className="flex-1">
<h4 className="text-lg font-semibold text-[#191970] mb-2 group-hover:text-indigo-600 transition-colors">
{feature.title}
</h4>
<p className="text-slate-500 text-sm leading-relaxed">
{feature.description}
</p>
</div>
</div>
</motion.div>
))}
</motion.div>
{/* Quote */}
<motion.blockquote
className="bg-gradient-to-r from-indigo-50/80 to-purple-50/80 backdrop-blur-sm rounded-2xl p-6 border-l-4 border-[#191970]"
variants={fadeIn}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
transition={{ delay: 0.6 }}
>
<p className="text-lg text-slate-600 italic font-medium leading-relaxed">
"Setiap individu berhak mendapatkan perlindungan dan rasa aman dalam menempuh pendidikan. Mari bersama-sama menjaga kampus kita sebagai tempat yang aman dan mendukung bagi semua."
</p>
<footer className="mt-4 text-sm text-[#191970] font-semibold">
Satgas PPKPT Polije
</footer>
</motion.blockquote>
</motion.div>
</div>
</div>
</section>
);
};
export default About;
export default function About() {
return <AboutSection />;
}

View File

@ -168,7 +168,7 @@ const Navbar = () => {
transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }}
className="fixed top-0 left-0 right-0 z-50 transition-colors duration-300 bg-white/50 backdrop-blur-md border-b border-white/20 shadow-sm will-change-transform"
>
<div className="w-full px-4">
<div className="w-full px-8 lg:px-12">
<div className="flex items-center justify-between h-20">
{/* Logo Section - Left */}
<Link to="/" className="flex items-center space-x-2 cursor-default">

View File

@ -0,0 +1,369 @@
"use client";
import { TimelineContent } from "@/components/ui/timeline-animation";
import { VerticalCutReveal } from "@/components/ui/vertical-cut-reveal";
import { ArrowRight, Shield, Heart, Lock, Scale, Phone } from "lucide-react";
import { useRef, useEffect, useState } from "react";
import { useInView, motion, AnimatePresence } from "framer-motion";
export default function AboutSection() {
const heroRef = useRef(null);
const counterRef = useRef(null);
const isCounterInView = useInView(counterRef, { once: true });
const [count, setCount] = useState(0);
// Carousel state
const heroImages = ["/Gambar1.jpg", "/Gambar2.jpeg", "/Gambar3.jpeg"];
const [slideIndex, setSlideIndex] = useState(0);
const [progress, setProgress] = useState(0);
const SLIDE_DURATION = 5000; // 5 seconds
useEffect(() => {
const progressInterval = setInterval(() => {
setProgress((prev) => {
if (prev >= 100) return 0;
return prev + (100 / (SLIDE_DURATION / 50));
});
}, 50);
const slideTimer = setInterval(() => {
setSlideIndex((prev) => (prev + 1) % heroImages.length);
setProgress(0);
}, SLIDE_DURATION);
return () => {
clearInterval(progressInterval);
clearInterval(slideTimer);
};
}, []);
useEffect(() => {
if (!isCounterInView) return;
let start = 0;
const end = 100;
const duration = 2000;
const stepTime = duration / end;
const timer = setInterval(() => {
start += 1;
setCount(start);
if (start >= end) clearInterval(timer);
}, stepTime);
return () => clearInterval(timer);
}, [isCounterInView]);
const revealVariants = {
visible: (i) => ({
y: 0,
opacity: 1,
filter: "blur(0px)",
transition: {
delay: i * 0.15,
duration: 0.3,
},
}),
hidden: {
filter: "blur(10px)",
y: -20,
opacity: 0,
},
};
const scaleVariants = {
visible: (i) => ({
opacity: 1,
filter: "blur(0px)",
transition: {
delay: i * 0.15,
duration: 0.3,
},
}),
hidden: {
filter: "blur(10px)",
opacity: 0,
},
};
return (
<section className="min-h-screen py-16 bg-[#f9f9f9] flex items-center" ref={heroRef} id="about">
<div className="max-w-[90rem] mx-auto w-full px-8 lg:px-12">
<div className="relative">
{/* Header */}
<div className="flex justify-between items-center mb-8 w-[85%] absolute lg:top-4 md:top-0 sm:-top-2 -top-3 z-10">
<div className="flex items-center gap-2 text-xl">
<span className="text-[#191970] animate-spin"></span>
<TimelineContent
as="span"
animationNum={0}
timelineRef={heroRef}
customVariants={revealVariants}
className="text-sm font-medium text-gray-600"
>
TENTANG KAMI
</TimelineContent>
</div>
<div className="flex gap-3">
<TimelineContent
as="div"
animationNum={0}
timelineRef={heroRef}
customVariants={revealVariants}
className="md:w-8 md:h-8 sm:w-6 w-5 sm:h-6 h-5 border border-indigo-200 bg-indigo-50 rounded-lg flex items-center justify-center"
>
<Shield className="w-4 h-4 text-[#191970]" />
</TimelineContent>
<TimelineContent
as="div"
animationNum={1}
timelineRef={heroRef}
customVariants={revealVariants}
className="md:w-8 md:h-8 sm:w-6 w-5 sm:h-6 h-5 border border-indigo-200 bg-indigo-50 rounded-lg flex items-center justify-center"
>
<Heart className="w-4 h-4 text-[#191970]" />
</TimelineContent>
<TimelineContent
as="div"
animationNum={2}
timelineRef={heroRef}
customVariants={revealVariants}
className="md:w-8 md:h-8 sm:w-6 w-5 sm:h-6 h-5 border border-indigo-200 bg-indigo-50 rounded-lg flex items-center justify-center"
>
<Lock className="w-4 h-4 text-[#191970]" />
</TimelineContent>
<TimelineContent
as="div"
animationNum={3}
timelineRef={heroRef}
customVariants={revealVariants}
className="md:w-8 md:h-8 sm:w-6 w-5 sm:h-6 h-5 border border-indigo-200 bg-indigo-50 rounded-lg flex items-center justify-center"
>
<Scale className="w-4 h-4 text-[#191970]" />
</TimelineContent>
</div>
</div>
{/* Hero Image with clip path */}
<TimelineContent
as="figure"
animationNum={4}
timelineRef={heroRef}
customVariants={scaleVariants}
className="relative group"
>
<svg
className="w-full"
width={"100%"}
height={"100%"}
viewBox="0 0 100 40"
>
<defs>
<clipPath
id="clip-inverted"
clipPathUnits={"userSpaceOnUse"}
>
<path
transform="scale(100, 40)"
d="M0.0998072 1H0.422076H0.749756C0.767072 1 0.774207 0.961783 0.77561 0.942675V0.807325C0.777053 0.743631 0.791844 0.731953 0.799059 0.734076H0.969813C0.996268 0.730255 1.00088 0.693206 0.999875 0.675159V0.0700637C0.999875 0.0254777 0.985045 0.00477707 0.977629 0H0.902473C0.854975 0 0.890448 0.138535 0.850165 0.138535H0.0204424C0.00408849 0.142357 0 0.180467 0 0.199045V0.410828C0 0.449045 0.0136283 0.46603 0.0204424 0.469745H0.0523086C0.0696245 0.471019 0.0735527 0.497877 0.0733523 0.511146V0.915605C0.0723903 0.983121 0.090588 1 0.0998072 1Z"
fill="#D9D9D9"
/>
</clipPath>
</defs>
<g clipPath="url(#clip-inverted)">
<AnimatePresence>
<motion.image
key={slideIndex}
preserveAspectRatio="xMidYMid slice"
width={"100%"}
height={"100%"}
xlinkHref={heroImages[slideIndex]}
initial={{ x: 100 }}
animate={{ x: 0 }}
exit={{ x: -100 }}
transition={{ duration: 0.6, ease: "easeInOut" }}
/>
</AnimatePresence>
</g>
</svg>
{/* Slide progress indicators */}
<div className="absolute bottom-[3%] left-[10%] flex gap-3 items-center z-10">
{heroImages.map((_, i) => (
<div
key={i}
className="h-[6px] rounded-full overflow-hidden cursor-pointer shadow-sm transition-all duration-300"
style={{
width: i === slideIndex ? '3rem' : '1.5rem',
backgroundColor: 'rgba(255,255,255,0.4)'
}}
onClick={() => { setSlideIndex(i); setProgress(0); }}
>
<div
className="h-full rounded-full transition-all duration-100 ease-linear"
style={{
width: i === slideIndex ? `${progress}%` : i < slideIndex ? '100%' : '0%',
backgroundColor: '#fff',
boxShadow: '0 0 4px rgba(0,0,0,0.2)'
}}
/>
</div>
))}
</div>
{/* 100% stat in the white cutout */}
<TimelineContent
as="div"
animationNum={5}
timelineRef={heroRef}
customVariants={revealVariants}
className="absolute bottom-[10%] right-[0.5%] text-right"
>
<div className="flex items-baseline gap-2 justify-end" ref={counterRef}>
<span className="text-[#191970] font-extrabold text-2xl sm:text-3xl lg:text-4xl leading-none" style={{ fontFamily: "'Inter', sans-serif" }}>{count}%</span>
<span className="text-gray-500 text-2xl sm:text-3xl lg:text-4xl uppercase tracking-wider font-normal leading-none">Rahasia</span>
</div>
<span className="text-gray-400 text-xs sm:text-sm mt-0.5 block text-left">kerahasiaan terjamin</span>
</TimelineContent>
</TimelineContent>
{/* Stats */}
<div className="flex flex-wrap items-center justify-start gap-6 py-4 text-sm">
<TimelineContent
as="div"
animationNum={5}
timelineRef={heroRef}
customVariants={revealVariants}
className="flex items-center gap-2 sm:text-base text-xs"
>
<span className="text-[#191970] font-bold">24/7</span>
<span className="text-gray-600">layanan aktif</span>
</TimelineContent>
<span className="text-gray-300">|</span>
<TimelineContent
as="div"
animationNum={7}
timelineRef={heroRef}
customVariants={revealVariants}
className="flex items-center gap-2 sm:text-base text-xs"
>
<span className="text-[#191970] font-bold">Gratis</span>
<span className="text-gray-600">tanpa biaya</span>
</TimelineContent>
<span className="text-gray-300">|</span>
<TimelineContent
as="div"
animationNum={8}
timelineRef={heroRef}
customVariants={revealVariants}
className="flex items-center gap-2 sm:text-base text-xs"
>
<span className="text-[#191970] font-bold">Cepat</span>
<span className="text-gray-600">respon tanggap</span>
</TimelineContent>
</div>
</div>
{/* Main Content */}
<div className="grid md:grid-cols-3 gap-8">
<div className="md:col-span-2">
<h2 className="sm:text-4xl md:text-5xl text-2xl !leading-[110%] font-semibold text-gray-900 mb-8">
<VerticalCutReveal
splitBy="words"
staggerDuration={0.1}
staggerFrom="first"
reverse={true}
transition={{
type: "spring",
stiffness: 250,
damping: 30,
delay: 1,
}}
>
Menciptakan Kampus Aman & Bermartabat.
</VerticalCutReveal>
</h2>
<TimelineContent
as="div"
animationNum={9}
timelineRef={heroRef}
customVariants={revealVariants}
className="grid md:grid-cols-2 gap-8 text-gray-600"
>
<TimelineContent
as="div"
animationNum={10}
timelineRef={heroRef}
customVariants={revealVariants}
className="sm:text-base text-xs"
>
<p className="leading-relaxed text-justify">
PolijeCare merupakan kanal resmi pengaduan Satgas Pencegahan
dan Penanganan Kekerasan Seksual (PPKPT) Politeknik Negeri
Jember. Kami hadir sebagai garda terdepan dalam melindungi
korban dan memberikan pendampingan profesional.
</p>
</TimelineContent>
<TimelineContent
as="div"
animationNum={11}
timelineRef={heroRef}
customVariants={revealVariants}
className="sm:text-base text-xs"
>
<p className="leading-relaxed text-justify">
Setiap laporan ditangani secara empatik, profesional, dan
menjaga kerahasiaan penuh. Kami percaya bahwa setiap individu
berhak mendapat rasa aman dalam menempuh pendidikan tanpa
ancaman kekerasan seksual.
</p>
</TimelineContent>
</TimelineContent>
</div>
<div className="md:col-span-1">
<div className="text-right">
<TimelineContent
as="div"
animationNum={12}
timelineRef={heroRef}
customVariants={revealVariants}
className="text-[#191970] text-2xl font-bold mb-2"
>
SATGAS PPKPT
</TimelineContent>
<TimelineContent
as="div"
animationNum={13}
timelineRef={heroRef}
customVariants={revealVariants}
className="text-gray-600 text-sm mb-8"
>
Politeknik Negeri Jember
</TimelineContent>
<TimelineContent
as="div"
animationNum={14}
timelineRef={heroRef}
customVariants={revealVariants}
className="mb-6"
>
<p className="text-gray-900 font-medium mb-4">
Siap untuk melaporkan atau butuh bantuan? Kami siap mendengarkan Anda.
</p>
</TimelineContent>
<TimelineContent
as="a"
animationNum={15}
timelineRef={heroRef}
customVariants={revealVariants}
href="#contact"
className="group relative inline-flex w-fit ml-auto items-center gap-2 px-7 py-3 bg-[#191970] text-white rounded-full font-semibold text-sm cursor-pointer transition-all duration-300 hover:bg-[#1a237e] hover:text-white hover:shadow-[0_4px_20px_rgba(26,35,126,0.5)] hover:gap-3"
>
<Phone className="w-4 h-4 opacity-60" />
HUBUNGI KAMI
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</TimelineContent>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -44,8 +44,7 @@ export function ExpandableTabs({
}, [activeTab]);
useOnClickOutside(outsideClickRef, () => {
setSelected(null);
onChange?.(null);
// Keep the active tab visible don't clear selection on outside click
});
const handleSelect = (index) => {

View File

@ -0,0 +1,92 @@
import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence, useScroll, useMotionValueEvent } from "framer-motion";
import { Plus, Heart, Star } from "lucide-react";
export function ScrollParticles() {
const { scrollY } = useScroll();
const [particles, setParticles] = useState([]);
const [windowHeight, setWindowHeight] = useState(0);
const lastY = useRef(0);
useEffect(() => {
if (typeof window !== "undefined") {
setWindowHeight(window.innerHeight);
const handleResize = () => setWindowHeight(window.innerHeight);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}
}, []);
useMotionValueEvent(scrollY, "change", (latest) => {
const diff = Math.abs(latest - lastY.current);
const direction = latest > lastY.current ? 1 : -1;
// Spawn particles if scrolling fast enough
if (diff > 5 && windowHeight > 0) {
const scrollHeight = document.documentElement.scrollHeight - windowHeight;
const progress = latest / scrollHeight;
// Calculate approximate scrollbar thumb position (center of thumb)
const thumbY = progress * (windowHeight - 40);
const shapes = ["plus", "heart", "star"];
const randomShape = shapes[Math.floor(Math.random() * shapes.length)];
const newParticle = {
id: Date.now() + Math.random(),
top: thumbY + (Math.random() * 40 - 20), // Wider spread
left: -Math.random() * 20 - 10,
size: Math.random() * 14 + 10, // Larger size: 10px - 24px
color: Math.random() > 0.5 ? "#191970" : "#4C6EF5",
shape: randomShape,
rotation: Math.random() * 360,
velocity: {
x: -Math.random() * 30 - 20, // Faster drift left
y: (Math.random() * 30 - 15) + (direction * 8)
}
};
setParticles(prev => [...prev.slice(-20), newParticle]);
}
lastY.current = latest;
});
return (
<div className="fixed top-0 right-0 w-4 h-full pointer-events-none z-[9999]">
<AnimatePresence>
{particles.map((particle) => (
<motion.div
key={particle.id}
initial={{
opacity: 1,
x: 0,
y: particle.top,
scale: 0,
rotate: particle.rotation
}}
animate={{
opacity: 0,
x: particle.velocity.x,
y: particle.top + particle.velocity.y,
scale: 1,
rotate: particle.rotation + 180
}}
exit={{ opacity: 0 }}
transition={{ duration: 0.8, ease: "easeOut" }}
className="absolute right-1 flex items-center justify-center"
style={{
width: particle.size,
height: particle.size,
color: particle.color,
}}
>
{particle.shape === "plus" && <Plus size={particle.size} strokeWidth={3} />}
{particle.shape === "heart" && <Heart size={particle.size} fill={particle.color} strokeWidth={0} />}
{particle.shape === "star" && <Star size={particle.size} fill={particle.color} strokeWidth={0} />}
</motion.div>
))}
</AnimatePresence>
</div>
);
}

View File

@ -0,0 +1,65 @@
"use client";
import React, { useRef, createElement, forwardRef } from "react";
import {
motion,
useInView,
useScroll,
useTransform,
} from "framer-motion";
import { cn } from "@/lib/utils";
const TimelineContent = forwardRef(
(
{
as = "div",
children,
className,
animationNum = 0,
timelineRef,
customVariants,
...props
},
ref
) => {
const defaultVariants = {
hidden: {
opacity: 0,
y: 20,
filter: "blur(8px)",
},
visible: (i) => ({
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: {
delay: i * 0.3,
duration: 0.5,
ease: "easeOut",
},
}),
};
const variants = customVariants || defaultVariants;
const isInView = useInView(timelineRef, { once: true, amount: 0.1 });
const MotionComponent = motion[as] || motion.div;
return (
<MotionComponent
ref={ref}
className={cn(className)}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
variants={variants}
custom={animationNum}
{...props}
>
{children}
</MotionComponent>
);
}
);
TimelineContent.displayName = "TimelineContent";
export { TimelineContent };

View File

@ -0,0 +1,189 @@
"use client";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
const VerticalCutReveal = forwardRef(
(
{
children,
reverse = false,
transition = {
type: "spring",
stiffness: 190,
damping: 22,
},
splitBy = "words",
staggerDuration = 0.2,
staggerFrom = "first",
containerClassName,
wordLevelClassName,
elementLevelClassName,
onClick,
onStart,
onComplete,
autoStart = true,
...props
},
ref
) => {
const containerRef = useRef(null);
const text =
typeof children === "string" ? children : children?.toString() || "";
const [isAnimating, setIsAnimating] = useState(false);
const splitIntoCharacters = (text) => {
if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
return Array.from(segmenter.segment(text), ({ segment }) => segment);
}
return Array.from(text);
};
const elements = useMemo(() => {
const words = text.split(" ");
if (splitBy === "characters") {
return words.map((word, i) => ({
characters: splitIntoCharacters(word),
needsSpace: i !== words.length - 1,
}));
}
return splitBy === "words"
? text.split(" ")
: splitBy === "lines"
? text.split("\n")
: text.split(splitBy);
}, [text, splitBy]);
const getStaggerDelay = useCallback(
(index) => {
const total =
splitBy === "characters"
? elements.reduce(
(acc, word) =>
acc +
(typeof word === "string"
? 1
: word.characters.length + (word.needsSpace ? 1 : 0)),
0
)
: elements.length;
if (staggerFrom === "first") return index * staggerDuration;
if (staggerFrom === "last")
return (total - 1 - index) * staggerDuration;
if (staggerFrom === "center") {
const center = Math.floor(total / 2);
return Math.abs(center - index) * staggerDuration;
}
if (staggerFrom === "random") {
const randomIndex = Math.floor(Math.random() * total);
return Math.abs(randomIndex - index) * staggerDuration;
}
return Math.abs(staggerFrom - index) * staggerDuration;
},
[elements.length, staggerFrom, staggerDuration]
);
const startAnimation = useCallback(() => {
setIsAnimating(true);
onStart?.();
}, [onStart]);
useImperativeHandle(ref, () => ({
startAnimation,
reset: () => setIsAnimating(false),
}));
useEffect(() => {
if (autoStart) {
startAnimation();
}
}, [autoStart]);
const variants = {
hidden: { y: reverse ? "-100%" : "100%" },
visible: (i) => ({
y: 0,
transition: {
...transition,
delay: (transition?.delay || 0) + getStaggerDelay(i),
},
}),
};
return (
<span
className={cn(
containerClassName,
"flex flex-wrap whitespace-pre-wrap",
splitBy === "lines" && "flex-col"
)}
onClick={onClick}
ref={containerRef}
{...props}
>
<span className="sr-only">{text}</span>
{(splitBy === "characters"
? elements
: elements.map((el, i) => ({
characters: [el],
needsSpace: i !== elements.length - 1,
}))
).map((wordObj, wordIndex, array) => {
const previousCharsCount = array
.slice(0, wordIndex)
.reduce((sum, word) => sum + word.characters.length, 0);
return (
<span
key={wordIndex}
aria-hidden="true"
className={cn("inline-flex overflow-hidden", wordLevelClassName)}
>
{wordObj.characters.map((char, charIndex) => (
<span
className={cn(
elementLevelClassName,
"whitespace-pre-wrap relative"
)}
key={charIndex}
>
<motion.span
custom={previousCharsCount + charIndex}
initial="hidden"
animate={isAnimating ? "visible" : "hidden"}
variants={variants}
onAnimationComplete={
wordIndex === elements.length - 1 &&
charIndex === wordObj.characters.length - 1
? onComplete
: undefined
}
className="inline-block"
>
{char}
</motion.span>
</span>
))}
{wordObj.needsSpace && <span> </span>}
</span>
);
})}
</span>
);
}
);
VerticalCutReveal.displayName = "VerticalCutReveal";
export { VerticalCutReveal };

View File

@ -640,4 +640,38 @@ body {
.dark .highlight-marker::before {
background: rgba(80, 100, 220, 0.85);
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
.dark ::-webkit-scrollbar-track {
background: #1f2937;
}
::-webkit-scrollbar-thumb {
background: #191970;
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: #101050;
}
/* Firefox Scrollbar */
@supports (scrollbar-color: auto) {
html {
scrollbar-width: thin;
scrollbar-color: #191970 #f1f1f1;
}
.dark html {
scrollbar-color: #191970 #1f2937;
}
}