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 (
+ { void handleFollow(); }}
+ disabled={isLoading}
+ className={`px-6 py-2 rounded font-semibold text-sm ${
+ isFollowing
+ ? 'bg-gray-200 text-gray-800 hover:bg-gray-300'
+ : 'bg-blue-500 text-white hover:bg-blue-600'
+ } transition-colors duration-200 disabled:opacity-50`}
+ >
+ {isLoading ? 'Loading...' : isFollowing ? 'Following' : 'Follow'}
+
+ );
+};
+
+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}
-
- Follow
-
-
+
+
+
0 ? profileImage : '/placeholder.svg'}
+ alt={username}
+ className="w-full h-full object-cover"
+ />
+
+
+
+
+
{username}
+
+ {isCurrentUser ? (
+
+ Edit Profile
+
+ ) : (
+
+ )}
+
-
-
- {posts} posts
-
-
- {followers} followers
-
-
- {following} following
-
+
+
+
+ {posts}
+ posts
+
+
+ {currentFollowers}
+ followers
+
+
+ {following}
+ following
+
+
+
+
+
{fullName}
+ {bio.length > 0 &&
{bio}
}
+ {website !== null && website.length > 0 && (
+
+
+ {website}
+
+ )}
-
);
};
-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"
+ />
+
+
+
+ Change Photo
+
+ { void handleImageUpload(e); }}
+ className="hidden"
+ disabled={isUploading}
+ />
+
+ {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 (
-
-
{
- setActiveTab('posts');
- }}
- >
-
-
-
{
- setActiveTab('saved');
- }}
- >
-
-
+
- {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 != 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 */}
+
+
+
+
+ {/* Info Side */}
+
+ {/* Header */}
+
+
+
{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