feat(ui/ux): add timeline, hero, and NeoFetch components + data/hooks
- Add timeline route helper
- src/app/timeline/route.ts
- simple helper to navigate to #timeline
- Add NeoFetch component (client)
- src/components/NeoFetch.tsx
- Displays avatar iframe, uptime calculation, experience count, WakaTime stats, terminal/ip, locale and colour palette
- Uses custom hooks useIpData and useWakaTimeData, and events data
- Add Top (hero) component (client)
- src/components/Top.tsx
- Full-screen hero with randomized background, parallax on mouse, device orientation & motion handlers, requestPermission trigger on image click
- Includes Sidebar import and optimized Image usage
- Add Timeline UI component (client)
- src/components/timeline.tsx
- Year selector + filtered event list with links and icons
- Handles initial selection and rendering grouped by year
- Add reusable Timeline primitives (client)
- src/components/ui/timeline.tsx
- Timeline context and composable parts: Timeline, TimelineItem, Indicator, Separator, Date, Title, Content, Header
- Orientation support and controlled/uncontrolled API
- Add data & hooks
- src/lib/events.ts
- Seeded events array (education/awards/conference entries) used by timeline and NeoFetch
- src/hooks/use-ip-data.ts
- Fetches terminal/ip info from https://api.imnya.ng/ip
- src/hooks/use-wakatime-data.ts
- Fetches WakaTime summary from https://api.imnya.ng/wakatime
Notes:
- All new components are client-side ("use client")
- Adds device motion/orientation listeners with cleanup
- Provides basic error handling for network hooks
- Improves homepage/UX with interactive hero and timeline data visualization
This commit is contained in:
parent
2e1f6a7ec4
commit
96a43a9a3c
34 changed files with 771 additions and 63 deletions
110
src/components/NeoFetch.tsx
Normal file
110
src/components/NeoFetch.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
import { events } from "@/lib/events";
|
||||
import { useIpData } from "../hooks/use-ip-data";
|
||||
import { useWakaTimeData } from "../hooks/use-wakatime-data";
|
||||
|
||||
export default function NeoFetch() {
|
||||
const ipData = useIpData();
|
||||
const wakaTimeData = useWakaTimeData();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<iframe
|
||||
src="https://f.imnya.ng/.art.html"
|
||||
className="border-0 w-[430px] h-[430px] scale-75"
|
||||
></iframe>
|
||||
<div className="px-12 md:py-12 text-lg font-mono">
|
||||
<a href="mailto:contact@imnya.ng" className="text-[#FFD7D7]">
|
||||
imnyang<span className="text-foreground">@</span>adofai.gg
|
||||
</a>
|
||||
<p>----------</p>
|
||||
|
||||
<p className="text-foreground">
|
||||
<span className="text-[#FFD7D7]">Uptime</span>:{" "}
|
||||
{(() => {
|
||||
const startDate = new Date("2010-11-08T00:00:00+09:00");
|
||||
const now = new Date();
|
||||
let diff = now.getTime() - startDate.getTime();
|
||||
|
||||
const years = Math.floor(diff / (1000 * 60 * 60 * 24 * 365));
|
||||
diff %= 1000 * 60 * 60 * 24 * 365;
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
diff %= 1000 * 60 * 60 * 24;
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
diff %= 1000 * 60 * 60;
|
||||
const mins = Math.floor(diff / (1000 * 60));
|
||||
|
||||
return `${years} years, ${days} days, ${hours} hours, ${mins} mins`;
|
||||
})()}
|
||||
</p>
|
||||
|
||||
<p className="text-foreground">
|
||||
<span className="text-[#FFD7D7]">Experience</span>:{" "}
|
||||
{Object.values(events).flat().length}
|
||||
</p>
|
||||
|
||||
<p className="text-foreground">
|
||||
<span className="text-[#FFD7D7]">WakaTime</span>:{" "}
|
||||
{wakaTimeData?.data?.total_seconds_including_other_language
|
||||
? (() => {
|
||||
const seconds = wakaTimeData.data.total_seconds_including_other_language;
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${days} days, ${hours} hours, ${minutes} minutes, ${Math.round(
|
||||
seconds % 60
|
||||
)} seconds`;
|
||||
})()
|
||||
: "N/A"}
|
||||
</p>
|
||||
|
||||
<p className="text-foreground">
|
||||
<span className="text-[#FFD7D7]">Most used Language</span>:{" "}
|
||||
{wakaTimeData?.data?.languages[0]?.name || "N/A"}{" "}
|
||||
{wakaTimeData?.data?.languages[0]?.total_seconds
|
||||
? (() => {
|
||||
const seconds = wakaTimeData.data.languages[0].total_seconds;
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `(${days} days, ${hours} hours, ${minutes} minutes, ${Math.round(
|
||||
seconds % 60
|
||||
)} seconds)`;
|
||||
})()
|
||||
: "N/A"}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="text-[#FFD7D7]">Terminal</span>: {ipData}
|
||||
</p>
|
||||
|
||||
<p className="text-foreground">
|
||||
<span className="text-[#FFD7D7]">Locale</span>: ko_KR.UTF-8
|
||||
</p>
|
||||
|
||||
<br />
|
||||
<div id="color" className="grid grid-cols-8 w-fit">
|
||||
<p className="bg-background w-8">⠀⠀</p>
|
||||
<p className="bg-[#f38ba8] w-8">⠀⠀</p>
|
||||
<p className="bg-[#a6e3a1] w-8">⠀⠀</p>
|
||||
<p className="bg-[#f9e2af] w-8">⠀⠀</p>
|
||||
<p className="bg-[#89b4fa] w-8">⠀⠀</p>
|
||||
<p className="bg-[#f5c2e7] w-8">⠀⠀</p>
|
||||
<p className="bg-[#94e2d5] w-8">⠀⠀</p>
|
||||
<p className="bg-muted-foreground w-8">⠀⠀</p>
|
||||
|
||||
<p className="bg-[#45475a] w-8">⠀⠀</p>
|
||||
<p className="bg-[#f0c0cd] w-8">⠀⠀</p>
|
||||
<p className="bg-[#c3e9bf] w-8">⠀⠀</p>
|
||||
<p className="bg-[#f0e0bf] w-8">⠀⠀</p>
|
||||
<p className="bg-[#c3d9fd] w-8">⠀⠀</p>
|
||||
<p className="bg-[#f8d2ee] w-8">⠀⠀</p>
|
||||
<p className="bg-[#b1faee] w-8">⠀⠀</p>
|
||||
<p className="bg-foreground w-8">⠀⠀</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
src/components/Top.tsx
Normal file
105
src/components/Top.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"use client";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export default function Top() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [randomBg, setRandomBg] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
setRandomBg(Math.floor(Math.random() * 14) + 1);
|
||||
}, []);
|
||||
|
||||
const requestPermission = async () => {
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
|
||||
if (!isIOS)
|
||||
window.addEventListener("deviceorientation", handleDeviceOrientation);
|
||||
|
||||
try {
|
||||
const res = await (DeviceOrientationEvent as any).requestPermission();
|
||||
if (res === "granted") {
|
||||
window.addEventListener("devicemotion", handleDeviceMotion);
|
||||
window.addEventListener("deviceorientation", handleDeviceOrientation);
|
||||
}
|
||||
} catch {
|
||||
window.addEventListener("deviceorientation", handleDeviceOrientation);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeviceOrientation = (event: DeviceOrientationEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
const gamma = event.gamma || 0;
|
||||
const beta = event.beta || 0;
|
||||
|
||||
const x = (gamma / 90) * -50;
|
||||
const y = (beta / 180) * -50;
|
||||
|
||||
const bg = containerRef.current;
|
||||
bg.style.backgroundPosition = `${50 + x}% ${50 + y}%`;
|
||||
|
||||
const img = bg.querySelector("img");
|
||||
if (img) img.style.transform = `translate(${x * 2}px, ${y * 2}px)`;
|
||||
};
|
||||
|
||||
const handleDeviceMotion = (event: DeviceMotionEvent) => {
|
||||
if (!containerRef.current || !event.accelerationIncludingGravity) return;
|
||||
const { x, y } = event.accelerationIncludingGravity;
|
||||
if (x === null || y === null) return;
|
||||
const bg = containerRef.current;
|
||||
bg.style.backgroundPosition = `${50 - x * 3}% ${50 - y * 3}%`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.removeEventListener("deviceorientation", handleDeviceOrientation);
|
||||
window.removeEventListener("devicemotion", handleDeviceMotion);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
id="top"
|
||||
className="snap-start h-screen w-full relative"
|
||||
onWheel={(e) => {
|
||||
e.preventDefault();
|
||||
window.scrollBy(0, window.innerHeight * (e.deltaY > 0 ? 1 : -1));
|
||||
}}
|
||||
>
|
||||
<Sidebar />
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute top-0 right-0 w-[calc(100%-100px)] h-screen lg:h-[calc(100vh-56px)] flex flex-col items-center justify-end lg:rounded-bl-[160px] bg-cover bg-center overflow-hidden"
|
||||
style={{
|
||||
backgroundImage: `url('/background/${randomBg}.avif')`,
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
const { clientX, clientY } = e;
|
||||
const { innerWidth, innerHeight } = window;
|
||||
const x = (clientX / innerWidth - 0.5) * -50;
|
||||
const y = (clientY / innerHeight - 0.5) * -50;
|
||||
|
||||
const bg = e.currentTarget;
|
||||
if (bg) {
|
||||
bg.style.backgroundPosition = `${50 + x}% ${50 + y}%`;
|
||||
}
|
||||
const img = e.currentTarget.querySelector("img");
|
||||
if (img) {
|
||||
img.style.transform = `translate(${x}px, ${y}px)`;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={"/char.avif"}
|
||||
alt="character"
|
||||
width={4096}
|
||||
height={4096}
|
||||
className="w-[50vh] lg:w-[30vw] translate-y-[10%] transition-transform duration-100 ease-out"
|
||||
unoptimized
|
||||
onClick={requestPermission}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,26 @@
|
|||
import Image from "next/image";
|
||||
import { Popover } from "./ui/popover";
|
||||
|
||||
export default function Sidebar() {
|
||||
return (
|
||||
<div className="w-[100px] h-screen absolute top-0 left-0 flex flex-col items-center gap-6">
|
||||
<Image src="/Frame.svg" alt="logo" className="w-fit h-fit mt-[50px]" width={30} height={30} />
|
||||
<div className="font-ntype rotate-90 mt-8 text-3xl opacity-70 flex flex-row gap-2 w-full">
|
||||
<h1><a href="mailto:me@imnya.ng">me@imnya.ng</a></h1>
|
||||
</div>
|
||||
return (
|
||||
<div className="w-[100px] h-screen absolute top-0 left-0 flex flex-col items-center justify-between">
|
||||
<div className="w-full h-full flex flex-col items-center gap-6">
|
||||
<Image
|
||||
src="/Frame.svg"
|
||||
alt="logo"
|
||||
className="w-fit h-fit mt-[50px]"
|
||||
width={30}
|
||||
height={30}
|
||||
/>
|
||||
<div className="font-ntype rotate-90 mt-8 text-3xl opacity-70 flex flex-row gap-2 w-full">
|
||||
<h1>
|
||||
<a href="mailto:me@imnya.ng">me@imnya.ng</a>
|
||||
</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<Popover />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
86
src/components/timeline.tsx
Normal file
86
src/components/timeline.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"use client";
|
||||
import { events } from "@/lib/events";
|
||||
import { LinkIcon } from "lucide-react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
export default function TimelineComponent() {
|
||||
const [selectedYear, setSelectedYear] = useState<number | null>(null);
|
||||
|
||||
const years = Array.from(
|
||||
new Set(events.map((event) => new Date(event.date).getFullYear()))
|
||||
).sort((a, b) => b - a);
|
||||
|
||||
useEffect(() => {
|
||||
if (years.length > 0 && selectedYear === null) {
|
||||
setSelectedYear(years[0]);
|
||||
}
|
||||
}, [years, selectedYear]);
|
||||
|
||||
const filteredEvents = selectedYear
|
||||
? events.filter(
|
||||
(event) => new Date(event.date).getFullYear() === selectedYear
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div
|
||||
id="timeline"
|
||||
className="w-full flex flex-col items-center justify-center px-12 mt-8"
|
||||
>
|
||||
<div className="w-full">
|
||||
<h1 className="text-2xl font-bold mb-4 w-full">🌠 수상 및 교육</h1>
|
||||
<br />
|
||||
<div className="flex flex-col md:flex-row gap-4 h-full">
|
||||
{/* Left column - Year buttons */}
|
||||
<div className="w-full md:w-24 flex flex-row md:flex-col gap-2 overflow-y-auto pr-2">
|
||||
{years.map((year) => (
|
||||
<button
|
||||
key={year}
|
||||
onClick={() => setSelectedYear(year)}
|
||||
className={`px-4 py-2 rounded-lg font-semibold transition-all text-sm ${
|
||||
selectedYear === year
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-background border border-border hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right column - Events */}
|
||||
<div className="flex-1 overflow-y-auto pr-2">
|
||||
<div className="space-y-2">
|
||||
{filteredEvents.map((event, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border bg-background px-4 py-3"
|
||||
>
|
||||
<div className="flex flex-row gap-2 mb-1">
|
||||
<span className="text-md font-semibold fixed-width-number">
|
||||
{new Date(event.date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
<span className="text-md font-semibold fixed-width-number text-muted-foreground">
|
||||
ㆍ{event.category}
|
||||
</span>
|
||||
</div>
|
||||
{event.link ? (
|
||||
<a href={event.link} className="">
|
||||
{event.description}{" "}
|
||||
<LinkIcon className="inline-block w-4 h-4 mb-1 ml-1" />
|
||||
</a>
|
||||
) : (
|
||||
<span>{event.description}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
src/components/ui/timeline.tsx
Normal file
210
src/components/ui/timeline.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Types
|
||||
type TimelineContextValue = {
|
||||
activeStep: number
|
||||
setActiveStep: (step: number) => void
|
||||
}
|
||||
|
||||
// Context
|
||||
const TimelineContext = React.createContext<TimelineContextValue | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
const useTimeline = () => {
|
||||
const context = React.useContext(TimelineContext)
|
||||
if (!context) {
|
||||
throw new Error("useTimeline must be used within a Timeline")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Components
|
||||
interface TimelineProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
defaultValue?: number
|
||||
value?: number
|
||||
onValueChange?: (value: number) => void
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}
|
||||
|
||||
function Timeline({
|
||||
defaultValue = 1,
|
||||
value,
|
||||
onValueChange,
|
||||
orientation = "vertical",
|
||||
className,
|
||||
...props
|
||||
}: TimelineProps) {
|
||||
const [activeStep, setInternalStep] = React.useState(defaultValue)
|
||||
|
||||
const setActiveStep = React.useCallback(
|
||||
(step: number) => {
|
||||
if (value === undefined) {
|
||||
setInternalStep(step)
|
||||
}
|
||||
onValueChange?.(step)
|
||||
},
|
||||
[value, onValueChange]
|
||||
)
|
||||
|
||||
const currentStep = value ?? activeStep
|
||||
|
||||
return (
|
||||
<TimelineContext.Provider
|
||||
value={{ activeStep: currentStep, setActiveStep }}
|
||||
>
|
||||
<div
|
||||
data-slot="timeline"
|
||||
className={cn(
|
||||
"group/timeline flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
data-orientation={orientation}
|
||||
{...props}
|
||||
/>
|
||||
</TimelineContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// TimelineContent
|
||||
function TimelineContent({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="timeline-content"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// TimelineDate
|
||||
interface TimelineDateProps extends React.HTMLAttributes<HTMLTimeElement> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
function TimelineDate({
|
||||
asChild = false,
|
||||
className,
|
||||
...props
|
||||
}: TimelineDateProps) {
|
||||
const Comp = asChild ? Slot.Root : "time"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="timeline-date"
|
||||
className={cn(
|
||||
"text-muted-foreground mb-1 block text-xs font-medium group-data-[orientation=vertical]/timeline:max-sm:h-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// TimelineHeader
|
||||
function TimelineHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div data-slot="timeline-header" className={cn(className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
// TimelineIndicator
|
||||
interface TimelineIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
function TimelineIndicator({
|
||||
asChild = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TimelineIndicatorProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="timeline-indicator"
|
||||
className={cn(
|
||||
"border-primary/20 group-data-completed/timeline-item:border-primary absolute size-4 rounded-full border-2 group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:left-0 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:top-0 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2",
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// TimelineItem
|
||||
interface TimelineItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
step: number
|
||||
}
|
||||
|
||||
function TimelineItem({ step, className, ...props }: TimelineItemProps) {
|
||||
const { activeStep } = useTimeline()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="timeline-item"
|
||||
className={cn(
|
||||
"group/timeline-item has-[+[data-completed]]:[&_[data-slot=timeline-separator]]:bg-primary relative flex flex-1 flex-col gap-0.5 group-data-[orientation=horizontal]/timeline:mt-8 group-data-[orientation=horizontal]/timeline:not-last:pe-8 group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=vertical]/timeline:not-last:pb-12",
|
||||
className
|
||||
)}
|
||||
data-completed={step <= activeStep || undefined}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// TimelineSeparator
|
||||
function TimelineSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="timeline-separator"
|
||||
className={cn(
|
||||
"bg-primary/10 absolute self-start group-last/timeline-item:hidden group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:h-0.5 group-data-[orientation=horizontal]/timeline:w-[calc(100%-1rem-0.25rem)] group-data-[orientation=horizontal]/timeline:translate-x-4.5 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem-0.25rem)] group-data-[orientation=vertical]/timeline:w-0.5 group-data-[orientation=vertical]/timeline:-translate-x-1/2 group-data-[orientation=vertical]/timeline:translate-y-4.5",
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// TimelineTitle
|
||||
function TimelineTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
data-slot="timeline-title"
|
||||
className={cn("text-sm font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue