import { useEffect, useState } from "react"; import { api } from "./api.js"; import AdminPage from "./AdminPage.jsx"; import WelcomePage from "./WelcomePage.jsx"; import Workspace from "./Workspace.jsx"; const IS_ADMIN_PATH = window.location.pathname === "/admin"; // First visit lands on the welcome page; after "Open the dashboard" (or any // return visit) the root goes straight to work. /welcome always shows it. const WELCOME_SEEN_KEY = "ccr_welcome_seen"; const IS_WELCOME_PATH = window.location.pathname === "/welcome"; function relativeTime(iso) { if (!iso) return ""; const then = new Date(iso.endsWith("Z") || iso.includes("+") ? iso : iso + "Z"); const mins = Math.max(0, Math.floor((Date.now() - then.getTime()) / 60000)); if (mins < 1) return "just now"; if (mins < 60) return `${mins}m ago`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); if (days < 7) return `${days}d ago`; return then.toISOString().slice(0, 10); } function groupProjects(projects) { // Buckets by last activity: Today / This week / Earlier, with archived // projects collapsed into their own group at the bottom. Projects arrive // sorted by last activity (backend), so group order falls out naturally. const now = Date.now(); const DAY = 86400000; const groups = { Today: [], "This week": [], Earlier: [], Archived: [] }; for (const p of projects) { if (p.archived) { groups.Archived.push(p); continue; } const iso = p.last_activity_at || p.created_at; const t = new Date(iso.endsWith("Z") || iso.includes("+") ? iso : iso + "Z").getTime(); const age = now - t; if (age < DAY) groups.Today.push(p); else if (age < 7 * DAY) groups["This week"].push(p); else groups.Earlier.push(p); } return Object.entries(groups).filter(([, items]) => items.length > 0); } export default function App() { const [projects, setProjects] = useState([]); const [selectedId, setSelectedId] = useState(null); const [creating, setCreating] = useState(false); const [newName, setNewName] = useState(""); const [filter, setFilter] = useState(""); const [error, setError] = useState(""); const [auth, setAuth] = useState(null); const [showLogin, setShowLogin] = useState(false); const [authMode, setAuthMode] = useState("signin"); // signin | register const [authEmail, setAuthEmail] = useState(""); const [authPassword, setAuthPassword] = useState(""); const [authName, setAuthName] = useState(""); const [authError, setAuthError] = useState(""); const [authBusy, setAuthBusy] = useState(false); const [inviteToken, setInviteToken] = useState(""); const [showWelcome, setShowWelcome] = useState( IS_WELCOME_PATH || (!IS_ADMIN_PATH && !localStorage.getItem(WELCOME_SEEN_KEY)) ); function enterDashboard() { localStorage.setItem(WELCOME_SEEN_KEY, "1"); if (window.location.pathname !== "/") { window.history.replaceState({}, "", "/"); } setShowWelcome(false); } // Invite links carry their role in the signed payload; decode it for the // banner only - the server re-verifies the signature on registration. const inviteRole = (() => { if (!inviteToken) return null; try { const data = JSON.parse(atob(inviteToken.split(".")[0].replace(/-/g, "+").replace(/_/g, "/"))); return { lab: "lab member", external: "external user" }[data.invite] || null; } catch { return null; } })(); const loadProjects = () => api.listProjects().then(setProjects).catch((e) => setError(e.message)); const loadAuth = () => api.authMe().then(setAuth).catch(() => {}); useEffect(() => { loadProjects(); loadAuth(); // Surface Google sign-in failures passed back via redirect. const params = new URLSearchParams(window.location.search); const authFail = params.get("auth_error"); if (authFail) { setError(`Sign-in problem: ${authFail.replaceAll("-", " ")}.`); window.history.replaceState({}, "", "/"); } // Invite link (?invite=TOKEN): open the signup form with the token // attached. The URL keeps the token until signup succeeds, so a page // refresh doesn't lose the invite. const invite = params.get("invite"); if (invite) { setInviteToken(invite); setAuthMode("register"); setShowLogin(true); setShowWelcome(false); // invited people go straight to the signup form } }, []); async function handleAuthSubmit(e) { e.preventDefault(); setAuthError(""); setAuthBusy(true); try { if (authMode === "register") { await api.register({ email: authEmail.trim(), password: authPassword, name: authName.trim(), ...(inviteToken ? { invite_token: inviteToken } : {}), }); if (inviteToken) { setInviteToken(""); window.history.replaceState({}, "", "/"); // invite consumed } } else { await api.login({ email: authEmail.trim(), password: authPassword }); } setShowLogin(false); setAuthEmail(""); setAuthPassword(""); setAuthName(""); await Promise.all([loadAuth(), loadProjects()]); // owned projects appear on sign-in } catch (err) { setAuthError(err.message); } finally { setAuthBusy(false); } } async function handleLogout() { try { await api.logout(); await Promise.all([loadAuth(), loadProjects()]); } catch (err) { setError(err.message); } } useEffect(() => { if (projects.length === 0) { setSelectedId(null); return; } if (!selectedId || !projects.some((p) => p.id === selectedId)) { setSelectedId(projects[0].id); } }, [projects, selectedId]); async function createProject(e) { e.preventDefault(); if (!newName.trim()) return; try { const p = await api.createProject({ name: newName.trim() }); setNewName(""); setCreating(false); await loadProjects(); setSelectedId(p.id); } catch (err) { setError(err.message); } } const selected = projects.find((p) => p.id === selectedId) || null; const normalizedFilter = filter.trim().toLowerCase(); const visibleProjects = projects.filter((p) => p.name.toLowerCase().includes(normalizedFilter) ); return (
🎟 You've been invited{inviteRole ? ` as a ${inviteRole}` : ""} - create your account below and the access comes with it.
)}Accounts are free. Signing in lifts the anonymous limits {auth?.limits?.max_rows ? ` (${Math.round(auth.limits.max_bytes / 1048576)} MB / ${auth.limits.max_rows.toLocaleString()} rows per file, ${auth?.usage?.max_runs_per_day ?? 3} runs/day)` : ""}{" "} and keeps your datasets and runs instead of deleting them after analysis.
{authError &&{authError}
} {auth?.google_available && ( <> Continue with Googleor use email and password
> )}{authMode === "register" ? ( <> Already have an account?{" "} > ) : ( <> New here?{" "} > )} {auth?.google_available ? " · Forgot your password? Contact the lab admin, or use Google." : " · Google sign-in arrives with lab accounts. Forgot your password? Contact the lab admin."}
A project holds your datasets and runs. Upload a corpus (CSV/XLSX), choose a validated construct, and run a CCR analysis - results include per-item loadings, score distributions, and a reproducibility record for every run.
Self-contained by design: embeddings run on this server itself - no third-party AI APIs. Please don't upload sensitive or identifiable data on this dev instance.