Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,9 @@ agents/
.mcp.json
.kiro/
CLAUDE.md

# Vendored copy of the legacy Studio Drupal install
studio_main/

# Output of server/migrations/StudioSurvey.ts
migration-reports/
22 changes: 20 additions & 2 deletions client/src/Commons.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Suspense, useEffect, useState } from "react";
import { useLocation, Switch, Route, useHistory } from "react-router-dom";
import { useLocation, Switch, Route, Redirect, useHistory } from "react-router-dom";
import CommonsActivity from "./screens/commons/Activity";
import CommonsAuthor from "./screens/commons/Author";
import CommonsBook from "./screens/commons/Book";
import CommonsCatalog from "./components/commons/CommonsCatalog";
import CommonsCollection from "./screens/commons/Collection";
import Footer from "./components/navigation/Footer";
import CommonsHomework from "./components/commons/CommonsHomework";
import CommonsInteractives from "./screens/commons/Interactives";
import CommonsJumbotron from "./components/commons/CommonsJumbotron";
import CommonsMenu from "./components/commons/CommonsMenu";
import Navbar from "./components/navigation/Navbar";
Expand Down Expand Up @@ -65,7 +67,10 @@ const Commons = () => {
}

const currentPath = location.pathname;
if (currentPath.includes("/collection")) {
// Checked before "/collection", which "/interactives-collections" also contains.
if (currentPath.startsWith("/interactive")) {
setActiveItem("interactives");
} else if (currentPath.includes("/collection")) {
setActiveItem("collections");
} else if (currentPath.includes("/homework")) {
setActiveItem("homework");
Expand Down Expand Up @@ -102,7 +107,20 @@ const Commons = () => {
key="homework"
component={CommonsHomework}
/>,
<Route
exact
path="/interactives"
key="interactives"
component={CommonsInteractives}
/>,
]}
<Route exact path="/interactive/:activityID" component={CommonsActivity} />
{/* The page's first path, kept so links shared from staging still open. */}
<Route
exact
path="/activity/:activityID"
render={({ match }) => <Redirect to={`/interactive/${match.params.activityID}`} />}
/>
<Route exact path="/author/:id" component={CommonsAuthor} />
<Route exact path="/book/:id" component={CommonsBook} />
<Route exact path="/commons-project/:id" component={CommonsProject} />
Expand Down
5 changes: 5 additions & 0 deletions client/src/Conductor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ const ProjectsCompleted = lazyWithRetry(() => import('./screens/conductor/Projec
const ProjectsFlagged = lazyWithRetry(() => import('./screens/conductor/Projects/ProjectsFlagged'));
import ProjectTimeline from './components/projects/ProjectTimeline';
const MyTasks = lazyWithRetry(() => import('./screens/conductor/Tasks'));
const MyH5P = lazyWithRetry(() => import('./screens/conductor/H5P'));
const EditH5P = lazyWithRetry(() => import('./screens/conductor/H5P/Edit'));
import ProjectView from './components/projects/ProjectView';
const Search = lazyWithRetry(() => import('./screens/conductor/Search'));
const ShapeshiftConsole = lazyWithRetry(() => import('./screens/conductor/controlpanel/ShapeshiftConsole'));
Expand Down Expand Up @@ -132,6 +134,9 @@ const Conductor = () => {
<PrivateRoute exact path='/search' component={Search} />
<PrivateRoute exact path='/alerts' component={MyAlerts} />
<PrivateRoute exact path='/tasks' component={MyTasks} />
<PrivateRoute exact path='/h5p' component={MyH5P} />
{/* One route for both, so a new activity's editor is not reloaded when its URL changes after the first save. */}
<PrivateRoute exact path={['/h5p/new', '/h5p/:activityID/edit']} component={EditH5P} />
<PrivateRoute exact path='/projects/(create)?' component={MyProjects} />
<PrivateRoute exact path='/projects/available' component={ProjectsAvailable} />
<PrivateRoute exact path='/projects/completed' component={ProjectsCompleted} />
Expand Down
5 changes: 5 additions & 0 deletions client/src/Platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ const queryClient = new QueryClient({
});

export const COMMONS_PATHS = [
"/activity",
"/interactive",
"/catalog",
"/collections",
"/commons-project",
Expand Down Expand Up @@ -91,6 +93,9 @@ const Platform = () => {

const commonsRouterPaths = [
"/",
"/activity/:activityID",
"/interactive/:activityID",
"/interactives",
"/catalog",
"/catalog/:entryType",
"/collections",
Expand Down
81 changes: 81 additions & 0 deletions client/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import axios, { AxiosRequestConfig } from "axios";
import {
Announcement,
ActivityMetadataInput,
ActivityPerson,
CommonsActivity,
CommonsActivitiesParams,
CommonsActivitySummary,
EditableActivity,
EditorSavedContent,
EditorToken,
MyActivitiesParams,
MyActivitiesResult,
AssetSearchParams,
AssetTagFramework,
AssetTagFrameworkWithCampusDefault,
Expand Down Expand Up @@ -436,6 +446,77 @@ class API {
return res;
}

/**
* Activities listed in the public catalog, for the Interactives page.
*/
async getCommonsActivities(params: CommonsActivitiesParams) {
const res = await axios.get<
{ activities: CommonsActivitySummary[]; total_items: number } & ConductorBaseResponse
>("/commons/activities", { params });
return res;
}

/**
* The public page data for one H5P activity. A 404 means missing, removed
* or not visible to the caller, which the page deliberately treats alike.
*/
async getCommonsActivity(activityID: string) {
const res = await axios.get<
{ activity: CommonsActivity } & ConductorBaseResponse
>(`/commons/activities/${activityID}`);
return res;
}

/**
* The signed-in user's activities for My H5P: owned and collaborating.
*/
async getMyActivities(params: MyActivitiesParams) {
const res = await axios.get<MyActivitiesResult & ConductorBaseResponse>(
"/activities/mine",
{ params }
);
return res;
}

/** An activity with everything its edit screen needs. Owners and collaborators only. */
async getActivityForEditing(activityID: string) {
const res = await axios.get<
{ activity: EditableActivity; role: "owner" | "collaborator"; people: ActivityPerson[] } & ConductorBaseResponse
>(`/activities/${activityID}/authoring`);
return res;
}

async getActivitySubjects() {
const res = await axios.get<{ subjects: string[] } & ConductorBaseResponse>("/activities/subjects");
return res;
}

/** A token for the H5P editor: `create` without an id, `edit` with one. */
async getActivityEditorToken(activityID?: string) {
const res = await axios.post<EditorToken & ConductorBaseResponse>(
activityID ? `/activities/${activityID}/editor-token` : "/activities/editor-token"
);
return res;
}

/** Allocates the H5P content id and creates the activity; returns an edit token for it. */
async createActivity(metadata: ActivityMetadataInput) {
const res = await axios.post<
{ activity: { activityID: string; h5pContentID: number } } & EditorToken & ConductorBaseResponse
>("/activities", metadata);
return res;
}

async updateActivity(activityID: string, update: ActivityMetadataInput & { contentSaved?: EditorSavedContent }) {
const res = await axios.patch<ConductorBaseResponse>(`/activities/${activityID}`, update);
return res;
}

async deleteActivity(activityID: string) {
const res = await axios.delete<ConductorBaseResponse>(`/activities/${activityID}`);
return res;
}

async getAuthor(id: string) {
const res = await axios.get<
{
Expand Down
66 changes: 66 additions & 0 deletions client/src/components/commons/ActivityCardDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Badge } from "@libretexts/davis-react";
import { IconCategory, IconLanguage, IconLicense, IconUser } from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import { ActivityCardFields } from "../../types";
import { getActivityLicense } from "../../utils/activityHelpers";
import { getLanguageName } from "../../utils/languageCodes";

/** Tags beyond this many collapse into a "+N" count. */
const MAX_TAGS = 3;

/**
* Author, subjects, license, language and tags for an activity card, with the
* icons and labels of the public activity page. A field with no value is left
* out, and with none at all nothing renders. The description is not included:
* each card places it under its own title.
*/
const ActivityCardDetails = ({ activity, className }: { activity: ActivityCardFields; className?: string }) => {
const authors = (activity.authors ?? []).filter((a) => a?.trim());
const subjects = (activity.subjects ?? []).filter((s) => s?.trim());
const tags = (activity.tags ?? []).filter((t) => t?.trim());
const license = getActivityLicense(activity.license);
const language = activity.language?.trim() ? getLanguageName(activity.language.trim()) : undefined;

const rows: { key: string; label: string; icon: Icon; value: string }[] = [];
if (authors.length) rows.push({ key: "author", label: authors.length > 1 ? "Authors" : "Author", icon: IconUser, value: authors.join(", ") });
if (subjects.length) rows.push({ key: "subject", label: subjects.length > 1 ? "Subjects" : "Subject", icon: IconCategory, value: subjects.join(", ") });
if (license) rows.push({ key: "license", label: "License", icon: IconLicense, value: license.text });
if (language) rows.push({ key: "language", label: "Language", icon: IconLanguage, value: language });

if (!rows.length && !tags.length) return null;
const hiddenTags = tags.length - MAX_TAGS;
return (
<div className={["flex flex-col gap-2", className].filter(Boolean).join(" ")}>
{rows.length > 0 && (
<dl className="m-0 flex flex-col gap-1 text-sm text-neutral-700">
{rows.map(({ key, label, icon: RowIcon, value }) => (
<div key={key} className="flex min-w-0 items-start gap-2">
<dt className="flex shrink-0 items-center pt-0.5">
<RowIcon size={16} className="text-neutral-500" aria-hidden="true" />
<span className="sr-only">{label}</span>
</dt>
<dd className="m-0 min-w-0 line-clamp-2 break-words">{value}</dd>
</div>
))}
</dl>
)}
{tags.length > 0 && (
<ul className="m-0 flex list-none flex-wrap gap-1 p-0" aria-label="Tags">
{tags.slice(0, MAX_TAGS).map((tag) => (
<li key={tag}>
<Badge label={tag} size="sm" />
</li>
))}
{hiddenTags > 0 && (
<li>
<Badge label={`+${hiddenTags}`} size="sm" />
<span className="sr-only">{` more tag${hiddenTags === 1 ? "" : "s"}`}</span>
</li>
)}
</ul>
)}
</div>
);
};

export default ActivityCardDetails;
1 change: 1 addition & 0 deletions client/src/components/commons/CommonsMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const CommonsMenu = ({ activeItem = "catalog" }: { activeItem?: string }) => {

if (org.orgID === "libretexts") {
opts.push({ key: "homework", text: "Homework" });
opts.push({ key: "interactives", text: "Interactives" });
}

return opts;
Expand Down
107 changes: 63 additions & 44 deletions client/src/components/navigation/Navbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useTypedSelector } from "../../../state/hooks.js";
import NavbarShell from "./NavbarShell.js";
import EnvironmentBanner from "../EnvironmentBanner.js";
import useClientConfig from "../../../hooks/useClientConfig.js";
import useH5PAuthoringEnabled from "../../../hooks/useH5PAuthoringEnabled.js";
import { Button } from "@libretexts/davis-react";
import { NavbarContext } from "../../../types/index.js";
import {
Expand Down Expand Up @@ -51,50 +52,67 @@ const MobileActionRow: React.FC<{ children: React.ReactNode }> = ({ children })
const ConductorNavItems: React.FC<ConductorNavItemsProps> = ({
activeItem,
setActiveItem,
}) => (
<>
<li>
<Link
to="/home"
className={`${menuItemBase} ${activeItem === "home" ? menuItemActive : menuItemInactive}`}
aria-current={activeItem === "home" ? "page" : undefined}
onClick={() => setActiveItem("home")}
>
Home
</Link>
</li>
<li>
<Link
to="/projects"
className={`${menuItemBase} ${activeItem === "projects" ? menuItemActive : menuItemInactive}`}
aria-current={activeItem === "projects" ? "page" : undefined}
onClick={() => setActiveItem("projects")}
>
My Projects
</Link>
</li>
<li>
<Link
to="/tasks"
className={`${menuItemBase} ${activeItem === "tasks" ? menuItemActive : menuItemInactive}`}
aria-current={activeItem === "tasks" ? "page" : undefined}
onClick={() => setActiveItem("tasks")}
>
My Tasks
</Link>
</li>
<li>
<Link
to="/alerts"
className={`${menuItemBase} ${activeItem === "alerts" ? menuItemActive : menuItemInactive}`}
aria-current={activeItem === "alerts" ? "page" : undefined}
onClick={() => setActiveItem("alerts")}
>
My Alerts
</Link>
</li>
</>
);
}) => {
// My H5P stays hidden until authoring opens on this deployment, so
// production shows no empty list before the Studio import.
const h5pAuthoringEnabled = useH5PAuthoringEnabled();
return (
<>
<li>
<Link
to="/home"
className={`${menuItemBase} ${activeItem === "home" ? menuItemActive : menuItemInactive}`}
aria-current={activeItem === "home" ? "page" : undefined}
onClick={() => setActiveItem("home")}
>
Home
</Link>
</li>
<li>
<Link
to="/projects"
className={`${menuItemBase} ${activeItem === "projects" ? menuItemActive : menuItemInactive}`}
aria-current={activeItem === "projects" ? "page" : undefined}
onClick={() => setActiveItem("projects")}
>
My Projects
</Link>
</li>
{h5pAuthoringEnabled && (
<li>
<Link
to="/h5p"
className={`${menuItemBase} ${activeItem === "h5p" ? menuItemActive : menuItemInactive}`}
aria-current={activeItem === "h5p" ? "page" : undefined}
onClick={() => setActiveItem("h5p")}
>
My H5P
</Link>
</li>
)}
<li>
<Link
to="/tasks"
className={`${menuItemBase} ${activeItem === "tasks" ? menuItemActive : menuItemInactive}`}
aria-current={activeItem === "tasks" ? "page" : undefined}
onClick={() => setActiveItem("tasks")}
>
My Tasks
</Link>
</li>
<li>
<Link
to="/alerts"
className={`${menuItemBase} ${activeItem === "alerts" ? menuItemActive : menuItemInactive}`}
aria-current={activeItem === "alerts" ? "page" : undefined}
onClick={() => setActiveItem("alerts")}
>
My Alerts
</Link>
</li>
</>
);
};

/**
* Single navbar orchestrator for all app contexts (conductor, commons, store, support).
Expand Down Expand Up @@ -135,6 +153,7 @@ const Navbar: React.FC<{}> = () => {
const p = location.pathname;
if (p.includes("/home")) setActiveItem("home");
else if (p.includes("/projects")) setActiveItem("projects");
else if (p.startsWith("/h5p")) setActiveItem("h5p");
else if (p.includes("/tasks")) setActiveItem("tasks");
else if (p.includes("/alerts")) setActiveItem("alerts");
else setActiveItem("");
Expand Down
Loading
Loading