fixed bugs

This commit is contained in:
Anant-0705 2026-07-15 14:59:46 +05:30
parent f64ea867bc
commit 2f9909f73d
13 changed files with 2472 additions and 826 deletions

View File

@ -1,8 +1,8 @@
import axios from 'axios'; import axios from 'axios';
export const API_URL = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') export const API_URL = import.meta.env.DEV
? "http://localhost:8787" ? `http://${window.location.hostname}:8787`
: "https://app-d9wl4z.laflabs-tech.workers.dev"; : "https://app-d9wl4z.laflabs-tech.workers.dev";
/** /**

View File

@ -20,7 +20,7 @@ app.use(
// Optional: Handle pre-flight requests // Optional: Handle pre-flight requests
app.options('*', (c) => { app.options('*', (c) => {
return c.text('', 204); return c.text('', 204 as any);
}); });
// Mount the routes // Mount the routes

View File

@ -17,10 +17,10 @@ adminRouter.get('/dashboard', async (c) => {
]); ]);
const stats = { const stats = {
total_users: users.results[0]?.count ?? 0, total_users: (users.results[0] as any)?.count ?? 0,
total_posts: posts.results[0]?.count ?? 0, total_posts: (posts.results[0] as any)?.count ?? 0,
total_likes: likes.results[0]?.count ?? 0, total_likes: (likes.results[0] as any)?.count ?? 0,
total_comments: comments.results[0]?.count ?? 0, total_comments: (comments.results[0] as any)?.count ?? 0,
}; };
return c.json({ stats }); return c.json({ stats });
@ -60,7 +60,7 @@ adminRouter.get('/users', async (c) => {
countQuery, countQuery,
]); ]);
const total = countResult[0]?.count ?? 0; const total = (countResult[0] as any)?.count ?? 0;
return c.json({ users: users ?? [], total }); return c.json({ users: users ?? [], total });
} catch (e: any) { } catch (e: any) {
@ -128,7 +128,7 @@ adminRouter.get('/posts', async (c) => {
countQuery, countQuery,
]); ]);
const total = countResult[0]?.count ?? 0; const total = (countResult[0] as any)?.count ?? 0;
return c.json({ posts: posts ?? [], total }); return c.json({ posts: posts ?? [], total });
} catch (e: any) { } catch (e: any) {

View File

@ -1,13 +1,15 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { AppContext, authMiddleware } from '../utils'; import { AppContext, authMiddleware, getMWorldOrigin, verifyMAuthToken } from '../utils';
// Types based on the D1 schema // Types based on the D1 schema
type User = { type User = {
id: string; // The API contract uses 'id', but DB uses 'email'. We will map it. Let's use email as ID. id: string; // The API contract uses 'id', but DB uses 'email'. We will map it. Let's use email as ID.
email: string; email: string;
username: string; username: string;
fullName: string;
citizen_id: string | null;
bio: string | null; bio: string | null;
profile_picture_url: string | null; avatar_url: string | null;
}; };
type Post = { type Post = {
@ -45,29 +47,9 @@ publicRouter.post('/user/check-in', async (c) => {
return c.json({ success: false, msg: "Token is required." }, 400); return c.json({ success: false, msg: "Token is required." }, 400);
} }
// This is a placeholder for the actual mAuth verification function const origin = getMWorldOrigin(c);
// In a real scenario, this function would call the mAuth API const userData = await verifyMAuthToken(token, origin);
const verifyMAuthToken = async (token: string, origin: string) => { const { email, fullName, citizen_id } = userData;
// Dummy implementation for demonstration
// This would call `https://api.mauth.mworld.cloud/mAuthUser/...`
if (token === "invalid-token") throw new Error("Invalid token");
return {
success: true,
user_data: {
email: `user_${crypto.randomUUID().substring(0,8)}@example.com`,
fullName: "Test User",
citizen_id: crypto.randomUUID(),
}
};
};
const origin = new URL(c.req.url).origin;
const mAuthResponse = await verifyMAuthToken(token, origin);
if (!mAuthResponse.success || !mAuthResponse.user_data) {
return c.json({ success: false, msg: 'Invalid mAuth token' }, 401);
}
const { email, fullName, citizen_id } = mAuthResponse.user_data;
const existingUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(email).first<User>(); const existingUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(email).first<User>();
@ -79,27 +61,26 @@ publicRouter.post('/user/check-in', async (c) => {
if (!success) { if (!success) {
return c.json({ success: false, msg: 'Failed to update user' }, 401); return c.json({ success: false, msg: 'Failed to update user' }, 401);
} }
user = { ...existingUser, fullName: fullName, citizen_id: citizen_id }; user = { ...existingUser, fullName: fullName, citizen_id: citizen_id } as any;
} else { } else {
const usernameBase = (email.split('@')[0] || 'user').replace(/[^a-zA-Z0-9]/g, ''); const usernameBase = (email.split('@')[0] || 'user').replace(/[^a-zA-Z0-9]/g, '');
let username = `${usernameBase}${Math.floor(Math.random() * 1000)}`; let username = `${usernameBase}${Math.floor(Math.random() * 1000)}`;
let isUsernameTaken = await c.env.DB.prepare('SELECT id FROM users WHERE username = ?').bind(username).first(); let isUsernameTaken = await c.env.DB.prepare('SELECT email FROM users WHERE username = ?').bind(username).first();
while(isUsernameTaken) { while(isUsernameTaken) {
username = `${usernameBase}${Math.floor(Math.random() * 1000)}`; username = `${usernameBase}${Math.floor(Math.random() * 1000)}`;
isUsernameTaken = await c.env.DB.prepare('SELECT id FROM users WHERE username = ?').bind(username).first(); isUsernameTaken = await c.env.DB.prepare('SELECT email FROM users WHERE username = ?').bind(username).first();
} }
const newUserId = crypto.randomUUID();
const { success } = await c.env.DB.prepare( const { success } = await c.env.DB.prepare(
'INSERT INTO users (id, email, username, bio, profile_picture_url) VALUES (?, ?, ?, ?, ?)' 'INSERT INTO users (email, username, fullName, citizen_id, bio, avatar_url) VALUES (?, ?, ?, ?, ?, ?)'
) )
.bind(newUserId, email, username, 'Welcome to my profile!', null) .bind(email, username, fullName ?? 'Unknown', citizen_id ?? null, 'Welcome to my profile!', null)
.run(); .run();
if(!success) { if(!success) {
return c.json({ success: false, msg: 'Failed to create user' }, 401); return c.json({ success: false, msg: 'Failed to create user' }, 401);
} }
user = { id: newUserId, email, username, bio: 'Welcome to my profile!', profile_picture_url: null }; user = { email, username, fullName: fullName ?? 'Unknown', citizen_id: citizen_id ?? null, bio: 'Welcome to my profile!', avatar_url: null } as any;
} }
return c.json({ success: true, user: user }); return c.json({ success: true, user: user });
@ -175,7 +156,7 @@ publicRouter.get('/geocode', authMiddleware, async (c) => {
}); });
if (!response.ok) { if (!response.ok) {
return c.json({ success: false, msg: 'Failed to fetch geocoding data' }, response.status); return c.json({ success: false, msg: 'Failed to fetch geocoding data' }, response.status as any);
} }
const data = await response.json(); const data = await response.json();
@ -196,7 +177,7 @@ publicRouter.get('/reverse-geocode', authMiddleware, async (c) => {
}); });
if (!response.ok) { if (!response.ok) {
return c.json({ success: false, msg: 'Failed to fetch reverse geocoding data' }, response.status); return c.json({ success: false, msg: 'Failed to fetch reverse geocoding data' }, response.status as any);
} }
const data = await response.json(); const data = await response.json();
@ -208,8 +189,35 @@ publicRouter.get('/reverse-geocode', authMiddleware, async (c) => {
// GET /api/posts // GET /api/posts
publicRouter.get('/posts', async (c) => { publicRouter.get('/posts', async (c) => {
try { try {
const { results } = await c.env.DB.prepare('SELECT * FROM posts ORDER BY created_at DESC LIMIT 50').all<Post>(); const authHeader = c.req.header('Authorization');
return c.json({ success: true, results: results ?? [] }); let userEmail: string | null = null;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.split(' ')[1];
try {
const origin = getMWorldOrigin(c);
const userData = await verifyMAuthToken(token, origin);
userEmail = userData.email;
} catch (e) {
// Ignore invalid tokens for public feed
}
}
const { results } = await c.env.DB.prepare(`
SELECT
p.*,
(SELECT COUNT(*) FROM likes WHERE post_id = p.id) as likes_count,
(SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comments_count,
(SELECT 1 FROM likes WHERE post_id = p.id AND user_email = ?) as is_liked_by_user
FROM posts p
ORDER BY p.created_at DESC LIMIT 50
`).bind(userEmail).all();
const formattedResults = results?.map((post: any) => ({
...post,
is_liked_by_user: !!post.is_liked_by_user
}));
return c.json({ success: true, results: formattedResults ?? [] });
} catch (e: any) { } catch (e: any) {
return c.json({ success: false, msg: 'Failed to fetch posts', error: e.message }, 500); return c.json({ success: false, msg: 'Failed to fetch posts', error: e.message }, 500);
} }
@ -225,18 +233,17 @@ publicRouter.post('/posts', authMiddleware, async (c) => {
} }
try { try {
const id = crypto.randomUUID();
const createdAt = new Date().toISOString(); const createdAt = new Date().toISOString();
const { success } = await c.env.DB.prepare( const result = await c.env.DB.prepare(
'INSERT INTO posts (id, caption, media_url, media_type, created_at, user_email) VALUES (?, ?, ?, ?, ?, ?)' 'INSERT INTO posts (caption, media_url, media_type, created_at, user_email) VALUES (?, ?, ?, ?, ?)'
).bind(id, body.caption ?? null, body.media_url || null, body.media_type || null, createdAt, user.email || null).run(); ).bind(body.caption ?? null, body.media_url || null, body.media_type || null, createdAt, user.email || null).run();
if (!success) { if (!result.success) {
return c.json({ success: false, msg: 'Failed to create post' }, 500); return c.json({ success: false, msg: 'Failed to create post' }, 500);
} }
const newPost: Post = { const newPost: Post = {
id, id: String(result.meta.last_row_id),
caption: body.caption ?? null, caption: body.caption ?? null,
media_url: body.media_url, media_url: body.media_url,
media_type: body.media_type, media_type: body.media_type,
@ -254,21 +261,71 @@ publicRouter.post('/posts', authMiddleware, async (c) => {
publicRouter.get('/users/:username/posts', async (c) => { publicRouter.get('/users/:username/posts', async (c) => {
const { username } = c.req.param(); const { username } = c.req.param();
try { try {
const user = await c.env.DB.prepare('SELECT email FROM users WHERE username = ?').bind(username).first<{ email: string }>(); const user = await c.env.DB.prepare('SELECT email, username, bio, avatar_url FROM users WHERE username = ?').bind(username).first<{ email: string, username: string, bio: string | null, avatar_url: string | null }>();
if (!user) { if (!user) {
return c.json({ success: false, msg: 'User not found' }, 404); return c.json({ success: false, msg: 'User not found' }, 404);
} }
const { results } = await c.env.DB.prepare('SELECT * FROM posts WHERE user_email = ? ORDER BY created_at DESC') const authHeader = c.req.header('Authorization');
.bind(user.email || null) let viewerEmail: string | null = null;
.all<Post>(); if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.split(' ')[1];
try {
const origin = getMWorldOrigin(c);
const userData = await verifyMAuthToken(token, origin);
viewerEmail = userData.email;
} catch (e) {
// Ignore
}
}
return c.json({ success: true, results: results ?? [] }); const { results } = await c.env.DB.prepare(`
SELECT
p.*,
(SELECT COUNT(*) FROM likes WHERE post_id = p.id) as likes_count,
(SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comments_count,
(SELECT 1 FROM likes WHERE post_id = p.id AND user_email = ?) as is_liked_by_user
FROM posts p WHERE p.user_email = ? ORDER BY p.created_at DESC
`).bind(viewerEmail, user.email).all();
const formattedResults = results?.map((post: any) => ({
...post,
is_liked_by_user: !!post.is_liked_by_user
}));
return c.json({ success: true, user, results: formattedResults ?? [] });
} catch (e: any) { } catch (e: any) {
return c.json({ success: false, msg: 'Failed to fetch user posts', error: e.message }, 500); return c.json({ success: false, msg: 'Failed to fetch user posts', error: e.message }, 500);
} }
}); });
// PUT /api/users/profile
publicRouter.put('/users/profile', authMiddleware, async (c) => {
const user = c.get('user');
const body = await c.req.json<{ bio?: string; avatar_url?: string; username?: string }>();
try {
if (body.username && body.username !== user.username) {
const existing = await c.env.DB.prepare('SELECT email FROM users WHERE username = ? AND email != ?')
.bind(body.username, user.email)
.first();
if (existing) {
return c.json({ success: false, msg: 'Username is already taken' }, 400);
}
}
const newUsername = body.username || user.username;
await c.env.DB.prepare('UPDATE users SET bio = ?, avatar_url = ?, username = ? WHERE email = ?')
.bind(body.bio ?? null, body.avatar_url ?? null, newUsername, user.email)
.run();
return c.json({ success: true, msg: 'Profile updated successfully' });
} catch (e: any) {
return c.json({ success: false, msg: 'Failed to update profile', error: e.message }, 500);
}
});
// POST /api/posts/:id/like // POST /api/posts/:id/like
publicRouter.post('/posts/:id/like', authMiddleware, async (c) => { publicRouter.post('/posts/:id/like', authMiddleware, async (c) => {
const user = c.get('user'); const user = c.get('user');
@ -280,23 +337,22 @@ publicRouter.post('/posts/:id/like', authMiddleware, async (c) => {
return c.json({ success: false, msg: 'Post not found' }, 404); return c.json({ success: false, msg: 'Post not found' }, 404);
} }
const id = crypto.randomUUID(); const result = await c.env.DB.prepare('INSERT INTO likes (user_email, post_id) VALUES (?, ?)')
const { success } = await c.env.DB.prepare('INSERT INTO likes (id, user_email, post_id) VALUES (?, ?, ?)') .bind(user.email || null, postId)
.bind(id, user.email || null, postId)
.run(); .run();
// If it fails because of unique constraint, it's not an error in this context. // If it fails because of unique constraint, it's not an error in this context.
// A more robust implementation would use ON CONFLICT DO NOTHING. // A more robust implementation would use ON CONFLICT DO NOTHING.
// D1 doesn't support that directly in write queries. A pre-check is an option. // D1 doesn't support that directly in write queries. A pre-check is an option.
if (!success) { if (!result.success) {
const existingLike = await c.env.DB.prepare('SELECT * FROM likes WHERE user_email = ? AND post_id = ?').bind(user.email || null, postId).first<Like>(); const existingLike = await c.env.DB.prepare('SELECT * FROM likes WHERE user_email = ? AND post_id = ?').bind(user.email || null, postId).first<Like>();
if(existingLike){ if(existingLike){
return c.json({ success: true, like: existingLike }); return c.json({ success: true, like: { ...existingLike, id: String(existingLike.id) } });
} }
return c.json({ success: false, msg: 'Failed to like post' }, 500); return c.json({ success: false, msg: 'Failed to like post' }, 500);
} }
const newLike: Like = { id, user_email: user.email, post_id: postId }; const newLike: Like = { id: String(result.meta.last_row_id), user_email: user.email, post_id: postId };
return c.json({ success: true, like: newLike }, 201); return c.json({ success: true, like: newLike }, 201);
} catch (e: any) { } catch (e: any) {
// Catch unique constraint violation // Catch unique constraint violation
@ -308,6 +364,19 @@ publicRouter.post('/posts/:id/like', authMiddleware, async (c) => {
} }
}); });
// GET /api/posts/:id/comments
publicRouter.get('/posts/:id/comments', async (c) => {
const postId = c.req.param('id');
try {
const { results } = await c.env.DB.prepare(
'SELECT c.*, u.username, u.avatar_url FROM comments c JOIN users u ON c.user_email = u.email WHERE c.post_id = ? ORDER BY c.created_at ASC'
).bind(postId).all();
return c.json({ success: true, results: results ?? [] });
} catch (e: any) {
return c.json({ success: false, msg: 'Failed to fetch comments', error: e.message }, 500);
}
});
// POST /api/posts/:id/comments // POST /api/posts/:id/comments
publicRouter.post('/posts/:id/comments', authMiddleware, async (c) => { publicRouter.post('/posts/:id/comments', authMiddleware, async (c) => {
@ -325,18 +394,17 @@ publicRouter.post('/posts/:id/comments', authMiddleware, async (c) => {
return c.json({ success: false, msg: 'Post not found' }, 404); return c.json({ success: false, msg: 'Post not found' }, 404);
} }
const id = crypto.randomUUID();
const createdAt = new Date().toISOString(); const createdAt = new Date().toISOString();
const { success } = await c.env.DB.prepare( const result = await c.env.DB.prepare(
'INSERT INTO comments (id, content, created_at, user_email, post_id) VALUES (?, ?, ?, ?, ?)' 'INSERT INTO comments (text, created_at, user_email, post_id) VALUES (?, ?, ?, ?)'
).bind(id, body.content || null, createdAt, user.email || null, postId).run(); ).bind(body.content || null, createdAt, user.email || null, postId).run();
if (!success) { if (!result.success) {
return c.json({ success: false, msg: 'Failed to create comment' }, 500); return c.json({ success: false, msg: 'Failed to create comment' }, 500);
} }
const newComment: Comment = { const newComment: Comment = {
id, id: String(result.meta.last_row_id),
content: body.content, content: body.content,
created_at: createdAt, created_at: createdAt,
user_email: user.email, user_email: user.email,

View File

@ -1,168 +0,0 @@
import { MiddlewareHandler, Context } from 'hono';
export type UserData = {
email: string;
username: string;
fullName: string;
citizen_id: string;
role?: string;
phone?: string;
photo_url?: string;
bio?: string;
[key: string]: any;
};
export type Bindings = {
DB: D1Database;
R2: R2Bucket;
S3_ENDPOINT: string;
S3_ACCESS_KEY_ID: string;
S3_SECRET_ACCESS_KEY: string;
};
export type Variables = {
user: UserData;
token: string;
};
export type AppContext = {
Bindings: Bindings;
Variables: Variables;
};
export function getMWorldOrigin(c: Context): string {
const origin = c.req.header('x-mworld-origin') || c.req.header('origin') || 'https://mworld.cloud';
return origin.split('?')[0].split('#')[0].replace(/\/$/, '');
}
/**
* mAuth Token Verification
* Uses the secure mAuth API to retrieve user identity.
*/
export async function verifyMAuthToken(token: string, origin: string): Promise<UserData> {
if (token === 'admin_token') {
return {
email: 'admin@mworld.cloud',
fullName: 'Admin User',
citizen_id: 'admin-bypass-id',
phone: '9999999999',
role: 'admin'
};
}
const MAUTH_SERVER_URL = 'https://api.mauth.mworld.cloud';
try {
const payload = JSON.stringify({
origin: origin,
required_user_data: ['fullName', 'email', 'phone', 'citizen_id']
});
const mAuthUrl = `${MAUTH_SERVER_URL}/mAuthUser/${encodeURIComponent(token)}?payload=${encodeURIComponent(payload)}`;
const response = await fetch(mAuthUrl);
const data = (await response.json()) as any;
if (data.success && data.user_data) {
const userData = data.user_data;
if (!userData.email) throw new Error('mAuth returned user data without an email');
if (!userData.fullName) userData.fullName = userData.email.split('@')[0] || 'MWorld User';
return userData;
}
throw new Error(data.msg || 'mAuth verification failed');
} catch (err: any) {
console.error('[mAuth Error]:', err.message);
throw new Error(`Authentication failed: ${err.message}`);
}
}
export const authMiddleware: MiddlewareHandler<AppContext> = async (c, next) => {
const authHeader = c.req.header('Authorization');
const origin = getMWorldOrigin(c);
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return c.json({ success: false, msg: 'Missing or invalid Authorization header' }, 401);
}
const token = authHeader.split(' ')[1];
try {
const userData = await verifyMAuthToken(token, origin);
const dbUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?')
.bind(userData.email)
.first();
if (!dbUser) {
const baseUsername = userData.email.split('@')[0].replace(/[^a-zA-Z0-9]/g, '');
const defaultUsername = `${baseUsername}_${Math.random().toString(36).substring(2, 8)}`;
await c.env.DB.prepare(`
INSERT INTO users (email, fullName, mauth_token, phone, username, citizen_id, role)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).bind(
userData.email,
userData.fullName,
token,
userData.phone || null,
defaultUsername,
userData.citizen_id,
'visitor'
).run();
const newUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(userData.email).first();
c.set('user', newUser as UserData);
} else {
c.set('user', dbUser as UserData);
}
c.set('token', token);
await next();
} catch (err: any) {
return c.json({ success: false, msg: err.message }, 401);
}
};
export const adminAuthMiddleware: MiddlewareHandler<AppContext> = async (c, next) => {
const authHeader = c.req.header('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return c.json({ success: false, msg: 'Missing admin token' }, 401);
}
const token = authHeader.split(' ')[1];
// 1. Try mAuth Agentic Token verification
try {
const verifyRes = await fetch('https://api.mauth.mworld.cloud/mAuthAG/verifyToken', {
headers: { 'mAuthAgenticToken': token }
});
const verifyData = await verifyRes.json() as any;
if (verifyData.success) {
// Valid Agentic Token
await next();
return;
}
} catch (err) {
console.error('[Admin Auth] Agentic verification failed:', err);
}
// 2. Fallback to Legacy Hash-based validation
try {
const encoder = new TextEncoder();
const data = encoder.encode(token);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
const result = await c.env.DB.prepare("SELECT value FROM settings WHERE key = 'admin_access_token_hash'")
.first<{ value: string }>();
if (result && result.value === hashHex) {
await next();
return;
}
} catch (err: any) {
console.error('[Admin Auth] Legacy verification failed:', err.message);
}
return c.json({ success: false, msg: 'Invalid or expired admin token' }, 401);
};

View File

@ -43,6 +43,7 @@ export async function verifyMAuthToken(token: string, origin: string): Promise<U
if (token === 'admin_token') { if (token === 'admin_token') {
return { return {
email: 'admin@mworld.cloud', email: 'admin@mworld.cloud',
username: 'admin',
fullName: 'Admin User', fullName: 'Admin User',
citizen_id: 'admin-bypass-id', citizen_id: 'admin-bypass-id',
phone: '9999999999', phone: '9999999999',

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,6 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"framer-motion": "^11.0.20", "framer-motion": "^11.0.20",
"lucide-react": "^0.363.0", "lucide-react": "^0.363.0",
"mauth-react": "0.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.51.2", "react-hook-form": "^7.51.2",
@ -29,6 +28,7 @@
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.20",
"@types/react": "^18.2.64", "@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21", "@types/react-dom": "^18.2.21",
"@typescript-eslint/eslint-plugin": "^7.1.1", "@typescript-eslint/eslint-plugin": "^7.1.1",

View File

@ -1,8 +1,8 @@
import axios from 'axios'; import axios from 'axios';
export const API_URL = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') export const API_URL = import.meta.env.DEV
? "http://127.0.0.1:8787" ? `http://${window.location.hostname}:8787`
: "https://app-d9wl4z.laflabs-tech.workers.dev"; : "https://app-d9wl4z.laflabs-tech.workers.dev";
export const api = axios.create({ export const api = axios.create({

View File

@ -1,10 +1,11 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { formatDistanceToNow } from 'date-fns';
import { api } from '../api'; import { api } from '../api';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
@ -26,11 +27,20 @@ export default function CommentSheet({ postId, onClose }: CommentSheetProps) {
resolver: zodResolver(commentSchema), resolver: zodResolver(commentSchema),
}); });
const { data: comments, isLoading: commentsLoading } = useQuery({
queryKey: ['comments', postId],
queryFn: async () => {
const { data } = await api.get(`/api/posts/${postId}/comments`);
return data.results || [];
}
});
const { mutate: addComment, isPending } = useMutation({ const { mutate: addComment, isPending } = useMutation({
mutationFn: (data: CommentFormData) => api.post(`/api/posts/${postId}/comments`, data), mutationFn: (data: CommentFormData) => api.post(`/api/posts/${postId}/comments`, data),
onSuccess: () => { onSuccess: () => {
toast.success('Comment added!'); toast.success('Comment added!');
queryClient.invalidateQueries({ queryKey: ['posts'] }); queryClient.invalidateQueries({ queryKey: ['posts'] });
queryClient.invalidateQueries({ queryKey: ['comments', postId] });
reset(); reset();
}, },
onError: (error) => { onError: (error) => {
@ -44,7 +54,7 @@ export default function CommentSheet({ postId, onClose }: CommentSheetProps) {
}; };
return ( return (
<div className="fixed inset-0 bg-black/50 z-50 flex justify-center items-end" onClick={onClose}> <div className="fixed inset-0 bg-black/50 z-[60] flex justify-center items-end" onClick={onClose}>
<motion.div <motion.div
initial={{ y: '100%' }} initial={{ y: '100%' }}
animate={{ y: '0%' }} animate={{ y: '0%' }}
@ -58,11 +68,30 @@ export default function CommentSheet({ postId, onClose }: CommentSheetProps) {
<button onClick={onClose}><X size={24} /></button> <button onClick={onClose}><X size={24} /></button>
</header> </header>
<div className="flex-grow p-4 overflow-y-auto"> <div className="flex-grow p-4 overflow-y-auto">
{/* NOTE: API to GET comments is not available in the spec. */} {commentsLoading ? (
<div className="flex justify-center py-16"><span className="text-muted animate-pulse">Loading comments...</span></div>
) : comments && comments.length > 0 ? (
<div className="space-y-4">
{comments.map((comment: any) => (
<div key={comment.id} className="flex gap-3">
<img src={comment.avatar_url || `https://api.dicebear.com/7.x/pixel-art/svg?seed=${comment.user_email}`} alt={comment.username} className="w-8 h-8 rounded-full" />
<div>
<p className="text-sm">
<span className="font-semibold text-ink mr-2">{comment.username}</span>
<span className="text-body break-all">{comment.text}</span>
</p>
<p className="text-xs text-muted-soft mt-1">
{formatDistanceToNow(new Date(comment.created_at), { addSuffix: true })}
</p>
</div>
</div>
))}
</div>
) : (
<div className="text-center text-muted py-16"> <div className="text-center text-muted py-16">
<p>Be the first to comment!</p> <p>Be the first to comment!</p>
<p className="text-sm">(Viewing comments is not yet supported)</p>
</div> </div>
)}
</div> </div>
<form onSubmit={handleSubmit(onSubmit)} className="p-4 border-t border-hairline-soft flex items-center gap-2"> <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"/> <img src={`https://api.dicebear.com/7.x/pixel-art/svg?seed=${user?.email}`} alt="Your avatar" className="w-10 h-10 rounded-full"/>

View File

@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Heart, MessageCircle, Send, MoreHorizontal } from 'lucide-react'; import { Heart, MessageCircle, Send, MoreHorizontal } from 'lucide-react';
@ -56,6 +57,28 @@ export default function PostCard({ post }: PostCardProps) {
} }
}; };
const handleShare = async () => {
const shareUrl = `${window.location.origin}/post/${post.id}`;
if (navigator.share) {
try {
await navigator.share({
title: 'Post from InstaClone',
text: post.caption || 'Check out this post!',
url: shareUrl,
});
} catch (err) {
console.error('Error sharing:', err);
}
} else {
try {
await navigator.clipboard.writeText(shareUrl);
toast.success('Link copied to clipboard!');
} catch (err) {
toast.error('Failed to copy link');
}
}
};
const mediaUrl = post.media_url.startsWith('/api/') ? `${API_URL}${post.media_url}` : post.media_url; const mediaUrl = post.media_url.startsWith('/api/') ? `${API_URL}${post.media_url}` : post.media_url;
return ( return (
@ -85,7 +108,7 @@ export default function PostCard({ post }: PostCardProps) {
<button onClick={() => setShowCommentSheet(true)}> <button onClick={() => setShowCommentSheet(true)}>
<MessageCircle className="text-ink" size={24} /> <MessageCircle className="text-ink" size={24} />
</button> </button>
<button> <button onClick={handleShare}>
<Send className="text-ink" size={24} /> <Send className="text-ink" size={24} />
</button> </button>
</div> </div>

View File

@ -12,10 +12,9 @@ const fetchPosts = async (): Promise<Post[]> => {
username: post.user_email.split('@')[0], username: post.user_email.split('@')[0],
avatarUrl: `https://api.dicebear.com/7.x/pixel-art/svg?seed=${post.user_email}` avatarUrl: `https://api.dicebear.com/7.x/pixel-art/svg?seed=${post.user_email}`
}, },
// Mocking counts as API does not provide them likes_count: post.likes_count ?? 0,
likes_count: Math.floor(Math.random() * 1000), comments_count: post.comments_count ?? 0,
comments_count: Math.floor(Math.random() * 100), is_liked_by_user: !!post.is_liked_by_user,
is_liked_by_user: false, // Cannot determine from API, so default to false
})); }));
}; };

View File

@ -1,32 +1,46 @@
import { useParams } from 'react-router-dom'; import { useState, useRef } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api, API_URL } from '../api'; import { api, API_URL } from '../api';
import type { Post } from '../types'; import type { Post } from '../types';
import { Image, Video } from 'lucide-react'; import { Image, Video, Check, X, Camera } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { toast } from 'sonner';
interface ProfileData { interface ProfileData {
username: string; username: string;
email: string;
posts: Post[]; posts: Post[];
// User details are not available from the specified API endpoint bio: string | null;
bio?: string; avatarUrl: string | null;
avatarUrl?: string;
postCount: number; postCount: number;
} }
const fetchProfileData = async (username: string): Promise<ProfileData> => { const fetchProfileData = async (username: string): Promise<ProfileData> => {
const { data } = await api.get(`/api/users/${username}/posts`); const { data } = await api.get(`/api/users/${username}/posts`);
const user = data.user;
const posts: Post[] = data.results || []; const posts: Post[] = data.results || [];
return { return {
username, username: user.username,
email: user.email,
posts, posts,
avatarUrl: `https://api.dicebear.com/7.x/pixel-art/svg?seed=${username}`, avatarUrl: user.avatar_url || `https://api.dicebear.com/7.x/pixel-art/svg?seed=${user.email}`,
bio: 'Bio is not available from API.', bio: user.bio,
postCount: posts.length, postCount: posts.length,
}; };
}; };
export default function ProfilePage() { export default function ProfilePage() {
const { username } = useParams<{ username: string }>(); const { username } = useParams<{ username: string }>();
const { user: authUser, updateUser } = useAuth();
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const [isEditing, setIsEditing] = useState(false);
const [editBio, setEditBio] = useState('');
const [editAvatarUrl, setEditAvatarUrl] = useState('');
const [editUsername, setEditUsername] = useState('');
const { data: profile, isLoading, error } = useQuery({ const { data: profile, isLoading, error } = useQuery({
queryKey: ['profile', username], queryKey: ['profile', username],
@ -34,17 +48,120 @@ export default function ProfilePage() {
enabled: !!username, enabled: !!username,
}); });
const { mutate: updateProfile, isPending: isUpdating } = useMutation({
mutationFn: async (data: { bio?: string, avatar_url?: string, username?: string }) => {
return api.put('/api/users/profile', data);
},
onSuccess: (_, variables) => {
toast.success('Profile updated!');
if (variables.username && variables.username !== username) {
if (authUser) {
updateUser({ ...authUser, username: variables.username });
}
navigate(`/profile/${variables.username}`, { replace: true });
} else {
queryClient.invalidateQueries({ queryKey: ['profile', username] });
}
setIsEditing(false);
},
onError: (err: any) => {
toast.error(err.response?.data?.msg || 'Failed to update profile');
}
});
const { mutate: uploadAvatar, isPending: isUploading } = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append('file', file);
const { data } = await api.post('/api/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
return data.url;
},
onSuccess: (url) => {
setEditAvatarUrl(url);
updateProfile({ avatar_url: url, bio: editBio });
},
onError: () => {
toast.error('Failed to upload image');
}
});
const handleEditClick = () => {
if (profile) {
setEditBio(profile.bio || '');
setEditAvatarUrl(profile.avatarUrl || '');
setEditUsername(profile.username || '');
setIsEditing(true);
}
};
const handleSave = () => {
updateProfile({ bio: editBio, avatar_url: editAvatarUrl, username: editUsername });
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
uploadAvatar(e.target.files[0]);
}
};
if (isLoading) return <div className="p-4 text-center">Loading profile...</div>; 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 (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>; if (!profile) return <div className="p-4 text-center text-muted">User not found.</div>;
const isOwnProfile = authUser?.username === profile.username;
const displayAvatar = isEditing ? editAvatarUrl : profile.avatarUrl;
const absoluteAvatarUrl = displayAvatar?.startsWith('/api/') ? `${API_URL}${displayAvatar}` : displayAvatar;
return ( return (
<div> <div>
<header className="p-4 flex flex-col items-center border-b border-hairline-soft"> <header className="p-4 flex flex-col items-center border-b border-hairline-soft relative">
<img src={profile.avatarUrl} alt={profile.username} className="w-24 h-24 rounded-full mb-4 border-2 border-hairline"/> <div className="relative group cursor-pointer" onClick={() => isOwnProfile && fileInputRef.current?.click()}>
<img src={absoluteAvatarUrl || ''} alt={profile.username} className="w-24 h-24 rounded-full mb-4 border-2 border-hairline object-cover"/>
{isOwnProfile && (
<div className="absolute inset-0 bg-black/40 rounded-full mb-4 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
{isUploading ? <span className="text-white text-xs">...</span> : <Camera className="text-white" size={24}/>}
</div>
)}
</div>
<input type="file" ref={fileInputRef} className="hidden" accept="image/*" onChange={handleFileChange} />
<h1 className="text-xl font-bold text-ink">{profile.username}</h1> <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"> {isEditing ? (
<div className="w-full max-w-xs mt-2 flex flex-col items-center gap-2">
<input
type="text"
value={editUsername}
onChange={(e) => setEditUsername(e.target.value)}
className="w-full bg-transparent border border-hairline rounded p-2 text-center focus:outline-none focus:border-primary text-lg font-bold"
placeholder="Username"
/>
<textarea
value={editBio}
onChange={(e) => setEditBio(e.target.value)}
className="w-full bg-transparent border border-hairline rounded p-2 text-center resize-none focus:outline-none focus:border-primary text-sm"
rows={2}
placeholder="Add a bio..."
/>
<div className="flex gap-2">
<button onClick={handleSave} disabled={isUpdating} className="p-2 rounded-full bg-primary/10 text-primary hover:bg-primary/20"><Check size={18}/></button>
<button onClick={() => setIsEditing(false)} disabled={isUpdating} className="p-2 rounded-full bg-red-500/10 text-red-500 hover:bg-red-500/20"><X size={18}/></button>
</div>
</div>
) : (
<>
<p className="text-muted text-sm my-2 text-center max-w-xs">{profile.bio}</p>
{isOwnProfile && (
<button onClick={handleEditClick} className="mt-1 px-4 py-1 rounded bg-surface-soft border border-hairline hover:bg-hairline-soft text-sm font-semibold transition-colors">
Edit Profile
</button>
)}
</>
)}
<div className="flex space-x-8 mt-4">
<div className="text-center"> <div className="text-center">
<p className="font-bold text-ink">{profile.postCount}</p> <p className="font-bold text-ink">{profile.postCount}</p>
<p className="text-muted-soft text-sm">posts</p> <p className="text-muted-soft text-sm">posts</p>