From 12738f0611f5b86646e18b5f0dbd0377e76eaf5d Mon Sep 17 00:00:00 2001 From: Dhruval Anandkar Date: Mon, 20 Apr 2026 22:05:11 -0400 Subject: [PATCH] Add live artifact editor and demo login - New ArtifactEditor with Preview/Split/Edit modes, live markdown preview, editable title, dirty-state tracking, Ctrl/Cmd+S save, Revert, and .docx export - Wire ArtifactEditor into ProjectWorkspace, replacing the read-only viewer - Add POST /api/auth/dev-login endpoint (disabled in production) that creates or reuses a demo user and issues a standard session token - Add DevLoginRequest schema and authApi.devLogin client - Add 'Try the Demo (no sign-up)' button on LoginPage with loading and error states --- backend/app/api/auth.py | 64 ++++- backend/app/schemas/__init__.py | 2 + backend/app/schemas/auth.py | 6 + frontend/src/components/ArtifactEditor.tsx | 299 +++++++++++++++++++++ frontend/src/lib/api.ts | 2 + frontend/src/pages/LoginPage.tsx | 45 +++- frontend/src/pages/ProjectWorkspace.tsx | 61 +---- 7 files changed, 426 insertions(+), 53 deletions(-) create mode 100644 frontend/src/components/ArtifactEditor.tsx diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 10813a6..414183a 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -14,7 +14,7 @@ from app.core.security import create_session_token, verify_session_token from app.config import get_settings from app.models import User -from app.schemas import GoogleAuthRequest, AuthResponse, UserResponse +from app.schemas import GoogleAuthRequest, DevLoginRequest, AuthResponse, UserResponse router = APIRouter() settings = get_settings() @@ -135,6 +135,68 @@ async def google_auth( ) +@router.post("/dev-login", response_model=AuthResponse) +async def dev_login( + request: DevLoginRequest, + response: Response, + db: AsyncSession = Depends(get_db), +): + """ + Dummy/demo login — creates (or reuses) a local demo user and issues a + session token. Disabled automatically in production. + + This is intentionally no-OAuth so the app can be tried without Google + credentials. Do not enable in production. + """ + if settings.is_production: + raise HTTPException(status_code=404, detail="Not found") + + email = (request.email or "demo@cortexlab.local").strip().lower() + name = (request.name or "Demo Researcher").strip() or "Demo Researcher" + # Stable synthetic google_id so the same demo email always maps to the same user + google_id = f"demo:{email}" + + result = await db.execute(select(User).where(User.google_id == google_id)) + user = result.scalar_one_or_none() + + if not user: + user = User( + google_id=google_id, + email=email, + name=name, + avatar_url=None, + ) + db.add(user) + await db.commit() + await db.refresh(user) + else: + # Keep display name fresh if the caller provided a new one + if request.name and user.name != name: + user.name = name + await db.commit() + + session_token = create_session_token(user.id) + + response.set_cookie( + key="session_token", + value=session_token, + httponly=True, + secure=settings.is_production, + samesite="lax", + max_age=86400 * 7, + ) + + return AuthResponse( + user=UserResponse( + id=user.id, + email=user.email, + name=user.name, + avatar_url=user.avatar_url, + ), + session_token=session_token, + ) + + @router.post("/logout") async def logout(response: Response): """ diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 4fd26cf..3ee45ac 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -2,6 +2,7 @@ from app.schemas.auth import ( GoogleAuthRequest, + DevLoginRequest, UserResponse, AuthResponse, SessionValidation, @@ -38,6 +39,7 @@ __all__ = [ # Auth "GoogleAuthRequest", + "DevLoginRequest", "UserResponse", "AuthResponse", "SessionValidation", diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 7ef7db2..8c09174 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -13,6 +13,12 @@ class GoogleAuthRequest(BaseModel): id_token: str +class DevLoginRequest(BaseModel): + """Request body for dummy/demo login (dev & demos only).""" + name: Optional[str] = None + email: Optional[str] = None + + class UserResponse(BaseModel): """User data response.""" id: str diff --git a/frontend/src/components/ArtifactEditor.tsx b/frontend/src/components/ArtifactEditor.tsx new file mode 100644 index 0000000..f2a11f1 --- /dev/null +++ b/frontend/src/components/ArtifactEditor.tsx @@ -0,0 +1,299 @@ +/** + * ArtifactEditor + * + * Live-editing surface for a research artifact (report / paper draft). + * + * Features: + * - Three view modes: Preview, Edit, and Split (editor + live preview side-by-side) + * - Editable title + markdown body + * - Auto-tracks unsaved changes, supports Ctrl/Cmd+S to save + * - Saves via PUT /api/artifacts/:id (existing backend endpoint) + * - Export to .docx, and Revert-to-saved + */ + +import { useEffect, useMemo, useRef, useState } from 'react'; +import ReactMarkdown from 'react-markdown'; +import { + FileText, + Download, + Pencil, + Eye, + Columns2, + Save, + Loader2, + RotateCcw, + X, + Check, +} from 'lucide-react'; +import { artifactsApi, type Artifact } from '../lib/api'; +import { cn, formatRelativeTime, downloadBlob } from '../lib/utils'; + +type ViewMode = 'preview' | 'edit' | 'split'; + +interface ArtifactEditorProps { + artifact: Artifact; + onClose: () => void; + onSaved?: (updated: Artifact) => void; +} + +export function ArtifactEditor({ artifact, onClose, onSaved }: ArtifactEditorProps) { + const [mode, setMode] = useState('preview'); + const [title, setTitle] = useState(artifact.title); + const [content, setContent] = useState(artifact.content_markdown); + const [saving, setSaving] = useState(false); + const [exporting, setExporting] = useState(false); + const [justSaved, setJustSaved] = useState(false); + const [error, setError] = useState(null); + + // Track last-known-saved values so Dirty detection is reliable even after saves + const [savedTitle, setSavedTitle] = useState(artifact.title); + const [savedContent, setSavedContent] = useState(artifact.content_markdown); + const [version, setVersion] = useState(artifact.version); + const [updatedAt, setUpdatedAt] = useState(artifact.updated_at); + + const textareaRef = useRef(null); + + // When the selected artifact changes, reset all state + useEffect(() => { + setTitle(artifact.title); + setContent(artifact.content_markdown); + setSavedTitle(artifact.title); + setSavedContent(artifact.content_markdown); + setVersion(artifact.version); + setUpdatedAt(artifact.updated_at); + setMode('preview'); + setError(null); + }, [artifact.id]); + + const isDirty = title !== savedTitle || content !== savedContent; + + const handleSave = async () => { + if (!isDirty || saving) return; + setSaving(true); + setError(null); + try { + const payload: { title?: string; content_markdown?: string } = {}; + if (title !== savedTitle) payload.title = title; + if (content !== savedContent) payload.content_markdown = content; + + const { data } = await artifactsApi.update(artifact.id, payload); + setSavedTitle(data.title); + setSavedContent(data.content_markdown); + setTitle(data.title); + setContent(data.content_markdown); + setVersion(data.version); + setUpdatedAt(data.updated_at); + setJustSaved(true); + setTimeout(() => setJustSaved(false), 1800); + onSaved?.(data); + } catch (err: any) { + console.error('Save failed:', err); + setError(err?.response?.data?.detail || 'Failed to save changes.'); + } finally { + setSaving(false); + } + }; + + const handleRevert = () => { + setTitle(savedTitle); + setContent(savedContent); + setError(null); + }; + + const handleExport = async () => { + setExporting(true); + try { + const response = await artifactsApi.exportDocx(artifact.id); + downloadBlob(response.data, `${title || 'artifact'}.docx`); + } catch (err) { + console.error('Export failed:', err); + setError('Export failed.'); + } finally { + setExporting(false); + } + }; + + // Ctrl/Cmd+S -> save + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { + e.preventDefault(); + void handleSave(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [handleSave]); + + // Useful metric for the status bar + const wordCount = useMemo( + () => (content.trim() ? content.trim().split(/\s+/).length : 0), + [content] + ); + + const showEditor = mode !== 'preview'; + const showPreview = mode !== 'edit'; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+ {mode === 'preview' ? ( +

{title}

+ ) : ( + setTitle(e.target.value)} + placeholder="Untitled artifact" + className="w-full font-bold text-slate-900 bg-transparent border-b border-transparent focus:border-indigo-300 focus:outline-none py-0.5" + /> + )} +

+ v{version} • {formatRelativeTime(updatedAt)} + {isDirty && ( + + + Unsaved changes + + )} + {justSaved && !isDirty && ( + + Saved + + )} +

+
+
+ +
+ {/* Mode switcher */} +
+ setMode('preview')} icon={} label="Preview" /> + setMode('split')} icon={} label="Split" /> + setMode('edit')} icon={} label="Edit" /> +
+ + {isDirty && ( + + )} + + + + + + +
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Body */} +
+
+ {showEditor && ( +
+