@@ -134,7 +134,7 @@ async function createRandomUsers(count = 100) {
|
||||
name,
|
||||
birth,
|
||||
phone,
|
||||
passwordHash: hashPassword("1234"),
|
||||
passwordHash: hashPassword("12341234"),
|
||||
agreementTermsAt: new Date(),
|
||||
authLevel: "USER",
|
||||
isAdultVerified: Math.random() < 0.6,
|
||||
@@ -145,7 +145,7 @@ async function createRandomUsers(count = 100) {
|
||||
// 기존 사용자도 패스워드를 1234로 업데이트
|
||||
await prisma.user.update({
|
||||
where: { userId: user.userId },
|
||||
data: { passwordHash: hashPassword("1234") },
|
||||
data: { passwordHash: hashPassword("12341234") },
|
||||
});
|
||||
}
|
||||
if (roleUser && user) {
|
||||
@@ -238,7 +238,7 @@ async function upsertAdmin() {
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { nickname: "admin" },
|
||||
update: {
|
||||
passwordHash: hashPassword("1234"),
|
||||
passwordHash: hashPassword("12341234"),
|
||||
grade: 7,
|
||||
points: 1650000,
|
||||
level: 200,
|
||||
@@ -248,7 +248,7 @@ async function upsertAdmin() {
|
||||
name: "Administrator",
|
||||
birth: new Date("1990-01-01"),
|
||||
phone: "010-0000-0001",
|
||||
passwordHash: hashPassword("1234"),
|
||||
passwordHash: hashPassword("12341234"),
|
||||
agreementTermsAt: new Date(),
|
||||
authLevel: "ADMIN",
|
||||
grade: 7,
|
||||
|
||||
@@ -35,17 +35,42 @@ export async function POST(req: Request) {
|
||||
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) {
|
||||
return NextResponse.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다" }, { status: 401 });
|
||||
}
|
||||
// 사용자의 관리자 권한 여부 확인
|
||||
let isAdmin = false;
|
||||
const userRoles = await prisma.userRole.findMany({
|
||||
where: { userId: user.userId },
|
||||
select: { roleId: true },
|
||||
});
|
||||
if (userRoles.length > 0) {
|
||||
const roleIds = userRoles.map((r) => r.roleId);
|
||||
const hasAdmin = await prisma.rolePermission.findFirst({
|
||||
where: {
|
||||
roleId: { in: roleIds },
|
||||
resource: "ADMIN",
|
||||
action: "ADMINISTER",
|
||||
allowed: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
isAdmin = !!hasAdmin;
|
||||
}
|
||||
|
||||
const res = NextResponse.json({ ok: true, user: { userId: user.userId, nickname: user.nickname } });
|
||||
res.headers.append(
|
||||
"Set-Cookie",
|
||||
`uid=${encodeURIComponent(user.userId)}; Path=/; HttpOnly; SameSite=Lax`
|
||||
);
|
||||
res.headers.append(
|
||||
"Set-Cookie",
|
||||
`isAdmin=${isAdmin ? "1" : "0"}; Path=/; HttpOnly; SameSite=Lax`
|
||||
);
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.headers.append("Set-Cookie", `uid=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`);
|
||||
res.headers.append("Set-Cookie", `isAdmin=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`);
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@ export function AppHeader() {
|
||||
const [indicatorLeft, setIndicatorLeft] = React.useState<number>(0);
|
||||
const [indicatorWidth, setIndicatorWidth] = React.useState<number>(0);
|
||||
const [indicatorVisible, setIndicatorVisible] = React.useState<boolean>(false);
|
||||
// 로그인 상태 확인 (전역 버튼 노출용)
|
||||
const { data: authData } = useSWR<{ user: { userId: string; nickname: string; profileImage: string | null; points: number; level: number; grade: number } | null }>(
|
||||
"/api/me",
|
||||
(u: string) => fetch(u).then((r) => r.json())
|
||||
);
|
||||
// 모바일 사이드바 열릴 때만 현재 사용자 정보를 가져옵니다(훅은 항상 동일한 순서로 호출)
|
||||
const { data: meData } = useSWR<{ user: { userId: string; nickname: string; profileImage: string | null; points: number; level: number; grade: number } | null }>(
|
||||
mobileOpen ? "/api/me" : null,
|
||||
@@ -468,13 +473,30 @@ export function AppHeader() {
|
||||
<div id="dummy" className="block"></div>
|
||||
<div className="hidden xl:flex xl:flex-1 justify-end">
|
||||
<SearchBar/>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="ml-3 inline-flex items-center px-3 h-10 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
||||
aria-label="어드민(임시)"
|
||||
>
|
||||
어드민(임시)
|
||||
</Link>
|
||||
{authData?.user && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fetch("/api/auth/session", { method: "DELETE" });
|
||||
} finally {
|
||||
window.location.reload();
|
||||
}
|
||||
}}
|
||||
className="ml-3 inline-flex items-center px-3 h-10 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
||||
aria-label="로그아웃"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
)}
|
||||
{!authData?.user && (
|
||||
<Link
|
||||
href={`/login?next=${encodeURIComponent((pathname || "/") + (searchParams?.toString() ? `?${searchParams.toString()}` : ""))}`}
|
||||
className="ml-3 inline-flex items-center px-3 h-10 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
||||
aria-label="로그인"
|
||||
>
|
||||
로그인
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
<div
|
||||
@@ -505,29 +527,45 @@ export function AppHeader() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 animate-pulse">
|
||||
<div className="w-12 h-12 rounded-full bg-neutral-200" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="h-3 w-1/2 bg-neutral-200 rounded" />
|
||||
<div className="mt-2 h-3 w-1/3 bg-neutral-200 rounded" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-neutral-700">로그인이 필요합니다</div>
|
||||
<Link
|
||||
href={`/login?next=${encodeURIComponent((pathname || "/") + (searchParams?.toString() ? `?${searchParams.toString()}` : ""))}`}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="h-9 px-3 inline-flex items-center rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
||||
aria-label="로그인"
|
||||
>
|
||||
로그인
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{meData?.user && (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fetch("/api/auth/session", { method: "DELETE" });
|
||||
} finally {
|
||||
setMobileOpen(false);
|
||||
window.location.reload();
|
||||
}
|
||||
}}
|
||||
className="h-9 px-3 inline-flex items-center rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
||||
aria-label="로그아웃"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{meData?.user && (
|
||||
<div className="grid grid-cols-3 gap-2 mt-3">
|
||||
<Link href="/my-page?tab=points" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">포인트 히스토리</Link>
|
||||
<Link href="/my-page?tab=posts" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">내가 쓴 글</Link>
|
||||
<Link href="/my-page?tab=comments" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">내가 쓴 댓글</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-2 mt-3">
|
||||
<Link href="/my-page?tab=points" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">포인트 히스토리</Link>
|
||||
<Link href="/my-page?tab=posts" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">내가 쓴 글</Link>
|
||||
<Link href="/my-page?tab=comments" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">내가 쓴 댓글</Link>
|
||||
</div>
|
||||
</div>
|
||||
<SearchBar />
|
||||
<Link
|
||||
href="/admin"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="inline-flex items-center justify-center h-10 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
||||
aria-label="어드민(임시)"
|
||||
>
|
||||
어드민(임시)
|
||||
</Link>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{categories.map((cat) => (
|
||||
<div key={cat.id}>
|
||||
|
||||
@@ -318,7 +318,7 @@ export function Editor({ value, onChange, placeholder, withToolbar = true }: Pro
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
data-placeholder={placeholder}
|
||||
style={{
|
||||
minHeight: 160,
|
||||
minHeight: 500,
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
|
||||
@@ -146,8 +146,8 @@ export function HeroBanner({ subItems, activeSubId, hideSubOnMobile, showPartner
|
||||
)
|
||||
)}
|
||||
{!usePartnerCats && (!Array.isArray(subItems) || subItems.length === 0) && (
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<span className="px-3 h-[28px] rounded-full bg-transparent text-white/85 text-[12px] leading-[28px] whitespace-nowrap cursor-default">암실소문</span>
|
||||
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
|
||||
<span className="px-3 h-full inline-flex items-center bg-white text-[#d73b29] text-[20px] font-[700] rounded-tl-[14px] rounded-tr-[14px] whitespace-nowrap">암실소문</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -268,8 +268,8 @@ export function HeroBanner({ subItems, activeSubId, hideSubOnMobile, showPartner
|
||||
)
|
||||
)}
|
||||
{!usePartnerCats && (!Array.isArray(subItems) || subItems.length === 0) && (
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<span className="px-3 h-[28px] rounded-full bg-transparent text-white/85 text-[12px] leading-[28px] whitespace-nowrap cursor-default">암실소문</span>
|
||||
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
|
||||
<span className="px-3 h-full inline-flex items-center bg-white text-[#d73b29] text-[20px] font-[700] rounded-tl-[14px] rounded-tr-[14px] whitespace-nowrap">암실소문</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import QueryProvider from "@/app/QueryProvider";
|
||||
import { AppHeader } from "@/app/components/AppHeader";
|
||||
import { AppFooter } from "@/app/components/AppFooter";
|
||||
import { ToastProvider } from "@/app/components/ui/ToastProvider";
|
||||
import { AutoLoginAdmin } from "@/app/components/AutoLoginAdmin";
|
||||
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -23,7 +22,6 @@ export default function RootLayout({
|
||||
<body className="min-h-screen bg-background text-foreground antialiased">
|
||||
<QueryProvider>
|
||||
<ToastProvider>
|
||||
<AutoLoginAdmin />
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<div className="sticky top-0 z-50 bg-white/90 backdrop-blur">
|
||||
<div className="mx-auto w-full">
|
||||
|
||||
@@ -3,12 +3,15 @@ import Link from "next/link";
|
||||
import React from "react";
|
||||
import { Button } from "@/app/components/ui/Button";
|
||||
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { show } = useToast();
|
||||
const [nickname, setNickname] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const sp = useSearchParams();
|
||||
const next = sp?.get("next") || "/";
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
@@ -21,7 +24,7 @@ export default function LoginPage() {
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error || "로그인 실패");
|
||||
show("로그인되었습니다");
|
||||
location.href = "/";
|
||||
location.href = next;
|
||||
} catch (err: any) {
|
||||
show(err.message || "로그인 실패");
|
||||
} finally {
|
||||
|
||||
174
src/app/page.tsx
174
src/app/page.tsx
@@ -50,14 +50,7 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
||||
// 에러 무시
|
||||
}
|
||||
|
||||
// 로그인되지 않은 경우 어드민 사용자 가져오기
|
||||
if (!currentUser) {
|
||||
const admin = await prisma.user.findUnique({
|
||||
where: { nickname: "admin" },
|
||||
select: { userId: true, nickname: true, profileImage: true, points: true, level: true, grade: true },
|
||||
});
|
||||
if (admin) currentUser = admin;
|
||||
}
|
||||
// 로그인되지 않은 경우 어드민 사용자로 대체하지 않음 (요청사항)
|
||||
|
||||
// 내가 쓴 게시글/댓글 수
|
||||
let myPostsCount = 0;
|
||||
@@ -219,84 +212,97 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:flex xl:gap-[23px] gap-4 h-full min-h-0">
|
||||
<div className="hidden xl:grid relative overflow-hidden rounded-xl bg-white px-[25px] py-[34px] grid-rows-[120px_120px_1fr] gap-y-[32px] h-[514px] w-[350px] shrink-0">
|
||||
<div className="absolute inset-x-0 top-0 h-[56px] bg-[#d5d5d5] z-0" />
|
||||
<div className="h-[120px] flex items-center justify-center relative z-10">
|
||||
<div className="flex items-center justify-center gap-[8px]">
|
||||
<UserAvatar
|
||||
src={currentUser?.profileImage || null}
|
||||
alt={currentUser?.nickname || "프로필"}
|
||||
width={120}
|
||||
height={120}
|
||||
className="rounded-full"
|
||||
/>
|
||||
{currentUser && (
|
||||
<div className="w-[62px] h-[62px] flex items-center justify-center">
|
||||
<GradeIcon grade={currentUser.grade} width={62} height={62} />
|
||||
{currentUser ? (
|
||||
<>
|
||||
<div className="h-[120px] flex items-center justify-center relative z-10">
|
||||
<div className="flex items-center justify-center gap-[8px]">
|
||||
<UserAvatar
|
||||
src={currentUser.profileImage || null}
|
||||
alt={currentUser.nickname || "프로필"}
|
||||
width={120}
|
||||
height={120}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<div className="w-[62px] h-[62px] flex items-center justify-center">
|
||||
<GradeIcon grade={currentUser.grade} width={62} height={62} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-[120px] flex flex-col items-center relative z-10">
|
||||
<div className="text-[18px] text-[#5c5c5c] font-[700] truncate text-center mb-[20px]">{currentUser.nickname}</div>
|
||||
<div className="w-[300px] pl-[67px] flex flex-col gap-[12px]">
|
||||
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
|
||||
<div className="w-[64px] flex items-center">
|
||||
<ProfileLabelIcon width={16} height={16} />
|
||||
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">레벨</span>
|
||||
</div>
|
||||
<div className="text-[16px] text-[#5c5c5c] font-[700]">Lv. {currentUser.level}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
|
||||
<div className="w-[64px] flex items-center">
|
||||
<ProfileLabelIcon width={16} height={16} />
|
||||
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">등급</span>
|
||||
</div>
|
||||
<div className="text-[16px] text-[#5c5c5c] font-[700]">{getGradeName(currentUser.grade)}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
|
||||
<div className="w-[64px] flex items-center">
|
||||
<ProfileLabelIcon width={16} height={16} />
|
||||
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">포인트</span>
|
||||
</div>
|
||||
<div className="text-[16px] text-[#5c5c5c] font-[700]">{currentUser.points.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-[12px] relative z-10">
|
||||
<Link href="/my-page" className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
|
||||
<span className="flex items-center w-full pl-[88px]">
|
||||
<span className="flex items-center gap-[8px]">
|
||||
<SearchIcon width={16} height={16} />
|
||||
<span>내 정보 페이지</span>
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
<Link href="/my-page?tab=points" className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
|
||||
<span className="flex items-center w-full pl-[88px]">
|
||||
<span className="flex items-center gap-[8px]">
|
||||
<SearchIcon width={16} height={16} />
|
||||
<span>포인트 히스토리</span>
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
<Link href={`/my-page?tab=posts`} className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
|
||||
<span className="flex items-center w-full pl-[88px]">
|
||||
<span className="flex items-center gap-[8px]">
|
||||
<SearchIcon width={16} height={16} />
|
||||
<span>내가 쓴 게시글</span>
|
||||
</span>
|
||||
<span className="ml-auto inline-flex items-center justify-center h-[20px] px-[8px] rounded-full bg-white text-[#5c5c5c] text-[12px] leading-[20px] shrink-0">{myPostsCount.toLocaleString()}개</span>
|
||||
</span>
|
||||
</Link>
|
||||
<Link href={`/my-page?tab=comments`} className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
|
||||
<span className="flex items-center w-full pl-[88px]">
|
||||
<span className="flex items-center gap-[8px]">
|
||||
<SearchIcon width={16} height={16} />
|
||||
<span>내가 쓴 댓글</span>
|
||||
</span>
|
||||
<span className="ml-auto inline-flex items-center justify-center h-[20px] px-[8px] rounded-full bg-white text-[#5c5c5c] text-[12px] leading-[20px] shrink-0">{myCommentsCount.toLocaleString()}개</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-6 relative z-10">
|
||||
<div className="mt-[56px] flex flex-col items-center gap-4">
|
||||
<div className="w-[120px] h-[120px] rounded-full bg-[#e9e9e9] border border-neutral-200" />
|
||||
<div className="text-[18px] text-[#5c5c5c] font-[700]">로그인이 필요합니다</div>
|
||||
<div className="text-[13px] text-neutral-600">내 정보와 등급/포인트를 확인하려면 로그인하세요.</div>
|
||||
</div>
|
||||
<Link href={`/login?next=/`} className="w-[300px] h-[40px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[14px] font-[700] flex items-center justify-center">
|
||||
로그인 하러 가기
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[120px] flex flex-col items-center relative z-10">
|
||||
<div className="text-[18px] text-[#5c5c5c] font-[700] truncate text-center mb-[20px]">{currentUser?.nickname || "사용자"}</div>
|
||||
<div className="w-[300px] pl-[67px] flex flex-col gap-[12px]">
|
||||
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
|
||||
<div className="w-[64px] flex items-center">
|
||||
<ProfileLabelIcon width={16} height={16} />
|
||||
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">레벨</span>
|
||||
</div>
|
||||
<div className="text-[16px] text-[#5c5c5c] font-[700]">Lv. {currentUser?.level || 1}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
|
||||
<div className="w-[64px] flex items-center">
|
||||
<ProfileLabelIcon width={16} height={16} />
|
||||
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">등급</span>
|
||||
</div>
|
||||
<div className="text-[16px] text-[#5c5c5c] font-[700]">{getGradeName(currentUser?.grade || 0)}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
|
||||
<div className="w-[64px] flex items-center">
|
||||
<ProfileLabelIcon width={16} height={16} />
|
||||
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">포인트</span>
|
||||
</div>
|
||||
<div className="text-[16px] text-[#5c5c5c] font-[700]">{(currentUser?.points || 0).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-[12px] relative z-10">
|
||||
<Link href="/my-page" className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
|
||||
<span className="flex items-center w-full pl-[88px]">
|
||||
<span className="flex items-center gap-[8px]">
|
||||
<SearchIcon width={16} height={16} />
|
||||
<span>내 정보 페이지</span>
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
<Link href="/my-page?tab=points" className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
|
||||
<span className="flex items-center w-full pl-[88px]">
|
||||
<span className="flex items-center gap-[8px]">
|
||||
<SearchIcon width={16} height={16} />
|
||||
<span>포인트 히스토리</span>
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
<Link href={`/my-page?tab=posts`} className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
|
||||
<span className="flex items-center w-full pl-[88px]">
|
||||
<span className="flex items-center gap-[8px]">
|
||||
<SearchIcon width={16} height={16} />
|
||||
<span>내가 쓴 게시글</span>
|
||||
</span>
|
||||
<span className="ml-auto inline-flex items-center justify-center h-[20px] px-[8px] rounded-full bg-white text-[#5c5c5c] text-[12px] leading-[20px] shrink-0">{myPostsCount.toLocaleString()}개</span>
|
||||
</span>
|
||||
</Link>
|
||||
<Link href={`/my-page?tab=comments`} className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
|
||||
<span className="flex items-center w-full pl-[88px]">
|
||||
<span className="flex items-center gap-[8px]">
|
||||
<SearchIcon width={16} height={16} />
|
||||
<span>내가 쓴 댓글</span>
|
||||
</span>
|
||||
<span className="ml-auto inline-flex items-center justify-center h-[20px] px-[8px] rounded-full bg-white text-[#5c5c5c] text-[12px] leading-[20px] shrink-0">{myCommentsCount.toLocaleString()}개</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(await Promise.all(firstTwo.map((b) => prepareBoardPanelData(b)))).map((panelData, idx) => (
|
||||
<div key={firstTwo[idx].id} className="overflow-hidden xl:h-[514px] h-full min-h-0 flex flex-col flex-1">
|
||||
|
||||
@@ -23,12 +23,44 @@ export default async function PostDetail({ params }: { params: any }) {
|
||||
const parsed = settingRow ? JSON.parse(settingRow.value as string) : {};
|
||||
const showBanner: boolean = parsed.showBanner ?? true;
|
||||
|
||||
// 현재 게시글이 속한 카테고리의 보드들을 서브카테고리로 구성
|
||||
let subItems:
|
||||
| { id: string; name: string; href: string }[]
|
||||
| undefined = undefined;
|
||||
if (post?.boardId) {
|
||||
const categories = await prisma.boardCategory.findMany({
|
||||
where: { status: "active" },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
include: {
|
||||
boards: {
|
||||
where: { status: "active" },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
select: { id: true, name: true, slug: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const categoryOfPost = categories.find((c) =>
|
||||
c.boards.some((b) => b.id === post.boardId)
|
||||
);
|
||||
if (categoryOfPost) {
|
||||
subItems = categoryOfPost.boards.map((b) => ({
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
href: `/boards/${b.slug}`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 상단 배너 */}
|
||||
{showBanner && (
|
||||
<section>
|
||||
<HeroBanner />
|
||||
<HeroBanner
|
||||
subItems={subItems}
|
||||
activeSubId={post?.boardId}
|
||||
showPartnerCats={false}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useToast } from "@/app/components/ui/ToastProvider";
|
||||
import { UploadButton } from "@/app/components/UploadButton";
|
||||
import { Editor } from "@/app/components/Editor";
|
||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||
import useSWR from "swr";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NewPostPage() {
|
||||
const router = useRouter();
|
||||
@@ -16,6 +18,7 @@ export default function NewPostPage() {
|
||||
const [form, setForm] = useState({ boardId: initialBoardId, title: "", content: "" });
|
||||
const [isSecret, setIsSecret] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { data: meData } = useSWR<{ user: { userId: string } | null }>("/api/me", (u: string) => fetch(u).then((r) => r.json()));
|
||||
async function submit() {
|
||||
try {
|
||||
if (!form.boardId.trim()) {
|
||||
@@ -52,6 +55,40 @@ export default function NewPostPage() {
|
||||
}
|
||||
const plainLength = (form.content || "").replace(/<[^>]*>/g, "").length;
|
||||
const MAX_LEN = 10000;
|
||||
if (!meData) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<HeroBanner />
|
||||
</section>
|
||||
<section className="mx-auto max-w-2xl bg-white rounded-2xl border border-neutral-300 px-6 sm:px-8 py-8 text-center">
|
||||
<div className="text-lg font-semibold text-neutral-900">확인 중...</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!meData.user) {
|
||||
const next = `/posts/new${sp.toString() ? `?${sp.toString()}` : ""}`;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<HeroBanner />
|
||||
</section>
|
||||
<section className="mx-auto max-w-2xl bg-white rounded-2xl border border-neutral-300 px-6 sm:px-8 py-8 text-center">
|
||||
<div className="text-[22px] md:text-[26px] font-semibold text-neutral-900">로그인이 필요합니다</div>
|
||||
<p className="mt-2 text-neutral-600">게시글 작성은 로그인 후 이용할 수 있어요.</p>
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
href={`/login?next=${encodeURIComponent(next)}`}
|
||||
className="inline-flex items-center px-5 h-12 rounded-xl border border-neutral-300 text-neutral-800 hover:bg-neutral-100"
|
||||
>
|
||||
로그인 하러 가기
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
|
||||
@@ -19,23 +19,9 @@ export function getUserIdFromRequest(req: Request): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// 개발 환경에서만: 인증 정보가 없으면 admin 사용자 ID를 반환
|
||||
// DB 조회 결과를 프로세스 전역에 캐싱해 과도한 쿼리를 방지
|
||||
export async function getUserIdOrAdmin(req: Request): Promise<string | null> {
|
||||
const uid = getUserIdFromRequest(req);
|
||||
if (uid) return uid;
|
||||
if (process.env.NODE_ENV === "production") return null;
|
||||
try {
|
||||
const globalAny = global as unknown as { __ADMIN_UID?: string };
|
||||
if (globalAny.__ADMIN_UID) return globalAny.__ADMIN_UID;
|
||||
const admin = await prisma.user.findUnique({ where: { nickname: "admin" }, select: { userId: true } });
|
||||
if (admin?.userId) {
|
||||
globalAny.__ADMIN_UID = admin.userId;
|
||||
return admin.userId;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
// 비로그인 시 admin으로 대체하지 않고 null 반환 (익명 처리)
|
||||
return uid ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,22 +8,34 @@ const protectedApi = [
|
||||
export async function middleware(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl;
|
||||
const response = NextResponse.next();
|
||||
|
||||
// 쿠키에 uid가 없으면 어드민으로 자동 로그인 (기본값)
|
||||
const uid = req.cookies.get("uid")?.value;
|
||||
if (!uid) {
|
||||
// 어드민 사용자 ID 가져오기 (DB 조회 대신 하드코딩 - 실제 환경에서는 다른 방식 사용 권장)
|
||||
// 어드민 nickname은 "admin"으로 고정되어 있다고 가정
|
||||
// 실제 userId는 DB에서 가져와야 하지만, middleware는 비동기 DB 호출을 제한적으로 지원
|
||||
// 대신 page.tsx에서 처리하도록 함
|
||||
|
||||
// 페이지 요청일 경우만 쿠키 설정 시도
|
||||
// API는 제외하고 페이지만 처리
|
||||
if (!pathname.startsWith("/api")) {
|
||||
// 페이지 레벨에서 처리하도록 함 (쿠키는 클라이언트 측에서 설정 필요)
|
||||
const isAdmin = req.cookies.get("isAdmin")?.value;
|
||||
|
||||
// Admin 페이지 보호
|
||||
if (pathname.startsWith("/admin")) {
|
||||
// 비로그인 사용자는 로그인 페이지로
|
||||
if (!uid) {
|
||||
const loginUrl = req.nextUrl.clone();
|
||||
loginUrl.pathname = "/login";
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
// 로그인했지만 관리자가 아니면 홈으로
|
||||
if (isAdmin !== "1") {
|
||||
const homeUrl = req.nextUrl.clone();
|
||||
homeUrl.pathname = "/";
|
||||
return NextResponse.redirect(homeUrl);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 로그인된 상태에서 로그인 페이지 접근 시 홈으로 리다이렉트
|
||||
if (pathname === "/login" || pathname.startsWith("/login/")) {
|
||||
if (uid) {
|
||||
const homeUrl = req.nextUrl.clone();
|
||||
homeUrl.pathname = "/";
|
||||
return NextResponse.redirect(homeUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const needAuth = protectedApi.some((re) => re.test(pathname));
|
||||
if (needAuth && !uid) {
|
||||
return new NextResponse(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||
@@ -36,4 +48,3 @@ export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)"],
|
||||
};
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user