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
52 changes: 52 additions & 0 deletions .github/workflows/mobile-reader.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: 📖 Mobile Reader

on:
pull_request:
types: [opened, synchronize, reopened]

permissions:
pull-requests: write

jobs:
generate-reader:
name: Generate Mobile Reader View
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Generate Reader Markdown
run: npx github-mobile-reader@latest --repo ${{ github.repository }} --pr ${{ github.event.pull_request.number }} --out ./reader-output
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Post PR Comment
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = './reader-output/pr-${{ github.event.pull_request.number }}.md';
if (!fs.existsSync(path)) { console.log('No reader file generated.'); return; }
const body = fs.readFileSync(path, 'utf8');
const comments = await github.rest.issues.listComments({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: ${{ github.event.pull_request.number }},
});
const prev = comments.data.find(c =>
c.user.login === 'github-actions[bot]' && c.body.startsWith('# 📖 PR #')
);
if (prev) await github.rest.issues.deleteComment({
owner: context.repo.owner, repo: context.repo.repo, comment_id: prev.id,
});
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: ${{ github.event.pull_request.number }}, body,
});
38 changes: 38 additions & 0 deletions src/app/api/delete-account/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { createClient } from '@supabase/supabase-js';
import { NextResponse } from 'next/server';

export async function DELETE(request: Request) {
// 요청 시점에 클라이언트 생성 (빌드 타임 평가 방지)
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
);

// Authorization 헤더에서 액세스 토큰 추출
const authHeader = request.headers.get('Authorization');
const accessToken = authHeader?.replace('Bearer ', '');

if (!accessToken) {
return NextResponse.json({ error: '인증이 필요합니다.' }, { status: 401 });
}

// 토큰으로 실제 로그인한 유저 확인
const { data: { user }, error: authError } = await supabaseAdmin.auth.getUser(accessToken);

if (authError || !user) {
return NextResponse.json({ error: '유효하지 않은 토큰입니다.' }, { status: 401 });
}

// profiles 테이블 삭제
await supabaseAdmin.from('profiles').delete().eq('id', user.id);

// Supabase Auth 유저 삭제
const { error } = await supabaseAdmin.auth.admin.deleteUser(user.id);

if (error) {
console.error('회원 탈퇴 실패:', error);
return NextResponse.json({ error: '회원 탈퇴에 실패했습니다.' }, { status: 500 });
}

return NextResponse.json({ success: true });
}
116 changes: 83 additions & 33 deletions src/components/UserProfileModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/lib/supabase';
import { User } from '@supabase/supabase-js';
import { X, Camera, Save } from 'lucide-react';
import { X, Camera, Save, Trash2 } from 'lucide-react';
import Image from 'next/image';
import { wsrvLoader } from '@/components/common/wsrvLoader';
import { useAuthStore } from '@/store/useAuthStore';
import { useRouter } from 'next/navigation';

interface UserProfileModalProps {
user: User;
Expand All @@ -14,16 +16,20 @@ interface UserProfileModalProps {
}

export default function UserProfileModal({ user, isOpen, onClose }: UserProfileModalProps) {
const router = useRouter();
const { deleteAccount } = useAuthStore();
const [nickname, setNickname] = useState('');
const [avatarUrl, setAvatarUrl] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// 탈퇴 확인 단계: false → 확인창 표시 → true → 처리 중
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteConfirmInput, setDeleteConfirmInput] = useState('');
const [isDeleting, setIsDeleting] = useState(false);

const loadProfile = useCallback(async () => {
if (!user) return;

setIsLoading(true);

const { data } = await supabase
.from('profiles')
.select('nickname, avatar_url')
Expand All @@ -34,17 +40,17 @@ export default function UserProfileModal({ user, isOpen, onClose }: UserProfileM
setNickname(data.nickname || '');
setAvatarUrl(data.avatar_url || user.user_metadata.avatar_url || '');
} else {
// 프로필이 없으면 카카오 정보로 초기화
setNickname(user.user_metadata.full_name || '');
setAvatarUrl(user.user_metadata.avatar_url || '');
}

setIsLoading(false);
}, [user]);

useEffect(() => {
if (isOpen && user) {
loadProfile();
setShowDeleteConfirm(false);
setDeleteConfirmInput('');
}
}, [isOpen, user, loadProfile]);

Expand All @@ -53,21 +59,15 @@ export default function UserProfileModal({ user, isOpen, onClose }: UserProfileM
alert('닉네임을 입력해주세요.');
return;
}

setIsSaving(true);

// upsert: 있으면 업데이트, 없으면 생성
const { error } = await supabase.from('profiles').upsert({
id: user.id,
nickname: nickname.trim(),
avatar_url: avatarUrl,
updated_at: new Date().toISOString(),
});

setIsSaving(false);

if (error) {
console.error('프로필 저장 실패:', error);
alert('프로필 저장에 실패했습니다.');
} else {
alert('프로필이 저장되었습니다!');
Expand All @@ -78,52 +78,51 @@ export default function UserProfileModal({ user, isOpen, onClose }: UserProfileM
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;

// 파일 크기 체크 (5MB 제한)
if (file.size > 5 * 1024 * 1024) {
alert('파일 크기는 5MB 이하여야 합니다.');
return;
}

// 이미지 파일 체크
if (!file.type.startsWith('image/')) {
alert('이미지 파일만 업로드 가능합니다.');
return;
}

setIsLoading(true);

// Supabase Storage에 업로드
const fileExt = file.name.split('.').pop();
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
const filePath = `avatars/${fileName}`;

const { error: uploadError } = await supabase.storage.from('profiles').upload(filePath, file);

if (uploadError) {
console.error('업로드 실패:', uploadError);
alert('이미지 업로드에 실패했습니다.');
setIsLoading(false);
return;
}

// Public URL 가져오기
const { data } = supabase.storage.from('profiles').getPublicUrl(filePath);

setAvatarUrl(data.publicUrl);
setIsLoading(false);
};

const handleDeleteAccount = async () => {
if (deleteConfirmInput !== '탈퇴') return;
setIsDeleting(true);
try {
await deleteAccount();
alert('회원 탈퇴가 완료됐습니다.');
onClose();
router.push('/');
} catch {
alert('회원 탈퇴에 실패했습니다. 다시 시도해주세요.');
} finally {
setIsDeleting(false);
}
};

if (!isOpen) return null;

return (
<div className='fixed inset-0 z-[60] flex items-center justify-center'>
{/* 배경 오버레이 */}
<div className='absolute inset-0 bg-black/50' onClick={onClose} />

{/* 모달 */}
<div className='relative bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4 p-6'>
{/* 닫기 버튼 */}
<button
onClick={onClose}
className='absolute top-4 right-4 p-2 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors'
Expand All @@ -132,12 +131,11 @@ export default function UserProfileModal({ user, isOpen, onClose }: UserProfileM
<X className='w-5 h-5 text-gray-600' />
</button>

{/* 제목 */}
<h2 className='text-2xl font-bold mb-6'>프로필 수정</h2>

{isLoading ? (
<div className='flex justify-center items-center h-48'>
<div className='animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600'></div>
<div className='animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600' />
</div>
) : (
<div className='space-y-6'>
Expand All @@ -160,8 +158,6 @@ export default function UserProfileModal({ user, isOpen, onClose }: UserProfileM
</div>
)}
</div>

{/* 카메라 버튼 */}
<label
htmlFor='avatar-upload'
className='absolute bottom-0 right-0 bg-blue-600 hover:bg-blue-700 text-white p-2 rounded-full cursor-pointer transition-colors shadow-lg'
Expand All @@ -179,7 +175,7 @@ export default function UserProfileModal({ user, isOpen, onClose }: UserProfileM
<p className='text-xs text-gray-500 mt-2'>클릭하여 이미지 변경 (최대 5MB)</p>
</div>

{/* 닉네임 입력 */}
{/* 닉네임 */}
<div>
<label className='block text-sm font-semibold text-gray-700 mb-2'>닉네임</label>
<input
Expand Down Expand Up @@ -213,7 +209,7 @@ export default function UserProfileModal({ user, isOpen, onClose }: UserProfileM
>
{isSaving ? (
<>
<div className='animate-spin rounded-full h-5 w-5 border-b-2 border-white'></div>
<div className='animate-spin rounded-full h-5 w-5 border-b-2 border-white' />
저장 중...
</>
) : (
Expand All @@ -223,6 +219,60 @@ export default function UserProfileModal({ user, isOpen, onClose }: UserProfileM
</>
)}
</button>

{/* 회원 탈퇴 */}
<div className='border-t border-gray-100 pt-4'>
{!showDeleteConfirm ? (
<button
onClick={() => setShowDeleteConfirm(true)}
className='flex items-center gap-1.5 text-sm text-gray-400 hover:text-red-500 transition-colors'
>
<Trash2 size={14} />
회원 탈퇴
</button>
) : (
<div className='bg-red-50 border border-red-200 rounded-lg p-4 space-y-3'>
<p className='text-sm font-semibold text-red-700'>정말 탈퇴하시겠습니까?</p>
<p className='text-xs text-red-500'>
탈퇴 시 모든 정보가 삭제되며 복구할 수 없습니다.
<br />
확인하려면 아래에 <strong>탈퇴</strong>를 입력하세요.
</p>
<input
type='text'
value={deleteConfirmInput}
onChange={(e) => setDeleteConfirmInput(e.target.value)}
placeholder='탈퇴'
className='w-full border border-red-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-400'
/>
<div className='flex gap-2'>
<button
onClick={() => {
setShowDeleteConfirm(false);
setDeleteConfirmInput('');
}}
className='flex-1 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors'
>
취소
</button>
<button
onClick={handleDeleteAccount}
disabled={deleteConfirmInput !== '탈퇴' || isDeleting}
className='flex-1 py-2 text-sm bg-red-500 hover:bg-red-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-lg transition-colors flex items-center justify-center gap-1'
>
{isDeleting ? (
<div className='animate-spin rounded-full h-4 w-4 border-b-2 border-white' />
) : (
<>
<Trash2 size={14} />
탈퇴하기
</>
)}
</button>
</div>
</div>
)}
</div>
</div>
)}
</div>
Expand Down
29 changes: 29 additions & 0 deletions src/store/useAuthStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface AuthState {
loadProfile: (userId: string) => Promise<void>;
initialize: () => Promise<void>;
logout: () => Promise<void>;
deleteAccount: () => Promise<void>;
}

export const useAuthStore = create<AuthState>((set, get) => ({
Expand Down Expand Up @@ -101,4 +102,32 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({ user: null, profile: null });
alert('로그아웃 되었습니다.');
},

// 회원 탈퇴
deleteAccount: async () => {
const user = get().user;
if (!user) return;

// 현재 세션의 액세스 토큰 가져오기
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
throw new Error('세션이 만료됐습니다. 다시 로그인해주세요.');
}

// 서버에 액세스 토큰을 전달해 본인 확인 후 삭제
const res = await fetch('/api/delete-account', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`,
},
});

if (!res.ok) {
throw new Error('회원 탈퇴에 실패했습니다.');
}

await supabase.auth.signOut();
set({ user: null, profile: null });
},
}));