feat: add frontend in /frontend
This commit is contained in:
parent
9aac2ee90a
commit
f64ea867bc
4
frontend/.gitignore
vendored
Normal file
4
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.wrangler
|
||||||
|
.env
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal 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
3098
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
frontend/package.json
Normal file
46
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/postcss.config.js
Normal file
1
frontend/postcss.config.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }
|
||||||
24
frontend/src/App.tsx
Normal file
24
frontend/src/App.tsx
Normal 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
33
frontend/src/api.ts
Normal 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);
|
||||||
|
}
|
||||||
|
);
|
||||||
81
frontend/src/components/CommentSheet.tsx
Normal file
81
frontend/src/components/CommentSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
frontend/src/components/Layout.tsx
Normal file
14
frontend/src/components/Layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
frontend/src/components/NavigationBar.tsx
Normal file
38
frontend/src/components/NavigationBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
frontend/src/components/PostCard.tsx
Normal file
111
frontend/src/components/PostCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
frontend/src/components/ProtectedRoute.tsx
Normal file
16
frontend/src/components/ProtectedRoute.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
152
frontend/src/context/AuthContext.tsx
Normal file
152
frontend/src/context/AuthContext.tsx
Normal 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
24
frontend/src/index.css
Normal 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
36
frontend/src/lib/utils.ts
Normal 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
29
frontend/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
121
frontend/src/pages/CreatePostPage.tsx
Normal file
121
frontend/src/pages/CreatePostPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
frontend/src/pages/FeedPage.tsx
Normal file
55
frontend/src/pages/FeedPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
frontend/src/pages/LoginPage.tsx
Normal file
29
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
frontend/src/pages/MyProfilePage.tsx
Normal file
29
frontend/src/pages/MyProfilePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
frontend/src/pages/ProfilePage.tsx
Normal file
72
frontend/src/pages/ProfilePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
frontend/src/pages/index.ts
Normal file
5
frontend/src/pages/index.ts
Normal 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';
|
||||||
32
frontend/src/types/index.ts
Normal file
32
frontend/src/types/index.ts
Normal 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[];
|
||||||
|
}
|
||||||
53
frontend/tailwind.config.js
Normal file
53
frontend/tailwind.config.js
Normal 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
25
frontend/tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
27
frontend/vite.config.ts
Normal 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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user