Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions src/app/gacha-board/create/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import GachaPostEditor from '@/components/GachaPostEditor';
'use client';

export const metadata = {
title: '게시글 작성 | 전국 가챠 지도',
};
import dynamic from 'next/dynamic';

// 관리자 전용 무거운 컴포넌트 — 초기 번들에서 분리
const GachaPostEditor = dynamic(() => import('@/components/GachaPostEditor'), {
ssr: false,
loading: () => (
<div className='min-h-screen bg-gray-50 flex items-center justify-center'>
<div className='w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin' />
</div>
),
});

export default function Page() {
return <GachaPostEditor />;
Expand Down
13 changes: 12 additions & 1 deletion src/app/gacha-board/edit/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import GachaPostEditor from '@/components/GachaPostEditor';
'use client';

import dynamic from 'next/dynamic';

const GachaPostEditor = dynamic(() => import('@/components/GachaPostEditor'), {
ssr: false,
loading: () => (
<div className='flex items-center justify-center min-h-screen'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900' />
</div>
),
});

export default function Page({ params }: { params: { id: string } }) {
return <GachaPostEditor postId={params.id} />;
Expand Down
3 changes: 3 additions & 0 deletions src/app/gacha-board/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import GachaBoardPage from '@/components/GachaBoardPage';

// 1분마다 재검증
export const revalidate = 60;

export const metadata = {
title: '신규 가챠·신규 쿠지 정보 게시판',
description: '신규 가챠, 신규 쿠지(이치방쿠지), 신규 가차 출시 정보를 가장 빠르게 확인하세요. 전국 가챠 지도에서 최신 캡슐토이·제일복권 소식을 제공합니다.',
Expand Down
4 changes: 2 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ export default function RootLayout({
return (
<html lang='ko'>
<head>
{/* 카카오맵 스크립트를 head에 배치 */}
<Script src={API} strategy='beforeInteractive' />
{/* 카카오맵 스크립트 — Map 컴포넌트가 자체적으로 로드 완료를 polling하므로 afterInteractive로 변경 */}
<Script src={API} strategy='afterInteractive' />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<script
Expand Down
4 changes: 3 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { getVerifiedShops } from '@/lib/supabase';
import Header from '@/components/Header';
import KakaoMap from '@/components/Map';
// import GoogleAd from '@/components/GoogleAd';

// 5분마다 재검증 — 매 요청마다 DB를 치지 않도록 캐싱
export const revalidate = 300;

export default async function Home() {
const shops = await getVerifiedShops();
Expand Down
5 changes: 2 additions & 3 deletions src/components/GachaBoardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,8 @@ export default function GachaBoardPage() {

const isAdmin = profile?.is_admin || false;

useEffect(() => {
fetchPosts();
}, [selectedBrand, sortBy]);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { fetchPosts(); }, [selectedBrand, sortBy]);

const fetchPosts = async () => {
setLoading(true);
Expand Down
26 changes: 7 additions & 19 deletions src/components/GachaPostDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,8 @@ export default function GachaPostDetail({ postId }: { postId: string }) {

const isAdmin = profile?.is_admin || false;

useEffect(() => {
fetchPost();
}, [postId]);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { fetchPost(); }, [postId]);

const fetchPost = async () => {
setLoading(true);
Expand Down Expand Up @@ -234,23 +233,12 @@ export default function GachaPostDetail({ postId }: { postId: string }) {

<hr className='mb-8' />

{/* 이미지 1 (대표) + 캡션 */}
{mainImage && (
{/* 첫 번째 이미지 캡션 (이미지는 상단 풀블리드에서 이미 표시) */}
{mainImage && allCaptions[0] && (
<div className='mb-8'>
<div className='relative w-full rounded-xl overflow-hidden bg-gray-100' style={{ aspectRatio: '4/3' }}>
<Image
loader={wsrvLoader}
src={mainImage}
alt={`${post.title} - 이미지 1`}
fill
className='object-contain'
/>
</div>
{allCaptions[0] && (
<p className='mt-3 text-sm text-gray-600 leading-relaxed whitespace-pre-wrap'>
{allCaptions[0]}
</p>
)}
<p className='text-sm text-gray-600 leading-relaxed whitespace-pre-wrap'>
{allCaptions[0]}
</p>
</div>
)}

Expand Down
9 changes: 3 additions & 6 deletions src/components/GachaPostEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,8 @@ export default function GachaPostEditor({ postId }: { postId?: string }) {
}
}, [authLoading, profile, router]);

useEffect(() => {
if (postId) {
fetchPost();
}
}, [postId]);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { if (postId) fetchPost(); }, [postId]);

const fetchPost = async () => {
try {
Expand Down Expand Up @@ -207,7 +204,7 @@ export default function GachaPostEditor({ postId }: { postId?: string }) {
setUploadItems((prev) =>
prev.map((i) => (i.id === item.id ? { ...i, publicUrl, status: 'done' } : i)),
);
} catch (err) {
} catch {
if (!isMounted.current) return;
setUploadItems((prev) =>
prev.map((i) => (i.id === item.id ? { ...i, status: 'error' } : i)),
Expand Down
93 changes: 69 additions & 24 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import { useState } from 'react';
import Image from 'next/image';
import { supabase } from '@/lib/supabase';
import Link from 'next/link';
import { LogOut, User as UserIcon, Settings } from 'lucide-react';
import { LogOut, User as UserIcon, Settings, X, Map, Newspaper } from 'lucide-react';
import UserProfileModal from './UserProfileModal';
import { useAuthStore } from '@/store/useAuthStore';

export default function Header() {
const { user, profile, isLoading, logout, loadProfile } = useAuthStore();
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);

// 카카오 로그인 핸들러
const handleLogin = async () => {
await supabase.auth.signInWithOAuth({
provider: 'kakao',
Expand All @@ -22,38 +22,32 @@ export default function Header() {
});
};

// 표시할 닉네임 결정
const displayName = profile?.nickname || user?.user_metadata.full_name || '사용자';

// 표시할 아바타 URL 결정
const displayAvatar = profile?.avatar_url || user?.user_metadata.avatar_url;

return (
<>
<header className='sticky top-0 left-0 right-0 z-50 px-4 py-3 flex items-center justify-between pointer-events-none bg-white shadow-md'>
{/* 로고 영역 */}
<div className='pointer-events-auto flex items-center justify-center gap-1'>
<Image src={'/logo.png'} alt='가챠맵 로고' width={50} height={50} />
<Link href='/' className='text-xl text-neutral-900 px-3 py-1'>
전국 가챠 지도 1.01v
</Link>
<Link
href='/gacha-board'
className='hidden sm:inline-flex items-center px-3 py-1.5 text-sm font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-full transition-colors'
{/* 로고 영역 — 클릭 시 사이드바 */}
<div className='pointer-events-auto flex items-center gap-1'>
<button
onClick={() => setIsSidebarOpen(true)}
className='flex items-center gap-1 rounded-lg hover:bg-gray-100 transition-colors p-1'
aria-label='메뉴 열기'
>
신규 가챠 정보
</Link>
<Image src='/logo.png' alt='가챠맵 로고' width={40} height={40} />
<span className='text-sm sm:text-lg font-medium text-neutral-900 px-1'>
전국 가챠 지도
</span>
</button>
</div>

{/* 우측 로그인/유저 정보 영역 */}
{/* 우측 로그인/유저 영역 */}
<div className='pointer-events-auto flex items-center gap-2'>
{isLoading ? (
// 로딩 스켈레톤
<div className='w-20 h-9 bg-gray-200 rounded-full animate-pulse' />
) : user ? (
// 로그인 상태일 때
<div className='flex items-center gap-2 bg-white/90 backdrop-blur-sm p-1 pr-3 rounded-full shadow-md border border-gray-200'>
{/* 프로필 이미지 (클릭 시 수정 모달) */}
<button
onClick={() => setIsProfileModalOpen(true)}
className='relative group'
Expand All @@ -72,7 +66,6 @@ export default function Header() {
<UserIcon size={16} />
</div>
)}
{/* 호버 시 설정 아이콘 표시 */}
<div className='absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity'>
<Settings size={14} className='text-blue-600' />
</div>
Expand All @@ -91,25 +84,77 @@ export default function Header() {
</button>
</div>
) : (
// 비로그인 상태일 때
<button
onClick={handleLogin}
className='bg-[#FEE500] hover:bg-[#FDD835] text-black text-sm font-bold px-4 py-2 rounded-full shadow-md transition-transform active:scale-95 flex items-center gap-2'
>
<span className='hidden xs:inline'>카카오</span>로그인
카카오 로그인
</button>
)}
</div>
</header>

{/* 사이드바 오버레이 */}
{isSidebarOpen && (
<div
className='fixed inset-0 z-50 bg-black/40'
onClick={() => setIsSidebarOpen(false)}
/>
)}

{/* 사이드바 */}
<aside
className={`fixed top-0 left-0 z-50 h-full w-64 bg-white shadow-xl transform transition-transform duration-300 flex flex-col ${
isSidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
{/* 사이드바 헤더 */}
<div className='flex items-center justify-between px-5 py-4 border-b'>
<div className='flex items-center gap-2'>
<Image src='/logo.png' alt='가챠맵 로고' width={32} height={32} />
<span className='font-bold text-gray-900'>가챠 지도</span>
</div>
<button
onClick={() => setIsSidebarOpen(false)}
className='p-1.5 rounded-lg hover:bg-gray-100 transition-colors text-gray-500'
>
<X size={20} />
</button>
</div>

{/* 메뉴 */}
<nav className='flex-1 px-3 py-4 space-y-1'>
<Link
href='/'
onClick={() => setIsSidebarOpen(false)}
className='flex items-center gap-3 px-4 py-3 rounded-xl text-gray-700 hover:bg-gray-100 transition-colors font-medium'
>
<Map size={20} className='text-blue-500' />
가챠 지도
</Link>
<Link
href='/gacha-board'
onClick={() => setIsSidebarOpen(false)}
className='flex items-center gap-3 px-4 py-3 rounded-xl text-gray-700 hover:bg-gray-100 transition-colors font-medium'
>
<Newspaper size={20} className='text-blue-500' />
신규 가챠·쿠지 정보
</Link>
</nav>

{/* 하단 버전 */}
<div className='px-5 py-4 border-t text-xs text-gray-400'>
전국 가챠 지도 1.01v
</div>
</aside>

{/* 프로필 수정 모달 */}
{user && (
<UserProfileModal
user={user}
isOpen={isProfileModalOpen}
onClose={() => {
setIsProfileModalOpen(false);
// 모달 닫을 때 프로필 데이터 새로고침
if (user) loadProfile(user.id);
}}
/>
Expand Down
2 changes: 0 additions & 2 deletions src/components/Map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,13 @@ export default function KakaoMap({ shops }: MapProps) {
const loadKakaoMap = () => {
if (window.kakao && window.kakao.maps) {
window.kakao.maps.load(() => {
console.log('카카오맵 로드 완료');
setIsMapLoaded(true);
});
} else {
const checkInterval = setInterval(() => {
if (window.kakao && window.kakao.maps) {
clearInterval(checkInterval);
window.kakao.maps.load(() => {
console.log('카카오맵 로드 완료 (재시도)');
setIsMapLoaded(true);
});
}
Expand Down
8 changes: 4 additions & 4 deletions src/components/ShopDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export default function ShopDetailView({ shop, onClose, onBackToList }: ShopDeta
.from('reviews')
.select('*')
.eq('shop_id', shop.id)
.order('created_at', { ascending: false });
.order('created_at', { ascending: false })
.limit(10);

if (error) throw error;

Expand All @@ -59,9 +60,8 @@ export default function ShopDetailView({ shop, onClose, onBackToList }: ShopDeta
}
};

useEffect(() => {
fetchReviews();
}, [shop.id]);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { fetchReviews(); }, [shop.id]);

const nextImage = () => {
if (hasImages && shop) {
Expand Down
6 changes: 0 additions & 6 deletions src/lib/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,12 @@ export const supabase = createClient(supabaseUrl, supabaseKey, {
* 모든 인증된 가게 목록을 가져오는 함수
*/
export async function getVerifiedShops(): Promise<Shop[]> {
console.log('🔍 Fetching shops from Supabase...');

const { data, error } = await supabase.from('shops').select('*').eq('is_verified', true);

if (error) {
console.error('❌ Supabase Error:', error);
console.error('Error details:', JSON.stringify(error, null, 2));
return [];
}

console.log('✅ Fetched shops:', data?.length || 0);
console.log('Sample data:', data?.[0]); // 첫 번째 데이터 확인

return data as Shop[];
}
6 changes: 4 additions & 2 deletions src/store/useAuthStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({

// 리스너 등록 + subscription 저장
const { data } = supabase.auth.onAuthStateChange(async (event, session) => {
console.log('🔄 Auth Event:', event);

if (event === 'SIGNED_IN' && session?.user) {
// 이미 같은 유저 + 프로필 로드 완료 상태면 재로드 생략
// (페이지 이동, 창 포커스 복귀 등으로 SIGNED_IN이 재발생하는 경우 방지)
const currentUser = get().user;
if (currentUser?.id === session.user.id && get().profile) return;
set({ user: session.user });
await get().loadProfile(session.user.id);
}
Expand Down