perbaiki halaamn tentang
This commit is contained in:
parent
a55559a202
commit
21065b0ab3
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 |
|
|
@ -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 />;
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue