feat: add frontend in /frontend
2659
dist-worker/unified-worker.js
Normal file
4
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.wrangler
|
||||
.env
|
||||
26
frontend/components.json
Normal 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
@ -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
|
||||
93
frontend/design/asset-manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
1217
frontend/design/design-contract.json
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>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
45
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
frontend/postcss.config.js
Normal file
@ -0,0 +1 @@
|
||||
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }
|
||||
BIN
frontend/public/assets/admin-01-admin-auth-header.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/public/assets/landing-01-hero-section.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
frontend/public/assets/landing-02-feed-philosophy-section.png
Normal file
|
After Width: | Height: | Size: 953 KiB |
BIN
frontend/public/assets/landing-03-creator-discovery-section.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 20 KiB |
53
frontend/src/App.tsx
Normal 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
@ -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;
|
||||
117
frontend/src/components/AdminTokenGate.tsx
Normal 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>;
|
||||
}
|
||||
177
frontend/src/components/AppLayout.tsx
Normal 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;
|
||||
151
frontend/src/components/features/CommentsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
frontend/src/components/features/FollowersModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
frontend/src/components/features/PostCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
frontend/src/components/features/PostComposer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
frontend/src/components/ui/alert.tsx
Normal 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;
|
||||
5
frontend/src/components/ui/badge.tsx
Normal 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;
|
||||
6
frontend/src/components/ui/button.tsx
Normal 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;
|
||||
10
frontend/src/components/ui/card.tsx
Normal 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;
|
||||
9
frontend/src/components/ui/dialog.tsx
Normal 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;
|
||||
6
frontend/src/components/ui/dropdown-menu.tsx
Normal 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} />;
|
||||
4
frontend/src/components/ui/empty.tsx
Normal 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;
|
||||
7
frontend/src/components/ui/field.tsx
Normal 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;
|
||||
5
frontend/src/components/ui/input.tsx
Normal 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;
|
||||
4
frontend/src/components/ui/label.tsx
Normal 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;
|
||||
4
frontend/src/components/ui/separator.tsx
Normal 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;
|
||||
3
frontend/src/components/ui/sheet.tsx
Normal 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';
|
||||
5
frontend/src/components/ui/switch.tsx
Normal 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;
|
||||
10
frontend/src/components/ui/table.tsx
Normal 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;
|
||||
8
frontend/src/components/ui/tabs.tsx
Normal 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;
|
||||
5
frontend/src/components/ui/textarea.tsx
Normal 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;
|
||||
5
frontend/src/components/ui/tooltip.tsx
Normal 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} />;
|
||||
154
frontend/src/context/AuthContext.tsx
Normal 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;
|
||||
};
|
||||
48
frontend/src/hooks/useMediaUpload.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
68
frontend/src/hooks/useNotifications.ts
Normal 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
@ -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
@ -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
@ -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
@ -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>,
|
||||
)
|
||||
440
frontend/src/pages/Admin.tsx
Normal 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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
205
frontend/src/pages/AppHome.tsx
Normal 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
467
frontend/src/pages/Landing.tsx
Normal 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 • 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 & 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 & Circle
|
||||
</span>
|
||||
|
||||
<h2 className="font-['Sora',sans-serif] text-3xl sm:text-4xl font-bold text-[#1e3a8a] tracking-tight">
|
||||
Find & 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 "Morning coffee in downtown".
|
||||
</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: "Looks awesome! Which coffee shop is this?"
|
||||
</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">
|
||||
© {new Date().getFullYear()} Vibely. Authentic social updates, chronologically delivered.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
frontend/src/pages/NotificationsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
340
frontend/src/pages/ProfilePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
303
frontend/src/pages/SearchPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
358
frontend/src/pages/SettingsPage.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
7
frontend/src/pages/index.ts
Normal 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';
|
||||
63
frontend/tailwind.config.js
Normal 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
@ -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
@ -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);
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||