feat: add frontend in /frontend

This commit is contained in:
MWorld Deployer 2026-07-15 12:36:03 +05:30
parent 9aac2ee90a
commit f64ea867bc
27 changed files with 4178 additions and 0 deletions

4
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
.wrangler
.env

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Instagram</title>
<script async src="https://data-analytics.mworld.cloud/analytics.js" data-site-id="d9wl4z"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3098
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
frontend/package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "instagram-clone",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^3.3.4",
"@tanstack/react-query": "^5.28.6",
"axios": "^1.6.8",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"date-fns": "^3.6.0",
"framer-motion": "^11.0.20",
"lucide-react": "^0.363.0",
"mauth-react": "0.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.2",
"react-router-dom": "^6.22.3",
"sonner": "^1.4.41",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@typescript-eslint/eslint-plugin": "^7.1.1",
"@typescript-eslint/parser": "^7.1.1",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.18",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.2.2",
"vite": "^5.1.6"
}
}

View File

@ -0,0 +1 @@
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }

24
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,24 @@
import { Routes, Route } from 'react-router-dom';
import { Toaster } from 'sonner';
import { FeedPage, CreatePostPage, ProfilePage, MyProfilePage, LoginPage } from './pages';
import Layout from './components/Layout';
import ProtectedRoute from './components/ProtectedRoute';
export default function App() {
return (
<>
<Toaster position="top-center" richColors />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}>
<Route element={<Layout />}>
<Route path="/" element={<FeedPage />} />
<Route path="/create" element={<CreatePostPage />} />
<Route path="/profile" element={<MyProfilePage />} />
<Route path="/profile/:username" element={<ProfilePage />} />
</Route>
</Route>
</Routes>
</>
);
}

33
frontend/src/api.ts Normal file
View File

@ -0,0 +1,33 @@
import axios from 'axios';
export const API_URL = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
? "http://127.0.0.1:8787"
: "https://app-d9wl4z.laflabs-tech.workers.dev";
export const api = axios.create({
baseURL: API_URL
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
config.headers['x-mworld-origin'] = currentOrigin;
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
const hadToken = localStorage.getItem('auth_token');
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
if (hadToken) window.location.reload();
}
return Promise.reject(error);
}
);

View File

@ -0,0 +1,81 @@
import { motion } from 'framer-motion';
import { X } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { api } from '../api';
import { useAuth } from '../context/AuthContext';
const commentSchema = z.object({
content: z.string().min(1, 'Comment cannot be empty').max(500),
});
type CommentFormData = z.infer<typeof commentSchema>;
interface CommentSheetProps {
postId: string;
onClose: () => void;
}
export default function CommentSheet({ postId, onClose }: CommentSheetProps) {
const { user } = useAuth();
const queryClient = useQueryClient();
const { register, handleSubmit, reset, formState: { errors } } = useForm<CommentFormData>({
resolver: zodResolver(commentSchema),
});
const { mutate: addComment, isPending } = useMutation({
mutationFn: (data: CommentFormData) => api.post(`/api/posts/${postId}/comments`, data),
onSuccess: () => {
toast.success('Comment added!');
queryClient.invalidateQueries({ queryKey: ['posts'] });
reset();
},
onError: (error) => {
toast.error('Failed to add comment.');
console.error(error);
}
});
const onSubmit = (data: CommentFormData) => {
addComment(data);
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex justify-center items-end" onClick={onClose}>
<motion.div
initial={{ y: '100%' }}
animate={{ y: '0%' }}
exit={{ y: '100%' }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="bg-canvas w-full max-w-md h-[80vh] rounded-t-lg flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<header className="flex items-center justify-between p-4 border-b border-hairline-soft">
<h2 className="font-bold text-lg text-ink">Comments</h2>
<button onClick={onClose}><X size={24} /></button>
</header>
<div className="flex-grow p-4 overflow-y-auto">
{/* NOTE: API to GET comments is not available in the spec. */}
<div className="text-center text-muted py-16">
<p>Be the first to comment!</p>
<p className="text-sm">(Viewing comments is not yet supported)</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="p-4 border-t border-hairline-soft flex items-center gap-2">
<img src={`https://api.dicebear.com/7.x/pixel-art/svg?seed=${user?.email}`} alt="Your avatar" className="w-10 h-10 rounded-full"/>
<input
{...register('content')}
placeholder="Add a comment..."
className="flex-grow bg-surface-soft border border-hairline rounded-full px-4 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
disabled={isPending}
/>
<button type="submit" disabled={isPending} className="text-primary font-semibold disabled:text-muted">Post</button>
</form>
{errors.content && <p className="text-xs text-red-500 px-4 pb-2">{errors.content.message}</p>}
</motion.div>
</div>
);
}

View File

@ -0,0 +1,14 @@
import type { ReactNode } from 'react';
import { Outlet } from 'react-router-dom';
import NavigationBar from './NavigationBar';
export default function Layout({ children }: { children?: ReactNode }) {
return (
<div className="bg-canvas min-h-screen">
<main className="max-w-md mx-auto pb-20">
{children || <Outlet />}
</main>
<NavigationBar />
</div>
);
}

View File

@ -0,0 +1,38 @@
import { NavLink } from 'react-router-dom';
import { Home, PlusSquare, User, CircleUser } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { cn } from '../lib/utils';
const navItems = [
{ path: '/', icon: Home, label: 'Feed' },
{ path: '/create', icon: PlusSquare, label: 'Create' },
{ path: '/profile', icon: User, label: 'Profile' },
];
export default function NavigationBar() {
const { user } = useAuth();
return (
<nav className="fixed bottom-0 left-0 right-0 h-16 bg-canvas border-t border-hairline z-50">
<div className="max-w-md mx-auto h-full flex justify-around items-center">
{navItems.map((item) => {
const path = item.path === '/profile' && user?.username ? `/profile/${user.username}` : item.path;
return (
<NavLink
key={item.path}
to={path}
className={({ isActive }) =>
cn(
'flex flex-col items-center justify-center text-muted-soft transition-colors',
isActive && 'text-ink'
)
}
>
<item.icon className="h-6 w-6" strokeWidth={2} />
</NavLink>
);
})}
</div>
</nav>
);
}

View File

@ -0,0 +1,111 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Heart, MessageCircle, Send, MoreHorizontal } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { api, API_URL } from '../api';
import { cn } from '../lib/utils';
import type { Post } from '../types';
import CommentSheet from './CommentSheet';
interface PostCardProps {
post: Post;
}
export default function PostCard({ post }: PostCardProps) {
const queryClient = useQueryClient();
const [showCommentSheet, setShowCommentSheet] = useState(false);
const { mutate: toggleLike } = useMutation({
mutationFn: (isLiked: boolean) => {
const method = isLiked ? 'delete' : 'post';
return api[method](`/api/posts/${post.id}/like`);
},
onMutate: async (isLiked: boolean) => {
await queryClient.cancelQueries({ queryKey: ['posts'] });
const previousPosts = queryClient.getQueryData<Post[]>(['posts']);
queryClient.setQueryData<Post[]>(['posts'], (oldPosts) =>
oldPosts?.map(p =>
p.id === post.id
? {
...p,
is_liked_by_user: !isLiked,
likes_count: p.likes_count + (!isLiked ? 1 : -1)
}
: p
)
);
return { previousPosts };
},
onError: (err, isLiked, context) => {
queryClient.setQueryData(['posts'], context?.previousPosts);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
}
});
const handleLike = () => {
toggleLike(post.is_liked_by_user);
};
const handleDoubleClick = () => {
if (!post.is_liked_by_user) {
toggleLike(false);
}
};
const mediaUrl = post.media_url.startsWith('/api/') ? `${API_URL}${post.media_url}` : post.media_url;
return (
<div className="bg-canvas border-b border-hairline-soft">
<div className="flex items-center p-3">
<img src={post.user_info?.avatarUrl} alt={post.user_info?.username} className="w-8 h-8 rounded-full mr-3" />
<span className="font-semibold text-sm text-ink">{post.user_info?.username}</span>
<button className="ml-auto"><MoreHorizontal className="text-ink" size={20}/></button>
</div>
<div onDoubleClick={handleDoubleClick} className="relative w-full aspect-square bg-hairline-soft">
{post.media_type === 'video' ? (
<video src={mediaUrl} controls autoPlay muted loop className="w-full h-full object-cover" />
) : (
<img src={mediaUrl} alt={post.caption || 'Post image'} className="w-full h-full object-cover" />
)}
</div>
<div className="p-3">
<div className="flex items-center space-x-4 mb-2">
<motion.button whileTap={{ scale: 0.9 }} onClick={handleLike}>
<Heart
className={cn('transition-colors', post.is_liked_by_user ? 'text-primary fill-current' : 'text-ink')}
size={24}
/>
</motion.button>
<button onClick={() => setShowCommentSheet(true)}>
<MessageCircle className="text-ink" size={24} />
</button>
<button>
<Send className="text-ink" size={24} />
</button>
</div>
<p className="font-semibold text-sm text-ink">{post.likes_count.toLocaleString()} likes</p>
<p className="text-sm mt-1">
<span className="font-semibold text-ink mr-2">{post.user_info?.username}</span>
<span className="text-body">{post.caption}</span>
</p>
<button onClick={() => setShowCommentSheet(true)} className="text-sm text-muted mt-1">View all {post.comments_count} comments</button>
<p className="text-xs text-muted-soft mt-2 uppercase">
{formatDistanceToNow(new Date(post.created_at), { addSuffix: true })}
</p>
</div>
<AnimatePresence>
{showCommentSheet && <CommentSheet postId={post.id} onClose={() => setShowCommentSheet(false)} />}
</AnimatePresence>
</div>
);
}

View File

@ -0,0 +1,16 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function ProtectedRoute() {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return <div className="w-full h-screen flex items-center justify-center"><p>Loading...</p></div>;
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
}

View File

@ -0,0 +1,152 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import axios from 'axios';
import { api } from '../api';
const M_AUTH_API_URL = "https://api.mauth.mworld.cloud";
const M_AUTH_FRONTEND_URL = "https://v0.mauth.mworld.cloud";
export interface UserData {
username: string;
fullName: string;
email: string;
phone: string;
citizen_id: string;
[key: string]: any;
}
interface AuthContextType {
user: UserData | null;
token: string | null;
login: () => Promise<UserData | null>;
logout: () => void;
updateUser: (userData: UserData) => void;
isAuthenticated: boolean;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | null>(null);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [user, setUser] = useState<UserData | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const updateUser = (newUserData: UserData) => {
setUser(newUserData);
localStorage.setItem('auth_user', JSON.stringify(newUserData));
};
const handleCheckIn = async (authToken: string) => {
try {
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
const res = await axios.post(`${api.defaults.baseURL}/api/user/check-in`,
{ mauth_token: authToken },
{ headers: { 'Authorization': `Bearer ${authToken}`, 'x-mworld-origin': currentOrigin } }
);
if (res.data?.success && res.data.user) {
setUser(res.data.user);
localStorage.setItem('auth_user', JSON.stringify(res.data.user));
return res.data.user;
}
} catch (err) {
console.error("Check-in failed:", err);
logout();
}
return null;
};
useEffect(() => {
const initAuth = async () => {
const urlParams = new URLSearchParams(window.location.search);
const originParam = urlParams.get('origin');
if (originParam) {
localStorage.setItem('mworld_origin', originParam);
} else if (!localStorage.getItem('mworld_origin')) {
localStorage.setItem('mworld_origin', window.location.origin);
}
try {
const savedUser = localStorage.getItem('auth_user');
const savedToken = localStorage.getItem('auth_token');
if (savedUser && savedToken && savedUser !== 'undefined') {
setToken(savedToken);
setUser(JSON.parse(savedUser));
// Refresh user data on load
await handleCheckIn(savedToken);
}
} catch (error) {
logout();
} finally {
setIsLoading(false);
}
};
initAuth();
}, []);
const login = async (): Promise<UserData | null> => {
setIsLoading(true);
try {
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
const response = await axios.get(`${M_AUTH_API_URL}/mAuthGen?origin=${encodeURIComponent(currentOrigin)}`);
const link = response.data?.mAuth?.auth_link;
if (!link) {
setIsLoading(false);
return null;
}
const popup = window.open(link, "mAuthLogin", "width=500,height=700");
return new Promise((resolve) => {
const messageListener = async (event: MessageEvent) => {
// The popup broadcasts to the parent window containing the access token
if (event.origin !== M_AUTH_FRONTEND_URL) return;
const { user_access_token } = event.data;
if (user_access_token) {
setToken(user_access_token);
localStorage.setItem('auth_token', user_access_token);
const loggedInUser = await handleCheckIn(user_access_token);
window.removeEventListener("message", messageListener);
popup?.close();
setIsLoading(false);
resolve(loggedInUser);
}
};
window.addEventListener("message", messageListener);
// Cleanup if popup closed manually
const checkClosed = setInterval(() => {
if (popup?.closed) {
clearInterval(checkClosed);
window.removeEventListener("message", messageListener);
setIsLoading(false);
resolve(null);
}
}, 1000);
});
} catch (error) {
setIsLoading(false);
return null;
}
};
const logout = () => {
setUser(null);
setToken(null);
localStorage.removeItem('auth_user');
localStorage.removeItem('auth_token');
};
return (
<AuthContext.Provider value={{ user, token, login, logout, updateUser, isAuthenticated: !!user, isLoading }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
};

24
frontend/src/index.css Normal file
View File

@ -0,0 +1,24 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--primary: #ff385c;
--primary-active: #e00b41;
--ink: #222222;
--body: #3f3f3f;
--muted: #6a6a6a;
--muted-soft: #929292;
--hairline: #dddddd;
--hairline-soft: #ebebeb;
--canvas: #ffffff;
--surface-soft: #f7f7f7;
--on-primary: #ffffff;
}
body {
@apply bg-surface-soft text-body;
font-family: 'Airbnb Cereal VF', 'Circular', sans-serif;
}
}

36
frontend/src/lib/utils.ts Normal file
View File

@ -0,0 +1,36 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatPrice(price: any): string {
const num = Number(price);
if (isNaN(num)) return '';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
}).format(num);
}
export function formatMileage(mileage: any): string {
const num = Number(mileage);
if (isNaN(num)) return '';
return new Intl.NumberFormat('en-US').format(num) + ' miles';
}
export function formatDate(dateString: any): string {
if (!dateString) return '';
try {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
});
} catch (e) {
return '';
}
}

29
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,29 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AuthProvider } from './context/AuthContext'
import App from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
},
},
})
createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
)

View File

@ -0,0 +1,121 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { api } from '../api';
import { UploadCloud, Image, Video } from 'lucide-react';
const postSchema = z.object({
caption: z.string().max(2200).optional(),
});
type PostFormData = z.infer<typeof postSchema>;
export default function CreatePostPage() {
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const navigate = useNavigate();
const { register, handleSubmit, formState: { errors } } = useForm<PostFormData>({
resolver: zodResolver(postSchema),
});
const uploadMutation = useMutation({
mutationFn: (fileToUpload: File) => {
const formData = new FormData();
formData.append('file', fileToUpload);
return api.post('/api/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
});
const postMutation = useMutation({
mutationFn: (data: { caption?: string; media_url: string; media_type: string }) => {
return api.post('/api/posts', data);
},
onSuccess: () => {
toast.success('Post created successfully!');
navigate('/');
},
onError: () => {
toast.error('Failed to create post. Please try again.');
}
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const selectedFile = e.target.files[0];
setFile(selectedFile);
setPreview(URL.createObjectURL(selectedFile));
}
};
const onSubmit = (data: PostFormData) => {
if (!file) {
toast.error('Please select a photo or video to upload.');
return;
}
const promise = uploadMutation.mutateAsync(file)
.then(response => {
const { url } = response.data;
const media_type = file.type.startsWith('video') ? 'video' : 'image';
return postMutation.mutateAsync({ caption: data.caption, media_url: url, media_type });
});
toast.promise(promise, {
loading: 'Creating post...',
success: 'Post created!',
error: 'Failed to create post.',
});
};
const isLoading = uploadMutation.isPending || postMutation.isPending;
return (
<div className="p-4">
<h1 className="text-2xl font-bold text-ink mb-6 text-center">Create New Post</h1>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
<label htmlFor="file-upload" className="cursor-pointer">
{preview ? (
<div className="w-full aspect-square rounded-lg overflow-hidden border border-hairline bg-hairline-soft">
{file?.type.startsWith('video') ? (
<video src={preview} className="w-full h-full object-cover" />
) : (
<img src={preview} alt="Preview" className="w-full h-full object-cover" />
)}
</div>
) : (
<div className="w-full aspect-square border-2 border-dashed border-hairline rounded-lg flex flex-col items-center justify-center text-muted hover:bg-surface-soft transition-colors">
<UploadCloud size={48} />
<p className="mt-2 font-semibold">Click to upload</p>
<p className="text-sm">Photos or Videos</p>
</div>
)}
</label>
<input id="file-upload" type="file" className="hidden" accept="image/*,video/*" onChange={handleFileChange} disabled={isLoading} />
</div>
<div>
<textarea
{...register('caption')}
placeholder="Write a caption..."
rows={4}
className="w-full p-2 border border-hairline rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary"
disabled={isLoading}
/>
{errors.caption && <p className="text-xs text-red-500 mt-1">{errors.caption.message}</p>}
</div>
<button type="submit" disabled={isLoading} className="w-full bg-primary text-on-primary font-bold py-3 px-4 rounded-lg hover:bg-primary-active transition-colors disabled:bg-primary/50 disabled:cursor-not-allowed">
{isLoading ? 'Sharing...' : 'Share'}
</button>
</form>
</div>
);
}

View File

@ -0,0 +1,55 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../api';
import type { Post } from '../types';
import PostCard from '../components/PostCard';
const fetchPosts = async (): Promise<Post[]> => {
const { data } = await api.get('/api/posts');
// Frontend data augmentation
return (data.results || []).map((post: any) => ({
...post,
user_info: {
username: post.user_email.split('@')[0],
avatarUrl: `https://api.dicebear.com/7.x/pixel-art/svg?seed=${post.user_email}`
},
// Mocking counts as API does not provide them
likes_count: Math.floor(Math.random() * 1000),
comments_count: Math.floor(Math.random() * 100),
is_liked_by_user: false, // Cannot determine from API, so default to false
}));
};
export default function FeedPage() {
const { data: posts, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts
});
if (isLoading) {
return (
<div className="space-y-4 p-4">
<div className="w-full h-96 bg-hairline-soft rounded-lg animate-pulse"></div>
<div className="w-full h-96 bg-hairline-soft rounded-lg animate-pulse"></div>
</div>
);
}
if (error) {
return <p className="text-center text-red-500 p-4">Error fetching posts.</p>;
}
return (
<div className="bg-canvas">
<header className="sticky top-0 bg-canvas/80 backdrop-blur-sm z-10 border-b border-hairline-soft">
<h1 className="text-xl font-bold text-ink text-center py-3">InstaClone</h1>
</header>
<div className="space-y-6 pt-4">
{posts && posts.length > 0 ? (
posts.map((post) => <PostCard key={post.id} post={post} />)
) : (
<p className="text-center text-muted p-8">No posts yet. Be the first to share! </p>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,29 @@
import { useAuth } from '../context/AuthContext';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
export default function LoginPage() {
const { login, isAuthenticated } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (isAuthenticated) {
navigate('/');
}
}, [isAuthenticated, navigate]);
return (
<div className="min-h-screen flex items-center justify-center bg-surface-soft p-4">
<div className="w-full max-w-sm text-center">
<h1 className="text-3xl font-bold text-ink mb-2">InstaClone</h1>
<p className="text-muted mb-8">Share your moments with the world.</p>
<button
onClick={() => login()}
className="w-full bg-primary text-on-primary font-bold py-3 px-4 rounded-lg hover:bg-primary-active transition-colors"
>
Login with mAuth
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,29 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function MyProfilePage() {
const { user, isAuthenticated, isLoading } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (isLoading) {
return; // Wait until loading is complete
}
if (!isAuthenticated) {
navigate('/login', { replace: true });
} else if (user?.username) {
navigate(`/profile/${user.username}`, { replace: true });
}
// If user has no username for some reason, they will stay on a blank page.
// A better implementation would redirect to a 'complete your profile' page.
}, [user, isAuthenticated, isLoading, navigate]);
return (
<div className="w-full h-screen flex items-center justify-center">
<p>Loading your profile...</p>
</div>
);
}

View File

@ -0,0 +1,72 @@
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { api, API_URL } from '../api';
import type { Post } from '../types';
import { Image, Video } from 'lucide-react';
interface ProfileData {
username: string;
posts: Post[];
// User details are not available from the specified API endpoint
bio?: string;
avatarUrl?: string;
postCount: number;
}
const fetchProfileData = async (username: string): Promise<ProfileData> => {
const { data } = await api.get(`/api/users/${username}/posts`);
const posts: Post[] = data.results || [];
return {
username,
posts,
avatarUrl: `https://api.dicebear.com/7.x/pixel-art/svg?seed=${username}`,
bio: 'Bio is not available from API.',
postCount: posts.length,
};
};
export default function ProfilePage() {
const { username } = useParams<{ username: string }>();
const { data: profile, isLoading, error } = useQuery({
queryKey: ['profile', username],
queryFn: () => fetchProfileData(username!),
enabled: !!username,
});
if (isLoading) return <div className="p-4 text-center">Loading profile...</div>;
if (error) return <div className="p-4 text-center text-red-500">Error loading profile.</div>;
if (!profile) return <div className="p-4 text-center text-muted">User not found.</div>;
return (
<div>
<header className="p-4 flex flex-col items-center border-b border-hairline-soft">
<img src={profile.avatarUrl} alt={profile.username} className="w-24 h-24 rounded-full mb-4 border-2 border-hairline"/>
<h1 className="text-xl font-bold text-ink">{profile.username}</h1>
<p className="text-muted text-sm my-2">{profile.bio}</p>
<div className="flex space-x-8 mt-2">
<div className="text-center">
<p className="font-bold text-ink">{profile.postCount}</p>
<p className="text-muted-soft text-sm">posts</p>
</div>
</div>
</header>
<div className="grid grid-cols-3 gap-0.5">
{profile.posts.map(post => (
<div key={post.id} className="relative aspect-square bg-hairline-soft">
<img
src={post.media_url.startsWith('/api/') ? `${API_URL}${post.media_url}` : post.media_url}
alt={post.caption || ''}
className="w-full h-full object-cover"
/>
{post.media_type === 'video' && (
<Video className="absolute top-2 right-2 text-white/80" size={16} />
)}
</div>
))}
{profile.posts.length === 0 && <p className="col-span-3 text-center text-muted p-8">No posts yet.</p>}
</div>
</div>
);
}

View File

@ -0,0 +1,5 @@
export { default as FeedPage } from './FeedPage';
export { default as CreatePostPage } from './CreatePostPage';
export { default as ProfilePage } from './ProfilePage';
export { default as MyProfilePage } from './MyProfilePage';
export { default as LoginPage } from './LoginPage';

View File

@ -0,0 +1,32 @@
export interface Post {
id: string;
caption: string | null;
media_url: string;
media_type: string; // 'image' or 'video'
created_at: string;
user_email: string;
// Frontend-augmented fields
user_info?: {
username: string;
avatarUrl: string;
};
likes_count: number;
comments_count: number;
is_liked_by_user: boolean;
}
export interface Comment {
id: string;
content: string;
created_at: string;
user_email: string;
post_id: string;
}
export interface UserProfile {
email: string;
username: string;
bio: string | null;
profile_picture_url: string | null;
posts: Post[];
}

View File

@ -0,0 +1,53 @@
/** @type {import('tailwindcss').Config} */
import typography from '@tailwindcss/typography';
import animate from 'tailwindcss-animate';
export default {
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
theme: {
extend: {
colors: {
primary: 'var(--primary)',
'primary-active': 'var(--primary-active)',
ink: 'var(--ink)',
body: 'var(--body)',
muted: 'var(--muted)',
'muted-soft': 'var(--muted-soft)',
hairline: 'var(--hairline)',
'hairline-soft': 'var(--hairline-soft)',
canvas: 'var(--canvas)',
'surface-soft': 'var(--surface-soft)',
'on-primary': 'var(--on-primary)',
},
fontFamily: {
sans: ['Airbnb Cereal VF', 'Circular', 'sans-serif'],
},
borderRadius: {
sm: '8px',
lg: '24px',
xl: '32px',
full: '9999px',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [animate, typography],
};

25
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"strict": false,
"noImplicitAny": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts", "tailwind.config.js"]
}

27
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,27 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { fileURLToPath } from 'url'
import path from 'path'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"react-query": "@tanstack/react-query",
},
},
build: {
rollupOptions: {
onwarn(warning, warn) {
if (warning.code === 'MODULE_LEVEL_DIRECTIVE' && warning.message.includes('use client')) {
return;
}
warn(warning);
},
},
},
})