import { useEffect, useState } from "react"; import { api } from "./api.js"; import AccountMenu from "./AccountMenu.jsx"; import AdminPage from "./AdminPage.jsx"; import CiteDialog from "./CiteDialog.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"; // Collapsible project sidebar (asked for 2026-09-15: "closable like ChatGPT or // Claude"). Two behaviours behind one piece of state: // wide - the sidebar slides out of the row and the workspace takes the // space; the choice is remembered. // narrow - it is a drawer over the workspace, always starting closed. It // used to be a block stacked above the workspace, so on a phone you // scrolled past the project list to reach the thing you opened. const SIDEBAR_KEY = "ccr_sidebar_open"; const NARROW_QUERY = "(max-width: 820px)"; const isNarrow = () => window.matchMedia(NARROW_QUERY).matches; function initialSidebarOpen() { if (isNarrow()) return false; try { return localStorage.getItem(SIDEBAR_KEY) !== "0"; } catch { return true; // private mode with storage blocked: open, as it always was } } // "Panel left", the mark ChatGPT and Claude both use for this, so the gesture // is recognisable without a label. Three buttons share it: collapse, expand, // and closing the drawer on a phone. function PanelIcon() { return ( ); } 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 [showCite, setShowCite] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(initialSidebarOpen); 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(() => { try { localStorage.setItem(SIDEBAR_KEY, sidebarOpen ? "1" : "0"); } catch { /* storage blocked: the choice just does not survive a reload */ } }, [sidebarOpen]); useEffect(() => { // Escape closes the drawer, and so does crossing into narrow width: a // drawer left open across a resize would cover the workspace. function onKey(e) { if (e.key === "Escape" && isNarrow()) setSidebarOpen(false); } const mq = window.matchMedia(NARROW_QUERY); const onNarrow = (e) => { if (e.matches) setSidebarOpen(false); }; document.addEventListener("keydown", onKey); mq.addEventListener("change", onNarrow); return () => { document.removeEventListener("keydown", onKey); mq.removeEventListener("change", onNarrow); }; }, []); 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(); selectProject(p.id); } catch (err) { setError(err.message); } } function selectProject(id) { setSelectedId(id); if (isNarrow()) setSidebarOpen(false); // the drawer covers what you picked } // The dashboard carries no top bar at all: its sidebar IS the chrome. Only // the two views without a sidebar (landing page, /admin) get one. const isDashboard = !IS_ADMIN_PATH && !showWelcome; const isLabMember = ["lab", "maintainer", "pi"].includes(auth?.role); 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 (
{!isDashboard && (
{ // Inside the SPA the brand is an instant way back to the // dashboard; from /admin it's a normal navigation. if (!IS_ADMIN_PATH) { e.preventDefault(); enterDashboard(); } }} > CCR Platform {/* The tagline used to sit here on every screen. It says what the product is, which is a landing-page job, so it now lives in the hero and the footer and nowhere else. */}
)} {showLogin && (
setShowLogin(false)}>
e.stopPropagation()}>

{authMode === "register" ? "Create an account" : "Sign in"}

{inviteToken && authMode === "register" && (

🎟 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 Google

or use email and password

)}
{authMode === "register" && ( )}

{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."}

)} {IS_ADMIN_PATH ? (
) : showWelcome ? (
) : (
{/* Tapping beside an open drawer closes it. Hidden above 820px, where the sidebar sits in the row rather than over it. */} {sidebarOpen && (
setSidebarOpen(false)} aria-hidden="true" /> )}
{/* Only while the sidebar is away, which on a phone is every visit. Reserving the row when the sidebar is open left an empty strip above the project name (spotted 2026-09-15) and pushed the heading out of line with the logo beside it. Toggling now shifts the content by one row, which is the cheaper of the two. */} {!sidebarOpen && (
CCR Platform
)} {error && (
setError("")}> {error}
)} {selected ? ( { setSelectedId(null); loadProjects(); }} /> ) : (

Create your first project

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.

Read the 5-minute guide

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.

)}
)} {/* Site identity footer, shown on the public welcome/landing view only. Link scanners and reviewers judge the domain by its public face, and the lab identity is also baked into the served HTML (see da069b3), so the dashboard and results views drop it to keep the working area uncluttered. */} {showWelcome && (

CCR Platform - Contextualized Construct Representation analysis for psychology research, operated by the{" "} Culture and Morality Lab {" "} at the University of Massachusetts Amherst. Principal investigator: Dr. Mohammad Atari.

Contact devaanand@umass.edu {" · "} Source code {" · "} How this tool works {" · "} {/* Ali Hajian, 2026-09-09: somewhere inconspicuous to get a citation, the way Google Scholar's Cite button works. The footer is that place - present for anyone writing a paper, invisible to anyone who is not. */}

)} {showCite && setShowCite(false)} />}
); }