Apply ZIP update: 185-didactopus-course-compliance-ui-prototype.zip [2026-03-14T13:20:21]
This commit is contained in:
parent
3fa217c5bc
commit
d7b7c235a5
|
|
@ -6,19 +6,10 @@ build-backend = "setuptools.build_meta"
|
||||||
name = "didactopus"
|
name = "didactopus"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = ["pydantic>=2.7", "pyyaml>=6.0"]
|
||||||
"pydantic>=2.7",
|
|
||||||
"pyyaml>=6.0",
|
|
||||||
"fastapi>=0.115",
|
|
||||||
"uvicorn>=0.30",
|
|
||||||
"sqlalchemy>=2.0",
|
|
||||||
"psycopg[binary]>=3.1",
|
|
||||||
"passlib[bcrypt]>=1.7",
|
|
||||||
"python-jose[cryptography]>=3.3"
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
didactopus-api = "didactopus.api:main"
|
didactopus-compliance-demo = "didactopus.course_ingestion_compliance:main"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
sources:
|
sources:
|
||||||
- source_id: mit-ocw-bayes-demo
|
- source_id: mit-ocw-bayes
|
||||||
title: Example MIT OpenCourseWare Course Page
|
title: Example MIT OpenCourseWare Bayesian Materials
|
||||||
url: https://ocw.mit.edu/courses/example-course/
|
url: https://ocw.mit.edu/courses/example-course/
|
||||||
publisher: Massachusetts Institute of Technology
|
publisher: Massachusetts Institute of Technology
|
||||||
creator: MIT OpenCourseWare
|
creator: MIT OpenCourseWare
|
||||||
|
|
@ -8,16 +8,12 @@ sources:
|
||||||
license_url: https://creativecommons.org/licenses/by-nc-sa/4.0/
|
license_url: https://creativecommons.org/licenses/by-nc-sa/4.0/
|
||||||
retrieved_at: 2026-03-13
|
retrieved_at: 2026-03-13
|
||||||
adapted: true
|
adapted: true
|
||||||
adaptation_notes: >
|
attribution_text: Derived in part from MIT OpenCourseWare material used under CC BY-NC-SA 4.0.
|
||||||
Didactopus extracted topic structure, concepts, and exercise prompts into a derived domain pack.
|
|
||||||
attribution_text: >
|
|
||||||
Derived in part from MIT OpenCourseWare material, used under CC BY-NC-SA 4.0.
|
|
||||||
excluded_from_upstream_license: false
|
excluded_from_upstream_license: false
|
||||||
exclusion_notes: ""
|
exclusion_notes: ""
|
||||||
tags: [mit-ocw, bayes, course]
|
|
||||||
|
|
||||||
- source_id: mit-ocw-third-party-note
|
- source_id: excluded-figure
|
||||||
title: Example Excluded Third-Party Item
|
title: Example Third-Party Figure
|
||||||
url: https://ocw.mit.edu/courses/example-course/pages/lecture-videos/
|
url: https://ocw.mit.edu/courses/example-course/pages/lecture-videos/
|
||||||
publisher: Massachusetts Institute of Technology
|
publisher: Massachusetts Institute of Technology
|
||||||
creator: Third-party rights holder
|
creator: Third-party rights holder
|
||||||
|
|
@ -25,10 +21,6 @@ sources:
|
||||||
license_url: ""
|
license_url: ""
|
||||||
retrieved_at: 2026-03-13
|
retrieved_at: 2026-03-13
|
||||||
adapted: false
|
adapted: false
|
||||||
adaptation_notes: ""
|
attribution_text: Tracked for exclusion; not reused in redistributed pack content.
|
||||||
attribution_text: >
|
|
||||||
Referenced only for exclusion tracking; not reused in redistributed Didactopus artifacts.
|
|
||||||
excluded_from_upstream_license: true
|
excluded_from_upstream_license: true
|
||||||
exclusion_notes: >
|
exclusion_notes: Figure flagged as excluded from the course-level Creative Commons license.
|
||||||
This item was flagged as excluded from the OCW Creative Commons license and should not be redistributed as pack content.
|
|
||||||
tags: [mit-ocw, excluded-content]
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Didactopus Contribution Management Layer</title>
|
<title>Didactopus Learner Prototype</title>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</head>
|
</head>
|
||||||
<body><div id="root"></div></body>
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
{
|
{
|
||||||
"name": "didactopus-contribution-ui",
|
"name": "didactopus-learner-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": { "dev": "vite", "build": "vite build" },
|
"scripts": {
|
||||||
"dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" },
|
"dev": "vite",
|
||||||
"devDependencies": { "vite": "^5.4.0" }
|
"build": "vite build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,243 +1,148 @@
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { login, refresh, fetchPacks, createContribution, fetchAdminPacks, fetchPackValidation, fetchPackProvenance, fetchPackVersions, fetchPackComments, fetchSubmissions, fetchSubmissionDiff, fetchSubmissionGates, fetchReviewTasks, upsertPack, publishPack, governanceAction, addReviewComment } from "./api";
|
import { domains } from "./sampleData";
|
||||||
import { loadAuth, saveAuth, clearAuth } from "./authStore";
|
|
||||||
|
|
||||||
function LoginView({ onAuth }) {
|
function DomainCard({ domain, selected, onSelect }) {
|
||||||
const [username, setUsername] = useState("wesley");
|
|
||||||
const [password, setPassword] = useState("demo-pass");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
async function doLogin() {
|
|
||||||
try {
|
|
||||||
const result = await login(username, password);
|
|
||||||
saveAuth(result);
|
|
||||||
onAuth(result);
|
|
||||||
} catch { setError("Login failed"); }
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="page narrow-page">
|
<button className={`domain-card ${selected ? "selected" : ""}`} onClick={() => onSelect(domain.id)}>
|
||||||
<section className="card narrow">
|
<div className="domain-title">{domain.title}</div>
|
||||||
<h1>Didactopus login</h1>
|
<div className="domain-subtitle">{domain.subtitle}</div>
|
||||||
<label>Username<input value={username} onChange={(e) => setUsername(e.target.value)} /></label>
|
<div className="domain-meta">
|
||||||
<label>Password<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /></label>
|
<span>{domain.level}</span>
|
||||||
<button className="primary" onClick={doLogin}>Login</button>
|
<span>{domain.progress}% progress</span>
|
||||||
{error ? <div className="error">{error}</div> : null}
|
</div>
|
||||||
</section>
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OnboardingPanel({ onboarding }) {
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<h2>First-session onboarding</h2>
|
||||||
|
<h3>{onboarding.headline}</h3>
|
||||||
|
<p>{onboarding.body}</p>
|
||||||
|
<ul>
|
||||||
|
{onboarding.checklist.map((item, idx) => <li key={idx}>{item}</li>)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NextStepCard({ step }) {
|
||||||
|
return (
|
||||||
|
<div className="step-card">
|
||||||
|
<div className="step-header">
|
||||||
|
<div>
|
||||||
|
<h3>{step.title}</h3>
|
||||||
|
<div className="muted">{step.minutes} minutes</div>
|
||||||
|
</div>
|
||||||
|
<div className="reward-pill">{step.reward}</div>
|
||||||
|
</div>
|
||||||
|
<p>{step.reason}</p>
|
||||||
|
<details>
|
||||||
|
<summary>Why this is recommended</summary>
|
||||||
|
<ul>
|
||||||
|
{step.why.map((item, idx) => <li key={idx}>{item}</li>)}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
<button className="primary">Start this step</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavTabs({ tab, setTab, role }) {
|
function MasteryMap({ nodes }) {
|
||||||
return (
|
return (
|
||||||
<div className="tab-row">
|
<section className="card">
|
||||||
<button className={tab==="contribute" ? "active-tab" : ""} onClick={() => setTab("contribute")}>Contribute</button>
|
<h2>Visible mastery map</h2>
|
||||||
{role === "admin" ? <>
|
<div className="map-grid">
|
||||||
<button className={tab==="submissions" ? "active-tab" : ""} onClick={() => setTab("submissions")}>Submissions</button>
|
{nodes.map((node) => (
|
||||||
<button className={tab==="review" ? "active-tab" : ""} onClick={() => setTab("review")}>Governance</button>
|
<div key={node.id} className={`map-node ${node.status}`}>
|
||||||
</> : null}
|
<div className="node-label">{node.label}</div>
|
||||||
</div>
|
<div className="node-status">{node.status}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MilestonePanel({ milestones, rewardLabel, progress }) {
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Milestones and rewards</h2>
|
||||||
|
<div className="progress-wrap">
|
||||||
|
<div className="progress-label">Mastery progress</div>
|
||||||
|
<div className="progress-bar"><div className="progress-fill" style={{ width: `${progress}%` }} /></div>
|
||||||
|
<div className="muted">{progress}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="reward-banner">{rewardLabel}</div>
|
||||||
|
<ul>
|
||||||
|
{milestones.map((m, idx) => <li key={idx}>{m}</li>)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompliancePanel({ compliance }) {
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Source attribution and compliance</h2>
|
||||||
|
<div className="compliance-grid">
|
||||||
|
<div><strong>Sources</strong><br />{compliance.sources}</div>
|
||||||
|
<div><strong>Attribution</strong><br />{compliance.attributionRequired ? "required" : "not required"}</div>
|
||||||
|
<div><strong>Share-alike</strong><br />{compliance.shareAlikeRequired ? "yes" : "no"}</div>
|
||||||
|
<div><strong>Noncommercial</strong><br />{compliance.noncommercialOnly ? "yes" : "no"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flag-row">
|
||||||
|
{compliance.flags.length ? compliance.flags.map((f) => <span className="flag" key={f}>{f}</span>) : <span className="muted">No extra flags</span>}
|
||||||
|
</div>
|
||||||
|
<p className="muted">
|
||||||
|
This panel is here so a learner or curator can inspect provenance-sensitive packs without needing to guess what the reuse constraints are.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [auth, setAuth] = useState(loadAuth());
|
const [selectedDomainId, setSelectedDomainId] = useState(domains[0].id);
|
||||||
const [tab, setTab] = useState("contribute");
|
const domain = useMemo(() => domains.find((d) => d.id === selectedDomainId) || domains[0], [selectedDomainId]);
|
||||||
const [packs, setPacks] = useState([]);
|
|
||||||
const [adminPacks, setAdminPacks] = useState([]);
|
|
||||||
const [selectedPackId, setSelectedPackId] = useState("");
|
|
||||||
const [validation, setValidation] = useState(null);
|
|
||||||
const [provenance, setProvenance] = useState(null);
|
|
||||||
const [versions, setVersions] = useState([]);
|
|
||||||
const [comments, setComments] = useState([]);
|
|
||||||
const [submissions, setSubmissions] = useState([]);
|
|
||||||
const [selectedSubmissionId, setSelectedSubmissionId] = useState(null);
|
|
||||||
const [submissionDiff, setSubmissionDiff] = useState(null);
|
|
||||||
const [submissionGates, setSubmissionGates] = useState(null);
|
|
||||||
const [reviewTasks, setReviewTasks] = useState([]);
|
|
||||||
const [commentText, setCommentText] = useState("Looks structurally plausible.");
|
|
||||||
const [reviewSummary, setReviewSummary] = useState("Reviewed and ready for next stage.");
|
|
||||||
const [message, setMessage] = useState("");
|
|
||||||
const [contribPack, setContribPack] = useState({
|
|
||||||
id: "bayes-pack",
|
|
||||||
title: "Bayesian Reasoning",
|
|
||||||
subtitle: "Contributor revision scaffold",
|
|
||||||
level: "novice-friendly",
|
|
||||||
concepts: [{ id: "prior", title: "Prior", prerequisites: [], masteryDimension: "mastery", exerciseReward: "Prior badge earned" }],
|
|
||||||
onboarding: { headline: "Start here", body: "Begin", checklist: [] },
|
|
||||||
compliance: { sources: 1, attributionRequired: true, shareAlikeRequired: false, noncommercialOnly: false, flags: [] }
|
|
||||||
});
|
|
||||||
|
|
||||||
async function refreshAuthToken() {
|
|
||||||
if (!auth?.refresh_token) return null;
|
|
||||||
try {
|
|
||||||
const result = await refresh(auth.refresh_token);
|
|
||||||
saveAuth(result);
|
|
||||||
setAuth(result);
|
|
||||||
return result;
|
|
||||||
} catch {
|
|
||||||
clearAuth();
|
|
||||||
setAuth(null);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function guarded(fn) {
|
|
||||||
try { return await fn(auth.access_token); }
|
|
||||||
catch {
|
|
||||||
const next = await refreshAuthToken();
|
|
||||||
if (!next) throw new Error("auth failed");
|
|
||||||
return await fn(next.access_token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!auth) return;
|
|
||||||
async function load() {
|
|
||||||
const p = await guarded((token) => fetchPacks(token));
|
|
||||||
setPacks(p);
|
|
||||||
setSelectedPackId((prev) => prev || p[0]?.id || "");
|
|
||||||
if (auth.role === "admin") {
|
|
||||||
setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
|
|
||||||
setSubmissions(await guarded((token) => fetchSubmissions(token)));
|
|
||||||
setReviewTasks(await guarded((token) => fetchReviewTasks(token)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
}, [auth]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!auth?.role || auth.role !== "admin" || !selectedPackId) return;
|
|
||||||
async function loadPackReview() {
|
|
||||||
setValidation(await guarded((token) => fetchPackValidation(token, selectedPackId)));
|
|
||||||
setProvenance(await guarded((token) => fetchPackProvenance(token, selectedPackId)));
|
|
||||||
setVersions(await guarded((token) => fetchPackVersions(token, selectedPackId)));
|
|
||||||
setComments(await guarded((token) => fetchPackComments(token, selectedPackId)));
|
|
||||||
}
|
|
||||||
loadPackReview();
|
|
||||||
}, [auth, selectedPackId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!auth?.role || auth.role !== "admin" || !selectedSubmissionId) return;
|
|
||||||
async function loadSubmission() {
|
|
||||||
setSubmissionDiff(await guarded((token) => fetchSubmissionDiff(token, selectedSubmissionId)));
|
|
||||||
setSubmissionGates(await guarded((token) => fetchSubmissionGates(token, selectedSubmissionId)));
|
|
||||||
}
|
|
||||||
loadSubmission()
|
|
||||||
}, [auth, selectedSubmissionId]);
|
|
||||||
|
|
||||||
async function submitContribution() {
|
|
||||||
const result = await guarded((token) => createContribution(token, { pack: contribPack, submission_summary: "Contributor-submitted revision from UI scaffold" }));
|
|
||||||
setMessage(`Submission created: ${result.submission_id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doGovernance(status) {
|
|
||||||
await guarded((token) => governanceAction(token, selectedPackId, { status, review_summary: reviewSummary }));
|
|
||||||
setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
|
|
||||||
setVersions(await guarded((token) => fetchPackVersions(token, selectedPackId)));
|
|
||||||
setMessage(`Pack moved to ${status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addCommentNow() {
|
|
||||||
const versionNumber = versions[0]?.version_number || 1;
|
|
||||||
await guarded((token) => addReviewComment(token, selectedPackId, versionNumber, { comment_text: commentText, disposition: "comment" }));
|
|
||||||
setComments(await guarded((token) => fetchPackComments(token, selectedPackId)));
|
|
||||||
setMessage("Review comment added");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!auth) return <LoginView onAuth={setAuth} />;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<header className="hero">
|
<header className="hero">
|
||||||
<div>
|
<div>
|
||||||
<h1>Didactopus contribution management layer</h1>
|
<h1>Didactopus learner prototype</h1>
|
||||||
<p>Contributor submissions, diffs, QA/provenance gates, and reviewer task queue scaffolding.</p>
|
<p>
|
||||||
<div className="muted">Signed in as {auth.username} ({auth.role})</div>
|
Pick a topic, get a clear first session, see your mastery map, and understand why the system suggests each next step.
|
||||||
{message ? <div className="message">{message}</div> : null}
|
</p>
|
||||||
</div>
|
|
||||||
<div className="hero-controls">
|
|
||||||
{auth.role === "admin" ? (
|
|
||||||
<label>Pack<select value={selectedPackId} onChange={(e) => setSelectedPackId(e.target.value)}>{adminPacks.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}</select></label>
|
|
||||||
) : (
|
|
||||||
<label>Base pack<select value={contribPack.id} onChange={(e) => setContribPack({ ...contribPack, id: e.target.value })}>{packs.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}</select></label>
|
|
||||||
)}
|
|
||||||
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<NavTabs tab={tab} setTab={setTab} role={auth.role} />
|
<section className="domain-grid">
|
||||||
|
{domains.map((d) => (
|
||||||
|
<DomainCard key={d.id} domain={d} selected={d.id === domain.id} onSelect={setSelectedDomainId} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
{tab === "contribute" && (
|
<main className="layout">
|
||||||
<main className="layout onecol">
|
<div className="left-col">
|
||||||
<section className="card">
|
<OnboardingPanel onboarding={domain.onboarding} />
|
||||||
<h2>Contributor submission</h2>
|
<MasteryMap nodes={domain.masteryMap} />
|
||||||
<label>Pack title<input value={contribPack.title} onChange={(e) => setContribPack({ ...contribPack, title: e.target.value })} /></label>
|
</div>
|
||||||
<label>Subtitle<input value={contribPack.subtitle} onChange={(e) => setContribPack({ ...contribPack, subtitle: e.target.value })} /></label>
|
|
||||||
<label>Onboarding headline<input value={contribPack.onboarding.headline} onChange={(e) => setContribPack({ ...contribPack, onboarding: { ...contribPack.onboarding, headline: e.target.value } })} /></label>
|
|
||||||
<label>Onboarding body<textarea value={contribPack.onboarding.body} onChange={(e) => setContribPack({ ...contribPack, onboarding: { ...contribPack.onboarding, body: e.target.value } })} /></label>
|
|
||||||
<button className="primary" onClick={submitContribution}>Submit contribution</button>
|
|
||||||
<pre className="prebox">{JSON.stringify(contribPack, null, 2)}</pre>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "submissions" && auth.role === "admin" && (
|
<div className="center-col">
|
||||||
<main className="layout twocol">
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2>Submission queue</h2>
|
<h2>What should I do next?</h2>
|
||||||
<table className="table">
|
<div className="steps-stack">
|
||||||
<thead><tr><th>ID</th><th>Pack</th><th>Version</th><th>Status</th><th>Select</th></tr></thead>
|
{domain.nextSteps.map((step) => <NextStepCard key={step.id} step={step} />)}
|
||||||
<tbody>
|
|
||||||
{submissions.map((s) => (
|
|
||||||
<tr key={s.submission_id}>
|
|
||||||
<td>{s.submission_id}</td>
|
|
||||||
<td>{s.pack_id}</td>
|
|
||||||
<td>{s.proposed_version_number}</td>
|
|
||||||
<td>{s.status}</td>
|
|
||||||
<td><button onClick={() => setSelectedSubmissionId(s.submission_id)}>Inspect</button></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<h3>Review tasks</h3>
|
|
||||||
<pre className="prebox">{JSON.stringify(reviewTasks, null, 2)}</pre>
|
|
||||||
</section>
|
|
||||||
<section className="card">
|
|
||||||
<h2>Submission diff and gates</h2>
|
|
||||||
<h3>Diff summary</h3>
|
|
||||||
<pre className="prebox">{JSON.stringify(submissionDiff, null, 2)}</pre>
|
|
||||||
<h3>Gate summary</h3>
|
|
||||||
<pre className="prebox">{JSON.stringify(submissionGates, null, 2)}</pre>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "review" && auth.role === "admin" && (
|
|
||||||
<main className="layout twocol">
|
|
||||||
<section className="card">
|
|
||||||
<h2>Governance and approval gates</h2>
|
|
||||||
<div className="button-row">
|
|
||||||
<button onClick={() => doGovernance("in_review")}>Move to in_review</button>
|
|
||||||
<button onClick={() => doGovernance("approved")}>Approve</button>
|
|
||||||
<button onClick={() => doGovernance("rejected")}>Reject</button>
|
|
||||||
<button onClick={() => publishPack(auth.access_token, selectedPackId, true)}>Publish directly</button>
|
|
||||||
</div>
|
</div>
|
||||||
<label>Review summary<textarea value={reviewSummary} onChange={(e) => setReviewSummary(e.target.value)} /></label>
|
|
||||||
<h3>Validation</h3>
|
|
||||||
<pre className="prebox">{JSON.stringify(validation, null, 2)}</pre>
|
|
||||||
<h3>Provenance</h3>
|
|
||||||
<pre className="prebox">{JSON.stringify(provenance, null, 2)}</pre>
|
|
||||||
</section>
|
</section>
|
||||||
<section className="card">
|
</div>
|
||||||
<h2>Versions and comments</h2>
|
|
||||||
<h3>Versions</h3>
|
<div className="right-col">
|
||||||
<pre className="prebox">{JSON.stringify(versions, null, 2)}</pre>
|
<MilestonePanel milestones={domain.milestones} rewardLabel={domain.rewardLabel} progress={domain.progress} />
|
||||||
<label>Reviewer comment<textarea value={commentText} onChange={(e) => setCommentText(e.target.value)} /></label>
|
<CompliancePanel compliance={domain.compliance} />
|
||||||
<button className="primary" onClick={addCommentNow}>Add comment</button>
|
</div>
|
||||||
<h3>Comments</h3>
|
</main>
|
||||||
<pre className="prebox">{JSON.stringify(comments, null, 2)}</pre>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,5 @@ import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")).render(<App />);
|
createRoot(document.getElementById("root")).render(<App />);
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,177 @@
|
||||||
:root {
|
:root {
|
||||||
--bg:#f6f8fb; --card:#ffffff; --text:#1f2430; --muted:#60697a; --border:#dbe1ea; --accent:#2d6cdf; --soft:#eef4ff;
|
--bg: #f6f8fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--text: #1f2430;
|
||||||
|
--muted: #60697a;
|
||||||
|
--border: #dbe1ea;
|
||||||
|
--accent: #2d6cdf;
|
||||||
|
--soft: #eef4ff;
|
||||||
}
|
}
|
||||||
* { box-sizing:border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); color:var(--text); }
|
body {
|
||||||
.page { max-width:1500px; margin:0 auto; padding:24px; }
|
margin: 0;
|
||||||
.narrow-page { max-width:520px; }
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
.hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; }
|
background: var(--bg);
|
||||||
.hero-controls { min-width:280px; display:flex; flex-direction:column; gap:12px; }
|
color: var(--text);
|
||||||
label { display:block; font-weight:600; }
|
}
|
||||||
input, select, textarea { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
|
.page {
|
||||||
.card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
|
max-width: 1500px;
|
||||||
.narrow { margin-top:60px; }
|
margin: 0 auto;
|
||||||
.tab-row { display:flex; gap:10px; margin:16px 0; flex-wrap:wrap; }
|
padding: 20px;
|
||||||
.tab-row button, button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; }
|
}
|
||||||
.active-tab, .primary { background:var(--accent); color:white; border-color:var(--accent); }
|
.hero {
|
||||||
.layout { display:grid; gap:16px; }
|
background: var(--card);
|
||||||
.twocol { grid-template-columns:1fr 1fr; }
|
border: 1px solid var(--border);
|
||||||
.onecol { grid-template-columns:1fr; }
|
border-radius: 22px;
|
||||||
.muted { color:var(--muted); }
|
padding: 24px;
|
||||||
.error { color:#b42318; margin-top:10px; }
|
}
|
||||||
.message { margin-top:10px; padding:10px 12px; border:1px solid var(--border); background:#fff7dd; border-radius:12px; }
|
.domain-grid {
|
||||||
.table { width:100%; border-collapse:collapse; }
|
display: grid;
|
||||||
.table th, .table td { border-bottom:1px solid var(--border); text-align:left; padding:8px; vertical-align:top; }
|
grid-template-columns: repeat(2, 1fr);
|
||||||
.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:320px; }
|
gap: 16px;
|
||||||
.button-row { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:12px; }
|
margin-top: 16px;
|
||||||
@media (max-width:1100px) {
|
}
|
||||||
.hero { flex-direction:column; }
|
.domain-card {
|
||||||
.twocol { grid-template-columns:1fr; }
|
border: 1px solid var(--border);
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 16px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.domain-card.selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(45,108,223,0.12);
|
||||||
|
}
|
||||||
|
.domain-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.domain-subtitle {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.domain-meta {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.3fr 0.9fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.steps-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.step-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px;
|
||||||
|
background: #fcfdff;
|
||||||
|
}
|
||||||
|
.step-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.reward-pill {
|
||||||
|
background: var(--soft);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.primary {
|
||||||
|
margin-top: 10px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.map-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.map-node {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.map-node.mastered { background: #eef9f0; }
|
||||||
|
.map-node.active { background: #eef4ff; }
|
||||||
|
.map-node.locked { background: #f6f7fa; }
|
||||||
|
.node-label {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.node-status {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.progress-wrap {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e9edf4;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
.reward-banner {
|
||||||
|
background: #fff7dd;
|
||||||
|
border: 1px solid #ecdca2;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.compliance-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.flag-row {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.flag {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #f4f7fc;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.layout { grid-template-columns: 1fr; }
|
||||||
|
.domain-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue