diff --git a/frontend/public/Gambar1.jpg b/frontend/public/Gambar1.jpg new file mode 100644 index 0000000..0ff8fde Binary files /dev/null and b/frontend/public/Gambar1.jpg differ diff --git a/frontend/public/Gambar2.jpeg b/frontend/public/Gambar2.jpeg new file mode 100644 index 0000000..aac5644 Binary files /dev/null and b/frontend/public/Gambar2.jpeg differ diff --git a/frontend/public/Gambar3.jpeg b/frontend/public/Gambar3.jpeg new file mode 100644 index 0000000..6560a98 Binary files /dev/null and b/frontend/public/Gambar3.jpeg differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d895b3a..0c38b8e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( + @@ -22,36 +25,36 @@ function App() { } /> } /> } /> - + {/* Protected Dashboard Routes */} - - } + } /> - - } + } /> - - } + } /> - + {/* Redirect */} } /> - + {/* 404 */} } /> @@ -64,11 +67,11 @@ function App() { // Redirect component for role-based navigation const RedirectDashboard = () => { const { user, isAuthenticated } = useAuth(); - + if (!isAuthenticated || !user) { return ; } - + switch (user.role) { case 'user': return ; diff --git a/frontend/src/components/About.jsx b/frontend/src/components/About.jsx index b257093..51f49a3 100644 --- a/frontend/src/components/About.jsx +++ b/frontend/src/components/About.jsx @@ -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: ( - - - - ), - title: 'Perlindungan', - description: 'Melindungi korban dan saksi dari segala bentuk ancaman atau intimidasi' - }, - { - icon: ( - - - - ), - title: 'Pendampingan', - description: 'Memberikan dukungan psikologis dan hukum yang dibutuhkan' - }, - { - icon: ( - - - - ), - title: 'Kerahasiaan', - description: 'Menjaga identitas dan informasi pelapor dengan ketat' - }, - { - icon: ( - - - - ), - title: 'Keadilan', - description: 'Memastikan proses yang adil dan transparan untuk semua pihak' - } - ]; - - return ( -
- {/* Background Pattern */} -
-
-
-
- -
- {/* Section Header */} - - - Tentang Satgas PPKPT - - - Satuan Tugas Pencegahan dan Penanganan Kekerasan Seksual Politeknik Negeri Jember - - - -
- {/* Left Content - Logo and Description */} - - - - -

- Layanan Pengaduan dan Pendampingan Terpercaya -

-

- PolijeCare merupakan kanal resmi pengaduan Satgas PPKPT Politeknik Negeri Jember yang menangani laporan kekerasan seksual secara empati, profesional, dan menjaga kerahasiaan. -

-

- Kami berkomitmen untuk menciptakan lingkungan kampus yang aman, mendukung, dan bebas dari kekerasan seksual bagi seluruh sivitas akademika. -

-
- - {/* Stats */} - -
-
24/7
-
Layanan Darurat
-
-
-
100%
-
Rahasia Terjamin
-
-
-
- - {/* Right Content - Features Grid */} - - - {features.map((feature, index) => ( - -
-
- {feature.icon} -
-
-

- {feature.title} -

-

- {feature.description} -

-
-
-
- ))} -
- - {/* Quote */} - -

- "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." -

-
- — Satgas PPKPT Polije -
-
-
-
-
-
- ); -}; - -export default About; +export default function About() { + return ; +} diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 0d1c33c..7a6deea 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -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" > -
+
{/* Logo Section - Left */} diff --git a/frontend/src/components/ui/about-section.jsx b/frontend/src/components/ui/about-section.jsx new file mode 100644 index 0000000..976e15a --- /dev/null +++ b/frontend/src/components/ui/about-section.jsx @@ -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 ( +
+
+
+ {/* Header */} +
+
+ + + TENTANG KAMI + +
+
+ + + + + + + + + + + + +
+
+ + {/* Hero Image with clip path */} + + + + + + + + + + + + + + {/* Slide progress indicators */} +
+ {heroImages.map((_, i) => ( +
{ setSlideIndex(i); setProgress(0); }} + > +
+
+ ))} +
+ {/* 100% stat in the white cutout */} + +
+ {count}% + Rahasia +
+ kerahasiaan terjamin +
+ + + {/* Stats */} +
+ + 24/7 + layanan aktif + + | + + Gratis + tanpa biaya + + | + + Cepat + respon tanggap + +
+
+ + {/* Main Content */} +
+
+

+ + Menciptakan Kampus Aman & Bermartabat. + +

+ + + +

+ 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. +

+
+ +

+ 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. +

+
+
+
+ +
+
+ + SATGAS PPKPT + + + Politeknik Negeri Jember + + + +

+ Siap untuk melaporkan atau butuh bantuan? Kami siap mendengarkan Anda. +

+
+ + + + HUBUNGI KAMI + + +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/ui/expandable-tabs.jsx b/frontend/src/components/ui/expandable-tabs.jsx index c806088..b81ce3c 100644 --- a/frontend/src/components/ui/expandable-tabs.jsx +++ b/frontend/src/components/ui/expandable-tabs.jsx @@ -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) => { diff --git a/frontend/src/components/ui/scroll-particles.jsx b/frontend/src/components/ui/scroll-particles.jsx new file mode 100644 index 0000000..9dbb5ef --- /dev/null +++ b/frontend/src/components/ui/scroll-particles.jsx @@ -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 ( +
+ + {particles.map((particle) => ( + + {particle.shape === "plus" && } + {particle.shape === "heart" && } + {particle.shape === "star" && } + + ))} + +
+ ); +} diff --git a/frontend/src/components/ui/timeline-animation.jsx b/frontend/src/components/ui/timeline-animation.jsx new file mode 100644 index 0000000..7e73766 --- /dev/null +++ b/frontend/src/components/ui/timeline-animation.jsx @@ -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 ( + + {children} + + ); + } +); + +TimelineContent.displayName = "TimelineContent"; + +export { TimelineContent }; diff --git a/frontend/src/components/ui/vertical-cut-reveal.jsx b/frontend/src/components/ui/vertical-cut-reveal.jsx new file mode 100644 index 0000000..acbf1da --- /dev/null +++ b/frontend/src/components/ui/vertical-cut-reveal.jsx @@ -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 ( + + {text} + + {(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 ( + + ); + })} + + ); + } +); + +VerticalCutReveal.displayName = "VerticalCutReveal"; + +export { VerticalCutReveal }; diff --git a/frontend/src/index.css b/frontend/src/index.css index ec499b6..e0d6a60 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; + } } \ No newline at end of file