Spaces:
Running
Running
| 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 ( | |
| <div className="app"> | |
| <header className="header"> | |
| <a | |
| className="brand" | |
| href="/" | |
| onClick={(e) => { | |
| // 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(); | |
| } | |
| }} | |
| > | |
| {/* The logo already reads "CCR" - no wordmark next to it, and the | |
| tagline says what the tool does instead of re-expanding the acronym. */} | |
| <img className="brand-logo" src="/ccr-logo-white.svg" alt="CCR Platform" /> | |
| </a> | |
| <span className="sub"> | |
| Psychological Text Analysis with Contextualized Construct Representation | |
| </span> | |
| <nav className="header-nav"> | |
| {IS_ADMIN_PATH ? ( | |
| <a className="header-link" href="/welcome">About</a> | |
| ) : ( | |
| <button | |
| className={"header-link" + (showWelcome ? " current" : "")} | |
| onClick={() => setShowWelcome(true)} | |
| > | |
| About | |
| </button> | |
| )} | |
| <a className="header-link" href="/guide">Guide</a> | |
| {/* /testing (the click-through testing guide) and /product are both | |
| lab-internal; the server enforces the gate, the header just | |
| doesn't advertise them to everyone else. */} | |
| {["lab", "maintainer", "pi"].includes(auth?.role) && ( | |
| <> | |
| <a className="header-link" href="/testing">Testing</a> | |
| <a className="header-link" href="/product">How it works</a> | |
| </> | |
| )} | |
| {auth?.signed_in ? ( | |
| <> | |
| <span className="small">Hi, {auth.name}</span> | |
| {auth.is_admin && !IS_ADMIN_PATH && ( | |
| <a className="header-btn" href="/admin">Admin</a> | |
| )} | |
| <button className="header-btn" onClick={handleLogout}> | |
| Sign out | |
| </button> | |
| </> | |
| ) : ( | |
| <button className="header-btn" onClick={() => setShowLogin(true)}> | |
| Sign in | |
| </button> | |
| )} | |
| </nav> | |
| </header> | |
| {showLogin && ( | |
| <div className="modal-backdrop" onClick={() => setShowLogin(false)}> | |
| <div className="modal" onClick={(e) => e.stopPropagation()}> | |
| <h3>{authMode === "register" ? "Create an account" : "Sign in"}</h3> | |
| {inviteToken && authMode === "register" && ( | |
| <p className="hint" style={{ fontWeight: 600 }}> | |
| 🎟 You've been invited{inviteRole ? ` as a ${inviteRole}` : ""} - create | |
| your account below and the access comes with it. | |
| </p> | |
| )} | |
| <p className="hint"> | |
| 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. | |
| </p> | |
| {authError && <p className="small" style={{ color: "var(--danger, #b3261e)" }}>{authError}</p>} | |
| {auth?.google_available && ( | |
| <> | |
| <a className="primary google-btn" href="/api/auth/google/login"> | |
| Continue with Google | |
| </a> | |
| <p className="small muted" style={{ textAlign: "center", margin: "8px 0" }}> | |
| or use email and password | |
| </p> | |
| </> | |
| )} | |
| <form onSubmit={handleAuthSubmit} className="mt"> | |
| {authMode === "register" && ( | |
| <label className="field"> | |
| Name | |
| <input | |
| type="text" | |
| autoFocus | |
| value={authName} | |
| onChange={(e) => setAuthName(e.target.value)} | |
| placeholder="e.g. Mohammad" | |
| /> | |
| </label> | |
| )} | |
| <label className="field"> | |
| <input | |
| type="email" | |
| autoFocus={authMode === "signin"} | |
| value={authEmail} | |
| onChange={(e) => setAuthEmail(e.target.value)} | |
| placeholder="you@example.com" | |
| /> | |
| </label> | |
| <label className="field"> | |
| Password | |
| {authMode === "register" && ( | |
| <span className="field-hint"> at least 8 characters</span> | |
| )} | |
| <input | |
| type="password" | |
| value={authPassword} | |
| onChange={(e) => setAuthPassword(e.target.value)} | |
| /> | |
| </label> | |
| <div className="row"> | |
| <button | |
| className="primary" | |
| type="submit" | |
| disabled={ | |
| authBusy || | |
| !authEmail.trim() || | |
| !authPassword || | |
| (authMode === "register" && !authName.trim()) | |
| } | |
| > | |
| {authBusy ? "…" : authMode === "register" ? "Create account" : "Sign in"} | |
| </button> | |
| <button className="ghost" type="button" onClick={() => setShowLogin(false)}> | |
| Cancel | |
| </button> | |
| </div> | |
| </form> | |
| <p className="small muted mt"> | |
| {authMode === "register" ? ( | |
| <> | |
| Already have an account?{" "} | |
| <button className="linkish" onClick={() => { setAuthMode("signin"); setAuthError(""); }}> | |
| Sign in | |
| </button> | |
| </> | |
| ) : ( | |
| <> | |
| New here?{" "} | |
| <button className="linkish" onClick={() => { setAuthMode("register"); setAuthError(""); }}> | |
| Create a free account | |
| </button> | |
| </> | |
| )} | |
| {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."} | |
| </p> | |
| </div> | |
| </div> | |
| )} | |
| {IS_ADMIN_PATH ? ( | |
| <main className="main" style={{ maxWidth: 1100, margin: "0 auto", padding: "0 1rem" }}> | |
| <AdminPage auth={auth} /> | |
| </main> | |
| ) : showWelcome ? ( | |
| <main className="main" style={{ maxWidth: 920, margin: "0 auto", padding: "0 1rem" }}> | |
| <WelcomePage onEnter={enterDashboard} /> | |
| </main> | |
| ) : ( | |
| <div className="layout"> | |
| <aside className="sidebar"> | |
| <h2> | |
| Projects | |
| {projects.length > 0 && <span className="count">{projects.length}</span>} | |
| </h2> | |
| <input | |
| type="text" | |
| className="sidebar-filter" | |
| placeholder="Search projects..." | |
| value={filter} | |
| onChange={(e) => setFilter(e.target.value)} | |
| /> | |
| <div className="project-list"> | |
| {groupProjects(visibleProjects).map(([groupLabel, items]) => ( | |
| <div key={groupLabel}> | |
| <div className="group-label">{groupLabel}</div> | |
| {items.map((p) => ( | |
| <button | |
| key={p.id} | |
| className={"project-item" + (p.id === selectedId ? " active" : "")} | |
| onClick={() => setSelectedId(p.id)} | |
| title={p.name} | |
| > | |
| <span className="project-name">{p.name}</span> | |
| <span className="date"> | |
| {p.n_runs > 0 ? `${p.n_runs} run${p.n_runs === 1 ? "" : "s"} · ` : ""} | |
| {relativeTime(p.last_activity_at || p.created_at)} | |
| </span> | |
| </button> | |
| ))} | |
| </div> | |
| ))} | |
| {filter && visibleProjects.length === 0 && ( | |
| <p className="small muted">No projects match "{filter}".</p> | |
| )} | |
| </div> | |
| <div className="project-create"> | |
| {creating ? ( | |
| <form onSubmit={createProject}> | |
| <input | |
| type="text" | |
| autoFocus | |
| placeholder="Project name" | |
| value={newName} | |
| onChange={(e) => setNewName(e.target.value)} | |
| /> | |
| <div className="row mt"> | |
| <button className="primary" type="submit"> | |
| Create | |
| </button> | |
| <button className="ghost" type="button" onClick={() => setCreating(false)}> | |
| Cancel | |
| </button> | |
| </div> | |
| </form> | |
| ) : ( | |
| <button className="ghost" onClick={() => setCreating(true)}> | |
| + New project | |
| </button> | |
| )} | |
| </div> | |
| </aside> | |
| <main className="main"> | |
| {error && ( | |
| <div className="error-banner" onClick={() => setError("")}> | |
| {error} | |
| </div> | |
| )} | |
| {selected ? ( | |
| <Workspace | |
| key={selected.id} | |
| project={selected} | |
| auth={auth} | |
| onAuthRefresh={loadAuth} | |
| onProjectChanged={loadProjects} | |
| onProjectDeleted={() => { | |
| setSelectedId(null); | |
| loadProjects(); | |
| }} | |
| /> | |
| ) : ( | |
| <div className="card empty-state"> | |
| <h3>Create your first project</h3> | |
| <p className="hint"> | |
| 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. | |
| </p> | |
| <div className="empty-actions"> | |
| <button className="primary" onClick={() => setCreating(true)}> | |
| + New project | |
| </button> | |
| <a className="ghost-link" href="/guide">Read the 5-minute guide</a> | |
| </div> | |
| <p className="small muted"> | |
| 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. | |
| </p> | |
| </div> | |
| )} | |
| </main> | |
| </div> | |
| )} | |
| {/* 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 && ( | |
| <footer className="site-footer"> | |
| <p> | |
| <strong>CCR Platform</strong> - Contextualized Construct | |
| Representation analysis for psychology research, operated by the{" "} | |
| <a href="https://websites.umass.edu/matari/" target="_blank" rel="noopener noreferrer"> | |
| Culture and Morality Lab | |
| </a>{" "} | |
| at the University of Massachusetts Amherst. Principal investigator: | |
| Dr. Mohammad Atari. | |
| </p> | |
| <p> | |
| Contact <a href="mailto:devaanand@umass.edu">devaanand@umass.edu</a> | |
| {" · "} | |
| <a | |
| href="https://github.com/Culture-and-Morality-Lab/ccr-platform" | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| > | |
| Source code | |
| </a> | |
| {" · "} | |
| <a href="/guide">How this tool works</a> | |
| </p> | |
| </footer> | |
| )} | |
| </div> | |
| ); | |
| } | |