canvas

PMSoc LinkedIn Feed Widget

A static HTML page that mimics a LinkedIn company feed for the Sydney University Project Management Society (PMSoc), styled to the SoPM x PMSoc BBQ flyer palette. Built as a static snapshot (not a live LinkedIn embed) so it can be embedded via <iframe> in Canvas (Instructure) without CSP/framing issues.

Repo layout

PMSoc-LinkedIn.html      <- generated output, do not hand-edit
build/
  posts.json             <- the 5 posts shown, newest first
  canvas_template.html   <- page shell/CSS/JS (hand-edit this for style changes)
  build_canvas_html.py   <- reads posts.json + template -> writes PMSoc-LinkedIn.html
  assets/                <- base64 data-URI text files (post photos, logos, font)

How dates work

Each post in posts.json has a real date_iso (e.g. "2026-09-05"), not a frozen “2 days ago” string. The page computes “X days/months/years ago” in JavaScript every time it’s opened, so it never goes stale — you only need to rebuild when the actual list of posts changes, not on a schedule for dates alone.

Adding a new post (manual or automated)

  1. Grab the new post’s permalink URL, headline/caption text, and photo from PMSoc’s LinkedIn company page (https://www.linkedin.com/company/sydney-university-project-management-society/) — only ever use posts marked public (“Visible to anyone on or off LinkedIn”).
  2. Save the photo as a base64 data URI text file under build/assets/ (resize to ~480px wide, JPEG quality ~78, to keep the page small — a data URI string starting data:image/jpeg;base64,..., no line breaks).
  3. Add a new object to the front of the array in build/posts.json:
    {
      "date_iso": "YYYY-MM-DD",
      "title": "...",
      "text": "...",
      "img_asset": "postN_datauri.txt",
      "url": "https://www.linkedin.com/feed/update/urn:li:activity:.../"
    }
    
  4. Drop the oldest post off the end of the list if there are now more than 5, and delete its now-unused asset file from build/assets/.
  5. From the repo root, run:
    python3 build/build_canvas_html.py
    
  6. Commit and push PMSoc-LinkedIn.html, build/posts.json, and any new/removed files under build/assets/.

GitHub Pages setup (one-time, manual)

GitHub Pages can’t be turned on via this repo’s automation token (it doesn’t have the pages permission). A repo admin needs to enable it once: Settings → Pages → Build and deployment → Source: “Deploy from a branch” → Branch: main / (root) → Save. After that, every push to main updates the live Pages URL automatically — no further manual steps.

Embedding in Canvas

In the Canvas Rich Content Editor, switch to the HTML view and add:

<iframe src="https://usydsopm.github.io/canvas/PMSoc-LinkedIn.html"
        style="width:100%;height:1400px;border:0;"></iframe>

Adjust height to taste. Check with USYD’s Canvas/LMS admin team first that custom iframe embeds from github.io are permitted (some Canvas instances restrict embeddable domains to an allowlist).


PMSoc Instagram Feed Widget

A static HTML page styled to look like the real pmsoc.usyd Instagram profile: a profile header (avatar, Follow/Message buttons, post/follower/ following counts, bio), a Posts/Reels/Tagged tab row, and a 3-column post grid on the left mirroring the real grid order — the profile’s 3 currently pinned posts first, then its 9 most recent posts. Instagram lets an account pin up to 3 posts to the top of its grid regardless of date, so those 3 are kept separate from “most recent” and can be much older (the current pinned set includes one from January). Colours match the authentic Instagram look (white background, black/grey text, Instagram blue) rather than the SoPM/PMSoc teal branding used on the LinkedIn widget above. Built as a static snapshot (not a live Instagram embed), so it can be embedded via <iframe> in Canvas (Instructure) without CSP/framing issues.

To the right of the grid is a detail panel showing whichever post was last clicked, bigger, with its full caption — clicking any of the 9 grid tiles swaps the panel’s photo/caption/date in place (via JavaScript) rather than navigating away, so students can browse without leaving Canvas. A plain click only ever updates the panel; middle-click or cmd/ctrl-click on a tile still opens the real Instagram post in a new tab, since each tile stays a real <a href> underneath. Every action icon (like/comment/send/save) and the “View post on Instagram” link in the detail panel, and the Follow/ Message buttons and avatar/username in the header, always link out to the real pmsoc.usyd profile or that specific post — nothing in the header or detail panel is decorative-only.

Repo layout

PMSoc-Instagram.html        <- generated output, do not hand-edit
build/
  ig_posts.json             <- the 9 posts shown (3 pinned + 6 recent), in grid order
  ig_profile.json           <- profile header data (bio, follower/following/post counts, link)
  canvas_template_ig.html   <- page shell/CSS/JS (hand-edit this for style changes)
  build_canvas_html_ig.py   <- reads ig_posts.json + ig_profile.json + template -> writes PMSoc-Instagram.html
  assets/                   <- base64 data-URI text files (post photos, and for
                                reels their actual video footage; reuses
                                logo_datauri.txt from the LinkedIn widget's asset set
                                as the profile avatar)

How dates work

Same approach as the LinkedIn widget: each post in ig_posts.json has a real date_iso (e.g. "2026-09-05"), and the detail panel computes “X days/ months/years ago” in JavaScript every time the page is opened, so it never goes stale. A pinned post’s date is still its real post date (not “today”) — pinning only affects its position in the grid, matching how Instagram itself treats pinned posts.

Adding/updating posts

ig_posts.json is an array of exactly 9 posts (a 3x3 grid), in the same order the real grid shows them: the profile’s currently pinned posts first (in their pinned order), then its 6 most recent posts after them, newest first.

  1. Open https://www.instagram.com/pmsoc.usyd/ and read the grid in order. The first up-to-3 tiles with a small pin icon (visible in an authenticated browser session) are the pinned posts; note them and their order. Then list the next 6 tiles after those — that’s the “most recent” set. Use judgment on what counts as a normal public grid post/reel: skip Story/ Highlight-only content, anything private or restricted, and reposts that add no original PMSoc content.
  2. For each of the 9, open its permalink and grab: the exact date (Instagram shows the real date on hover/title over its relative “X days ago” string — use that, not the relative string), the full caption text, and its main photo (for a reel, a representative video frame).
  3. Save each photo as a base64 data URI text file under build/assets/ (resize to ~480px wide, JPEG quality ~78, to keep the page small — a data URI string starting data:image/jpeg;base64,..., no line breaks).
  4. Build the array in build/ig_posts.json, one object per post:
    {
      "date_iso": "YYYY-MM-DD",
      "caption": "...",
      "img_asset": "postN_ig_datauri.txt",
      "url": "https://www.instagram.com/p/.../",
      "type": "post",
      "pinned": false
    }
    

    Set "type" to "reel" for a reel (small play-icon badge in the grid), "carousel" for a multi-photo/video post (small stacked-squares badge), or "post" (or omit it) for a normal single-photo post. Set "pinned" to true for the profile’s currently pinned posts (adds a small pin badge in the grid) — there should be exactly 3 of these, first in the array. For a reel, also add "video_asset" so it actually plays in the widget instead of showing a static thumbnail — see “Making a reel’s video play inline” below.

  5. From the repo root, run:
    python3 build/build_canvas_html_ig.py
    
  6. Commit and push PMSoc-Instagram.html, build/ig_posts.json, and any new/removed files under build/assets/.

Making a reel’s video play inline

A reel’s grid tile can play its actual video in the detail panel (with normal browser controls — play/pause, scrubbing, volume, fullscreen) instead of just showing a static thumbnail and linking out. This works by embedding the video itself as a base64 data URI, the same technique used for photos, so the page stays fully self-contained and works offline/inside Canvas.

  1. Capture the reel’s video from an authenticated Instagram browser session (Instagram serves reel video through a blob: URL backed by MediaSource Extensions, which can’t be fetch()-ed directly — the reliable way to get a real file out of it is to re-record the decoded playback):
    • Open the reel’s permalink and let the <video> element load.
    • In the page console, grab the video element, call video.captureStream() to get a live MediaStream, and feed it to a MediaRecorder (video/webm;codecs=vp9,opus if supported) with recorder.start().
    • Click the reel’s own play button (a real user gesture is more reliable here than calling video.play() from a script, which Instagram’s own player can interrupt with a competing pause()).
    • Let it play through to the end, then read back the recorded chunks as a Blob, convert it to base64 (FileReader.readAsDataURL), and save it as data:video/webm;base64,....
    • Re-mux the result once with ffmpeg -i in.webm -c copy out.webm — the browser’s own recorded file otherwise carries a slightly wrong/rounded duration in its container header (the video content itself is fine).
  2. Save that data URI as a new text file under build/assets/ (e.g. post9_video_datauri.txt), matching the pattern used for photos.
  3. Add "video_asset": "post9_video_datauri.txt" to that post’s entry in build/ig_posts.json.
  4. Re-run python3 build/build_canvas_html_ig.py and commit the new build/assets/*.txt file along with the usual files.

Size tradeoff: embedding real video is much heavier than a photo — a ~10-15 second reel typically adds a few MB to PMSoc-Instagram.html (and to the git repo). This is a deliberate tradeoff for a widget that has to be a single static file with no external video host; keep an eye on overall page size if more than a couple of reels get this treatment, and prefer trimming recording length/quality over skipping the re-mux step if a file feels too large.

Updating the profile header

build/ig_profile.json holds everything in the header above the grid: display_name, handle, bio (use \n for line breaks — rendered with white-space: pre-line), link_text / link_url (the bio link), posts_count / followers_count / following_count, and profile_url / reels_url / tagged_url (used by the avatar, username, Follow/Message buttons, and the Posts/Reels/Tagged tabs). mention_handle / mention_url control the one @handle in the bio that gets turned into a link (e.g. @sydney_uni). Update these values periodically to keep the follower/following/post counts roughly current, then re-run python3 build/build_canvas_html_ig.py.

Embedding in Canvas

In the Canvas Rich Content Editor, switch to the HTML view and add:

<iframe src="https://usydsopm.github.io/canvas/PMSoc-Instagram.html"
        style="width:100%;height:1080px;border:0;"></iframe>

Adjust height to taste (the page lays out the grid and detail panel side by side above ~760px wide and stacks them on narrower screens/iframes (with a horizontal divider instead of the vertical one), so a narrower iframe will need extra height). Check with USYD’s Canvas/LMS admin team first that custom iframe embeds from github.io are permitted (some Canvas instances restrict embeddable domains to an allowlist).

PMSoc Instagram Feed Widget (scrollable variant)

Same pmsoc.usyd profile header + bio as the grid widget above, but instead of a 3x3 grid + side detail panel, every post is rendered as a full-size card (photo or playable video, action icons, date, full caption, “View on Instagram” link) stacked vertically so the whole thing scrolls like a real Instagram feed. No pinned-post special-casing here — pinning only affects the real grid’s tile order, so this view is purely chronological, newest first.

Shares its data source with the grid widget: build/ig_posts.json and build/ig_profile.json. Update posts/profile the same way described in the “PMSoc Instagram Feed Widget” section above, then re-run both build scripts (build_canvas_html_ig.py and build_canvas_html_ig_feed.py) so the grid and feed widgets stay in sync.

Repo layout

PMSoc-Instagram-Feed.html     <- generated output, do not hand-edit
build/
  ig_posts.json               <- same 9 posts as the grid widget (shared)
  ig_profile.json             <- same profile header data (shared)
  canvas_template_ig_feed.html<- page shell/CSS/JS for this variant
  build_canvas_html_ig_feed.py<- reads ig_posts.json + ig_profile.json + template -> writes PMSoc-Instagram-Feed.html
  assets/                     <- shared with the grid widget (same post photos/video/logo)

Rebuilding

From the repo root:

python3 build/build_canvas_html_ig_feed.py

Commit PMSoc-Instagram-Feed.html alongside whatever ig_posts.json / ig_profile.json / build/assets/ changes prompted the rebuild (the same commit that updates the grid widget can cover this one too).

Embedding in Canvas

<iframe src="https://usydsopm.github.io/canvas/PMSoc-Instagram-Feed.html"
        style="width:100%;height:1400px;border:0;"></iframe>

This widget is a single column (max-width 470px, centered) rather than a two-column layout, so there’s no narrow-screen breakpoint to worry about — just adjust height to however many posts you want visible before the iframe itself needs scrolling (the page also scrolls internally, so a shorter iframe still works, it just shows less at once).