FIX(app): fix navbar error
- Fix navigation bar issue - Correct component rendering
This commit is contained in:
@@ -13,7 +13,7 @@ commonLabels:
|
|||||||
# 이미지 태그 설정
|
# 이미지 태그 설정
|
||||||
images:
|
images:
|
||||||
- name: ghcr.io/mayne0213/portfolio
|
- name: ghcr.io/mayne0213/portfolio
|
||||||
newTag: main-sha-ab364cf43229f665303531dfae03af39df6fb2b1
|
newTag: main-sha-88c70df51ff99151aeec57bb855d36141c85bfe4
|
||||||
|
|
||||||
patchesStrategicMerge:
|
patchesStrategicMerge:
|
||||||
- deployment-patch.yaml
|
- deployment-patch.yaml
|
||||||
|
|||||||
@@ -271,4 +271,15 @@
|
|||||||
|
|
||||||
.dark .stars {
|
.dark .stars {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
import SectionHeader from '@/components/landing/section-header';
|
|
||||||
import { Card } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { ExternalLink, BookOpen, BarChart3, Github } from 'lucide-react';
|
|
||||||
import Image, { StaticImageData } from 'next/image';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import joossamHome from '@/public/joossam/home.png';
|
|
||||||
import jotionHome from '@/public/jotion/home.png';
|
|
||||||
import joossamMain from '@/public/joossam/main.png';
|
|
||||||
import jaejadleHome from '@/public/jaejadle/home.png';
|
|
||||||
import portfolioHome from '@/public/portfolio/home.png';
|
|
||||||
import todoListHome from '@/public/todoList/home.png';
|
|
||||||
import joviesHome from '@/public/jovies/home.png';
|
|
||||||
|
|
||||||
interface ProjectData {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
longDescription: string;
|
|
||||||
tags: string[];
|
|
||||||
imageSrc: StaticImageData;
|
|
||||||
githubUrl?: string;
|
|
||||||
liveUrl?: string;
|
|
||||||
docsUrl?: string;
|
|
||||||
monitoringUrl?: string;
|
|
||||||
featured?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROJECTS_DATA: ProjectData[] = [
|
|
||||||
{
|
|
||||||
title: 'Joossam English',
|
|
||||||
description: 'English learning platform for Korean students',
|
|
||||||
longDescription: 'A comprehensive English learning platform featuring interactive lessons, practice exercises, and progress tracking. Built with Next.js for optimal performance and SEO.',
|
|
||||||
tags: ['Next.js', 'TypeScript', 'Prisma', 'MySQL', 'NextAuth.js', 'Tailwind CSS'],
|
|
||||||
imageSrc: joossamHome,
|
|
||||||
githubUrl: 'https://github.com/minjo-on/joossam',
|
|
||||||
liveUrl: 'https://joossam.com',
|
|
||||||
docsUrl: '#',
|
|
||||||
monitoringUrl: '#',
|
|
||||||
featured: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Jotion',
|
|
||||||
description: 'Notion-like note-taking application',
|
|
||||||
longDescription: 'A powerful note-taking and knowledge management application inspired by Notion. Features rich text editing, hierarchical organization, and real-time collaboration.',
|
|
||||||
tags: ['Next.js', 'React', 'Convex', 'Clerk', 'BlockNote', 'Tailwind CSS'],
|
|
||||||
imageSrc: jotionHome,
|
|
||||||
githubUrl: 'https://github.com/minjo-on/jotion',
|
|
||||||
liveUrl: 'https://jotion.minjo.xyz',
|
|
||||||
docsUrl: '#',
|
|
||||||
monitoringUrl: '#',
|
|
||||||
featured: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Youni Classic',
|
|
||||||
description: 'Church community management system',
|
|
||||||
longDescription: 'A comprehensive church management system with member directory, event scheduling, and communication tools. Designed for efficient church administration.',
|
|
||||||
tags: ['Next.js', 'TypeScript', 'MySQL', 'Prisma', 'Tailwind CSS'],
|
|
||||||
imageSrc: joossamMain,
|
|
||||||
githubUrl: 'https://github.com/minjo-on/youniClassic',
|
|
||||||
docsUrl: '#',
|
|
||||||
monitoringUrl: '#',
|
|
||||||
featured: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Jaejadle Church',
|
|
||||||
description: 'Church website with event management',
|
|
||||||
longDescription: 'Modern church website featuring event calendars, sermon archives, and member portals. Built with focus on accessibility and mobile responsiveness.',
|
|
||||||
tags: ['Next.js', 'TypeScript', 'Tailwind CSS', 'Shadcn/ui'],
|
|
||||||
imageSrc: jaejadleHome,
|
|
||||||
githubUrl: 'https://github.com/minjo-on/jaejadle',
|
|
||||||
liveUrl: 'https://jaejadle.com',
|
|
||||||
docsUrl: '#',
|
|
||||||
monitoringUrl: '#',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Portfolio',
|
|
||||||
description: 'Personal portfolio website with Kubernetes deployment',
|
|
||||||
longDescription: 'My personal portfolio website showcasing projects and skills. Deployed on self-managed Kubernetes cluster with GitOps workflow using ArgoCD.',
|
|
||||||
tags: ['Next.js', 'TypeScript', 'Tailwind CSS', 'Docker', 'Kubernetes', 'ArgoCD'],
|
|
||||||
imageSrc: portfolioHome,
|
|
||||||
githubUrl: 'https://github.com/minjo-on/portfolio',
|
|
||||||
liveUrl: 'https://minjo.xyz',
|
|
||||||
docsUrl: '#',
|
|
||||||
monitoringUrl: '#',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '[Seminar] Todo List',
|
|
||||||
description: 'Educational todo list application for teaching',
|
|
||||||
longDescription: 'A todo list application created for teaching full-stack development concepts. Covers React fundamentals, state management, and API integration.',
|
|
||||||
tags: ['React', 'TypeScript', 'Vite', 'Tailwind CSS'],
|
|
||||||
imageSrc: todoListHome,
|
|
||||||
githubUrl: 'https://github.com/minjo-on/todoList',
|
|
||||||
docsUrl: '#',
|
|
||||||
monitoringUrl: '#',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Jovies',
|
|
||||||
description: 'Movie discovery and tracking application',
|
|
||||||
longDescription: 'A movie discovery platform integrating with TMDB API. Features movie search, ratings, reviews, and personal watchlists.',
|
|
||||||
tags: ['Next.js', 'TypeScript', 'TMDB API', 'Tailwind CSS'],
|
|
||||||
imageSrc: joviesHome,
|
|
||||||
githubUrl: 'https://github.com/minjo-on/jovies',
|
|
||||||
liveUrl: 'https://jovies.minjo.xyz',
|
|
||||||
docsUrl: '#',
|
|
||||||
monitoringUrl: '#',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function ProjectCard({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
longDescription,
|
|
||||||
tags,
|
|
||||||
imageSrc,
|
|
||||||
githubUrl,
|
|
||||||
liveUrl,
|
|
||||||
docsUrl,
|
|
||||||
monitoringUrl,
|
|
||||||
featured,
|
|
||||||
}: ProjectData) {
|
|
||||||
return (
|
|
||||||
<Card className="overflow-hidden w-full p-0 hover:shadow-lg transition-shadow">
|
|
||||||
{/* Image Section */}
|
|
||||||
<div className="aspect-1440/770 relative bg-white">
|
|
||||||
<Image
|
|
||||||
src={imageSrc}
|
|
||||||
alt={title}
|
|
||||||
fill
|
|
||||||
className="object-cover border-b-2 border-gray-200"
|
|
||||||
/>
|
|
||||||
{featured && (
|
|
||||||
<div className="absolute top-4 right-4 bg-primary text-primary-foreground px-3 py-1 rounded-full text-sm font-semibold">
|
|
||||||
Featured
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content Section */}
|
|
||||||
<div className="px-6 pt-6 pb-4 flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h3 className="font-semibold text-2xl">{title}</h3>
|
|
||||||
<p className="text-muted-foreground">{description}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{longDescription}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{tags.map((tag) => (
|
|
||||||
<Button key={tag} variant="outline" size="sm" className="text-xs">
|
|
||||||
{tag}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Links Section */}
|
|
||||||
<div className="px-6">
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-4 flex gap-4">
|
|
||||||
{githubUrl && (
|
|
||||||
<Link
|
|
||||||
href={githubUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
aria-label="GitHub"
|
|
||||||
>
|
|
||||||
<Github className="w-5 h-5" />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{liveUrl && (
|
|
||||||
<Link
|
|
||||||
href={liveUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
aria-label="Live Demo"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-5 h-5" />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{docsUrl && (
|
|
||||||
<Link
|
|
||||||
href={docsUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
aria-label="Documentation"
|
|
||||||
>
|
|
||||||
<BookOpen className="w-5 h-5" />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{monitoringUrl && (
|
|
||||||
<Link
|
|
||||||
href={monitoringUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
aria-label="Monitoring"
|
|
||||||
>
|
|
||||||
<BarChart3 className="w-5 h-5" />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
|
||||||
return (
|
|
||||||
<main className="page-container">
|
|
||||||
<SectionHeader
|
|
||||||
title="Projects"
|
|
||||||
description="A collection of my web development projects, from full-stack applications to educational demonstrations"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className="grid grid-cols-1 pc:grid-cols-2 gap-6 pc:gap-8 max-w-[1400px]">
|
|
||||||
{PROJECTS_DATA.map((project, index) => (
|
|
||||||
<ProjectCard key={index} {...project} />
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useState } from "react";
|
|
||||||
interface MeteorsProps {
|
|
||||||
number?: number;
|
|
||||||
minDelay?: number;
|
|
||||||
maxDelay?: number;
|
|
||||||
minDuration?: number;
|
|
||||||
maxDuration?: number;
|
|
||||||
angle?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
export const Meteors = ({
|
|
||||||
number = 20,
|
|
||||||
minDelay = 0.2,
|
|
||||||
maxDelay = 1.2,
|
|
||||||
minDuration = 2,
|
|
||||||
maxDuration = 10,
|
|
||||||
angle = 215,
|
|
||||||
className,
|
|
||||||
}: MeteorsProps) => {
|
|
||||||
const [meteorStyles] = useState<Array<React.CSSProperties>>(() => {
|
|
||||||
return [...new Array(number)].map(() => ({
|
|
||||||
"--angle": -angle + "deg",
|
|
||||||
top: "-5%",
|
|
||||||
left: `calc(0% + ${Math.floor(Math.random() * (typeof window !== "undefined" ? window.innerWidth : 1000))}px)`,
|
|
||||||
animationDelay: Math.random() * (maxDelay - minDelay) + minDelay + "s",
|
|
||||||
animationDuration:
|
|
||||||
Math.floor(Math.random() * (maxDuration - minDuration) + minDuration) +
|
|
||||||
"s",
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
|
||||||
{[...meteorStyles].map((style, idx) => (
|
|
||||||
// Meteor Head
|
|
||||||
<span
|
|
||||||
key={idx}
|
|
||||||
style={{ ...style }}
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none absolute size-1 rotate-(--angle) animate-meteor rounded-full bg-white shadow-[0_0_10px_2px_rgba(255,255,255,0.5)]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Meteor Tail */}
|
|
||||||
<div className="pointer-events-none absolute top-1/2 -z-10 h-px w-[100px] -translate-y-1/2 bg-linear-to-r from-white to-transparent" />
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import React, { useEffect, useState, useRef } from "react";
|
|
||||||
|
|
||||||
interface ShootingStar {
|
|
||||||
id: number;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
angle: number;
|
|
||||||
scale: number;
|
|
||||||
speed: number;
|
|
||||||
distance: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ShootingStarsProps {
|
|
||||||
minSpeed?: number;
|
|
||||||
maxSpeed?: number;
|
|
||||||
minDelay?: number;
|
|
||||||
maxDelay?: number;
|
|
||||||
starColor?: string;
|
|
||||||
trailColor?: string;
|
|
||||||
starWidth?: number;
|
|
||||||
starHeight?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRandomStartPoint = () => {
|
|
||||||
const side = Math.floor(Math.random() * 4);
|
|
||||||
const offset = Math.random() * window.innerWidth;
|
|
||||||
|
|
||||||
switch (side) {
|
|
||||||
case 0:
|
|
||||||
return { x: offset, y: 0, angle: 45 };
|
|
||||||
case 1:
|
|
||||||
return { x: window.innerWidth, y: offset, angle: 135 };
|
|
||||||
case 2:
|
|
||||||
return { x: offset, y: window.innerHeight, angle: 225 };
|
|
||||||
case 3:
|
|
||||||
return { x: 0, y: offset, angle: 315 };
|
|
||||||
default:
|
|
||||||
return { x: 0, y: 0, angle: 45 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
export const ShootingStars: React.FC<ShootingStarsProps> = ({
|
|
||||||
minSpeed = 10,
|
|
||||||
maxSpeed = 30,
|
|
||||||
minDelay = 1200,
|
|
||||||
maxDelay = 4200,
|
|
||||||
starColor = "#9E00FF",
|
|
||||||
trailColor = "#2EB9DF",
|
|
||||||
starWidth = 10,
|
|
||||||
starHeight = 1,
|
|
||||||
className,
|
|
||||||
}) => {
|
|
||||||
const [star, setStar] = useState<ShootingStar | null>(null);
|
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const createStar = () => {
|
|
||||||
const { x, y, angle } = getRandomStartPoint();
|
|
||||||
const newStar: ShootingStar = {
|
|
||||||
id: Date.now(),
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
angle,
|
|
||||||
scale: 1,
|
|
||||||
speed: Math.random() * (maxSpeed - minSpeed) + minSpeed,
|
|
||||||
distance: 0,
|
|
||||||
};
|
|
||||||
setStar(newStar);
|
|
||||||
|
|
||||||
const randomDelay = Math.random() * (maxDelay - minDelay) + minDelay;
|
|
||||||
setTimeout(createStar, randomDelay);
|
|
||||||
};
|
|
||||||
|
|
||||||
createStar();
|
|
||||||
|
|
||||||
return () => {};
|
|
||||||
}, [minSpeed, maxSpeed, minDelay, maxDelay]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const moveStar = () => {
|
|
||||||
if (star) {
|
|
||||||
setStar((prevStar) => {
|
|
||||||
if (!prevStar) return null;
|
|
||||||
const newX =
|
|
||||||
prevStar.x +
|
|
||||||
prevStar.speed * Math.cos((prevStar.angle * Math.PI) / 180);
|
|
||||||
const newY =
|
|
||||||
prevStar.y +
|
|
||||||
prevStar.speed * Math.sin((prevStar.angle * Math.PI) / 180);
|
|
||||||
const newDistance = prevStar.distance + prevStar.speed;
|
|
||||||
const newScale = 1 + newDistance / 100;
|
|
||||||
if (
|
|
||||||
newX < -20 ||
|
|
||||||
newX > window.innerWidth + 20 ||
|
|
||||||
newY < -20 ||
|
|
||||||
newY > window.innerHeight + 20
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...prevStar,
|
|
||||||
x: newX,
|
|
||||||
y: newY,
|
|
||||||
distance: newDistance,
|
|
||||||
scale: newScale,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const animationFrame = requestAnimationFrame(moveStar);
|
|
||||||
return () => cancelAnimationFrame(animationFrame);
|
|
||||||
}, [star]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
ref={svgRef}
|
|
||||||
className={cn("w-full h-full absolute inset-0", className)}
|
|
||||||
>
|
|
||||||
{star && (
|
|
||||||
<rect
|
|
||||||
key={star.id}
|
|
||||||
x={star.x}
|
|
||||||
y={star.y}
|
|
||||||
width={starWidth * star.scale}
|
|
||||||
height={starHeight}
|
|
||||||
fill="url(#gradient)"
|
|
||||||
transform={`rotate(${star.angle}, ${
|
|
||||||
star.x + (starWidth * star.scale) / 2
|
|
||||||
}, ${star.y + starHeight / 2})`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" style={{ stopColor: trailColor, stopOpacity: 0 }} />
|
|
||||||
<stop
|
|
||||||
offset="100%"
|
|
||||||
style={{ stopColor: starColor, stopOpacity: 1 }}
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useEffect, useRef } from 'react';
|
|
||||||
import { Renderer, Program, Mesh, Triangle, Color } from 'ogl';
|
|
||||||
|
|
||||||
interface ThreadsProps {
|
|
||||||
color?: [number, number, number];
|
|
||||||
amplitude?: number;
|
|
||||||
distance?: number;
|
|
||||||
enableMouseInteraction?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const vertexShader = `
|
|
||||||
attribute vec2 position;
|
|
||||||
attribute vec2 uv;
|
|
||||||
varying vec2 vUv;
|
|
||||||
void main() {
|
|
||||||
vUv = uv;
|
|
||||||
gl_Position = vec4(position, 0.0, 1.0);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const fragmentShader = `
|
|
||||||
precision highp float;
|
|
||||||
|
|
||||||
uniform float iTime;
|
|
||||||
uniform vec3 iResolution;
|
|
||||||
uniform vec3 uColor;
|
|
||||||
uniform float uAmplitude;
|
|
||||||
uniform float uDistance;
|
|
||||||
uniform vec2 uMouse;
|
|
||||||
|
|
||||||
#define PI 3.1415926538
|
|
||||||
|
|
||||||
const int u_line_count = 40;
|
|
||||||
const float u_line_width = 7.0;
|
|
||||||
const float u_line_blur = 10.0;
|
|
||||||
|
|
||||||
float Perlin2D(vec2 P) {
|
|
||||||
vec2 Pi = floor(P);
|
|
||||||
vec4 Pf_Pfmin1 = P.xyxy - vec4(Pi, Pi + 1.0);
|
|
||||||
vec4 Pt = vec4(Pi.xy, Pi.xy + 1.0);
|
|
||||||
Pt = Pt - floor(Pt * (1.0 / 71.0)) * 71.0;
|
|
||||||
Pt += vec2(26.0, 161.0).xyxy;
|
|
||||||
Pt *= Pt;
|
|
||||||
Pt = Pt.xzxz * Pt.yyww;
|
|
||||||
vec4 hash_x = fract(Pt * (1.0 / 951.135664));
|
|
||||||
vec4 hash_y = fract(Pt * (1.0 / 642.949883));
|
|
||||||
vec4 grad_x = hash_x - 0.49999;
|
|
||||||
vec4 grad_y = hash_y - 0.49999;
|
|
||||||
vec4 grad_results = inversesqrt(grad_x * grad_x + grad_y * grad_y)
|
|
||||||
* (grad_x * Pf_Pfmin1.xzxz + grad_y * Pf_Pfmin1.yyww);
|
|
||||||
grad_results *= 1.4142135623730950;
|
|
||||||
vec2 blend = Pf_Pfmin1.xy * Pf_Pfmin1.xy * Pf_Pfmin1.xy
|
|
||||||
* (Pf_Pfmin1.xy * (Pf_Pfmin1.xy * 6.0 - 15.0) + 10.0);
|
|
||||||
vec4 blend2 = vec4(blend, vec2(1.0 - blend));
|
|
||||||
return dot(grad_results, blend2.zxzx * blend2.wwyy);
|
|
||||||
}
|
|
||||||
|
|
||||||
float pixel(float count, vec2 resolution) {
|
|
||||||
return (1.0 / max(resolution.x, resolution.y)) * count;
|
|
||||||
}
|
|
||||||
|
|
||||||
float lineFn(vec2 st, float width, float perc, float offset, vec2 mouse, float time, float amplitude, float distance) {
|
|
||||||
float split_offset = (perc * 0.4);
|
|
||||||
float split_point = 0.1 + split_offset;
|
|
||||||
|
|
||||||
float amplitude_normal = smoothstep(split_point, 0.7, st.x);
|
|
||||||
float amplitude_strength = 0.5;
|
|
||||||
float finalAmplitude = amplitude_normal * amplitude_strength
|
|
||||||
* amplitude * (1.0 + (mouse.y - 0.5) * 0.2);
|
|
||||||
|
|
||||||
float time_scaled = time / 10.0 + (mouse.x - 0.5) * 1.0;
|
|
||||||
float blur = smoothstep(split_point, split_point + 0.05, st.x) * perc;
|
|
||||||
|
|
||||||
float xnoise = mix(
|
|
||||||
Perlin2D(vec2(time_scaled, st.x + perc) * 2.5),
|
|
||||||
Perlin2D(vec2(time_scaled, st.x + time_scaled) * 3.5) / 1.5,
|
|
||||||
st.x * 0.3
|
|
||||||
);
|
|
||||||
|
|
||||||
float y = 0.5 + (perc - 0.5) * distance + xnoise / 2.0 * finalAmplitude;
|
|
||||||
|
|
||||||
float line_start = smoothstep(
|
|
||||||
y + (width / 2.0) + (u_line_blur * pixel(1.0, iResolution.xy) * blur),
|
|
||||||
y,
|
|
||||||
st.y
|
|
||||||
);
|
|
||||||
|
|
||||||
float line_end = smoothstep(
|
|
||||||
y,
|
|
||||||
y - (width / 2.0) - (u_line_blur * pixel(1.0, iResolution.xy) * blur),
|
|
||||||
st.y
|
|
||||||
);
|
|
||||||
|
|
||||||
return clamp(
|
|
||||||
(line_start - line_end) * (1.0 - smoothstep(0.0, 1.0, pow(perc, 0.3))),
|
|
||||||
0.0,
|
|
||||||
1.0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
|
||||||
vec2 uv = fragCoord / iResolution.xy;
|
|
||||||
|
|
||||||
float line_strength = 1.0;
|
|
||||||
for (int i = 0; i < u_line_count; i++) {
|
|
||||||
float p = float(i) / float(u_line_count);
|
|
||||||
line_strength *= (1.0 - lineFn(
|
|
||||||
uv,
|
|
||||||
u_line_width * pixel(1.0, iResolution.xy) * (1.0 - p),
|
|
||||||
p,
|
|
||||||
(PI * 1.0) * p,
|
|
||||||
uMouse,
|
|
||||||
iTime,
|
|
||||||
uAmplitude,
|
|
||||||
uDistance
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
float colorVal = 1.0 - line_strength;
|
|
||||||
fragColor = vec4(uColor * colorVal, colorVal);
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
mainImage(gl_FragColor, gl_FragCoord.xy);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Threads: React.FC<ThreadsProps> = ({
|
|
||||||
color = [1, 1, 1],
|
|
||||||
amplitude = 1,
|
|
||||||
distance = 0,
|
|
||||||
enableMouseInteraction = false,
|
|
||||||
className = '',
|
|
||||||
...rest
|
|
||||||
}) => {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const animationFrameId = useRef<number | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!containerRef.current) return;
|
|
||||||
const container = containerRef.current;
|
|
||||||
|
|
||||||
const renderer = new Renderer({ alpha: true });
|
|
||||||
const gl = renderer.gl;
|
|
||||||
gl.clearColor(0, 0, 0, 0);
|
|
||||||
gl.enable(gl.BLEND);
|
|
||||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
||||||
container.appendChild(gl.canvas);
|
|
||||||
|
|
||||||
const geometry = new Triangle(gl);
|
|
||||||
const program = new Program(gl, {
|
|
||||||
vertex: vertexShader,
|
|
||||||
fragment: fragmentShader,
|
|
||||||
uniforms: {
|
|
||||||
iTime: { value: 0 },
|
|
||||||
iResolution: {
|
|
||||||
value: new Color(gl.canvas.width, gl.canvas.height, gl.canvas.width / gl.canvas.height)
|
|
||||||
},
|
|
||||||
uColor: { value: new Color(...color) },
|
|
||||||
uAmplitude: { value: amplitude },
|
|
||||||
uDistance: { value: distance },
|
|
||||||
uMouse: { value: new Float32Array([0.5, 0.5]) }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const mesh = new Mesh(gl, { geometry, program });
|
|
||||||
|
|
||||||
function resize() {
|
|
||||||
const { clientWidth, clientHeight } = container;
|
|
||||||
renderer.setSize(clientWidth, clientHeight);
|
|
||||||
program.uniforms.iResolution.value.r = clientWidth;
|
|
||||||
program.uniforms.iResolution.value.g = clientHeight;
|
|
||||||
program.uniforms.iResolution.value.b = clientWidth / clientHeight;
|
|
||||||
}
|
|
||||||
window.addEventListener('resize', resize);
|
|
||||||
resize();
|
|
||||||
|
|
||||||
const currentMouse = new Float32Array([0.5, 0.5]);
|
|
||||||
const targetMouse = new Float32Array([0.5, 0.5]);
|
|
||||||
|
|
||||||
function handleMouseMove(e: MouseEvent) {
|
|
||||||
const rect = container.getBoundingClientRect();
|
|
||||||
const x = (e.clientX - rect.left) / rect.width;
|
|
||||||
const y = 1.0 - (e.clientY - rect.top) / rect.height;
|
|
||||||
targetMouse[0] = x;
|
|
||||||
targetMouse[1] = y;
|
|
||||||
}
|
|
||||||
function handleMouseLeave() {
|
|
||||||
targetMouse[0] = 0.5;
|
|
||||||
targetMouse[1] = 0.5;
|
|
||||||
}
|
|
||||||
if (enableMouseInteraction) {
|
|
||||||
container.addEventListener('mousemove', handleMouseMove);
|
|
||||||
container.addEventListener('mouseleave', handleMouseLeave);
|
|
||||||
}
|
|
||||||
|
|
||||||
function update(t: number) {
|
|
||||||
if (enableMouseInteraction) {
|
|
||||||
const smoothing = 0.05;
|
|
||||||
currentMouse[0] += smoothing * (targetMouse[0] - currentMouse[0]);
|
|
||||||
currentMouse[1] += smoothing * (targetMouse[1] - currentMouse[1]);
|
|
||||||
program.uniforms.uMouse.value[0] = currentMouse[0];
|
|
||||||
program.uniforms.uMouse.value[1] = currentMouse[1];
|
|
||||||
} else {
|
|
||||||
program.uniforms.uMouse.value[0] = 0.5;
|
|
||||||
program.uniforms.uMouse.value[1] = 0.5;
|
|
||||||
}
|
|
||||||
program.uniforms.iTime.value = t * 0.001;
|
|
||||||
|
|
||||||
renderer.render({ scene: mesh });
|
|
||||||
animationFrameId.current = requestAnimationFrame(update);
|
|
||||||
}
|
|
||||||
animationFrameId.current = requestAnimationFrame(update);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (animationFrameId.current !== null) cancelAnimationFrame(animationFrameId.current);
|
|
||||||
window.removeEventListener('resize', resize);
|
|
||||||
|
|
||||||
if (enableMouseInteraction) {
|
|
||||||
container.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
container.removeEventListener('mouseleave', handleMouseLeave);
|
|
||||||
}
|
|
||||||
if (container.contains(gl.canvas)) container.removeChild(gl.canvas);
|
|
||||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
|
||||||
};
|
|
||||||
}, [color, amplitude, distance, enableMouseInteraction]);
|
|
||||||
|
|
||||||
return <div ref={containerRef} className={`w-full h-full relative ${className}`} {...rest} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Threads;
|
|
||||||
@@ -4,8 +4,9 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
const FOOTER_MENU_ITEMS = [
|
const FOOTER_MENU_ITEMS = [
|
||||||
{ name: 'About', path: '#about' },
|
{ name: 'About', path: '#about' },
|
||||||
{ name: 'Experience', path: '#experience' },
|
{ name: 'Skills', path: '#skills' },
|
||||||
{ name: 'Projects', path: '#projects' },
|
{ name: 'Projects', path: '#projects' },
|
||||||
|
{ name: 'Monitoring', path: '#monitoring' },
|
||||||
{ name: 'Contact', path: '#contact' },
|
{ name: 'Contact', path: '#contact' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -8,71 +8,54 @@ import { LanguageToggle } from '@/components/ui/language-toggle';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
const HEADER_MENU_ITEMS = [
|
const HEADER_MENU_ITEMS = [
|
||||||
{ key: 'about', path: '/#about', isScroll: true },
|
{ key: 'about', path: '/#about' },
|
||||||
{ key: 'skills', path: '/#skills', isScroll: true },
|
{ key: 'skills', path: '/#skills' },
|
||||||
{ key: 'projects', path: '/#projects', isScroll: true },
|
{ key: 'projects', path: '/#projects' },
|
||||||
{ key: 'monitoring', path: '/#monitoring', isScroll: true },
|
{ key: 'monitoring', path: '/#monitoring' },
|
||||||
{ key: 'contact', path: '/#contact', isScroll: true },
|
{ key: 'contact', path: '/#contact' },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface HeaderProfileProps {
|
const HeaderProfile = () => {
|
||||||
showImage?: boolean;
|
|
||||||
showName?: boolean;
|
|
||||||
imageClassName?: string;
|
|
||||||
hasOrder?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const HeaderProfile = ({
|
|
||||||
showImage = true,
|
|
||||||
showName = true,
|
|
||||||
imageClassName,
|
|
||||||
hasOrder = true,
|
|
||||||
}: HeaderProfileProps) => {
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link href="/#top" onClick={(e) => handleScrollClick(e, '/#top')} >
|
||||||
href="/"
|
<h1 className="text-base smalltablet:text-lg desktop:text-lg font-bold">
|
||||||
className={hasOrder ? 'order-2 tablet:order-1' : ''}
|
MINJO
|
||||||
>
|
</h1>
|
||||||
<div className="flex items-center gap-3 smalltablet:gap-4">
|
|
||||||
{showImage && (
|
|
||||||
<div
|
|
||||||
className={`w-9 h-9 smalltablet:w-10 smalltablet:h-10 desktop:w-14 desktop:h-14 rounded-full bg-gray-300 dark:bg-gray-600 ${imageClassName || ''}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showName && (
|
|
||||||
<h1 className="text-sm smalltablet:text-base desktop:text-lg transition-colors font-bold">
|
|
||||||
MINJO KIM
|
|
||||||
</h1>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleScrollClick = (
|
||||||
|
e: React.MouseEvent<HTMLAnchorElement>,
|
||||||
|
path: string,
|
||||||
|
) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const sectionId = path.replace('/#', '');
|
||||||
|
const element = document.getElementById(sectionId);
|
||||||
|
const navbarHeight = 70;
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
const elementPosition = element.getBoundingClientRect().top + window.scrollY;
|
||||||
|
const offsetPosition = elementPosition - navbarHeight;
|
||||||
|
|
||||||
|
window.scrollTo({
|
||||||
|
top: offsetPosition,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 요소를 찾지 못하면 최상단으로 스크롤
|
||||||
|
window.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const HeaderMenuItemsDesktop = () => {
|
const HeaderMenuItemsDesktop = () => {
|
||||||
const t = useTranslations('header');
|
const t = useTranslations('header');
|
||||||
|
|
||||||
const handleScrollClick = (e: React.MouseEvent<HTMLAnchorElement>, path: string, isScroll: boolean) => {
|
|
||||||
if (isScroll) {
|
|
||||||
e.preventDefault();
|
|
||||||
const sectionId = path.replace('/#', '');
|
|
||||||
const element = document.getElementById(sectionId);
|
|
||||||
if (element) {
|
|
||||||
const navbarHeight = 70;
|
|
||||||
const elementPosition = element.getBoundingClientRect().top + window.scrollY;
|
|
||||||
const offsetPosition = elementPosition - navbarHeight;
|
|
||||||
|
|
||||||
window.scrollTo({
|
|
||||||
top: offsetPosition,
|
|
||||||
behavior: 'smooth'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center">
|
<div className="tablet:flex justify-center items-center hidden">
|
||||||
{HEADER_MENU_ITEMS.map((item) => (
|
{HEADER_MENU_ITEMS.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.key}
|
key={item.key}
|
||||||
@@ -80,7 +63,7 @@ const HeaderMenuItemsDesktop = () => {
|
|||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={item.path}
|
href={item.path}
|
||||||
onClick={(e) => handleScrollClick(e, item.path, item.isScroll)}
|
onClick={(e) => handleScrollClick(e, item.path)}
|
||||||
className="font-extralight text-xs tablet:text-sm desktop:text-base duration-100 ease-in hover:border-b-2 hover:border-b-black hover:dark:border-b-white pb-1"
|
className="font-extralight text-xs tablet:text-sm desktop:text-base duration-100 ease-in hover:border-b-2 hover:border-b-black hover:dark:border-b-white pb-1"
|
||||||
>
|
>
|
||||||
{t(item.key)}
|
{t(item.key)}
|
||||||
@@ -95,71 +78,36 @@ const HeaderMenuItemsMobile = () => {
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const t = useTranslations('header');
|
const t = useTranslations('header');
|
||||||
|
|
||||||
const handleScrollClick = (e: React.MouseEvent<HTMLAnchorElement>, path: string, isScroll: boolean) => {
|
const handleMenuClick = (e: React.MouseEvent<HTMLAnchorElement>, path: string) => {
|
||||||
if (isScroll) {
|
handleScrollClick(e, path);
|
||||||
e.preventDefault();
|
|
||||||
const sectionId = path.replace('/#', '');
|
|
||||||
const element = document.getElementById(sectionId);
|
|
||||||
if (element) {
|
|
||||||
const navbarHeight = 70;
|
|
||||||
const elementPosition = element.getBoundingClientRect().top + window.scrollY;
|
|
||||||
const offsetPosition = elementPosition - navbarHeight;
|
|
||||||
|
|
||||||
window.scrollTo({
|
|
||||||
top: offsetPosition,
|
|
||||||
behavior: 'smooth'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="dark:invert hover:cursor-pointer"
|
className="cursor-pointer"
|
||||||
aria-label="Open menu"
|
|
||||||
>
|
>
|
||||||
<Menu className="w-6 h-6" />
|
{isOpen ? (
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
) : (
|
||||||
|
<Menu className="w-6 h-6" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="fixed inset-0 z-50 bg-background/95 backdrop-blur">
|
<div className="fixed top-[70px] left-0 right-0 z-50 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 border-b animate-in fade-in fade-out transition-all duration-300">
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col items-center gap-6 py-6">
|
||||||
<div className="flex justify-end p-4">
|
{HEADER_MENU_ITEMS.map((item) => (
|
||||||
<button
|
<Link
|
||||||
onClick={() => setIsOpen(false)}
|
href={item.path}
|
||||||
className="dark:invert hover:cursor-pointer"
|
key={item.key}
|
||||||
aria-label="Close menu"
|
onClick={(e) => handleMenuClick(e, item.path)}
|
||||||
>
|
className="text-lg smalltablet:text-xl duration-100 ease-in hover:border-b-2 hover:border-b-brand-primary hover:dark:border-b-white pb-1"
|
||||||
<X className="w-6 h-6" />
|
>
|
||||||
</button>
|
{t(item.key)}
|
||||||
</div>
|
</Link>
|
||||||
<div className="flex flex-col items-center justify-between h-full py-12 smalltablet:py-16">
|
))}
|
||||||
<div className="flex flex-col w-full h-full justify-center items-center gap-16 smalltablet:gap-20">
|
|
||||||
<HeaderProfile
|
|
||||||
showImage={false}
|
|
||||||
hasOrder={false}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-8 smalltablet:gap-12">
|
|
||||||
{HEADER_MENU_ITEMS.map(item => (
|
|
||||||
<div
|
|
||||||
key={item.key}
|
|
||||||
className="px-4 smalltablet:px-6 desktop:px-8 transition-all items-center"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={item.path}
|
|
||||||
onClick={(e) => handleScrollClick(e, item.path, item.isScroll)}
|
|
||||||
className="text-lg smalltablet:text-xl duration-100 ease-in hover:border-b-2 hover:border-b-brand-primary hover:dark:border-b-white pb-1"
|
|
||||||
>
|
|
||||||
{t(item.key)}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -170,19 +118,29 @@ const HeaderMenuItemsMobile = () => {
|
|||||||
const Header = () => {
|
const Header = () => {
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className="fixed h-[70px] top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60"
|
className="fixed h-[70px] top-0 z-60 w-full border-b border-border bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center tracking-wider tablet:tracking-widest font-brand-book px-4 smalltablet:px-6 desktop:px-8 py-4 max-w-[1920px] mx-auto">
|
<div className="flex justify-between items-center tracking-wider tablet:tracking-widest font-brand-book px-4 smalltablet:px-6 desktop:px-8 py-4 max-w-[1920px] mx-auto relative">
|
||||||
<HeaderProfile
|
{/* 모바일: 왼쪽 - 햄버거 아이콘 */}
|
||||||
showImage={false}
|
<div className="tablet:hidden flex items-center relative">
|
||||||
/>
|
<HeaderMenuItemsMobile />
|
||||||
<div className="order-1 tablet:order-2 flex items-center">
|
</div>
|
||||||
<div className="tablet:hidden">
|
|
||||||
<HeaderMenuItemsMobile />
|
{/* 모바일: 가운데 - 이름, 태블릿 이상: 왼쪽 - 이름 */}
|
||||||
</div>
|
<div className="tablet:hidden absolute left-1/2 -translate-x-1/2">
|
||||||
<div className="hidden tablet:block">
|
<HeaderProfile />
|
||||||
<HeaderMenuItemsDesktop />
|
</div>
|
||||||
</div>
|
<div className="hidden tablet:block">
|
||||||
|
<HeaderProfile />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모바일: 오른쪽 - 토글 버튼들, 태블릿 이상: 오른쪽 - 메뉴 + 토글 버튼들 */}
|
||||||
|
<div className="flex items-center relative tablet:hidden">
|
||||||
|
<LanguageToggle />
|
||||||
|
<ModeToggle />
|
||||||
|
</div>
|
||||||
|
<div className="hidden tablet:flex items-center">
|
||||||
|
<HeaderMenuItemsDesktop />
|
||||||
<LanguageToggle />
|
<LanguageToggle />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user