diff --git a/src/components/profile/FollowButton.tsx b/src/components/profile/FollowButton.tsx new file mode 100644 index 0000000..f83f7a6 --- /dev/null +++ b/src/components/profile/FollowButton.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +interface FollowButtonProps { + userId: number; + onFollowChange?: (isFollowing: boolean) => void; +} + +const FollowButton = ({ userId, onFollowChange }: FollowButtonProps) => { + const [isFollowing, setIsFollowing] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const navigate = useNavigate(); + + const handleFollow = async () => { + try { + setIsLoading(true); + const token = localStorage.getItem('access_token'); + if (token === null) { + void navigate('/'); + return; + } + + const endpoint = isFollowing ? 'unfollow' : 'follow'; + const response = await fetch( + `http://3.34.185.81:8000/api/follower/${endpoint}?follow_id=${userId}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + if (!response.ok) { + throw new Error(`Failed to ${endpoint}`); + } + + setIsFollowing(!isFollowing); + onFollowChange?.(!isFollowing); + } catch (error) { + console.error('Error following/unfollowing:', error); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +}; + +export default FollowButton; \ No newline at end of file diff --git a/src/components/profile/ProfileInfo.tsx b/src/components/profile/ProfileInfo.tsx index 6fd7e32..fcc3bdf 100644 --- a/src/components/profile/ProfileInfo.tsx +++ b/src/components/profile/ProfileInfo.tsx @@ -1,57 +1,139 @@ -import { Settings } from 'lucide-react'; +import { Link as LinkIcon } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; -type ProfileInfoProps = { +import FollowButton from './FollowButton'; + +interface ProfileInfoProps { + userId: number; username: string; posts: number; followers: number; following: number; fullName: string; bio: string; -}; + website: string | null; + profileImage: string; +} const ProfileInfo = ({ + userId, username, posts, followers, following, fullName, bio, + website, + profileImage, }: ProfileInfoProps) => { + const [isCurrentUser, setIsCurrentUser] = useState(false); + const [currentFollowers, setCurrentFollowers] = useState(followers); + const navigate = useNavigate(); + + useEffect(() => { + const checkCurrentUser = async () => { + try { + const token = localStorage.getItem('access_token'); + if (token === null) { + void navigate('/'); + return; + } + + const response = await fetch('http://3.34.185.81:8000/api/user/profile', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch user profile'); + } + + interface UserProfile { + user_id: number; + } + + const data: UserProfile = await response.json() as UserProfile; + setIsCurrentUser(data.user_id === userId); + } catch (error) { + console.error('Error checking current user:', error); + } + }; + + void checkCurrentUser(); + }, [userId, navigate]); + + const handleFollowChange = (isFollowing: boolean) => { + setCurrentFollowers((prev) => (isFollowing ? prev + 1 : prev - 1)); + }; + + const handleEditProfile = () => { + void navigate(`/settings`); + }; + return (
-
- {username} -
-
-

{username}

- - +
+
+ 0 ? profileImage : '/placeholder.svg'} + alt={username} + className="w-full h-full object-cover" + /> +
+ +
+
+

{username}

+
+ {isCurrentUser ? ( + + ) : ( + + )} +
-
- - {posts} posts - - - {followers} followers - - - {following} following - + +
+
+ {posts} + posts +
+
+ {currentFollowers} + followers +
+
+ {following} + following +
+
+ +
+

{fullName}

+ {bio.length > 0 &&

{bio}

} + {website !== null && website.length > 0 && ( + + + {website} + + )}
-
-

{fullName}

-

{bio}

-
); }; -export default ProfileInfo; +export default ProfileInfo; \ No newline at end of file diff --git a/src/components/profile/ProfilePictureUpload.tsx b/src/components/profile/ProfilePictureUpload.tsx new file mode 100644 index 0000000..f36184f --- /dev/null +++ b/src/components/profile/ProfilePictureUpload.tsx @@ -0,0 +1,77 @@ +import { Camera } from 'lucide-react'; +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +interface ProfilePictureUploadProps { + currentImage: string; + onSuccess: (newImageUrl: string) => void; +} + +const ProfilePictureUpload = ({ currentImage, onSuccess }: ProfilePictureUploadProps) => { + const [isUploading, setIsUploading] = useState(false); + const navigate = useNavigate(); + + const handleImageUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file == null) return; + + try { + setIsUploading(true); + const token = localStorage.getItem('access_token'); + if (token == null) { + void navigate('/'); + return; + } + + const formData = new FormData(); + formData.append('profile_image', file); + + const response = await fetch('http://3.34.185.81:8000/api/user/profile/edit', { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + }, + body: formData, + }); + + if (!response.ok) throw new Error('Failed to update profile picture'); + + const data = (await response.json()) as { profile_image: string }; + onSuccess(data.profile_image); + } catch (error) { + console.error('Error uploading profile picture:', error); + } finally { + setIsUploading(false); + } + }; + + return ( +
+ 0 ? currentImage : '/placeholder.svg'} + alt="Profile" + className="w-full h-full object-cover rounded-full" + /> + + {isUploading && ( +
+
+
+ )} +
+ ); +}; + +export default ProfilePictureUpload; \ No newline at end of file diff --git a/src/components/profile/ProfileTabs.tsx b/src/components/profile/ProfileTabs.tsx index c51fef6..a45a16b 100644 --- a/src/components/profile/ProfileTabs.tsx +++ b/src/components/profile/ProfileTabs.tsx @@ -1,35 +1,38 @@ -import { Bookmark, Grid } from 'lucide-react'; +import { Grid } from 'lucide-react'; import { useState } from 'react'; +import type { APIPost } from '../../types/post'; import PostGrid from '../shared/PostGrid'; +import PostModal from '../shared/PostModal'; -const ProfileTabs = () => { - const [activeTab, setActiveTab] = useState('posts'); +interface ProfileTabsProps { + postIds: number[]; +} + +const ProfileTabs = ({ postIds }: ProfileTabsProps) => { + const [selectedPost, setSelectedPost] = useState(null); return (
-
- - +
+
+ +
- {activeTab === 'posts' && } - {activeTab === 'saved' &&
저장됨
} + + { setSelectedPost(post); }} + /> + + {selectedPost !== null && ( + { setSelectedPost(null); }} /> + )}
); }; -export default ProfileTabs; +export default ProfileTabs; \ No newline at end of file diff --git a/src/components/shared/PostGrid.tsx b/src/components/shared/PostGrid.tsx index 48e56d3..face7f0 100644 --- a/src/components/shared/PostGrid.tsx +++ b/src/components/shared/PostGrid.tsx @@ -1,13 +1,90 @@ -const PostGrid = () => { - const posts = Array(4).fill(null); +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import type { APIPost } from '../../types/post'; + +interface PostGridProps { + postIds: number[]; + onPostClick: (post: APIPost) => void; +} + +const PostGrid = ({ postIds, onPostClick }: PostGridProps) => { + const [posts, setPosts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const navigate = useNavigate(); + + useEffect(() => { + const fetchPosts = async () => { + try { + setIsLoading(true); + const token = localStorage.getItem('access_token'); + if (token === null) { + void navigate('/'); + return; + } + + const postsPromises = postIds.map(id => + fetch(`http://3.34.185.81:8000/api/post/${id}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }).then(res => res.json()) + ); + + const postsData = await Promise.all(postsPromises); + setPosts(postsData); + } catch (error) { + console.error('Error fetching posts:', error); + } finally { + setIsLoading(false); + } + }; + + void fetchPosts(); + }, [postIds, navigate]); + + const getFullImageUrl = (url: string) => { + if (url.startsWith('http')) return url; + return `http://3.34.185.81:8000/${url.replace(/^\/+/, '')}`; + }; + + if (isLoading) { + return ( +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+ ))} +
+ ); + } return (
- {posts.map((_, index) => ( -
+ {posts.map((post) => ( +
{ onPostClick(post); }} + className="aspect-square relative group cursor-pointer" + > + {post.post_text +
+
+ {((post.post_text != null) || (post.location != null)) && ( +
+ {(post.post_text != null) &&

{post.post_text}

} + {(post.location != null) &&

{post.location}

} +
+ )} +
+
+
))}
); }; -export default PostGrid; +export default PostGrid; \ No newline at end of file diff --git a/src/components/shared/PostModal.tsx b/src/components/shared/PostModal.tsx new file mode 100644 index 0000000..dfa83c1 --- /dev/null +++ b/src/components/shared/PostModal.tsx @@ -0,0 +1,119 @@ +import { X } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +import type { APIPost, FeedPost } from '../../types/post'; + +interface PostModalProps { + post: APIPost | FeedPost; + onClose: () => void; +} + +interface UserResponse { + username: string; + // Add other fields as needed +} + +const PostModal = ({ post, onClose }: PostModalProps) => { + const [username, setUsername] = useState(''); + + useEffect(() => { + // If it's a feed post, we already have the username + if ('username' in post) { + setUsername(post.username); + return; + } + + // If it's an API post, we need to fetch the username + const fetchUsername = async () => { + try { + const token = localStorage.getItem('access_token'); + if (token === null) return; + + const response = await fetch(`http://3.34.185.81:8000/api/user/profile/${post.user_id}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) throw new Error('Failed to fetch user'); + + const data = await response.json() as UserResponse; + if (typeof data.username === 'string') { + setUsername(data.username); + } + } catch (error) { + console.error('Error fetching username:', error); + } + }; + + void fetchUsername(); + }, [post]); + + const getImageUrl = () => { + if ('imageUrl' in post) return post.imageUrl; + return post.file_url[0] != null ? + `http://3.34.185.81:8000/${post.file_url[0].replace(/^\/+/, '')}` : + '/placeholder.svg'; + }; + + const getCaption = () => { + if ('caption' in post) return post.caption; + return post.post_text; + }; + + const getTimestamp = () => { + if ('timestamp' in post) return post.timestamp; + return post.creation_date; + }; + + return ( +
+
+
{ e.stopPropagation(); }} + > + {/* Image Side */} +
+ {getCaption() +
+ + {/* Info Side */} +
+ {/* Header */} +
+ {username} + {username} + +
+ + {/* Caption */} +
+ {getCaption() !== null &&

{getCaption()}

} + {'location' in post && post.location !== null && ( +

+ {post.location} +

+ )} +

+ {getTimestamp()} +

+
+
+
+
+
+ ); +}; + +export default PostModal; \ No newline at end of file diff --git a/src/pages/ExplorePage.tsx b/src/pages/ExplorePage.tsx index 826d4f6..359a611 100644 --- a/src/pages/ExplorePage.tsx +++ b/src/pages/ExplorePage.tsx @@ -1,13 +1,31 @@ +import { useState } from 'react'; + import MobileBar from '../components/layout/MobileBar'; import SideBar from '../components/layout/SideBar'; import PostGrid from '../components/shared/PostGrid'; +import PostModal from '../components/shared/PostModal'; +import type { APIPost } from '../types/post'; const ExplorePage = () => { + const [selectedPost, setSelectedPost] = useState(null); + + // This is a placeholder. In a real app, you'd fetch this from an API + const dummyPostIds = [1, 2, 3, 4, 5, 6]; + return (
- + + {selectedPost !== null && ( + { setSelectedPost(null); }} + /> + )}
@@ -19,4 +37,4 @@ const ExplorePage = () => { ); }; -export default ExplorePage; +export default ExplorePage; \ No newline at end of file diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 84fb5c2..89481e8 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -1,3 +1,5 @@ +import { Settings } from 'lucide-react'; +import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import MobileBar from '../components/layout/MobileBar'; @@ -6,30 +8,89 @@ import SideBar from '../components/layout/SideBar'; import Highlights from '../components/profile/Highlights'; import ProfileInfo from '../components/profile/ProfileInfo'; import ProfileTabs from '../components/profile/ProfileTabs'; +import type { UserProfile } from '../types/user'; const ProfilePage = () => { const { username } = useParams(); - const profileData = { - username: username as string, - posts: 4, - followers: 100, - following: 100, - fullName: 'User1', - bio: 'User1 Bio', - }; + const [profile, setProfile] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchProfile = async () => { + if (username === undefined) return; + + try { + setIsLoading(true); + const token = localStorage.getItem('access_token'); + + if (token === null) { + throw new Error('No authentication token found'); + } + + const response = await fetch(`http://3.34.185.81:8000/api/user/${username}`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch profile'); + } + + const data = await response.json() as UserProfile; + setProfile(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load profile'); + console.error('Error fetching profile:', err); + } finally { + setIsLoading(false); + } + }; + + void fetchProfile(); + }, [username]); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error !== null || profile === null) { + return ( +
+
Error: {error ?? 'Profile not found'}
+
+ ); + } return (
- - -
-

{profileData.fullName}

-

{profileData.bio}

+
+ +
+ - +
@@ -41,4 +102,4 @@ const ProfilePage = () => { ); }; -export default ProfilePage; +export default ProfilePage; \ No newline at end of file diff --git a/src/types/post.ts b/src/types/post.ts index d0d8b47..02c409d 100644 --- a/src/types/post.ts +++ b/src/types/post.ts @@ -1,4 +1,13 @@ -export interface Post { +export interface APIPost { + post_id: number; + user_id: number; + post_text: string | null; + location: string | null; + creation_date: string; + file_url: string[]; +} + +export interface FeedPost { id: number; username: string; imageUrl: string; @@ -9,6 +18,8 @@ export interface Post { } export interface PostsProps { - posts: Post[]; + posts: FeedPost[]; postsPerPage: number; } + +export type Post = FeedPost; \ No newline at end of file diff --git a/src/types/user.ts b/src/types/user.ts index edf19a3..f5b6673 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -10,8 +10,10 @@ export interface UserProfile { birthday: string | null; introduce: string | null; website: string | null; - followers: number; - following: number; + follower_count: number; + following_count: number; + followers: number[]; + following: number[]; post_count: number; post_ids: number[]; -} +} \ No newline at end of file