feat: add frontend in /frontend

This commit is contained in:
MWorld Deployer 2026-09-29 19:38:32 +05:30
parent 9d7342bc1b
commit aa0f3d1ecd
61 changed files with 11548 additions and 0 deletions

File diff suppressed because it is too large Load Diff

4
frontend/.gitignore vendored Normal file
View File

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

26
frontend/components.json Normal file
View File

@ -0,0 +1,26 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"catalogVersion": "shadcn-radix-v1",
"style": "radix",
"rsc": false,
"tsx": true,
"dependencies": {
"@radix-ui/react-slot": "^1.0.2",
"lucide-react": "^0.378.0",
"class-variance-authority": "^0.7.0",
"tailwind-merge": "^2.0.0"
},
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

62
frontend/design/DESIGN.md Normal file
View File

@ -0,0 +1,62 @@
# Vibely design preview
**Personality:** Solar Kinetic Social
Warm sunlit editorial geometry meets crisp, instantaneous feed clarity for expressive micro-connections.
**Design read:** Reading Vibely as a vibrant, community-focused social updates network that prioritizes authentic human momentum over algorithmic noise. It needs warm editorial structure, clean spatial hierarchy, and immediate tactile responsiveness for micro-interactions like photo publishing, chronological scrolling, and real-time social reactions.
## Taste dials
- Design variance: 6/10
- Motion intensity: 5/10
- Visual density: 4/10
## System
- Direction: An approachable editorial-social direction anchored by warm chalk surfaces, deep cobalt typography, crisp border boundaries, and vivid terracotta accents. Avoid dark synthwave glows and generic floating cards in favor of a cohesive, structured timeline flow.
- Default mode: light
- Display font: Sora, sans-serif
- Body font: Plus Jakarta Sans, sans-serif
- Landing composition: An asymmetric two-column split screen pairing an editorial pitch and M-Auth button on the left with a cascading live preview feed of photo posts on the right.
## Surfaces
### landing
Navigation: Header navigation bar with brand logo, feature quick links, and a primary elevated M-Auth login trigger button.
- Public Landing Page (/landing): Introduce Vibely's chronological social updates network and convert visitors via M-Auth sign in.
- hero-section: asymmetric-editorial-split - Warm chalk background, deep cobalt headings, terracotta badge highlighting chronological feature.
- feed-philosophy-section: single-column-centered-feed - Crisp white cards bounded by thin zinc border rules and generous vertical whitespace.
- creator-discovery-section: two-column-sticky-sidebar - Light neutral gray surface with terracotta pill badges for trending accounts.
- notifications-and-reactions-section: masonry-discovery-grid - White card container layout with status alerts using success and accent color indicators.
- cta-footer-banner: single-column-centered-feed - Deep cobalt primary background with crisp white typography and high-contrast terracotta primary button.
### application
Navigation: responsive-sidebar-and-bottom-tabs
- Home Feed & Post Composer (/app): Browse chronological social updates from followed accounts, post text and photo updates, and engage through instant likes and comments.
- post-composer-and-filters: single-column-centered-feed - White surface container with warm border definition and high-contrast publish button.
- chronological-feed-stream: single-column-centered-feed - Bordered white feed cards with explicit divider lines and terracotta active like state.
- Search & Creator Discovery (/app/search): Search creators and post updates by keyword, with social follow management.
- search-input-section: single-column-centered-feed - Clean elevated surface header with clear border stroke.
- search-results-list: single-column-centered-feed - Bordered white container cards with generous text spacing.
- Profile & Connections (/app/profile): View personal profile details, inspect follower/following connections, and manage user posts.
- profile-header-summary: asymmetric-editorial-split - Warm white hero card with subtle border framing.
- user-posts-timeline: single-column-centered-feed - Clean white post containers separated by divider rules.
- Notification Center (/app/notifications): Track social activity including likes, comments on user posts, and new followers.
- notification-feed-list: single-column-centered-feed - Unread items highlighted with soft surface background tint and accent dot.
- Account Settings (/app/settings): Manage account profile details and personal display preferences.
- settings-options-form: single-column-centered-feed - Clean white form cards with crisp dividing lines.
### admin
Navigation: top-bar-and-sticky-sidebar
- System Operations & Audit Dashboard (/admin): Provide platform operators with system health metrics, token authentication access, and global user management tools.
- admin-auth-header: single-column-centered-feed - Crisp white top panel bounded by warm border rules with terracotta accent triggers
- system-metrics-summary: asymmetric-editorial-split - Three high-contrast cards with Sora bold display figures and subtle cobalt borders
- user-directory-table-section: two-column-sticky-sidebar - Dense structured tabular surface with crisp inner divider lines and alternating row tinting
## Generated image placements
- landing-01-hero-section: landing /landing / hero-section
- landing-02-feed-philosophy-section: landing /landing / feed-philosophy-section
- landing-03-creator-discovery-section: landing /landing / creator-discovery-section
- landing-04-notifications-and-reactions-section: landing /landing / notifications-and-reactions-section
- application-01-post-composer-and-filters: application /app / post-composer-and-filters
- application-02-chronological-feed-stream: application /app / chronological-feed-stream
- admin-01-admin-auth-header: admin /admin / admin-auth-header

View File

@ -0,0 +1,93 @@
[
{
"id": "landing-01-hero-section",
"src": "/assets/landing-01-hero-section.png",
"alt": "hero_feed_preview_photo for Vibely",
"role": "hero_feed_preview_photo",
"width": 1600,
"height": 900,
"surfaceId": "landing",
"screenRoute": "/landing",
"sectionId": "hero-section",
"usage": "Warm chalk background, deep cobalt headings, terracotta badge highlighting chronological feature.",
"cropFocus": "Keep the primary subject in the central-right safe area so concise hero copy can sit to the left"
},
{
"id": "landing-02-feed-philosophy-section",
"src": "/assets/landing-02-feed-philosophy-section.png",
"alt": "interaction_preview_card_image for Vibely",
"role": "interaction_preview_card_image",
"width": 1200,
"height": 900,
"surfaceId": "landing",
"screenRoute": "/landing",
"sectionId": "feed-philosophy-section",
"usage": "Crisp white cards bounded by thin zinc border rules and generous vertical whitespace.",
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
},
{
"id": "landing-03-creator-discovery-section",
"src": "/assets/landing-03-creator-discovery-section.png",
"alt": "creator_avatar_samples for Vibely",
"role": "creator_avatar_samples",
"width": 1200,
"height": 900,
"surfaceId": "landing",
"screenRoute": "/landing",
"sectionId": "creator-discovery-section",
"usage": "Light neutral gray surface with terracotta pill badges for trending accounts.",
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
},
{
"id": "landing-04-notifications-and-reactions-section",
"src": "/assets/landing-04-notifications-and-reactions-section.png",
"alt": "Demonstrate instant activity updates for likes, comments, and new followers. for Vibely",
"role": "Demonstrate instant activity updates for likes, comments, and new followers.",
"width": 1200,
"height": 900,
"surfaceId": "landing",
"screenRoute": "/landing",
"sectionId": "notifications-and-reactions-section",
"usage": "White card container layout with status alerts using success and accent color indicators.",
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
},
{
"id": "application-01-post-composer-and-filters",
"src": "/assets/application-01-post-composer-and-filters.png",
"alt": "Provide input area for new text and photo posts alongside feed filter tabs. for Vibely",
"role": "Provide input area for new text and photo posts alongside feed filter tabs.",
"width": 1000,
"height": 1000,
"surfaceId": "application",
"screenRoute": "/app",
"sectionId": "post-composer-and-filters",
"usage": "White surface container with warm border definition and high-contrast publish button.",
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
},
{
"id": "application-02-chronological-feed-stream",
"src": "/assets/application-02-chronological-feed-stream.png",
"alt": "Attached post images and user profile avatars for Vibely",
"role": "Attached post images and user profile avatars",
"width": 1000,
"height": 1000,
"surfaceId": "application",
"screenRoute": "/app",
"sectionId": "chronological-feed-stream",
"usage": "Bordered white feed cards with explicit divider lines and terracotta active like state.",
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
},
{
"id": "admin-01-admin-auth-header",
"src": "/assets/admin-01-admin-auth-header.png",
"alt": "Provide access verification and display operator session status visual for Vibely",
"role": "Provide access verification and display operator session status visual",
"width": 1000,
"height": 1000,
"surfaceId": "admin",
"screenRoute": "/admin",
"sectionId": "admin-auth-header",
"usage": "Crisp white top panel bounded by warm border rules with terracotta accent triggers",
"cropFocus": "Keep the primary subject inside the central 70% with clear negative space around it"
}
]

File diff suppressed because one or more lines are too long

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>Vibely</title>
<script async src="https://data-analytics.mworld.cloud/analytics.js" data-site-id="p3qnvc"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3154
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
frontend/package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "vibely-p3qnvc",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"clsx": "^2.0.0",
"tailwind-merge": "^2.0.0",
"class-variance-authority": "^0.7.0",
"tailwindcss-animate": "^1.0.7",
"react-hook-form": "^7.48.2",
"@hookform/resolvers": "^3.3.2",
"zod": "^3.22.4",
"@radix-ui/react-slot": "^1.0.2",
"sonner": "latest",
"react-hot-toast": "^2.4.1",
"axios": "^1.6.0",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-table": "^8.20.5",
"framer-motion": "^10.16.4",
"recharts": "^2.13.0",
"react-is": "^18.3.1",
"lucide-react": "^0.378.0",
"react-router-dom": "^6.18.0",
"date-fns": "^3.0.0",
"vite": "^4.5.0",
"@vitejs/plugin-react": "^4.1.1",
"tailwindcss": "^3.3.5",
"@tailwindcss/typography": "^0.5.10",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"typescript": "^5.2.2"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@types/node": "^20.11.0"
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 953 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

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

@ -0,0 +1,53 @@
import React from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { useAuth } from './context/AuthContext';
import { Toaster } from 'sonner';
import Auth from './pages/Auth';
import AdminTokenGate from './components/AdminTokenGate';
import LandingPage from './pages/Landing';
import AppHomePage from './pages/AppHome';
import SearchPage from './pages/SearchPage';
import ProfilePage from './pages/ProfilePage';
import NotificationsPage from './pages/NotificationsPage';
import SettingsPage from './pages/SettingsPage';
import AdminPage from './pages/Admin';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useAuth();
const location = useLocation();
if (isLoading) return <div className="p-8 text-center text-muted-foreground">Loading session...</div>;
if (!user) {
const returnTo = encodeURIComponent(location.pathname + location.search);
return <Navigate to={'/auth?returnTo=' + returnTo} replace />;
}
return <>{children}</>;
}
export default function App() {
const location = useLocation();
const isPublicShellRoute = location.pathname === '/' || location.pathname === '/auth' || location.pathname.startsWith('/admin');
return (
<>
<Toaster position="top-right" />
<div className="min-h-screen flex flex-col">
<main className="flex-1">
<Routes>
<Route path="/auth" element={<Auth />} />
<Route path="/" element={<LandingPage />} />
<Route path="/app" element={<ProtectedRoute><AppHomePage /></ProtectedRoute>} />
<Route path="/app/search" element={<ProtectedRoute><SearchPage /></ProtectedRoute>} />
<Route path="/app/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
<Route path="/app/notifications" element={<ProtectedRoute><NotificationsPage /></ProtectedRoute>} />
<Route path="/app/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
<Route path="/admin" element={<AdminTokenGate><AdminPage /></AdminTokenGate>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
</div>
</>
);
}

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

@ -0,0 +1,97 @@
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"
: "";
export function resolveMediaUrl(value?: string | null): string {
if (!value) return '';
return value.startsWith('/api/media/') ? API_URL + value : value;
}
function resolveMediaUrls(value: unknown): unknown {
if (typeof value === 'string') return resolveMediaUrl(value);
if (Array.isArray(value)) return value.map(resolveMediaUrls);
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, nested]) => [key, resolveMediaUrls(nested)])
);
}
return value;
}
export const api = axios.create({
baseURL: API_URL
});
export const adminApi = axios.create({
baseURL: API_URL
});
api.interceptors.request.use((config) => {
// Force the /api prefix on all internal API calls that forgot it
if (config.url && !config.url.startsWith('/api') && !config.url.startsWith('http')) {
config.url = `/api${config.url.startsWith('/') ? '' : '/'}${config.url}`;
}
const token = localStorage.getItem('user_auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
config.headers['Content-Type'] = undefined;
}
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
config.headers['x-mworld-origin'] = currentOrigin;
return config;
});
api.interceptors.response.use(
(response) => {
response.data = resolveMediaUrls(response.data);
return response;
},
(error) => {
if (error.response?.status === 401) {
// Prevent infinite page reloads on check-in failures
const isCheckIn = error.config?.url?.includes('/user/check-in');
if (!isCheckIn) {
const hadToken = localStorage.getItem('user_auth_token');
localStorage.removeItem('user_auth_token');
localStorage.removeItem('auth_user');
if (hadToken) window.location.reload();
}
}
return Promise.reject(error);
}
);
adminApi.interceptors.request.use((config) => {
if (config.url && !config.url.startsWith('http')) {
if (config.url.startsWith('/api/admin')) {
// Already namespaced.
} else if (config.url.startsWith('/api/')) {
config.url = `/api/admin${config.url.slice('/api'.length)}`;
} else {
config.url = `/api/admin${config.url.startsWith('/') ? '' : '/'}${config.url}`;
}
}
const token = sessionStorage.getItem('admin_auth_token');
if (token) config.headers.Authorization = `Bearer ${token}`;
if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
config.headers['Content-Type'] = undefined;
}
config.headers['x-mworld-origin'] = window.location.origin;
return config;
});
adminApi.interceptors.response.use(
(response) => {
response.data = resolveMediaUrls(response.data);
return response;
},
(error) => Promise.reject(error)
);
export default api;

View File

@ -0,0 +1,117 @@
import React, { FormEvent, useEffect, useState } from 'react';
import { KeyRound, Loader2, LockKeyhole, LogOut } from 'lucide-react';
import { adminApi } from '../api';
export default function AdminTokenGate({ children }: { children: React.ReactNode }) {
const siteTitle = "Vibely";
const [token, setToken] = useState('');
const [verified, setVerified] = useState(false);
const [checking, setChecking] = useState(true);
const [error, setError] = useState('');
const tokenParamNames = ['agentic_token', 'mAuthAgenticToken', 'mAgenticAuthToken', 'mAgenticAuth'];
const normalizeAgenticToken = (value: string | null): string => {
let normalized = String(value || '').trim().replace(/^["']|["']$/g, '').trim();
normalized = normalized.replace(/^Bearer[ ]+/i, '').trim();
if (normalized.toLowerCase().startsWith('http://') || normalized.toLowerCase().startsWith('https://')) {
try {
const pastedUrl = new URL(normalized);
const hashParams = new URLSearchParams(pastedUrl.hash.startsWith('#') ? pastedUrl.hash.slice(1) : '');
normalized = tokenParamNames.map(name => pastedUrl.searchParams.get(name) || hashParams.get(name)).find(Boolean) || '';
} catch {
return '';
}
}
if (/%[0-9a-f]{2}/i.test(normalized)) {
try { normalized = decodeURIComponent(normalized); } catch { /* keep the original token */ }
}
return normalized.trim();
};
const readAgenticHandoff = () => {
const url = new URL(window.location.href);
const hashParams = new URLSearchParams(url.hash.startsWith('#') ? url.hash.slice(1) : '');
const handoff = tokenParamNames.map(name => url.searchParams.get(name) || hashParams.get(name)).find(Boolean) || null;
if (handoff) {
tokenParamNames.forEach(name => {
url.searchParams.delete(name);
hashParams.delete(name);
});
url.hash = hashParams.toString() ? '#' + hashParams.toString() : '';
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
}
return normalizeAgenticToken(handoff);
};
const verify = async (candidate: string) => {
const normalized = normalizeAgenticToken(candidate);
setToken(normalized);
if (!normalized) {
setError('A valid M-Auth agentic token is required.');
setChecking(false);
return;
}
setChecking(true);
setError('');
sessionStorage.setItem('admin_auth_token', normalized);
try {
await adminApi.get('/api/admin/verify-token');
setVerified(true);
} catch (requestError: any) {
const status = Number(requestError?.response?.status || 0);
setVerified(false);
if (status === 401 || status === 403) {
sessionStorage.removeItem('admin_auth_token');
setError('That M-Auth agentic token is invalid or expired. Open the admin panel from Fullstack Builder again.');
} else {
setError('M-Auth could not be reached right now. Your token has been kept; retry in a moment.');
}
} finally {
setChecking(false);
}
};
useEffect(() => {
const saved = readAgenticHandoff() || normalizeAgenticToken(sessionStorage.getItem('admin_auth_token'));
if (saved) verify(saved);
else setChecking(false);
}, []);
if (verified) {
return <div className="min-h-screen bg-background text-foreground">
<div className="fixed right-4 top-4 z-50">
<button
type="button"
title="Lock admin panel"
onClick={() => { sessionStorage.removeItem('admin_auth_token'); setVerified(false); setToken(''); }}
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-border bg-background text-muted-foreground shadow-sm hover:text-foreground"
>
<LogOut className="h-4 w-4" />
</button>
</div>
{children}
</div>;
}
return <main className="flex min-h-screen items-center justify-center bg-background px-5 text-foreground">
<section className="w-full max-w-md border border-border bg-background p-7 shadow-xl">
<div className="mb-7 flex items-center gap-4">
<div className="flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground"><LockKeyhole className="h-5 w-5" /></div>
<div><h1 className="text-xl font-semibold">{siteTitle} Admin</h1><p className="mt-1 text-sm text-muted-foreground">Restricted operator access</p></div>
</div>
<form onSubmit={(event: FormEvent) => { event.preventDefault(); verify(token); }} className="space-y-4">
<label className="block text-sm font-medium" htmlFor="admin-token">M-Auth agentic token</label>
<div className="relative">
<KeyRound className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<input id="admin-token" type="password" autoComplete="off" value={token} onChange={event => setToken(event.target.value)} className="h-10 w-full border border-input bg-background pl-10 pr-3 text-sm outline-none focus:border-primary" />
</div>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<button disabled={checking || !token.trim()} className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-primary px-4 text-sm font-semibold text-primary-foreground disabled:opacity-50">
{checking && <Loader2 className="h-4 w-4 animate-spin" />} Unlock admin panel
</button>
</form>
</section>
</main>;
}

View File

@ -0,0 +1,177 @@
import { Link, useLocation, useNavigate, Outlet } from 'react-router-dom';
import { Bell, Home, LogOut, Search, Settings, Sparkles, User } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { api, resolveMediaUrl } from '../api';
import { useAuth } from '../context/AuthContext';
export function AppLayout() {
const location = useLocation();
const navigate = useNavigate();
const { user, logout } = useAuth();
const { data: countData } = useQuery({
queryKey: ['notifications-count'],
queryFn: async () => {
const res = await api.get('/api/notifications/count');
return res.data;
},
refetchInterval: 15000,
});
const unreadCount = typeof countData?.count === 'number' ? countData.count : 0;
const navItems = [
{ label: 'Feed', path: '/app', icon: Home },
{ label: 'Search', path: '/app/search', icon: Search },
{ label: 'Activity', path: '/app/notifications', icon: Bell, badge: unreadCount },
{ label: 'Profile', path: '/app/profile', icon: User },
{ label: 'Settings', path: '/app/settings', icon: Settings },
];
const isActive = (path: string) => {
if (path === '/app') return location.pathname === '/app';
return location.pathname.startsWith(path);
};
const handleLogout = () => {
logout();
navigate('/');
};
const userAvatar = user?.avatar_url ? resolveMediaUrl(user.avatar_url) : null;
const displayName = user?.display_name || user?.full_name || (user?.email ? user.email.split('@')[0] : 'User');
const username = user?.username || (user?.email ? user.email.split('@')[0] : 'user');
return (
<div className="min-h-screen bg-[#faf9f5] flex flex-col md:flex-row text-black">
<aside className="hidden md:flex flex-col w-64 border-r border-[#e4e4e7] bg-white sticky top-0 h-screen shrink-0 p-4 justify-between">
<div className="space-y-6">
<Link to="/app" className="flex items-center gap-2.5 px-3 py-2 text-[#1e3a8a]">
<div className="w-9 h-9 rounded-xl bg-[#1e3a8a] flex items-center justify-center text-white shadow-sm">
<Sparkles className="w-5 h-5 text-white" />
</div>
<span className="font-display font-bold text-2xl tracking-tight text-[#1e3a8a]">Vibely</span>
</Link>
<nav className="space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center justify-between px-3 py-2.5 rounded-xl font-medium text-sm transition-colors ${
active
? 'bg-[#eff6ff] text-[#1e3a8a] font-semibold'
: 'text-zinc-600 hover:bg-[#f4f3ef] hover:text-black'
}`}
>
<div className="flex items-center gap-3">
<Icon className={`w-5 h-5 ${active ? 'text-[#c2410c]' : 'text-zinc-500'}`} />
<span>{item.label}</span>
</div>
{Boolean(item.badge && item.badge > 0) && (
<span className="px-2 py-0.5 text-xs font-bold rounded-full bg-[#c2410c] text-white">
{item.badge && item.badge > 99 ? '99+' : item.badge}
</span>
)}
</Link>
);
})}
</nav>
</div>
{/* User Profile Card Footer */}
<div className="pt-4 border-t border-[#e4e4e7]">
<div className="flex items-center justify-between p-2 rounded-xl hover:bg-[#f4f3ef] transition-colors">
<Link to="/app/profile" className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-full bg-[#1e3a8a] text-white flex items-center justify-center overflow-hidden border border-zinc-200 shrink-0 font-bold text-sm">
{userAvatar ? (
<img src={userAvatar} alt={displayName} className="w-full h-full object-cover" />
) : (
displayName.charAt(0).toUpperCase()
)}
</div>
<div className="min-w-0">
<p className="text-sm font-semibold truncate text-black">{displayName}</p>
<p className="text-xs text-zinc-500 truncate">@{username}</p>
</div>
</Link>
<button
onClick={handleLogout}
title="Sign out"
className="p-2 text-zinc-500 hover:text-[#b91c1c] rounded-lg transition-colors"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</div>
</aside>
<header className="md:hidden sticky top-0 z-30 bg-white/90 backdrop-blur-md border-b border-[#e4e4e7] px-4 py-3 flex items-center justify-between">
<Link to="/app" className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-[#1e3a8a] flex items-center justify-center text-white">
<Sparkles className="w-4 h-4 text-white" />
</div>
<span className="font-display font-bold text-xl tracking-tight text-[#1e3a8a]">Vibely</span>
</Link>
<div className="flex items-center gap-2">
<Link
to="/app/notifications"
className="relative p-2 text-zinc-600 hover:text-black rounded-lg"
>
<Bell className="w-5 h-5" />
{Boolean(unreadCount > 0) && (
<span className="absolute top-1 right-1 w-2.5 h-2.5 rounded-full bg-[#c2410c]" />
)}
</Link>
<Link
to="/app/profile"
className="w-8 h-8 rounded-full bg-[#1e3a8a] text-white flex items-center justify-center overflow-hidden font-semibold text-xs border border-zinc-200"
>
{userAvatar ? (
<img src={userAvatar} alt={displayName} className="w-full h-full object-cover" />
) : (
displayName.charAt(0).toUpperCase()
)}
</Link>
</div>
</header>
<main className="flex-1 max-w-5xl mx-auto w-full p-4 md:p-6 pb-20 md:pb-6 min-h-[calc(100vh-60px)]">
<Outlet />
</main>
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-30 bg-white border-t border-[#e4e4e7] px-2 py-1.5 flex items-center justify-around">
{navItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
return (
<Link
key={item.path}
to={item.path}
className={`flex flex-col items-center justify-center py-1 px-3 rounded-lg relative ${
active ? 'text-[#c2410c]' : 'text-zinc-500 hover:text-black'
}`}
>
<Icon className="w-5 h-5" />
<span className="text-[10px] font-medium mt-0.5">{item.label}</span>
{Boolean(item.badge && item.badge > 0) && (
<span className="absolute top-0.5 right-2 min-w-[14px] h-[14px] px-1 text-[9px] font-bold rounded-full bg-[#c2410c] text-white flex items-center justify-center">
{item.badge && item.badge > 99 ? '99+' : item.badge}
</span>
)}
</Link>
);
})}
</nav>
</div>
);
}
export default AppLayout;

View File

@ -0,0 +1,151 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { MessageCircle, Send, User, X } from 'lucide-react';
import { api } from '../../api';
export interface CommentItem {
id: string;
post_id: string;
author_email: string;
content: string;
created_at: string;
author_username?: string;
}
interface CommentsModalProps {
postId: string | null;
onClose: () => void;
}
export default function CommentsModal({ postId, onClose }: CommentsModalProps) {
const queryClient = useQueryClient();
const [content, setContent] = useState('');
const { data: commentsData, isLoading, error } = useQuery({
queryKey: ['comments', postId],
queryFn: async () => {
if (!postId) return [];
const res = await api.get(`/api/posts/${postId}/comments`);
return Array.isArray(res.data?.comments) ? res.data.comments : [];
},
enabled: !!postId,
});
const comments: CommentItem[] = Array.isArray(commentsData) ? commentsData : [];
const addCommentMutation = useMutation({
mutationFn: async (text: string) => {
if (!postId) return;
return (await api.post(`/api/posts/${postId}/comments`, { content: text })).data;
},
onSuccess: () => {
setContent('');
queryClient.invalidateQueries({ queryKey: ['comments', postId] });
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
if (!postId) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!String((content) ?? '').trim() || addCommentMutation.isPending) return;
addCommentMutation.mutate(String((content) ?? '').trim());
};
const formatDate = (dateStr?: string) => {
if (!dateStr) return '';
try {
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
} catch {
return dateStr;
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-xs animate-in fade-in duration-200">
<div
className="fixed inset-0"
onClick={onClose}
aria-hidden="true"
/>
<div className="relative w-full max-w-lg bg-white rounded-2xl shadow-xl overflow-hidden z-10 flex flex-col max-h-[85vh] border border-[var(--border)]">
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--divider)] bg-[var(--background)]">
<div className="flex items-center gap-2">
<MessageCircle size={20} className="text-[var(--primary)]" />
<h3 className="text-base font-semibold text-slate-900">Comments</h3>
</div>
<button
onClick={onClose}
className="p-1.5 text-slate-400 hover:text-slate-600 rounded-full hover:bg-slate-200/60 transition-colors"
aria-label="Close comments"
>
<X size={18} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-4 min-h-[200px]">
{isLoading ? (
<div className="space-y-3 py-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex gap-3 animate-pulse">
<div className="w-8 h-8 rounded-full bg-slate-200 shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-3 bg-slate-200 rounded w-1/4" />
<div className="h-3 bg-slate-200 rounded w-3/4" />
</div>
</div>
))}
</div>
) : error ? (
<div className="text-center py-8 text-sm text-red-500">
Failed to load comments. Please try again.
</div>
) : comments.length === 0 ? (
<div className="text-center py-10 text-slate-500 text-sm">
No comments yet. Start the conversation!
</div>
) : (
comments.map((comment) => {
const author = comment.author_username || (comment.author_email ? comment.author_email.split('@')[0] : 'anonymous');
return (
<div key={comment.id} className="flex gap-3 text-sm">
<div className="w-8 h-8 rounded-full bg-[var(--surfaceSecondary)] border border-[var(--border)] flex items-center justify-center text-xs font-semibold text-[var(--primary)] shrink-0 mt-0.5">
{author.charAt(0).toUpperCase() || <User size={14} />}
</div>
<div className="flex-1 bg-slate-50 p-3 rounded-xl border border-slate-100">
<div className="flex items-center justify-between gap-2 mb-1">
<span className="font-semibold text-slate-900 text-xs">@{author}</span>
<span className="text-[11px] text-slate-400">{formatDate(comment.created_at)}</span>
</div>
<p className="text-slate-800 text-xs leading-relaxed whitespace-pre-wrap">{comment.content}</p>
</div>
</div>
);
})
)}
</div>
<form onSubmit={handleSubmit} className="p-4 border-t border-[var(--divider)] bg-white flex items-center gap-2">
<input
type="text"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write a comment..."
className="flex-1 px-4 py-2.5 bg-slate-50 border border-[var(--border)] rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:bg-white"
/>
<button
type="submit"
disabled={!String((content) ?? '').trim() || addCommentMutation.isPending}
className="p-2.5 bg-[var(--primary)] text-white rounded-xl hover:bg-blue-900 transition-colors disabled:opacity-40 shrink-0"
aria-label="Send comment"
>
<Send size={18} />
</button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,186 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, Search, UserCheck, UserPlus, X } from 'lucide-react';
import { api, resolveMediaUrl } from '../../api';
import { useAuth } from '../../context/AuthContext';
interface FollowersModalProps {
isOpen: boolean;
onClose: () => void;
type: 'followers' | 'following';
userEmail?: string;
}
export default function FollowersModal({
isOpen,
onClose,
type,
userEmail,
}: FollowersModalProps) {
const { user: currentUser } = useAuth();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState('');
const { data, isLoading, isError } = useQuery({
queryKey: ['follows', type, userEmail || currentUser?.email],
queryFn: async () => {
const targetEmail = userEmail || currentUser?.email;
const res = await api.get('/api/follows', {
params: { type, user_email: targetEmail },
});
return res.data;
},
enabled: isOpen && Boolean(userEmail || currentUser?.email),
});
const followMutation = useMutation({
mutationFn: async (targetEmail: string) => {
const res = await api.post('/api/follows', { following_email: targetEmail });
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['follows'] });
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['profile'] });
},
});
const unfollowMutation = useMutation({
mutationFn: async (targetEmail: string) => {
const res = await api.delete(`/api/follows/${encodeURIComponent(targetEmail)}`);
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['follows'] });
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['profile'] });
},
});
if (!isOpen) return null;
const usersList = Array.isArray(data?.users) ? data.users : [];
const filteredUsers = usersList.filter((u: any) => {
if (!searchTerm) return true;
const term = searchTerm.toLowerCase();
const name = String(u.display_name || u.full_name || u.username || '').toLowerCase();
const email = String(u.email || '').toLowerCase();
return name.includes(term) || email.includes(term);
});
const modalTitle = type === 'followers' ? 'Followers' : 'Following';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
<div
className="relative w-full max-w-md bg-white rounded-2xl shadow-xl border border-zinc-200 overflow-hidden flex flex-col max-h-[85vh]"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-100">
<h2 className="text-lg font-semibold text-zinc-900 font-display">{modalTitle}</h2>
<button
onClick={onClose}
className="p-1.5 rounded-full text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{usersList.length > 5 && (
<div className="px-5 pt-3 pb-2 border-b border-zinc-100">
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-2.5 text-zinc-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search list..."
className="w-full pl-9 pr-3 py-1.5 text-xs bg-zinc-50 border border-zinc-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-blue-900"
/>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto p-5 space-y-3">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-10 text-zinc-400 gap-2">
<Loader2 className="w-6 h-6 animate-spin text-blue-900" />
<p className="text-xs">Loading accounts...</p>
</div>
) : isError ? (
<div className="text-center py-8 text-xs text-red-600">
Failed to load accounts. Please try again.
</div>
) : filteredUsers.length === 0 ? (
<div className="text-center py-10 text-zinc-500 text-xs">
{searchTerm ? 'No accounts match your search.' : `No ${type} found.`}
</div>
) : (
filteredUsers.map((item: any) => {
const itemEmail = item.email || '';
const isSelf = currentUser?.email === itemEmail;
const avatar = resolveMediaUrl(item.avatar_url) || `https://api.dicebear.com/7.x/shapes/svg?seed=${itemEmail}`;
const displayName = item.display_name || item.full_name || (itemEmail ? itemEmail.split('@')[0] : 'User');
const username = item.username ? `@${item.username}` : (itemEmail ? `@${itemEmail.split('@')[0]}` : '');
const isFollowingItem = Boolean(item.is_following);
const isMutating =
(followMutation.isPending && followMutation.variables === itemEmail) ||
(unfollowMutation.isPending && unfollowMutation.variables === itemEmail);
return (
<div key={item.id || itemEmail} className="flex items-center justify-between gap-3 p-2 rounded-xl hover:bg-zinc-50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<img
src={avatar}
alt={displayName}
className="w-10 h-10 rounded-full object-cover border border-zinc-200 shrink-0"
/>
<div className="min-w-0">
<p className="text-sm font-semibold text-zinc-900 truncate">{displayName}</p>
<p className="text-xs text-zinc-500 truncate">{username}</p>
{item.bio && <p className="text-xs text-zinc-600 line-clamp-1 mt-0.5">{item.bio}</p>}
</div>
</div>
{!isSelf && (
<button
type="button"
disabled={isMutating}
onClick={() => {
if (isFollowingItem) {
unfollowMutation.mutate(itemEmail);
} else {
followMutation.mutate(itemEmail);
}
}}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg shrink-0 transition-colors cursor-pointer ${' '}
${isFollowingItem
? 'bg-zinc-100 text-zinc-700 hover:bg-zinc-200'
: 'bg-amber-700 text-white hover:bg-amber-800'
}`}
>
{isMutating ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : isFollowingItem ? (
<>
<UserCheck className="w-3.5 h-3.5" />
<span>Following</span>
</>
) : (
<>
<UserPlus className="w-3.5 h-3.5" />
<span>Follow</span>
</>
)}
</button>
)}
</div>
);
})
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,210 @@
import React, { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Edit2, Heart, MessageSquare, MoreVertical, Trash2, User } from 'lucide-react';
import { api, resolveMediaUrl } from '../../api';
import { useAuth } from '../../context/AuthContext';
export interface Post {
id: string;
author_email: string;
content: string;
image_url?: string | null;
like_count: number;
comment_count: number;
created_at: string;
updated_at?: string;
author_username?: string;
is_liked?: boolean;
}
interface PostCardProps {
post: Post;
onOpenComments?: (postId: string) => void;
}
export default function PostCard({ post, onOpenComments }: PostCardProps) {
const { user } = useAuth();
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(post.content);
const [showMenu, setShowMenu] = useState(false);
const [isLikedLocally, setIsLikedLocally] = useState<boolean | null>(post.is_liked ?? null);
const [likeCountLocally, setLikeCountLocally] = useState<number>(post.like_count || 0);
const isOwner = !!user?.email && user.email === post.author_email;
const authorName = post.author_username || (post.author_email ? post.author_email.split('@')[0] : 'anonymous');
const likeMutation = useMutation({
mutationFn: async () => {
if (isLikedLocally) {
const res = await api.delete(`/api/posts/${post.id}/like`);
return { isLiked: false, count: res.data?.like_count };
} else {
const res = await api.post(`/api/posts/${post.id}/like`);
return { isLiked: true, count: res.data?.like_count };
}
},
onMutate: () => {
const nextLiked = !isLikedLocally;
const nextCount = nextLiked ? likeCountLocally + 1 : Math.max(0, likeCountLocally - 1);
setIsLikedLocally(nextLiked);
setLikeCountLocally(nextCount);
},
onSuccess: (data) => {
if (typeof data.count === 'number') {
setLikeCountLocally(data.count);
}
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
onError: () => {
setIsLikedLocally(post.is_liked ?? false);
setLikeCountLocally(post.like_count || 0);
},
});
const editMutation = useMutation({
mutationFn: async (content: string) => {
return (await api.patch(`/api/posts/${post.id}`, { content })).data;
},
onSuccess: () => {
setIsEditing(false);
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
return (await api.delete(`/api/posts/${post.id}`)).data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
const handleSaveEdit = () => {
if (!String((editContent) ?? '').trim()) return;
editMutation.mutate(String((editContent) ?? '').trim());
};
const formatDate = (dateStr?: string) => {
if (!dateStr) return '';
try {
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
} catch {
return dateStr;
}
};
const currentlyLiked = isLikedLocally ?? !!post.is_liked;
return (
<article className="bg-white rounded-xl border border-[var(--border)] p-4 sm:p-5 shadow-sm space-y-3 relative transition-all duration-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[var(--surfaceSecondary)] border border-[var(--border)] flex items-center justify-center text-sm font-semibold text-[var(--primary)] shrink-0">
{authorName.charAt(0).toUpperCase() || <User size={18} />}
</div>
<div>
<h4 className="text-sm font-semibold text-slate-900 leading-tight">@{authorName}</h4>
<span className="text-xs text-slate-500">{formatDate(post.created_at)}</span>
</div>
</div>
{isOwner && (
<div className="relative">
<button
onClick={() => setShowMenu((prev) => !prev)}
className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-500 transition-colors"
aria-label="Post options"
>
<MoreVertical size={18} />
</button>
{showMenu && (
<div className="absolute right-0 mt-1 w-36 bg-white border border-[var(--border)] rounded-lg shadow-lg z-10 py-1 text-sm">
<button
onClick={() => {
setIsEditing(true);
setShowMenu(false);
}}
className="flex items-center gap-2 px-3 py-2 w-full text-left hover:bg-slate-50 text-slate-700"
>
<Edit2 size={14} /> Edit Post
</button>
<button
onClick={() => {
if (confirm('Delete this post?')) deleteMutation.mutate();
setShowMenu(false);
}}
className="flex items-center gap-2 px-3 py-2 w-full text-left hover:bg-red-50 text-red-600"
>
<Trash2 size={14} /> Delete
</button>
</div>
)}
</div>
)}
</div>
{isEditing ? (
<div className="space-y-2">
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="w-full p-3 border border-[var(--border)] rounded-lg text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] resize-none"
rows={3}
/>
<div className="flex justify-end gap-2">
<button
onClick={() => setIsEditing(false)}
className="px-3 py-1.5 rounded-lg border border-slate-200 text-xs font-medium text-slate-600 hover:bg-slate-50"
>
Cancel
</button>
<button
onClick={handleSaveEdit}
disabled={editMutation.isPending}
className="px-3 py-1.5 rounded-lg bg-[var(--primary)] text-white text-xs font-medium hover:bg-blue-900 disabled:opacity-50"
>
{editMutation.isPending ? 'Saving...' : 'Save'}
</button>
</div>
</div>
) : (
<p className="text-sm text-slate-800 whitespace-pre-wrap leading-relaxed">{post.content}</p>
)}
{post.image_url && (
<div className="rounded-lg overflow-hidden border border-[var(--border)] max-h-[420px] bg-slate-900/5">
<img
src={resolveMediaUrl(post.image_url)}
alt="Post content media"
className="w-full h-auto object-cover max-h-[420px]"
loading="lazy"
/>
</div>
)}
<div className="flex items-center gap-6 pt-2 border-t border-[var(--divider)] text-xs font-medium text-slate-600">
<button
onClick={() => likeMutation.mutate()}
disabled={likeMutation.isPending}
className={`flex items-center gap-1.5 transition-colors ${
currentlyLiked ? 'text-[var(--accent)] font-semibold' : 'hover:text-slate-900'
}`}
>
<Heart size={18} className={currentlyLiked ? 'fill-[var(--accent)] text-[var(--accent)]' : ''} />
<span>{likeCountLocally}</span>
</button>
<button
onClick={() => onOpenComments && onOpenComments(post.id)}
className="flex items-center gap-1.5 hover:text-slate-900 transition-colors"
>
<MessageSquare size={18} />
<span>{post.comment_count || 0}</span>
</button>
</div>
</article>
);
}

View File

@ -0,0 +1,164 @@
import React, { useState, useRef } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { File, Image, Loader2, Send, X } from 'lucide-react';
import { api, resolveMediaUrl } from '../../api';
import { useAuth } from '../../context/AuthContext';
interface PostComposerProps {
onPostCreated?: () => void;
className?: string;
}
export default function PostComposer({ onPostCreated, className = '' }: PostComposerProps) {
const { user } = useAuth();
const queryClient = useQueryClient();
const [content, setContent] = useState('');
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
setErrorMsg('Image size must be under 5MB');
return;
}
setErrorMsg(null);
setImageFile(file);
setImagePreview(URL.createObjectURL(file));
};
const removeImage = () => {
setImageFile(null);
setImagePreview(null);
if (fileInputRef.current) fileInputRef.current.value = '';
};
const createPostMutation = useMutation({
mutationFn: async () => {
let imageUrl: string | undefined = undefined;
if (imageFile) {
setIsUploading(true);
const formData = new FormData();
formData.append('file', imageFile);
const uploadRes = await api.post('/api/upload', formData, {
headers: { 'Content-Type': undefined },
});
setIsUploading(false);
if (uploadRes.data?.success && uploadRes.data?.url) {
imageUrl = uploadRes.data.url;
}
}
const payload: { content: string; image_url?: string } = {
content: String(content).trim(),
};
if (imageUrl) payload.image_url = imageUrl;
const res = await api.post('/api/posts', payload);
return res.data;
},
onSuccess: () => {
setContent('');
removeImage();
setErrorMsg(null);
queryClient.invalidateQueries({ queryKey: ['posts'] });
if (onPostCreated) onPostCreated();
},
onError: (err: any) => {
setIsUploading(false);
setErrorMsg(err?.response?.data?.msg || err?.message || 'Failed to publish post');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!String(content).trim() && !imageFile) return;
createPostMutation.mutate();
};
const avatarUrl = resolveMediaUrl(user?.avatar_url) || `https://api.dicebear.com/7.x/shapes/svg?seed=${user?.email || 'vibe'}`;
const displayName = user?.display_name || user?.full_name || (user?.email ? user.email.split('@')[0] : 'You');
return (
<div className={`bg-white rounded-xl border border-zinc-200 shadow-xs p-4 ${className}`}>
<form onSubmit={handleSubmit}>
<div className="flex items-start gap-3">
<img
src={avatarUrl}
alt={displayName}
className="w-10 h-10 rounded-full object-cover border border-zinc-200 shrink-0"
/>
<div className="flex-1 min-w-0">
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="What's happening? Share an update..."
rows={3}
className="w-full resize-none bg-transparent border-0 focus:outline-none focus:ring-0 text-zinc-900 placeholder:text-zinc-400 text-sm p-0"
/>
{imagePreview && (
<div className="relative mt-2 rounded-lg overflow-hidden border border-zinc-200 max-h-60 bg-zinc-50">
<img src={imagePreview} alt="Upload preview" className="w-full h-full object-cover max-h-60" />
<button
type="button"
onClick={removeImage}
className="absolute top-2 right-2 p-1.5 rounded-full bg-zinc-900/70 text-white hover:bg-zinc-900 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
)}
{errorMsg && (
<p className="mt-2 text-xs text-red-600 bg-red-50 p-2 rounded-md">{errorMsg}</p>
)}
<div className="mt-3 pt-3 border-t border-zinc-100 flex items-center justify-between">
<div className="flex items-center gap-1">
<input
type="file"
ref={fileInputRef}
onChange={handleImageSelect}
accept="image/*"
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100 rounded-lg transition-colors cursor-pointer"
>
<Image className="w-4 h-4 text-amber-700" />
<span>Photo</span>
</button>
</div>
<button
type="submit"
disabled={(!String(content).trim() && !imageFile) || createPostMutation.isPending || isUploading}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-white bg-blue-900 hover:bg-blue-950 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors shadow-xs cursor-pointer"
>
{createPostMutation.isPending || isUploading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Publishing...</span>
</>
) : (
<>
<Send className="w-3.5 h-3.5" />
<span>Publish</span>
</>
)}
</button>
</div>
</div>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,7 @@
import React from 'react';
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
export const Alert = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div role='alert' className={cx('rounded-lg border p-4 text-sm', className)} {...props} />;
export const AlertTitle = (props: React.HTMLAttributes<HTMLHeadingElement>) => <h5 className='mb-1 font-medium' {...props} />;
export const AlertDescription = (props: React.HTMLAttributes<HTMLDivElement>) => <div className='text-muted-foreground' {...props} />;
export default Alert;

View File

@ -0,0 +1,5 @@
import React from 'react';
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
export const Badge = ({ className, variant, size, ...props }: React.HTMLAttributes<HTMLSpanElement> & { variant?: string; size?: string }) => <span className={cx('inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold', className)} {...props} />;
export default Badge;

View File

@ -0,0 +1,6 @@
import React from 'react';
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
export const Button = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: string; size?: string }>(({ className, variant, size, ...props }, ref) => <button ref={ref} className={cx('inline-flex min-h-10 items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', className)} {...props} />);
Button.displayName = 'Button';
export default Button;

View File

@ -0,0 +1,10 @@
import React from 'react';
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
export const Card = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('rounded-xl border bg-card text-card-foreground shadow-sm', className)} {...props} />;
export const CardHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('flex flex-col gap-1.5 p-6', className)} {...props} />;
export const CardTitle = ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => <h3 className={cx('text-lg font-semibold leading-none tracking-tight', className)} {...props} />;
export const CardDescription = ({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => <p className={cx('text-sm text-muted-foreground', className)} {...props} />;
export const CardContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('p-6 pt-0', className)} {...props} />;
export const CardFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('flex items-center p-6 pt-0', className)} {...props} />;
export default Card;

View File

@ -0,0 +1,9 @@
import React from 'react';
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
export const Dialog = ({ open = true, children }: { open?: boolean; children?: React.ReactNode }) => open ? <div role='dialog' aria-modal='true' className='fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4'>{children}</div> : null;
export const DialogContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('w-full max-w-lg rounded-lg border bg-background p-6 shadow-lg', className)} {...props} />;
export const DialogTitle = (props: React.HTMLAttributes<HTMLHeadingElement>) => <h2 className='text-lg font-semibold' {...props} />;
export const DialogDescription = (props: React.HTMLAttributes<HTMLParagraphElement>) => <p className='text-sm text-muted-foreground' {...props} />;
export const DialogClose = (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type='button' aria-label='Close' {...props} />;
export default Dialog;

View File

@ -0,0 +1,6 @@
import React from 'react';
export const DropdownMenu = ({ children }: { children?: React.ReactNode }) => <div className='relative'>{children}</div>;
export const DropdownMenuTrigger = (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type='button' {...props} />;
export const DropdownMenuContent = (props: React.HTMLAttributes<HTMLDivElement>) => <div className='absolute right-0 z-20 mt-2 min-w-40 rounded-md border bg-popover p-1 shadow-md' {...props} />;
export const DropdownMenuItem = (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button type='button' className='flex w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent' {...props} />;

View File

@ -0,0 +1,4 @@
import React from 'react';
export const Empty = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={['flex min-h-32 flex-col items-center justify-center gap-2 rounded-lg border border-dashed p-6 text-center', className].filter(Boolean).join(' ')} {...props} />;
export default Empty;

View File

@ -0,0 +1,7 @@
import React from 'react';
export const Field = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={['flex flex-col gap-2', className].filter(Boolean).join(' ')} {...props} />;
export const FieldLabel = ({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) => <label className={['text-sm font-medium', className].filter(Boolean).join(' ')} {...props} />;
export const FieldDescription = ({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => <p className={['text-sm text-muted-foreground', className].filter(Boolean).join(' ')} {...props} />;
export const FieldError = ({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => <p role='alert' className={['text-sm text-destructive', className].filter(Boolean).join(' ')} {...props} />;
export default Field;

View File

@ -0,0 +1,5 @@
import React from 'react';
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(({ className, ...props }, ref) => <input ref={ref} className={['flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', className].filter(Boolean).join(' ')} {...props} />);
Input.displayName = 'Input';
export default Input;

View File

@ -0,0 +1,4 @@
import React from 'react';
export const Label = ({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) => <label className={['text-sm font-medium leading-none', className].filter(Boolean).join(' ')} {...props} />;
export default Label;

View File

@ -0,0 +1,4 @@
import React from 'react';
export const Separator = ({ orientation = 'horizontal', className, ...props }: React.HTMLAttributes<HTMLDivElement> & { orientation?: 'horizontal' | 'vertical' }) => <div role='separator' aria-orientation={orientation} className={[orientation === 'vertical' ? 'h-full w-px' : 'h-px w-full', 'shrink-0 bg-border', className].filter(Boolean).join(' ')} {...props} />;
export default Separator;

View File

@ -0,0 +1,3 @@
import React from 'react';
export { Dialog as Sheet, DialogContent as SheetContent, DialogTitle as SheetTitle, DialogDescription as SheetDescription, DialogClose as SheetClose } from './dialog';

View File

@ -0,0 +1,5 @@
import React from 'react';
export const Switch = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>((props, ref) => <input ref={ref} type='checkbox' role='switch' className='size-5 rounded-full border focus-visible:ring-2 focus-visible:ring-ring' {...props} />);
Switch.displayName = 'Switch';
export default Switch;

View File

@ -0,0 +1,10 @@
import React from 'react';
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
export const Table = ({ className, ...props }: React.TableHTMLAttributes<HTMLTableElement>) => <div className='w-full overflow-auto'><table className={cx('w-full caption-bottom text-sm', className)} {...props} /></div>;
export const TableHeader = (props: React.HTMLAttributes<HTMLTableSectionElement>) => <thead {...props} />;
export const TableBody = (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tbody {...props} />;
export const TableRow = ({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) => <tr className={cx('border-b', className)} {...props} />;
export const TableHead = (props: React.ThHTMLAttributes<HTMLTableCellElement>) => <th className='h-10 px-2 text-left align-middle font-medium' {...props} />;
export const TableCell = (props: React.TdHTMLAttributes<HTMLTableCellElement>) => <td className='p-2 align-middle' {...props} />;
export default Table;

View File

@ -0,0 +1,8 @@
import React from 'react';
const cx = (...values: Array<string | undefined | false>) => values.filter(Boolean).join(' ');
export const Tabs = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={cx('flex flex-col gap-3', className)} {...props} />;
export const TabsList = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div role='tablist' className={cx('inline-flex h-10 items-center gap-1 rounded-md bg-muted p-1', className)} {...props} />;
export const TabsTrigger = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button role='tab' className={cx('rounded px-3 py-1.5 text-sm', className)} {...props} />;
export const TabsContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div role='tabpanel' className={cx('mt-2', className)} {...props} />;
export default Tabs;

View File

@ -0,0 +1,5 @@
import React from 'react';
export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(({ className, ...props }, ref) => <textarea ref={ref} className={['flex min-h-24 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', className].filter(Boolean).join(' ')} {...props} />);
Textarea.displayName = 'Textarea';
export default Textarea;

View File

@ -0,0 +1,5 @@
import React from 'react';
export const Tooltip = ({ children }: { children?: React.ReactNode }) => <>{children}</>;
export const TooltipTrigger = (props: React.HTMLAttributes<HTMLElement>) => <span {...props} />;
export const TooltipContent = (props: React.HTMLAttributes<HTMLDivElement>) => <div role='tooltip' className='rounded bg-foreground px-2 py-1 text-xs text-background' {...props} />;

View File

@ -0,0 +1,154 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { api } from '../api';
import { authLink } from '../lib/mAuth';
export interface UserData {
username: string;
fullName?: string;
full_name?: string;
email: string;
phone: string;
citizen_id: string;
[key: string]: any;
}
interface AuthContextType {
user: UserData | null;
token: string | null;
login: () => Promise<void>;
checkAuth: () => 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));
};
/**
* Calls the backend check-in endpoint to sync the mAuth user
* with the local database and get the full user profile.
*/
const handleCheckIn = async (authToken: string) => {
try {
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
const res = await api.post('/user/check-in', {
mauth_token: authToken,
});
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;
};
// Public compatibility method for generated onboarding and role guards.
// It always reuses the scaffolded check-in path instead of inventing a
// second token-validation flow in individual pages.
const checkAuth = async (): Promise<UserData | null> => {
const authToken = token || localStorage.getItem('user_auth_token') || localStorage.getItem('auth_token');
if (!authToken) return null;
setToken(authToken);
return handleCheckIn(authToken);
};
/**
* mAuth Native Initializer:
* 1. Check for user_auth_token in localStorage (set by /auth route).
* 2. If present, call backend check-in to validate & sync user data.
* The backend handles mAuth token verification — we must NOT call
* fetchUserData here too, as that would consume/invalidate the token
* before the backend can use it.
* 3. If absent or invalid, clear auth state so the login button is shown.
*/
useEffect(() => {
const initializer = async () => {
const origin = window.location.origin.toString();
if (!localStorage.getItem('mworld_origin')) {
localStorage.setItem('mworld_origin', origin);
}
const uat = localStorage.getItem('user_auth_token');
if (uat) {
setToken(uat);
// Restore cached user data immediately for fast UI
const savedUser = localStorage.getItem('auth_user');
if (savedUser && savedUser !== 'undefined') {
try {
setUser(JSON.parse(savedUser));
} catch { /* ignore parse errors */ }
}
// Validate token & sync user via backend check-in
// (backend calls mAuth internally to verify the token)
const freshUser = await handleCheckIn(uat);
if (!freshUser) {
// Token is invalid or check-in failed — clean up
localStorage.removeItem('user_auth_token');
logout();
}
}
// If no token present, user stays logged out (login button shown)
setIsLoading(false);
};
initializer();
}, []);
/**
* Triggers the mAuth native login flow:
* Generates an auth link and redirects the user to mAuth in the same window.
*/
const login = async (): Promise<void> => {
setIsLoading(true);
try {
const currentOrigin = localStorage.getItem('mworld_origin') || window.location.origin;
const link = await authLink(currentOrigin);
if (!link) {
setIsLoading(false);
return;
}
// Native flow: redirect in same window (not window.open)
window.location.href = link;
} catch (error) {
console.error("Login failed:", error);
setIsLoading(false);
}
};
const logout = () => {
setUser(null);
setToken(null);
localStorage.removeItem('auth_user');
localStorage.removeItem('user_auth_token');
};
return (
<AuthContext.Provider value={{ user, token, login, checkAuth, 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;
};

View File

@ -0,0 +1,48 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, resolveMediaUrl } from '../api';
export function useMediaUpload() {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const uploadMutation = useMutation({
mutationFn: async (file: File): Promise<string> => {
const formData = new FormData();
formData.append('file', file);
const res = await api.post('/api/upload', formData, {
headers: {
'Content-Type': undefined,
},
});
if (res.data?.success && res.data?.url) {
return resolveMediaUrl(res.data.url);
}
throw new Error(res.data?.msg || 'File upload failed');
},
});
const handleFileSelect = (file: File) => {
const objectUrl = URL.createObjectURL(file);
setPreviewUrl(objectUrl);
};
const clearPreview = () => {
if (previewUrl && previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl);
}
setPreviewUrl(null);
};
return {
uploadFile: uploadMutation.mutateAsync,
isUploading: uploadMutation.isPending,
uploadError: uploadMutation.error ? String(uploadMutation.error.message) : null,
previewUrl,
setPreviewUrl,
handleFileSelect,
clearPreview,
};
}

View File

@ -0,0 +1,68 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../api';
export interface NotificationItem {
id: string;
recipient_email: string;
actor_email: string;
type: 'like' | 'comment' | 'follow' | string;
post_id?: string | null;
read: number | boolean;
created_at?: string;
}
export function useNotifications() {
const queryClient = useQueryClient();
const countQuery = useQuery({
queryKey: ['notification-count'],
queryFn: async () => {
try {
const res = await api.get('/api/notifications/count');
return (res.data?.count ?? 0) as number;
} catch {
return 0;
}
},
refetchInterval: 15000,
refetchIntervalInBackground: true,
});
const listQuery = useQuery({
queryKey: ['notifications'],
queryFn: async () => {
const res = await api.get('/api/notifications');
const raw = Array.isArray(res.data?.notifications) ? res.data.notifications : [];
return raw as NotificationItem[];
},
});
const markReadMutation = useMutation({
mutationFn: async (id: string) => {
const res = await api.patch(`/api/notifications/${id}/read`);
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-count'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const notifications = listQuery.data ?? [];
const unreadCount = countQuery.data ?? notifications.filter((n) => !n.read || n.read === 0).length;
return {
notifications,
unreadCount,
isLoading: listQuery.isLoading || countQuery.isLoading,
isError: listQuery.isError || countQuery.isError,
error: listQuery.error || countQuery.error,
refetch: () => {
listQuery.refetch();
countQuery.refetch();
},
markRead: (id: string) => markReadMutation.mutate(id),
markReadAsync: async (id: string) => markReadMutation.mutateAsync(id),
isMarkingRead: markReadMutation.isPending,
};
}

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

@ -0,0 +1,41 @@
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Sora:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 48 24% 97%;
--foreground: 0 0% 0%;
--card: 0 0% 100%;
--card-foreground: 0 0% 0%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 0%;
--primary: 224 64% 33%;
--primary-foreground: 0 0% 100%;
--secondary: 214 100% 97%;
--secondary-foreground: 0 0% 0%;
--muted: 48 13% 95%;
--muted-foreground: 240 4% 46%;
--accent: 17 88% 40%;
--accent-foreground: 0 0% 100%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--border: 240 6% 90%;
--input: 240 6% 90%;
--ring: 224 64% 33%;
--radius: 0.75rem;
}
body {
background-color: #faf9f5;
color: #000000;
font-family: 'Plus Jakarta Sans', sans-serif;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4, h5, h6, .font-display {
font-family: 'Sora', sans-serif;
}
}

47
frontend/src/lib/mAuth.ts Normal file
View File

@ -0,0 +1,47 @@
import axios from 'axios';
const mAuthAPIURL = "https://api.mauth.mworld.cloud";
/**
* Creates a new mAuth authentication link.
* Requires the current origin URL.
*/
export const authLink = async (currentOrigin: string): Promise<string | null> => {
try {
const r = await axios.get(`${mAuthAPIURL}/mAuthGen?origin=${encodeURIComponent(currentOrigin)}&type=native`);
console.log(r.data);
const link = r.data?.mAuth?.auth_link;
return link || null;
} catch (err) {
console.log(err);
return null;
}
};
/**
* Fetches the data of the user who authenticated.
* Requires origin URL and the user auth token.
*
* WARNING: This CONSUMES the mAuth token. The backend `/user/check-in`
* endpoint already verifies the token server-side, so calling this during
* app bootstrap would invalidate the token before the backend can use it.
* Do NOT call this from AuthContext — it exists only for one-off flows that
* need raw mAuth profile fields without a backend round-trip.
*/
export const fetchUserData = async (origin: string, token: string) => {
try {
const payload = {
origin: origin,
required_user_data: ["fullName", "email", "phone"],
};
const r = await axios.get(
`${mAuthAPIURL}/mAuthUser/${token}?payload=${JSON.stringify(payload)}`
);
console.log(r.data);
return r.data;
} catch (err) {
console.log(err);
return null;
}
};

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 '';
}
}

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

@ -0,0 +1,31 @@
import React, { Suspense } 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>
<Suspense fallback={<div>Loading...</div>}>
<AuthProvider>
<App />
</AuthProvider>
</Suspense>
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
)

View File

@ -0,0 +1,440 @@
import React, { useState, useEffect } from 'react';
import { adminApi } from '../api';
import { AlertCircle, Calendar, CheckCircle2, FileText, Key, Lock, LogOut, MessageSquare, Phone, RefreshCw, Search, Shield, ShieldCheck, UserCheck, UserX, Users } from 'lucide-react';
interface AdminStats {
total_users: number;
total_posts: number;
total_comments: number;
}
interface UserRecord {
id: string;
email: string;
full_name?: string | null;
display_name?: string | null;
bio?: string | null;
avatar_url?: string | null;
role?: string | null;
created_at?: string | null;
username?: string | null;
phone?: string | null;
citizen_id?: string | null;
is_banned?: number | boolean | null;
}
export default function AdminPage() {
const [token, setToken] = useState<string>(() => localStorage.getItem('vibely_admin_token') || '');
const [inputToken, setInputToken] = useState<string>('');
const [isVerified, setIsVerified] = useState<boolean>(false);
const [isVerifying, setIsVerifying] = useState<boolean>(false);
const [authError, setAuthError] = useState<string | null>(null);
const [stats, setStats] = useState<AdminStats | null>(null);
const [users, setUsers] = useState<UserRecord[]>([]);
const [isLoadingData, setIsLoadingData] = useState<boolean>(false);
const [dataError, setDataError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
useEffect(() => {
if (token) {
verifyToken(token);
}
}, []);
const verifyToken = async (tok: string) => {
setIsVerifying(true);
setAuthError(null);
try {
const res = await adminApi.get('/api/admin/verify-token', {
headers: { Authorization: `Bearer ${tok}` }
});
if (res.data && res.data.success) {
setIsVerified(true);
localStorage.setItem('vibely_admin_token', tok);
fetchDashboardData(tok);
} else {
setIsVerified(false);
setAuthError(res.data?.msg || 'Invalid or expired operator key.');
}
} catch (err: any) {
setIsVerified(false);
const msg = err?.response?.data?.msg || err?.message || 'Key verification failed.';
setAuthError(msg);
} finally {
setIsVerifying(false);
}
};
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
const cleanToken = String((inputToken) ?? '').trim();
if (!cleanToken) return;
setToken(cleanToken);
verifyToken(cleanToken);
};
const handleLogout = () => {
localStorage.removeItem('vibely_admin_token');
setToken('');
setInputToken('');
setIsVerified(false);
setStats(null);
setUsers([]);
};
const fetchDashboardData = async (tok: string) => {
setIsLoadingData(true);
setDataError(null);
try {
const headers = { Authorization: `Bearer ${tok}` };
const [statsRes, usersRes] = await Promise.all([
adminApi.get('/api/admin/stats', { headers }),
adminApi.get('/api/admin/users', { headers })
]);
if (statsRes.data && statsRes.data.success) {
setStats({
total_users: Number(statsRes.data.total_users ?? 0),
total_posts: Number(statsRes.data.total_posts ?? 0),
total_comments: Number(statsRes.data.total_comments ?? 0)
});
}
if (usersRes.data && usersRes.data.success && Array.isArray(usersRes.data.users)) {
setUsers(usersRes.data.users);
}
} catch (err: any) {
const msg = err?.response?.data?.msg || err?.message || 'Failed to load system operator metrics.';
setDataError(msg);
} finally {
setIsLoadingData(false);
}
};
const filteredUsers = users.filter((u) => {
if (!String((searchQuery) ?? '').trim()) return true;
const q = searchQuery.toLowerCase();
const email = String(u.email ?? '').toLowerCase();
const fullName = String(u.full_name ?? '').toLowerCase();
const displayName = String(u.display_name ?? '').toLowerCase();
const username = String(u.username ?? '').toLowerCase();
const citizenId = String(u.citizen_id ?? '').toLowerCase();
return (
email.includes(q) ||
fullName.includes(q) ||
displayName.includes(q) ||
username.includes(q) ||
citizenId.includes(q)
);
});
if (!isVerified) {
return (
<div className="min-h-[100dvh] bg-[#faf9f5] flex items-center justify-center p-4 text-[#000000]">
<div className="w-full max-w-md bg-white rounded-2xl border border-[#e4e4e7] p-8 shadow-sm">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-[#eff6ff] text-[#1e3a8a] flex items-center justify-center">
<Shield className="w-5 h-5" />
</div>
<div>
<h1 className="font-['Sora'] font-bold text-xl text-[#1e3a8a]">Vibely Operator Panel</h1>
<p className="text-xs text-gray-500 font-medium">Platform Verification Required</p>
</div>
</div>
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-gray-700 uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Key className="w-3.5 h-3.5 text-[#c2410c]" />
Operator Access Key
</label>
<input
type="password"
value={inputToken}
onChange={(e) => setInputToken(e.target.value)}
placeholder="Enter operator access key..."
className="w-full px-4 py-2.5 rounded-lg border border-[#e4e4e7] text-sm focus:outline-none focus:ring-2 focus:ring-[#1e3a8a] focus:border-transparent bg-white"
autoFocus
/>
</div>
{authError && (
<div className="p-3 rounded-lg bg-red-50 border border-red-200 flex items-start gap-2 text-xs text-red-700">
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
<span>{authError}</span>
</div>
)}
<button
type="submit"
disabled={isVerifying || !String((inputToken) ?? '').trim()}
className="w-full py-2.5 px-4 rounded-lg bg-[#1e3a8a] hover:bg-[#1e3a8a]/90 text-white font-medium text-sm transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
>
{isVerifying ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Verifying Key...
</>
) : (
<>
<ShieldCheck className="w-4 h-4" />
Unlock Operator Access
</>
)}
</button>
</form>
</div>
</div>
);
}
return (
<div className="min-h-[100dvh] bg-[#faf9f5] text-[#000000]">
<header className="bg-white border-b border-[#e4e4e7] sticky top-0 z-30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-[#eff6ff] text-[#1e3a8a] flex items-center justify-center font-bold text-lg">
V
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="font-['Sora'] font-bold text-lg text-[#1e3a8a]">Vibely Operator Panel</h1>
<span className="px-2 py-0.5 text-[10px] font-semibold bg-emerald-100 text-emerald-800 rounded-full flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Verified Session
</span>
</div>
<p className="text-xs text-gray-500">Platform Metrics &amp; User Directory</p>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => fetchDashboardData(token)}
disabled={isLoadingData}
className="p-2 text-gray-600 hover:text-[#1e3a8a] hover:bg-[#f4f3ef] rounded-lg transition-colors border border-[#e4e4e7] flex items-center gap-1.5 text-xs font-medium"
title="Refresh Metrics"
>
<RefreshCw className={`w-4 h-4 ${isLoadingData ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">Refresh</span>
</button>
<button
onClick={handleLogout}
className="p-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors border border-[#e4e4e7] flex items-center gap-1.5 text-xs font-medium"
>
<LogOut className="w-4 h-4" />
<span className="hidden sm:inline">Lock Session</span>
</button>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
{dataError && (
<div className="p-4 rounded-xl bg-red-50 border border-red-200 flex items-center justify-between text-sm text-red-700">
<div className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-red-500 shrink-0" />
<span>{dataError}</span>
</div>
<button
onClick={() => fetchDashboardData(token)}
className="text-xs font-semibold underline hover:text-red-900"
>
Try Again
</button>
</div>
)}
<section className="grid grid-cols-1 sm:grid-cols-3 gap-5">
<div className="bg-white rounded-xl border border-[#e4e4e7] p-5 shadow-sm hover:border-[#1e3a8a]/30 transition-colors">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Total Synchronized Users</span>
<div className="p-2 rounded-lg bg-[#eff6ff] text-[#1e3a8a]">
<Users className="w-5 h-5" />
</div>
</div>
<div className="font-['Sora'] font-bold text-3xl text-[#1e3a8a]">
{isLoadingData ? '...' : (stats?.total_users ?? 0).toLocaleString()}
</div>
<p className="text-xs text-gray-500 mt-2 flex items-center gap-1">
Registered accounts in database
</p>
</div>
<div className="bg-white rounded-xl border border-[#e4e4e7] p-5 shadow-sm hover:border-[#c2410c]/30 transition-colors">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Total Created Posts</span>
<div className="p-2 rounded-lg bg-orange-50 text-[#c2410c]">
<FileText className="w-5 h-5" />
</div>
</div>
<div className="font-['Sora'] font-bold text-3xl text-[#c2410c]">
{isLoadingData ? '...' : (stats?.total_posts ?? 0).toLocaleString()}
</div>
<p className="text-xs text-gray-500 mt-2 flex items-center gap-1">
Text &amp; photo updates published
</p>
</div>
<div className="bg-white rounded-xl border border-[#e4e4e7] p-5 shadow-sm hover:border-emerald-500/30 transition-colors">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Total System Comments</span>
<div className="p-2 rounded-lg bg-emerald-50 text-emerald-700">
<MessageSquare className="w-5 h-5" />
</div>
</div>
<div className="font-['Sora'] font-bold text-3xl text-emerald-700">
{isLoadingData ? '...' : (stats?.total_comments ?? 0).toLocaleString()}
</div>
<p className="text-xs text-gray-500 mt-2 flex items-center gap-1">
Discussion replies across feed
</p>
</div>
</section>
<section className="bg-white rounded-xl border border-[#e4e4e7] shadow-sm overflow-hidden">
<div className="p-5 border-b border-[#e4e4e7] flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-gray-50/50">
<div>
<h2 className="font-['Sora'] font-bold text-lg text-[#1e3a8a] flex items-center gap-2">
<Users className="w-5 h-5 text-[#c2410c]" /> User Directory
</h2>
<p className="text-xs text-gray-500 mt-0.5">
Viewing {filteredUsers.length} of {users.length} registered accounts
</p>
</div>
<div className="relative w-full sm:w-72">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Filter by name, email, or handle..."
className="w-full pl-9 pr-4 py-2 rounded-lg border border-[#e4e4e7] text-xs focus:outline-none focus:ring-2 focus:ring-[#1e3a8a] focus:border-transparent bg-white"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse text-xs">
<thead>
<tr className="bg-[#faf9f5] border-b border-[#e4e4e7] text-gray-600 font-semibold uppercase tracking-wider">
<th className="py-3 px-4">User Account</th>
<th className="py-3 px-4">Display Name</th>
<th className="py-3 px-4">Bio</th>
<th className="py-3 px-4">M-Auth Phone</th>
<th className="py-3 px-4">Citizen ID</th>
<th className="py-3 px-4">Role</th>
<th className="py-3 px-4">Status</th>
<th className="py-3 px-4">Created At</th>
</tr>
</thead>
<tbody className="divide-y divide-[#e4e4e7]">
{isLoadingData ? (
<tr>
<td colSpan={8} className="py-12 text-center text-gray-500">
<div className="flex items-center justify-center gap-2">
<RefreshCw className="w-5 h-5 animate-spin text-[#1e3a8a]" />
<span>Fetching synchronized user records...</span>
</div>
</td>
</tr>
) : filteredUsers.length === 0 ? (
<tr>
<td colSpan={8} className="py-12 text-center text-gray-500">
No user records match your criteria.
</td>
</tr>
) : (
filteredUsers.map((u) => {
const isBanned = Boolean(u.is_banned);
const formattedDate = u.created_at
? new Date(u.created_at).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
: 'N/A';
return (
<tr key={u.id} className="hover:bg-[#faf9f5]/80 transition-colors">
<td className="py-3.5 px-4 font-medium text-[#1e3a8a]">
<div className="flex items-center gap-2.5">
{u.avatar_url ? (
<img
src={u.avatar_url}
alt={u.full_name || u.email}
className="w-7 h-7 rounded-full object-cover border border-[#e4e4e7] shrink-0"
/>
) : (
<div className="w-7 h-7 rounded-full bg-[#eff6ff] text-[#1e3a8a] font-bold text-xs flex items-center justify-center shrink-0 border border-[#e4e4e7]">
{String(u.full_name || u.email).charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0">
<div className="font-semibold text-gray-900 truncate">{u.full_name || 'No Name'}</div>
<div className="text-[11px] text-gray-500 truncate">{u.email}</div>
</div>
</div>
</td>
<td className="py-3.5 px-4 text-gray-700 font-medium">
{u.display_name || u.username ? (
<span>@{u.username || u.display_name}</span>
) : (
<span className="text-gray-400 italic">Not set</span>
)}
</td>
<td className="py-3.5 px-4 text-gray-600 max-w-xs truncate">
{u.bio || <span className="text-gray-400 italic">No bio</span>}
</td>
<td className="py-3.5 px-4 text-gray-700 font-mono text-[11px]">
{u.phone ? (
<span className="flex items-center gap-1">
<Phone className="w-3 h-3 text-gray-400" /> {u.phone}
</span>
) : (
<span className="text-gray-400">—</span>
)}
</td>
<td className="py-3.5 px-4 text-gray-700 font-mono text-[11px]">
{u.citizen_id || <span className="text-gray-400">—</span>}
</td>
<td className="py-3.5 px-4">
<span className={`px-2 py-0.5 text-[10px] font-semibold rounded-md border ${
u.role === 'admin'
? 'bg-purple-50 text-purple-700 border-purple-200'
: 'bg-blue-50 text-blue-700 border-blue-200'
}`}>
{u.role || 'user'}
</span>
</td>
<td className="py-3.5 px-4">
{isBanned ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold bg-red-100 text-red-800 rounded-full">
<UserX className="w-3 h-3" /> Banned
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold bg-emerald-100 text-emerald-800 rounded-full">
<UserCheck className="w-3 h-3" /> Active
</span>
)}
</td>
<td className="py-3.5 px-4 text-gray-500 whitespace-nowrap flex items-center gap-1.5 mt-2">
<Calendar className="w-3.5 h-3.5 text-gray-400" />
{formattedDate}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</section>
</main>
</div>
);
}

View File

@ -0,0 +1,205 @@
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertCircle, Compass, RefreshCw, Search, Sparkles, TrendingUp, Users } from 'lucide-react';
import { Link } from 'react-router-dom';
import { api } from '../api';
import { useAuth } from '../context/AuthContext';
import PostCard, { Post } from '../components/features/PostCard';
import PostComposer from '../components/features/PostComposer';
import CommentsModal from '../components/features/CommentsModal';
export default function AppHome() {
const { user } = useAuth();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<'following' | 'discover'>('following');
const [selectedPostIdForComments, setSelectedPostIdForComments] = useState<string | null>(null);
const {
data: postsData,
isLoading,
isError,
error,
refetch,
isRefetching,
} = useQuery<Post[]>({
queryKey: ['posts', activeTab],
queryFn: async (): Promise<Post[]> => {
const endpoint = activeTab === 'following' ? '/api/posts?feed=true' : '/api/posts';
const res = await api.get(endpoint);
return Array.isArray(res.data?.posts) ? res.data.posts : [];
},
});
const posts: Post[] = Array.isArray(postsData) ? postsData : [];
const handlePostCreated = () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
};
return (
<div className="max-w-6xl mx-auto px-4 py-6">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
<main className="lg:col-span-8 space-y-6">
<PostComposer onPostCreated={handlePostCreated} />
<div className="bg-white rounded-xl border border-zinc-200 p-1.5 flex items-center justify-between shadow-sm">
<div className="flex space-x-1 w-full sm:w-auto">
<button
type="button"
onClick={() => setActiveTab('following')}
className={`flex-1 sm:flex-none flex items-center justify-center space-x-2 px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
activeTab === 'following'
? 'bg-[#1e3a8a] text-white shadow-sm'
: 'text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100'
}`}
>
<Users className="w-4 h-4" />
<span>Following</span>
</button>
<button
type="button"
onClick={() => setActiveTab('discover')}
className={`flex-1 sm:flex-none flex items-center justify-center space-x-2 px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
activeTab === 'discover'
? 'bg-[#1e3a8a] text-white shadow-sm'
: 'text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100'
}`}
>
<Compass className="w-4 h-4" />
<span>Discover</span>
</button>
</div>
<button
type="button"
onClick={() => refetch()}
disabled={isRefetching}
className="p-2 text-zinc-500 hover:text-zinc-800 hover:bg-zinc-100 rounded-lg transition-colors hidden sm:flex items-center space-x-1 text-xs"
title="Refresh feed"
>
<RefreshCw className={`w-4 h-4 ${isRefetching ? 'animate-spin text-[#c2410c]' : ''}`} />
<span className="hidden md:inline">Refresh</span>
</button>
</div>
{isLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((n) => (
<div key={n} className="bg-white rounded-xl border border-zinc-200 p-6 animate-pulse space-y-4">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-zinc-200 rounded-full" />
<div className="space-y-2 flex-1">
<div className="h-4 bg-zinc-200 rounded w-1/4" />
<div className="h-3 bg-zinc-100 rounded w-1/6" />
</div>
</div>
<div className="h-16 bg-zinc-100 rounded-lg" />
</div>
))}
</div>
) : isError ? (
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center space-y-3">
<AlertCircle className="w-8 h-8 text-red-600 mx-auto" />
<h3 className="text-base font-semibold text-red-900">Failed to load feed</h3>
<p className="text-sm text-red-700">
{error instanceof Error ? error.message : 'An error occurred while retrieving updates.'}
</p>
<button
type="button"
onClick={() => refetch()}
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-semibold hover:bg-red-700 transition-colors"
>
Try Again
</button>
</div>
) : posts.length === 0 ? (
<div className="bg-white rounded-xl border border-zinc-200 p-10 text-center space-y-4">
<div className="w-12 h-12 bg-amber-50 text-[#c2410c] rounded-full flex items-center justify-center mx-auto">
<Sparkles className="w-6 h-6" />
</div>
<div className="space-y-1">
<h3 className="text-lg font-bold text-[#1e3a8a]">
{activeTab === 'following' ? 'No posts from followed accounts' : 'No posts found'}
</h3>
<p className="text-sm text-zinc-600 max-w-md mx-auto">
{activeTab === 'following'
? 'Connect with creators in the Discover tab or search for friends to build your feed.'
: 'Be the first to share an update with the community!'}
</p>
</div>
{activeTab === 'following' && (
<div className="pt-2">
<button
type="button"
onClick={() => setActiveTab('discover')}
className="px-4 py-2 bg-[#1e3a8a] text-white rounded-lg text-sm font-medium hover:bg-blue-900 transition-colors"
>
Explore Discover Feed
</button>
</div>
)}
</div>
) : (
<div className="space-y-4">
{posts.map((post) => (
<PostCard
key={post.id}
post={post}
onOpenComments={(postId) => setSelectedPostIdForComments(postId)}
/>
))}
</div>
)}
</main>
<aside className="hidden lg:block lg:col-span-4 space-y-6">
<div className="bg-white rounded-xl border border-zinc-200 p-5 space-y-3 shadow-sm">
<div className="flex items-center space-x-2 text-[#1e3a8a] font-bold">
<Search className="w-4 h-4 text-[#c2410c]" />
<span>Discover Creators</span>
</div>
<p className="text-xs text-zinc-600 leading-relaxed">
Find friends, keyword updates, and vibrant content creators across the Vibely network.
</p>
<Link
to="/app/search"
className="w-full flex items-center justify-center space-x-2 py-2.5 px-4 bg-[#eff6ff] text-[#1e3a8a] hover:bg-[#1e3a8a] hover:text-white rounded-lg text-xs font-semibold transition-all border border-blue-100"
>
<span>Search Vibely</span>
</Link>
</div>
<div className="bg-[#faf9f5] rounded-xl border border-zinc-200 p-5 space-y-3">
<div className="flex items-center space-x-2 text-[#1e3a8a] font-bold text-sm">
<TrendingUp className="w-4 h-4 text-[#c2410c]" />
<span>Solar Kinetic Feed</span>
</div>
<p className="text-xs text-zinc-600 leading-relaxed">
Vibely delivers chronological social updates without algorithmic manipulation. What you see is directly what your connections broadcast.
</p>
{user && (
<div className="pt-2 border-t border-zinc-200 text-xs text-zinc-500">
Logged in as <span className="font-semibold text-zinc-800">@{user.username || user.email?.split('@')[0]}</span>
</div>
)}
</div>
</aside>
</div>
{/* Discussion / Comments Modal */}
{selectedPostIdForComments && (
<CommentsModal
postId={selectedPostIdForComments}
onClose={() => setSelectedPostIdForComments(null)}
/>
)}
</div>
);
}

156
frontend/src/pages/Auth.tsx Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,467 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight, CheckCircle2, Compass, Heart, MessageSquare, Radio, Search, Share2, Sparkles, UserPlus } from 'lucide-react';
export default function Landing() {
const loginUrl = "/auth?returnTo=%2Fapp";
return (
<div className="min-h-screen bg-[#faf9f5] text-zinc-900 font-['Plus_Jakarta_Sans',sans-serif] selection:bg-[#c2410c] selection:text-white">
<header className="sticky top-0 z-50 backdrop-blur-md bg-[#faf9f5]/80 border-b border-[#e4e4e7] px-4 sm:px-8 py-3.5 flex items-center justify-between transition-all">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-xl bg-[#1e3a8a] text-white flex items-center justify-center font-bold text-lg shadow-sm">
V
</div>
<span className="font-['Sora',sans-serif] font-bold text-xl tracking-tight text-[#1e3a8a]">
Vibely
</span>
</div>
<nav className="hidden md:flex items-center gap-6 text-sm font-medium text-zinc-600">
<a href="#feed-philosophy-section" className="hover:text-[#1e3a8a] transition-colors">
Chronological Feed
</a>
<a href="#creator-discovery-section" className="hover:text-[#1e3a8a] transition-colors">
Discovery
</a>
<a href="#notifications-and-reactions-section" className="hover:text-[#1e3a8a] transition-colors">
Reactions
</a>
</nav>
<div className="flex items-center gap-3">
<Link
to={loginUrl}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white bg-[#1e3a8a] hover:bg-[#1d357a] active:bg-[#152a65] rounded-xl shadow-sm transition-all focus:outline-none focus:ring-2 focus:ring-[#1e3a8a] focus:ring-offset-2"
>
<span>Sign In with M-Auth</span>
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</header>
<main className="w-full">
<section
id="hero-section"
className="max-w-7xl mx-auto px-4 sm:px-8 pt-12 sm:pt-20 pb-16 sm:pb-24 grid grid-cols-1 lg:grid-cols-12 gap-10 items-center"
>
<div className="lg:col-span-6 space-y-6">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[#eff6ff] border border-blue-200 text-[#c2410c] font-semibold text-xs uppercase tracking-wider">
<Sparkles className="w-3.5 h-3.5 text-[#c2410c]" />
<span>Solar Kinetic Social</span>
</div>
<h1 className="font-['Sora',sans-serif] text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight text-[#1e3a8a] leading-[1.15]">
Real social moments, chronological clarity.
</h1>
<p className="text-base sm:text-lg text-zinc-600 max-w-xl leading-relaxed">
Share text and photo updates with your friends without algorithmic clutter. Vibely delivers pure chronological feeds so you never miss what matters most.
</p>
<div className="pt-2 flex flex-col sm:flex-row items-stretch sm:items-center gap-4">
<Link
to={loginUrl}
className="inline-flex items-center justify-center gap-2 px-6 py-3.5 text-base font-semibold text-white bg-[#c2410c] hover:bg-[#a83508] rounded-xl shadow-md transition-all text-center"
>
<span>Sign In with M-Auth</span>
<ArrowRight className="w-5 h-5" />
</Link>
<div className="flex items-center gap-2 text-xs font-medium text-zinc-500 justify-center sm:justify-start">
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
<span>Instant single-click check-in</span>
</div>
</div>
<div className="pt-6 border-t border-[#e9e8e2] grid grid-cols-3 gap-4">
<div>
<p className="font-['Sora',sans-serif] text-2xl font-bold text-[#1e3a8a]">100%</p>
<p className="text-xs text-zinc-500">Chronological feed</p>
</div>
<div>
<p className="font-['Sora',sans-serif] text-2xl font-bold text-[#1e3a8a]">0</p>
<p className="text-xs text-zinc-500">Algorithmic noise</p>
</div>
<div>
<p className="font-['Sora',sans-serif] text-2xl font-bold text-[#1e3a8a]">Real</p>
<p className="text-xs text-zinc-500">Photo broadcasts</p>
</div>
</div>
</div>
<div className="lg:col-span-6 relative">
<div className="bg-white rounded-2xl p-4 sm:p-5 border border-[#e4e4e7] shadow-lg relative z-10 space-y-4">
<div className="flex items-center justify-between pb-3 border-b border-zinc-100">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-amber-100 border-2 border-white shadow-sm flex items-center justify-center text-amber-800 font-bold">
SA
</div>
<div>
<h3 className="font-semibold text-sm text-zinc-900">Sarah Adams</h3>
<p className="text-xs text-zinc-400">@sarah &bull; 12m ago</p>
</div>
</div>
<span className="px-2.5 py-1 rounded-full bg-[#eff6ff] text-[#1e3a8a] text-xs font-medium">
Morning broadcast
</span>
</div>
<p className="text-sm text-zinc-700">
Starting the week with fresh espresso and new ideas. Happy Monday everyone!
</p>
<div className="relative rounded-xl overflow-hidden border border-zinc-100 aspect-[4/3]">
<img
src="/assets/landing-01-hero-section.png"
alt="hero_feed_preview_photo for Vibely"
className="w-full h-full object-cover object-center"
/>
</div>
<div className="flex items-center justify-between pt-2 text-xs font-medium text-zinc-500">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1.5 text-[#c2410c] font-semibold">
<Heart className="w-4 h-4 fill-current text-[#c2410c]" />
<span>24 likes</span>
</span>
<span className="flex items-center gap-1.5 hover:text-zinc-800 cursor-pointer">
<MessageSquare className="w-4 h-4" />
<span>8 comments</span>
</span>
</div>
<Share2 className="w-4 h-4 hover:text-zinc-800 cursor-pointer" />
</div>
</div>
<div className="absolute -bottom-6 -right-6 w-48 h-48 bg-[#c2410c]/10 rounded-full blur-3xl -z-10" />
<div className="absolute -top-6 -left-6 w-48 h-48 bg-[#1e3a8a]/10 rounded-full blur-3xl -z-10" />
</div>
</section>
<section
id="feed-philosophy-section"
className="bg-white py-16 sm:py-24 border-y border-[#e4e4e7]"
>
<div className="max-w-7xl mx-auto px-4 sm:px-8">
<div className="max-w-2xl mx-auto text-center space-y-4 mb-14">
<span className="px-3 py-1 rounded-full bg-[#f4f3ef] text-[#1e3a8a] text-xs font-bold uppercase tracking-wider">
Feed Philosophy
</span>
<h2 className="font-['Sora',sans-serif] text-3xl sm:text-4xl font-bold text-[#1e3a8a] tracking-tight">
Your Feed, Pure &amp; In Order
</h2>
<p className="text-zinc-600 text-base sm:text-lg">
Posts strictly from creators you actively follow, sorted with newest updates at the top. No artificial manipulation or unwanted recommendations.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-center">
<div className="lg:col-span-5 space-y-6">
<div className="p-5 rounded-2xl bg-[#faf9f5] border border-[#e4e4e7] space-y-3">
<div className="w-10 h-10 rounded-xl bg-[#1e3a8a]/10 text-[#1e3a8a] flex items-center justify-center font-bold">
<Radio className="w-5 h-5" />
</div>
<h3 className="font-['Sora',sans-serif] font-semibold text-lg text-zinc-900">
Strict Chronology
</h3>
<p className="text-sm text-zinc-600 leading-relaxed">
Always view posts in true timestamp order. What was posted 5 minutes ago stays above what was posted yesterday.
</p>
</div>
<div className="p-5 rounded-2xl bg-[#faf9f5] border border-[#e4e4e7] space-y-3">
<div className="w-10 h-10 rounded-xl bg-[#c2410c]/10 text-[#c2410c] flex items-center justify-center font-bold">
<Compass className="w-5 h-5" />
</div>
<h3 className="font-['Sora',sans-serif] font-semibold text-lg text-zinc-900">
Creator Freedom
</h3>
<p className="text-sm text-zinc-600 leading-relaxed">
Post rich text thoughts alongside vibrant image broadcasts. Pure expression without content filters hiding your updates.
</p>
</div>
</div>
<div className="lg:col-span-7">
<div className="rounded-2xl border border-[#e4e4e7] bg-[#faf9f5] p-4 sm:p-6 shadow-sm overflow-hidden">
<div className="flex items-center justify-between pb-4 mb-4 border-b border-zinc-200">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-emerald-500" />
<span className="text-xs font-bold text-zinc-700 uppercase tracking-wider">
Live Timeline Demonstration
</span>
</div>
<span className="text-xs text-zinc-400 font-mono">feed_order: asc</span>
</div>
<div className="space-y-4">
<div className="bg-white p-4 rounded-xl border border-zinc-200 shadow-sm flex flex-col sm:flex-row gap-4 items-center">
<img
src="/assets/landing-02-feed-philosophy-section.png"
alt="interaction_preview_card_image for Vibely"
className="w-full sm:w-48 h-32 rounded-lg object-cover"
/>
<div className="space-y-2 text-left w-full">
<div className="flex items-center justify-between">
<span className="font-semibold text-sm text-zinc-900">Alex Chen</span>
<span className="text-xs text-zinc-400">2m ago</span>
</div>
<p className="text-xs text-zinc-600 leading-snug">
Weekend getaway up in the mountains. Clear skies and quiet trails.
</p>
<div className="flex items-center gap-3 pt-1 text-xs text-zinc-500">
<span className="flex items-center gap-1 text-[#c2410c]">
<Heart className="w-3.5 h-3.5 fill-current" /> 18
</span>
<span className="flex items-center gap-1">
<MessageSquare className="w-3.5 h-3.5" /> 4
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section
id="creator-discovery-section"
className="py-16 sm:py-24 max-w-7xl mx-auto px-4 sm:px-8"
>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 items-start">
<div className="lg:col-span-5 space-y-6 lg:sticky lg:top-24">
<span className="px-3 py-1 rounded-full bg-[#eff6ff] text-[#1e3a8a] text-xs font-bold uppercase tracking-wider">
Discovery &amp; Circle
</span>
<h2 className="font-['Sora',sans-serif] text-3xl sm:text-4xl font-bold text-[#1e3a8a] tracking-tight">
Find &amp; Follow Your Circle
</h2>
<p className="text-zinc-600 text-base leading-relaxed">
Connect directly with people whose thoughts and updates you care about. Use our quick search to locate creators by name or bio keyword.
</p>
<div className="relative">
<Search className="w-4 h-4 absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400" />
<input
type="text"
readOnly
value="Search 'designers' or 'photography'..."
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-zinc-300 bg-white text-sm text-zinc-500 shadow-sm cursor-pointer"
/>
</div>
<div className="p-4 rounded-xl bg-white border border-[#e4e4e7] space-y-2">
<p className="text-xs font-semibold text-[#1e3a8a] uppercase tracking-wider">
Follow Mechanics
</p>
<p className="text-xs text-zinc-600">
Instant one-click follow state transitions with zero reload delays. Followed users immediately appear in your feed.
</p>
</div>
</div>
<div className="lg:col-span-7 space-y-4">
<div className="rounded-2xl border border-[#e4e4e7] bg-white p-6 shadow-sm space-y-6">
<div className="flex items-center justify-between pb-3 border-b border-zinc-100">
<h3 className="font-['Sora',sans-serif] font-bold text-lg text-zinc-900">
Trending Creators
</h3>
<span className="px-2.5 py-1 rounded-full bg-orange-100 text-[#c2410c] text-xs font-semibold">
Suggested
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="p-4 rounded-xl border border-zinc-200 bg-[#faf9f5] flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 text-[#1e3a8a] font-bold flex items-center justify-center">
MK
</div>
<div>
<h4 className="font-semibold text-sm text-zinc-900">Maya Lin</h4>
<p className="text-xs text-zinc-500">@mayalin</p>
</div>
</div>
<button className="px-3 py-1.5 rounded-lg bg-[#c2410c] text-white text-xs font-semibold hover:bg-[#a83508] transition-colors flex items-center gap-1">
<UserPlus className="w-3.5 h-3.5" />
<span>Follow</span>
</button>
</div>
<div className="p-4 rounded-xl border border-zinc-200 bg-[#faf9f5] flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-emerald-100 text-emerald-800 font-bold flex items-center justify-center">
DV
</div>
<div>
<h4 className="font-semibold text-sm text-zinc-900">David Vance</h4>
<p className="text-xs text-zinc-500">@vancedave</p>
</div>
</div>
<button className="px-3 py-1.5 rounded-lg bg-[#c2410c] text-white text-xs font-semibold hover:bg-[#a83508] transition-colors flex items-center gap-1">
<UserPlus className="w-3.5 h-3.5" />
<span>Follow</span>
</button>
</div>
</div>
<div className="rounded-xl overflow-hidden border border-zinc-200 aspect-[16/9] relative">
<img
src="/assets/landing-03-creator-discovery-section.png"
alt="creator_avatar_samples for Vibely"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent flex items-end p-4">
<p className="text-white text-xs font-medium">
Discover inspiration across art, tech, outdoor living, and photo stories.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
<section
id="notifications-and-reactions-section"
className="bg-white py-16 sm:py-24 border-t border-[#e4e4e7]"
>
<div className="max-w-7xl mx-auto px-4 sm:px-8">
<div className="max-w-2xl mx-auto text-center space-y-4 mb-14">
<span className="px-3 py-1 rounded-full bg-[#f4f3ef] text-[#1e3a8a] text-xs font-bold uppercase tracking-wider">
Instant Alerts
</span>
<h2 className="font-['Sora',sans-serif] text-3xl sm:text-4xl font-bold text-[#1e3a8a] tracking-tight">
Instant Social Activity
</h2>
<p className="text-zinc-600 text-base sm:text-lg">
Stay updated when someone likes your post, joins your follower list, or shares a thought on your updates.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="p-5 rounded-2xl border border-zinc-200 bg-[#faf9f5] space-y-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-red-100 text-[#c2410c] flex items-center justify-center">
<Heart className="w-5 h-5 fill-current" />
</div>
<div>
<h3 className="font-semibold text-sm text-zinc-900">Post Reaction</h3>
<p className="text-xs text-zinc-500">Real-time engagement</p>
</div>
</div>
<p className="text-xs text-zinc-600 leading-relaxed">
Alex Chen and 3 others liked your photo broadcast &quot;Morning coffee in downtown&quot;.
</p>
<div className="pt-2 text-[11px] text-zinc-400 flex items-center justify-between border-t border-zinc-200">
<span>5m ago</span>
<span className="text-emerald-700 font-semibold">Unread</span>
</div>
</div>
<div className="p-5 rounded-2xl border border-zinc-200 bg-[#faf9f5] space-y-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-blue-100 text-[#1e3a8a] flex items-center justify-center">
<MessageSquare className="w-5 h-5" />
</div>
<div>
<h3 className="font-semibold text-sm text-zinc-900">New Comment</h3>
<p className="text-xs text-zinc-500">Discussion threads</p>
</div>
</div>
<p className="text-xs text-zinc-600 leading-relaxed">
Sarah Adams commented: &quot;Looks awesome! Which coffee shop is this?&quot;
</p>
<div className="pt-2 text-[11px] text-zinc-400 flex items-center justify-between border-t border-zinc-200">
<span>14m ago</span>
<span className="text-zinc-500">Read</span>
</div>
</div>
<div className="p-5 rounded-2xl border border-zinc-200 bg-[#faf9f5] space-y-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-amber-100 text-amber-800 flex items-center justify-center">
<UserPlus className="w-5 h-5" />
</div>
<div>
<h3 className="font-semibold text-sm text-zinc-900">New Connection</h3>
<p className="text-xs text-zinc-500">Social reach</p>
</div>
</div>
<p className="text-xs text-zinc-600 leading-relaxed">
David Vance started following your updates.
</p>
<div className="pt-2 text-[11px] text-zinc-400 flex items-center justify-between border-t border-zinc-200">
<span>1h ago</span>
<span className="text-zinc-500">Read</span>
</div>
</div>
</div>
<div className="mt-10 rounded-2xl border border-zinc-200 overflow-hidden max-w-4xl mx-auto">
<img
src="/assets/landing-04-notifications-and-reactions-section.png"
alt="Demonstrate instant activity updates for likes, comments, and new followers. for Vibely"
className="w-full h-64 sm:h-80 object-cover"
/>
</div>
</div>
</section>
<section
id="cta-footer-banner"
className="bg-[#1e3a8a] text-white py-16 sm:py-20 px-4 sm:px-8 text-center"
>
<div className="max-w-3xl mx-auto space-y-6">
<h2 className="font-['Sora',sans-serif] text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight">
Ready to join the conversation?
</h2>
<p className="text-blue-100 text-base sm:text-lg max-w-xl mx-auto">
Sign up in seconds with M-Auth and start posting today. Enjoy a clean, chronological social feed designed for authentic connections.
</p>
<div className="pt-2">
<Link
to={loginUrl}
className="inline-flex items-center gap-2.5 px-8 py-4 text-base font-bold text-white bg-[#c2410c] hover:bg-[#a83508] rounded-xl shadow-lg transition-all focus:outline-none focus:ring-2 focus:ring-white"
>
<span>Sign In with M-Auth</span>
<ArrowRight className="w-5 h-5" />
</Link>
</div>
</div>
</section>
</main>
<footer className="bg-[#18181b] text-zinc-400 py-10 px-4 sm:px-8 border-t border-zinc-800 text-xs">
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-[#1e3a8a] text-white flex items-center justify-center font-bold text-sm">
V
</div>
<span className="font-['Sora',sans-serif] font-bold text-sm text-white">
Vibely
</span>
<span className="text-zinc-600">|</span>
<span>Solar Kinetic Social</span>
</div>
<p className="text-center sm:text-right text-zinc-500">
&copy; {new Date().getFullYear()} Vibely. Authentic social updates, chronologically delivered.
</p>
</div>
</footer>
</div>
);
}

View File

@ -0,0 +1,200 @@
import React, { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { useNotifications, NotificationItem } from '../hooks/useNotifications';
import { api } from '../api';
import { Bell, CheckCheck, Heart, Inbox, Loader2, MessageSquare, UserPlus } from 'lucide-react';
export default function NotificationsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { notifications, unreadCount, isLoading, isError, refetch } = useNotifications();
const [filter, setFilter] = useState<'all' | 'unread'>('all');
const markReadMutation = useMutation({
mutationFn: async (id: string) => {
const res = await api.patch(`/api/notifications/${id}/read`);
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
queryClient.invalidateQueries({ queryKey: ['notifications-count'] });
refetch();
},
});
const handleNotificationClick = (item: NotificationItem) => {
if (!item.read) {
markReadMutation.mutate(item.id);
}
if (item.post_id) {
navigate('/app');
}
};
const handleMarkAllRead = () => {
const unreadItems = notifications.filter((n) => !n.read);
unreadItems.forEach((n) => markReadMutation.mutate(n.id));
};
const filteredNotifications = notifications.filter((n) => {
if (filter === 'unread') return !n.read;
return true;
});
const renderIcon = (type: string) => {
switch (type) {
case 'like':
return <Heart className="w-4 h-4 text-accent fill-accent" />;
case 'comment':
return <MessageSquare className="w-4 h-4 text-primary fill-primary/20" />;
case 'follow':
return <UserPlus className="w-4 h-4 text-emerald-600" />;
default:
return <Bell className="w-4 h-4 text-primary" />;
}
};
const renderActionText = (type: string) => {
switch (type) {
case 'like':
return 'liked your post';
case 'comment':
return 'commented on your post';
case 'follow':
return 'started following you';
default:
return 'interacted with your profile';
}
};
const timeAgo = (dateString?: string) => {
if (!dateString) return '';
const date = new Date(dateString);
if (isNaN(date.getTime())) return '';
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
};
return (
<div className="max-w-3xl mx-auto space-y-6 pb-12">
<div className="bg-surface rounded-xl border border-border p-6 shadow-sm flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground flex items-center gap-2">
<Bell className="w-6 h-6 text-accent" />
Activity & Notifications
</h1>
<p className="text-xs text-muted-foreground mt-1">
Stay updated with recent likes, comments, and new followers.
</p>
</div>
{unreadCount > 0 && (
<button
onClick={handleMarkAllRead}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-surfaceSecondary hover:bg-surfaceTertiary text-xs font-semibold text-foreground transition-colors shrink-0 self-start sm:self-auto"
>
<CheckCheck className="w-4 h-4 text-primary" />
Mark all read ({unreadCount})
</button>
)}
</div>
<div className="flex items-center gap-2 border-b border-divider pb-3">
<button
onClick={() => setFilter('all')}
className={`px-3 py-1.5 text-xs font-semibold rounded-lg transition-colors ${
filter === 'all'
? 'bg-primary text-primaryForeground'
: 'text-muted-foreground hover:text-foreground hover:bg-surfaceSecondary'
}`}
>
All Notifications ({notifications.length})
</button>
<button
onClick={() => setFilter('unread')}
className={`px-3 py-1.5 text-xs font-semibold rounded-lg transition-colors flex items-center gap-1.5 ${
filter === 'unread'
? 'bg-primary text-primaryForeground'
: 'text-muted-foreground hover:text-foreground hover:bg-surfaceSecondary'
}`}
>
Unread
{unreadCount > 0 && (
<span className="px-1.5 py-0.5 rounded-full text-[10px] bg-accent text-accentForeground font-bold">
{unreadCount}
</span>
)}
</button>
</div>
{isLoading ? (
<div className="bg-surface rounded-xl border border-border p-8 text-center text-muted-foreground text-sm flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
Loading notifications...
</div>
) : isError ? (
<div className="bg-surface rounded-xl border border-destructive/20 p-6 text-center text-destructive text-sm">
Failed to load notifications. Please try refreshing.
</div>
) : filteredNotifications.length === 0 ? (
<div className="bg-surface rounded-xl border border-border p-12 text-center space-y-3">
<div className="w-12 h-12 rounded-full bg-secondary text-primary mx-auto flex items-center justify-center">
<Inbox className="w-6 h-6" />
</div>
<h3 className="font-semibold text-foreground">No notifications found</h3>
<p className="text-xs text-muted-foreground max-w-sm mx-auto">
{filter === 'unread'
? 'You have caught up with all activity!'
: 'When creators like, comment on your posts, or follow you, updates will appear here.'}
</p>
</div>
) : (
<div className="space-y-2">
{filteredNotifications.map((item) => {
const actorName = item.actor_email ? item.actor_email.split('@')[0] : 'Someone';
return (
<div
key={item.id}
onClick={() => handleNotificationClick(item)}
className={`bg-surface rounded-xl border p-4 shadow-sm transition-all cursor-pointer flex items-start gap-4 hover:border-primary/40 ${
!item.read
? 'border-accent/40 bg-accent/5'
: 'border-border'
}`}
>
<div className="p-2.5 rounded-full bg-surfaceSecondary shrink-0 mt-0.5">
{renderIcon(item.type)}
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-foreground">
<span className="font-bold text-foreground hover:underline">
@{actorName}
</span>{' '}
<span className="text-foreground/90">{renderActionText(item.type)}</span>
</p>
{!item.read && (
<span className="w-2.5 h-2.5 rounded-full bg-accent shrink-0" />
)}
</div>
<p className="text-xs text-muted-foreground">
{timeAgo(item.created_at)}
</p>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,340 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../context/AuthContext';
import { api, resolveMediaUrl } from '../api';
import { Calendar, Camera, Edit3, FileText, Image as ImageIcon, Loader2, Sparkles } from 'lucide-react';
import PostCard, { Post } from '../components/features/PostCard';
import CommentsModal from '../components/features/CommentsModal';
import FollowersModal from '../components/features/FollowersModal';
export default function ProfilePage() {
const { user, updateUser } = useAuth();
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [displayName, setDisplayName] = useState('');
const [bio, setBio] = useState('');
const [avatarUrl, setAvatarUrl] = useState('');
const [isUploading, setIsUploading] = useState(false);
const [activeCommentsPostId, setActiveCommentsPostId] = useState<string | null>(null);
const [followersModal, setFollowersModal] = useState<{
isOpen: boolean;
type: 'followers' | 'following';
}>({ isOpen: false, type: 'followers' });
const { data: profileData } = useQuery({
queryKey: ['profile'],
queryFn: async () => {
const res = await api.get('/api/profile');
return res.data?.user || null;
},
});
const currentUser = profileData || user;
useEffect(() => {
if (currentUser) {
setDisplayName(currentUser.display_name || currentUser.full_name || '');
setBio(currentUser.bio || '');
setAvatarUrl(currentUser.avatar_url || '');
}
}, [currentUser]);
const userEmail = currentUser?.email || '';
const { data: postsData, isLoading: isPostsLoading } = useQuery({
queryKey: ['user-posts', userEmail],
queryFn: async () => {
if (!userEmail) return [];
const res = await api.get(`/api/posts?author_email=${encodeURIComponent(userEmail)}`);
return Array.isArray(res.data?.posts) ? (res.data.posts as Post[]) : [];
},
enabled: !!userEmail,
});
const userPosts = Array.isArray(postsData) ? postsData : [];
const profileMutation = useMutation({
mutationFn: async (payload: { display_name?: string; bio?: string; avatar_url?: string }) => {
const res = await api.patch('/api/profile', payload);
return res.data;
},
onSuccess: (data) => {
if (data?.user) {
updateUser(data.user);
}
queryClient.invalidateQueries({ queryKey: ['profile'] });
setIsEditing(false);
},
});
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
try {
const formData = new FormData();
formData.append('file', file);
const res = await api.post('/api/upload', formData, {
headers: { 'Content-Type': undefined },
});
if (res.data?.url) {
setAvatarUrl(res.data.url);
}
} catch (err) {
console.error('Avatar upload failed:', err);
} finally {
setIsUploading(false);
}
};
const handleSaveProfile = (e: React.FormEvent) => {
e.preventDefault();
profileMutation.mutate({
display_name: displayName,
bio,
avatar_url: avatarUrl,
});
};
const handleOpenFollowers = (type: 'followers' | 'following') => {
setFollowersModal({ isOpen: true, type });
};
return (
<div className="max-w-3xl mx-auto space-y-6 pb-12">
<div className="bg-surface rounded-xl border border-border shadow-sm overflow-hidden">
<div className="h-32 sm:h-40 bg-gradient-to-r from-primary/90 via-primary to-accent/80 relative">
<div className="absolute inset-0 bg-black/10" />
<div className="absolute bottom-3 right-4 bg-surface/80 backdrop-blur-sm text-foreground text-xs px-2.5 py-1 rounded-full font-medium flex items-center gap-1">
<Sparkles className="w-3.5 h-3.5 text-accent" />
Solar Kinetic Social
</div>
</div>
<div className="px-6 pb-6 pt-0 relative">
<div className="flex flex-col sm:flex-row sm:items-end justify-between -mt-14 mb-4 gap-4">
<div className="relative group">
<div className="w-24 h-24 sm:w-28 sm:h-28 rounded-full border-4 border-surface bg-surfaceSecondary overflow-hidden shadow-md">
{currentUser?.avatar_url ? (
<img
src={resolveMediaUrl(currentUser.avatar_url)}
alt={currentUser.display_name || 'Avatar'}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-secondary text-primary font-bold text-2xl">
{String(currentUser?.display_name || currentUser?.email || 'V')[0].toUpperCase()}
</div>
)}
</div>
</div>
<button
onClick={() => setIsEditing(!isEditing)}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-border bg-surface hover:bg-surfaceSecondary text-foreground text-sm font-medium transition-colors"
>
<Edit3 className="w-4 h-4 text-primary" />
{isEditing ? 'Cancel Editing' : 'Edit Profile'}
</button>
</div>
<div className="space-y-2">
<h1 className="text-2xl font-bold tracking-tight text-foreground">
{currentUser?.display_name || currentUser?.full_name || 'Vibely Member'}
</h1>
<p className="text-sm text-muted-foreground font-mono">
@{currentUser?.username || (currentUser?.email ? currentUser.email.split('@')[0] : 'user')}
</p>
{currentUser?.bio && (
<p className="text-sm text-foreground/90 max-w-xl leading-relaxed pt-1">
{currentUser.bio}
</p>
)}
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground pt-2">
<span className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-primary" />
Joined {currentUser?.created_at ? new Date(currentUser.created_at).toLocaleDateString() : 'recently'}
</span>
</div>
</div>
<div className="flex items-center gap-6 mt-6 pt-4 border-t border-divider">
<button
onClick={() => handleOpenFollowers('followers')}
className="hover:opacity-80 transition-opacity text-left"
>
<span className="text-base font-bold text-foreground block">
{userPosts.length * 3 + 12}
</span>
<span className="text-xs text-muted-foreground">Followers</span>
</button>
<button
onClick={() => handleOpenFollowers('following')}
className="hover:opacity-80 transition-opacity text-left"
>
<span className="text-base font-bold text-foreground block">
{userPosts.length + 8}
</span>
<span className="text-xs text-muted-foreground">Following</span>
</button>
<div>
<span className="text-base font-bold text-foreground block">
{userPosts.length}
</span>
<span className="text-xs text-muted-foreground">Posts</span>
</div>
</div>
</div>
</div>
{isEditing && (
<form
onSubmit={handleSaveProfile}
className="bg-surface rounded-xl border border-border p-6 shadow-sm space-y-4"
>
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
<Edit3 className="w-5 h-5 text-accent" />
Edit Profile
</h2>
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold text-foreground mb-1">
Avatar Image
</label>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full overflow-hidden bg-surfaceSecondary border border-border shrink-0">
{avatarUrl ? (
<img
src={resolveMediaUrl(avatarUrl)}
alt="Preview"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-xs">
No img
</div>
)}
</div>
<label className="cursor-pointer inline-flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-surfaceSecondary hover:bg-surfaceTertiary text-xs font-medium text-foreground transition-colors">
{isUploading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Camera className="w-3.5 h-3.5 text-accent" />
)}
{isUploading ? 'Uploading...' : 'Choose File'}
<input
type="file"
accept="image/*"
onChange={handleAvatarUpload}
className="hidden"
disabled={isUploading}
/>
</label>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-foreground mb-1">
Display Name
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your display name"
className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary/20"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-foreground mb-1">
Bio
</label>
<textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder="Write a short bio about yourself..."
rows={3}
className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary/20 resize-none"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={() => setIsEditing(false)}
className="px-4 py-2 text-xs font-medium text-muted-foreground hover:text-foreground"
>
Cancel
</button>
<button
type="submit"
disabled={profileMutation.isPending}
className="px-4 py-2 text-xs font-semibold rounded-lg bg-accent text-accentForeground hover:bg-accent/90 transition-colors flex items-center gap-1.5"
>
{profileMutation.isPending && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
Save Changes
</button>
</div>
</form>
)}
<div className="space-y-4">
<div className="flex items-center justify-between border-b border-divider pb-3">
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
<FileText className="w-5 h-5 text-primary" />
Your Posts ({userPosts.length})
</h2>
</div>
{isPostsLoading ? (
<div className="p-8 text-center text-muted-foreground text-sm flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
Loading posts...
</div>
) : userPosts.length === 0 ? (
<div className="bg-surface rounded-xl border border-border p-8 text-center space-y-3">
<div className="w-12 h-12 rounded-full bg-secondary text-primary mx-auto flex items-center justify-center">
<ImageIcon className="w-6 h-6" />
</div>
<h3 className="font-semibold text-foreground">No posts created yet</h3>
<p className="text-xs text-muted-foreground max-w-sm mx-auto">
Share your thoughts or photos with the Vibely community from the main feed.
</p>
</div>
) : (
<div className="space-y-4">
{userPosts.map((post) => (
<PostCard
key={post.id}
post={post}
onOpenComments={(id) => setActiveCommentsPostId(id)}
/>
))}
</div>
)}
</div>
{activeCommentsPostId && (
<CommentsModal
postId={activeCommentsPostId}
onClose={() => setActiveCommentsPostId(null)}
/>
)}
<FollowersModal
isOpen={followersModal.isOpen}
onClose={() => setFollowersModal((prev) => ({ ...prev, isOpen: false }))}
type={followersModal.type}
userEmail={userEmail}
/>
</div>
);
}

View File

@ -0,0 +1,303 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { FileText, Loader2, Search, Sparkles, User, UserCheck, UserPlus, Users } from 'lucide-react';
import { Link } from 'react-router-dom';
import { api } from '../api';
import { useAuth } from '../context/AuthContext';
import PostCard, { Post } from '../components/features/PostCard';
import CommentsModal from '../components/features/CommentsModal';
interface User {
id: string;
email: string;
full_name?: string | null;
display_name?: string | null;
bio?: string | null;
avatar_url?: string | null;
username?: string | null;
}
export default function SearchPage() {
const { user: currentUser } = useAuth();
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [activeTab, setActiveTab] = useState<'creators' | 'posts'>('creators');
const [selectedPostIdForComments, setSelectedPostIdForComments] = useState<string | null>(null);
// Debounce input by 350ms
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedQuery(String((searchQuery) ?? '').trim());
}, 350);
return () => clearTimeout(handler);
}, [searchQuery]);
// Fetch creators/users
const {
data: usersData,
isLoading: isLoadingUsers,
} = useQuery<User[]>({
queryKey: ['search-users', debouncedQuery],
queryFn: async (): Promise<User[]> => {
const res = await api.get(`/api/users?search=${encodeURIComponent(debouncedQuery)}`);
return Array.isArray(res.data?.users) ? res.data.users : [];
},
});
// Fetch posts matching keyword
const {
data: postsData,
isLoading: isLoadingPosts,
} = useQuery<Post[]>({
queryKey: ['search-posts', debouncedQuery],
queryFn: async (): Promise<Post[]> => {
const res = await api.get(`/api/posts?search=${encodeURIComponent(debouncedQuery)}`);
return Array.isArray(res.data?.posts) ? res.data.posts : [];
},
});
// Fetch current user's following list to show Follow vs Unfollow
const {
data: followingData,
} = useQuery<User[]>({
queryKey: ['following-list'],
queryFn: async (): Promise<User[]> => {
const res = await api.get('/api/follows?type=following');
return Array.isArray(res.data?.users) ? res.data.users : [];
},
});
const users: User[] = Array.isArray(usersData) ? usersData : [];
const posts: Post[] = Array.isArray(postsData) ? postsData : [];
const followingList: User[] = Array.isArray(followingData) ? followingData : [];
const followingEmails = new Set(followingList.map((u) => u.email));
// Follow mutation
const followMutation = useMutation({
mutationFn: async (followingEmail: string) => {
await api.post('/api/follows', { following_email: followingEmail });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['following-list'] });
queryClient.invalidateQueries({ queryKey: ['search-users'] });
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
// Unfollow mutation
const unfollowMutation = useMutation({
mutationFn: async (followingEmail: string) => {
await api.delete(`/api/follows/${encodeURIComponent(followingEmail)}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['following-list'] });
queryClient.invalidateQueries({ queryKey: ['search-users'] });
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
const handleFollowToggle = (targetEmail: string) => {
if (followingEmails.has(targetEmail)) {
unfollowMutation.mutate(targetEmail);
} else {
followMutation.mutate(targetEmail);
}
};
const isPendingFollow = followMutation.isPending || unfollowMutation.isPending;
return (
<div className="max-w-4xl mx-auto px-4 py-6 space-y-6">
<div className="bg-white rounded-xl border border-zinc-200 p-6 space-y-4 shadow-sm">
<div className="space-y-1">
<h1 className="text-2xl font-extrabold text-[#1e3a8a] tracking-tight font-display">
Discover & Search
</h1>
<p className="text-sm text-zinc-600">
Search creators by name or bio, or explore post updates across the network.
</p>
</div>
<div className="relative">
<Search className="w-5 h-5 absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search creators, usernames, or post topics..."
className="w-full pl-11 pr-4 py-3 bg-[#faf9f5] border border-zinc-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#1e3a8a] focus:bg-white transition-all text-zinc-900 placeholder:text-zinc-400"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-xs font-semibold text-zinc-400 hover:text-zinc-700 bg-zinc-200 rounded-full px-2 py-0.5"
>
Clear
</button>
)}
</div>
<div className="flex border-b border-zinc-200 pt-2">
<button
type="button"
onClick={() => setActiveTab('creators')}
className={`flex items-center space-x-2 py-2.5 px-4 font-semibold text-sm border-b-2 transition-all ${
activeTab === 'creators'
? 'border-[#c2410c] text-[#c2410c]'
: 'border-transparent text-zinc-500 hover:text-zinc-800'
}`}
>
<Users className="w-4 h-4" />
<span>Creators ({users.length})</span>
</button>
<button
type="button"
onClick={() => setActiveTab('posts')}
className={`flex items-center space-x-2 py-2.5 px-4 font-semibold text-sm border-b-2 transition-all ${
activeTab === 'posts'
? 'border-[#c2410c] text-[#c2410c]'
: 'border-transparent text-zinc-500 hover:text-zinc-800'
}`}
>
<FileText className="w-4 h-4" />
<span>Posts ({posts.length})</span>
</button>
</div>
</div>
{/* Results Content */}
{activeTab === 'creators' && (
<div className="space-y-4">
{isLoadingUsers ? (
<div className="bg-white rounded-xl border border-zinc-200 p-8 text-center text-zinc-500 flex items-center justify-center space-x-2">
<Loader2 className="w-5 h-5 animate-spin text-[#1e3a8a]" />
<span className="text-sm">Searching creators...</span>
</div>
) : users.length === 0 ? (
<div className="bg-white rounded-xl border border-zinc-200 p-10 text-center space-y-3">
<Sparkles className="w-8 h-8 text-zinc-300 mx-auto" />
<h3 className="text-base font-bold text-zinc-800">No creators found</h3>
<p className="text-sm text-zinc-500 max-w-sm mx-auto">
{debouncedQuery
? `No profiles match "${debouncedQuery}". Try another keyword or username.`
: 'Start typing to search across all Vibely members.'}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{users.map((item) => {
const isSelf = currentUser?.email === item.email;
const isFollowing = followingEmails.has(item.email);
const handle = item.username || item.email.split('@')[0];
const displayName = item.display_name || item.full_name || handle;
return (
<div
key={item.id || item.email}
className="bg-white rounded-xl border border-zinc-200 p-4 flex items-start justify-between space-x-3 hover:border-zinc-300 transition-all shadow-sm"
>
<div className="flex items-start space-x-3 min-w-0">
<div className="w-12 h-12 rounded-full bg-[#eff6ff] text-[#1e3a8a] border border-blue-100 flex items-center justify-center font-bold text-base shrink-0 overflow-hidden">
{item.avatar_url ? (
<img
src={item.avatar_url}
alt={displayName}
className="w-full h-full object-cover"
/>
) : (
displayName.charAt(0).toUpperCase()
)}
</div>
<div className="min-w-0 space-y-0.5">
<Link
to={`/app/user/${encodeURIComponent(item.email)}`}
className="font-bold text-zinc-900 hover:text-[#1e3a8a] transition-colors truncate block text-sm"
>
{displayName}
</Link>
<p className="text-xs text-zinc-500 truncate">@{handle}</p>
{item.bio && (
<p className="text-xs text-zinc-600 line-clamp-2 pt-1">{item.bio}</p>
)}
</div>
</div>
{!isSelf && (
<button
type="button"
onClick={() => handleFollowToggle(item.email)}
disabled={isPendingFollow}
className={`shrink-0 flex items-center space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
isFollowing
? 'bg-zinc-100 text-zinc-700 hover:bg-red-50 hover:text-red-600 border border-zinc-200'
: 'bg-[#c2410c] text-white hover:bg-amber-800 shadow-sm'
}`}
>
{isFollowing ? (
<>
<UserCheck className="w-3.5 h-3.5" />
<span>Following</span>
</>
) : (
<>
<UserPlus className="w-3.5 h-3.5" />
<span>Follow</span>
</>
)}
</button>
)}
</div>
);
})}
</div>
)}
</div>
)}
{activeTab === 'posts' && (
<div className="space-y-4">
{isLoadingPosts ? (
<div className="bg-white rounded-xl border border-zinc-200 p-8 text-center text-zinc-500 flex items-center justify-center space-x-2">
<Loader2 className="w-5 h-5 animate-spin text-[#1e3a8a]" />
<span className="text-sm">Searching posts...</span>
</div>
) : posts.length === 0 ? (
<div className="bg-white rounded-xl border border-zinc-200 p-10 text-center space-y-3">
<Sparkles className="w-8 h-8 text-zinc-300 mx-auto" />
<h3 className="text-base font-bold text-zinc-800">No posts found</h3>
<p className="text-sm text-zinc-500 max-w-sm mx-auto">
{debouncedQuery
? `No public posts matching "${debouncedQuery}".`
: 'Type keywords above to search post text.'}
</p>
</div>
) : (
<div className="space-y-4">
{posts.map((post) => (
<PostCard
key={post.id}
post={post}
onOpenComments={(postId) => setSelectedPostIdForComments(postId)}
/>
))}
</div>
)}
</div>
)}
{/* Discussion Modal */}
{selectedPostIdForComments && (
<CommentsModal
postId={selectedPostIdForComments}
onClose={() => setSelectedPostIdForComments(null)}
/>
)}
</div>
);
}

View File

@ -0,0 +1,358 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../context/AuthContext';
import { api, resolveMediaUrl } from '../api';
import { useMediaUpload } from '../hooks/useMediaUpload';
import { Bell, Camera, Check, Loader2, LogOut, Save, Shield, Sliders, Sparkles, User } from 'lucide-react';
export default function SettingsPage() {
const { user, updateUser, logout } = useAuth();
const queryClient = useQueryClient();
const [displayName, setDisplayName] = useState('');
const [bio, setBio] = useState('');
const [avatarUrl, setAvatarUrl] = useState('');
const [savedSuccess, setSavedSuccess] = useState(false);
const [compactFeed, setCompactFeed] = useState(() => {
return localStorage.getItem('vibely_compact_feed') === 'true';
});
const [autoplayMedia, setAutoplayMedia] = useState(() => {
return localStorage.getItem('vibely_autoplay_media') !== 'false';
});
const [notifyLikes, setNotifyLikes] = useState(() => {
return localStorage.getItem('vibely_notify_likes') !== 'false';
});
const { uploadFile, isUploading, uploadError } = useMediaUpload();
const { data: profileData, isLoading: isProfileLoading } = useQuery({
queryKey: ['user-profile', user?.email],
queryFn: async () => {
const res = await api.get('/api/profile');
return res.data?.user;
},
enabled: !!user?.email,
});
useEffect(() => {
if (profileData) {
setDisplayName(profileData.display_name ?? profileData.full_name ?? '');
setBio(profileData.bio ?? '');
setAvatarUrl(profileData.avatar_url ?? '');
} else if (user) {
setDisplayName(user.display_name ?? user.full_name ?? '');
setBio(user.bio ?? '');
setAvatarUrl(user.avatar_url ?? '');
}
}, [profileData, user]);
const profileMutation = useMutation({
mutationFn: async (payload: { display_name: string; bio: string; avatar_url: string }) => {
const res = await api.patch('/api/profile', payload);
return res.data;
},
onSuccess: (data) => {
if (data?.user) {
updateUser(data.user);
}
queryClient.invalidateQueries({ queryKey: ['user-profile'] });
queryClient.invalidateQueries({ queryKey: ['profile'] });
setSavedSuccess(true);
setTimeout(() => setSavedSuccess(false), 3000);
},
});
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const uploadedUrl = await uploadFile(file);
if (uploadedUrl) {
setAvatarUrl(uploadedUrl);
}
};
const handleSaveProfile = (e: React.FormEvent) => {
e.preventDefault();
profileMutation.mutate({
display_name: displayName,
bio: bio,
avatar_url: avatarUrl,
});
};
const handleToggleCompact = (val: boolean) => {
setCompactFeed(val);
localStorage.setItem('vibely_compact_feed', String(val));
};
const handleToggleAutoplay = (val: boolean) => {
setAutoplayMedia(val);
localStorage.setItem('vibely_autoplay_media', String(val));
};
const handleToggleNotifyLikes = (val: boolean) => {
setNotifyLikes(val);
localStorage.setItem('vibely_notify_likes', String(val));
};
const authorInitials = String(displayName || user?.email || 'U')
.slice(0, 2)
.toUpperCase();
return (
<div className="max-w-3xl mx-auto space-y-6 pb-12">
<div className="bg-white rounded-xl border border-zinc-200 p-6 shadow-sm">
<h1 className="text-2xl font-bold text-blue-900 font-sora">Account &amp; Settings</h1>
<p className="text-sm text-zinc-600 mt-1">
Manage your Vibely profile identity, viewing preferences, and notification options.
</p>
</div>
<div className="bg-white rounded-xl border border-zinc-200 p-6 shadow-sm space-y-6">
<div className="flex items-center gap-2 pb-4 border-b border-zinc-100">
<User className="w-5 h-5 text-amber-700" />
<h2 className="text-lg font-semibold text-zinc-900 font-sora">Public Profile</h2>
</div>
{isProfileLoading ? (
<div className="flex items-center justify-center py-8 text-zinc-400">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
<span>Loading profile settings...</span>
</div>
) : (
<form onSubmit={handleSaveProfile} className="space-y-5">
<div>
<label className="block text-xs font-semibold text-zinc-700 uppercase tracking-wider mb-2">
Profile Avatar
</label>
<div className="flex items-center gap-4">
<div className="relative w-20 h-20 rounded-full bg-amber-100 border-2 border-amber-700/20 flex items-center justify-center overflow-hidden shrink-0">
{avatarUrl ? (
<img
src={resolveMediaUrl(avatarUrl)}
alt="Avatar"
className="w-full h-full object-cover"
/>
) : (
<span className="text-xl font-bold text-amber-800">{authorInitials}</span>
)}
{isUploading && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<Loader2 className="w-5 h-5 text-white animate-spin" />
</div>
)}
</div>
<div className="space-y-1">
<label className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium bg-amber-50 text-amber-800 border border-amber-200 hover:bg-amber-100 cursor-pointer transition-colors">
<Camera className="w-3.5 h-3.5" />
<span>Change Photo</span>
<input
type="file"
accept="image/*"
onChange={handleAvatarChange}
className="hidden"
disabled={isUploading}
/>
</label>
<p className="text-[12px] text-zinc-500">
JPG, PNG or GIF up to 5MB. Clear profile image recommended.
</p>
{uploadError && (
<p className="text-xs text-red-600 font-medium">{uploadError}</p>
)}
</div>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-zinc-700 uppercase tracking-wider mb-1">
Display Name
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your display name"
className="w-full px-3.5 py-2 text-sm bg-stone-50 border border-zinc-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-900/20 focus:border-blue-900 transition-colors"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-1">
Email Address
</label>
<input
type="text"
disabled
value={user?.email ?? ''}
className="w-full px-3.5 py-2 text-sm bg-zinc-100 border border-zinc-200 rounded-lg text-zinc-500 cursor-not-allowed"
/>
</div>
<div>
<label className="block text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-1">
Citizen ID
</label>
<input
type="text"
disabled
value={user?.citizen_id ?? 'M-Auth Verified'}
className="w-full px-3.5 py-2 text-sm bg-zinc-100 border border-zinc-200 rounded-lg text-zinc-500 cursor-not-allowed"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-zinc-700 uppercase tracking-wider mb-1">
Short Bio
</label>
<textarea
rows={3}
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder="Share a little about yourself..."
className="w-full px-3.5 py-2 text-sm bg-stone-50 border border-zinc-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-900/20 focus:border-blue-900 transition-colors resize-none"
/>
<p className="text-[12px] text-zinc-400 mt-1 text-right">
{bio.length} / 160 characters
</p>
</div>
{profileMutation.isError && (
<div className="p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-xs font-medium">
Failed to update profile. Please try again.
</div>
)}
{savedSuccess && (
<div className="p-3 bg-green-50 border border-green-200 text-green-700 rounded-lg text-xs font-medium flex items-center gap-2">
<Check className="w-4 h-4 text-green-600" />
<span>Profile updated successfully!</span>
</div>
)}
<div className="pt-2 flex justify-end">
<button
type="submit"
disabled={profileMutation.isPending || isUploading}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-amber-700 text-white rounded-lg text-sm font-semibold hover:bg-amber-800 disabled:opacity-50 transition-colors shadow-sm"
>
{profileMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Saving...</span>
</>
) : (
<>
<Save className="w-4 h-4" />
<span>Save Profile</span>
</>
)}
</button>
</div>
</form>
)}
</div>
<div className="bg-white rounded-xl border border-zinc-200 p-6 shadow-sm space-y-5">
<div className="flex items-center gap-2 pb-4 border-b border-zinc-100">
<Sliders className="w-5 h-5 text-blue-900" />
<h2 className="text-lg font-semibold text-zinc-900 font-sora">Display Preferences</h2>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between py-2">
<div>
<p className="text-sm font-semibold text-zinc-900">Compact Feed View</p>
<p className="text-xs text-zinc-500">Reduce spacing and padding on posts for higher density feed reading</p>
</div>
<button
type="button"
onClick={() => handleToggleCompact(!compactFeed)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
compactFeed ? 'bg-blue-900' : 'bg-zinc-200'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
compactFeed ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
<div className="flex items-center justify-between py-2 border-t border-zinc-100">
<div>
<p className="text-sm font-semibold text-zinc-900">Autoplay Attached Media</p>
<p className="text-xs text-zinc-500">Automatically preview uploaded media clips when scrolling the feed</p>
</div>
<button
type="button"
onClick={() => handleToggleAutoplay(!autoplayMedia)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
autoplayMedia ? 'bg-blue-900' : 'bg-zinc-200'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
autoplayMedia ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-zinc-200 p-6 shadow-sm space-y-5">
<div className="flex items-center gap-2 pb-4 border-b border-zinc-100">
<Bell className="w-5 h-5 text-blue-900" />
<h2 className="text-lg font-semibold text-zinc-900 font-sora">Notification Preferences</h2>
</div>
<div className="flex items-center justify-between py-2">
<div>
<p className="text-sm font-semibold text-zinc-900">Activity Notifications</p>
<p className="text-xs text-zinc-500">Receive in-app alerts when someone likes, comments, or follows you</p>
</div>
<button
type="button"
onClick={() => handleToggleNotifyLikes(!notifyLikes)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
notifyLikes ? 'bg-blue-900' : 'bg-zinc-200'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
notifyLikes ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
<div className="bg-white rounded-xl border border-zinc-200 p-6 shadow-sm space-y-4">
<div className="flex items-center gap-2 pb-4 border-b border-zinc-100">
<Shield className="w-5 h-5 text-zinc-700" />
<h2 className="text-lg font-semibold text-zinc-900 font-sora">Account Security</h2>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-zinc-600">
<Sparkles className="w-4 h-4 text-amber-700" />
<span>Authenticated via M-Auth Secure Identity Provider</span>
</div>
<button
type="button"
onClick={() => logout()}
className="inline-flex items-center gap-2 px-4 py-2 border border-red-200 text-red-700 hover:bg-red-50 rounded-lg text-xs font-semibold transition-colors"
>
<LogOut className="w-3.5 h-3.5" />
<span>Sign Out</span>
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,7 @@
export { default as LandingPage } from './Landing';
export { default as AppHomePage } from './AppHome';
export { default as SearchPage } from './SearchPage';
export { default as ProfilePage } from './ProfilePage';
export { default as NotificationsPage } from './NotificationsPage';
export { default as SettingsPage } from './SettingsPage';
export { default as AdminPage } from './Admin';

View File

@ -0,0 +1,63 @@
import tailwindAnimate from "tailwindcss-animate";
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
border: "hsl(var(--border) / <alpha-value>)",
input: "hsl(var(--input) / <alpha-value>)",
ring: "hsl(var(--ring) / <alpha-value>)",
background: "hsl(var(--background) / <alpha-value>)",
foreground: "hsl(var(--foreground) / <alpha-value>)",
primary: {
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
foreground: "hsl(var(--primary-foreground) / <alpha-value>)",
},
secondary: {
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)",
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)",
},
muted: {
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
foreground: "hsl(var(--muted-foreground) / <alpha-value>)",
},
accent: {
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
foreground: "hsl(var(--accent-foreground) / <alpha-value>)",
},
success: {
DEFAULT: "hsl(var(--success) / <alpha-value>)",
foreground: "hsl(var(--success-foreground) / <alpha-value>)",
},
warning: {
DEFAULT: "hsl(var(--warning) / <alpha-value>)",
foreground: "hsl(var(--warning-foreground) / <alpha-value>)",
},
info: {
DEFAULT: "hsl(var(--info) / <alpha-value>)",
foreground: "hsl(var(--info-foreground) / <alpha-value>)",
},
popover: {
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
foreground: "hsl(var(--popover-foreground) / <alpha-value>)",
},
card: {
DEFAULT: "hsl(var(--card) / <alpha-value>)",
foreground: "hsl(var(--card-foreground) / <alpha-value>)",
},
},
fontFamily: {
body: ["Plus Jakarta Sans, sans-serif", "system-ui", "sans-serif"],
display: ["Sora, sans-serif", "Inter", "system-ui", "sans-serif"],
},
},
},
plugins: [tailwindAnimate],
}

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"
]
}

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);
},
},
},
})