From bf591ccc8338a328e12f7fc5776b2c818cc3976d Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 08:51:22 +0900 Subject: [PATCH 01/34] =?UTF-8?q?feat:=20posts=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=84=B1=EB=8A=A5=20=EB=B0=8F=20SEO=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.ts | 1 + src/app/layout.tsx | 10 +++ src/app/posts/components/AddPostForm.tsx | 93 +++++++++++++++++++--- src/app/posts/components/RecentSection.tsx | 13 ++- src/app/posts/page.tsx | 46 +++++++++-- src/app/posts/pageClient.tsx | 10 ++- src/lib/api/blog.tsx | 4 +- 7 files changed, 152 insertions(+), 25 deletions(-) diff --git a/next.config.ts b/next.config.ts index 27100a3..60bc211 100644 --- a/next.config.ts +++ b/next.config.ts @@ -11,6 +11,7 @@ const nextConfig: NextConfig = { }, images: { domains: ["evcsbwqeetfvegvrtbny.supabase.co"], + formats: ["image/avif", "image/webp"], }, }; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d039c49..01221a1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -53,6 +53,16 @@ export default function RootLayout({ }>) { return ( + + + + + + diff --git a/src/app/posts/components/AddPostForm.tsx b/src/app/posts/components/AddPostForm.tsx index 59c7a65..cca151f 100644 --- a/src/app/posts/components/AddPostForm.tsx +++ b/src/app/posts/components/AddPostForm.tsx @@ -28,6 +28,7 @@ const AddPostForm = ({ const [blurPreviewUrl, setBlurPreviewUrl] = useState(null); const [file, setFile] = useState(null); + const [message, setMessage] = useState(""); useEffect(() => { if (!isOpen) { @@ -40,14 +41,24 @@ const AddPostForm = ({ const selected = e.target.files?.[0] ?? null; if (!selected) return; - setFile(selected); + setMessage(""); + try { + const optimizedFile = await optimizeThumbnailImage(selected); + setFile(optimizedFile); - const objectUrl = URL.createObjectURL(selected); - setPreviewUrl(objectUrl); + const objectUrl = URL.createObjectURL(optimizedFile); + setPreviewUrl(objectUrl); - const blurDataUrl = await generateBlurredDataUrl(selected, 40); - changeThumbnailBlur(blurDataUrl); - setBlurPreviewUrl(blurDataUrl); + const blurDataUrl = await generateBlurredDataUrl(optimizedFile); + changeThumbnailBlur(blurDataUrl); + setBlurPreviewUrl(blurDataUrl); + } catch (error) { + console.error(error); + setMessage("이미지 최적화에 실패했습니다. 다른 이미지를 시도해주세요."); + setFile(null); + setPreviewUrl(null); + setBlurPreviewUrl(null); + } }; const handleSubmit = async (e: React.FormEvent) => { @@ -120,6 +131,9 @@ const AddPostForm = ({ onChange={handleFileChange} className="block w-full text-sm text-gray-300 mb-2 file:mr-4 file:py-2 file:px-4 file:cursor-pointer file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" /> + {message ? ( +

{message}

+ ) : null} {previewUrl && blurPreviewUrl ? (
@@ -160,16 +174,17 @@ const AddPostForm = ({ }; function generateBlurredDataUrl( - file: File, - blurAmount: number = 10 + file: File ): Promise { return new Promise((resolve, reject) => { const objectUrl = URL.createObjectURL(file); const img = new Image(); img.onload = () => { - const width = img.naturalWidth; - const height = img.naturalHeight; + const targetWidth = 24; + const ratio = targetWidth / img.naturalWidth; + const width = targetWidth; + const height = Math.max(1, Math.round(img.naturalHeight * ratio)); const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; @@ -180,10 +195,10 @@ function generateBlurredDataUrl( return reject(new Error("Canvas 2D context를 가져올 수 없습니다.")); } - ctx.filter = `blur(${blurAmount}px)`; + ctx.filter = "blur(8px)"; ctx.drawImage(img, 0, 0, width, height); - const blurredDataUrl = canvas.toDataURL(); + const blurredDataUrl = canvas.toDataURL("image/webp", 0.5); URL.revokeObjectURL(objectUrl); resolve(blurredDataUrl); @@ -199,6 +214,60 @@ function generateBlurredDataUrl( }); } +function optimizeThumbnailImage(file: File): Promise { + return new Promise((resolve, reject) => { + const objectUrl = URL.createObjectURL(file); + const img = new Image(); + + img.onload = () => { + const maxWidth = 1280; + const scale = Math.min(1, maxWidth / img.naturalWidth); + const width = Math.max(1, Math.round(img.naturalWidth * scale)); + const height = Math.max(1, Math.round(img.naturalHeight * scale)); + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext("2d"); + if (!ctx) { + URL.revokeObjectURL(objectUrl); + reject(new Error("Canvas 2D context를 가져올 수 없습니다.")); + return; + } + + ctx.drawImage(img, 0, 0, width, height); + + canvas.toBlob( + (blob) => { + URL.revokeObjectURL(objectUrl); + if (!blob) { + reject(new Error("썸네일 최적화에 실패했습니다.")); + return; + } + + const nextFile = new File( + [blob], + `${file.name.replace(/\.[^.]+$/, "")}.webp`, + { type: "image/webp" } + ); + + resolve(nextFile); + }, + "image/webp", + 0.82 + ); + }; + + img.onerror = () => { + URL.revokeObjectURL(objectUrl); + reject(new Error("이미지를 로드하는데 실패했습니다.")); + }; + + img.src = objectUrl; + }); +} + async function addFileToStorage(file: File, filePath: string) { const { error: uploadError } = await supabase.storage .from("blog-img") diff --git a/src/app/posts/components/RecentSection.tsx b/src/app/posts/components/RecentSection.tsx index b8ffbfa..812195a 100644 --- a/src/app/posts/components/RecentSection.tsx +++ b/src/app/posts/components/RecentSection.tsx @@ -13,7 +13,7 @@ const RecentSection = ({ data }: { data: PostsResponse }) => { useEffect(() => { setLoading(false); - }, [data]); + }, [data, setLoading]); if (isLoading) return ; @@ -21,7 +21,9 @@ const RecentSection = ({ data }: { data: PostsResponse }) => {
- {data.posts.map((post) => { + {data.posts.map((post, index) => { + const isLcpCandidate = index === 0; + const isAboveTheFoldCandidate = index < 3; return ( { alt={`thumbnail`} fill className={cn("object-cover")} - quality={50} - sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" + quality={45} + sizes="(max-width: 767px) 100vw, (max-width: 1023px) 50vw, 33vw" + priority={isLcpCandidate} + fetchPriority={isAboveTheFoldCandidate ? "high" : "auto"} + loading={isAboveTheFoldCandidate ? "eager" : "lazy"} placeholder="blur" blurDataURL={post.thumbnail_blur} /> diff --git a/src/app/posts/page.tsx b/src/app/posts/page.tsx index 5a5d7bb..38ccf28 100644 --- a/src/app/posts/page.tsx +++ b/src/app/posts/page.tsx @@ -1,14 +1,33 @@ import { getAllPosts } from "@/lib/api/blog"; +import type { Metadata } from "next"; import { notFound } from "next/navigation"; import PostClient from "./pageClient"; type SearchParams = Promise<{ [key: string]: string | string[] | undefined }>; -export const generateMetadata = () => { - return { - title: `Yonghun - 개발 블로그`, - }; -}; +export const generateMetadata = (): Metadata => ({ + title: "Yonghun - 개발 블로그", + description: + "프론트엔드/백엔드 개발 경험, 성능 최적화, 프로젝트 회고를 기록하는 개발 블로그입니다.", + alternates: { + canonical: "https://www.yonghun.me/posts", + }, + openGraph: { + type: "website", + url: "https://www.yonghun.me/posts", + title: "Yonghun - 개발 블로그", + description: + "프론트엔드/백엔드 개발 경험, 성능 최적화, 프로젝트 회고를 기록하는 개발 블로그입니다.", + images: "/metaimg.png", + }, + twitter: { + card: "summary_large_image", + title: "Yonghun - 개발 블로그", + description: + "프론트엔드/백엔드 개발 경험, 성능 최적화, 프로젝트 회고를 기록하는 개발 블로그입니다.", + images: "/metaimg.png", + }, +}); export default async function Posts(props: { searchParams: SearchParams }) { const searchParams = await props.searchParams; @@ -16,9 +35,22 @@ export default async function Posts(props: { searchParams: SearchParams }) { const data = await getAllPosts(page); - if (data.posts.length === 0 || !data) { + if (!data || data.posts.length === 0) { notFound(); } - return ; + const optimizedData = { + total_count: data.total_count, + posts: data.posts.map((post) => ({ + id: post.id, + title: post.title, + description: post.description, + tags: post.tags, + thumbnail: post.thumbnail, + thumbnail_blur: post.thumbnail_blur, + created_at: post.created_at, + })), + }; + + return ; } diff --git a/src/app/posts/pageClient.tsx b/src/app/posts/pageClient.tsx index d5f714d..c84c6af 100644 --- a/src/app/posts/pageClient.tsx +++ b/src/app/posts/pageClient.tsx @@ -3,6 +3,7 @@ import Nav from "@/components/common/Nav"; import { GlassBox } from "@/components/ui/GlassBox"; import { type PostsResponse } from "@/lib/api/blog"; +import Image from "next/image"; import { useRouter } from "next/navigation"; import { createContext, useContext, useRef, useState } from "react"; import { BsPencilSquare } from "react-icons/bs"; @@ -62,9 +63,16 @@ export default function PostClient({ - github button + github button diff --git a/src/lib/api/blog.tsx b/src/lib/api/blog.tsx index b0d9449..1da64a8 100644 --- a/src/lib/api/blog.tsx +++ b/src/lib/api/blog.tsx @@ -11,9 +11,11 @@ export type Post = { created_at: string; }; +export type PostListItem = Omit; + export type PostsResponse = { total_count: number; - posts: Post[]; + posts: PostListItem[]; }; export type AddPostsRequest = { From ff7b6ba03c13b5c3b5356cc20562e0531dece714 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 08:54:22 +0900 Subject: [PATCH 02/34] =?UTF-8?q?docs:=20AGENTS=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8f4a3d9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,114 @@ +# AGENTS.md + +이 문서는 `/Users/dd/Dev/portfolio-fe` 저장소에서 작업하는 사람/에이전트를 위한 실행 가이드입니다. + +## 1) 프로젝트 요약 + +- 스택: Next.js App Router + TypeScript + Tailwind CSS + Zustand +- 런타임: React 19, Next 15 +- 데이터: 외부 백엔드 API(`https://api.yonghun.me`) + Supabase Storage +- 주요 도메인: + - 포트폴리오/소개 페이지 + - 블로그 목록/상세/작성 페이지 + - SEO(메타데이터, sitemap, robots) + +## 2) 핵심 디렉터리 + +- `src/app`: 라우트 및 페이지(App Router) +- `src/app/posts`: 블로그 목록/상세/작성 UI +- `src/components`: 공통 UI, provider, 레이아웃 컴포넌트 +- `src/lib/api`: API 호출 유틸 및 도메인 API 함수 +- `src/lib/supabase`: Supabase 클라이언트 +- `src/data`: 정적 데이터 +- `src/store`: Zustand 스토어 + +## 3) 환경변수 + +현재 코드 기준으로 아래 키를 사용합니다. + +- `NEXT_PUBLIC_API_BASE_URL` +- `NEXT_PUBLIC_SUPABASE_URL` +- `NEXT_PUBLIC_SUPABASE_ANON_KEY` +- `NEXT_PUBLIC_GOOGLE_ANALYTICS` + +주의: +- `NEXT_PUBLIC_*` 값은 클라이언트에 노출됩니다. 비밀값(secret)은 절대 넣지 않습니다. +- API 기본값 fallback은 `http://localhost:8080` 입니다(`src/lib/api/apiClient.tsx`). + +## 4) 실행/검증 명령어 + +- 개발 서버: `npm run dev` +- 빌드: `npm run build` +- 프로덕션 실행: `npm run start` +- 린트: `npm run lint` + +작업 완료 전 최소 검증: +1. `npm run lint` +2. 변경한 라우트 수동 확인(특히 `posts`, `posts/[id]`, `posts/add`) +3. SEO 변경 시 메타데이터/OG/canonical 값 확인 + +## 5) 커밋 규칙 (이 저장소 표준) + +최근 커밋 로그를 보면 `feat/fix/docs/refactor/style` 타입은 잘 사용되지만, 대소문자(`Feat`, `Fix`, `Docs`)가 섞여 있습니다. +앞으로는 아래 규칙으로 통일합니다. + +형식: +- `: ` + +규칙: +- `type`은 반드시 **소문자** +- `subject`는 한국어/영어 모두 가능, 짧고 구체적으로 작성 +- 마침표(`.`) 생략 +- 한 커밋에는 하나의 목적만 담기 + +허용 타입: +- `feat`: 기능 추가/확장 +- `fix`: 버그 수정 +- `refactor`: 동작 변화 없는 구조 개선 +- `style`: UI 스타일 변경(로직 영향 없음) +- `docs`: 문서 수정 +- `chore`: 빌드/설정/의존성/기타 유지보수 + +좋은 예시: +- `feat: posts 페이지 성능 및 SEO 최적화` +- `fix: posts 목록 빈 상태 처리 오류 수정` +- `refactor: blog API 응답 타입 분리` +- `docs: README 배포 섹션 업데이트` + +피해야 할 예시: +- `Feat: 수정` (타입 대문자 + 의미 불명확) +- `fix: 이것저것 수정` (범위 과다) + +## 6) 브랜치/PR 권장 규칙 + +- 브랜치명: `feature/*`, `fix/*`, `refactor/*`, `docs/*` +- PR 제목도 커밋 규칙과 동일한 톤 권장 +- PR 본문에 반드시 포함: + - 변경 목적 + - 주요 변경 파일 + - 사용자 영향(화면/API/SEO) + - 테스트/검증 결과 + +## 7) 코드 작업 원칙 + +- TypeScript `strict`를 깨는 `any` 남발 금지 +- 기존 import alias(`@/*`) 우선 사용 +- API 레이어(`src/lib/api/*`)에서 타입 우선 정의 후 페이지에 전달 +- 네트워크 요청 실패 경로를 고려하고, 사용자 노출 메시지/상태를 처리 +- 불필요한 클라이언트 컴포넌트(`"use client"`) 증가 지양 +- 이미지/콘텐츠 변경 시 성능(LCP), 접근성(alt), SEO 메타데이터를 함께 확인 + +## 8) 변경 시 주의 포인트 (이 프로젝트 특화) + +- `next.config.ts`의 `/api/:path*` rewrites는 백엔드 연동 핵심 경로이므로 함부로 변경하지 않습니다. +- `src/lib/api/blog.tsx`의 `revalidate`(ISR) 정책 변경 시 캐시 전략을 PR에 명시합니다. +- `posts/add` 인증 흐름(`AuthForm`, `Auth`) 변경 시 Guest/Admin 분기 동작을 반드시 수동 검증합니다. +- Supabase 업로드 로직 변경 시 public URL, placeholder, blur 데이터 동작까지 함께 확인합니다. + +## 9) 작업 완료 체크리스트 + +1. 코드가 린트 오류 없이 통과한다. +2. 변경 범위의 핵심 페이지가 의도대로 동작한다. +3. 커밋 메시지가 소문자 타입 규칙을 지킨다. +4. 문서/타입/코드가 서로 모순되지 않는다. + From 9c8221c91ba448bb795429e5b053306a4f2bc6b4 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 08:58:01 +0900 Subject: [PATCH 03/34] =?UTF-8?q?refactor:=20=ED=8F=B0=ED=8A=B8=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EC=A0=84=EB=9E=B5=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/globals.css | 2 +- src/app/layout.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 5c2926a..abaa37a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -13,7 +13,7 @@ --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); --font-main: var(--font-body); - --font-nanum: "Nanum Pen Script", cursive; + --font-nanum: var(--font-nanum-pen), cursive; --animate-spotlight: spotlight 2s ease 0.75s 1 forwards; --animate-unspotlight: unspotlight 2s ease 0.75s 1 forwards; --animate-leftToRight: leftToRight 1s ease; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 01221a1..8b9e0ae 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -18,8 +18,11 @@ const geistMono = Geist_Mono({ }); const geistNanum = Nanum_Pen_Script({ + variable: "--font-nanum-pen", weight: "400", subsets: ["latin"], + display: "swap", + preload: false, }); export const metadata: Metadata = { @@ -64,7 +67,7 @@ export default function RootLayout({ {process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS ? ( From 36a8d19a818417551a3ed0548385e65b24275616 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 09:17:42 +0900 Subject: [PATCH 04/34] =?UTF-8?q?fix:=20=EB=B8=94=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=20=EB=A0=8C=EB=8D=94=EB=A7=81=20XSS=20?= =?UTF-8?q?=EB=B0=A9=EC=96=B4=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/QuillCodeRenderer.tsx | 31 ++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/components/common/QuillCodeRenderer.tsx b/src/components/common/QuillCodeRenderer.tsx index 28f440a..60bdedf 100644 --- a/src/components/common/QuillCodeRenderer.tsx +++ b/src/components/common/QuillCodeRenderer.tsx @@ -15,7 +15,7 @@ export default function QuillCodeRenderer({ useEffect(() => { if (containerRef.current) { const prevEl = document.createElement("div"); - prevEl.innerHTML = htmlString; + prevEl.innerHTML = sanitizeHtmlString(htmlString); prevEl .querySelectorAll(".ql-ui, .ql-picker, .ql-picker-options, option") .forEach((el) => el.remove()); @@ -51,3 +51,32 @@ export default function QuillCodeRenderer({ return
; } + +function sanitizeHtmlString(input: string) { + const parser = new DOMParser(); + const doc = parser.parseFromString(input, "text/html"); + + doc + .querySelectorAll( + "script, style, iframe, object, embed, form, link, meta, base" + ) + .forEach((node) => node.remove()); + + doc.body.querySelectorAll("*").forEach((el) => { + [...el.attributes].forEach((attr) => { + const name = attr.name.toLowerCase(); + const value = attr.value.trim().toLowerCase(); + + const isEventHandler = name.startsWith("on"); + const isUnsafeUrl = + (name === "href" || name === "src" || name === "xlink:href") && + (value.startsWith("javascript:") || value.startsWith("data:text/html")); + + if (isEventHandler || isUnsafeUrl) { + el.removeAttribute(attr.name); + } + }); + }); + + return doc.body.innerHTML; +} From 338bcd40637083673bfdb27beec04e4a062a96e1 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 09:21:00 +0900 Subject: [PATCH 05/34] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=82=AD=EC=A0=9C=20=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20=EB=B0=8F=20=EC=95=A1=EC=85=98=20=EB=B6=84=EA=B8=B0?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/posts/[id]/pageClient.tsx | 54 +++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/src/app/posts/[id]/pageClient.tsx b/src/app/posts/[id]/pageClient.tsx index d9fc29a..686cd53 100644 --- a/src/app/posts/[id]/pageClient.tsx +++ b/src/app/posts/[id]/pageClient.tsx @@ -3,11 +3,12 @@ import Nav from "@/components/common/Nav"; import QuillCodeRenderer from "@/components/common/QuillCodeRenderer"; import { GlassBox } from "@/components/ui/GlassBox"; -import { type Post } from "@/lib/api/blog"; +import { deletePost, type Post } from "@/lib/api/blog"; import { formatDate } from "@/lib/utils"; import useImageStore from "@/store/useImageStore"; import { AnimatePresence, motion } from "motion/react"; import Image from "next/image"; +import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { BsTrash, BsWrenchAdjustable } from "react-icons/bs"; import AuthForm from "../components/AuthForm"; @@ -27,10 +28,14 @@ const buttonVariants = { }; export default function PostDetailClient({ post }: { post: Post }) { + const router = useRouter(); const { curImage, setCurImage } = useImageStore(); const [viewAuth, setViewAuth] = useState(false); const [viewDelete, setViewDelete] = useState(false); + const [authAction, setAuthAction] = useState<"delete" | "edit" | null>(null); + const [message, setMessage] = useState(""); + const [isDeleting, setIsDeleting] = useState(false); useEffect(() => { const nodeList = @@ -50,7 +55,24 @@ export default function PostDetailClient({ post }: { post: Post }) { imgEl.removeEventListener("click", handleClick); }); }; - }, []); + }, [setCurImage]); + + const handleDelete = async () => { + if (isDeleting) return; + setMessage(""); + setIsDeleting(true); + try { + await deletePost(post.id); + setViewDelete(false); + router.replace("/posts"); + router.refresh(); + } catch (error) { + console.error(error); + setMessage("삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); + } finally { + setIsDeleting(false); + } + }; return (
@@ -84,7 +106,11 @@ export default function PostDetailClient({ post }: { post: Post }) { @@ -92,12 +118,17 @@ export default function PostDetailClient({ post }: { post: Post }) {
+ {message ?

{message}

: null}
{viewAuth && ( @@ -108,7 +139,11 @@ export default function PostDetailClient({ post }: { post: Post }) { { setViewAuth(false); - setViewDelete(true); + if (authAction === "delete") { + setViewDelete(true); + } else { + setMessage("수정 기능은 준비 중입니다."); + } }} />
@@ -120,7 +155,8 @@ export default function PostDetailClient({ post }: { post: Post }) { > setViewDelete(false)} - onSuccess={() => {}} + onSuccess={handleDelete} + isLoading={isDeleting} />
)} @@ -155,9 +191,11 @@ export default function PostDetailClient({ post }: { post: Post }) { function DeleteModal({ close, onSuccess, + isLoading, }: { close: VoidFunction; onSuccess: VoidFunction; + isLoading: boolean; }) { return ( 취소 @@ -190,8 +229,9 @@ function DeleteModal({ whileHover="hover" whileTap="tap" onClick={onSuccess} + disabled={isLoading} > - 삭제 + {isLoading ? "삭제 중..." : "삭제"}
From ee2d4b7e7276954da0c571f8c4102b6810045b0e Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 09:24:48 +0900 Subject: [PATCH 06/34] =?UTF-8?q?refactor:=20=ED=96=84=EB=B2=84=EA=B1=B0?= =?UTF-8?q?=20=EB=A9=94=EB=89=B4=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EA=B2=BD=EB=9F=89=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/HamburgerMenu.tsx | 143 ++++++++++------------------ 1 file changed, 49 insertions(+), 94 deletions(-) diff --git a/src/components/ui/HamburgerMenu.tsx b/src/components/ui/HamburgerMenu.tsx index 111a312..d940c40 100644 --- a/src/components/ui/HamburgerMenu.tsx +++ b/src/components/ui/HamburgerMenu.tsx @@ -1,6 +1,5 @@ "use client"; -import { AnimatePresence, motion } from "motion/react"; import { useState } from "react"; import DelayedLink from "../common/DelayedLink"; @@ -8,27 +7,6 @@ export default function HamburgerMenu() { const [isOpen, setIsOpen] = useState(false); const toggleOpen = () => setIsOpen((prev) => !prev); - const iconVariants = { - top: { - closed: { top: 0, y: 0, rotate: 0 }, - open: { top: "50%", y: "-50%", rotate: 45 }, - }, - middle: { - closed: { top: "50%", y: "-50%", opacity: 1, rotate: 0 }, - open: { top: "50%", y: "-50%", opacity: 0, rotate: 0 }, - }, - bottom: { - closed: { bottom: 0, y: 0, rotate: 0 }, - open: { top: "50%", y: "-50%", rotate: -45 }, - }, - }; - - const sidebarVariants = { - hidden: { x: "-100%" }, - visible: { x: 0 }, - exit: { x: "-100%" }, - }; - return ( <> - {isOpen && ( -
setIsOpen(false)} +
setIsOpen(false)} + > +
- )} +
    +
  • + + HOME + +
  • +
  • + + PROJECTS + +
  • +
  • + + ABOUT + +
  • +
  • + + POSTS + +
  • +
+ +
); } From e28fb13feec3e9610fb6e14f1094493baff9aa4c Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 09:26:17 +0900 Subject: [PATCH 07/34] =?UTF-8?q?feat:=20=EB=B3=B8=EB=AC=B8=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EB=B0=8F=20=EC=B5=9C=EC=A0=81=ED=99=94=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/Editor.tsx | 86 ++++++++++++++++++++++++++++++++-- src/lib/quill/ImageUploader.ts | 10 +++- 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/src/components/ui/Editor.tsx b/src/components/ui/Editor.tsx index 80fa934..eb1613d 100644 --- a/src/components/ui/Editor.tsx +++ b/src/components/ui/Editor.tsx @@ -6,7 +6,7 @@ import ImageUploader from "@/lib/quill/ImageUploader"; import { supabase } from "@/lib/supabase/supabasClient"; import hljs from "highlight.js"; import Quill from "quill"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import "quill/dist/quill.snow.css"; @@ -40,6 +40,7 @@ export type EditorProps = { const Editor = ({ initialHTML = "", onChange }: EditorProps) => { const containerRef = useRef(null); const onChangeRef = useRef(onChange); + const [message, setMessage] = useState(""); useEffect(() => { if (containerRef.current?.innerHTML !== "") return; @@ -71,15 +72,20 @@ const Editor = ({ initialHTML = "", onChange }: EditorProps) => { }, imageUploader: { upload: async (file: File) => { + setMessage(""); + const optimizedFile = await optimizeEditorImage(file); const filePath = `yonghunblog/${Date.now()}`; const { error: uploadError } = await supabase.storage .from("blog-img") - .upload(filePath, file); + .upload(filePath, optimizedFile, { + contentType: optimizedFile.type, + upsert: false, + }); if (uploadError) { console.error("이미지 업로드 에러: ", uploadError); - return; + throw new Error("이미지 업로드에 실패했습니다."); } const { data: urlData } = supabase.storage @@ -90,6 +96,9 @@ const Editor = ({ initialHTML = "", onChange }: EditorProps) => { return publicUrl; }, + onError: (error: Error) => { + setMessage(error.message); + }, }, }, formats: [ @@ -164,7 +173,76 @@ const Editor = ({ initialHTML = "", onChange }: EditorProps) => { }; }, []); - return
; + return ( +
+
+ {message ?

{message}

: null} +
+ ); }; export default Editor; + +async function optimizeEditorImage(file: File): Promise { + const fileType = file.type.toLowerCase(); + + if (fileType === "image/gif") { + throw new Error( + "GIF 업로드는 지원하지 않습니다. mp4/webm 또는 정적 이미지로 업로드해주세요." + ); + } + + if (fileType === "image/svg+xml") { + return file; + } + + return new Promise((resolve, reject) => { + const objectUrl = URL.createObjectURL(file); + const img = new Image(); + + img.onload = () => { + const maxWidth = 1600; + const scale = Math.min(1, maxWidth / img.naturalWidth); + const width = Math.max(1, Math.round(img.naturalWidth * scale)); + const height = Math.max(1, Math.round(img.naturalHeight * scale)); + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext("2d"); + if (!ctx) { + URL.revokeObjectURL(objectUrl); + reject(new Error("이미지 최적화 처리에 실패했습니다.")); + return; + } + + ctx.drawImage(img, 0, 0, width, height); + + canvas.toBlob( + (blob) => { + URL.revokeObjectURL(objectUrl); + if (!blob) { + reject(new Error("이미지 최적화 처리에 실패했습니다.")); + return; + } + + resolve( + new File([blob], `${file.name.replace(/\.[^.]+$/, "")}.webp`, { + type: "image/webp", + }) + ); + }, + "image/webp", + 0.82 + ); + }; + + img.onerror = () => { + URL.revokeObjectURL(objectUrl); + reject(new Error("이미지 로드에 실패했습니다.")); + }; + + img.src = objectUrl; + }); +} diff --git a/src/lib/quill/ImageUploader.ts b/src/lib/quill/ImageUploader.ts index dabac96..09758b4 100644 --- a/src/lib/quill/ImageUploader.ts +++ b/src/lib/quill/ImageUploader.ts @@ -5,6 +5,7 @@ import { LoadingImage } from "./LoadingImage"; export interface ImageUploaderOptions { upload: (file: File) => Promise; + onError?: (error: Error) => void; } export default class ImageUploader { @@ -146,7 +147,14 @@ export default class ImageUploader { this.options.upload(file).then( (url) => this.replaceWithFinalImage(url), - () => this.removePlaceholder() + (error) => { + this.removePlaceholder(); + if (error instanceof Error) { + this.options.onError?.(error); + } else { + this.options.onError?.(new Error("이미지 업로드에 실패했습니다.")); + } + } ); } From 639a265b78b2ff1da39ae10f72ac0e88e58a2aca Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 09:28:55 +0900 Subject: [PATCH 08/34] =?UTF-8?q?refactor:=20posts=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=8E=98=EC=9D=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EA=B2=BD=EB=9F=89=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/posts/components/RecentSection.tsx | 7 +++++-- src/app/posts/page.tsx | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/posts/components/RecentSection.tsx b/src/app/posts/components/RecentSection.tsx index 812195a..60e76ba 100644 --- a/src/app/posts/components/RecentSection.tsx +++ b/src/app/posts/components/RecentSection.tsx @@ -24,6 +24,7 @@ const RecentSection = ({ data }: { data: PostsResponse }) => { {data.posts.map((post, index) => { const isLcpCandidate = index === 0; const isAboveTheFoldCandidate = index < 3; + const useBlurPlaceholder = Boolean(post.thumbnail_blur); return ( { priority={isLcpCandidate} fetchPriority={isAboveTheFoldCandidate ? "high" : "auto"} loading={isAboveTheFoldCandidate ? "eager" : "lazy"} - placeholder="blur" - blurDataURL={post.thumbnail_blur} + placeholder={useBlurPlaceholder ? "blur" : "empty"} + blurDataURL={ + useBlurPlaceholder ? post.thumbnail_blur : undefined + } />
diff --git a/src/app/posts/page.tsx b/src/app/posts/page.tsx index 38ccf28..3a15c5c 100644 --- a/src/app/posts/page.tsx +++ b/src/app/posts/page.tsx @@ -41,13 +41,13 @@ export default async function Posts(props: { searchParams: SearchParams }) { const optimizedData = { total_count: data.total_count, - posts: data.posts.map((post) => ({ + posts: data.posts.map((post, index) => ({ id: post.id, title: post.title, description: post.description, tags: post.tags, thumbnail: post.thumbnail, - thumbnail_blur: post.thumbnail_blur, + thumbnail_blur: index < 3 ? post.thumbnail_blur : "", created_at: post.created_at, })), }; From 72e5f67b84ad1f8676016cdb00368e38ebfd9094 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 09:29:52 +0900 Subject: [PATCH 09/34] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=8B=A4=ED=8C=A8=20UX/=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=EC=B2=98=EB=A6=AC=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/posts/components/AddPostForm.tsx | 32 ++++++++++++++++----- src/app/posts/components/AuthForm.tsx | 36 ++++++++++++++++++------ 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/src/app/posts/components/AddPostForm.tsx b/src/app/posts/components/AddPostForm.tsx index cca151f..738ec4e 100644 --- a/src/app/posts/components/AddPostForm.tsx +++ b/src/app/posts/components/AddPostForm.tsx @@ -29,6 +29,7 @@ const AddPostForm = ({ const [file, setFile] = useState(null); const [message, setMessage] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { if (!isOpen) { @@ -63,13 +64,25 @@ const AddPostForm = ({ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!file) return; + if (!file || isSubmitting) { + if (!file) { + setMessage("대표 이미지를 선택해주세요."); + } + return; + } + + setMessage(""); + setIsSubmitting(true); const filePath = `yonghunblog/${Date.now()}`; const imageUrl = await addFileToStorage(file, filePath); - if (!imageUrl) return; + if (!imageUrl) { + setMessage("이미지 업로드에 실패했습니다. 다시 시도해주세요."); + setIsSubmitting(false); + return; + } const payload: AddPostsRequest = { ...data, @@ -79,12 +92,14 @@ const AddPostForm = ({ try { await addPost(payload); + onClose(); } catch (error) { console.error(error); await deleteFileFromStorage(filePath); + setMessage("게시글 저장에 실패했습니다. 다시 시도해주세요."); + } finally { + setIsSubmitting(false); } - - onClose(); }; return ( @@ -96,7 +111,9 @@ const AddPostForm = ({ initial={{ opacity: 0 }} animate={{ opacity: 1, transition: { duration: 0.2 } }} exit={{ opacity: 0, transition: { duration: 0.2 } }} - onClick={onClose} + onClick={() => { + if (!isSubmitting) onClose(); + }} /> diff --git a/src/app/posts/components/AuthForm.tsx b/src/app/posts/components/AuthForm.tsx index f37300e..67f1f86 100644 --- a/src/app/posts/components/AuthForm.tsx +++ b/src/app/posts/components/AuthForm.tsx @@ -28,18 +28,29 @@ const AuthForm = ({ onSuccess, setRole }: AuthFormProps) => { const [user, setUser] = useState(""); const [password, setPassword] = useState(""); const [message, setMessage] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - const data = await Auth(user, password); + if (isSubmitting) return; + setMessage(""); + setIsSubmitting(true); - setRole?.(data.role); + try { + const data = await Auth(user, password); + setRole?.(data.role); - if (data.role === "Guest") { - setMessage("사용자 정보를 확인해주세요."); - } + if (data.role === "Guest") { + setMessage("사용자 정보를 확인해주세요."); + } - if (data.role === "Admin") onSuccess?.(); + if (data.role === "Admin") onSuccess?.(); + } catch (error) { + console.error(error); + setMessage("인증 요청에 실패했습니다. 잠시 후 다시 시도해주세요."); + } finally { + setIsSubmitting(false); + } }; return ( @@ -62,7 +73,10 @@ const AuthForm = ({ onSuccess, setRole }: AuthFormProps) => { id="text" required value={user} - onChange={(e) => setUser(e.target.value)} + onChange={(e) => { + setUser(e.target.value); + setMessage(""); + }} className="mt-1 block w-full px-4 py-2 border border-gray-400 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400" />
@@ -76,7 +90,10 @@ const AuthForm = ({ onSuccess, setRole }: AuthFormProps) => { id="password" required value={password} - onChange={(e) => setPassword(e.target.value)} + onChange={(e) => { + setPassword(e.target.value); + setMessage(""); + }} className="mt-1 block w-full px-4 py-2 border border-gray-400 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400" /> @@ -88,8 +105,9 @@ const AuthForm = ({ onSuccess, setRole }: AuthFormProps) => { variants={buttonVariants} whileHover="hover" whileTap="tap" + disabled={isSubmitting} > - 확인하기 + {isSubmitting ? "확인 중..." : "확인하기"} From c4a359d2e83fde2b148e66b3b2fb4229de7170cc Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 09:31:08 +0900 Subject: [PATCH 10/34] =?UTF-8?q?refactor:=20posts=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=A0=91=EA=B7=BC=EC=84=B1=20=EC=86=8D=EC=84=B1=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/posts/[id]/pageClient.tsx | 17 ++++++++++++++++- src/app/posts/components/AddPostForm.tsx | 4 +++- src/app/posts/components/AuthForm.tsx | 5 ++++- src/app/posts/components/RecentSection.tsx | 8 ++++++-- src/app/posts/pageClient.tsx | 4 +++- 5 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/app/posts/[id]/pageClient.tsx b/src/app/posts/[id]/pageClient.tsx index 686cd53..b0f4e89 100644 --- a/src/app/posts/[id]/pageClient.tsx +++ b/src/app/posts/[id]/pageClient.tsx @@ -111,6 +111,7 @@ export default function PostDetailClient({ post }: { post: Post }) { setViewAuth(true); setMessage(""); }} + aria-label="게시글 삭제 인증 열기" > @@ -123,18 +124,26 @@ export default function PostDetailClient({ post }: { post: Post }) { setViewAuth(true); setMessage(""); }} + aria-label="게시글 수정 인증 열기" > - {message ?

{message}

: null} + {message ? ( +

+ {message} +

+ ) : null} {viewAuth && (
setViewAuth(false)} + role="dialog" + aria-modal="true" + aria-label="사용자 인증" > { @@ -152,6 +161,9 @@ export default function PostDetailClient({ post }: { post: Post }) {
setViewDelete(false)} + role="dialog" + aria-modal="true" + aria-label="게시글 삭제 확인" > setViewDelete(false)} @@ -169,6 +181,9 @@ export default function PostDetailClient({ post }: { post: Post }) { initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} + role="dialog" + aria-modal="true" + aria-label="이미지 확대 보기" > {message ? ( -

{message}

+

+ {message} +

) : null} {previewUrl && blurPreviewUrl ? ( diff --git a/src/app/posts/components/AuthForm.tsx b/src/app/posts/components/AuthForm.tsx index 67f1f86..9e2ec8d 100644 --- a/src/app/posts/components/AuthForm.tsx +++ b/src/app/posts/components/AuthForm.tsx @@ -61,6 +61,7 @@ const AuthForm = ({ onSuccess, setRole }: AuthFormProps) => { initial="hidden" animate="visible" onClick={(e) => e.stopPropagation()} + aria-busy={isSubmitting} >

사용자 확인

@@ -97,7 +98,9 @@ const AuthForm = ({ onSuccess, setRole }: AuthFormProps) => { className="mt-1 block w-full px-4 py-2 border border-gray-400 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400" />
-
{message}
+
+ {message} +
{ if (isLoading) return ; return ( -
+
{data.posts.map((post, index) => { @@ -30,6 +33,7 @@ const RecentSection = ({ data }: { data: PostsResponse }) => { href={`/posts/${post.id}`} key={post.id} className="text-left" + aria-label={`${post.title} 게시글 상세 보기`} > {`thumbnail`} github button router.push("/posts/add")} + aria-label="새 글 작성 페이지로 이동" > From 36a8ad6da4cfd3113d38605e9bdfa24cddbbb12e Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 09:31:57 +0900 Subject: [PATCH 11/34] =?UTF-8?q?ci:=20posts=20Lighthouse=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=B8=A1=EC=A0=95=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/lighthouse.yml | 30 ++++++++++++++++++++++++++++++ .lighthouserc.json | 21 +++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 .github/workflows/lighthouse.yml create mode 100644 .lighthouserc.json diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml new file mode 100644 index 0000000..17966dc --- /dev/null +++ b/.github/workflows/lighthouse.yml @@ -0,0 +1,30 @@ +name: Lighthouse CI + +on: + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + lighthouse: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build app + run: npm run build + + - name: Run Lighthouse CI + run: npx @lhci/cli@0.13.x autorun --config=.lighthouserc.json diff --git a/.lighthouserc.json b/.lighthouserc.json new file mode 100644 index 0000000..525d2c2 --- /dev/null +++ b/.lighthouserc.json @@ -0,0 +1,21 @@ +{ + "ci": { + "collect": { + "startServerCommand": "npm run start -- --port=3000", + "startServerReadyPattern": "Ready", + "url": ["http://localhost:3000/posts"], + "numberOfRuns": 3 + }, + "assert": { + "assertions": { + "categories:performance": ["error", {"minScore": 0.8}], + "categories:accessibility": ["warn", {"minScore": 0.9}], + "categories:best-practices": ["warn", {"minScore": 0.9}], + "categories:seo": ["warn", {"minScore": 0.9}] + } + }, + "upload": { + "target": "temporary-public-storage" + } + } +} From 2d17bd1fabbf46f5271e21097e955774a08c406a Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 09:39:44 +0900 Subject: [PATCH 12/34] =?UTF-8?q?chore:=20Lighthouse=20SEO=20=EC=B5=9C?= =?UTF-8?q?=EC=86=8C=20=EC=A0=90=EC=88=98=20=EA=B8=B0=EC=A4=80=20=EC=83=81?= =?UTF-8?q?=ED=96=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .lighthouserc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.lighthouserc.json b/.lighthouserc.json index 525d2c2..5903e9d 100644 --- a/.lighthouserc.json +++ b/.lighthouserc.json @@ -11,7 +11,7 @@ "categories:performance": ["error", {"minScore": 0.8}], "categories:accessibility": ["warn", {"minScore": 0.9}], "categories:best-practices": ["warn", {"minScore": 0.9}], - "categories:seo": ["warn", {"minScore": 0.9}] + "categories:seo": ["warn", {"minScore": 1}] } }, "upload": { From 3d65b030d18916379530886426a0c2cb2500f127 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 09:45:02 +0900 Subject: [PATCH 13/34] =?UTF-8?q?docs:=20AGENTS=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=EB=A5=BC=20=EC=83=81=EB=8C=80=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EB=A1=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8f4a3d9..208d8aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -이 문서는 `/Users/dd/Dev/portfolio-fe` 저장소에서 작업하는 사람/에이전트를 위한 실행 가이드입니다. +이 문서는 이 저장소 루트(`./`)에서 작업하는 사람/에이전트를 위한 실행 가이드입니다. ## 1) 프로젝트 요약 @@ -111,4 +111,3 @@ 2. 변경 범위의 핵심 페이지가 의도대로 동작한다. 3. 커밋 메시지가 소문자 타입 규칙을 지킨다. 4. 문서/타입/코드가 서로 모순되지 않는다. - From db6eee300b9125ed0649eeb25784e1b2bfa67e79 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 09:59:07 +0900 Subject: [PATCH 14/34] =?UTF-8?q?fix:=20next=20=EB=B3=B4=EC=95=88=20?= =?UTF-8?q?=ED=8C=A8=EC=B9=98=20=EB=B2=84=EC=A0=84=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 867 +++++++++++++++++++++++++--------------------- package.json | 4 +- tsconfig.json | 26 +- 3 files changed, 499 insertions(+), 398 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3d020bc..bb35d56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "clsx": "^2.1.1", "highlight.js": "^11.11.1", "motion": "^12.9.4", - "next": "15.3.1", + "next": "^15.5.12", "quill": "^2.0.3", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -29,7 +29,7 @@ "@types/react-dom": "^19", "@types/react-syntax-highlighter": "^15.5.13", "eslint": "^9", - "eslint-config-next": "15.3.1", + "eslint-config-next": "^15.5.12", "tailwindcss": "^4", "typescript": "^5" } @@ -69,9 +69,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "license": "MIT", "optional": true, "dependencies": { @@ -90,9 +90,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.6.1.tgz", - "integrity": "sha512-KTsJMmobmbrFLe3LDh0PC2FXpcSYJt/MLjlkh/9LEnmKYLSYmT/0EW9JWANjeoemiuZrmogti0tW5Ch+qNUYDw==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -122,9 +122,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -293,10 +293,20 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.1.tgz", - "integrity": "sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], @@ -312,13 +322,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.1.0" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.1.tgz", - "integrity": "sha512-VfuYgG2r8BpYiOUN+BfYeFo69nP/MIwAtSJ7/Zpxc5QF3KS22z8Pvg3FkrSFJBPNQ7mmcUcYQFBmEQp7eu1F8Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], @@ -334,13 +344,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.1.0" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz", - "integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], @@ -354,9 +364,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz", - "integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], @@ -370,9 +380,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz", - "integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], @@ -386,9 +396,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz", - "integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], @@ -402,9 +412,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz", - "integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "cpu": [ "ppc64" ], @@ -417,10 +427,26 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz", - "integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ "s390x" ], @@ -434,9 +460,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz", - "integrity": "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], @@ -450,9 +476,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz", - "integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], @@ -466,9 +492,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz", - "integrity": "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], @@ -482,9 +508,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.1.tgz", - "integrity": "sha512-anKiszvACti2sGy9CirTlNyk7BjjZPiML1jt2ZkTdcvpLU1YH6CXwRAZCA2UmRXnhiIftXQ7+Oh62Ji25W72jA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], @@ -500,13 +526,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.1.0" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.1.tgz", - "integrity": "sha512-kX2c+vbvaXC6vly1RDf/IWNXxrlxLNpBVWkdpRq5Ka7OOKj6nr66etKy2IENf6FtOgklkg9ZdGpEu9kwdlcwOQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], @@ -522,13 +548,57 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.1.0" + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.1.tgz", - "integrity": "sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ "s390x" ], @@ -544,13 +614,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.1.0" + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.1.tgz", - "integrity": "sha512-wExv7SH9nmoBW3Wr2gvQopX1k8q2g5V5Iag8Zk6AVENsjwd+3adjwxtp3Dcu2QhOXr8W9NusBU6XcQUohBZ5MA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], @@ -566,13 +636,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.1.0" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.1.tgz", - "integrity": "sha512-DfvyxzHxw4WGdPiTF0SOHnm11Xv4aQexvqhRDAoD00MzHekAj9a/jADXeXYCDFH/DzYruwHbXU7uz+H+nWmSOQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], @@ -588,13 +658,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.1.tgz", - "integrity": "sha512-pax/kTR407vNb9qaSIiWVnQplPcGU8LRIJpDT5o8PdAx5aAA7AS3X9PS8Isw1/WfqgQorPotjrZL3Pqh6C5EBg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], @@ -610,21 +680,40 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.1.0" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.1.tgz", - "integrity": "sha512-YDybQnYrLQfEpzGOQe7OKcyLUCML4YOXl428gOOzBgN6Gw0rv8dpsJ7PqTHxBnXnwXr8S1mYFSLSa727tpz0xg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.4.0" + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -633,9 +722,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.1.tgz", - "integrity": "sha512-WKf/NAZITnonBf3U1LfdjoMgNO5JYRSlhovhRhMxXVdvWYveM4kM3L8m35onYIdh75cOMCo1BexgVQcCDzyoWw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], @@ -652,9 +741,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.1.tgz", - "integrity": "sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], @@ -684,15 +773,15 @@ } }, "node_modules/@next/env": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.1.tgz", - "integrity": "sha512-cwK27QdzrMblHSn9DZRV+DQscHXRuJv6MydlJRpFSqJWZrTYMLzKDeyueJNN9MGd8NNiUKzDQADAf+dMLXX7YQ==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.12.tgz", + "integrity": "sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.3.1.tgz", - "integrity": "sha512-oEs4dsfM6iyER3jTzMm4kDSbrQJq8wZw5fmT6fg2V3SMo+kgG+cShzLfEV20senZzv8VF+puNLheiGPlBGsv2A==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.12.tgz", + "integrity": "sha512-+ZRSDFTv4aC96aMb5E41rMjysx8ApkryevnvEYZvPZO52KvkqP5rNExLUXJFr9P4s0f3oqNQR6vopCZsPWKDcQ==", "dev": true, "license": "MIT", "dependencies": { @@ -700,9 +789,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.1.tgz", - "integrity": "sha512-hjDw4f4/nla+6wysBL07z52Gs55Gttp5Bsk5/8AncQLJoisvTBP0pRIBK/B16/KqQyH+uN4Ww8KkcAqJODYH3w==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.12.tgz", + "integrity": "sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==", "cpu": [ "arm64" ], @@ -716,9 +805,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.1.tgz", - "integrity": "sha512-q+aw+cJ2ooVYdCEqZVk+T4Ni10jF6Fo5DfpEV51OupMaV5XL6pf3GCzrk6kSSZBsMKZtVC1Zm/xaNBFpA6bJ2g==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.12.tgz", + "integrity": "sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==", "cpu": [ "x64" ], @@ -732,9 +821,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.1.tgz", - "integrity": "sha512-wBQ+jGUI3N0QZyWmmvRHjXjTWFy8o+zPFLSOyAyGFI94oJi+kK/LIZFJXeykvgXUk1NLDAEFDZw/NVINhdk9FQ==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.12.tgz", + "integrity": "sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==", "cpu": [ "arm64" ], @@ -748,9 +837,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.1.tgz", - "integrity": "sha512-IIxXEXRti/AulO9lWRHiCpUUR8AR/ZYLPALgiIg/9ENzMzLn3l0NSxVdva7R/VDcuSEBo0eGVCe3evSIHNz0Hg==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.12.tgz", + "integrity": "sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==", "cpu": [ "arm64" ], @@ -764,9 +853,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.1.tgz", - "integrity": "sha512-bfI4AMhySJbyXQIKH5rmLJ5/BP7bPwuxauTvVEiJ/ADoddaA9fgyNNCcsbu9SlqfHDoZmfI6g2EjzLwbsVTr5A==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.12.tgz", + "integrity": "sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==", "cpu": [ "x64" ], @@ -780,9 +869,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.1.tgz", - "integrity": "sha512-FeAbR7FYMWR+Z+M5iSGytVryKHiAsc0x3Nc3J+FD5NVbD5Mqz7fTSy8CYliXinn7T26nDMbpExRUI/4ekTvoiA==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.12.tgz", + "integrity": "sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==", "cpu": [ "x64" ], @@ -796,9 +885,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.1.tgz", - "integrity": "sha512-yP7FueWjphQEPpJQ2oKmshk/ppOt+0/bB8JC8svPUZNy0Pi3KbPx2Llkzv1p8CoQa+D2wknINlJpHf3vtChVBw==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.12.tgz", + "integrity": "sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==", "cpu": [ "arm64" ], @@ -812,9 +901,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.1.tgz", - "integrity": "sha512-3PMvF2zRJAifcRNni9uMk/gulWfWS+qVI/pagd+4yLF5bcXPZPPH2xlYRYOsUjmCJOXSTAC2PjRzbhsRzR2fDQ==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.12.tgz", + "integrity": "sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw==", "cpu": [ "x64" ], @@ -883,9 +972,9 @@ "license": "MIT" }, "node_modules/@rushstack/eslint-patch": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.11.0.tgz", - "integrity": "sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.15.0.tgz", + "integrity": "sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw==", "dev": true, "license": "MIT" }, @@ -963,12 +1052,6 @@ "@supabase/storage-js": "2.7.1" } }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "license": "Apache-2.0" - }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1348,21 +1431,20 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.1.tgz", - "integrity": "sha512-oUlH4h1ABavI4F0Xnl8/fOtML/eu8nI2A1nYd+f+55XI0BLu+RIqKoCiZKNo6DtqZBEQm5aNKA20G3Z5w3R6GQ==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", + "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.31.1", - "@typescript-eslint/type-utils": "8.31.1", - "@typescript-eslint/utils": "8.31.1", - "@typescript-eslint/visitor-keys": "8.31.1", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/type-utils": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.1" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1372,23 +1454,33 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "@typescript-eslint/parser": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.31.1.tgz", - "integrity": "sha512-oU/OtYVydhXnumd0BobL9rkJg7wFJ9bFFPmSmB/bf/XWN85hlViji59ko6bSKBXyseT9V8l+CN1nwmlbiN0G7Q==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", + "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.31.1", - "@typescript-eslint/types": "8.31.1", - "@typescript-eslint/typescript-estree": "8.31.1", - "@typescript-eslint/visitor-keys": "8.31.1", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1398,19 +1490,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.31.1.tgz", - "integrity": "sha512-BMNLOElPxrtNQMIsFHE+3P0Yf1z0dJqV9zLdDxN/xLlWMlXK/ApEsVEKzpizg9oal8bAT5Sc7+ocal7AC1HCVw==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", + "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.31.1", - "@typescript-eslint/visitor-keys": "8.31.1" + "@typescript-eslint/tsconfig-utils": "^8.56.0", + "@typescript-eslint/types": "^8.56.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1418,19 +1511,20 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.31.1.tgz", - "integrity": "sha512-fNaT/m9n0+dpSp8G/iOQ05GoHYXbxw81x+yvr7TArTuZuCA6VVKbqWYVZrV5dVagpDTtj/O8k5HBEE/p/HM5LA==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", + "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.31.1", - "@typescript-eslint/utils": "8.31.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.0.1" + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1438,16 +1532,12 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.31.1.tgz", - "integrity": "sha512-SfepaEFUDQYRoA70DD9GtytljBePSj17qPxFHA/h3eg6lPTqGJ5mWOtbXCk1YrVU1cTJRd14nhaXWFu0l2troQ==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", + "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", "dev": true, "license": "MIT", "engines": { @@ -1456,23 +1546,23 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.31.1.tgz", - "integrity": "sha512-kaA0ueLe2v7KunYOyWYtlf/QhhZb7+qh4Yw6Ni5kgukMIG+iP773tjgBiLWIXYumWCwEq3nLW+TUywEp8uEeag==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", + "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.31.1", - "@typescript-eslint/visitor-keys": "8.31.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.0.1" + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1482,47 +1572,60 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "node_modules/@typescript-eslint/types": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", + "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", + "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" + "@typescript-eslint/project-service": "8.56.0", + "@typescript-eslint/tsconfig-utils": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { - "node": ">=8.6.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" + "balanced-match": "^1.0.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -1542,16 +1645,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.31.1.tgz", - "integrity": "sha512-2DSI4SNfF5T4oRveQ4nUrSjUqjMND0nLq9rEkz0gfGr3tg0S5KB6DhwR+WZPCjzkZl3cH+4x2ce3EsL50FubjQ==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", + "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.31.1", - "@typescript-eslint/types": "8.31.1", - "@typescript-eslint/typescript-estree": "8.31.1" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1561,19 +1664,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.31.1.tgz", - "integrity": "sha512-I+/rgqOVBn6f0o7NDTmAPWWC6NuqhV174lfYvAm9fUaWeiefLdux9/YI3/nLugEn9L8fcSi0XmpKi/r5u0nmpw==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", + "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.31.1", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.56.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1583,6 +1686,19 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", + "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@unrs/resolver-binding-darwin-arm64": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.7.2.tgz", @@ -1915,18 +2031,20 @@ } }, "node_modules/array-includes": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", - "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "is-string": "^1.0.7" + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -2139,17 +2257,6 @@ "node": ">=8" } }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2211,9 +2318,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001716", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001716.tgz", - "integrity": "sha512-49/c1+x3Kwz7ZIWt+4DvK3aMJy9oYXXG6/97JKsnjdCk/6n9vVyWL8NAwVt95Lwt9eigI10Hl782kDfZUUlRXw==", + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", "funding": [ { "type": "opencollective", @@ -2292,25 +2399,11 @@ "node": ">=6" } }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2323,20 +2416,9 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "devOptional": true, + "dev": true, "license": "MIT" }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, "node_modules/comma-separated-tokens": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz", @@ -2438,9 +2520,9 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -2499,9 +2581,9 @@ } }, "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "devOptional": true, "license": "Apache-2.0", "engines": { @@ -2558,9 +2640,9 @@ } }, "node_modules/es-abstract": { - "version": "1.23.9", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", - "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", "dev": true, "license": "MIT", "dependencies": { @@ -2568,18 +2650,18 @@ "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", - "call-bound": "^1.0.3", + "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.0", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", @@ -2591,21 +2673,24 @@ "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", + "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.0", + "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.3", + "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.3", + "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", @@ -2614,7 +2699,7 @@ "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.18" + "which-typed-array": "^1.1.19" }, "engines": { "node": ">= 0.4" @@ -2806,13 +2891,13 @@ } }, "node_modules/eslint-config-next": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.3.1.tgz", - "integrity": "sha512-GnmyVd9TE/Ihe3RrvcafFhXErErtr2jS0JDeCSp3vWvy86AXwHsRBt0E3MqP/m8ACS1ivcsi5uaqjbhsG18qKw==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.12.tgz", + "integrity": "sha512-ktW3XLfd+ztEltY5scJNjxjHwtKWk6vU2iwzZqSN09UsbBmMeE/cVlJ1yESg6Yx5LW7p/Z8WzUAgYXGLEmGIpg==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "15.3.1", + "@next/eslint-plugin-next": "15.5.12", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", @@ -2891,9 +2976,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", - "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", "dev": true, "license": "MIT", "dependencies": { @@ -2919,30 +3004,30 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.31.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", - "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", "dependencies": { "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.8", - "array.prototype.findlastindex": "^1.2.5", - "array.prototype.flat": "^1.3.2", - "array.prototype.flatmap": "^1.3.2", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.0", + "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", - "is-core-module": "^2.15.1", + "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", - "object.values": "^1.2.0", + "object.values": "^1.2.1", "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.8", + "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "engines": { @@ -3234,9 +3319,9 @@ "license": "MIT" }, "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { @@ -3545,13 +3630,6 @@ "dev": true, "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -3782,13 +3860,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "license": "MIT", - "optional": true - }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -4007,6 +4078,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4799,15 +4883,13 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/next/-/next-15.3.1.tgz", - "integrity": "sha512-8+dDV0xNLOgHlyBxP1GwHGVaNXsmp+2NhZEYrXr24GWLHtt27YrBPbPuHvzlhi7kZNYjeJNR93IF5zfFu5UL0g==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz", + "integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==", "license": "MIT", "dependencies": { - "@next/env": "15.3.1", - "@swc/counter": "0.1.3", + "@next/env": "15.5.12", "@swc/helpers": "0.5.15", - "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -4819,19 +4901,19 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.3.1", - "@next/swc-darwin-x64": "15.3.1", - "@next/swc-linux-arm64-gnu": "15.3.1", - "@next/swc-linux-arm64-musl": "15.3.1", - "@next/swc-linux-x64-gnu": "15.3.1", - "@next/swc-linux-x64-musl": "15.3.1", - "@next/swc-win32-arm64-msvc": "15.3.1", - "@next/swc-win32-x64-msvc": "15.3.1", - "sharp": "^0.34.1" + "@next/swc-darwin-arm64": "15.5.12", + "@next/swc-darwin-x64": "15.5.12", + "@next/swc-linux-arm64-gnu": "15.5.12", + "@next/swc-linux-arm64-musl": "15.5.12", + "@next/swc-linux-x64-gnu": "15.5.12", + "@next/swc-linux-x64-musl": "15.5.12", + "@next/swc-win32-arm64-msvc": "15.5.12", + "@next/swc-win32-x64-msvc": "15.5.12", + "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.41.2", + "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", @@ -5566,9 +5648,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "devOptional": true, "license": "ISC", "bin": { @@ -5628,16 +5710,16 @@ } }, "node_modules/sharp": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.1.tgz", - "integrity": "sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", "optional": true, "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.7.1" + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -5646,26 +5728,30 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.1", - "@img/sharp-darwin-x64": "0.34.1", - "@img/sharp-libvips-darwin-arm64": "1.1.0", - "@img/sharp-libvips-darwin-x64": "1.1.0", - "@img/sharp-libvips-linux-arm": "1.1.0", - "@img/sharp-libvips-linux-arm64": "1.1.0", - "@img/sharp-libvips-linux-ppc64": "1.1.0", - "@img/sharp-libvips-linux-s390x": "1.1.0", - "@img/sharp-libvips-linux-x64": "1.1.0", - "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", - "@img/sharp-libvips-linuxmusl-x64": "1.1.0", - "@img/sharp-linux-arm": "0.34.1", - "@img/sharp-linux-arm64": "0.34.1", - "@img/sharp-linux-s390x": "0.34.1", - "@img/sharp-linux-x64": "0.34.1", - "@img/sharp-linuxmusl-arm64": "0.34.1", - "@img/sharp-linuxmusl-x64": "0.34.1", - "@img/sharp-wasm32": "0.34.1", - "@img/sharp-win32-ia32": "0.34.1", - "@img/sharp-win32-x64": "0.34.1" + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/shebang-command": { @@ -5767,16 +5853,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "license": "MIT", - "optional": true, - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5803,12 +5879,18 @@ "dev": true, "license": "MIT" }, - "node_modules/streamsearch": { + "node_modules/stop-iteration-iterator": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, "engines": { - "node": ">=10.0.0" + "node": ">= 0.4" } }, "node_modules/string.prototype.includes": { @@ -6024,14 +6106,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", - "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -6041,11 +6123,14 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -6056,9 +6141,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { @@ -6088,9 +6173,9 @@ "license": "MIT" }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index ae007ad..e86a09f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "clsx": "^2.1.1", "highlight.js": "^11.11.1", "motion": "^12.9.4", - "next": "15.3.1", + "next": "^15.5.12", "quill": "^2.0.3", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -30,7 +30,7 @@ "@types/react-dom": "^19", "@types/react-syntax-highlighter": "^15.5.13", "eslint": "^9", - "eslint-config-next": "15.3.1", + "eslint-config-next": "^15.5.12", "tailwindcss": "^4", "typescript": "^5" } diff --git a/tsconfig.json b/tsconfig.json index 42ebe75..456f105 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -19,10 +23,22 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] }, - "types": ["node"] + "types": [ + "node" + ] }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } From 7c64014ee44c714892b2c93eefa9662b7cd04318 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 10:03:02 +0900 Subject: [PATCH 15/34] =?UTF-8?q?refactor:=20lint=20=EA=B2=BD=EA=B3=A0=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/about/pageClient.tsx | 11 ++++- src/app/posts/components/AddPostForm.tsx | 11 ++++- src/app/projects/[id]/pageClient.tsx | 4 +- src/components/ui/Editor.tsx | 22 ++++++--- src/components/ui/MaskContainer.tsx | 22 ++++----- src/components/ui/ParticleCanvas.tsx | 57 ++++++++++++------------ src/components/ui/Spotlight.tsx | 2 +- 7 files changed, 75 insertions(+), 54 deletions(-) diff --git a/src/app/about/pageClient.tsx b/src/app/about/pageClient.tsx index a823c4d..4e623b1 100644 --- a/src/app/about/pageClient.tsx +++ b/src/app/about/pageClient.tsx @@ -4,6 +4,7 @@ import Nav from "@/components/common/Nav"; import { GlassBox } from "@/components/ui/GlassBox"; import { about } from "@/data"; import { motion } from "motion/react"; +import Image from "next/image"; export default function AboutClient() { return ( @@ -20,13 +21,19 @@ export default function AboutClient() { >
{about.name} - + - github button + github button
diff --git a/src/app/posts/components/AddPostForm.tsx b/src/app/posts/components/AddPostForm.tsx index cd41fb4..2cff5c9 100644 --- a/src/app/posts/components/AddPostForm.tsx +++ b/src/app/posts/components/AddPostForm.tsx @@ -3,6 +3,7 @@ import { addPost, type AddPostsRequest } from "@/lib/api/blog"; import { supabase } from "@/lib/supabase/supabasClient"; import { AnimatePresence, motion } from "motion/react"; +import NextImage from "next/image"; import { useEffect, useState } from "react"; const sheetVariants = { @@ -156,14 +157,20 @@ const AddPostForm = ({ {previewUrl && blurPreviewUrl ? (
- selected preview - selected preview
diff --git a/src/app/projects/[id]/pageClient.tsx b/src/app/projects/[id]/pageClient.tsx index 8fca165..14311a1 100644 --- a/src/app/projects/[id]/pageClient.tsx +++ b/src/app/projects/[id]/pageClient.tsx @@ -21,9 +21,11 @@ export default function DetailClient({ project }: { project: Project }) { {/* 메인 이미지 */}
- thumbnail
diff --git a/src/components/ui/Editor.tsx b/src/components/ui/Editor.tsx index eb1613d..f54549c 100644 --- a/src/components/ui/Editor.tsx +++ b/src/components/ui/Editor.tsx @@ -40,18 +40,27 @@ export type EditorProps = { const Editor = ({ initialHTML = "", onChange }: EditorProps) => { const containerRef = useRef(null); const onChangeRef = useRef(onChange); + const isInitializedRef = useRef(false); const [message, setMessage] = useState(""); useEffect(() => { - if (containerRef.current?.innerHTML !== "") return; + onChangeRef.current = onChange; + }, [onChange]); + useEffect(() => { + if (containerRef.current?.innerHTML !== "") return; + if (isInitializedRef.current) return; let quill: null | Quill = null; let editorContainer: null | HTMLDivElement = null; if (quill || editorContainer) return; - editorContainer = containerRef.current!.appendChild( - containerRef.current!.ownerDocument.createElement("div") + const container = containerRef.current; + if (!container) return; + isInitializedRef.current = true; + + editorContainer = container.appendChild( + container.ownerDocument.createElement("div") ); quill = new Quill(editorContainer, { @@ -167,11 +176,10 @@ const Editor = ({ initialHTML = "", onChange }: EditorProps) => { }); return () => { - if (containerRef.current) { - containerRef.current.innerHTML = ""; - } + container.innerHTML = ""; + isInitializedRef.current = false; }; - }, []); + }, [initialHTML]); return (
diff --git a/src/components/ui/MaskContainer.tsx b/src/components/ui/MaskContainer.tsx index cb69a43..707112f 100644 --- a/src/components/ui/MaskContainer.tsx +++ b/src/components/ui/MaskContainer.tsx @@ -3,7 +3,7 @@ import { cn } from "@/lib/utils"; import useMaskRevealStore from "@/store/useMaskRevealStore"; import { motion } from "motion/react"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import ParticleCanvas from "./ParticleCanvas"; export const MaskContainer = ({ @@ -27,28 +27,24 @@ export const MaskContainer = ({ y: null | number; }>({ x: null, y: null }); const containerRef = useRef(null); - const updateMousePosition = (e: MouseEvent) => { + const updateMousePosition = useCallback((e: MouseEvent) => { if (!containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); setMousePosition({ x: e.clientX - rect.left, y: e.clientY - rect.top }); - }; + }, []); useEffect(() => { - if (!containerRef.current) return; - containerRef.current.addEventListener("mousemove", updateMousePosition); + const container = containerRef.current; + if (!container) return; + container.addEventListener("mousemove", updateMousePosition); return () => { - if (containerRef.current) { - containerRef.current.removeEventListener( - "mousemove", - updateMousePosition - ); - } + container.removeEventListener("mousemove", updateMousePosition); }; - }, []); + }, [updateMousePosition]); useEffect(() => { setIsHover(isHovered); - }, [isHovered]); + }, [isHovered, setIsHover]); const maskSize = isHovered ? revealSize : size; diff --git a/src/components/ui/ParticleCanvas.tsx b/src/components/ui/ParticleCanvas.tsx index e212e6a..b667a44 100644 --- a/src/components/ui/ParticleCanvas.tsx +++ b/src/components/ui/ParticleCanvas.tsx @@ -8,51 +8,52 @@ const INTERVAL = 1000 / 60; const DELAY = 300; const ParticleCanvas = ({ - x = window.innerWidth / 2, - y = window.innerHeight / 2, + x, + y, }: { x?: number; y?: number; }) => { + const fallbackX = typeof window !== "undefined" ? window.innerWidth / 2 : 0; + const fallbackY = typeof window !== "undefined" ? window.innerHeight / 2 : 0; const canvasRef = useRef(null); const particlesRef = useRef([]); - const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1; + const originRef = useRef({ + x: x ?? fallbackX, + y: y ?? fallbackY, + }); - const initCanvas = () => { + useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; - const canvasWidth = window.innerWidth; - const canvasHeight = window.innerHeight; - - canvas.style.width = canvasWidth + "px"; - canvas.style.height = canvasHeight + "px"; - - canvas.width = canvasWidth * dpr; - canvas.height = canvasHeight * dpr; - - ctx.scale(dpr, dpr); - }; + const dpr = window.devicePixelRatio || 1; + let then = Date.now(); + let animationFrameId: number; + const initCanvas = () => { + const canvasWidth = window.innerWidth; + const canvasHeight = window.innerHeight; - const createParticles = () => { - particlesRef.current = []; - for (let i = 0; i < PARTICLE_NUM; i++) { - particlesRef.current.push(new Particle(x, y)); - } - }; + canvas.style.width = canvasWidth + "px"; + canvas.style.height = canvasHeight + "px"; - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; + canvas.width = canvasWidth * dpr; + canvas.height = canvasHeight * dpr; - const ctx = canvas.getContext("2d"); - if (!ctx) return; + ctx.scale(dpr, dpr); + }; - let then = Date.now(); - let animationFrameId: number; + const createParticles = () => { + particlesRef.current = []; + for (let i = 0; i < PARTICLE_NUM; i++) { + particlesRef.current.push( + new Particle(originRef.current.x, originRef.current.y) + ); + } + }; initCanvas(); diff --git a/src/components/ui/Spotlight.tsx b/src/components/ui/Spotlight.tsx index 19278c7..7121bbf 100644 --- a/src/components/ui/Spotlight.tsx +++ b/src/components/ui/Spotlight.tsx @@ -36,7 +36,7 @@ export const Spotlight = ({ className }: SpotlightProps) => { changeColor("white"); } } - }, [pathname, isHover]); + }, [pathname, isHover, changeColor]); return ( Date: Tue, 17 Feb 2026 10:06:05 +0900 Subject: [PATCH 16/34] =?UTF-8?q?fix:=20metadataBase=20=EB=B0=8F=20posts?= =?UTF-8?q?=20=EC=BA=90=EC=8B=9C=20=EA=B2=BD=EA=B3=A0=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout.tsx | 1 + src/lib/api/blog.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8b9e0ae..9cc4c5f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -26,6 +26,7 @@ const geistNanum = Nanum_Pen_Script({ }); export const metadata: Metadata = { + metadataBase: new URL("https://www.yonghun.me"), title: "Yonghun - 포트폴리오", description: "기억에 남는 순간을 만들고 싶은 웹 개발자 이용훈입니다.", keywords: "웹개발,프론트엔드,백엔드,포트폴리오,개발자,블로그", diff --git a/src/lib/api/blog.tsx b/src/lib/api/blog.tsx index 1da64a8..9b4c34d 100644 --- a/src/lib/api/blog.tsx +++ b/src/lib/api/blog.tsx @@ -34,7 +34,7 @@ export async function getAllPosts(page?: number) { }); } else { return apiClient(`${BASE_URL}/posts`, { - next: { revalidate: 3600 }, + cache: "no-store", }); } } From 14d457438f7abaec4822190fbe4e9029f2d96c41 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 10:17:18 +0900 Subject: [PATCH 17/34] =?UTF-8?q?fix:=20posts=20LCP=20=EB=A1=9C=EB=94=A9?= =?UTF-8?q?=20=EC=9A=B0=EC=84=A0=EC=88=9C=EC=9C=84=20=EB=B0=8F=20preconnec?= =?UTF-8?q?t=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout.tsx | 10 ---------- src/app/posts/components/RecentSection.tsx | 5 ++--- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9cc4c5f..400e40d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -57,16 +57,6 @@ export default function RootLayout({ }>) { return ( - - - - - - diff --git a/src/app/posts/components/RecentSection.tsx b/src/app/posts/components/RecentSection.tsx index ff04294..c95e404 100644 --- a/src/app/posts/components/RecentSection.tsx +++ b/src/app/posts/components/RecentSection.tsx @@ -26,7 +26,6 @@ const RecentSection = ({ data }: { data: PostsResponse }) => {
{data.posts.map((post, index) => { const isLcpCandidate = index === 0; - const isAboveTheFoldCandidate = index < 3; const useBlurPlaceholder = Boolean(post.thumbnail_blur); return ( { quality={45} sizes="(max-width: 767px) 100vw, (max-width: 1023px) 50vw, 33vw" priority={isLcpCandidate} - fetchPriority={isAboveTheFoldCandidate ? "high" : "auto"} - loading={isAboveTheFoldCandidate ? "eager" : "lazy"} + fetchPriority={isLcpCandidate ? "high" : "auto"} + loading={isLcpCandidate ? "eager" : "lazy"} placeholder={useBlurPlaceholder ? "blur" : "empty"} blurDataURL={ useBlurPlaceholder ? post.thumbnail_blur : undefined From bdd18079e094d9f6c4177d42b688b2cb181dd594 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 10:19:11 +0900 Subject: [PATCH 18/34] =?UTF-8?q?fix:=20LCP=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=9A=B0=EC=84=A0=20=EB=A1=9C=EB=94=A9=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A6=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EB=B9=84=EC=9A=A9=20?= =?UTF-8?q?=EC=99=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/posts/[id]/pageClient.tsx | 4 +++ src/components/ui/MaskContainer.tsx | 49 ++++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/app/posts/[id]/pageClient.tsx b/src/app/posts/[id]/pageClient.tsx index b0f4e89..1e15ca2 100644 --- a/src/app/posts/[id]/pageClient.tsx +++ b/src/app/posts/[id]/pageClient.tsx @@ -83,6 +83,10 @@ export default function PostDetailClient({ post }: { post: Post }) { alt="post thumbnail" fill className="object-cover" + sizes="100vw" + priority + fetchPriority="high" + loading="eager" placeholder="blur" blurDataURL={post.thumbnail_blur} /> diff --git a/src/components/ui/MaskContainer.tsx b/src/components/ui/MaskContainer.tsx index 707112f..36e4d27 100644 --- a/src/components/ui/MaskContainer.tsx +++ b/src/components/ui/MaskContainer.tsx @@ -27,20 +27,61 @@ export const MaskContainer = ({ y: null | number; }>({ x: null, y: null }); const containerRef = useRef(null); - const updateMousePosition = useCallback((e: MouseEvent) => { + const rectRef = useRef(null); + const rafRef = useRef(null); + const pointerRef = useRef<{ x: number; y: number } | null>(null); + + const updateRect = useCallback(() => { if (!containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); - setMousePosition({ x: e.clientX - rect.left, y: e.clientY - rect.top }); + rectRef.current = containerRef.current.getBoundingClientRect(); }, []); + const flushMousePosition = useCallback(() => { + const rect = rectRef.current; + const pointer = pointerRef.current; + if (!rect || !pointer) { + rafRef.current = null; + return; + } + + setMousePosition({ x: pointer.x - rect.left, y: pointer.y - rect.top }); + rafRef.current = null; + }, []); + + const updateMousePosition = useCallback( + (e: MouseEvent) => { + pointerRef.current = { x: e.clientX, y: e.clientY }; + + if (rafRef.current !== null) return; + rafRef.current = window.requestAnimationFrame(flushMousePosition); + }, + [flushMousePosition] + ); + useEffect(() => { const container = containerRef.current; if (!container) return; + updateRect(); + + const onEnter = () => updateRect(); + const onScroll = () => updateRect(); + const onResize = () => updateRect(); + container.addEventListener("mousemove", updateMousePosition); + container.addEventListener("mouseenter", onEnter); + window.addEventListener("resize", onResize); + window.addEventListener("scroll", onScroll, true); + return () => { container.removeEventListener("mousemove", updateMousePosition); + container.removeEventListener("mouseenter", onEnter); + window.removeEventListener("resize", onResize); + window.removeEventListener("scroll", onScroll, true); + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current); + } }; - }, [updateMousePosition]); + }, [updateMousePosition, updateRect]); useEffect(() => { setIsHover(isHovered); From 4b14f4e9261df3ab43810e8ed805ce26e4c61066 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 10:28:18 +0900 Subject: [PATCH 19/34] =?UTF-8?q?chore:=20Next=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=84=A4=EC=A0=95=EC=9D=84=20remotePatterns?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/next.config.ts b/next.config.ts index 60bc211..12b4f73 100644 --- a/next.config.ts +++ b/next.config.ts @@ -10,7 +10,13 @@ const nextConfig: NextConfig = { ]; }, images: { - domains: ["evcsbwqeetfvegvrtbny.supabase.co"], + remotePatterns: [ + { + protocol: "https", + hostname: "evcsbwqeetfvegvrtbny.supabase.co", + pathname: "/**", + }, + ], formats: ["image/avif", "image/webp"], }, }; From fdc054bb4ecfe2e588e54c89761f280ea3c4ed48 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 10:32:23 +0900 Subject: [PATCH 20/34] =?UTF-8?q?fix:=20legacy=20=EC=95=A0=EB=8B=88?= =?UTF-8?q?=EB=A9=94=EC=9D=B4=EC=85=98=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94=20=EA=B2=BD=EA=B3=A0=20=EB=8C=80?= =?UTF-8?q?=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/posts/[id]/pageClient.tsx | 4 ++++ src/app/posts/components/RecentSection.tsx | 5 +++++ src/lib/image.ts | 21 +++++++++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 src/lib/image.ts diff --git a/src/app/posts/[id]/pageClient.tsx b/src/app/posts/[id]/pageClient.tsx index 1e15ca2..b5385c7 100644 --- a/src/app/posts/[id]/pageClient.tsx +++ b/src/app/posts/[id]/pageClient.tsx @@ -4,6 +4,7 @@ import Nav from "@/components/common/Nav"; import QuillCodeRenderer from "@/components/common/QuillCodeRenderer"; import { GlassBox } from "@/components/ui/GlassBox"; import { deletePost, type Post } from "@/lib/api/blog"; +import { isKnownAnimatedSupabaseImage } from "@/lib/image"; import { formatDate } from "@/lib/utils"; import useImageStore from "@/store/useImageStore"; import { AnimatePresence, motion } from "motion/react"; @@ -30,6 +31,7 @@ const buttonVariants = { export default function PostDetailClient({ post }: { post: Post }) { const router = useRouter(); const { curImage, setCurImage } = useImageStore(); + const isAnimatedThumbnail = isKnownAnimatedSupabaseImage(post.thumbnail); const [viewAuth, setViewAuth] = useState(false); const [viewDelete, setViewDelete] = useState(false); @@ -87,6 +89,7 @@ export default function PostDetailClient({ post }: { post: Post }) { priority fetchPriority="high" loading="eager" + unoptimized={isAnimatedThumbnail} placeholder="blur" blurDataURL={post.thumbnail_blur} /> @@ -198,6 +201,7 @@ export default function PostDetailClient({ post }: { post: Post }) { alt="enlarged screenshot" fill className="object-contain" + unoptimized={isKnownAnimatedSupabaseImage(curImage)} /> diff --git a/src/app/posts/components/RecentSection.tsx b/src/app/posts/components/RecentSection.tsx index c95e404..5c15ed6 100644 --- a/src/app/posts/components/RecentSection.tsx +++ b/src/app/posts/components/RecentSection.tsx @@ -2,6 +2,7 @@ import { GlassBox } from "@/components/ui/GlassBox"; import { type PostsResponse } from "@/lib/api/blog"; +import { isKnownAnimatedSupabaseImage } from "@/lib/image"; import { cn, formatDate } from "@/lib/utils"; import Image from "next/image"; import Link from "next/link"; @@ -27,6 +28,9 @@ const RecentSection = ({ data }: { data: PostsResponse }) => { {data.posts.map((post, index) => { const isLcpCandidate = index === 0; const useBlurPlaceholder = Boolean(post.thumbnail_blur); + const isAnimatedLegacyImage = isKnownAnimatedSupabaseImage( + post.thumbnail + ); return ( { priority={isLcpCandidate} fetchPriority={isLcpCandidate ? "high" : "auto"} loading={isLcpCandidate ? "eager" : "lazy"} + unoptimized={isAnimatedLegacyImage} placeholder={useBlurPlaceholder ? "blur" : "empty"} blurDataURL={ useBlurPlaceholder ? post.thumbnail_blur : undefined diff --git a/src/lib/image.ts b/src/lib/image.ts new file mode 100644 index 0000000..9420b3a --- /dev/null +++ b/src/lib/image.ts @@ -0,0 +1,21 @@ +const ANIMATED_SUPABASE_IMAGE_IDS = new Set([ + "1747995748559", + "1747983130757", + "1747987828131", + "1747984646422", +]); + +export function isKnownAnimatedSupabaseImage(src: string) { + if (!src.startsWith("http")) return false; + + try { + const url = new URL(src); + if (url.hostname !== "evcsbwqeetfvegvrtbny.supabase.co") return false; + const fileId = url.pathname.split("/").pop(); + if (!fileId) return false; + + return ANIMATED_SUPABASE_IMAGE_IDS.has(fileId); + } catch { + return false; + } +} From 5c463a9f728ae364badae627f50e8cc6eb3db171 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 10:44:08 +0900 Subject: [PATCH 21/34] =?UTF-8?q?chore:=20Next=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20quality=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/next.config.ts b/next.config.ts index 12b4f73..76ccc08 100644 --- a/next.config.ts +++ b/next.config.ts @@ -17,6 +17,7 @@ const nextConfig: NextConfig = { pathname: "/**", }, ], + qualities: [45, 50, 75], formats: ["image/avif", "image/webp"], }, }; From 54649c64ae1e5b798ddea179917bd34d940a16d5 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 10:56:50 +0900 Subject: [PATCH 22/34] =?UTF-8?q?ci:=20Lighthouse=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=EC=97=90=20Supabase=20env=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/lighthouse.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 17966dc..6979337 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -9,6 +9,9 @@ on: jobs: lighthouse: runs-on: ubuntu-latest + env: + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} steps: - name: Checkout From fd6efb57773e3a13df3b2acb84f66a93a05b791b Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 11:04:17 +0900 Subject: [PATCH 23/34] =?UTF-8?q?ci:=20Lighthouse=20=EA=B2=B0=EA=B3=BC=20P?= =?UTF-8?q?R=20=EC=BD=94=EB=A9=98=ED=8A=B8=20=EC=9E=90=EB=8F=99=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/lighthouse.yml | 120 +++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 6979337..abc22ac 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -6,6 +6,10 @@ on: - main workflow_dispatch: +permissions: + contents: read + pull-requests: write + jobs: lighthouse: runs-on: ubuntu-latest @@ -30,4 +34,120 @@ jobs: run: npm run build - name: Run Lighthouse CI + id: lhci + continue-on-error: true run: npx @lhci/cli@0.13.x autorun --config=.lighthouserc.json + + - name: Prepare Lighthouse summary + if: github.event_name == 'pull_request' + run: | + node <<'EOF' + const fs = require("fs"); + const path = require("path"); + + const reportsDir = path.join(process.cwd(), ".lighthouseci"); + const outPath = path.join(process.cwd(), ".lighthouse-summary.md"); + + let lines = []; + lines.push(""); + lines.push("## Lighthouse CI 결과"); + lines.push(""); + lines.push(`- 실행 상태: **${process.env.LHCI_OUTCOME || "unknown"}**`); + lines.push("- 대상: `/posts` (3회 측정)"); + lines.push(""); + + if (!fs.existsSync(reportsDir)) { + lines.push("리포트 디렉터리를 찾을 수 없습니다."); + fs.writeFileSync(outPath, lines.join("\n")); + process.exit(0); + } + + const reportFiles = fs + .readdirSync(reportsDir) + .filter((name) => name.endsWith(".report.json")) + .map((name) => path.join(reportsDir, name)); + + if (reportFiles.length === 0) { + lines.push("생성된 Lighthouse report JSON이 없습니다."); + fs.writeFileSync(outPath, lines.join("\n")); + process.exit(0); + } + + const rows = []; + for (const reportFile of reportFiles.slice(0, 3)) { + try { + const report = JSON.parse(fs.readFileSync(reportFile, "utf8")); + const categories = report.categories || {}; + const formatScore = (value) => + typeof value === "number" ? Math.round(value * 100) : "-"; + + rows.push({ + url: report.finalDisplayedUrl || report.finalUrl || "-", + performance: formatScore(categories.performance?.score), + accessibility: formatScore(categories.accessibility?.score), + bestPractices: formatScore(categories["best-practices"]?.score), + seo: formatScore(categories.seo?.score), + }); + } catch { + // ignore broken report file and continue + } + } + + if (rows.length > 0) { + lines.push("| URL | Perf | A11y | BP | SEO |"); + lines.push("|---|---:|---:|---:|---:|"); + for (const row of rows) { + lines.push( + `| ${row.url} | ${row.performance} | ${row.accessibility} | ${row.bestPractices} | ${row.seo} |` + ); + } + } else { + lines.push("점수 요약을 만들 report를 파싱하지 못했습니다."); + } + + fs.writeFileSync(outPath, lines.join("\n")); + EOF + env: + LHCI_OUTCOME: ${{ steps.lhci.outcome }} + + - name: Comment PR with Lighthouse summary + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require("fs"); + const marker = ""; + const body = fs.readFileSync(".lighthouse-summary.md", "utf8"); + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + + const existing = comments.find((c) => c.body && c.body.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } + + - name: Fail when Lighthouse assertions fail + if: steps.lhci.outcome == 'failure' + run: | + echo "Lighthouse CI assertions failed." + exit 1 From 2ff1b11c70b71984708a02a1342be9c308e6d58a Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 14:07:05 +0900 Subject: [PATCH 24/34] =?UTF-8?q?ci:=20Lighthouse=20=EB=A6=AC=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=EC=8B=B1=20=ED=8C=A8=ED=84=B4=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/lighthouse.yml | 34 ++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index abc22ac..7a3bdf8 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -62,47 +62,51 @@ jobs: process.exit(0); } - const reportFiles = fs + const jsonFiles = fs .readdirSync(reportsDir) - .filter((name) => name.endsWith(".report.json")) + .filter((name) => name.endsWith(".json")) .map((name) => path.join(reportsDir, name)); - if (reportFiles.length === 0) { - lines.push("생성된 Lighthouse report JSON이 없습니다."); - fs.writeFileSync(outPath, lines.join("\n")); - process.exit(0); - } - const rows = []; - for (const reportFile of reportFiles.slice(0, 3)) { + for (const jsonFile of jsonFiles) { try { - const report = JSON.parse(fs.readFileSync(reportFile, "utf8")); + const report = JSON.parse(fs.readFileSync(jsonFile, "utf8")); + if (!report || typeof report !== "object" || !report.categories) { + continue; + } + const categories = report.categories || {}; const formatScore = (value) => typeof value === "number" ? Math.round(value * 100) : "-"; rows.push({ - url: report.finalDisplayedUrl || report.finalUrl || "-", + url: report.finalDisplayedUrl || report.finalUrl || report.requestedUrl || "-", performance: formatScore(categories.performance?.score), accessibility: formatScore(categories.accessibility?.score), bestPractices: formatScore(categories["best-practices"]?.score), seo: formatScore(categories.seo?.score), }); } catch { - // ignore broken report file and continue + // ignore non-report JSON files } } + if (rows.length === 0) { + lines.push("파싱 가능한 Lighthouse report JSON이 없습니다."); + lines.push(""); + lines.push(`- 감지된 JSON 파일 수: ${jsonFiles.length}`); + fs.writeFileSync(outPath, lines.join("\n")); + process.exit(0); + } + if (rows.length > 0) { lines.push("| URL | Perf | A11y | BP | SEO |"); lines.push("|---|---:|---:|---:|---:|"); - for (const row of rows) { + for (const row of rows.slice(0, 3)) { lines.push( `| ${row.url} | ${row.performance} | ${row.accessibility} | ${row.bestPractices} | ${row.seo} |` ); } - } else { - lines.push("점수 요약을 만들 report를 파싱하지 못했습니다."); } fs.writeFileSync(outPath, lines.join("\n")); From c9ee1c92b39d58553266f2419078f059a7796b9c Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 14:16:27 +0900 Subject: [PATCH 25/34] =?UTF-8?q?ci:=20Lighthouse=20=EC=B8=A1=EC=A0=95=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=ED=99=95=EC=9E=A5=20=EB=B0=8F=20PR=20?= =?UTF-8?q?=EC=BD=94=EB=A9=98=ED=8A=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/lighthouse.yml | 4 ++-- .lighthouserc.json | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 7a3bdf8..5df44ed 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -53,7 +53,7 @@ jobs: lines.push("## Lighthouse CI 결과"); lines.push(""); lines.push(`- 실행 상태: **${process.env.LHCI_OUTCOME || "unknown"}**`); - lines.push("- 대상: `/posts` (3회 측정)"); + lines.push("- 대상: `/posts`, `/posts/91`, `/projects/1` (각 3회 측정)"); lines.push(""); if (!fs.existsSync(reportsDir)) { @@ -102,7 +102,7 @@ jobs: if (rows.length > 0) { lines.push("| URL | Perf | A11y | BP | SEO |"); lines.push("|---|---:|---:|---:|---:|"); - for (const row of rows.slice(0, 3)) { + for (const row of rows) { lines.push( `| ${row.url} | ${row.performance} | ${row.accessibility} | ${row.bestPractices} | ${row.seo} |` ); diff --git a/.lighthouserc.json b/.lighthouserc.json index 5903e9d..9e39017 100644 --- a/.lighthouserc.json +++ b/.lighthouserc.json @@ -3,7 +3,11 @@ "collect": { "startServerCommand": "npm run start -- --port=3000", "startServerReadyPattern": "Ready", - "url": ["http://localhost:3000/posts"], + "url": [ + "http://localhost:3000/posts", + "http://localhost:3000/posts/91", + "http://localhost:3000/projects/1" + ], "numberOfRuns": 3 }, "assert": { From d528ac95e637d88e79d2021d1bf50f8008705940 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 14:20:54 +0900 Subject: [PATCH 26/34] =?UTF-8?q?ci:=20Lighthouse=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EC=97=90=20API=20base=20URL=20=EC=A3=BC?= =?UTF-8?q?=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/lighthouse.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 5df44ed..01e0c7d 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -14,6 +14,7 @@ jobs: lighthouse: runs-on: ubuntu-latest env: + NEXT_PUBLIC_API_BASE_URL: https://api.yonghun.me NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} From ba63a5e0611767e6d1245d204f834481a0c54a10 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 14:35:16 +0900 Subject: [PATCH 27/34] =?UTF-8?q?ci:=20Lighthouse=20URL=EB=B3=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EC=B8=A1=EC=A0=95=20=EB=B0=8F=20=EC=BD=94?= =?UTF-8?q?=EB=A9=98=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/lighthouse.yml | 60 +++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 01e0c7d..4c8dcc4 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -13,6 +13,16 @@ permissions: jobs: lighthouse: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - path: /posts + path_key: posts + - path: /posts/91 + path_key: posts-91 + - path: /projects/1 + path_key: projects-1 env: NEXT_PUBLIC_API_BASE_URL: https://api.yonghun.me NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} @@ -34,10 +44,21 @@ jobs: - name: Build app run: npm run build + - name: Prepare LHCI runtime config + run: | + node <<'EOF' + const fs = require("fs"); + const config = JSON.parse(fs.readFileSync(".lighthouserc.json", "utf8")); + config.ci.collect.url = [`http://localhost:3000${process.env.LH_PATH}`]; + fs.writeFileSync(".lighthouserc.runtime.json", JSON.stringify(config, null, 2)); + EOF + env: + LH_PATH: ${{ matrix.path }} + - name: Run Lighthouse CI id: lhci continue-on-error: true - run: npx @lhci/cli@0.13.x autorun --config=.lighthouserc.json + run: npx @lhci/cli@0.13.x autorun --config=.lighthouserc.runtime.json - name: Prepare Lighthouse summary if: github.event_name == 'pull_request' @@ -50,11 +71,11 @@ jobs: const outPath = path.join(process.cwd(), ".lighthouse-summary.md"); let lines = []; - lines.push(""); + lines.push(``); lines.push("## Lighthouse CI 결과"); lines.push(""); lines.push(`- 실행 상태: **${process.env.LHCI_OUTCOME || "unknown"}**`); - lines.push("- 대상: `/posts`, `/posts/91`, `/projects/1` (각 3회 측정)"); + lines.push(`- 대상: \`${process.env.LH_PATH}\` (3회 측정)`); lines.push(""); if (!fs.existsSync(reportsDir)) { @@ -100,20 +121,31 @@ jobs: process.exit(0); } - if (rows.length > 0) { - lines.push("| URL | Perf | A11y | BP | SEO |"); - lines.push("|---|---:|---:|---:|---:|"); - for (const row of rows) { - lines.push( - `| ${row.url} | ${row.performance} | ${row.accessibility} | ${row.bestPractices} | ${row.seo} |` - ); - } - } + const toNumber = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null); + const median = (arr) => { + const clean = arr.filter((v) => v !== null).sort((a, b) => a - b); + if (clean.length === 0) return "-"; + return clean[Math.floor(clean.length / 2)]; + }; + + lines.push(`- 수집된 리포트 수: ${rows.length}`); + lines.push(""); + lines.push("| URL | Perf(median) | A11y(median) | BP(median) | SEO(median) |"); + lines.push("|---|---:|---:|---:|---:|"); + + const url = rows[0]?.url || `http://localhost:3000${process.env.LH_PATH}`; + const perf = median(rows.map((r) => toNumber(r.performance))); + const a11y = median(rows.map((r) => toNumber(r.accessibility))); + const bp = median(rows.map((r) => toNumber(r.bestPractices))); + const seo = median(rows.map((r) => toNumber(r.seo))); + lines.push(`| ${url} | ${perf} | ${a11y} | ${bp} | ${seo} |`); fs.writeFileSync(outPath, lines.join("\n")); EOF env: LHCI_OUTCOME: ${{ steps.lhci.outcome }} + LH_PATH: ${{ matrix.path }} + LH_PATH_KEY: ${{ matrix.path_key }} - name: Comment PR with Lighthouse summary if: github.event_name == 'pull_request' @@ -121,7 +153,7 @@ jobs: with: script: | const fs = require("fs"); - const marker = ""; + const marker = ``; const body = fs.readFileSync(".lighthouse-summary.md", "utf8"); const { owner, repo } = context.repo; const issue_number = context.issue.number; @@ -150,6 +182,8 @@ jobs: body, }); } + env: + LH_PATH_KEY: ${{ matrix.path_key }} - name: Fail when Lighthouse assertions fail if: steps.lhci.outcome == 'failure' From d362f366e9f7031c8e52a2bd687bc20802e8102e Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 14:40:02 +0900 Subject: [PATCH 28/34] =?UTF-8?q?ci:=20Lighthouse=20posts=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=B8=A1=EC=A0=95=20URL=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/lighthouse.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 4c8dcc4..45dd449 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -19,8 +19,8 @@ jobs: include: - path: /posts path_key: posts - - path: /posts/91 - path_key: posts-91 + - path: /posts/100 + path_key: posts-100 - path: /projects/1 path_key: projects-1 env: From 42831e00b6f662f550bfafb204639fe220ef78b0 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 15:01:16 +0900 Subject: [PATCH 29/34] =?UTF-8?q?refactor:=20Lighthouse=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=ED=86=B5=EC=9D=BC=20=EB=B0=8F=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EB=B8=94=EB=A1=9D=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EA=B2=BD?= =?UTF-8?q?=EB=9F=89=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .lighthouserc.json | 14 ++++++++- src/app/layout.tsx | 3 +- src/components/common/QuillCodeRenderer.tsx | 8 +++++- src/components/provider/InitHLJS.tsx | 32 --------------------- src/components/ui/Editor.tsx | 2 +- src/lib/highlight/hljs.ts | 16 +++++++++++ 6 files changed, 38 insertions(+), 37 deletions(-) delete mode 100644 src/components/provider/InitHLJS.tsx create mode 100644 src/lib/highlight/hljs.ts diff --git a/.lighthouserc.json b/.lighthouserc.json index 9e39017..2525bf2 100644 --- a/.lighthouserc.json +++ b/.lighthouserc.json @@ -3,9 +3,21 @@ "collect": { "startServerCommand": "npm run start -- --port=3000", "startServerReadyPattern": "Ready", + "settings": { + "preset": "desktop", + "formFactor": "desktop", + "screenEmulation": { + "mobile": false, + "width": 1350, + "height": 940, + "deviceScaleFactor": 1, + "disabled": false + }, + "throttlingMethod": "devtools" + }, "url": [ "http://localhost:3000/posts", - "http://localhost:3000/posts/91", + "http://localhost:3000/posts/100", "http://localhost:3000/projects/1" ], "numberOfRuns": 3 diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 400e40d..4b698e6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,10 +1,10 @@ import GoogleAnalytics from "@/components/provider/GoogleAnalytics"; -import InitHLJS from "@/components/provider/InitHLJS"; import PathListener from "@/components/provider/PathListener"; import NoiseBackground from "@/components/ui/NoiseBackground"; import { Spotlight } from "@/components/ui/Spotlight"; import type { Metadata } from "next"; import { Geist, Geist_Mono, Nanum_Pen_Script } from "next/font/google"; +import "highlight.js/styles/github-dark.css"; import "./globals.css"; const geistSans = Geist({ @@ -63,7 +63,6 @@ export default function RootLayout({ {process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS ? ( ) : null} - diff --git a/src/components/common/QuillCodeRenderer.tsx b/src/components/common/QuillCodeRenderer.tsx index 60bdedf..93d8aa6 100644 --- a/src/components/common/QuillCodeRenderer.tsx +++ b/src/components/common/QuillCodeRenderer.tsx @@ -1,6 +1,6 @@ "use client"; -import hljs from "highlight.js"; +import hljs from "@/lib/highlight/hljs"; import { useEffect, useRef } from "react"; interface QuillCodeRendererProps { @@ -41,6 +41,12 @@ export default function QuillCodeRenderer({ div.replaceWith(pre); }); + prevEl.querySelectorAll("img").forEach((img) => { + img.setAttribute("loading", "lazy"); + img.setAttribute("decoding", "async"); + img.setAttribute("fetchpriority", "low"); + }); + containerRef.current.innerHTML = prevEl.innerHTML; containerRef.current.querySelectorAll("pre code").forEach((block) => { diff --git a/src/components/provider/InitHLJS.tsx b/src/components/provider/InitHLJS.tsx deleted file mode 100644 index c4af86c..0000000 --- a/src/components/provider/InitHLJS.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -"use client"; - -import { useEffect } from "react"; -import hljs from "highlight.js/lib/core"; - -import js from "highlight.js/lib/languages/javascript"; -import ts from "highlight.js/lib/languages/typescript"; -import rust from "highlight.js/lib/languages/rust"; -import html from "highlight.js/lib/languages/xml"; -import css from "highlight.js/lib/languages/css"; -import json from "highlight.js/lib/languages/json"; - -import "highlight.js/styles/github-dark.css"; - -export default function InitHLJS() { - useEffect(() => { - hljs.registerLanguage("javascript", js); - hljs.registerLanguage("typescript", ts); - hljs.registerLanguage("rust", rust); - hljs.registerLanguage("html", html); - hljs.registerLanguage("css", css); - hljs.registerLanguage("json", json); - - if (typeof window !== "undefined") { - (window as any).hljs = hljs; - } - }, []); - - return null; -} diff --git a/src/components/ui/Editor.tsx b/src/components/ui/Editor.tsx index f54549c..57171d7 100644 --- a/src/components/ui/Editor.tsx +++ b/src/components/ui/Editor.tsx @@ -3,8 +3,8 @@ "use client"; import ImageUploader from "@/lib/quill/ImageUploader"; +import hljs from "@/lib/highlight/hljs"; import { supabase } from "@/lib/supabase/supabasClient"; -import hljs from "highlight.js"; import Quill from "quill"; import { useEffect, useRef, useState } from "react"; diff --git a/src/lib/highlight/hljs.ts b/src/lib/highlight/hljs.ts new file mode 100644 index 0000000..3ef173e --- /dev/null +++ b/src/lib/highlight/hljs.ts @@ -0,0 +1,16 @@ +import hljs from "highlight.js/lib/core"; +import css from "highlight.js/lib/languages/css"; +import html from "highlight.js/lib/languages/xml"; +import javascript from "highlight.js/lib/languages/javascript"; +import json from "highlight.js/lib/languages/json"; +import rust from "highlight.js/lib/languages/rust"; +import typescript from "highlight.js/lib/languages/typescript"; + +hljs.registerLanguage("javascript", javascript); +hljs.registerLanguage("typescript", typescript); +hljs.registerLanguage("rust", rust); +hljs.registerLanguage("html", html); +hljs.registerLanguage("css", css); +hljs.registerLanguage("json", json); + +export default hljs; From 66f3f4ac58251f962c39df9e1af537ea249b01b9 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 15:34:09 +0900 Subject: [PATCH 30/34] =?UTF-8?q?ci:=20Lighthouse=20URL=EB=B3=84=20?= =?UTF-8?q?=EC=84=B1=EB=8A=A5=20=EA=B8=B0=EC=A4=80=20=EB=B0=8F=20=EC=A6=9D?= =?UTF-8?q?=EA=B0=90=20=EC=BD=94=EB=A9=98=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/lighthouse.yml | 36 +++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 45dd449..8912ec8 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -19,10 +19,16 @@ jobs: include: - path: /posts path_key: posts + perf_level: error + perf_min: "0.7" - path: /posts/100 path_key: posts-100 + perf_level: warn + perf_min: "0.5" - path: /projects/1 path_key: projects-1 + perf_level: error + perf_min: "0.8" env: NEXT_PUBLIC_API_BASE_URL: https://api.yonghun.me NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} @@ -50,10 +56,16 @@ jobs: const fs = require("fs"); const config = JSON.parse(fs.readFileSync(".lighthouserc.json", "utf8")); config.ci.collect.url = [`http://localhost:3000${process.env.LH_PATH}`]; + config.ci.assert.assertions["categories:performance"] = [ + process.env.LH_PERF_LEVEL, + { minScore: Number(process.env.LH_PERF_MIN) }, + ]; fs.writeFileSync(".lighthouserc.runtime.json", JSON.stringify(config, null, 2)); EOF env: LH_PATH: ${{ matrix.path }} + LH_PERF_LEVEL: ${{ matrix.perf_level }} + LH_PERF_MIN: ${{ matrix.perf_min }} - name: Run Lighthouse CI id: lhci @@ -76,6 +88,7 @@ jobs: lines.push(""); lines.push(`- 실행 상태: **${process.env.LHCI_OUTCOME || "unknown"}**`); lines.push(`- 대상: \`${process.env.LH_PATH}\` (3회 측정)`); + lines.push(`- 성능 기준: **${process.env.LH_PERF_LEVEL} >= ${Math.round(Number(process.env.LH_PERF_MIN) * 100)}**`); lines.push(""); if (!fs.existsSync(reportsDir)) { @@ -146,6 +159,8 @@ jobs: LHCI_OUTCOME: ${{ steps.lhci.outcome }} LH_PATH: ${{ matrix.path }} LH_PATH_KEY: ${{ matrix.path_key }} + LH_PERF_LEVEL: ${{ matrix.perf_level }} + LH_PERF_MIN: ${{ matrix.perf_min }} - name: Comment PR with Lighthouse summary if: github.event_name == 'pull_request' @@ -154,9 +169,10 @@ jobs: script: | const fs = require("fs"); const marker = ``; - const body = fs.readFileSync(".lighthouse-summary.md", "utf8"); + const baseBody = fs.readFileSync(".lighthouse-summary.md", "utf8"); const { owner, repo } = context.repo; const issue_number = context.issue.number; + const perfPattern = /\|\s*[^|]+\s*\|\s*(\d+)\s*\|/; const comments = await github.paginate(github.rest.issues.listComments, { owner, @@ -166,6 +182,24 @@ jobs: }); const existing = comments.find((c) => c.body && c.body.includes(marker)); + const currentPerfMatch = baseBody.match(perfPattern); + const currentPerf = currentPerfMatch ? Number(currentPerfMatch[1]) : null; + + let deltaLine = "- 이전 대비 Perf(median): 첫 측정"; + if (existing) { + const prevPerfMatch = existing.body.match(perfPattern); + const prevPerf = prevPerfMatch ? Number(prevPerfMatch[1]) : null; + + if (currentPerf !== null && prevPerf !== null) { + const delta = currentPerf - prevPerf; + const sign = delta > 0 ? "+" : ""; + deltaLine = `- 이전 대비 Perf(median): ${sign}${delta}p (이전 ${prevPerf} -> 현재 ${currentPerf})`; + } else { + deltaLine = "- 이전 대비 Perf(median): 비교 불가"; + } + } + + const body = `${baseBody}\n${deltaLine}`; if (existing) { await github.rest.issues.updateComment({ From 135e0ceaf4967b7f94722c1dc24a5fc8055f3643 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Tue, 17 Feb 2026 15:50:21 +0900 Subject: [PATCH 31/34] =?UTF-8?q?fix:=20posts=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A0=91=EA=B7=BC=EC=84=B1=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/posts/[id]/pageClient.tsx | 282 +++++++++++++++----- src/app/posts/components/AuthForm.tsx | 24 +- src/components/common/QuillCodeRenderer.tsx | 3 + 3 files changed, 237 insertions(+), 72 deletions(-) diff --git a/src/app/posts/[id]/pageClient.tsx b/src/app/posts/[id]/pageClient.tsx index b5385c7..cf0da08 100644 --- a/src/app/posts/[id]/pageClient.tsx +++ b/src/app/posts/[id]/pageClient.tsx @@ -10,7 +10,7 @@ import useImageStore from "@/store/useImageStore"; import { AnimatePresence, motion } from "motion/react"; import Image from "next/image"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { BsTrash, BsWrenchAdjustable } from "react-icons/bs"; import AuthForm from "../components/AuthForm"; @@ -28,10 +28,51 @@ const buttonVariants = { tap: { scale: 0.97 }, }; +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'; + +const trapTabKey = (event: ReactKeyboardEvent) => { + if (event.key !== "Tab") return; + + const focusables = Array.from( + event.currentTarget.querySelectorAll(FOCUSABLE_SELECTOR) + ); + + if (focusables.length === 0) { + event.preventDefault(); + return; + } + + const firstEl = focusables[0]; + const lastEl = focusables[focusables.length - 1]; + const activeEl = document.activeElement as HTMLElement | null; + + if (event.shiftKey && activeEl === firstEl) { + event.preventDefault(); + lastEl.focus(); + return; + } + + if (!event.shiftKey && activeEl === lastEl) { + event.preventDefault(); + firstEl.focus(); + } +}; + +const focusDialog = (dialog: HTMLDivElement | null) => { + if (!dialog) return; + const firstFocusable = dialog.querySelector(FOCUSABLE_SELECTOR); + (firstFocusable ?? dialog).focus(); +}; + export default function PostDetailClient({ post }: { post: Post }) { const router = useRouter(); const { curImage, setCurImage } = useImageStore(); const isAnimatedThumbnail = isKnownAnimatedSupabaseImage(post.thumbnail); + const authDialogRef = useRef(null); + const deleteDialogRef = useRef(null); + const imageDialogRef = useRef(null); + const lastFocusedElementRef = useRef(null); const [viewAuth, setViewAuth] = useState(false); const [viewDelete, setViewDelete] = useState(false); @@ -46,19 +87,65 @@ export default function PostDetailClient({ post }: { post: Post }) { const target = event.currentTarget as HTMLImageElement; setCurImage(target.src); }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + const target = event.currentTarget as HTMLImageElement; + setCurImage(target.src); + }; nodeList.forEach((imgEl) => { + if (imgEl.closest("a, button")) return; + imgEl.setAttribute("tabindex", "0"); + imgEl.setAttribute("role", "button"); + if (!imgEl.getAttribute("aria-label")) { + imgEl.setAttribute("aria-label", "이미지 확대 보기"); + } + imgEl.classList.add("cursor-zoom-in"); imgEl.addEventListener("click", handleClick); + imgEl.addEventListener("keydown", handleKeyDown); }); return () => { setCurImage(null); nodeList.forEach((imgEl) => { imgEl.removeEventListener("click", handleClick); + imgEl.removeEventListener("keydown", handleKeyDown); }); }; }, [setCurImage]); + useEffect(() => { + const isDialogOpen = viewAuth || viewDelete || Boolean(curImage); + + if (isDialogOpen) { + if (!lastFocusedElementRef.current) { + lastFocusedElementRef.current = document.activeElement as HTMLElement | null; + } + + if (viewAuth) { + focusDialog(authDialogRef.current); + return; + } + + if (viewDelete) { + focusDialog(deleteDialogRef.current); + return; + } + + if (curImage) { + focusDialog(imageDialogRef.current); + } + + return; + } + + if (lastFocusedElementRef.current) { + lastFocusedElementRef.current.focus(); + lastFocusedElementRef.current = null; + } + }, [viewAuth, viewDelete, curImage]); + const handleDelete = async () => { if (isDeleting) return; setMessage(""); @@ -79,70 +166,77 @@ export default function PostDetailClient({ post }: { post: Post }) { return (