Session log — medistage — Recent Events banner and Past Events page
Session log — medistage — Recent Events banner and Past Events page
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-bannermarkup 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-eventsdeployed at/var/www/medistage/user-content/plugins/ml-past-events/. - Class
ML_Past_Events(extendsPM_Plugin). UsedML_vendor prefix per the platform rules. - Storage: events live as JSON in
pm_options.ML_Past_Eventsunder aneventsarray. 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()andcss_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. Usecss_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 theml-past-eventssubfolder 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_eventround-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_accessand rejects otherwise. No CSRF token yet, the auth check is the boundary for Phase 2.
Storage
- Same
pm_options.ML_Past_EventsJSON 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 asmax(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 withcustomHTML blocks for header, form, and table, plusgroupwrappers, matching the EP Redirects pattern.fetch()dispatches on$_POST['action']: save_event, delete_event, get_event, upload_photo. All return JSON anddie().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-speakersv1.0.0 deployed atuser-content/plugins/ml-speakers/. ClassML_Speakers extends PM_Plugin. Same admin pattern asml-past-events: settings() renders header + form + table, fetch() dispatches save_speaker / delete_speaker / get_speaker / upload_photo. - Speaker storage: JSON in
pm_options.ML_Speakerswith{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-eventsbumped to v1.2.0. Event admin form gets a Speaker dropdown above the now-relabelled "fallback" free-text speaker fields. Event data model gets aspeaker_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/<slug> 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_Speakersrewritten 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 viapm_contentparent/child rows (PageMotor resolves nested slugs throughget_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_SpeakersandML_Past_Eventsonspeaker_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_Speakersmirroring the Past Events admin pattern. - Auto-create/update/delete a
pm_contentrow 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_idon 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.