Files
nexusAI/packages/chat-client/src/components/ProjectView.jsx
2026-04-18 22:53:03 -07:00

301 lines
12 KiB
JavaScript

import React, { useState, useEffect } from 'react';
import { fetchSessions, updateProject, deleteProject } from '../api/orchestration';
import ProjectModal from './ProjectModal';
export default function ProjectView({ project, onNavigate, onBack, onSelectSession, onNewProjectChat, onProjectsChange }) {
const [sessions, setSessions] = useState([]);
const [loading, setLoading] = useState(true);
const [input, setInput] = useState('');
const [menuOpen, setMenuOpen] = useState(false);
const [modal, setModal] = useState(null); // { mode: 'edit' | 'confirm-delete' }
useEffect(() => { load(); }, [project.id]);
async function load() {
setLoading(true);
try {
setSessions(await fetchSessions(50, 0, project.id));
} catch (err) {
console.error('[ProjectView] Failed to load sessions:', err.message);
} finally {
setLoading(false);
}
}
function handleSend() {
const text = input.trim();
if (!text) return;
setInput('');
onNewProjectChat(text);
}
function handleKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
async function handleSave({ name, description, colour, icon, isolated }) {
try {
await updateProject(project.id, { name, description, colour, icon, isolated });
onProjectsChange?.();
setModal(null);
} catch (err) {
console.error('[ProjectView] Update failed:', err.message);
}
}
async function handleDelete() {
try {
await deleteProject(project.id);
onProjectsChange?.();
onBack();
} catch (err) {
console.error('[ProjectView] Delete failed:', err.message);
}
}
function formatTimestamp(ts) {
if (!ts) return '—';
const date = new Date(ts * 1000);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays === 1) return 'Yesterday';
return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
}
return (
<div className="flex-col flex-1 overflow-hidden" style={{ background: 'var(--bg-base)' }}>
{/* Colour accent bar */}
<div style={{ height: '3px', flexShrink: 0, background: project.colour ?? 'var(--accent)' }} />
{/* Header */}
<div className="panel-header" style={{ padding: '0 24px', justifyContent: 'space-between' }}>
<button
className="btn-reset text-xs text-muted"
onClick={onBack}
style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
>
All Projects
</button>
{/* ⋮ menu */}
<div style={{ position: 'relative' }}>
<button
className="btn-icon"
onClick={() => setMenuOpen(o => !o)}
title="Project options"
style={{ fontSize: '18px', letterSpacing: '1px' }}
></button>
{menuOpen && (
<>
{/* Click-away backdrop */}
<div
style={{ position: 'fixed', inset: 0, zIndex: 40 }}
onClick={() => setMenuOpen(false)}
/>
<div style={{
position: 'absolute', top: '100%', right: 0,
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
padding: '4px', zIndex: 50, minWidth: '150px',
}}>
<MenuButton onClick={() => { setMenuOpen(false); setModal({ mode: 'edit' }); }}>
Edit details
</MenuButton>
<MenuButton danger onClick={() => { setMenuOpen(false); setModal({ mode: 'confirm-delete' }); }}>
Delete project
</MenuButton>
</div>
</>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 scroll-y" style={{ padding: '32px 24px' }}>
{/* Project title + description */}
<div style={{ marginBottom: '32px' }}>
<h1 style={{ fontSize: '22px', fontWeight: 600, color: 'var(--text-primary)', marginBottom: '8px' }}>
{project.name}
</h1>
{project.description && (
<p className="text-sm" style={{ color: 'var(--text-secondary)', maxWidth: '560px', lineHeight: 1.6 }}>
{project.description}
</p>
)}
</div>
{/* Conversations */}
<div>
<p className="label-upper" style={{ marginBottom: '12px' }}>Conversations</p>
{loading ? (
<div className="text-sm text-muted">Loading...</div>
) : sessions.length === 0 ? (
/* Empty state */
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '16px', padding: '32px 0' }}>
<p className="text-sm text-muted">No conversations yet start one below</p>
<div style={{ width: '100%', maxWidth: '520px' }}>
<div style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
padding: '12px 14px',
}}>
<textarea
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Start a conversation in ${project.name}`}
rows={1}
autoFocus
style={{
width: '100%', background: 'transparent',
border: 'none', outline: 'none',
color: 'var(--text-primary)', fontSize: '14px',
lineHeight: '1.6', resize: 'none', fontFamily: 'inherit',
maxHeight: '120px', overflowY: 'auto',
}}
onInput={e => {
e.target.style.height = 'auto';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>
<button
onClick={handleSend}
disabled={!input.trim()}
className="btn-primary"
style={{ width: '32px', height: '32px', fontSize: '16px', border: '1px solid var(--border)' }}
></button>
</div>
</div>
<p className="text-xs text-muted" style={{ textAlign: 'center', marginTop: '8px' }}>
Enter to send · Shift+Enter for new line
</p>
</div>
</div>
) : (
/* Sessions list + new conversation input */
<>
<div style={{ display: 'flex', flexDirection: 'column', marginBottom: '24px' }}>
{sessions.map((session, i) => (
<button
key={session.external_id}
className="btn-reset"
onClick={() => { onSelectSession(session); onNavigate('chat'); }}
style={{
padding: '12px 16px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
borderBottom: i < sessions.length - 1 ? '1px solid var(--border)' : 'none',
borderRadius: i === 0
? 'var(--radius-md) var(--radius-md) 0 0'
: i === sessions.length - 1
? '0 0 var(--radius-md) var(--radius-md)'
: '0',
background: 'var(--bg-surface)',
textAlign: 'left',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-elevated)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-surface)'}
>
<span className="text-base" style={{ color: 'var(--text-primary)' }}>
{session.name || session.external_id}
</span>
<span className="text-xs text-muted" style={{ flexShrink: 0, marginLeft: '16px' }}>
{formatTimestamp(session.updated_at)}
</span>
</button>
))}
</div>
{/* New conversation input */}
<div style={{ width: '100%', maxWidth: '520px' }}>
<div style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
padding: '12px 14px',
}}>
<textarea
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`New conversation in ${project.name}`}
rows={1}
style={{
width: '100%', background: 'transparent',
border: 'none', outline: 'none',
color: 'var(--text-primary)', fontSize: '14px',
lineHeight: '1.6', resize: 'none', fontFamily: 'inherit',
maxHeight: '120px', overflowY: 'auto',
}}
onInput={e => {
e.target.style.height = 'auto';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>
<button
onClick={handleSend}
disabled={!input.trim()}
className="btn-primary"
style={{ width: '32px', height: '32px', fontSize: '16px', border: '1px solid var(--border)' }}
></button>
</div>
</div>
<p className="text-xs text-muted" style={{ textAlign: 'center', marginTop: '8px' }}>
Enter to send · Shift+Enter for new line
</p>
</div>
</>
)}
</div>
</div>
{/* Modal */}
{modal && (
<ProjectModal
project={project}
mode={modal.mode}
onSave={handleSave}
onDelete={handleDelete}
onClose={() => setModal(null)}
/>
)}
</div>
);
}
function MenuButton({ children, onClick, danger }) {
return (
<button
className="btn-reset text-sm"
onClick={onClick}
style={{
width: '100%', padding: '8px 12px',
borderRadius: 'var(--radius-sm)',
justifyContent: 'flex-start',
color: danger ? '#ff6b6b' : 'var(--text-primary)',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-surface)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>{children}</button>
);
}