devaanand's picture
fix(ui): show the site identity footer on the welcome view only
59b268b
Raw
History Blame Contribute Delete
18.7 kB
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">
Email
<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&apos;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>
);
}