forked from sagnik/Project_Velocity
fix: Applied fix for name, the oracle team sharing, sentinel client list visibility
This commit is contained in:
4
app/dist/index.html
vendored
4
app/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Velocity WebOS</title>
|
||||
<script type="module" crossorigin src="./assets/index-CV1YNwsn.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BDvIhi37.css">
|
||||
<script type="module" crossorigin src="./assets/index-Bj2Xa_13.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-W2SBxMnB.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
getVelocityToken,
|
||||
isAdminRole,
|
||||
normalizeVelocityRole,
|
||||
type VelocityUserProfile,
|
||||
resolveVelocityFirstName,
|
||||
} from '@/lib/velocityPlatformClient';
|
||||
|
||||
import {
|
||||
@@ -52,8 +52,8 @@ export const MODULE_ROUTES: Array<{
|
||||
{ id: 'sentinel', path: '/sentinel', title: 'The Sentinel', component: Sentinel },
|
||||
{ id: 'inventory', path: '/inventory', title: 'Inventory', component: Inventory },
|
||||
{ id: 'catalyst', path: '/catalyst', title: 'The Catalyst', component: Catalyst },
|
||||
{ id: 'settings', path: '/settings', title: 'Settings', component: Settings },
|
||||
{ id: 'crm', path: '/crm', title: 'CRM', component: CRM },
|
||||
{ id: 'settings', path: '/settings', title: 'Settings', component: Settings },
|
||||
{ id: 'admin', path: '/admin', title: 'Admin', component: AdminPage, adminOnly: true },
|
||||
];
|
||||
|
||||
@@ -98,7 +98,7 @@ function MainLayout() {
|
||||
const currentRoute = availableRoutes.find((r) => r.path === location.pathname);
|
||||
const pageTitle = currentRoute?.title ?? 'Velocity';
|
||||
const roleLabel = formatRoleLabel(user?.role);
|
||||
const userLabel = user?.name?.trim() || user?.id || 'Authenticated User';
|
||||
const userLabel = user?.name?.trim() || user?.fullName?.trim() || user?.email?.trim() || user?.id || 'Authenticated User';
|
||||
const initials = userLabel
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
@@ -187,7 +187,13 @@ function MainLayout() {
|
||||
<span>Settings</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-white/10" />
|
||||
<DropdownMenuItem className="text-red-400 focus:text-red-400 focus:bg-red-500/10 cursor-pointer" onClick={() => logout()}>
|
||||
<DropdownMenuItem
|
||||
className="text-red-400 focus:text-red-400 focus:bg-red-500/10 cursor-pointer"
|
||||
onClick={() => {
|
||||
clearVelocityToken();
|
||||
logout();
|
||||
}}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
</DropdownMenuItem>
|
||||
@@ -237,7 +243,7 @@ function MainLayout() {
|
||||
// ── Root App ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated, login, logout } = useStore();
|
||||
const { isAuthenticated, login, logout, user } = useStore();
|
||||
const [authBootstrapped, setAuthBootstrapped] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -257,9 +263,18 @@ function App() {
|
||||
void getVelocityMe()
|
||||
.then((me) => {
|
||||
if (cancelled) return;
|
||||
const resolvedEmail = me.email?.trim() || user?.email?.trim() || undefined;
|
||||
const resolvedFullName = me.full_name?.trim() || user?.fullName?.trim() || undefined;
|
||||
login({
|
||||
id: me.user_id,
|
||||
name: resolveVelocityDisplayName(me),
|
||||
name: resolveVelocityFirstName({
|
||||
...me,
|
||||
email: resolvedEmail ?? null,
|
||||
full_name: resolvedFullName ?? null,
|
||||
}),
|
||||
fullName: resolvedFullName,
|
||||
email: resolvedEmail,
|
||||
avatar: me.avatar_url?.trim() || user?.avatar?.trim() || undefined,
|
||||
role: normalizeVelocityRole(me.role),
|
||||
});
|
||||
setAuthBootstrapped(true);
|
||||
@@ -286,9 +301,18 @@ function App() {
|
||||
void getVelocityMe()
|
||||
.then((me) => {
|
||||
if (cancelled) return;
|
||||
const resolvedEmail = me.email?.trim() || user?.email?.trim() || undefined;
|
||||
const resolvedFullName = me.full_name?.trim() || user?.fullName?.trim() || undefined;
|
||||
login({
|
||||
id: me.user_id,
|
||||
name: resolveVelocityDisplayName(me),
|
||||
name: resolveVelocityFirstName({
|
||||
...me,
|
||||
email: resolvedEmail ?? null,
|
||||
full_name: resolvedFullName ?? null,
|
||||
}),
|
||||
fullName: resolvedFullName,
|
||||
email: resolvedEmail,
|
||||
avatar: me.avatar_url?.trim() || user?.avatar?.trim() || undefined,
|
||||
role: normalizeVelocityRole(me.role),
|
||||
});
|
||||
})
|
||||
@@ -301,7 +325,7 @@ function App() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [authBootstrapped, isAuthenticated, login, logout]);
|
||||
}, [authBootstrapped, isAuthenticated, login, logout, user]);
|
||||
|
||||
if (!authBootstrapped) {
|
||||
return (
|
||||
@@ -363,17 +387,3 @@ function formatRoleLabel(role: string | undefined) {
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function resolveVelocityDisplayName(profile: VelocityUserProfile) {
|
||||
const fullName = profile.full_name?.trim();
|
||||
if (fullName) {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
const email = profile.email?.trim();
|
||||
if (email) {
|
||||
return email;
|
||||
}
|
||||
|
||||
return profile.user_id;
|
||||
}
|
||||
|
||||
@@ -135,10 +135,10 @@ export default function OraclePage() {
|
||||
|
||||
// ── Share handler ───────────────────────────────────────────────────────────
|
||||
|
||||
const handleShare = useCallback(async (params: { recipientEmail: string; visibility: 'private' | 'team'; message: string; sourceRevision: number }) => {
|
||||
const handleShare = useCallback(async (params: { recipientUserId: string; visibility: 'private' | 'team'; message: string; sourceRevision: number }) => {
|
||||
if (!page) return;
|
||||
await createFork(page.pageId, {
|
||||
recipientUserId: params.recipientEmail,
|
||||
recipientUserId: params.recipientUserId,
|
||||
sourceRevision: params.sourceRevision,
|
||||
visibility: params.visibility,
|
||||
message: params.message,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
clearVelocityToken,
|
||||
loginVelocity,
|
||||
normalizeVelocityRole,
|
||||
type VelocityUserProfile,
|
||||
resolveVelocityFirstName,
|
||||
} from '@/lib/velocityPlatformClient';
|
||||
|
||||
export function LoginScreen() {
|
||||
@@ -22,9 +22,18 @@ export function LoginScreen() {
|
||||
setError('');
|
||||
try {
|
||||
const me = await loginVelocity(email.trim(), password);
|
||||
const fallbackEmail = me.email?.trim() || email.trim();
|
||||
const fallbackFullName = me.full_name?.trim() || undefined;
|
||||
login({
|
||||
id: me.user_id,
|
||||
name: resolveVelocityDisplayName(me),
|
||||
name: resolveVelocityFirstName({
|
||||
...me,
|
||||
email: fallbackEmail || null,
|
||||
full_name: fallbackFullName || null,
|
||||
}),
|
||||
fullName: fallbackFullName,
|
||||
email: fallbackEmail || undefined,
|
||||
avatar: me.avatar_url?.trim() || undefined,
|
||||
role: normalizeVelocityRole(me.role),
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -172,17 +181,3 @@ export function LoginScreen() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveVelocityDisplayName(profile: VelocityUserProfile) {
|
||||
const fullName = profile.full_name?.trim();
|
||||
if (fullName) {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
const email = profile.email?.trim();
|
||||
if (email) {
|
||||
return email;
|
||||
}
|
||||
|
||||
return profile.user_id;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState, type ChangeEvent } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
User,
|
||||
@@ -15,13 +15,20 @@ import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
LogOut,
|
||||
Pencil,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useStore } from '@/store/useStore';
|
||||
import { useCurrency, CURRENCY_OPTIONS } from '@/store/useCurrencyStore';
|
||||
import type { CurrencyCode } from '@/store/useCurrencyStore';
|
||||
import { API_URL } from '@/lib/api';
|
||||
import { clearVelocityToken, getVelocityToken, normalizeVelocityRole } from '@/lib/velocityPlatformClient';
|
||||
import {
|
||||
clearVelocityToken,
|
||||
getVelocityToken,
|
||||
normalizeVelocityRole,
|
||||
resolveVelocityFirstName,
|
||||
uploadVelocityAvatar,
|
||||
} from '@/lib/velocityPlatformClient';
|
||||
|
||||
// ── Design tokens (matching inventory glassmorphism) ─────────────────────────
|
||||
const GLASS = {
|
||||
@@ -361,35 +368,104 @@ function CompanionSurfacesCard() {
|
||||
|
||||
// ── Profile ──────────────────────────────────────────────────────────────────
|
||||
function ProfileSettings() {
|
||||
const { user } = useStore();
|
||||
const initials = user?.name.split(' ').map((n) => n[0]).join('') ?? 'AU';
|
||||
const { user, login } = useStore();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
||||
const displayName = user?.fullName?.trim() || user?.name?.trim() || user?.email?.trim() || user?.id || 'Authenticated User';
|
||||
const firstName = resolveVelocityFirstName({
|
||||
user_id: user?.id ?? '',
|
||||
full_name: user?.fullName ?? user?.name ?? null,
|
||||
email: user?.email ?? null,
|
||||
});
|
||||
const initials = (user?.fullName || user?.name || user?.email || 'AU')
|
||||
.split(/[\s@._-]+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((n) => n[0]?.toUpperCase() ?? '')
|
||||
.join('') || 'AU';
|
||||
const roleLabel = normalizeVelocityRole(user?.role)
|
||||
.toLowerCase()
|
||||
.split('_')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ') || 'Authenticated User';
|
||||
|
||||
const handleAvatarUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file || !user) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAvatarError(null);
|
||||
setIsUploadingAvatar(true);
|
||||
try {
|
||||
const { avatar_url } = await uploadVelocityAvatar(file);
|
||||
login({
|
||||
...user,
|
||||
avatar: avatar_url,
|
||||
});
|
||||
} catch (error) {
|
||||
setAvatarError(error instanceof Error ? error.message : 'Failed to upload profile picture.');
|
||||
} finally {
|
||||
setIsUploadingAvatar(false);
|
||||
if (event.target) {
|
||||
event.target.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard delay={0.1}>
|
||||
<SectionHeader icon={User} title="Profile" />
|
||||
<div className="px-6 pb-6">
|
||||
<div className="flex items-center gap-4 mb-5 p-4 rounded-xl" style={INNER_SURFACE}>
|
||||
<div
|
||||
className="w-14 h-14 rounded-full flex items-center justify-center flex-shrink-0 text-white text-lg font-bold"
|
||||
style={{ background: 'hsl(var(--accent))', boxShadow: '0 0 20px hsl(var(--accent) / 0.3)' }}
|
||||
>
|
||||
{initials}
|
||||
<div className="relative flex-shrink-0">
|
||||
{user?.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={displayName}
|
||||
className="w-14 h-14 rounded-full object-cover border border-white/10 shadow-[0_0_20px_rgba(59,130,246,0.2)]"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-14 h-14 rounded-full flex items-center justify-center text-white text-lg font-bold"
|
||||
style={{ background: 'hsl(var(--accent))', boxShadow: '0 0 20px hsl(var(--accent) / 0.3)' }}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="absolute -right-1 -top-1 w-6 h-6 rounded-full flex items-center justify-center border border-white/10 bg-zinc-900/90 text-zinc-200 hover:text-white hover:bg-zinc-800 transition-colors"
|
||||
title="Update profile picture"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-semibold">{user?.name}</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-white font-semibold truncate">{displayName}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'hsl(var(--muted-fg))' }}>
|
||||
{roleLabel}
|
||||
</p>
|
||||
{isUploadingAvatar && (
|
||||
<p className="mt-3 text-xs text-zinc-400">Uploading profile picture...</p>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/jpg"
|
||||
className="hidden"
|
||||
onChange={handleAvatarUpload}
|
||||
/>
|
||||
</div>
|
||||
{avatarError && (
|
||||
<p className="mb-4 text-xs text-red-400">{avatarError}</p>
|
||||
)}
|
||||
<div className="space-y-0 -mx-6">
|
||||
<SettingsRow label="Authenticated Name" description="Resolved from the active Velocity session">
|
||||
<span className="text-sm text-white">{user?.name ?? 'Unavailable'}</span>
|
||||
<span className="text-sm text-white">{firstName || 'Unavailable'}</span>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="User ID" description="Backend principal identifier">
|
||||
<span className="text-sm font-mono text-white">{user?.id ?? 'Unavailable'}</span>
|
||||
|
||||
@@ -7,6 +7,15 @@ export interface VelocityUserProfile {
|
||||
role: string;
|
||||
full_name?: string | null;
|
||||
email?: string | null;
|
||||
avatar_url?: string | null;
|
||||
}
|
||||
|
||||
export interface VelocityActiveUser {
|
||||
user_id: string;
|
||||
role: string;
|
||||
full_name?: string | null;
|
||||
email?: string | null;
|
||||
avatar_url?: string | null;
|
||||
}
|
||||
|
||||
export interface VelocityLoginResponse {
|
||||
@@ -189,6 +198,39 @@ export function normalizeVelocityRole(role: string | null | undefined): string {
|
||||
return (role ?? '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
export function resolveVelocityFullName(profile: Pick<VelocityUserProfile, 'full_name' | 'email' | 'user_id'>): string {
|
||||
const fullName = profile.full_name?.trim();
|
||||
if (fullName) {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
const email = profile.email?.trim();
|
||||
if (email) {
|
||||
return email;
|
||||
}
|
||||
|
||||
return profile.user_id;
|
||||
}
|
||||
|
||||
export function resolveVelocityFirstName(profile: Pick<VelocityUserProfile, 'full_name' | 'email' | 'user_id'>): string {
|
||||
const fullName = profile.full_name?.trim();
|
||||
if (fullName) {
|
||||
return fullName.split(/\s+/)[0] ?? fullName;
|
||||
}
|
||||
|
||||
const email = profile.email?.trim();
|
||||
if (email) {
|
||||
const local = email.split('@')[0]?.trim() ?? '';
|
||||
if (local) {
|
||||
const normalized = local.replace(/[._-]+/g, ' ').trim();
|
||||
const firstToken = normalized.split(/\s+/)[0] ?? normalized;
|
||||
return firstToken.charAt(0).toUpperCase() + firstToken.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
return profile.user_id;
|
||||
}
|
||||
|
||||
export function isAdminRole(role: string | null | undefined): boolean {
|
||||
const normalized = normalizeVelocityRole(role);
|
||||
return normalized === 'ADMIN' || normalized === 'SUPERADMIN';
|
||||
@@ -209,6 +251,36 @@ export async function getVelocityMe(): Promise<VelocityUserProfile> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function listVelocityUsers(): Promise<VelocityActiveUser[]> {
|
||||
return platformFetch<VelocityActiveUser[]>('/api/auth/users', {
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadVelocityAvatar(file: File): Promise<{ avatar_url: string }> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
|
||||
const response = await fetch(`${API_URL}/api/auth/profile/avatar`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(undefined, false),
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
typeof body?.detail === 'string'
|
||||
? body.detail
|
||||
: typeof body?.message === 'string'
|
||||
? body.message
|
||||
: `Request failed: ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<{ avatar_url: string }>;
|
||||
}
|
||||
|
||||
export async function getAdminHealth(): Promise<AdminHealthSnapshot> {
|
||||
return platformFetch<AdminHealthSnapshot>('/api/admin-surface/health');
|
||||
}
|
||||
|
||||
@@ -1,51 +1,99 @@
|
||||
/**
|
||||
* ShareModal — Fork-based sharing workflow.
|
||||
* Explains the direct_fork_only semantics (recipient gets an editable copy,
|
||||
* not live edit access to owner's canvas).
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Share2, GitFork, Lock, Users, MessageSquare, ChevronDown } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { CanvasPage } from '../types/canvas';
|
||||
import { listVelocityUsers, type VelocityActiveUser } from '@/lib/velocityPlatformClient';
|
||||
|
||||
interface ShareModalProps {
|
||||
page: CanvasPage | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onShare: (params: {
|
||||
recipientEmail: string;
|
||||
recipientUserId: string;
|
||||
visibility: 'private' | 'team';
|
||||
message: string;
|
||||
sourceRevision: number;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
const TEAM_MEMBERS = [
|
||||
{ id: 'u2', name: 'Elena Rostova', email: 'elena@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80', role: 'Senior Broker' },
|
||||
{ id: 'u3', name: 'Priya Sharma', email: 'priya@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1554151228-14d9def656e4?auto=format&fit=crop&w=80&q=80', role: 'Senior Broker' },
|
||||
{ id: 'u4', name: 'Carlos Mendez', email: 'carlos@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&w=80&q=80', role: 'Broker' },
|
||||
{ id: 'u5', name: 'Ravi Kapoor', email: 'ravi@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=80&q=80', role: 'Broker' },
|
||||
];
|
||||
function getDisplayName(member: VelocityActiveUser): string {
|
||||
return member.full_name?.trim() || member.email?.trim() || member.user_id;
|
||||
}
|
||||
|
||||
function getRoleLabel(member: VelocityActiveUser): string {
|
||||
return member.role
|
||||
.toLowerCase()
|
||||
.split('_')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function getInitials(member: VelocityActiveUser): string {
|
||||
const basis = getDisplayName(member);
|
||||
return basis
|
||||
.split(/[\s@._-]+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part.charAt(0).toUpperCase())
|
||||
.join('') || 'U';
|
||||
}
|
||||
|
||||
export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const [recipient, setRecipient] = useState<{ id: string; name: string; email: string } | null>(null);
|
||||
const [teamMembers, setTeamMembers] = useState<VelocityActiveUser[]>([]);
|
||||
const [loadingMembers, setLoadingMembers] = useState(false);
|
||||
const [membersError, setMembersError] = useState<string | null>(null);
|
||||
const [recipient, setRecipient] = useState<VelocityActiveUser | null>(null);
|
||||
const [visibility, setVisibility] = useState<'private' | 'team'>('private');
|
||||
const [message, setMessage] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [memberDropOpen, setMemberDropOpen] = useState(false);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setMemberDropOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoadingMembers(true);
|
||||
setMembersError(null);
|
||||
|
||||
void listVelocityUsers()
|
||||
.then((users) => {
|
||||
if (cancelled) return;
|
||||
setTeamMembers(users);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
setMembersError(error instanceof Error ? error.message : 'Failed to load team members.');
|
||||
setTeamMembers([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingMembers(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const selectedRecipientLabel = useMemo(
|
||||
() => (recipient ? getDisplayName(recipient) : 'Select verified teammate...'),
|
||||
[recipient],
|
||||
);
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!recipient || !page) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onShare({
|
||||
recipientEmail: recipient.email,
|
||||
recipientUserId: recipient.user_id,
|
||||
visibility,
|
||||
message,
|
||||
sourceRevision: page.headRevision,
|
||||
@@ -56,9 +104,9 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
||||
onClose();
|
||||
setRecipient(null);
|
||||
setMessage('');
|
||||
}, 2000);
|
||||
}, 1800);
|
||||
} catch {
|
||||
// stay open on error
|
||||
// keep modal open and let caller surface the error upstream
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -68,7 +116,6 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
key="share-backdrop"
|
||||
className="fixed inset-0 z-40"
|
||||
@@ -95,7 +142,6 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
||||
boxShadow: '0 24px 80px rgba(0,0,0,0.8)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-xl bg-blue-500/15 border border-blue-500/25 flex items-center justify-center">
|
||||
@@ -113,16 +159,14 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Fork explanation */}
|
||||
<div
|
||||
className="flex items-start gap-3 p-3 rounded-xl mb-5"
|
||||
style={{ background: 'rgba(59,130,246,0.07)', border: '1px solid rgba(59,130,246,0.18)' }}
|
||||
>
|
||||
<GitFork className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-zinc-300 leading-relaxed">
|
||||
The recipient gets a <span className="text-blue-300 font-medium">fork</span> — an editable copy
|
||||
of this canvas at revision {page?.headRevision}. They can build on it and open a merge
|
||||
request to propose their changes back.
|
||||
The recipient gets a <span className="text-blue-300 font-medium">fork</span> of this canvas at the selected revision.
|
||||
They can edit their copy and later open a merge request back into the source branch.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -131,17 +175,16 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
||||
<div className="w-12 h-12 rounded-full bg-green-500/15 border border-green-500/30 flex items-center justify-center">
|
||||
<Share2 className="w-6 h-6 text-green-400" />
|
||||
</div>
|
||||
<p className="text-sm text-zinc-200 font-medium">Fork created successfully!</p>
|
||||
<p className="text-xs text-zinc-500">{recipient?.name} can now access their copy.</p>
|
||||
<p className="text-sm text-zinc-200 font-medium">Fork created successfully.</p>
|
||||
<p className="text-xs text-zinc-500">{recipient ? getDisplayName(recipient) : 'Recipient'} can access the shared copy.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Recipient picker */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-400 mb-1.5 block">Recipient</label>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setMemberDropOpen((p) => !p)}
|
||||
onClick={() => setMemberDropOpen((prev) => !prev)}
|
||||
className="w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
@@ -151,10 +194,14 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-3.5 h-3.5 text-zinc-500" />
|
||||
<span>{recipient?.name ?? 'Select team member…'}</span>
|
||||
<span>{selectedRecipientLabel}</span>
|
||||
</div>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-zinc-600" style={{ transform: memberDropOpen ? 'rotate(180deg)' : 'none' }} />
|
||||
<ChevronDown
|
||||
className="w-3.5 h-3.5 text-zinc-600"
|
||||
style={{ transform: memberDropOpen ? 'rotate(180deg)' : 'none' }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{memberDropOpen && (
|
||||
<motion.div
|
||||
@@ -164,16 +211,40 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
{TEAM_MEMBERS.map((m) => (
|
||||
{loadingMembers && (
|
||||
<div className="px-3 py-3 text-xs text-zinc-500">Loading verified accounts...</div>
|
||||
)}
|
||||
{!loadingMembers && membersError && (
|
||||
<div className="px-3 py-3 text-xs text-red-400">{membersError}</div>
|
||||
)}
|
||||
{!loadingMembers && !membersError && teamMembers.length === 0 && (
|
||||
<div className="px-3 py-3 text-xs text-zinc-500">No verified users available.</div>
|
||||
)}
|
||||
{!loadingMembers && !membersError && teamMembers.map((member) => (
|
||||
<button
|
||||
key={m.id}
|
||||
key={member.user_id}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-white/5 transition-colors text-left"
|
||||
onClick={() => { setRecipient(m); setMemberDropOpen(false); }}
|
||||
onClick={() => {
|
||||
setRecipient(member);
|
||||
setMemberDropOpen(false);
|
||||
}}
|
||||
>
|
||||
<img src={m.avatar} className="w-7 h-7 rounded-full" alt={m.name} />
|
||||
<div>
|
||||
<p className="text-sm text-zinc-200">{m.name}</p>
|
||||
<p className="text-[10px] text-zinc-500">{m.role}</p>
|
||||
{member.avatar_url ? (
|
||||
<img
|
||||
src={member.avatar_url}
|
||||
alt={getDisplayName(member)}
|
||||
className="w-7 h-7 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-7 h-7 rounded-full bg-blue-500/15 border border-blue-500/25 text-[10px] font-semibold text-blue-300 flex items-center justify-center">
|
||||
{getInitials(member)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-zinc-200 truncate">{getDisplayName(member)}</p>
|
||||
<p className="text-[10px] text-zinc-500 truncate">
|
||||
{member.email || member.user_id} · {getRoleLabel(member)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -183,7 +254,6 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visibility */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-400 mb-1.5 block">Fork visibility</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -210,7 +280,6 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-400 mb-1.5 flex items-center gap-1.5">
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
@@ -219,7 +288,7 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Add context for the recipient…"
|
||||
placeholder="Add context for the recipient..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-xl resize-none focus:outline-none"
|
||||
style={{
|
||||
@@ -230,7 +299,6 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -244,7 +312,7 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
||||
disabled={!recipient || submitting}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white"
|
||||
>
|
||||
{submitting ? 'Creating fork…' : 'Share (Create Fork)'}
|
||||
{submitting ? 'Creating fork...' : 'Share (Create Fork)'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,11 +50,11 @@ export function useOraclePage(pageId: string | null): OraclePageState {
|
||||
}
|
||||
const disconnect = connectPageSocket(pageId, {
|
||||
onMessage: (msg: OracleWSMessage) => handleWSMessage(msg),
|
||||
onReconnect: () => void load(),
|
||||
onReconnect: () => undefined,
|
||||
onOpen: () => setIsConnected(true),
|
||||
onClose: () => setIsConnected(false),
|
||||
});
|
||||
disconnectRef.current = disconnect;
|
||||
setIsConnected(true);
|
||||
return () => {
|
||||
disconnect();
|
||||
disconnectRef.current = null;
|
||||
|
||||
@@ -167,7 +167,8 @@ export function connectPageSocket(
|
||||
pageId: string,
|
||||
handlers: {
|
||||
onMessage: (msg: OracleWSMessage) => void;
|
||||
onReconnect: () => void;
|
||||
onReconnect?: () => void;
|
||||
onOpen?: () => void;
|
||||
onClose: () => void;
|
||||
},
|
||||
): () => void {
|
||||
@@ -184,6 +185,10 @@ export function connectPageSocket(
|
||||
function connect() {
|
||||
ws = new WebSocket(`${wsBase}/ws/oracle/canvas/${pageId}`);
|
||||
|
||||
ws.onopen = () => {
|
||||
handlers.onOpen?.();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
handlers.onMessage(JSON.parse(event.data as string) as OracleWSMessage);
|
||||
@@ -196,7 +201,7 @@ export function connectPageSocket(
|
||||
handlers.onClose();
|
||||
if (!stopped) {
|
||||
retryTimeout = setTimeout(() => {
|
||||
handlers.onReconnect();
|
||||
handlers.onReconnect?.();
|
||||
connect();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface NavItem {
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
fullName?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
import { inspectAttr } from 'kimi-plugin-inspect-react'
|
||||
|
||||
const backendProxyTarget = process.env.VITE_BACKEND_PROXY_TARGET?.trim() || "https://api.desineuron.in"
|
||||
const backendProxyTarget = process.env.VITE_BACKEND_PROXY_TARGET?.trim() || "https://velocity.desineuron.in"
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
@@ -37,6 +37,12 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
"/ws": {
|
||||
target: backendProxyTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user