diff --git a/README.md b/README.md index a4539e9..af64c7c 100644 --- a/README.md +++ b/README.md @@ -22,4 +22,6 @@ ### 예시 ``` feat(#123) :: 로그인 기능 추가 -``` \ No newline at end of file +``` + + diff --git a/docs/hongin.md b/docs/hongin.md new file mode 100644 index 0000000..10e4e40 --- /dev/null +++ b/docs/hongin.md @@ -0,0 +1,15 @@ +### 작업 내역 (커밋 컨벤션 기반) + +- feat :: middleware를 활용한 Protected Route 구현 +- feat :: 로그인 미인증 사용자 접근 시 리다이렉트 처리 추가 + +- feat :: react-hot-toast를 활용한 사용자 알림(Toast) UI 적용 +- refactor :: alert 기반 UX 제거 및 Toast 기반으로 개선 + +- feat :: 로그인 성공 후 redirect 파라미터 기반 경로 복귀 기능 구현 + +- refactor :: 하드코딩된 URL 제거 및 env 환경 변수로 전환 +- chore :: .env.local 파일을 활용한 BASE_URL 설정 + +- feat :: Tailwind CSS 기반 로딩 스피너 UI 구현 +- refactor :: 기존 텍스트 로딩 UI를 시각적 로딩 UI로 개선 \ No newline at end of file diff --git "a/docs/imjemin\354\210\230\354\240\225.md" "b/docs/imjemin\354\210\230\354\240\225.md" new file mode 100644 index 0000000..2b23138 --- /dev/null +++ "b/docs/imjemin\354\210\230\354\240\225.md" @@ -0,0 +1,69 @@ +# 변경 사항 정리 + +## 수정된 파일 목록 + +- `src/components/layout/Header.tsx` +- `src/components/layout/Listbox.tsx` +- `src/components/ui/list.tsx` +- `src/app/(main)/[datas]/[id]/page.tsx` +- `src/util/makeDocument.tsx` + +--- + +## 파일별 상세 변경 내용 + +### src/components/layout/Header.tsx + +**수정** +- 데스크탑 헤더의 인증 로딩 중 표시되던 `로딩중...` 텍스트를 회전 스피너로 교체 +- 모바일 메뉴의 인증 로딩 중 표시되던 `로딩중...` 텍스트를 회전 스피너로 교체 +- 모바일 드롭다운 메뉴의 표시 방식을 `hidden` / `flex` 즉시 전환에서 `max-height` + `opacity` 기반 슬라이드 애니메이션(300ms)으로 변경 + +--- + +### src/components/layout/Listbox.tsx + +**추가** +- `isLoading` prop 추가 +- 로딩 중일 때 게시글 건수 영역을 스켈레톤(회색 블록)으로 표시 +- 로딩 중일 때 데스크탑 테이블 행 6개를 pulse 스켈레톤으로 표시 +- 로딩 중일 때 모바일 목록 행 6개를 pulse 스켈레톤으로 표시 + +**수정** +- 테이블 헤더(번호/제목/작성자/등록일/조회)를 로딩 상태와 무관하게 항상 렌더링하도록 구조 변경 + +**삭제** +- 더 이상 사용되지 않는 `Loading` 컴포넌트 import 제거 +- 도달 불가능한 `` fallback 분기 제거 + +--- + +### src/components/ui/list.tsx + +**추가** +- `isLoading` 상태 추가 (초기값 `true`) +- Firebase 데이터 fetch 완료 후 `finally` 블록에서 `isLoading`을 `false`로 전환 +- `Listbox`에 `isLoading` prop 전달 + +--- + +### src/app/(main)/[datas]/[id]/page.tsx + +**추가** +- `imageLoaded` 상태 추가 +- 활동 상세 이미지에 `loading="lazy"` 속성 추가 +- 이미지 로드 완료 시 `onLoad`로 `imageLoaded`를 `true`로 전환 +- 이미지 로드 전후로 `opacity-0` -> `opacity-100` fade-in 전환 효과(500ms) 추가 + +**수정** +- 이미지 컨테이너 배경색 `bg-gray-50` -> `bg-gray-100` 변경 (로딩 중 플레이스홀더 시각화) + +--- + +### src/util/makeDocument.tsx + +**추가** +- 게시물 본문 인라인 이미지(`next/image`)에 `loading="lazy"` 속성 추가 + +**수정** +- 인라인 주석(`{/* 원하는 높이 설정 */}`) 제거 diff --git "a/docs/\353\266\204\354\204\235.md" "b/docs/\353\266\204\354\204\235.md" new file mode 100644 index 0000000..e69de29 diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..b420c79 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +export function middleware(request: NextRequest) { + const token = request.cookies.get("token")?.value; + + const { pathname } = request.nextUrl; + + const protectedPaths = ["/write"]; + + const isProtected = protectedPaths.some((path) => + pathname.startsWith(path) + ); + + if (isProtected && !token) { + + const loginUrl = new URL("/login/success", request.url); + + loginUrl.searchParams.set("redirect", pathname); + + return NextResponse.redirect(loginUrl); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/write/:path*"], +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index af38eef..2fe63bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "nodemailer": "^7.0.3", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hot-toast": "^2.6.0", "zustand": "^5.0.5" }, "devDependencies": { @@ -13346,7 +13347,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -15015,6 +15015,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -17661,6 +17670,23 @@ "react": "^19.1.0" } }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index 0586b0e..e66ea98 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "nodemailer": "^7.0.3", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hot-toast": "^2.6.0", "zustand": "^5.0.5" }, "devDependencies": { diff --git a/src/app/(main)/[datas]/[id]/page.tsx b/src/app/(main)/[datas]/[id]/page.tsx index e710063..cafd6e5 100644 --- a/src/app/(main)/[datas]/[id]/page.tsx +++ b/src/app/(main)/[datas]/[id]/page.tsx @@ -20,6 +20,7 @@ type CommentType = { export default function DetailPage() { const [commentText, setCommentText] = useState(''); const [role, setRole] = useState(null); + const [imageLoaded, setImageLoaded] = useState(false); // eslint-disable-next-line @typescript-eslint/no-explicit-any const [item, setItem] = useState(null); @@ -181,9 +182,15 @@ export default function DetailPage() {
{item.image && (
-
+
{/* eslint-disable-next-line @next/next/no-img-element */} - 이미지 + 이미지 setImageLoaded(true)} + className={`h-full w-full object-cover transition-opacity duration-500 ${imageLoaded ? "opacity-100" : "opacity-0"}`} + />
)} diff --git a/src/app/(main)/signupform/page.tsx b/src/app/(main)/signupform/page.tsx index f85f563..9c9bc13 100644 --- a/src/app/(main)/signupform/page.tsx +++ b/src/app/(main)/signupform/page.tsx @@ -77,8 +77,6 @@ export default function SignupForm() { let message = error?.message || "회원가입 중 오류가 발생했습니다."; if (error?.code === "auth/email-already-in-use") { message = "이미 가입된 이메일입니다."; - alert(message); - router.push("/login"); return; } else if (error?.code === "auth/weak-password") { message = "비밀번호가 너무 약합니다. 6자 이상으로 설정해주세요."; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a837a16..65081bf 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,10 @@ import "./globals.css"; import FirebaseAnalytics from "@/components/FirebaseAnalytics"; import { AuthProvider } from "@/components/providers/AuthProvider"; +import { Toaster } from "react-hot-toast"; + +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL!; + const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], @@ -15,11 +19,10 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); -// app/layout.tsx export const metadata: Metadata = { title: { default: "NDIE", - template: "%s | NDIE", // 각 페이지 제목 뒤에 붙는 기본 이름 + template: "%s | NDIE", }, description: "사단법인 디지털과포용성네트워크(NDIE)는 디지털 전환 속 소외된 이들을 위한 포용적 디지털 사회 구현을 목표로 연구, 교육, 정책 제안, 네트워킹 등 다양한 활동을 수행합니다.", @@ -35,18 +38,18 @@ export const metadata: Metadata = { authors: [ { name: "사단법인 디지털과포용성네트워크", - url: "https://ndie-fe-985895714915.asia-northeast1.run.app/", // 실제 도메인으로 바꿔주세요 + url: baseUrl, }, ], openGraph: { title: "디지털과포용성네트워크", description: "디지털 포용성과 다양성을 추구하는 비영리 법인 NDIE. 디지털 접근성 향상과 인간 중심의 기술 환경 조성을 위한 연구와 활동을 수행합니다.", - url: "https://ndie-fe-985895714915.asia-northeast1.run.app/", // 실제 도메인으로 바꿔주세요 + url: baseUrl, siteName: "디지털과포용성네트워크", images: [ { - url: "https://storage.googleapis.com/focus-pathfinder/e675f822-6cdb-4e11-9d02-d94b102cb7ce", // 사이트 og 이미지 URL + url: "https://storage.googleapis.com/focus-pathfinder/e675f822-6cdb-4e11-9d02-d94b102cb7ce", width: 1200, height: 630, }, @@ -58,12 +61,14 @@ export const metadata: Metadata = { title: "디지털과포용성네트워크", description: "포용적 디지털 사회를 위한 연구·교육·정책 활동. 디지털 소외 해소와 사회 참여 기회 확대를 위해 노력합니다.", - images: ["https://storage.googleapis.com/focus-pathfinder/e675f822-6cdb-4e11-9d02-d94b102cb7ce"], + images: [ + "https://storage.googleapis.com/focus-pathfinder/e675f822-6cdb-4e11-9d02-d94b102cb7ce", + ], }, icons: { - icon: '/favicon.ico', + icon: "/favicon.ico", }, - metadataBase: new URL("https://ndie-fe-985895714915.asia-northeast1.run.app/"), + metadataBase: new URL(baseUrl), }; export default function RootLayout({ @@ -78,7 +83,9 @@ export default function RootLayout({ > {children} + + ); -} +} \ No newline at end of file diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index aea9829..7f33c1b 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -68,7 +68,7 @@ export const Header = () => {
{showLoading ? ( - 로딩중... + ) : isLoggedIn ? (
+
{showLoading ? ( - 로딩중... + ) : isLoggedIn ? (
+
); }; diff --git a/src/components/layout/Listbox.tsx b/src/components/layout/Listbox.tsx index 69ed0b9..fa0900c 100644 --- a/src/components/layout/Listbox.tsx +++ b/src/components/layout/Listbox.tsx @@ -3,7 +3,6 @@ import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; -import Loading from '@/components/ui/loading'; type ListItem = { id: string | number; @@ -17,9 +16,10 @@ type ListboxProps = { item: ListItem[]; datas: string; name: string; + isLoading?: boolean; }; -export default function Listbox({ item, datas, name }: ListboxProps) { +export default function Listbox({ item, datas, name, isLoading }: ListboxProps) { const router = useRouter(); const [searchTerm, setSearchTerm] = useState(''); const [filteredItems, setFilteredItems] = useState(item); @@ -64,7 +64,11 @@ export default function Listbox({ item, datas, name }: ListboxProps) { return (
-

전체 {filteredItems.length} 건

+ {isLoading ? ( +
+ ) : ( +

전체 {filteredItems.length} 건

+ )}

- {Array.isArray(filteredItems) ? ( +
+

번호

+

제목

+

작성자

+

등록일

+

조회

+
+ + {isLoading ? ( <> -
-

번호

-

제목

-

작성자

-

등록일

-

조회

+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ {Array.from({ length: 5 }).map((_, j) => ( +
+
+
+ ))} +
+ ))}
- +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+ + ) : ( + <>
{hasResults ? ( filteredItems.map((i, index) => ( @@ -113,7 +140,6 @@ export default function Listbox({ item, datas, name }: ListboxProps) {
검색 결과가 없습니다.
)}
-
{hasResults ? ( filteredItems.map((i, index) => ( @@ -138,10 +164,6 @@ export default function Listbox({ item, datas, name }: ListboxProps) { )}
- ) : hasSearched ? ( -
검색 결과가 없습니다.
- ) : ( - )}
); diff --git a/src/components/ui/LoginSuccessContent.tsx b/src/components/ui/LoginSuccessContent.tsx index 8fd0554..77b6c7d 100644 --- a/src/components/ui/LoginSuccessContent.tsx +++ b/src/components/ui/LoginSuccessContent.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; +import { toast } from "react-hot-toast"; export default function LoginSuccessContent() { const router = useRouter(); @@ -10,9 +11,20 @@ export default function LoginSuccessContent() { useEffect(() => { const code = searchParams.get("code"); + const redirect = searchParams.get("redirect"); + + + if (redirect && !code) { + toast.error("로그인이 필요합니다"); + + + router.replace("/"); + + return; +} if (!code) { - router.replace("/login"); + router.replace("/"); return; } @@ -41,14 +53,27 @@ export default function LoginSuccessContent() { throw new Error(errorMessage); } - // Firebase Auth로 로그인 처리 (AuthProvider가 자동 감지) - router.push("/"); + + if (redirect) { + router.replace(redirect); + } else { + router.replace("/"); + } } catch (error) { - alert(error instanceof Error ? error.message : "로그인 중 오류 발생"); + + toast.error( + error instanceof Error ? error.message : "로그인 중 오류 발생" + ); } }; + verifyLogin(); }, [API_BASE, router, searchParams]); - return

카카오 로그인 처리 중...

; -} + return ( +
+
+

카카오 로그인 처리 중...

+
+); +} \ No newline at end of file diff --git a/src/components/ui/list.tsx b/src/components/ui/list.tsx index 86cf544..316a9a3 100644 --- a/src/components/ui/list.tsx +++ b/src/components/ui/list.tsx @@ -15,6 +15,7 @@ type ListProps = { export function List({ name, data }: ListProps) { const [item, setitem] = useState([]); + const [isLoading, setIsLoading] = useState(true); useEffect(() => { const fetchData = async () => { @@ -34,6 +35,8 @@ export function List({ name, data }: ListProps) { setitem(items as any); } catch (error) { console.error("Error fetching data:", error); + } finally { + setIsLoading(false); } }; @@ -46,7 +49,7 @@ export function List({ name, data }: ListProps) {

{name}


- +
diff --git a/src/types/global.d.ts b/src/types/global.d.ts new file mode 100644 index 0000000..7f70183 --- /dev/null +++ b/src/types/global.d.ts @@ -0,0 +1 @@ +declare module "*.css"; \ No newline at end of file diff --git a/src/util/makeDocument.tsx b/src/util/makeDocument.tsx index a8ce1f9..a8cb603 100644 --- a/src/util/makeDocument.tsx +++ b/src/util/makeDocument.tsx @@ -45,12 +45,13 @@ export default function makeDocument(text : string) { if(!src) return null; // src[1]가 src 주소임 (match 결과는 [전체, 첫번째 캡처 그룹]) - return
{/* 원하는 높이 설정 */} + return
추가된이미지
}