좀 많은게 바뀐거 같아요
This commit is contained in:
parent
b3f61ef0ed
commit
0dfe8ee371
10 changed files with 505 additions and 1106 deletions
|
|
@ -1,111 +0,0 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Send, AlignJustify, BadgeCheck, House, CircleHelp, ChartGantt, PhoneCall } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export default function BottomBar() {
|
||||
const [email, setEmail] = useState<string>('me@imnya.ng');
|
||||
const [hash, setHash] = useState<string>(window.location.hash);
|
||||
const [accessKeyCombo, setAccessKeyCombo] = useState<string>("Alt");
|
||||
|
||||
useEffect(() => {
|
||||
const emaillist = ['me', 'mail', 'not', 'cat', 'neko', 'meow', 'heart'];
|
||||
const domainlist = ['imnya.ng', 'al-1s.kr'];
|
||||
|
||||
const randomEmail = () => {
|
||||
const random = Math.floor(Math.random() * 1000);
|
||||
if (random === 0) {
|
||||
setEmail(`furry@${domainlist[Math.floor(Math.random() * domainlist.length)]}`);
|
||||
} else {
|
||||
setEmail(`${emaillist[Math.floor(Math.random() * emaillist.length)]}@${domainlist[Math.floor(Math.random() * domainlist.length)]}`);
|
||||
}
|
||||
};
|
||||
randomEmail();
|
||||
|
||||
const handleHashChange = () => {
|
||||
setHash(window.location.hash);
|
||||
};
|
||||
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', handleHashChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const ua = navigator.userAgent;
|
||||
let keyCombo = "Alt";
|
||||
|
||||
if (/Mac/i.test(ua)) {
|
||||
keyCombo = "Control + Option";
|
||||
} else if (/Linux/i.test(ua)) {
|
||||
keyCombo = "Alt";
|
||||
if (/Firefox/i.test(ua)) {
|
||||
keyCombo = "Alt + Shift";
|
||||
}
|
||||
} else if (/Windows/i.test(ua)) {
|
||||
if (/Firefox/i.test(ua)) {
|
||||
keyCombo = "Alt + Shift";
|
||||
}
|
||||
}
|
||||
|
||||
setAccessKeyCombo(keyCombo);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full flex justify-center fixed bottom-0 z-50">
|
||||
<header className="bg-background/75 text-foreground w-full md:w-[50%] h-12 border rounded-full select-none m-4 mt-2">
|
||||
<div className="flex items-center justify-between w-full h-full py-4 px-8">
|
||||
<a href={`mailto:${email}`} className="flex flex-row gap-4"><Send width={16} /> {email}</a>
|
||||
<div>
|
||||
{["top", "about", "project", "timeline", "contact"].map((section, index) => (
|
||||
<button
|
||||
key={section}
|
||||
onClick={() => window.location.hash = `#${section}`}
|
||||
accessKey={(index + 1).toString()}
|
||||
className="w-[0px] h-[0px] text-[0px] text-background"
|
||||
></button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button><AlignJustify /></button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{["top", "about", "project", "timeline", "contact"].map((section, index) => {
|
||||
const icons = [House, CircleHelp, ChartGantt, ChartGantt, PhoneCall];
|
||||
const Icon = icons[index];
|
||||
return (
|
||||
<DropdownMenuItem asChild key={section}>
|
||||
<a href={`#${section}`} className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row gap-2 items-center justify-between h-4">
|
||||
{hash === `#${section}` ? (
|
||||
<BadgeCheck width={16} height={16} />
|
||||
) : (
|
||||
<Icon width={16} height={16} />
|
||||
)}
|
||||
{section.charAt(0).toUpperCase() + section.slice(1)}
|
||||
</div>
|
||||
<p className="text-muted-foreground">{accessKeyCombo} + {index + 1}</p>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>© 2021-2025 imnyang</DropdownMenuLabel>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
src/components/Contact.tsx
Normal file
91
src/components/Contact.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { Github, Instagram, Rss } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function Contact() {
|
||||
return (
|
||||
<div className="w-full h-64 flex items-center justify-center">
|
||||
<div className="w-full md:w-[50%] p-4 flex items-center justify-center flex-col gap-4">
|
||||
<div className="flex items-center justify-center gap-4 flex-row">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="https://github.com/imnyang"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex flex-row gap-4"
|
||||
>
|
||||
<Github />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="px-2 py-1 text-xs">
|
||||
Github
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="https://x.com/imnya_ng"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex flex-row gap-4 text-3xl"
|
||||
>
|
||||
𝕏
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="px-2 py-1 text-xs">𝕏</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="https://instagram.com/loopback.ip"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex flex-row gap-4"
|
||||
>
|
||||
<Instagram />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="px-2 py-1 text-xs">
|
||||
Instagram
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="https://blog.imnya.ng"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex flex-row gap-4"
|
||||
>
|
||||
<Rss />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="px-2 py-1 text-xs">
|
||||
Blog
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 items-center justify-center">
|
||||
<p>Github</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<a href="https://github.com/sponsors/imnyang" target="_blank" rel="noreferrer" className="flex items-center justify-center gap-2">
|
||||
<span>💕</span>
|
||||
<span>Sponsor</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export default function About() {
|
||||
const [wakatime, setWakatime] = useState<any>();
|
||||
const [time, setTime] = useState<number>(0);
|
||||
const [post, setPost] = useState<any>({});
|
||||
const [age, setAge] = useState<number>(0);
|
||||
const [totalSeconds, setTotalSeconds] = useState<number>(0);
|
||||
const [isVisible, setIsVisible] = useState<boolean>(false);
|
||||
const AboutRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 나이 계산
|
||||
const referenceDate = new Date(2010, 10, 8); // 2010년 11월 8일 (0-indexed)
|
||||
const currentDate = new Date();
|
||||
let calculatedAge = currentDate.getFullYear() - referenceDate.getFullYear();
|
||||
if (currentDate < new Date(currentDate.getFullYear(), referenceDate.getMonth(), referenceDate.getDate())) {
|
||||
calculatedAge -= 1;
|
||||
}
|
||||
setAge(calculatedAge);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 블로그 데이터 가져오기
|
||||
fetch("https://api.imnya.ng/rss")
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data) setPost(data[0] || {});
|
||||
})
|
||||
.catch(error => console.error("Error fetching posts:", error));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Intersection Observer로 isVisible 상태 변경
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
if (AboutRef.current) observer.observe(AboutRef.current);
|
||||
|
||||
return () => {
|
||||
if (AboutRef.current) observer.unobserve(AboutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Wakatime 데이터 가져오기 (한 번만 실행)
|
||||
fetch("https://api.imnya.ng/wakatime")
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data) {
|
||||
const roundedSeconds = Math.round(data.data.total_seconds); // 반올림
|
||||
setTotalSeconds(roundedSeconds);
|
||||
setWakatime(data.data);
|
||||
}
|
||||
})
|
||||
.catch(error => console.error("Error fetching Wakatime data:", error));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
const start = Date.now() - time;
|
||||
|
||||
let animationFrameId: number;
|
||||
|
||||
const tick = () => {
|
||||
const elapsed = Date.now() - start;
|
||||
if (elapsed >= totalSeconds) {
|
||||
setTime(totalSeconds);
|
||||
return;
|
||||
}
|
||||
setTime(elapsed);
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
|
||||
return () => cancelAnimationFrame(animationFrameId);
|
||||
}, [isVisible, totalSeconds]);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center justify-center" ref={AboutRef}>
|
||||
<div className="w-full md:w-[50%] p-4">
|
||||
<h1 className="text-2xl font-bold">🤔 About</h1>
|
||||
<div className="flex items-start justify-center flex-col p-2 mt-2 w-full">
|
||||
<div className="flex flex-col font-light text-2xl">
|
||||
<h1>항상 <strong className="font-black">새로운 것</strong>을 찾고</h1>
|
||||
<h1>삶을 더 <strong className="font-black">간단명료</strong>하게 만들고 있는</h1>
|
||||
<h1>학생 개발자 <strong className="font-black">남현석</strong>입니다.</h1>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<p>{age}살의 어린 나이지만</p>
|
||||
<p>저는 항상 제가 할 수 있는 최적의 코드를 목표로 하고</p>
|
||||
<p>사용자의 경험을 중심적으로 고려하며</p>
|
||||
<p>새로운 기술에 대한 관심이 높습니다.</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<a className="mt-4 text-foreground" href="https://wakatime.com/@imnyang" target="_blank" rel="noopener noreferrer">
|
||||
Wakatime Mar 18th ~ : <span className="tnum text-muted-foreground">{time}</span>s
|
||||
</a>
|
||||
<h1 className="text-foreground">
|
||||
최근 블로그 보기 : <a href={post.link} className="text-muted-foreground">{post.title}</a>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import ProjectCard from "../ProjectCard";
|
||||
|
||||
const projects = [
|
||||
{
|
||||
name: "🍴 오늘 인천 상정중학교",
|
||||
description: "오늘의 급식을 인스타그램에서도 빠르게",
|
||||
link: "https://www.instagram.com/today.isangjeong/"
|
||||
},
|
||||
{
|
||||
name: "💕 Two Hearts",
|
||||
description: "Chrome 새탭을 더 좋게 (사실 Edge에서 쓰려고 만듦)",
|
||||
link: "https://github.com/imnyang/two_hearts"
|
||||
},
|
||||
{
|
||||
name: "🩷 Dynamic Kawaii",
|
||||
description: "진정한 VSCode 테마",
|
||||
link: "https://github.com/imnyang/dynamic-kawaii"
|
||||
},
|
||||
{
|
||||
name: "💊 FakeAlyac",
|
||||
description: "어? 내 시스템 트레이에 있는거 알약 아닌데?",
|
||||
link: "https://github.com/imnyang/FakeAlyac"
|
||||
}
|
||||
]
|
||||
|
||||
export default function Project() {
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center justify-center select-none">
|
||||
<div className="w-full md:w-[50%] p-4">
|
||||
<h1 className="text-2xl font-bold">📖 Project</h1>
|
||||
<div className="mt-4 gap-4 grid grid-cols-1 md:grid-cols-2">
|
||||
{projects.map((project, index) => (
|
||||
<ProjectCard key={index} name={project.name} description={project.description} link={project.link} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import "../../index.css";
|
||||
import Image from "@/profile.avif";
|
||||
|
||||
export default function Top() {
|
||||
const [mousePos, setMousePos] = useState({ x: 50, y: 50 });
|
||||
const [scale, setScale] = useState(1.15);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
const { left, top, width, height } = e.currentTarget.getBoundingClientRect();
|
||||
const x = ((e.clientX - left) / width) * 100;
|
||||
const y = ((e.clientY - top) / height) * 100;
|
||||
|
||||
setMousePos((prev) => ({
|
||||
x: prev.x + (x - prev.x) * 0.1,
|
||||
y: prev.y + (y - prev.y) * 0.1,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleWheel = (e) => {
|
||||
e.preventDefault();
|
||||
setScale((prev) => {
|
||||
const newScale = prev + e.deltaY * -0.0025;
|
||||
return Math.min(Math.max(newScale, 1), 3);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background text-foreground w-full h-screen flex items-center justify-center">
|
||||
<div className="flex flex-col md:flex-row w-full md:w-[50%] h-full py-16 md:py-32">
|
||||
<div className="w-full md:w-[45%] h-full flex flex-col justify-center items-center md:items-end md:pr-16">
|
||||
<div className="text-center md:text-right">
|
||||
<h1 className="text-5xl ntypefont">
|
||||
Nam
|
||||
<br />
|
||||
HyunSuk
|
||||
</h1>
|
||||
<p className="mt-4 text-xl">
|
||||
암냥이라는 이름으로 활동하고 있는
|
||||
<br /> 학생 개발자 남현석이라고 합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="md:w-[55%] select-none p-5 md:p-0 w-full h-full flex justify-center md:justify-end items-end rounded-3xl mb-8 md:mb-0 overflow-hidden"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<img
|
||||
src={Image}
|
||||
alt="Me"
|
||||
className="w-full h-full object-cover rounded-3xl transition-transform duration-500 ease-out"
|
||||
style={{
|
||||
transform: `scale(${isHovering ? scale : 1})`,
|
||||
transformOrigin: `${mousePos.x}% ${mousePos.y}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
|
||||
export default function Wakatime() {
|
||||
const [wakatime, setWakatime] = useState<any>();
|
||||
useEffect(() => {
|
||||
// Wakatime 데이터 가져오기 (한 번만 실행)
|
||||
fetch("https://api.imnya.ng/wakatime")
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data) {
|
||||
setWakatime(data.data);
|
||||
}
|
||||
})
|
||||
.catch(error => console.error("Error fetching Wakatime data:", error));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center justify-center">
|
||||
<div className="w-full md:w-[50%] p-4">
|
||||
<a className="text-2xl font-bold" href="https://wakatime.com/@imnyang" target="_blank" rel="noopener noreferrer">🍝 Wakatime</a>
|
||||
<p>Dashboards for developers</p>
|
||||
<br />
|
||||
{wakatime && wakatime.languages && (
|
||||
<div>
|
||||
<p>총 시간: {(wakatime.human_readable_total)}</p>
|
||||
<p>하루 평균: {wakatime.human_readable_daily_average}</p>
|
||||
<br />
|
||||
|
||||
<p>가장 많이 사용한 언어:</p>
|
||||
<ul>
|
||||
{wakatime.languages.slice(0, 3).map((language: any, index: number) => (
|
||||
<li key={index}>{index+1}. {language.name}: {language.percent}%</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
|
||||
interface ProjectCardProps {
|
||||
name: string;
|
||||
description: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
const ProjectCard: React.FC<ProjectCardProps> = ({ name, description, link }) => {
|
||||
return (
|
||||
<a href={link}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{name}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectCard;
|
||||
220
src/components/TimeLine.tsx
Normal file
220
src/components/TimeLine.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { Accordion, AccordionContent, AccordionItem } from "@/components/ui/accordion";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
const events = [
|
||||
{
|
||||
date: "2025-02-27",
|
||||
description:
|
||||
"화이트햇스쿨 3기 최종합격",
|
||||
category: "Education",
|
||||
link: "https://whitehatschool.kr/home/kor/main.do"
|
||||
},
|
||||
{
|
||||
date: "2025-01-19",
|
||||
description:
|
||||
"2024 Sunrin LOGCON(TeamLog 주최) 중등부 3위",
|
||||
category: "Award",
|
||||
link: "https://teamlog.kr"
|
||||
},
|
||||
{
|
||||
date: "2025-01-12",
|
||||
description:
|
||||
"2024 Sunrin Layer7 CTF 중등부 2위",
|
||||
category: "Award",
|
||||
link: "https://layer7.kr"
|
||||
},
|
||||
{
|
||||
date: "2025-01-10",
|
||||
description:
|
||||
"선린인터넷고 중학생 특별교육 이수",
|
||||
category: "Education",
|
||||
link: "https://sunrint.sen.hs.kr/"
|
||||
},
|
||||
{
|
||||
date: "2024-12-14",
|
||||
description:
|
||||
"2024 글로벌스타트업학교 K-청소년스타트업 경진대회 우수상 수상",
|
||||
category: "Award",
|
||||
link: "https://www.ncf.or.kr/projects/'2024-%EA%B8%80%EB%A1%9C%EB%B2%8C%EC%8A%A4%ED%83%80%ED%8A%B8%EC%97%85%ED%95%99%EA%B5%90-k-%EC%B2%AD%EC%86%8C%EB%85%84%EC%8A%A4%ED%83%80%ED%8A%B8%EC%97%85-%EA%B2%BD%EC%A7%84%EB%8C%80%ED%9A%8C'-%EC%B0%B8%EA%B0%80%EC%9E%90-%EB%AA%A8%EC%A7%91",
|
||||
},
|
||||
{
|
||||
date: "2024-12-07",
|
||||
description: "글로벌 스타트업 학교 팀 1위",
|
||||
category: "Award",
|
||||
link: "https://ncf.or.kr",
|
||||
},
|
||||
{
|
||||
date: "2024-12-07",
|
||||
description: "글로벌 스타트업 학교 개인 최우수상",
|
||||
category: "Award",
|
||||
link: "https://ncf.or.kr",
|
||||
},
|
||||
{
|
||||
date: "2024-08-18",
|
||||
description: "29회 해킹캠프 CTF 1위 (고민중독)",
|
||||
category: "Award & Conference",
|
||||
link: "https://ctf.hackingcamp.org/",
|
||||
},
|
||||
{
|
||||
date: "2024-08-01",
|
||||
description:
|
||||
"글로벌 스타트업 학교 2기 베트남 해외 연수 데모데이 대상 (1위)",
|
||||
category: "Award",
|
||||
link: "http://ncf.or.kr",
|
||||
},
|
||||
{
|
||||
date: "2024-05-16",
|
||||
description: "글로벌 스타트업 학교 2기 합격",
|
||||
category: "Education",
|
||||
link: "http://ncf.or.kr",
|
||||
},
|
||||
{
|
||||
date: "2024-05-11",
|
||||
description: "LG AI 청소년 캠프 1기 LG 탐색상 수상",
|
||||
category: "Award",
|
||||
link: "https://lgaiyouthcamp.or.kr/",
|
||||
},
|
||||
{
|
||||
date: "2024-05-11",
|
||||
description: "LG AI 청소년 캠프 1기 수료",
|
||||
category: "Award & Education",
|
||||
link: "https://lgaiyouthcamp.or.kr/",
|
||||
},
|
||||
{
|
||||
date: "2023-11-14",
|
||||
description: "인천상정중학교 2023학년도 SW 문제 해결 활동 우수상(2위) 수여",
|
||||
category: "Award",
|
||||
},
|
||||
{
|
||||
date: "2023-09-02",
|
||||
description:
|
||||
"선린인터넷고등학교 제6회 소프트웨어나눔축제 Layer7 부서 과정 이수",
|
||||
category: "Education",
|
||||
},
|
||||
{
|
||||
date: "2023-07-24",
|
||||
description: "한국정보기술연구원이 주도하는 사이버 가디언즈 보안캠프 수료",
|
||||
category: "Education",
|
||||
},
|
||||
{
|
||||
date: "2023-05-15",
|
||||
description: "한국 코드페어 예선 진출",
|
||||
category: "Award",
|
||||
},
|
||||
{
|
||||
date: "2022-12-20",
|
||||
description: "2022 SW영재 창작대회 은상 수상",
|
||||
category: "Award",
|
||||
},
|
||||
{
|
||||
date: "2022-09-27",
|
||||
description: "2022 삼성 주니어 SW 창작대회 본선 진출",
|
||||
category: "Award",
|
||||
},
|
||||
{
|
||||
date: "2022-05-23",
|
||||
description: "2022학년도 석정초SW영재학급 첫 수업",
|
||||
category: "Education",
|
||||
},
|
||||
{
|
||||
date: "2022-07-26",
|
||||
description: "제 14회 맑은하늘 맑은웃음 공모전에서 맑은웃음상 수여",
|
||||
category: "Award",
|
||||
},
|
||||
{
|
||||
date: "2021-11-14",
|
||||
description: "Become a ZEPETO Creator 이수",
|
||||
category: "Education",
|
||||
},
|
||||
{
|
||||
date: "2021-05-19",
|
||||
description:
|
||||
"소프트웨어와 전자신문이 주관한 소프트웨어재단 꿈찾기 캠프 이수",
|
||||
category: "Education",
|
||||
},
|
||||
{
|
||||
date: "2018-01-27",
|
||||
description:
|
||||
"제4회 맑은하늘 맑은웃음 어린이 문예공모전에서 위닉스상(2위) 수여",
|
||||
category: "Award",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Timeline() {
|
||||
const [count, setCount] = useState<number>(0);
|
||||
const [isVisible, setIsVisible] = useState<boolean>(false);
|
||||
const TimelineRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
if (TimelineRef.current) {
|
||||
observer.observe(TimelineRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (TimelineRef.current) {
|
||||
observer.unobserve(TimelineRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && count < events.length) {
|
||||
const timer = setTimeout(() => setCount(count + 1), count === 0 ? 300 : 25);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isVisible, count]);
|
||||
|
||||
return (
|
||||
<div ref={TimelineRef} className="w-full flex flex-col items-center justify-center mt-8">
|
||||
<div className="w-full">
|
||||
<h1 className="text-2xl font-bold mb-4 w-full">🌠 수상 및 교육</h1>
|
||||
<p>현재까지 {count}개의 개성있는 조각들이 모였어요!</p>
|
||||
<br/>
|
||||
<Accordion type="multiple" className="w-full space-y-2">
|
||||
{Array.from(new Set(events.map(event => new Date(event.date).getFullYear()))).sort((a, b) => b - a).map(year => (
|
||||
<AccordionItem
|
||||
value={year.toString()}
|
||||
key={year}
|
||||
className="rounded-lg border bg-background px-4 py-1"
|
||||
>
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger className="flex flex-1 items-center gap-3 py-2 text-left text-[15px] font-semibold leading-6 transition-all [&>svg>path:last-child]:origin-center [&>svg>path:last-child]:transition-all [&>svg>path:last-child]:duration-200 [&>svg]:-order-1 [&[data-state=open]>svg>path:last-child]:rotate-90 [&[data-state=open]>svg>path:last-child]:opacity-0 [&[data-state=open]>svg]:rotate-180">
|
||||
{year}
|
||||
<Plus
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
className="shrink-0 opacity-60 transition-transform duration-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
<AccordionContent className="pb-2 ps-7 text-foreground overflow-y-auto">
|
||||
{events.filter(event => new Date(event.date).getFullYear() === year).map((event, index) => (
|
||||
<div key={index} className="my-2">
|
||||
<p className="flex flex-row"><p className="text-md font-semibold fixed-width-number">{new Date(event.date).toLocaleDateString('en-US', { month: 'short', day: '2-digit' })}</p><p className="text-md font-semibold fixed-width-number text-muted-foreground">ㆍ{event.category}</p></p>
|
||||
{event.link ? (
|
||||
<a href={event.link}>{event.description}</a>
|
||||
) : (
|
||||
<span>{event.description}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,255 +0,0 @@
|
|||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ function TooltipTrigger({
|
|||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
|
|
@ -44,7 +44,7 @@ function TooltipContent({
|
|||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-w-sm rounded-md px-3 py-1.5 text-xs",
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
Loading…
Reference in a new issue