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 (
{ // 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. */} CCR Platform Psychological Text Analysis with Contextualized Construct Representation
{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 ? (
) : (
{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 && ( )}
); }