Session log — medistage — Recent Events banner and Past Events page

← All session logs

Session log — medistage — Recent Events banner and Past Events page

25 May 2026 · Hasmukh with Claude · auto-published from the local journal entry. A polished narrative version can be requested in any future Claude session.

Summary

Added a "Recent Events" banner on the medistage homepage, sitting directly below the existing "Upcoming Free Session" banner, with a "Browse past events" button that links to a new placeholder page at /past-events.

Decisions

  • Site: medistage.medilearn.africa (not staging.medilearn.africa — the Upcoming Free Session banner only exists on medistage).
  • Visual treatment: re-used the existing .session-banner markup so the two banners stack as a matched pair, but applied a blue gradient (linear-gradient(135deg, #5dade2, #2980b9)) inline to the Recent Events one so it reads as related but distinct from the green Upcoming banner.
  • Button: visible "Browse past events" using btn btn--secondary (the white-outline-on-coloured-background style already used elsewhere).
  • Past Events page is intentionally a styled placeholder. Content lists what will appear here once the archive is populated, and offers two CTAs ("Get in touch", "Back to home"). Hasmukh confirmed he will provide the actual event records later.
  • The h1 was placed inside the page content (Past Events page does not auto-render a Page_Title h1 in this template).

Changes made

  • pm_content id=1 (home): inserted a new <section class="session-banner"> block right after the existing Next Free Session banner.
  • pm_content: inserted new page record id=8, slug past-events, title "Past events", status live, type page.

Files / artefacts

  • Backup of the pre-change home content: sessions-log/backup-medistage-home-content-20260525-162417.html

Phase 1 of the Past Events admin tool (later in the same session)

Hasmukh asked for a proper admin interface to add past events rather than hand-editing the page. Confirmed approach: new EP-style plugin, photo per event, embedded YouTube/Vimeo player on click, up to about 20 events, no pagination needed.

Plugin built and live on medistage

  • New plugin ml-past-events deployed at /var/www/medistage/user-content/plugins/ml-past-events/.
  • Class ML_Past_Events (extends PM_Plugin). Used ML_ vendor prefix per the platform rules.
  • Storage: events live as JSON in pm_options.ML_Past_Events under an events array. Each event has id, event_date (YYYY-MM-DD), topic, speaker, speciality, summary, photo_url, recording_url, status.
  • Shortcode [past-events] renders the public list, newest first, filtered to status=published.
  • Public assets registered via js_sitewide() and css_sitewide() so the CSS and JS load on every page (file sizes are small, simpler than per-page wiring for now).
  • Settings page is a placeholder note for now. The proper admin list and form come in Phase 2.

Public page

  • /past-events/ content rewritten to use the shortcode between a kept intro section and a kept "Get in touch / Back to home" footer.
  • Three seeded events to show the layout: one with the Setshedi photo and a (dummy) YouTube link to demonstrate the inline player, two text-only placeholders.
  • Year filter pills auto-appear only when more than one year is present (currently all 2026, so no pills yet).
  • Inline embedded YouTube/Vimeo player on the "Watch the recording" button. Closes again on second click. Closing also auto-cleans when the year filter hides a card.

Lessons captured

  • $motor->db->option(...) does NOT exist on this PageMotor version. The correct option-read API is $motor->options->option($class_name). Found by grepping EP Newsletter and EP Redirects. Worth remembering for future plugins.
  • For frontend CSS/JS on plugins, css() / js() are not enough on their own. Use css_sitewide() / js_sitewide() so they load on the page where the shortcode is rendered. Per-page conditional loading is possible but is a future polish.

Phase 2 of the Past Events admin tool (also in the same session)

Hasmukh confirmed Phase 1 looked good and asked to proceed straight to Phase 2.

Admin manager built and live

  • Plugin bumped to v1.1.0. settings() now renders a full in-admin manager instead of the placeholder text.
  • Admin URL: /admin/plugins/ML_Past_Events (also surfaced on the plugin listing at /admin/plugins/).
  • UI: a single page with an Add/Edit form at the top and a sortable table of every event below.
  • Form fields: event date (HTML5 date picker), status (draft / published), topic, speaker name, speciality or role, short summary, recording link (YouTube or Vimeo), and a speaker photo with its own upload zone.
  • Photo upload: own drop-in upload control. Click Upload, file picker opens, file is sent through AJAX to the plugin's fetch() handler which calls $motor->tools->upload->save() with the ml-past-events subfolder and JPG/PNG/WebP/GIF allowlist. The returned URL is stored in a hidden field and rendered as a circular preview.
  • Edit: click Edit on a row, the row's data loads into the form via an AJAX get_event round-trip, and the Save button becomes Update event.
  • Delete: confirmation dialog then AJAX delete_event. Table refreshes from the server response.
  • Validation: date and topic are required client-side and server-side. Status sanitised to draft / published only.
  • Auth: every fetch() request checks $motor->user->access === $motor->users->admin_access and rejects otherwise. No CSRF token yet, the auth check is the boundary for Phase 2.

Storage

  • Same pm_options.ML_Past_Events JSON store as Phase 1.
  • Shape extended to {events: [...], next_id: N}. Each event has a stable id, event_date (YYYY-MM-DD), topic, speaker, speciality, summary, photo_url, recording_url, status.
  • load_option() recomputes next_id as max(max(existing ids) + 1, stored next_id) so it stays correct even if events are edited or deleted directly in the database, and so the seeded ids 1, 2, 3 from Phase 1 cannot be reused by the admin form.

Plugin valet methods used

  • settings() returns POAF with custom HTML blocks for header, form, and table, plus group wrappers, matching the EP Redirects pattern.
  • fetch() dispatches on $_POST['action']: save_event, delete_event, get_event, upload_photo. All return JSON and die().
  • js_sitewide() + css_sitewide() load the public list assets.
  • js_settings() + css_settings() load the admin manager assets only on the plugin settings page.

New / changed files

  • user-content/plugins/ml-past-events/plugin.php (rewritten, v1.1.0)
  • user-content/plugins/ml-past-events/js/ml-past-events-admin.js (new)
  • user-content/plugins/ml-past-events/css/ml-past-events-admin.css (new)

Phase 3 (built immediately after the brief, while Hasmukh was at the gym)

Hasmukh changed his mind and asked me to start and complete Phase 3 autonomously while he exercised. All three sub-phases (3a, 3b, 3c) shipped in one go. Brief from before still applies; what follows is what actually got built.

Phase 3a, 3b, 3c — what is live

  • New plugin ml-speakers v1.0.0 deployed at user-content/plugins/ml-speakers/. Class ML_Speakers extends PM_Plugin. Same admin pattern as ml-past-events: settings() renders header + form + table, fetch() dispatches save_speaker / delete_speaker / get_speaker / upload_photo.
  • Speaker storage: JSON in pm_options.ML_Speakers with {speakers: [...], next_id: N}. Each speaker has id, name, slug (auto from name, editable), role, photo_url, short_bio, long_bio, links: [{label, url}], status.
  • Auto-managed pm_content rows: when a speaker is saved, the plugin upserts a pm_content row under the /speakers parent page with content [speaker id=N]. Status mirrors the speaker status (published → live, draft → draft). Deleting a speaker deletes their page.
  • Parent /speakers page seeded (pm_content id=9, slug='speakers') with the [speakers-index] shortcode and a section heading.
  • Public shortcodes: [speakers-index] renders the grid of published speakers. [speaker id=N] renders the per-speaker landing page with header (large photo, name, role, short bio), long bio (auto-wrapped to paragraphs if plain text, passed through if HTML), Further reading links list, and Past events with this speaker (auto-joined from ML_Past_Events on speaker_id).
  • Two seeded speakers: Prof Mashiko Setshedi (with the existing photo and a real bio) at /speakers/mashiko-setshedi, and a labelled Placeholder at /speakers/placeholder-speaker.
  • Phase 3c — events wired: ml-past-events bumped to v1.2.0. Event admin form gets a Speaker dropdown above the now-relabelled "fallback" free-text speaker fields. Event data model gets a speaker_id. Public event card render uses the linked speaker's photo, name, role and short_bio with a "Read more about [Name]" link to /speakers/&lt;slug&gt; when a speaker is linked; falls back to the free-text fields otherwise. The admin table marks linked vs text-only events.
  • The Setshedi event (id=1) was linked to her speaker record so the wiring can be seen working without Hasmukh touching anything.

Verified end-to-end

All URLs return 200:

  • /, /past-events/, /speakers/ (200 direct)
  • /speakers/mashiko-setshedi, /speakers/placeholder-speaker (200 after trailing-slash redirect)
  • /admin/plugins/ML_Past_Events, /admin/plugins/ML_Speakers (302 to login, as expected when unauthenticated)

Zero PHP errors since 16:00 UTC across the whole Phase 3 deployment.

Spot-checks confirmed:

  • Event card on /past-events/ for the Setshedi event renders her short bio plus "Read more about Prof Mashiko Setshedi" link.
  • Her speaker page at /speakers/mashiko-setshedi renders the full bio in three paragraphs, the Further reading list (UCT department link + placeholder paper link), and a "Past Medilearn events with this speaker" section listing the linked event by date.
  • The /speakers/ index grid shows both seeded speakers as tiles with photo, name, role, short bio, and a "View profile" link.

Files touched in Phase 3

  • user-content/plugins/ml-speakers/plugin.php (new)
  • user-content/plugins/ml-speakers/js/ml-speakers-admin.js (new)
  • user-content/plugins/ml-speakers/css/ml-speakers-admin.css (new)
  • user-content/plugins/ml-speakers/css/ml-speakers.css (new)
  • user-content/plugins/ml-past-events/plugin.php (bumped to v1.2.0)
  • user-content/plugins/ml-past-events/js/ml-past-events-admin.js (speaker dropdown wired)
  • pm_options.plugins (ML_Speakers appended)
  • pm_options.ML_Speakers (seeded)
  • pm_options.ML_Past_Events (event id=1 now has speaker_id=1)
  • pm_content (new rows: id=9 /speakers parent, id=10 mashiko-setshedi, id=11 placeholder-speaker)

Bug fix at the end of the session — broken cross-plugin admin link

After Hasmukh logged in to look at Phase 3, he reported that clicking "Speakers list" (and "ML Speakers" in the description) in the ML Past Events settings page sent him to PageMotor's "Awkward! This is the PageMotor Admin 404 page" instead of the ML Speakers admin.

Root cause

PageMotor does not expose plugin settings as RESTful URLs. The URL bar stays at /admin/plugins/ even when viewing a specific plugin's settings; the switch happens via a POST form submitted by the "Settings" button on the plugin grid (see lib/plugins.php:85 admin() method, which checks $_POST['plugin']). Plain GET requests to /admin/plugins/ML_Speakers are not real routes, so PageMotor's admin 404 fires.

I wrote those cross-plugin links as <a href="/admin/plugins/ML_Speakers"> which never worked.

Fix

In ml-past-events/plugin.php:

  • All three <a> tags pointing at /admin/plugins/ML_Speakers rewritten as <a href="#" class="ml-pe-plugin-link" data-pm-plugin="ML_Speakers">.

In ml-past-events/js/ml-past-events-admin.js:

  • Added a delegated click handler at the top of the IIFE that intercepts .ml-pe-plugin-link[data-pm-plugin] clicks, builds the same POST form PageMotor's own Settings button uses, and submits it to /admin/plugins/.

This mirrors the native UX exactly — clicking the link lands you on the ML Speakers settings, with the URL bar at /admin/plugins/ just like when you arrive by clicking the Settings button on the plugin grid.

Note for future plugin work

Never use /admin/plugins/<Class_Name> as a hyperlink target. If a future plugin needs to point at another plugin's settings, use the .ml-pe-plugin-link pattern (or a similar JS-driven POST-form generator). Worth lifting this into a shared admin helper if a third plugin ever needs it.

Original Phase 3 brief (kept here for reference)

Hasmukh asked, before stepping away to test Phases 1 and 2, for each event to have a speaker short bio with a Read More link to a per-presenter landing page. Discussed architecture, agreed to model speakers as separate entities (one canonical record per speaker, many events can reference one speaker). Decisions captured:

  • URL style: /speakers/<slug> with a /speakers/ index page. Implemented via pm_content parent/child rows (PageMotor resolves nested slugs through get_content_by_slug($slug, $parent_slug), confirmed in lib/page.php).
  • Long bio editor: rich text with basic formatting (paragraphs, headings, links, bold). Use TinyMCE if simple to wire (PageMotor ships it in lib/tinymce), otherwise a textarea that accepts HTML.
  • Auto-built events list on each speaker page: yes, derived by joining ML_Speakers and ML_Past_Events on speaker_id. List shows date, topic, and a link back to the relevant card on /past-events/.
  • Links list per speaker: unbounded ordered list. Each entry is {label, url}. Hasmukh's wording was "as many links as needed per speaker, say other participations, papers they have published, etc". Render as a "Further reading" section on the speaker page. No categories in v1 (Hasmukh orders manually).

Phase 3a (one session): Speakers admin manager

  • New plugin ml-speakers (separate from ml-past-events for clean separation; events reference speakers by id).
  • Storage: pm_options.ML_Speakers = {speakers: [...], next_id: N}. Each speaker has id, name, slug (auto from name, editable), role, photo_url, short_bio, long_bio, links: [{label, url}], status.
  • Admin URL /admin/plugins/ML_Speakers mirroring the Past Events admin pattern.
  • Auto-create/update/delete a pm_content row for each published speaker (parent page = speakers index). This is the public landing page placeholder; the actual rendering comes in 3b via a [speaker] shortcode.

Phase 3b (one session): Public landing pages

  • Ensure the /speakers/ parent page exists with a [speakers-index] shortcode listing all published speakers as a grid (photo, name, role).
  • [speaker] shortcode (or auto-detect from current page slug) renders the per-speaker page: large photo, name, role, long_bio, links list, "Past events with this speaker" list.
  • Style to match the medilearn theme aesthetic.

Phase 3c (one session): Wire events to speakers

  • Add a Speaker dropdown to the Past Events admin form. Selecting one stores speaker_id on the event.
  • Keep the existing free-text speaker and speciality fields as fallback for events with no linked speaker.
  • Update event card rendering on /past-events/: when an event has a speaker_id, pull photo/name/speciality/short_bio from the speaker record and show "Read more about [Name]" linking to /speakers/<slug>.

Open questions for Phase 3 implementation

  • Does PageMotor's pm_content slug uniqueness allow same slug under different parents? check_slug($id, $slug, $parent) includes parent, so yes, the speakers' slugs are scoped under the /speakers parent.
  • Rich text widget: use TinyMCE via PageMotor's bundled config, or settle for a "HTML allowed" textarea. Investigate at start of Phase 3a.

Other follow-ups (separate from Phase 3)

  • Replace the three placeholder events with real medistage past events. Hasmukh can do this himself now through the admin manager.
  • Optional polish: add a CSRF token to the admin AJAX requests for defence in depth. Auth check is sufficient as a first cut, this is just hardening.
  • Optional polish: per-page asset loading. Public CSS and JS are currently sitewide. Could be scoped to only pages where the [past-events] shortcode appears, in exchange for a little more wiring.
  • If the same Upcoming + Recent pattern is wanted on staging.medilearn.africa, recreate both banners there. The medilearn site does not yet have either.
  • Consider adding the Recent Events button to the homepage nav once enough past sessions are listed to make the link worth surfacing.