FIX(app): fix navbar error
- Fix navigation bar issue - Correct component rendering
This commit is contained in:
@@ -13,7 +13,7 @@ commonLabels:
|
||||
# 이미지 태그 설정
|
||||
images:
|
||||
- name: ghcr.io/mayne0213/portfolio
|
||||
newTag: main-sha-ab364cf43229f665303531dfae03af39df6fb2b1
|
||||
newTag: main-sha-88c70df51ff99151aeec57bb855d36141c85bfe4
|
||||
|
||||
patchesStrategicMerge:
|
||||
- deployment-patch.yaml
|
||||
|
||||
@@ -272,3 +272,14 @@
|
||||
.dark .stars {
|
||||
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 = [
|
||||
{ name: 'About', path: '#about' },
|
||||
{ name: 'Experience', path: '#experience' },
|
||||
{ name: 'Skills', path: '#skills' },
|
||||
{ name: 'Projects', path: '#projects' },
|
||||
{ name: 'Monitoring', path: '#monitoring' },
|
||||
{ name: 'Contact', path: '#contact' },
|
||||
];
|
||||
|
||||
|
||||
@@ -8,71 +8,54 @@ import { LanguageToggle } from '@/components/ui/language-toggle';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
const HEADER_MENU_ITEMS = [
|
||||
{ key: 'about', path: '/#about', isScroll: true },
|
||||
{ key: 'skills', path: '/#skills', isScroll: true },
|
||||
{ key: 'projects', path: '/#projects', isScroll: true },
|
||||
{ key: 'monitoring', path: '/#monitoring', isScroll: true },
|
||||
{ key: 'contact', path: '/#contact', isScroll: true },
|
||||
{ key: 'about', path: '/#about' },
|
||||
{ key: 'skills', path: '/#skills' },
|
||||
{ key: 'projects', path: '/#projects' },
|
||||
{ key: 'monitoring', path: '/#monitoring' },
|
||||
{ key: 'contact', path: '/#contact' },
|
||||
];
|
||||
|
||||
interface HeaderProfileProps {
|
||||
showImage?: boolean;
|
||||
showName?: boolean;
|
||||
imageClassName?: string;
|
||||
hasOrder?: boolean;
|
||||
}
|
||||
|
||||
const HeaderProfile = ({
|
||||
showImage = true,
|
||||
showName = true,
|
||||
imageClassName,
|
||||
hasOrder = true,
|
||||
}: HeaderProfileProps) => {
|
||||
const HeaderProfile = () => {
|
||||
return (
|
||||
<Link
|
||||
href="/"
|
||||
className={hasOrder ? 'order-2 tablet:order-1' : ''}
|
||||
>
|
||||
<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 href="/#top" onClick={(e) => handleScrollClick(e, '/#top')} >
|
||||
<h1 className="text-base smalltablet:text-lg desktop:text-lg font-bold">
|
||||
MINJO
|
||||
</h1>
|
||||
</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 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 (
|
||||
<div className="flex justify-center items-center">
|
||||
<div className="tablet:flex justify-center items-center hidden">
|
||||
{HEADER_MENU_ITEMS.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
@@ -80,7 +63,7 @@ const HeaderMenuItemsDesktop = () => {
|
||||
>
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
{t(item.key)}
|
||||
@@ -95,71 +78,36 @@ const HeaderMenuItemsMobile = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
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'
|
||||
});
|
||||
}
|
||||
}
|
||||
const handleMenuClick = (e: React.MouseEvent<HTMLAnchorElement>, path: string) => {
|
||||
handleScrollClick(e, path);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="dark:invert hover:cursor-pointer"
|
||||
aria-label="Open menu"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
{isOpen ? (
|
||||
<X className="w-6 h-6" />
|
||||
) : (
|
||||
<Menu className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-background/95 backdrop-blur">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex justify-end p-4">
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="dark:invert hover:cursor-pointer"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<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 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 items-center gap-6 py-6">
|
||||
{HEADER_MENU_ITEMS.map((item) => (
|
||||
<Link
|
||||
href={item.path}
|
||||
key={item.key}
|
||||
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"
|
||||
>
|
||||
{t(item.key)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -170,19 +118,29 @@ const HeaderMenuItemsMobile = () => {
|
||||
const Header = () => {
|
||||
return (
|
||||
<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">
|
||||
<HeaderProfile
|
||||
showImage={false}
|
||||
/>
|
||||
<div className="order-1 tablet:order-2 flex items-center">
|
||||
<div className="tablet:hidden">
|
||||
<HeaderMenuItemsMobile />
|
||||
</div>
|
||||
<div className="hidden tablet:block">
|
||||
<HeaderMenuItemsDesktop />
|
||||
</div>
|
||||
<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">
|
||||
{/* 모바일: 왼쪽 - 햄버거 아이콘 */}
|
||||
<div className="tablet:hidden flex items-center relative">
|
||||
<HeaderMenuItemsMobile />
|
||||
</div>
|
||||
|
||||
{/* 모바일: 가운데 - 이름, 태블릿 이상: 왼쪽 - 이름 */}
|
||||
<div className="tablet:hidden absolute left-1/2 -translate-x-1/2">
|
||||
<HeaderProfile />
|
||||
</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 />
|
||||
<ModeToggle />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user