fix: add reveal animation in landing
continuous-integration/drone/push Build is passing Details

This commit is contained in:
Sabda Yagra 2026-03-06 11:19:17 +07:00
parent a71cbeefeb
commit 24b44a491a
10 changed files with 1068 additions and 767 deletions

View File

@ -6,6 +6,7 @@ import {
ArticleCategory,
} from "@/service/categories/article-categories";
import { useTranslations } from "next-intl";
import { RevealR } from "../ui/RevealR";
export default function Category() {
const t = useTranslations("MediaUpdate");
@ -21,7 +22,7 @@ export default function Category() {
// Filter hanya kategori yang aktif dan published
const activeCategories = response.data.data.filter(
(category: ArticleCategory) =>
category.isActive && category.isPublish
category.isActive && category.isPublish,
);
setCategories(activeCategories);
}
@ -55,6 +56,7 @@ export default function Category() {
categories.length > 0 ? categories : fallbackCategories;
return (
<RevealR>
<section className="px-4 py-10">
<div className="max-w-[1350px] mx-auto bg-white dark:bg-default-50 dark:border dark:border-slate-50 rounded-xl shadow-md p-6">
<h2 className="text-xl font-semibold mb-5">
@ -93,7 +95,7 @@ export default function Category() {
onClick={() => {
// Navigate to category page or search by category
console.log(
`Category clicked: ${categoryTitle} (${categorySlug})`
`Category clicked: ${categoryTitle} (${categorySlug})`,
);
// TODO: Implement navigation to category page
}}
@ -106,5 +108,6 @@ export default function Category() {
)}
</div>
</section>
</RevealR>
);
}

View File

@ -13,6 +13,7 @@ import "swiper/css";
import "swiper/css/navigation";
import LocalSwitcher from "../partials/header/locale-switcher";
import { useTranslations } from "next-intl";
import { Reveal } from "./Reveal";
// Custom styles for Swiper
const swiperStyles = `
@ -100,6 +101,7 @@ export default function Footer() {
}, []);
return (
<Reveal>
<footer className="border-t bg-white dark:bg-default-50 text-center">
<style jsx>{swiperStyles}</style>
<div className="max-w-[1350px] mx-auto">
@ -273,5 +275,6 @@ export default function Footer() {
</div>
</div>
</footer>
</Reveal>
);
}

View File

@ -18,6 +18,9 @@ import "swiper/css/navigation";
import "swiper/css/pagination";
import ImageBlurry from "../ui/image-blurry";
import { useTranslations } from "next-intl";
import { Reveal } from "../ui/Reveal";
import { RevealL } from "../ui/RevealL";
import { RevealR } from "../ui/RevealR";
const images = ["/PPS.png", "/PPS2.jpeg", "/PPS3.jpg", "/PPS4.png"];
@ -41,7 +44,7 @@ export default function Header() {
undefined,
undefined,
"createdAt",
slug
slug,
);
let articlesData: any[] = [];
@ -56,14 +59,14 @@ export default function Header() {
"createdAt",
"",
"",
""
"",
);
articlesData = (fallbackResponse?.data?.data?.content || []).filter(
(item: any) => item.typeId === 1
(item: any) => item.typeId === 1,
);
} else {
articlesData = (response?.data?.data || []).filter(
(item: any) => item.typeId === 1
(item: any) => item.typeId === 1,
);
}
@ -106,14 +109,14 @@ export default function Header() {
const ids = new Set<number>(
(Array.isArray(bookmarks) ? bookmarks : [])
.map((b: any) => Number(b.articleId ?? b.id ?? b.article?.id))
.filter((x) => !isNaN(x))
.filter((x) => !isNaN(x)),
);
const merged = new Set([...localSet, ...ids]);
setBookmarkedIds(merged);
localStorage.setItem(
"bookmarkedIds",
JSON.stringify(Array.from(merged))
JSON.stringify(Array.from(merged)),
);
}
} catch (error) {
@ -128,12 +131,13 @@ export default function Header() {
if (bookmarkedIds.size > 0) {
localStorage.setItem(
"bookmarkedIds",
JSON.stringify(Array.from(bookmarkedIds))
JSON.stringify(Array.from(bookmarkedIds)),
);
}
}, [bookmarkedIds]);
return (
<RevealR>
<section className="max-w-[1350px] mx-auto px-4">
<div className="flex flex-col lg:flex-row gap-6 py-6">
{data.length > 0 && (
@ -197,6 +201,7 @@ export default function Header() {
</Swiper>
</div>
</section>
</RevealR>
);
}
@ -218,12 +223,26 @@ function Card({
const [isBookmarked, setIsBookmarked] = useState(isInitiallyBookmarked);
const DEFAULT_IMAGE = "/assets/logo1.png";
const [imageSrc, setImageSrc] = useState(
item?.smallThumbnailLink || DEFAULT_IMAGE
);
const [imageSrc, setImageSrc] = useState(DEFAULT_IMAGE);
useEffect(() => {
setImageSrc(item?.smallThumbnailLink || DEFAULT_IMAGE);
const src = item?.smallThumbnailLink;
if (!src) {
setImageSrc(DEFAULT_IMAGE);
return;
}
const img = new window.Image();
img.src = src;
img.onload = () => {
setImageSrc(src);
};
img.onerror = () => {
setImageSrc(DEFAULT_IMAGE);
};
}, [item?.smallThumbnailLink]);
useEffect(() => {
@ -266,7 +285,7 @@ function Card({
newSet.add(Number(item.id));
localStorage.setItem(
"bookmarkedIds",
JSON.stringify(Array.from(newSet))
JSON.stringify(Array.from(newSet)),
);
MySwal.fire({
@ -292,7 +311,7 @@ function Card({
};
return (
<div>
<RevealL>
<div
className={`rounded-xl overflow-hidden shadow hover:shadow-lg transition-all bg-white dark:bg-black dark:border dark:border-slate-50 ${
isBig
@ -312,12 +331,10 @@ function Card({
fill
className="object-cover"
/> */}
<Image
<ImageBlurry
src={imageSrc}
alt={item.title}
fill
className="object-cover"
onError={() => setImageSrc(DEFAULT_IMAGE)}
className="w-full h-full object-contain"
/>
</Link>
</div>
@ -379,7 +396,7 @@ function Card({
</div>
</div>
</div>
</div>
</RevealL>
);
}

View File

@ -17,6 +17,8 @@ import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import { ThumbsUp, ThumbsDown } from "lucide-react";
import { useTranslations } from "next-intl";
import ImageBlurry from "../ui/image-blurry";
import { RevealL } from "../ui/RevealL";
function formatTanggal(dateString: string) {
if (!dateString) return "";
@ -340,24 +342,37 @@ export default function MediaUpdate() {
};
function SafeImage({ src, alt }: { src?: string; alt?: string }) {
const [imgSrc, setImgSrc] = useState(src || DEFAULT_IMAGE);
const [imgSrc, setImgSrc] = useState(DEFAULT_IMAGE);
useEffect(() => {
setImgSrc(src || DEFAULT_IMAGE);
if (!src) {
setImgSrc(DEFAULT_IMAGE);
return;
}
const img = new window.Image();
img.src = src;
img.onload = () => {
setImgSrc(src);
};
img.onerror = () => {
setImgSrc(DEFAULT_IMAGE);
};
}, [src]);
return (
<Image
<ImageBlurry
src={imgSrc}
alt={alt || "Image"}
fill
className="object-cover cursor-pointer hover:opacity-90 transition-opacity"
onError={() => setImgSrc(DEFAULT_IMAGE)}
className="w-full h-full object-contain"
/>
);
}
return (
<RevealL>
<section className="bg-white dark:bg-default-50 px-4 py-10 border max-w-[1350px] mx-auto rounded-md border-[#CDD5DF] my-10">
<div className="max-w-screen-xl mx-auto">
<h2 className="text-2xl font-semibold text-center mb-6">
@ -580,5 +595,6 @@ export default function MediaUpdate() {
)}
</div>
</section>
</RevealL>
);
}

View File

@ -14,6 +14,7 @@ import { DynamicLogoTenant } from "./dynamic-logo-tenant";
import { useTranslations } from "next-intl";
import LocalSwitcher from "../partials/header/locale-switcher";
import ThemeSwitcher from "../partials/header/theme-switcher";
import { RevealT } from "../ui/RevealT";
export default function Navbar() {
const t = useTranslations("Navbar");
@ -75,6 +76,7 @@ export default function Navbar() {
const fullname = Cookies.get("ufne");
return (
<RevealT>
<header className="relative max-w-[1400px] mx-auto flex items-center justify-between px-4 py-3 border-b bg-white dark:bg-default-50 z-50">
<div className="flex flex-row items-center justify-between space-x-4 z-10">
<Menu
@ -340,19 +342,31 @@ export default function Navbar() {
</div>
<div className="space-y-5 text-[16px] font-bold">
<Link href="/about" className="block text-black dark:text-white">
<Link
href="/about"
className="block text-black dark:text-white"
>
{t("about")}
</Link>
<Link href="/advertising" className="block text-black dark:text-white">
<Link
href="/advertising"
className="block text-black dark:text-white"
>
{t("advertising")}
</Link>
<Link href="/contact" className="block text-black dark:text-white">
<Link
href="/contact"
className="block text-black dark:text-white"
>
{t("contact")}
</Link>
{!isLoggedIn ? (
<>
<Link href="/auth" className="block text-lg text-gray-800 dark:text-white">
<Link
href="/auth"
className="block text-lg text-gray-800 dark:text-white"
>
{t("login")}
</Link>
<Link
@ -390,5 +404,6 @@ export default function Navbar() {
</div>
)}
</header>
</RevealT>
);
}

View File

@ -93,17 +93,17 @@ export default function ImageDetail({ id }: { id: number }) {
files:
article.files?.map((f: any) => ({
id: f.id,
url: f.file_url,
fileName: f.file_name,
filePath: f.file_path,
fileThumbnail: f.file_thumbnail,
fileAlt: f.file_alt,
widthPixel: f.width_pixel,
heightPixel: f.height_pixel,
url: f.fileUrl,
fileName: f.fileName,
filePath: f.filePath,
fileThumbnail: f.fileThumbnail,
fileAlt: f.fileAlt,
widthPixel: f.widthPixel,
heightPixel: f.heightPixel,
size: f.size,
downloadCount: f.download_count,
createdAt: f.created_at,
updatedAt: f.updated_at,
downloadCount: f.downloadCount,
createdAt: f.createdAt,
updatedAt: f.updatedAt,
})) || [],
};
@ -155,7 +155,7 @@ export default function ImageDetail({ id }: { id: number }) {
<div className="relative">
<Image
placeholder={`data:image/svg+xml;base64,${toBase64(
shimmer(700, 475)
shimmer(700, 475),
)}`}
width={2560}
height={1440}
@ -185,7 +185,7 @@ export default function ImageDetail({ id }: { id: number }) {
<a onClick={() => setSelectedImage(index)} key={file?.id}>
<Image
placeholder={`data:image/svg+xml;base64,${toBase64(
shimmer(700, 475)
shimmer(700, 475),
)}`}
width={1920}
height={1080}
@ -223,7 +223,7 @@ export default function ImageDetail({ id }: { id: number }) {
</span>
<span className="flex items-center gap-1 border-r-2 pr-2 border-black text-black">
<Eye className="w-4 h-4" />
{data.viewCount || 0}
{data.clickCount || 0}{" "}
</span>
<span className="text-black">
Creator: {data.creatorGroupLevelName}

58
components/ui/Reveal.tsx Normal file
View File

@ -0,0 +1,58 @@
import React, { useRef, useEffect } from "react";
import { motion, useInView, useAnimation } from "framer-motion";
interface Props {
children: React.ReactNode;
}
export const Reveal = ({ children }: Props) => {
const ref = useRef(null);
const isInView = useInView(ref, { once: false });
const mainControls = useAnimation();
const slideControls = useAnimation();
useEffect(() => {
if (isInView) {
mainControls.start("visible");
slideControls.start("visible");
} else mainControls.start("hidden");
}, [isInView]);
return (
<div ref={ref}>
<motion.div
variants={{
hidden: { opacity: 0, y: 75 },
visible: { opacity: 1, y: 0 },
}}
initial="hidden"
animate={mainControls}
transition={{
duration: 1,
delay: 0.1,
}}
>
{children}
</motion.div>
{/* TODO green slide thingy */}
{/* <motion.div
variants={{
hidden: { left: 0 },
visible: { left: "100%" },
}}
initial="hidden"
animate={slideControls}
transition={{ duration: 0.5, ease: "easeIn" }}
style={{
position: "absolute",
top: 4,
bottom: 4,
left: 0,
right: 0,
background: "#5e84ff",
zIndex: 20,
}}
/> */}
</div>
);
};

63
components/ui/RevealL.tsx Normal file
View File

@ -0,0 +1,63 @@
import React, { useRef, useEffect } from "react";
import { motion, useInView, useAnimation } from "framer-motion";
interface Props {
children: React.ReactNode;
}
export const RevealL = ({ children }: Props) => {
const ref = useRef(null);
const isInView = useInView(ref, { once: false });
const mainControls = useAnimation();
const slideControls = useAnimation();
useEffect(() => {
if (isInView) {
mainControls.start("visible");
slideControls.start("visible");
} else {
mainControls.start("hidden");
}
}, [isInView]);
return (
<div ref={ref} className="relative overflow-hidden">
<motion.div
variants={{
hidden: { opacity: 0, x: -75 }, // ← muncul dari kiri
visible: { opacity: 1, x: 0 },
}}
initial="hidden"
animate={mainControls}
transition={{
duration: 1,
delay: 0.1,
}}
>
{children}
</motion.div>
{/* Optional: Slide Overlay Animation */}
{/*
<motion.div
variants={{
hidden: { left: 0 },
visible: { left: "100%" },
}}
initial="hidden"
animate={slideControls}
transition={{ duration: 0.5, ease: "easeIn" }}
style={{
position: "absolute",
top: 4,
bottom: 4,
left: 0,
right: 0,
background: "#5e84ff",
zIndex: 20,
}}
/>
*/}
</div>
);
};

63
components/ui/RevealR.tsx Normal file
View File

@ -0,0 +1,63 @@
import React, { useRef, useEffect } from "react";
import { motion, useInView, useAnimation } from "framer-motion";
interface Props {
children: React.ReactNode;
}
export const RevealR = ({ children }: Props) => {
const ref = useRef(null);
const isInView = useInView(ref, { once: false });
const mainControls = useAnimation();
const slideControls = useAnimation();
useEffect(() => {
if (isInView) {
mainControls.start("visible");
slideControls.start("visible");
} else {
mainControls.start("hidden");
}
}, [isInView]);
return (
<div ref={ref} className="relative overflow-hidden">
<motion.div
variants={{
hidden: { opacity: 0, x: 75 },
visible: { opacity: 1, x: 0 },
}}
initial="hidden"
animate={mainControls}
transition={{
duration: 1,
delay: 0.1,
}}
>
{children}
</motion.div>
{/* Optional: Slide Overlay Animation */}
{/*
<motion.div
variants={{
hidden: { left: 0 },
visible: { left: "100%" },
}}
initial="hidden"
animate={slideControls}
transition={{ duration: 0.5, ease: "easeIn" }}
style={{
position: "absolute",
top: 4,
bottom: 4,
left: 0,
right: 0,
background: "#5e84ff",
zIndex: 20,
}}
/>
*/}
</div>
);
};

63
components/ui/RevealT.tsx Normal file
View File

@ -0,0 +1,63 @@
import React, { useRef, useEffect } from "react";
import { motion, useInView, useAnimation } from "framer-motion";
interface Props {
children: React.ReactNode;
}
export const RevealT = ({ children }: Props) => {
const ref = useRef(null);
const isInView = useInView(ref, { once: false });
const mainControls = useAnimation();
const slideControls = useAnimation();
useEffect(() => {
if (isInView) {
mainControls.start("visible");
slideControls.start("visible");
} else {
mainControls.start("hidden");
}
}, [isInView]);
return (
<div ref={ref} className="relative overflow-hidden">
<motion.div
variants={{
hidden: { opacity: 0, y: -75 }, // 👈 muncul dari atas
visible: { opacity: 1, y: 0 },
}}
initial="hidden"
animate={mainControls}
transition={{
duration: 1,
delay: 0.1,
}}
>
{children}
</motion.div>
{/* Optional: Slide Overlay Animation */}
{/*
<motion.div
variants={{
hidden: { top: 0 },
visible: { top: "100%" },
}}
initial="hidden"
animate={slideControls}
transition={{ duration: 0.5, ease: "easeIn" }}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
bottom: 0,
background: "#5e84ff",
zIndex: 20,
}}
/>
*/}
</div>
);
};