@@ -134,7 +134,7 @@ async function createRandomUsers(count = 100) {
|
|||||||
name,
|
name,
|
||||||
birth,
|
birth,
|
||||||
phone,
|
phone,
|
||||||
passwordHash: hashPassword("1234"),
|
passwordHash: hashPassword("12341234"),
|
||||||
agreementTermsAt: new Date(),
|
agreementTermsAt: new Date(),
|
||||||
authLevel: "USER",
|
authLevel: "USER",
|
||||||
isAdultVerified: Math.random() < 0.6,
|
isAdultVerified: Math.random() < 0.6,
|
||||||
@@ -145,7 +145,7 @@ async function createRandomUsers(count = 100) {
|
|||||||
// 기존 사용자도 패스워드를 1234로 업데이트
|
// 기존 사용자도 패스워드를 1234로 업데이트
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { userId: user.userId },
|
where: { userId: user.userId },
|
||||||
data: { passwordHash: hashPassword("1234") },
|
data: { passwordHash: hashPassword("12341234") },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (roleUser && user) {
|
if (roleUser && user) {
|
||||||
@@ -238,7 +238,7 @@ async function upsertAdmin() {
|
|||||||
const admin = await prisma.user.upsert({
|
const admin = await prisma.user.upsert({
|
||||||
where: { nickname: "admin" },
|
where: { nickname: "admin" },
|
||||||
update: {
|
update: {
|
||||||
passwordHash: hashPassword("1234"),
|
passwordHash: hashPassword("12341234"),
|
||||||
grade: 7,
|
grade: 7,
|
||||||
points: 1650000,
|
points: 1650000,
|
||||||
level: 200,
|
level: 200,
|
||||||
@@ -248,7 +248,7 @@ async function upsertAdmin() {
|
|||||||
name: "Administrator",
|
name: "Administrator",
|
||||||
birth: new Date("1990-01-01"),
|
birth: new Date("1990-01-01"),
|
||||||
phone: "010-0000-0001",
|
phone: "010-0000-0001",
|
||||||
passwordHash: hashPassword("1234"),
|
passwordHash: hashPassword("12341234"),
|
||||||
agreementTermsAt: new Date(),
|
agreementTermsAt: new Date(),
|
||||||
authLevel: "ADMIN",
|
authLevel: "ADMIN",
|
||||||
grade: 7,
|
grade: 7,
|
||||||
|
|||||||
@@ -35,17 +35,42 @@ export async function POST(req: Request) {
|
|||||||
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) {
|
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) {
|
||||||
return NextResponse.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다" }, { status: 401 });
|
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 } });
|
const res = NextResponse.json({ ok: true, user: { userId: user.userId, nickname: user.nickname } });
|
||||||
res.headers.append(
|
res.headers.append(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
`uid=${encodeURIComponent(user.userId)}; Path=/; HttpOnly; SameSite=Lax`
|
`uid=${encodeURIComponent(user.userId)}; Path=/; HttpOnly; SameSite=Lax`
|
||||||
);
|
);
|
||||||
|
res.headers.append(
|
||||||
|
"Set-Cookie",
|
||||||
|
`isAdmin=${isAdmin ? "1" : "0"}; Path=/; HttpOnly; SameSite=Lax`
|
||||||
|
);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE() {
|
export async function DELETE() {
|
||||||
const res = NextResponse.json({ ok: true });
|
const res = NextResponse.json({ ok: true });
|
||||||
res.headers.append("Set-Cookie", `uid=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`);
|
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;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ export function AppHeader() {
|
|||||||
const [indicatorLeft, setIndicatorLeft] = React.useState<number>(0);
|
const [indicatorLeft, setIndicatorLeft] = React.useState<number>(0);
|
||||||
const [indicatorWidth, setIndicatorWidth] = React.useState<number>(0);
|
const [indicatorWidth, setIndicatorWidth] = React.useState<number>(0);
|
||||||
const [indicatorVisible, setIndicatorVisible] = React.useState<boolean>(false);
|
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 }>(
|
const { data: meData } = useSWR<{ user: { userId: string; nickname: string; profileImage: string | null; points: number; level: number; grade: number } | null }>(
|
||||||
mobileOpen ? "/api/me" : null,
|
mobileOpen ? "/api/me" : null,
|
||||||
@@ -468,13 +473,30 @@ export function AppHeader() {
|
|||||||
<div id="dummy" className="block"></div>
|
<div id="dummy" className="block"></div>
|
||||||
<div className="hidden xl:flex xl:flex-1 justify-end">
|
<div className="hidden xl:flex xl:flex-1 justify-end">
|
||||||
<SearchBar/>
|
<SearchBar/>
|
||||||
<Link
|
{authData?.user && (
|
||||||
href="/admin"
|
<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"
|
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="어드민(임시)"
|
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>
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div
|
<div
|
||||||
@@ -505,29 +527,45 @@ export function AppHeader() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-3 animate-pulse">
|
<div className="flex items-center justify-between">
|
||||||
<div className="w-12 h-12 rounded-full bg-neutral-200" />
|
<div className="text-sm text-neutral-700">로그인이 필요합니다</div>
|
||||||
<div className="flex-1 min-w-0">
|
<Link
|
||||||
<div className="h-3 w-1/2 bg-neutral-200 rounded" />
|
href={`/login?next=${encodeURIComponent((pathname || "/") + (searchParams?.toString() ? `?${searchParams.toString()}` : ""))}`}
|
||||||
<div className="mt-2 h-3 w-1/3 bg-neutral-200 rounded" />
|
onClick={() => setMobileOpen(false)}
|
||||||
</div>
|
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>
|
</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">
|
<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=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=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>
|
<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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<SearchBar />
|
<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">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => (
|
||||||
<div key={cat.id}>
|
<div key={cat.id}>
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ export function Editor({ value, onChange, placeholder, withToolbar = true }: Pro
|
|||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
data-placeholder={placeholder}
|
data-placeholder={placeholder}
|
||||||
style={{
|
style={{
|
||||||
minHeight: 160,
|
minHeight: 500,
|
||||||
border: "1px solid #ddd",
|
border: "1px solid #ddd",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
|
|||||||
@@ -146,8 +146,8 @@ export function HeroBanner({ subItems, activeSubId, hideSubOnMobile, showPartner
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{!usePartnerCats && (!Array.isArray(subItems) || subItems.length === 0) && (
|
{!usePartnerCats && (!Array.isArray(subItems) || subItems.length === 0) && (
|
||||||
<div className="flex items-center gap-[8px]">
|
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
|
||||||
<span className="px-3 h-[28px] rounded-full bg-transparent text-white/85 text-[12px] leading-[28px] whitespace-nowrap cursor-default">암실소문</span>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -268,8 +268,8 @@ export function HeroBanner({ subItems, activeSubId, hideSubOnMobile, showPartner
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{!usePartnerCats && (!Array.isArray(subItems) || subItems.length === 0) && (
|
{!usePartnerCats && (!Array.isArray(subItems) || subItems.length === 0) && (
|
||||||
<div className="flex items-center gap-[8px]">
|
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
|
||||||
<span className="px-3 h-[28px] rounded-full bg-transparent text-white/85 text-[12px] leading-[28px] whitespace-nowrap cursor-default">암실소문</span>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import QueryProvider from "@/app/QueryProvider";
|
|||||||
import { AppHeader } from "@/app/components/AppHeader";
|
import { AppHeader } from "@/app/components/AppHeader";
|
||||||
import { AppFooter } from "@/app/components/AppFooter";
|
import { AppFooter } from "@/app/components/AppFooter";
|
||||||
import { ToastProvider } from "@/app/components/ui/ToastProvider";
|
import { ToastProvider } from "@/app/components/ui/ToastProvider";
|
||||||
import { AutoLoginAdmin } from "@/app/components/AutoLoginAdmin";
|
|
||||||
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -23,7 +22,6 @@ export default function RootLayout({
|
|||||||
<body className="min-h-screen bg-background text-foreground antialiased">
|
<body className="min-h-screen bg-background text-foreground antialiased">
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<AutoLoginAdmin />
|
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
<div className="sticky top-0 z-50 bg-white/90 backdrop-blur">
|
<div className="sticky top-0 z-50 bg-white/90 backdrop-blur">
|
||||||
<div className="mx-auto w-full">
|
<div className="mx-auto w-full">
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import Link from "next/link";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button } from "@/app/components/ui/Button";
|
import { Button } from "@/app/components/ui/Button";
|
||||||
import { useToast } from "@/app/components/ui/ToastProvider";
|
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { show } = useToast();
|
const { show } = useToast();
|
||||||
const [nickname, setNickname] = React.useState("");
|
const [nickname, setNickname] = React.useState("");
|
||||||
const [password, setPassword] = React.useState("");
|
const [password, setPassword] = React.useState("");
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const sp = useSearchParams();
|
||||||
|
const next = sp?.get("next") || "/";
|
||||||
const onSubmit = async (e: React.FormEvent) => {
|
const onSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -21,7 +24,7 @@ export default function LoginPage() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data?.error || "로그인 실패");
|
if (!res.ok) throw new Error(data?.error || "로그인 실패");
|
||||||
show("로그인되었습니다");
|
show("로그인되었습니다");
|
||||||
location.href = "/";
|
location.href = next;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
show(err.message || "로그인 실패");
|
show(err.message || "로그인 실패");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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;
|
let myPostsCount = 0;
|
||||||
@@ -219,45 +212,45 @@ 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="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="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="absolute inset-x-0 top-0 h-[56px] bg-[#d5d5d5] z-0" />
|
||||||
|
{currentUser ? (
|
||||||
|
<>
|
||||||
<div className="h-[120px] flex items-center justify-center relative z-10">
|
<div className="h-[120px] flex items-center justify-center relative z-10">
|
||||||
<div className="flex items-center justify-center gap-[8px]">
|
<div className="flex items-center justify-center gap-[8px]">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
src={currentUser?.profileImage || null}
|
src={currentUser.profileImage || null}
|
||||||
alt={currentUser?.nickname || "프로필"}
|
alt={currentUser.nickname || "프로필"}
|
||||||
width={120}
|
width={120}
|
||||||
height={120}
|
height={120}
|
||||||
className="rounded-full"
|
className="rounded-full"
|
||||||
/>
|
/>
|
||||||
{currentUser && (
|
|
||||||
<div className="w-[62px] h-[62px] flex items-center justify-center">
|
<div className="w-[62px] h-[62px] flex items-center justify-center">
|
||||||
<GradeIcon grade={currentUser.grade} width={62} height={62} />
|
<GradeIcon grade={currentUser.grade} width={62} height={62} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[120px] flex flex-col items-center relative z-10">
|
<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="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="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="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
|
||||||
<div className="w-[64px] flex items-center">
|
<div className="w-[64px] flex items-center">
|
||||||
<ProfileLabelIcon width={16} height={16} />
|
<ProfileLabelIcon width={16} height={16} />
|
||||||
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">레벨</span>
|
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">레벨</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[16px] text-[#5c5c5c] font-[700]">Lv. {currentUser?.level || 1}</div>
|
<div className="text-[16px] text-[#5c5c5c] font-[700]">Lv. {currentUser.level}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
|
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
|
||||||
<div className="w-[64px] flex items-center">
|
<div className="w-[64px] flex items-center">
|
||||||
<ProfileLabelIcon width={16} height={16} />
|
<ProfileLabelIcon width={16} height={16} />
|
||||||
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">등급</span>
|
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">등급</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[16px] text-[#5c5c5c] font-[700]">{getGradeName(currentUser?.grade || 0)}</div>
|
<div className="text-[16px] text-[#5c5c5c] font-[700]">{getGradeName(currentUser.grade)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
|
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
|
||||||
<div className="w-[64px] flex items-center">
|
<div className="w-[64px] flex items-center">
|
||||||
<ProfileLabelIcon width={16} height={16} />
|
<ProfileLabelIcon width={16} height={16} />
|
||||||
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">포인트</span>
|
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">포인트</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[16px] text-[#5c5c5c] font-[700]">{(currentUser?.points || 0).toLocaleString()}</div>
|
<div className="text-[16px] text-[#5c5c5c] font-[700]">{currentUser.points.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -297,6 +290,19 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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>
|
||||||
{(await Promise.all(firstTwo.map((b) => prepareBoardPanelData(b)))).map((panelData, idx) => (
|
{(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">
|
<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 parsed = settingRow ? JSON.parse(settingRow.value as string) : {};
|
||||||
const showBanner: boolean = parsed.showBanner ?? true;
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 상단 배너 */}
|
{/* 상단 배너 */}
|
||||||
{showBanner && (
|
{showBanner && (
|
||||||
<section>
|
<section>
|
||||||
<HeroBanner />
|
<HeroBanner
|
||||||
|
subItems={subItems}
|
||||||
|
activeSubId={post?.boardId}
|
||||||
|
showPartnerCats={false}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useToast } from "@/app/components/ui/ToastProvider";
|
|||||||
import { UploadButton } from "@/app/components/UploadButton";
|
import { UploadButton } from "@/app/components/UploadButton";
|
||||||
import { Editor } from "@/app/components/Editor";
|
import { Editor } from "@/app/components/Editor";
|
||||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function NewPostPage() {
|
export default function NewPostPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -16,6 +18,7 @@ export default function NewPostPage() {
|
|||||||
const [form, setForm] = useState({ boardId: initialBoardId, title: "", content: "" });
|
const [form, setForm] = useState({ boardId: initialBoardId, title: "", content: "" });
|
||||||
const [isSecret, setIsSecret] = useState(false);
|
const [isSecret, setIsSecret] = useState(false);
|
||||||
const [loading, setLoading] = 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() {
|
async function submit() {
|
||||||
try {
|
try {
|
||||||
if (!form.boardId.trim()) {
|
if (!form.boardId.trim()) {
|
||||||
@@ -52,6 +55,40 @@ export default function NewPostPage() {
|
|||||||
}
|
}
|
||||||
const plainLength = (form.content || "").replace(/<[^>]*>/g, "").length;
|
const plainLength = (form.content || "").replace(/<[^>]*>/g, "").length;
|
||||||
const MAX_LEN = 10000;
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -19,23 +19,9 @@ export function getUserIdFromRequest(req: Request): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 개발 환경에서만: 인증 정보가 없으면 admin 사용자 ID를 반환
|
|
||||||
// DB 조회 결과를 프로세스 전역에 캐싱해 과도한 쿼리를 방지
|
|
||||||
export async function getUserIdOrAdmin(req: Request): Promise<string | null> {
|
export async function getUserIdOrAdmin(req: Request): Promise<string | null> {
|
||||||
const uid = getUserIdFromRequest(req);
|
const uid = getUserIdFromRequest(req);
|
||||||
if (uid) return uid;
|
// 비로그인 시 admin으로 대체하지 않고 null 반환 (익명 처리)
|
||||||
if (process.env.NODE_ENV === "production") return null;
|
return uid ?? 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,19 +8,31 @@ const protectedApi = [
|
|||||||
export async function middleware(req: NextRequest) {
|
export async function middleware(req: NextRequest) {
|
||||||
const { pathname } = req.nextUrl;
|
const { pathname } = req.nextUrl;
|
||||||
const response = NextResponse.next();
|
const response = NextResponse.next();
|
||||||
|
|
||||||
// 쿠키에 uid가 없으면 어드민으로 자동 로그인 (기본값)
|
|
||||||
const uid = req.cookies.get("uid")?.value;
|
const uid = req.cookies.get("uid")?.value;
|
||||||
if (!uid) {
|
const isAdmin = req.cookies.get("isAdmin")?.value;
|
||||||
// 어드민 사용자 ID 가져오기 (DB 조회 대신 하드코딩 - 실제 환경에서는 다른 방식 사용 권장)
|
|
||||||
// 어드민 nickname은 "admin"으로 고정되어 있다고 가정
|
|
||||||
// 실제 userId는 DB에서 가져와야 하지만, middleware는 비동기 DB 호출을 제한적으로 지원
|
|
||||||
// 대신 page.tsx에서 처리하도록 함
|
|
||||||
|
|
||||||
// 페이지 요청일 경우만 쿠키 설정 시도
|
// Admin 페이지 보호
|
||||||
// API는 제외하고 페이지만 처리
|
if (pathname.startsWith("/admin")) {
|
||||||
if (!pathname.startsWith("/api")) {
|
// 비로그인 사용자는 로그인 페이지로
|
||||||
// 페이지 레벨에서 처리하도록 함 (쿠키는 클라이언트 측에서 설정 필요)
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,4 +48,3 @@ export const config = {
|
|||||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)"],
|
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user