From 2d8256c0eb01bdab6b3aac3250f8ab493f9ebf6c Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Sat, 24 Oct 2026 09:30:10 +0700 Subject: [PATCH 01/21] google-sites - add redesign widgets, sheet templates and setup guide Apps Script web app for the live sections of the redesigned Google Site: an opportunities finder (advisor and contact details only for docchula.com sign-ins), upcoming events and the research showcase, all read from Google Sheets and Forms so the team never edits code. Includes Node tests with stand-ins for the Google services, sheet templates, and the setup and page-building guide in google-sites/README.md. --- google-sites/README.md | 233 ++++++++ google-sites/apps-script/App.html | 589 +++++++++++++++++++++ google-sites/apps-script/Code.gs | 200 +++++++ google-sites/apps-script/Config.gs | 48 ++ google-sites/apps-script/Parse.gs | 415 +++++++++++++++ google-sites/apps-script/Styles.html | 191 +++++++ google-sites/apps-script/Widget.html | 19 + google-sites/apps-script/appsscript.json | 13 + google-sites/templates/events.csv | 6 + google-sites/templates/showcase-import.csv | 1 + google-sites/test/.gitignore | 1 + google-sites/test/fixtures.mjs | 70 +++ google-sites/test/harness.mjs | 106 ++++ google-sites/test/parse.test.mjs | 166 ++++++ google-sites/test/preview.mjs | 24 + google-sites/test/widgets.test.mjs | 116 ++++ 16 files changed, 2198 insertions(+) create mode 100644 google-sites/README.md create mode 100644 google-sites/apps-script/App.html create mode 100644 google-sites/apps-script/Code.gs create mode 100644 google-sites/apps-script/Config.gs create mode 100644 google-sites/apps-script/Parse.gs create mode 100644 google-sites/apps-script/Styles.html create mode 100644 google-sites/apps-script/Widget.html create mode 100644 google-sites/apps-script/appsscript.json create mode 100644 google-sites/templates/events.csv create mode 100644 google-sites/templates/showcase-import.csv create mode 100644 google-sites/test/.gitignore create mode 100644 google-sites/test/fixtures.mjs create mode 100644 google-sites/test/harness.mjs create mode 100644 google-sites/test/parse.test.mjs create mode 100644 google-sites/test/preview.mjs create mode 100644 google-sites/test/widgets.test.mjs diff --git a/google-sites/README.md b/google-sites/README.md new file mode 100644 index 0000000..948fcbb --- /dev/null +++ b/google-sites/README.md @@ -0,0 +1,233 @@ +# MDCU Research Division — Google Sites upgrade + +The site stays on **Google Sites**, so anyone on the team can keep editing pages — and adding photos and +posters — the way they do now. What changes: + +- **New look and structure** — one custom theme, five pages (Home, Opportunities, Events, Showcase, About), + with the old site's messages kept: the hero subtitle, the "MDCU Research club" quote, both mission + statements, the "Our impact" lines and every past activity post. +- **Live sections** for the things that go stale. They read Google Sheets and Forms through a small Apps Script + web app (`apps-script/`) embedded in the pages. Edit a row → the site updates within ~5 minutes. + +| Section | Content comes from | Updated by | +| --- | --- | --- | +| Page wording, mission, Executive Board, footer | Google Sites editor | PR & Content team | +| **Activity posts** ("What's new", "Previous events") with photos and posters | Google Sites editor — *Insert › Images* | Activity team | +| Upcoming events (auto-hide after the date) | **Website data** sheet › `Events` tab | Activity team | +| Opportunities finder — titles public, **details only for docchula.com sign-ins** | The existing **Research opportunities** sheet (faculty form) | Faculty, via the form | +| Research Showcase | Showcase form → **Website data** › `Showcase` tab, after a tick in `Approved` | Students submit, team approves | + +--- + +## 1. One-time setup (about an hour, done once) + +### 1.1 Own everything with the club account + +Create the sheet, form and Apps Script projects while signed in as **researchclub@docchula.com** +(or move them into a shared drive). If they belong to a student's personal account, the site breaks when +that student graduates — and the sign-in check in §1.4 only works when the owner is a docchula.com account. + +### 1.2 "Website data" sheet + +1. Create a Google Sheet called **Website data**. +2. Rename the first tab to `Events`, then **File › Import › Upload** `templates/events.csv` → *Replace current sheet*. + Past rows are harmless; only events dated today or later appear on the site. +3. Tidy the columns so nobody can type the wrong thing: + - `Date` → **Format › Number › Date** + - `Type` → **Data › Data validation** › dropdown: `Workshop, Lab tour, Research talk, Meetup, Other` + - `Status` → dropdown: `Open, Full, Closed, Cancelled` + - `Show on site` → **Insert › Checkbox** + - `Poster link` (optional) → a Drive link to the poster, shared as *Anyone with the link — Viewer*. +4. Share the sheet with the team as **Editor**. + +### 1.3 Showcase form + +Create a Google Form **Add my presentation to the MDCU Research Showcase**, restricted to docchula.com. +Use these question titles exactly (the widget finds columns by these words; the Thai part is optional): + +| Question title | Type | +| --- | --- | +| Presenter name / ชื่อผู้นำเสนอ | Short answer | +| Class (e.g. MDCU78) / รุ่น | Short answer | +| Conference name / ชื่องานประชุม | Short answer | +| Short name (e.g. VSS 2026) / ชื่อย่อ | Short answer | +| City, country / เมือง | Short answer | +| Conference date / วันที่นำเสนอ | Date | +| Format / รูปแบบ | Multiple choice: Oral presentation, Poster presentation, Other | +| Research title / ชื่องานวิจัย | Short answer | +| Research field / สาขา | Short answer | +| Keywords / คำสำคัญ | Short answer | +| Advisors / อาจารย์ที่ปรึกษา | Short answer | +| Abstract / บทคัดย่อ | Paragraph | +| Reflection / สิ่งที่ได้เรียนรู้ | Paragraph (optional) | +| Photo / รูป | File upload (optional, image) | +| Consent / ยินยอม | Checkbox, required: "I agree that my name, class, photo and research details are shown on the club website" | + +Then **Responses › Link to Sheets › Select existing spreadsheet › Website data**, rename the new tab to +`Showcase`, and add a last column titled `Approved` filled with checkboxes. Only ticked rows appear on the site. + +To bring over the 31 presentations from the old *Researcher Profile* pages, paste them under the matching +headers (see `templates/showcase-import.csv`) and tick `Approved` for those whose presenters gave consent. + +> Photos and posters from Drive only display if the file is shared as *Anyone with the link — Viewer*. +> Otherwise the widget quietly shows the presenter's initial instead, or leaves the poster out. + +### 1.4 Apps Script web app — two copies + +Opportunity details (advisor, contact, timeline, description, what to send) are only for MDCU students. +The script removes them on the server for anyone who isn't signed in with a **docchula.com** account, so they +never reach a signed-out visitor's browser. Titles, departments, topics and seat counts stay public. + +Google only tells a script who the visitor is in some setups, so deploy the same code twice: + +| Copy | Who has access | Used for | +| --- | --- | --- | +| **Public** | Anyone | Every embed on the site. Details are hidden unless Google reports a docchula.com visitor. | +| **Members** | Anyone within docchula.com | Opened by the "Sign in with docchula.com" button. Google asks for a docchula.com sign-in, then shows everything. | + +For each copy: + +1. → **New project** (*MDCU site widgets — public* / *— members*). +2. **Project Settings** → tick *Show "appsscript.json" manifest file*, then paste `apps-script/appsscript.json`. +3. Create the files and paste their contents: `Parse.gs`, `Config.gs`, `Code.gs` (script files) and + `Widget.html`, `Styles.html`, `App.html` (HTML files — name them without the extension). + *(Or use [clasp](https://github.com/google/clasp): `clasp create --type webapp --rootDir google-sites/apps-script`, then `clasp push`.)* +4. In `Config.gs`, set `WEBSITE_DATA_SHEET_ID` (the long ID in the sheet's URL) and the two form links. +5. **Deploy › New deployment › Web app** — *Execute as:* **Me**, *Who has access:* as in the table. + Approve the permission prompt: the script can **read** spreadsheets and see the visitor's email address + (only to check that it ends in `@docchula.com`; it isn't stored). +6. Copy the web app URL and open it with `?view=opportunities` on the end to check it works. + +Finally, paste the **members** URL into `MEMBERS_APP_URL` in the **public** copy's `Config.gs` and redeploy it. + +If the whole site stays docchula.com-only, one copy is enough: deploy it for *Anyone within docchula.com* and +leave `MEMBERS_APP_URL` blank. + +After a code change, use **Deploy › Manage deployments › Edit › New version** so the URL stays the same. +To show a sheet edit immediately instead of within 5 minutes, run `clearCache` from the editor. + +--- + +## 2. Rebuilding the pages in Google Sites + +Edits stay in draft until someone presses **Publish**. Tell the other editors not to publish until the team +has reviewed the draft, and use **⋮ › Version history** if anything needs rolling back. + +**Nothing from the old site is deleted.** Pages that are replaced are hidden from navigation, their wording is +reused, and page addresses stay the same so old links keep working (rename the *title* only). + +### 2.1 Theme + +**Themes › Custom › +** (start from *Simple*), name it *MDCU Research 2026*, upload the logo, then: + +| Setting | Value | +| --- | --- | +| Main / button colour | `#16305A` (navy) — button text white | +| Accent | `#F4B13A` (amber) | +| Section style *Emphasis 1* | background `#F4F7FA` | +| Section style *Emphasis 2* | background `#0B1630`, text white | +| Title, Heading | IBM Plex Sans Thai, bold, `#16305A` | +| Subheading, Normal text | IBM Plex Sans Thai, `#1B2537` | +| Small text (eyebrows, captions) | IBM Plex Mono | +| Navigation | IBM Plex Sans Thai; light background | + +If a font isn't offered, use *Noto Sans Thai* (both cover Thai and English). + +### 2.2 Pages and navigation + +`Home · Opportunities · Events · Showcase · About` + +- Rename the *titles* **Researcher Profile → Showcase** and **Previous events → Events**. +- Keep *Our Structure* and *Our Impact* as sub-pages of About (or hide them once their content is on About). +- Once the Showcase widget is filled, **hide** the 24 conference pages from navigation — keep them as an archive. + +### 2.3 Page recipes + +Embeds: **Insert › Embed › By URL** → paste the **public** web app URL plus the view below. Embed boxes have a +fixed height in Sites; anything taller scrolls inside the box, so start with the heights given and adjust. + +**Home** +1. Header *Title only*. First section **Emphasis 2**, two columns. + Left — *Small text* "MDCU Research Division", title "Today’s research, / tomorrow’s cure." (second line in + amber `#F4B13A`), *Small text* credit "Inspired by Mayo Clinic’s “Tomorrow’s Cure”", the old subtitle + "A student-led research club affiliated with the Student Union of the Faculty of Medicine, Chulalongkorn + University", button **Find a research project** → Opportunities, embed `?view=stats` (110 px). + Right — embed `?view=open-now` (470 px). +2. **MDCU Research club** — the old quote ("We are the community of medical students…") beside the group photo, + **About us** button. +3. **Our mission** (Emphasis 1) — the two old statements, each with its photo and the old buttons + (**Researcher requirements**, **Previous events**, **Researcher profiles**). +4. **Our impact** — four columns: Research Workshop, Lab tour, Research Talk (old lines) and Opportunities Board. +5. **Events** (Emphasis 1) — left: embed `?view=events-home` (520 px). Right: **What's new** posts — each one an + image (poster) beside a title and a collapsible text group, exactly like the current posts. +6. **Research showcase** — embed `?view=showcase-home` (780 px), button **Explore the showcase**. +7. **Get involved** (Emphasis 2) — three columns with buttons (Opportunities, Instagram, Facebook group). + +**Opportunities** — intro text, a three-step "How it works" row, embed `?view=opportunities` (1100 px), +section **Emphasis 1** "For faculty" with **Post a project** (faculty form) and **Email the club** buttons. + +**Events** — intro text, embed `?view=events` (560 px), then **Previous events**: the existing activity posts +(poster or photos + title + collapsible text), newest first. To add one, duplicate a post section +(**⋮ › Duplicate section**), replace the image (**Insert › Images** or click the image › **Replace image**) and +the text, and drag it to the top. + +**Showcase** — Emphasis 2 header with the old intro "Researcher profiles & project highlights by MDCU's researchers" +and an **Add your presentation** button (showcase form), embed `?view=showcase` (1200 px), and "Earlier highlights" +from the Alumni Hall of Fame page. + +**About** — mission (two columns on Emphasis 2), Executive Board 2026 (board poster or image + name + role grid), +Core Team photo, contact list, Google Maps embed. **Footer** (Settings › Footer): logo, one-line description, +email, Instagram, Facebook, address. + +--- + +## 3. Everyday updates — no code + +| Job | Where | Time | +| --- | --- | --- | +| Announce an event | Website data › Events — add a row (add a poster link if you have one) | ~2 min | +| Event full / closed | Events › `Status` | 10 s | +| Post photos or a recap of an activity | Sites editor › Events — duplicate a post, swap image and text, Publish | ~10 min | +| Publish a presentation | Website data › Showcase › tick `Approved` | 1 min | +| A project is filled | Opportunities sheet › status → `Closed` | 10 s | +| New academic year | Opportunities sheet › duplicate the tab, rename e.g. `2027-2028` (newest is shown automatically) | ~2 min | +| New Executive Board / wording / photos | Sites editor › edit › Publish | 5–15 min | + +Upcoming events drop off the list on their own the day after they happen. A row with a missing title or an +unreadable date is skipped instead of breaking the page. + +--- + +## 4. Content fixes found while auditing the current site + +- **"What's new [Jul 2026]" and "[Jan 2026]"** sit on Home months later; registration links are hidden inside + collapsed text. → The live "Upcoming" box handles announcements; the posts stay as activity posts. +- **Previous events** has two empty image boxes (Lab tour 2 and an empty row) and is missing the Jan 2026 workshop. +- **About** publishes a former secretary's personal phone number and LINE ID. → Use the club email and Instagram. +- **Researcher Profile**: the VSS 2024 page is titled *Asian Pacific Digestive Week 2024*; the ARC 2024 abstract page + has no title or presenter; TERMIS EU 2025 and HIV Drug Therapy Glasgow 2024 are listed but have no page; + MRPC 2024 is dated Feb 2025; one presenter's name is spelled two ways; typos such as "reseach", "Internation", + "systemic review". +- **Opportunities sheet**: the same department is typed several ways (the widget merges them via `DEPT_ALIASES`); + one project summary is garbled Thai pasted from a PDF; academic titles are in mixed order (the widget reorders them). +- **Page headers**: the two logos overlap the page title on every page. +- **Name**: "MDCU Research Division" and "MDCU Research Club" are used interchangeably — pick one. +- **First Meet 2026** venue differs between the poster (2nd floor) and the text (floor M). + +## 5. Decision for the team: who can see the site? + +Today the published site — and `researchclub.docchula.com`, which redirects to it — asks every visitor to sign in +with a **docchula.com** account, so prospective members, faculty using other accounts, partners and search engines +see nothing. With the two-copy setup in §1.4 the site can be published to **Anyone** (Publish › Settings): visitors +see everything except opportunity details, which stay behind the docchula.com sign-in. + +## 6. Developing + +```bash +node --test google-sites/test/*.test.mjs # parser + widget tests (Node 18+) +node google-sites/test/preview.mjs # renders every view to google-sites/test/out/*.html +``` + +`test/harness.mjs` runs the real `.gs` files with stand-ins for SpreadsheetApp, CacheService, HtmlService and +Session (to simulate signed-in and signed-out visitors), using the placeholder data in `test/fixtures.mjs`. +Keep real names and contact details out of the fixtures — this repository is public. diff --git a/google-sites/apps-script/App.html b/google-sites/apps-script/App.html new file mode 100644 index 0000000..77ccea4 --- /dev/null +++ b/google-sites/apps-script/App.html @@ -0,0 +1,589 @@ + diff --git a/google-sites/apps-script/Code.gs b/google-sites/apps-script/Code.gs new file mode 100644 index 0000000..c26846e --- /dev/null +++ b/google-sites/apps-script/Code.gs @@ -0,0 +1,200 @@ +/** + * Web app behind the live sections of the Google Site. Each Sites embed loads + * the deployment URL with ?view=…: + * + * opportunities project finder (Opportunities page) + * open-now three newest projects, dark panel (Home, hero) + * stats headline numbers, dark (Home, hero) + * events-home next event, or an empty state (Home) + * events all upcoming events (Events page) + * showcase-home numbers + three latest talks (Home) + * showcase filterable presentations (Showcase page) + */ +var VIEWS = ['opportunities', 'open-now', 'stats', 'events-home', 'events', 'showcase-home', 'showcase']; + +function doGet(e) { + var params = (e && e.parameter) || {}; + var view = VIEWS.indexOf(params.view) !== -1 ? params.view : 'opportunities'; + var data; + try { + data = loadView_(view, params); + } catch (err) { + console.error('Could not load view "%s": %s', view, err && err.stack); + data = { error: true }; + } + var template = HtmlService.createTemplateFromFile('Widget'); + template.view = view; + template.data = toScriptJson_(data); + template.config = toScriptJson_({ + links: CONFIG.LINKS, + self: ScriptApp.getService().getUrl(), + membersUrl: CONFIG.MEMBERS_APP_URL, + memberDomain: CONFIG.MEMBER_DOMAIN, + fullPage: params.full === '1' + }); + return template.evaluate() + .setTitle('MDCU Research Division') + .addMetaTag('viewport', 'width=device-width, initial-scale=1') + .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); +} + +/** Used by Widget.html to pull in Styles.html and App.html. */ +function include(name) { + return HtmlService.createHtmlOutputFromFile(name).getContent(); +} + +/** Run from the editor to make sheet edits show up immediately instead of within CACHE_SECONDS. */ +function clearCache() { + var cache = CacheService.getScriptCache(); + var today = today_(); + cache.removeAll(['opp:latest', 'events:' + today, 'showcase']); +} + +function loadView_(view, params) { + if (view === 'opportunities' || view === 'open-now') { + var member = isMember_(); + var opp = opportunities_(view === 'opportunities' ? params.year : undefined); + var items = member ? opp.items : Parse.publicOpportunities(opp.items, CONFIG.PUBLIC_OPPORTUNITY_FIELDS); + if (view === 'opportunities') return { year: opp.year, years: opp.years, items: items, viewer: { member: member } }; + return { + year: opp.year, + total: items.length, + items: items.filter(function (i) { return i.kind === 'project'; }).slice(0, 3), + viewer: { member: member } + }; + } + // Past activities live as hand-made posts (with photos) in Google Sites, so only upcoming events are sent. + if (view === 'events') return { upcoming: events_().upcoming }; + if (view === 'events-home') { + var upcoming = events_().upcoming; + return { next: upcoming[0] || null, upcomingCount: upcoming.length }; + } + var items = showcase_(); + var stats = Parse.showcaseStats(items); + if (view === 'showcase') return { stats: stats, items: items }; + if (view === 'showcase-home') return { stats: stats, items: items.slice(0, 3), conferences: conferenceNames_(items) }; + return { openProjects: opportunities_().items.length, stats: stats }; // 'stats' +} + +/** + * True when the visitor is signed in with a MEMBER_DOMAIN account. Google only tells a script + * who the visitor is when the script's owner is in the same Workspace domain, so deploy it from + * the club's docchula.com account. + */ +function isMember_() { + var email = ''; + try { + email = Session.getActiveUser().getEmail() || ''; + } catch (err) { + email = ''; + } + var suffix = '@' + CONFIG.MEMBER_DOMAIN.toLowerCase(); + return email.toLowerCase().slice(-suffix.length) === suffix; +} + +function opportunities_(year) { + return cached_('opp:' + (year || 'latest'), function () { + var ss = SpreadsheetApp.openById(CONFIG.OPPORTUNITIES_SHEET_ID); + var tabs = ss.getSheets() + .map(function (s) { return s.getName(); }) + .filter(function (n) { return /^\d{4}\s*-\s*\d{4}$/.test(n); }) + .sort() + .reverse(); + var name = year && tabs.indexOf(year) !== -1 ? year : tabs[0]; + if (!name) return { year: '', years: [], items: [] }; + var rows = ss.getSheetByName(name).getDataRange().getDisplayValues(); + var items = Parse.parseOpportunities(rows, { deptAliases: CONFIG.DEPT_ALIASES }); + // Defined projects first, then open labs; newest form answers (lowest in the sheet) first. + items.sort(function (a, b) { + if (a.kind !== b.kind) return a.kind === 'project' ? -1 : 1; + return Number(b.id.slice(4)) - Number(a.id.slice(4)); + }); + return { year: name, years: tabs, items: items }; + }); +} + +function events_() { + var today = today_(); + return cached_('events:' + today, function () { + return Parse.parseEvents(readTab_(CONFIG.WEBSITE_DATA_SHEET_ID, CONFIG.EVENTS_TAB), today); + }); +} + +function showcase_() { + return cached_('showcase', function () { + return Parse.parseShowcase(readTab_(CONFIG.WEBSITE_DATA_SHEET_ID, CONFIG.SHOWCASE_TAB)); + }); +} + +function conferenceNames_(items) { + var seen = {}; + return items + .map(function (i) { return Parse.clean(i.conf).replace(/\s*(19|20)\d{2}$/, ''); }) + .filter(function (n) { + var key = n.toLowerCase(); + if (!n || seen[key]) return false; + seen[key] = true; + return true; + }); +} + +/** + * Reads a tab as display strings, except real dates, which become YYYY-MM-DD so + * the parser never has to guess between day/month orders. Time-only cells keep + * their display text. + */ +function readTab_(spreadsheetId, tabName) { + var sheet = SpreadsheetApp.openById(spreadsheetId).getSheetByName(tabName); + if (!sheet) return []; + var range = sheet.getDataRange(); + var raw = range.getValues(); + return range.getDisplayValues().map(function (row, r) { + return row.map(function (text, c) { + var value = raw[r][c]; + if (Object.prototype.toString.call(value) === '[object Date]' && value.getFullYear() > 1900) { + return Utilities.formatDate(value, CONFIG.TIME_ZONE, 'yyyy-MM-dd'); + } + return text; + }); + }); +} + +function today_() { + return Utilities.formatDate(new Date(), CONFIG.TIME_ZONE, 'yyyy-MM-dd'); +} + +// CacheService holds at most 100 KB per key, and Thai text is 3 bytes a character, +// so larger values are stored in pieces. +var CACHE_PIECE = 30000; + +function cached_(key, compute) { + var cache = CacheService.getScriptCache(); + var count = Number(cache.get(key)); + if (count) { + var keys = []; + for (var i = 0; i < count; i++) keys.push(key + ':' + i); + var parts = cache.getAll(keys); + if (keys.every(function (k) { return parts[k] != null; })) { + return JSON.parse(keys.map(function (k) { return parts[k]; }).join('')); + } + } + var value = compute(); + var text = JSON.stringify(value); + var entries = {}; + var pieces = 0; + for (var p = 0; p < text.length; p += CACHE_PIECE) { + entries[key + ':' + pieces] = text.slice(p, p + CACHE_PIECE); + pieces++; + } + entries[key] = String(pieces); + cache.putAll(entries, CONFIG.CACHE_SECONDS); + return value; +} + +/** JSON that is safe to drop inside a + + + diff --git a/google-sites/apps-script/appsscript.json b/google-sites/apps-script/appsscript.json new file mode 100644 index 0000000..3fccc47 --- /dev/null +++ b/google-sites/apps-script/appsscript.json @@ -0,0 +1,13 @@ +{ + "timeZone": "Asia/Bangkok", + "runtimeVersion": "V8", + "exceptionLogging": "STACKDRIVER", + "oauthScopes": [ + "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/userinfo.email" + ], + "webapp": { + "executeAs": "USER_DEPLOYING", + "access": "ANYONE_ANONYMOUS" + } +} diff --git a/google-sites/templates/events.csv b/google-sites/templates/events.csv new file mode 100644 index 0000000..28c24c6 --- /dev/null +++ b/google-sites/templates/events.csv @@ -0,0 +1,6 @@ +Date,Start,End,Type,Title,Venue,Description,Register link,Poster link,Recording links,Seats,Status,Show on site +2026-07-06,16:00,18:30,Meetup,MDCU Research Division First Meet 2026,"Smart Classroom, อาคาร อปร.","What research is and how to start, through a hands-on interactive session — plus talks from four senior researchers and an early look at Core Team recruitment.",,,,,,TRUE +2026-01-16,16:15,20:00,Workshop,How to Design High-Impact Research,"Room 1302, 13th floor, Bhumisiri Building","Lecture on research fundamentals and key ideas, followed by a workshop on the thinking process behind study design.",,,,40,,TRUE +2025-07-06,09:00,12:00,Workshop,Statistics in Medical Research & Basic Software,Online via Zoom,"ResearchNav 2025, Workshop 4. Session 1: statistical fundamentals from principles to practice. Session 2: hands-on with Stata.",,,,,,TRUE +2025-03-19,13:00,15:30,Lab tour,Lab Tour 2: Systems Biology & Cancer Biomarkers,"Faculty of Medicine, Chulalongkorn University",Visits to the Center of Excellence in Systems Biology and the Cancer Biomarker Research Laboratory.,,,,,,TRUE +2025-03-11,16:30,19:30,Workshop,Transforming Medical Research with AI,"Room 1302, 13th floor, Bhumisiri Building","AI tools that help with research, with an interactive session.",,,,40,,TRUE diff --git a/google-sites/templates/showcase-import.csv b/google-sites/templates/showcase-import.csv new file mode 100644 index 0000000..41cc0d6 --- /dev/null +++ b/google-sites/templates/showcase-import.csv @@ -0,0 +1 @@ +Timestamp,Email address,Presenter name / ชื่อผู้นำเสนอ,Class (e.g. MDCU78) / รุ่น,Conference name / ชื่องานประชุม,Short name (e.g. VSS 2026) / ชื่อย่อ,"City, country / เมือง",Conference date / วันที่นำเสนอ,Format / รูปแบบ,Research title / ชื่องานวิจัย,Research field / สาขา,Keywords / คำสำคัญ,Advisors / อาจารย์ที่ปรึกษา,Abstract / บทคัดย่อ,Reflection / สิ่งที่ได้เรียนรู้,Photo / รูป,Consent / ยินยอม,Approved diff --git a/google-sites/test/.gitignore b/google-sites/test/.gitignore new file mode 100644 index 0000000..89f9ac0 --- /dev/null +++ b/google-sites/test/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/google-sites/test/fixtures.mjs b/google-sites/test/fixtures.mjs new file mode 100644 index 0000000..fc9a130 --- /dev/null +++ b/google-sites/test/fixtures.mjs @@ -0,0 +1,70 @@ +// Placeholder sheet contents shaped like the real tabs. No real people or contact details. + +const time = (hhmm) => { + const [h, m] = hhmm.split(':').map(Number); + return { v: new Date(1899, 11, 30, h, m), d: hhmm }; // how Sheets returns time-only cells +}; +const date = (iso) => { + const [y, m, d] = iso.split('-').map(Number); + return { v: new Date(y, m - 1, d), d: `${d}/${m}/${y}` }; // display order is locale-dependent +}; + +const OPP_HEADER = ['', 'ชื่อหัวข้อโครงการวิจัย', 'สเตตัสการเปิดรับสมัคร', 'ชื่อจริง', 'นามสกุล', 'ตำแหน่งทางวิชาการ', + 'สังกัด/ภาควิชา', 'ช่องทาง', 'คอนแทคติดต่อ', 'ชื่อโครงการ (Title)', 'รายละเอียดของโครงการโดยสังเขป', + 'บทบาทหน้าที่ของนิสิตในโครงการวิจัย', 'ทักษะหรือสิ่งที่นิสิตจะได้เรียนรู้', + 'สิ่งที่ต้องการให้นิสิตที่สนใจส่งมาด้วยเพื่อพิจารณาการรับเข้าร่วมโครงการ', 'ระยะเวลาโดยประมาณตั้งแต่เริ่มต้นจนสิ้นสุดโครงการ', + 'โปสเตอร์ประชาสัมพันธ์โครงการวิจัย', 'keywords ที่สำคัญของโครงการ', 'เรื่องอื่น ๆ ที่อาจารย์ต้องการแจ้งให้นิสิตทราบเพิ่มเติม', + 'จำนวนนิสิตที่ต้องการ']; +const opp = (o) => OPP_HEADER.map((_, i) => o[i] ?? ''); + +export const opportunities = { + '2026-2027': [ + ['', 'MDCU Research Club 📂 แบบฟอร์มเปิดรับสมัครนิสิตช่วยทำวิจัย'], + ['', 'หัวข้อการทำวิจัย 🧐', '', 'ข้อมูลของอาจารย์'], + OPP_HEADER, + opp({ 1: 'Deep learning triage for chest radiographs', 2: 'Available', 3: 'ตัวอย่าง', 4: 'หนึ่ง', 5: 'ดร., นพ., ผศ.', + 6: 'รังสีวิทยา', 7: 'Email', 8: 'example.one@example.com', 10: 'Build and validate a model that flags urgent findings on chest X-rays.', + 11: 'Label images, run baseline models, help write the manuscript', 12: 'Python, model evaluation, study design', + 13: 'Curriculum vitae, Letter of intent', 14: 'Jan 2027 – Dec 2027', 16: 'Deep learning, Radiology, Triage', 18: '3' }), + opp({ 1: 'การศึกษาปัจจัยเสี่ยงของภาวะหกล้มในผู้สูงอายุในชุมชน', 2: 'Available', 3: 'ตัวอย่าง', 4: 'สอง', 5: 'อาจารย์, พญ.', + 6: 'เวชศาสตร์ครอบครัว', 7: 'LINE, Email', 8: 'LINE: example-two\nexample.two@example.com', 10: 'สำรวจและวิเคราะห์ปัจจัยเสี่ยงของการหกล้มในผู้สูงอายุ', + 11: 'เก็บข้อมูลภาคสนามและวิเคราะห์ข้อมูลเบื้องต้น', 12: 'การออกแบบแบบสอบถาม การวิเคราะห์ข้อมูล', 14: 'Mar 2027 – Feb 2028', + 16: 'ผู้สูงอายุ, การหกล้ม, เวชศาสตร์ชุมชน', 18: '2' }), + opp({ 1: 'ยังไม่มีโครงการแต่เปิดรับนิสิตที่สนใจ', 2: 'Available', 3: 'ศ.นพ.ตัวอย่าง', 4: 'สาม', 5: 'ศ.', 6: 'ภาควิชาจุลชีววิทยา', + 7: 'Email', 8: 'example.three@example.com', 16: 'Virology, Vaccine immunology' }), + opp({ 1: 'Closed sample project', 2: 'Closed', 3: 'ตัวอย่าง', 4: 'สี่', 5: 'รศ.', 6: 'ศัลยศาสตร์', 7: 'Email' }) + ], + '2025-2026': [OPP_HEADER, opp({ 1: 'Last year sample', 2: 'Available', 3: 'ตัวอย่าง', 4: 'ห้า', 6: 'อายุรศาสตร์', 7: 'Email' })] +}; + +const EVENT_HEADER = ['Date', 'Start', 'End', 'Type', 'Title', 'Venue', 'Description', 'Register link', 'Poster link', 'Recording links', 'Seats', 'Status', 'Show on site']; + +export const events = [ + EVENT_HEADER, + [date('2026-11-20'), time('16:00'), time('18:30'), 'Workshop', 'Sample workshop: writing your first abstract', 'Room 1302, Bhumisiri Building', + 'Hands-on session on structuring an abstract, with feedback in small groups.', 'https://forms.gle/example', + 'https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz012345/view?usp=sharing', '', '40', 'Open', 'TRUE'], + [date('2026-12-05'), time('13:00'), time('15:30'), 'Lab tour', 'Sample lab tour', 'Faculty of Medicine', '', 'https://forms.gle/example2', '', '', '', 'Full', 'TRUE'], + [date('2026-07-06'), time('16:00'), time('18:30'), 'Meetup', 'Sample First Meet', 'Smart Classroom', 'Meet the team and hear from senior researchers.', '', '', '', '', '', 'TRUE'], + [date('2025-07-06'), time('09:00'), time('12:00'), 'Workshop', 'Sample statistics workshop', 'Online via Zoom', 'Statistics fundamentals, then hands-on software.', '', '', + 'Session 1 | https://youtu.be/example1\nSession 2 | https://youtu.be/example2', '', '', 'TRUE'], + [date('2025-03-19'), time('13:00'), time('15:30'), 'Lab tour', 'Sample lab tour 2', 'Faculty of Medicine', '', '', '', '', '', '', 'TRUE'] +]; + +const SHOWCASE_HEADER = ['Timestamp', 'Email address', 'Presenter name / ชื่อผู้นำเสนอ', 'Class (e.g. MDCU78) / รุ่น', 'Conference name / ชื่องานประชุม', + 'Short name (e.g. VSS 2026) / ชื่อย่อ', 'City, country / เมือง', 'Conference date / วันที่นำเสนอ', 'Format / รูปแบบ', + 'Research title / ชื่องานวิจัย', 'Research field / สาขา', 'Keywords / คำสำคัญ', 'Advisors / อาจารย์ที่ปรึกษา', + 'Abstract / บทคัดย่อ', 'Reflection / สิ่งที่ได้เรียนรู้', 'Photo / รูป', 'Consent / ยินยอม', 'Approved']; + +const sc = (who, cls, conf, short, city, iso, format, title, field, approved = 'TRUE') => + [date('2026-06-01'), 'x@example.com', who, cls, conf, short, city, date(iso), format, title, field, + 'keyword one, keyword two', 'Advisor Example', 'A short placeholder abstract. '.repeat(12).trim(), '', '', 'I agree', approved]; + +export const showcase = [ + SHOWCASE_HEADER, + sc('นักศึกษา ก.', 'MDCU78', 'Sample Vision Meeting 2026', 'SVM 2026', 'Florida, USA', '2026-05-16', 'Poster', 'A sample study on visual attention in older adults', 'Neuroscience'), + sc('นักศึกษา ข.', 'MDCU77', 'Sample Cardiology Session 2026', 'SCS 2026', 'New Orleans, USA', '2026-03-28', 'Poster', 'งานวิจัยตัวอย่างด้านหัวใจล้มเหลวเรื้อรัง', 'Clinical science'), + sc('นักศึกษา ค.', 'MDCU80', 'Sample Education Meeting 2025', 'SEM 2025', 'Tokyo, Japan', '2025-07-20', 'Oral presentation', 'Peer support teams in a sample medical school', 'Medical education'), + sc('นักศึกษา ง.', 'MDCU76', 'Sample Asia Meeting 2024', 'SAM 2024', 'Bangkok, Thailand', '2024-12-01', 'Oral', 'A sample retrospective cohort', 'Emergency medicine'), + sc('นักศึกษา จ.', 'MDCU76', 'Sample Asia Meeting 2024', 'SAM 2024', 'Bangkok, Thailand', '2024-12-01', 'Poster', 'Waiting for approval', '', 'FALSE') +]; diff --git a/google-sites/test/harness.mjs b/google-sites/test/harness.mjs new file mode 100644 index 0000000..aca1557 --- /dev/null +++ b/google-sites/test/harness.mjs @@ -0,0 +1,106 @@ +// Runs the real Apps Script files under Node with small stand-ins for the Google +// services they use, so doGet() can be called for every view. +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +const dir = new URL('../apps-script/', import.meta.url); +const read = (name) => readFileSync(new URL(name, dir), 'utf8'); + +const cellValue = (c) => (c && typeof c === 'object' && 'v' in c ? c.v : c); +const cellText = (c) => (c && typeof c === 'object' && 'd' in c ? c.d : String(c ?? '')); + +function fakeSheet(name, rows) { + return { + getName: () => name, + getDataRange: () => ({ + getValues: () => rows.map((r) => r.map(cellValue)), + getDisplayValues: () => rows.map((r) => r.map(cellText)) + }) + }; +} + +function fakeSpreadsheet(tabs) { + const sheets = Object.entries(tabs).map(([name, rows]) => fakeSheet(name, rows)); + return { getSheets: () => sheets, getSheetByName: (n) => sheets.find((s) => s.getName() === n) || null }; +} + +/** + * fixtures: { opportunities: {tab: rows}, events: rows, showcase: rows } + * now: ISO timestamp used as "today". + * email: the visitor Google reports to the script ('' = signed out or another domain). + */ +export function loadApp({ fixtures, now = '2026-10-23T10:00:00+07:00', config = {}, email = '' }) { + const FIXED = new Date(now).getTime(); + class FixedDate extends Date { + constructor(...args) { super(...(args.length ? args : [FIXED])); } + static now() { return FIXED; } + } + const store = new Map(); + const context = { + console, + JSON, + Date: FixedDate, + SpreadsheetApp: { + openById(id) { + if (id === 'OPP') return fakeSpreadsheet(fixtures.opportunities || {}); + if (id === 'DATA') return fakeSpreadsheet({ Events: fixtures.events || [], Showcase: fixtures.showcase || [] }); + throw new Error('Unknown spreadsheet ' + id); + } + }, + CacheService: { + getScriptCache: () => ({ + get: (k) => store.get(k) ?? null, + getAll: (keys) => Object.fromEntries(keys.filter((k) => store.has(k)).map((k) => [k, store.get(k)])), + putAll: (entries) => Object.entries(entries).forEach(([k, v]) => store.set(k, v)), + removeAll: (keys) => keys.forEach((k) => store.delete(k)) + }) + }, + Utilities: { + formatDate: (d, tz) => new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(d) + }, + ScriptApp: { getService: () => ({ getUrl: () => 'https://script.google.com/a/macros/docchula.com/s/TEST/exec' }) }, + Session: { getActiveUser: () => ({ getEmail: () => email }) }, + HtmlService: { + XFrameOptionsMode: { ALLOWALL: 'ALLOWALL' }, + createHtmlOutputFromFile: (name) => ({ getContent: () => read(name + '.html') }), + createTemplateFromFile(name) { + const source = read(name + '.html'); + const template = { + evaluate() { + const vars = { ...template, include: context.include }; + const html = source.replace(/<\?!=\s*([\s\S]*?)\s*;?\s*\?>/g, (_, expr) => + String(new Function(...Object.keys(vars), 'return (' + expr + ');')(...Object.values(vars)))); + const output = { + getContent: () => html, + setTitle: () => output, + addMetaTag: () => output, + setXFrameOptionsMode: () => output + }; + return output; + } + }; + return template; + } + } + }; + vm.createContext(context); + for (const file of ['Parse.gs', 'Config.gs', 'Code.gs']) vm.runInContext(read(file), context, { filename: file }); + vm.runInContext( + 'CONFIG.OPPORTUNITIES_SHEET_ID = "OPP"; CONFIG.WEBSITE_DATA_SHEET_ID = "DATA";' + + 'Object.assign(CONFIG, ' + JSON.stringify(config) + ');', + context + ); + return context; +} + +/** Returns the full HTML the web app would serve for ?view=. */ +export function render(view, options) { + const app = loadApp(options); + return app.doGet({ parameter: { view, ...(options.params || {}) } }).getContent(); +} + +/** The JSON payload the page receives, parsed back out of the HTML. */ +export function payload(html) { + const m = html.match(/window\.__DATA__ = ([\s\S]*?);\n/); + return JSON.parse(m[1]); +} diff --git a/google-sites/test/parse.test.mjs b/google-sites/test/parse.test.mjs new file mode 100644 index 0000000..8feb726 --- /dev/null +++ b/google-sites/test/parse.test.mjs @@ -0,0 +1,166 @@ +// Run with: node --test google-sites/test +// Loads Parse.gs exactly as Apps Script would and checks it against sheet-shaped fixtures. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +const source = readFileSync(new URL('../apps-script/Parse.gs', import.meta.url), 'utf8'); +const context = {}; +vm.runInNewContext(source + '\nthis.Parse = Parse;', context); +const { Parse } = context; +const plain = (value) => JSON.parse(JSON.stringify(value)); + +// Same layout as the live "Research opportunities for MDCU students" tab: +// a banner row, a group-header row, then the real header row. +const OPP_HEADER = ['', 'ชื่อหัวข้อโครงการวิจัย', 'สเตตัสการเปิดรับสมัคร', 'ชื่อจริง', 'นามสกุล', 'ตำแหน่งทางวิชาการ', + 'สังกัด/ภาควิชา', 'ช่องทาง', 'คอนแทคติดต่อ', 'ชื่อโครงการ (Title)', 'รายละเอียดของโครงการโดยสังเขป', + 'บทบาทหน้าที่ของนิสิตในโครงการวิจัย', 'ทักษะหรือสิ่งที่นิสิตจะได้เรียนรู้', + 'สิ่งที่ต้องการให้นิสิตที่สนใจส่งมาด้วยเพื่อพิจารณาการรับเข้าร่วมโครงการ', 'ระยะเวลาโดยประมาณตั้งแต่เริ่มต้นจนสิ้นสุดโครงการ', + 'โปสเตอร์ประชาสัมพันธ์โครงการวิจัย', 'keywords ที่สำคัญของโครงการ', 'เรื่องอื่น ๆ ที่อาจารย์ต้องการแจ้งให้นิสิตทราบเพิ่มเติม', + 'จำนวนนิสิตที่ต้องการ']; + +const oppRow = (o) => OPP_HEADER.map((_, i) => o[i] ?? ''); + +const OPP_ROWS = [ + ['', 'MDCU Research Club 📂 แบบฟอร์มเปิดรับสมัครนิสิตช่วยทำวิจัย', '', '', '', '', '⚠️ หมายเหตุ'], + ['', 'หัวข้อการทำวิจัย 🧐', '', 'ข้อมูลของอาจารย์', '', '', '', 'ช่องทางการติดต่อที่อาจารย์สะดวก 📩'], + OPP_HEADER, + oppRow({ 1: 'Sample antiviral screening project', 2: 'Available', 3: 'สมหญิง', 4: 'ใจดี', 5: 'ดร., พญ., รศ.', + 6: 'จุลชีววิทยา', 7: 'LINE, Email', 8: 'line: sample-id', 9: 'Sample antiviral screening project', + 10: 'Screening compounds in cell culture', 11: 'ขึ้นกับระดับความสนใจของนิสิต', 12: 'cell-based assays', + 13: 'Curriculum vitae, Letter of intent, Transcript (CR25)', 14: 'Until Jan 2028', + 15: 'https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz012345/view?usp=sharing', + 16: 'antiviral drug, drug discovery', 17: '-', 18: '1 คน' }), + oppRow({ 1: 'ยังไม่มีโครงการแต่เปิดรับนิสิตที่สนใจ', 2: 'Available', 3: 'ศ.นพ.สมชาย', 4: 'ตัวอย่าง', 5: 'ศ.', + 6: 'คณะแพทยศาสตร์/ภาควิชากุมารเวชศาสตร์', 7: 'Email', 16: 'Virology, Vaccines' }), + oppRow({ 1: 'A closed project', 2: 'Closed', 3: 'สมศรี', 4: 'ทดสอบ', 5: 'ผศ.', 6: 'ภาควิชาโสต ศอ นาสิกวิทยา', 7: 'Email' }), + oppRow({}), + oppRow({ 1: 'ยังไม่มีโครงการแต่เปิดรับนิสิตที่สนใจ', 2: 'Available', 3: 'สมปอง', 4: 'ตัวอย่าง', + 5: 'ไม่มีตำแหน่งวิชาการ (นักวิทยาศาสตร์)', 6: 'ภาควิชาโสต ศอ นาสิกวิทยา', 7: 'Email', 16: '' }) +]; + +test('finds the header row below banner rows', () => { + assert.equal(Parse.findHeaderRow(OPP_ROWS, ['ชื่อหัวข้อโครงการวิจัย']), 2); +}); + +test('parses defined projects and open labs, hiding closed ones', () => { + const items = Parse.parseOpportunities(OPP_ROWS); + assert.equal(items.length, 3); + const [project, openLab, scientist] = items; + + assert.equal(project.kind, 'project'); + assert.equal(project.title, 'Sample antiviral screening project'); + assert.equal(project.advisor, 'รศ. ดร. พญ. สมหญิง ใจดี'); + assert.deepEqual(plain(project.channels), ['LINE', 'Email']); + assert.deepEqual(plain(project.docs), ['Curriculum vitae', 'Letter of intent', 'Transcript (CR25)']); + assert.deepEqual(plain(project.keywords), ['antiviral drug', 'drug discovery']); + assert.equal(project.seats, '1'); + assert.equal(project.notes, ''); + assert.equal(project.poster, 'https://lh3.googleusercontent.com/d/1AbCdEfGhIjKlMnOpQrStUvWxYz012345=w1200'); + + assert.equal(openLab.kind, 'open'); + assert.equal(openLab.title, 'Virology · Vaccines'); + assert.equal(openLab.advisor, 'ศ. นพ. สมชาย ตัวอย่าง'); + assert.equal(openLab.dept, 'กุมารเวชศาสตร์'); + assert.equal(openLab.duration, ''); + + assert.equal(scientist.advisor, 'นักวิทยาศาสตร์ สมปอง ตัวอย่าง'); + assert.equal(scientist.dept, 'โสต ศอ นาสิกวิทยา'); + assert.equal(scientist.title, 'Open to interested students'); +}); + +test('closed projects can be included on request', () => { + const items = Parse.parseOpportunities(OPP_ROWS, { includeClosed: true }); + assert.equal(items.filter((i) => i.status === 'closed').length, 1); +}); + +test('columns can be reordered without breaking the parser', () => { + const order = OPP_HEADER.map((_, i) => i).reverse(); + const shuffled = OPP_ROWS.map((row) => order.map((i) => row[i] ?? '')); + const a = plain(Parse.parseOpportunities(OPP_ROWS)).map(({ id, ...rest }) => rest); + const b = plain(Parse.parseOpportunities(shuffled)).map(({ id, ...rest }) => rest); + assert.deepEqual(b, a); +}); + +test('department aliases merge spelling variants', () => { + assert.equal(Parse.normalizeDept('หน่วยปฏิบัติการวิจัยความผิดปกติของรก ภาควิชาสูติศาสตร์-นรีเวชวิทยา', + { 'สูติศาสตร์-นรีเวชวิทยา': 'สูติศาสตร์-นรีเวชวิทยา', 'สูตินรีเวชวิทยา': 'สูติศาสตร์-นรีเวชวิทยา' }), 'สูติศาสตร์-นรีเวชวิทยา'); +}); + +test('reads dates in the formats staff are likely to type', () => { + for (const v of ['2026-07-06', '6/7/2026', '06/07/2026', '6 Jul 2026', '6 July 2026', '6 ก.ค. 2569', '6/7/2569']) { + assert.equal(Parse.toIsoDate(v), '2026-07-06', v); + } + for (const v of ['', 'next week', '31/31/2026', 'TBA']) { + assert.equal(Parse.toIsoDate(v), '', v); + } +}); + +const EVENT_ROWS = [ + ['Date', 'Start', 'End', 'Type', 'Title', 'Venue', 'Description', 'Register link', 'Poster link', 'Recording links', 'Seats', 'Status', 'Show on site'], + ['2026-11-20', '16:00', '18:00', 'Workshop', 'Sample upcoming workshop', 'Room 1302', 'Hands-on session', 'https://forms.gle/example', '', '', '40', '', 'TRUE'], + ['2026-12-05', '13:00', '15:00', 'Lab tour', 'Sample lab tour', 'Lab building', '', 'javascript:alert(1)', '', '', '', 'Full', 'TRUE'], + ['2026-07-06', '16:00', '18:30', 'Meetup', 'Sample first meet', 'Smart Classroom', '', '', '', '', '', '', 'TRUE'], + ['2025-07-06', '09:00', '12:00', 'Workshop', 'Sample online workshop', 'Online (Zoom)', '', '', + '', 'Session 1 | https://youtu.be/example1\nhttps://youtu.be/example2', '', '', 'TRUE'], + ['2026-11-25', '', '', 'Talk', 'Cancelled talk', '', '', '', '', '', '', 'Cancelled', 'TRUE'], + ['2026-11-26', '', '', 'Talk', 'Hidden draft', '', '', '', '', '', '', '', 'FALSE'], + ['', '', '', 'Talk', 'Row without a date', '', '', '', '', '', '', '', 'TRUE'] +]; + +test('splits events into upcoming and past around today', () => { + const { upcoming, past } = Parse.parseEvents(EVENT_ROWS, '2026-10-23'); + assert.deepEqual(plain(upcoming.map((e) => e.title)), ['Sample upcoming workshop', 'Sample lab tour']); + assert.deepEqual(plain(past.map((e) => e.title)), ['Sample first meet', 'Sample online workshop']); + assert.equal(upcoming[0].time, '16:00–18:00'); + assert.equal(upcoming[0].register, 'https://forms.gle/example'); + assert.equal(upcoming[1].status, 'full'); + assert.equal(upcoming[1].register, '', 'unsafe links are dropped'); + assert.deepEqual(plain(past[1].recordings), [ + { label: 'Session 1', url: 'https://youtu.be/example1' }, + { label: 'Watch the recording', url: 'https://youtu.be/example2' } + ]); +}); + +test('an event on today counts as upcoming until the day ends', () => { + const { upcoming } = Parse.parseEvents(EVENT_ROWS, '2026-11-20'); + assert.equal(upcoming[0].title, 'Sample upcoming workshop'); +}); + +const SHOWCASE_ROWS = [ + ['Timestamp', 'Email address', 'Presenter name / ชื่อผู้นำเสนอ', 'Class (e.g. MDCU78) / รุ่น', 'Conference name / ชื่องานประชุม', + 'Short name (e.g. VSS 2026) / ชื่อย่อ', 'City, country / เมือง', 'Conference date / วันที่นำเสนอ', 'Format / รูปแบบ', + 'Research title / ชื่องานวิจัย', 'Research field / สาขา', 'Keywords / คำสำคัญ', 'Advisors / อาจารย์ที่ปรึกษา', + 'Abstract / บทคัดย่อ', 'Reflection / สิ่งที่ได้เรียนรู้', 'Photo / รูป', 'Consent / ยินยอม', 'Approved'], + ['1/6/2026', 'a@example.com', 'Student A', 'mdcu 78', 'Sample Vision Meeting', 'SVM 2026', 'Florida, USA', '2026-05-16', 'Poster presentation', + 'A sample study title', 'Neuroscience', 'attention, aging', 'Dr. Example', 'Abstract text', '', '', 'I agree', 'TRUE'], + ['2/6/2026', 'b@example.com', 'Student B', 'MDCU77', 'Sample Cardiology Session', 'SCS 2025', 'Chicago, USA', '2025-03-30', 'Oral', + 'งานวิจัยตัวอย่าง', 'Cardiology', '', '', '', '', '', 'I agree', 'TRUE'], + ['3/6/2026', 'c@example.com', 'Student C', 'MDCU76', 'Sample Asia Meeting', 'SAM 2024', 'Bangkok, Thailand', '2024-12-01', 'Competition', + 'Awaiting review', '', '', '', '', '', '', 'I agree', 'FALSE'], + ['4/6/2026', 'd@example.com', 'Student D', 'MDCU76', 'Sample Asia Meeting', 'SAM 2024', 'Bangkok, Thailand', '2024-12-01', 'Poster', + 'No consent given', '', '', '', '', '', '', '', 'TRUE'] +]; + +test('showcase shows only approved rows with consent, newest first', () => { + const items = Parse.parseShowcase(SHOWCASE_ROWS); + assert.deepEqual(plain(items.map((i) => i.title)), ['A sample study title', 'งานวิจัยตัวอย่าง']); + assert.equal(items[0].format, 'Poster'); + assert.equal(items[0].cls, 'MDCU78'); + assert.equal(items[0].year, '2026'); + assert.equal(items[0].country, 'USA'); + assert.equal(items[1].format, 'Oral'); + assert.equal(items[1].lang, 'th'); +}); + +test('showcase stats count distinct conferences and countries', () => { + const stats = Parse.showcaseStats(Parse.parseShowcase(SHOWCASE_ROWS)); + assert.deepEqual(plain(stats), { presentations: 2, conferences: 2, countries: 1, since: 2025 }); +}); + +test('only web and mail links are allowed', () => { + assert.equal(Parse.safeUrl('javascript:alert(1)'), ''); + assert.equal(Parse.safeUrl('www.example.com'), 'https://www.example.com'); + assert.equal(Parse.safeUrl('mailto:researchclub@docchula.com'), 'mailto:researchclub@docchula.com'); +}); diff --git a/google-sites/test/preview.mjs b/google-sites/test/preview.mjs new file mode 100644 index 0000000..8a6fc71 --- /dev/null +++ b/google-sites/test/preview.mjs @@ -0,0 +1,24 @@ +// Writes one HTML file per widget view, rendered from the placeholder fixtures, +// so the widgets can be checked in a browser without deploying. +// node google-sites/test/preview.mjs [outDir] [nowIso] +import { mkdirSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import * as fixtures from './fixtures.mjs'; +import { render } from './harness.mjs'; + +const out = resolve(process.argv[2] || new URL('./out', import.meta.url).pathname); +const now = process.argv[3]; +const views = ['opportunities', 'open-now', 'stats', 'events-home', 'events', 'showcase-home', 'showcase']; + +const pages = views.map((view) => [view, view, {}]).concat([ + ['opportunities-member', 'opportunities', { email: 'student@docchula.com' }], + ['opportunities-full', 'opportunities', { email: 'student@docchula.com', params: { full: '1' } }] +]); + +mkdirSync(out, { recursive: true }); +for (const [file, view, extra] of pages) { + writeFileSync(resolve(out, file + '.html'), render(view, { fixtures, ...(now ? { now } : {}), ...extra })); +} +writeFileSync(resolve(out, 'index.html'), 'Widget previews
    ' + + pages.map(([file]) => `
  • ${file}
  • `).join('') + '
'); +console.log('Wrote ' + pages.length + ' previews to ' + out); diff --git a/google-sites/test/widgets.test.mjs b/google-sites/test/widgets.test.mjs new file mode 100644 index 0000000..4c6ae6b --- /dev/null +++ b/google-sites/test/widgets.test.mjs @@ -0,0 +1,116 @@ +// Run with: node --test google-sites/test/*.test.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as fixtures from './fixtures.mjs'; +import { render, payload } from './harness.mjs'; + +const VIEWS = ['opportunities', 'open-now', 'stats', 'events-home', 'events', 'showcase-home', 'showcase']; + +test('every view renders a complete page without the error fallback', () => { + for (const view of VIEWS) { + const html = render(view, { fixtures }); + const data = payload(html); + assert.ok(!data.error, view + ' loaded with an error'); + assert.match(html, /
<\/div>/, view); + assert.match(html, /:root \{/, view + ' is missing Styles.html'); + assert.match(html, /function renderOpportunities/, view + ' is missing App.html'); + assert.ok(!/<\?/.test(html), view + ' has an unevaluated scriptlet'); + } +}); + +const MEMBER = 'student@docchula.com'; + +test('opportunities: newest academic year, projects before open labs, closed hidden', () => { + const data = payload(render('opportunities', { fixtures, email: MEMBER })); + assert.equal(data.viewer.member, true); + assert.equal(data.year, '2026-2027'); + assert.deepEqual(data.years, ['2026-2027', '2025-2026']); + assert.deepEqual(data.items.map((i) => i.kind), ['project', 'project', 'open']); + assert.equal(data.items[0].title, 'การศึกษาปัจจัยเสี่ยงของภาวะหกล้มในผู้สูงอายุในชุมชน', 'newest answer first'); + assert.equal(data.items[2].advisor, 'ศ. นพ. ตัวอย่าง สาม'); + assert.equal(data.items[2].dept, 'จุลชีววิทยา'); +}); + +test('open-now: only defined projects, with the total for the button', () => { + const data = payload(render('open-now', { fixtures })); + assert.equal(data.total, 3); + assert.equal(data.items.length, 2); + assert.ok(data.items.every((i) => i.kind === 'project')); +}); + +test('events: only upcoming events are sent; real date cells are read regardless of display format', () => { + const data = payload(render('events', { fixtures })); + assert.deepEqual(data.upcoming.map((e) => e.date), ['2026-11-20', '2026-12-05']); + assert.equal(data.upcoming[0].time, '16:00–18:30'); + assert.equal(data.upcoming[0].poster, 'https://lh3.googleusercontent.com/d/1AbCdEfGhIjKlMnOpQrStUvWxYz012345=w1200'); + assert.equal(data.past, undefined, 'past activities are hand-made posts in Google Sites'); +}); + +test('events-home: next event, or an empty state once it has passed', () => { + const later = payload(render('events-home', { fixtures, now: '2027-01-10T09:00:00+07:00' })); + assert.deepEqual(later, { next: null, upcomingCount: 0 }); + const now = payload(render('events-home', { fixtures })); + assert.equal(now.next.title, 'Sample workshop: writing your first abstract'); + assert.equal(now.upcomingCount, 2); +}); + +test('showcase: approved rows only, stats across conferences and countries', () => { + const data = payload(render('showcase', { fixtures })); + assert.equal(data.items.length, 4); + assert.deepEqual(data.stats, { presentations: 4, conferences: 4, countries: 3, since: 2024 }); + const home = payload(render('showcase-home', { fixtures })); + assert.equal(home.items.length, 3); + assert.deepEqual(home.conferences, ['SVM', 'SCS', 'SEM', 'SAM']); +}); + +test('a broken sheet ID shows the friendly fallback instead of a Google error page', () => { + const html = render('events', { fixtures, config: { WEBSITE_DATA_SHEET_ID: 'MISSING' } }); + assert.equal(payload(html).error, true); +}); + +test('signed-out visitors only receive the public opportunity fields', () => { + const html = render('opportunities', { fixtures }); + const data = payload(html); + assert.equal(data.viewer.member, false); + assert.equal(data.items.length, 3, 'every project is still listed'); + for (const item of data.items) { + assert.deepEqual(Object.keys(item).sort(), ['dept', 'id', 'keywords', 'kind', 'lang', 'locked', 'seats', 'status', 'title']); + } + for (const secret of ['example.one@example.com', 'example-two', 'Build and validate a model', 'Curriculum vitae', 'ตัวอย่าง หนึ่ง', 'Mar 2027']) { + assert.ok(!html.includes(secret), 'sent to a signed-out visitor: ' + secret); + } +}); + +test('only accounts on the member domain unlock details', () => { + const member = payload(render('opportunities', { fixtures, email: 'Student@DocChula.com' })); + assert.equal(member.viewer.member, true); + assert.equal(member.items[1].contact, 'example.one@example.com'); + for (const email of ['someone@gmail.com', 'x@notdocchula.com', 'x@docchula.com.evil.example']) { + const data = payload(render('opportunities', { fixtures, email })); + assert.equal(data.viewer.member, false, email); + assert.ok(data.items.every((i) => i.locked && !('advisor' in i) && !('contact' in i)), email); + } +}); + +test('the home "open now" panel never carries hidden fields for signed-out visitors', () => { + const data = payload(render('open-now', { fixtures })); + assert.equal(data.total, 3); + assert.ok(data.items.every((i) => i.locked && !('advisor' in i) && !('duration' in i))); +}); + +test('the members copy opens as a full page with the sign-in link pointing at it', () => { + const html = render('opportunities', { + fixtures, email: MEMBER, params: { full: '1' }, + config: { MEMBERS_APP_URL: 'https://script.google.com/a/macros/docchula.com/s/MEMBERS/exec' } + }); + assert.match(html, /"fullPage":true/); + assert.match(html, /"membersUrl":"https:\/\/script\.google\.com\/a\/macros\/docchula\.com\/s\/MEMBERS\/exec"/); +}); + +test('sheet text cannot break out of the data script tag', () => { + const hostile = structuredClone(fixtures.events); + hostile[1][4] = ''; + const html = render('events', { fixtures: { ...fixtures, events: hostile } }); + assert.ok(!html.includes(''); +}); From ab46b1f7fc303546cc6c73df98a22f2f413a64fb Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Sat, 24 Oct 2026 11:33:24 +0700 Subject: [PATCH 02/21] google-sites - add rebuild progress notes --- google-sites/PROGRESS.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 google-sites/PROGRESS.md diff --git a/google-sites/PROGRESS.md b/google-sites/PROGRESS.md new file mode 100644 index 0000000..0dfabb6 --- /dev/null +++ b/google-sites/PROGRESS.md @@ -0,0 +1,34 @@ +# Rebuild progress — Google Sites draft + +Working notes for whoever continues the rebuild (person or Claude session). The live site is +; the editor is the same site with `/edit`. +Target look: the canvas preview and the recipes in +`README.md` §2.3. Everything below happens in the **draft** — nothing is live until someone presses Publish. + +## Done + +- Published once (theme, Home headline, page renames, Opportunities intro) — see git log for the date. +- Theme *MDCU Research 2026*: white top navigation, amber underline on the current page, + amber filled buttons on dark (Style 3) sections. +- Home: old image header removed; new dark hero section (eyebrow, "Today's research, / tomorrow's cure.", + Mayo credit, Thai line, original subtitle) with **Find a research project** → Opportunities and + **See what's on** → Events. The right half of the hero is left empty for the `open-now` embed. + +## To do + +1. Home sections 01–06 restyled like the preview (eyebrows, left-aligned headings, "Our impact" cards, + Events split, Showcase, Get involved), footer in three columns. +2. Opportunities, Events, Showcase, About pages restyled the same way. +3. Apps Script widgets (README §1.4) — blocked until **researchclub@docchula.com** is signed in to Chrome + (only the owner can approve Google's permission screen). Then embed: + Home `stats` + `open-now` (hero right column), `events-home`, `showcase-home`; + Opportunities `opportunities`; Events `events`; Showcase `showcase`. +4. Review the draft, then Publish. + +## Editor tips (for browser automation) + +- Styled text: dispatch a synthetic `paste` ClipboardEvent with `text/html` on the focused text box — + the editor keeps inline font, size and colour and handles Thai. Don't use the system clipboard. +- Moving components: synthetic mouse events (mousedown → several mousemoves → mouseup) work, but keep + the drop point away from the top/bottom edge or the canvas auto-scrolls. Resizing handles need real + mouse drags. Items stacked in one column share its width. From 575919d94b208b9b8c02d53213679f690f05f964 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Sat, 24 Oct 2026 11:35:08 +0700 Subject: [PATCH 03/21] google-sites - update rebuild progress notes --- google-sites/PROGRESS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/google-sites/PROGRESS.md b/google-sites/PROGRESS.md index 0dfabb6..3ee9b09 100644 --- a/google-sites/PROGRESS.md +++ b/google-sites/PROGRESS.md @@ -13,10 +13,13 @@ Target look: the canvas preview Date: Sat, 24 Oct 2026 12:23:35 +0700 Subject: [PATCH 04/21] google-sites - note Home rebuild progress --- google-sites/PROGRESS.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/google-sites/PROGRESS.md b/google-sites/PROGRESS.md index 3ee9b09..9b1899e 100644 --- a/google-sites/PROGRESS.md +++ b/google-sites/PROGRESS.md @@ -13,25 +13,26 @@ Target look: the canvas preview Date: Sat, 24 Oct 2026 13:32:28 +0700 Subject: [PATCH 05/21] google-sites - note all pages restyled in draft --- google-sites/PROGRESS.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/google-sites/PROGRESS.md b/google-sites/PROGRESS.md index 9b1899e..66d43dc 100644 --- a/google-sites/PROGRESS.md +++ b/google-sites/PROGRESS.md @@ -18,14 +18,20 @@ Target look: the canvas preview Date: Sat, 24 Oct 2026 13:40:33 +0700 Subject: [PATCH 06/21] google-sites - add one-time setup script for the Website data sheet and showcase form --- google-sites/setup/Setup.gs | 104 ++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 google-sites/setup/Setup.gs diff --git a/google-sites/setup/Setup.gs b/google-sites/setup/Setup.gs new file mode 100644 index 0000000..6890d72 --- /dev/null +++ b/google-sites/setup/Setup.gs @@ -0,0 +1,104 @@ +/** + * One-time setup. Paste this into its OWN Apps Script project (not the widgets project), run + * setupWebsiteData() once, approve the permission screen, then read the links in View > Logs. + * Afterwards the project can be deleted - the widgets project never needs these permissions. + * + * It creates, in the signed-in account's Drive: + * - a spreadsheet "Website data" with an Events tab (dropdowns, checkboxes, sample rows) + * - a Google Form "Add my presentation to the MDCU Research Showcase" linked to a Showcase tab + * that has an Approved checkbox column (only ticked rows appear on the site) + */ +function setupWebsiteData() { + var ss = SpreadsheetApp.create('Website data'); + var events = ss.getSheets()[0]; + events.setName('Events'); + + var header = ['Date', 'Start', 'End', 'Type', 'Title', 'Venue', 'Description', 'Register link', 'Poster link', + 'Recording links', 'Seats', 'Status', 'Show on site']; + var rows = [ + ['2026-07-06', '16:00', '18:30', 'Meetup', 'MDCU Research Division First Meet 2026', 'Smart Classroom, อาคาร อปร.', + 'What research is and how to start, through a hands-on interactive session — plus talks from four senior researchers and an early look at Core Team recruitment.', '', '', '', '', '', true], + ['2026-01-16', '16:15', '20:00', 'Workshop', 'How to Design High-Impact Research', 'Room 1302, 13th floor, Bhumisiri Building', + 'Lecture on research fundamentals and key ideas, followed by a workshop on the thinking process behind study design.', '', '', '', '40', '', true], + ['2025-07-06', '09:00', '12:00', 'Workshop', 'Statistics in Medical Research & Basic Software', 'Online via Zoom', + 'ResearchNav 2025, Workshop 4. Session 1: statistical fundamentals from principles to practice. Session 2: hands-on with Stata.', '', '', '', '', '', true], + ['2025-03-19', '13:00', '15:30', 'Lab tour', 'Lab Tour 2: Systems Biology & Cancer Biomarkers', 'Faculty of Medicine, Chulalongkorn University', + 'Visits to the Center of Excellence in Systems Biology and the Cancer Biomarker Research Laboratory.', '', '', '', '', '', true], + ['2025-03-11', '16:30', '19:30', 'Workshop', 'Transforming Medical Research with AI', 'Room 1302, 13th floor, Bhumisiri Building', + 'AI tools that help with research, with an interactive session.', '', '', '', '40', '', true] + ]; + var n = rows.length; + events.getRange(1, 1, 1, header.length).setValues([header]).setFontWeight('bold').setBackground('#16305A').setFontColor('#FFFFFF'); + // Text format first, so "16:00" and "40" stay exactly as typed. + events.getRange(2, 2, 500, 2).setNumberFormat('@'); + events.getRange(2, 11, 500, 1).setNumberFormat('@'); + events.getRange(2, 1, 500, 1).setNumberFormat('yyyy-mm-dd'); + var data = rows.map(function (r) { + var out = r.slice(); + var p = r[0].split('-'); + out[0] = new Date(+p[0], +p[1] - 1, +p[2]); + return out; + }); + events.getRange(2, 1, n, header.length).setValues(data); + events.getRange(2, 4, 500, 1).setDataValidation(SpreadsheetApp.newDataValidation() + .requireValueInList(['Workshop', 'Lab tour', 'Research talk', 'Meetup', 'Other'], true).setAllowInvalid(true).build()); + events.getRange(2, 12, 500, 1).setDataValidation(SpreadsheetApp.newDataValidation() + .requireValueInList(['Open', 'Full', 'Closed', 'Cancelled'], true).setAllowInvalid(true).build()); + events.getRange(2, 13, 500, 1).insertCheckboxes(); + events.getRange(2, 13, n, 1).check(); + events.setFrozenRows(1); + events.setColumnWidths(1, 3, 90); + events.setColumnWidth(5, 280); + events.setColumnWidth(6, 200); + events.setColumnWidth(7, 380); + events.getRange(1, 1, 1, 1).setNote('One row per event. Only rows dated today or later appear as "Upcoming" on the site; "Show on site" must be ticked.'); + + var form = FormApp.create('Add my presentation to the MDCU Research Showcase'); + form.setDescription('Presented your research at a conference? Fill this in and the Research Division team will review it. ' + + 'Approved entries appear on the Showcase page of the MDCU Research Division website.'); + form.setCollectEmail(true); + form.setRequireLogin(true); + form.setConfirmationMessage('Thank you! The team will review your presentation before it appears on the website.'); + var text = function (title, required, help) { + var item = form.addTextItem().setTitle(title).setRequired(!!required); + if (help) item.setHelpText(help); + return item; + }; + text('Presenter name / ชื่อผู้นำเสนอ', true); + text('Class (e.g. MDCU78) / รุ่น', true); + text('Conference name / ชื่องานประชุม', true); + text('Short name (e.g. VSS 2026) / ชื่อย่อ', false); + text('City, country / เมือง', true, 'For example: Bangkok, Thailand'); + form.addDateItem().setTitle('Conference date / วันที่นำเสนอ').setRequired(true); + form.addMultipleChoiceItem().setTitle('Format / รูปแบบ') + .setChoiceValues(['Oral presentation', 'Poster presentation', 'Other']).setRequired(true); + text('Research title / ชื่องานวิจัย', true); + text('Research field / สาขา', false); + text('Keywords / คำสำคัญ', false, 'Separate with commas'); + text('Advisors / อาจารย์ที่ปรึกษา', false); + form.addParagraphTextItem().setTitle('Abstract / บทคัดย่อ').setRequired(true); + form.addParagraphTextItem().setTitle('Reflection / สิ่งที่ได้เรียนรู้').setRequired(false); + text('Photo / รูป', false, 'Optional. Paste a Google Drive link to your photo, shared as "Anyone with the link - Viewer".'); + form.addCheckboxItem().setTitle('Consent / ยินยอม') + .setChoiceValues(['I agree that my name, class, photo and research details are shown on the club website']) + .setRequired(true); + form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId()); + SpreadsheetApp.flush(); + + var showcase = null; + ss.getSheets().forEach(function (s) { if (s.getName() !== 'Events') showcase = s; }); + if (showcase) { + showcase.setName('Showcase'); + var col = showcase.getLastColumn() + 1; + showcase.getRange(1, col).setValue('Approved').setFontWeight('bold'); + showcase.getRange(2, col, 500, 1).insertCheckboxes(); + showcase.setFrozenRows(1); + showcase.getRange(1, col).setNote('Tick to publish this presentation on the Showcase page.'); + } + + Logger.log('WEBSITE_DATA_SHEET_ID = ' + ss.getId()); + Logger.log('Spreadsheet: ' + ss.getUrl()); + Logger.log('Form (share this with students): ' + form.getPublishedUrl()); + Logger.log('Form (edit): ' + form.getEditUrl()); + return ss.getId(); +} From 7d00f9fa75e1122ba149b8d5c9b35b85697dcf8c Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Sat, 24 Oct 2026 13:44:46 +0700 Subject: [PATCH 07/21] google-sites - note widgets project state --- google-sites/PROGRESS.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/google-sites/PROGRESS.md b/google-sites/PROGRESS.md index 66d43dc..cbcd109 100644 --- a/google-sites/PROGRESS.md +++ b/google-sites/PROGRESS.md @@ -27,11 +27,18 @@ Target look: the canvas preview run `setupWebsiteData()` (creates the "Website data" + sheet + showcase form; read the IDs/links in the execution log, then delete that project); + - widgets project -> Deploy > New deployment > Web app > "Anyone" (the first dialog is open at + "Authorize access"), then a second deployment "Anyone within docchula.com" for the members copy. + Then: put the sheet ID and members URL in `Config.gs`, create a new version, and embed + (Sites: Insert > Embed > By URL + `?view=...`): Home `stats` + `open-now` (hero right column), + `events-home`, `showcase-home`; Opportunities `opportunities`; Events `events`; Showcase `showcase`. +2. Add the faculty form link (Opportunities "For faculty") and the showcase form link (Showcase hero). +3. Review the draft, then Publish (not published since the first publish - ask the owner first). ## Editor tips (for browser automation) From 2a325ad34b06a7222bdf684dab16f2988e33231e Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Sat, 24 Oct 2026 16:38:04 +0700 Subject: [PATCH 08/21] google-sites - point widgets at the Website data sheet, fix page links, use full spreadsheets scope SpreadsheetApp.openById refuses the read-only scope, so the web app needs the spreadsheets scope to read the opportunities and Website data sheets. --- google-sites/apps-script/Config.gs | 8 ++++---- google-sites/apps-script/appsscript.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/google-sites/apps-script/Config.gs b/google-sites/apps-script/Config.gs index b189f2e..15b81c5 100644 --- a/google-sites/apps-script/Config.gs +++ b/google-sites/apps-script/Config.gs @@ -8,7 +8,7 @@ var CONFIG = { OPPORTUNITIES_SHEET_ID: '1rauSCAn-TY2GrytPxR-Fz4000X8-A7PXW3-8Hr10X4g', // "Website data" — holds the Events tab and the Showcase tab (linked to the showcase form). - WEBSITE_DATA_SHEET_ID: 'PASTE_WEBSITE_DATA_SHEET_ID_HERE', + WEBSITE_DATA_SHEET_ID: '1wwTHjyXvQPko8dfZXaXaFK4Oe9wR1CdbtjD0OkfbGjk', EVENTS_TAB: 'Events', SHOWCASE_TAB: 'Showcase', @@ -38,11 +38,11 @@ var CONFIG = { LINKS: { site: 'https://sites.google.com/docchula.com/mdcuresearch-docchula', opportunitiesPage: 'https://sites.google.com/docchula.com/mdcuresearch-docchula/opportunities', - eventsPage: 'https://sites.google.com/docchula.com/mdcuresearch-docchula/events', - showcasePage: 'https://sites.google.com/docchula.com/mdcuresearch-docchula/showcase', + eventsPage: 'https://sites.google.com/docchula.com/mdcuresearch-docchula/previous-events', + showcasePage: 'https://sites.google.com/docchula.com/mdcuresearch-docchula/researcher-profile', instagram: 'https://www.instagram.com/researchclub.mdcu/', email: 'researchclub@docchula.com', facultyForm: '', - showcaseForm: '' + showcaseForm: 'https://docs.google.com/forms/d/e/1FAIpQLSeovLgBQ3cEw8nlmHkjzG6HwR89R5AEMyThhRb5SWkAS0NGAg/viewform' } }; diff --git a/google-sites/apps-script/appsscript.json b/google-sites/apps-script/appsscript.json index 3fccc47..6d285e8 100644 --- a/google-sites/apps-script/appsscript.json +++ b/google-sites/apps-script/appsscript.json @@ -3,7 +3,7 @@ "runtimeVersion": "V8", "exceptionLogging": "STACKDRIVER", "oauthScopes": [ - "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/userinfo.email" ], "webapp": { From d97790d82334f6fde5ca350c5777f74b7086443e Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Sat, 24 Oct 2026 17:11:27 +0700 Subject: [PATCH 09/21] google-sites - match the artifact's Home sections and add phone layouts New views: hero-actions (buttons + numbers) and showcase-stats. events-home now shows the two latest activities beside Upcoming, showcase-home gets the buttons row, open-now gets the sign-in hint. Google Sites keeps an embed's width-to-height ratio on phones, so wide views switch to a compact teaser with a link to the standalone page and the open-now panel scales down. Links to the standalone page use the /a/macros/docchula.com/ address. --- google-sites/apps-script/App.html | 195 ++++++++++++++++++++++++--- google-sites/apps-script/Code.gs | 37 +++-- google-sites/apps-script/Styles.html | 98 ++++++++++++-- google-sites/test/preview.mjs | 23 +++- google-sites/test/widgets.test.mjs | 22 ++- 5 files changed, 329 insertions(+), 46 deletions(-) diff --git a/google-sites/apps-script/App.html b/google-sites/apps-script/App.html index 77ccea4..f87fd62 100644 --- a/google-sites/apps-script/App.html +++ b/google-sites/apps-script/App.html @@ -121,6 +121,20 @@ return LINKS.instagram ? extLink(LINKS.instagram, icon('ig') + 'Follow @researchclub.mdcu', 'btn ghost') : ''; } + // ---------- sizing ---------- + // Google Sites keeps an embed's width-to-height ratio on phones, so a section that is 1100px wide on a + // laptop becomes a short strip on a phone. Wide views switch to a compact teaser with a link to the + // standalone page; narrow ones scale down as a whole. + function narrow(px) { return !CONFIG.fullPage && window.innerWidth < px; } + function fullUrl(view) { return (CONFIG.self || '') + '?view=' + view + '&full=1'; } + function openFull(view, label) { + return '' + esc(label) + icon('out') + ''; + } + function fitTo(designWidth) { + var z = Math.min(1, window.innerWidth / designWidth); + document.documentElement.style.zoom = z < 1 ? String(z) : ''; + } + // ---------- Opportunities ---------- var MEMBER_DOMAIN = CONFIG.memberDomain || 'docchula.com'; @@ -260,8 +274,17 @@ var depts = unique(items.map(function (i) { return i.dept; })).sort(function (a, b) { return a.localeCompare(b, 'th'); }); var member = !!(DATA.viewer && DATA.viewer.member); + if (narrow(640)) { + app.innerHTML = '

' + plural(items.length, 'project', 'projects') + ' open' + + (DATA.year ? ' · ' + esc(DATA.year.replace('-', '–')) : '') + '

' + + '
    ' + items.slice(0, 3).map(function (o) { + return '
  • ' + (o.kind === 'open' ? 'Open lab' : 'Project') + '' + esc(o.title) + '
  • '; + }).join('') + '
' + openFull('opportunities', 'Open the project finder') + '
'; + return; + } + app.innerHTML = '
' + - (CONFIG.fullPage ? fullPageBar() : '') + + (CONFIG.fullPage ? fullPageBar() : '

Find a project

') + (member ? memberNote() : signInBanner()) + '
' + '
' + (items.length ? '' : '

New projects for this year are on their way — check back soon.

') + 'Browse all ' + plural(DATA.total || 0, 'project', 'projects') + icon('arrow') + '' + + (member ? '' : '

' + icon('lock') + 'Advisors and contacts unlock with your ' + esc(MEMBER_DOMAIN) + ' account

') + ''; } + function heroNumbers() { + var s = DATA.stats || {}; + return [[DATA.openProjects, 'Open projects', 'projects'], [s.presentations, 'Conference presentations', 'presentations'], + [s.countries, 'Countries', 'countries']].filter(function (c) { return c[0]; }); + } + function renderStats() { + app.innerHTML = '
' + heroNumbers().map(function (c) { + return '

' + esc(c[0]) + '

' + esc(c[1]) + '

'; + }).join('') + '
'; + } + + /** "Find a research project" + "See what's on" + the three headline numbers, under the hero headline. */ + function renderHeroActions() { + var cells = heroNumbers(); + var compact = window.innerWidth < 480; + app.innerHTML = '
' + + '' + + (!cells.length ? '' : compact + ? '

' + cells.map(function (c) { return '' + esc(c[0]) + ' ' + esc(c[2]); }).join(' · ') + '

' + : '
' + cells.map(function (c) { + return '

' + esc(c[0]) + '

' + esc(c[1]) + '

'; + }).join('') + '
') + + '
'; + } + + function renderShowcaseStats() { var s = DATA.stats || {}; - var cells = [[DATA.openProjects, 'Open projects'], [s.presentations, 'Conference presentations'], [s.countries, 'Countries']] - .filter(function (c) { return c[0]; }); - app.innerHTML = '
' + cells.map(function (c) { + var year = new Date().getFullYear(); + var cells = [[s.presentations, 'Presentations'], [s.conferences, 'Conferences'], [s.countries, 'Countries'], + [s.since ? s.since + '–' + String(year).slice(2) : '', 'Academic years']].filter(function (c) { return c[0]; }); + if (window.innerWidth < 480) { + app.innerHTML = '

' + cells.map(function (c) { return '' + esc(c[0]) + ' ' + esc(c[1].toLowerCase()); }).join(' · ') + '

'; + return; + } + app.innerHTML = '
' + cells.map(function (c) { return '

' + esc(c[0]) + '

' + esc(c[1]) + '

'; }).join('') + '
'; } @@ -399,25 +456,84 @@ '
' + poster(e, 'thumb') + ''; } + /** One-line event for the compact (phone) layouts. */ + function eventLine(e) { + return '
  • ' + dayOf(e.date) + ' ' + monthOf(e.date).slice(0, 3) + '' + esc(e.title) + '
  • '; + } + function renderEvents() { var upcoming = DATA.upcoming || []; + if (narrow(640)) { + app.innerHTML = '
    ' + + (upcoming.length + ? '
      ' + upcoming.slice(0, 3).map(eventLine).join('') + '
    ' + openFull('events', 'Details, registration and calendar') + : '

    No upcoming events yet

    New dates are posted on Instagram first.

    ' + + (LINKS.instagram ? extLink(LINKS.instagram, icon('ig') + 'Follow @researchclub.mdcu', 'openfull') : '')) + + '
    '; + return; + } app.innerHTML = '
    ' + - '

    Upcoming

    ' + + (CONFIG.fullPage ? '
    MDCU Research Division · Upcoming eventsBack to the website
    ' : '') + (upcoming.length ? nextCard(upcoming[0]) + (upcoming.length > 1 ? '
      ' + upcoming.slice(1).map(eventRow).join('') + '
    ' : '') - : emptyBlock('No upcoming events yet', 'New dates are posted on Instagram first. This list updates on its own when the next event is added.', followButton())) + + : emptyBlock('No upcoming events yet', 'New dates are posted on Instagram first. This list updates on its own as soon as the team adds the next event.', followButton())) + + '
    '; + } + + /** A past activity as a short "What's new" teaser; the full post (photos, text) is on the Events page. */ + function newsRow(e) { + var thumb = e.poster + ? 'Poster: ' + esc(e.title) + '' + : ''; + var text = e.description || ''; + return '
    ' + thumb + + '

    What\'s new · ' + esc(monthOf(e.date)) + '

    ' + + '' + esc(e.title) + '' + + (text ? '

    ' + esc(text) + '

    ' : '') + + (LINKS.eventsPage ? 'Read more' : '') + + '
    '; + } + + function upcomingCard(e) { + return '
    ' + + '
    ' + dayOf(e.date) + '' + monthOf(e.date) + '
    ' + + '

    ' + esc(e.type) + ' · ' + esc(eventStatus(e)) + '

    ' + + '' + esc(e.title) + '' + + '

    ' + esc([e.venue, e.time].filter(Boolean).join(' · ')) + '

    ' + + '
    ' + (e.register && e.status === 'open' ? extLink(e.register, 'Register' + icon('out'), 'btn primary') : '') + + extLink(calendarUrl(e), icon('cal') + 'Add to Google Calendar', 'cal') + '
    ' + '
    '; } function renderEventsHome() { var next = DATA.next; - app.innerHTML = '
    ' + - '

    Upcoming

    ' + - (next ? nextCard(next) : emptyBlock('Nothing scheduled right now', - 'New dates go up on Instagram first, and most events are first come, first served — follow to hear early.', followButton())) + - (DATA.upcomingCount > 1 && LINKS.eventsPage - ? '

    See all ' + DATA.upcomingCount + ' upcoming events

    ' : '') + + var recent = DATA.recent || []; + if (narrow(640)) { + app.innerHTML = '
    ' + + '
      ' + + (next ? eventLine(next) : '
    • NextNothing scheduled yet — new dates go up on Instagram first.
    • ') + + (recent[0] ? '
    • Latest' + esc(recent[0].title) + '
    • ' : '') + + '
    ' + + (LINKS.eventsPage ? 'All events' + icon('arrow') + '' : '') + + '
    '; + return; + } + app.innerHTML = '
    ' + + '

    Upcoming

    ' + + (next + ? upcomingCard(next) + (DATA.upcomingCount > 1 && LINKS.eventsPage + ? '

    See all ' + DATA.upcomingCount + ' upcoming events

    ' : '') + : '
    ' + + '' + icon('cal') + '' + + '

    Nothing scheduled right now

    ' + + '

    New dates go up on Instagram first, and most events are first come, first served — follow to hear early.

    ' + + followButton() + + '
    ') + + '
    ' + + '

    What\'s new

    ' + + (recent.length ? recent.map(newsRow).join('') : '

    Photos and recaps from past activities are on the Events page.

    ') + + '
    ' + '
    '; } @@ -441,8 +557,8 @@ : ' type="button" data-id="' + esc(s.id) + '" aria-pressed="' + !!opts.selected + '"'; return '
  • <' + tag + ' class="card"' + attrs + '>' + '' + esc(s.conf) + '' + esc(s.where) + '' + formatTag(s.format) + '' + - '' + esc(s.title) + '' + - (s.field ? '' + esc(s.field) + '' : '') + + '' + esc(s.title) + '' + + (s.field ? '' + esc(s.field) + '' : '') + '' + presenterBadge(s) + '' + esc(s.presenter) + '' + (s.cls ? ' · ' + esc(s.cls) + '' : '') + '' + '
  • '; @@ -485,7 +601,20 @@ var years = unique(items.map(function (s) { return s.year; })).sort().reverse(); var state = { q: '', year: 'all', format: 'all', sel: items.length ? items[0].id : null, all: false }; + if (narrow(640)) { + app.innerHTML = '

    ' + plural(items.length, 'presentation', 'presentations') + '

    ' + + '
      ' + items.slice(0, 3).map(function (s) { + return '
    • ' + esc(s.conf) + '' + esc(s.title) + '
    • '; + }).join('') + '
    ' + openFull('showcase', 'Browse and search all presentations') + '
    '; + return; + } + app.innerHTML = '
    ' + + (CONFIG.fullPage + ? '
    MDCU Research Division' + + 'Research showcase' + + 'Back to the website
    ' + : '

    Browse presentations

    ') + '
    ' + '
    ' + @@ -541,11 +670,20 @@ var s = DATA.stats || {}; var items = DATA.items || []; var nums = [[s.presentations, 'Presentations'], [s.conferences, 'Conferences'], [s.countries, 'Countries']]; - app.innerHTML = '
    ' + - '
    ' + nums.map(function (n) { return '

    ' + esc(n[0] || 0) + '

    ' + n[1] + '

    '; }).join('') + '
    ' + - '
      ' + items.map(function (i) { return showcaseCard(i, { asLink: true }); }).join('') + '
    ' + + var numsHtml = '
    ' + nums.map(function (n) { return '

    ' + esc(n[0] || 0) + '

    ' + n[1] + '

    '; }).join('') + '
    '; + if (narrow(640)) { + app.innerHTML = '
    ' + numsHtml + + (items[0] ? '

    ' + esc(items[0].conf) + ' · ' + esc(items[0].title) + '

    ' : '') + + 'Explore all ' + plural(s.presentations || 0, 'presentation', 'presentations') + icon('arrow') + '' + + '
    '; + return; + } + app.innerHTML = '
    ' + numsHtml + + '
      ' + items.map(function (i) { return showcaseCard(i, { asLink: true }); }).join('') + '
    ' + ((DATA.conferences || []).length ? '
    Presented at' + DATA.conferences.map(function (c) { return '' + esc(c) + ''; }).join('') + '
    ' : '') + + '
    Explore all ' + plural(s.presentations || 0, 'presentation', 'presentations') + '' + + (LINKS.showcaseForm ? extLink(LINKS.showcaseForm, 'Presented recently? Add your work', 'plain') : '') + '
    ' + '
    '; } @@ -571,19 +709,36 @@ var VIEWS = { 'opportunities': renderOpportunities, 'open-now': renderOpenNow, + 'hero-actions': renderHeroActions, 'stats': renderStats, 'events': renderEvents, 'events-home': renderEventsHome, 'showcase': renderShowcase, + 'showcase-stats': renderShowcaseStats, 'showcase-home': renderShowcaseHome }; - if (VIEW === 'open-now' || VIEW === 'stats') document.body.classList.add('dark'); + // Each embed sits on a Google Sites section; its background has to match that section exactly. + var DARK = ['open-now', 'hero-actions', 'stats', 'showcase-stats']; + if (DARK.indexOf(VIEW) !== -1) document.body.classList.add('dark'); + if (VIEW === 'events-home' && !CONFIG.fullPage) document.body.classList.add('ground'); if (DATA.error || !VIEWS[VIEW]) { app.innerHTML = '

    This section couldn\'t load right now. Please refresh the page' + (LINKS.email ? ', or email ' + esc(LINKS.email) + ' if it keeps happening' : '') + '.

    '; return; } VIEWS[VIEW](); + + // Sites resizes the frame when the page switches between laptop and phone widths. + var lastWidth = window.innerWidth; + var timer = null; + window.addEventListener('resize', function () { + clearTimeout(timer); + timer = setTimeout(function () { + if (Math.abs(window.innerWidth - lastWidth) < 8) return; + lastWidth = window.innerWidth; + VIEWS[VIEW](); + }, 150); + }); })(); diff --git a/google-sites/apps-script/Code.gs b/google-sites/apps-script/Code.gs index c26846e..51eda09 100644 --- a/google-sites/apps-script/Code.gs +++ b/google-sites/apps-script/Code.gs @@ -4,13 +4,18 @@ * * opportunities project finder (Opportunities page) * open-now three newest projects, dark panel (Home, hero) - * stats headline numbers, dark (Home, hero) - * events-home next event, or an empty state (Home) + * hero-actions buttons + headline numbers, dark (Home, hero) + * stats headline numbers only, dark (spare) + * events-home next event + latest activities (Home) * events all upcoming events (Events page) * showcase-home numbers + three latest talks (Home) + * showcase-stats showcase numbers, dark (Showcase page, hero) * showcase filterable presentations (Showcase page) + * + * Add &full=1 for a standalone page (the "Open full screen" link on phones). */ -var VIEWS = ['opportunities', 'open-now', 'stats', 'events-home', 'events', 'showcase-home', 'showcase']; +var VIEWS = ['opportunities', 'open-now', 'hero-actions', 'stats', 'events-home', 'events', 'showcase-home', + 'showcase-stats', 'showcase']; function doGet(e) { var params = (e && e.parameter) || {}; @@ -27,7 +32,7 @@ function doGet(e) { template.data = toScriptJson_(data); template.config = toScriptJson_({ links: CONFIG.LINKS, - self: ScriptApp.getService().getUrl(), + self: appUrl_(), membersUrl: CONFIG.MEMBERS_APP_URL, memberDomain: CONFIG.MEMBER_DOMAIN, fullPage: params.full === '1' @@ -63,17 +68,31 @@ function loadView_(view, params) { viewer: { member: member } }; } - // Past activities live as hand-made posts (with photos) in Google Sites, so only upcoming events are sent. + // Full write-ups and photos of past activities live as hand-made posts in Google Sites; + // Home only shows the two latest as short "What's new" teasers. if (view === 'events') return { upcoming: events_().upcoming }; if (view === 'events-home') { - var upcoming = events_().upcoming; - return { next: upcoming[0] || null, upcomingCount: upcoming.length }; + var ev = events_(); + return { next: ev.upcoming[0] || null, upcomingCount: ev.upcoming.length, recent: ev.past.slice(0, 2) }; } var items = showcase_(); var stats = Parse.showcaseStats(items); if (view === 'showcase') return { stats: stats, items: items }; - if (view === 'showcase-home') return { stats: stats, items: items.slice(0, 3), conferences: conferenceNames_(items) }; - return { openProjects: opportunities_().items.length, stats: stats }; // 'stats' + if (view === 'showcase-stats') return { stats: stats }; + if (view === 'showcase-home') { + return { stats: stats, items: items.slice(0, 3), conferences: conferenceNames_(items) }; + } + return { openProjects: opportunities_().items.length, stats: stats }; // 'stats' and 'hero-actions' +} + +/** + * The web app's address in the club-domain form (/a/macros/docchula.com/…). The plain form sends + * people signed in to several Google accounts to the wrong one and Google shows an error instead. + */ +function appUrl_() { + var url = ScriptApp.getService().getUrl() || ''; + var m = url.match(/\/macros\/(?:u\/\d+\/)?s\/([\w-]+)\/(?:exec|dev)/); + return m ? 'https://script.google.com/a/macros/' + CONFIG.MEMBER_DOMAIN + '/s/' + m[1] + '/exec' : url; } /** diff --git a/google-sites/apps-script/Styles.html b/google-sites/apps-script/Styles.html index 0ae37c6..bbbbcf0 100644 --- a/google-sites/apps-script/Styles.html +++ b/google-sites/apps-script/Styles.html @@ -107,23 +107,91 @@ .x.sheet-close { display: inline-flex; } } + body.ground { background: var(--ground); } + .h2 { margin: 0 0 22px; font-size: 30px; font-weight: 700; line-height: 1.2; letter-spacing: -.01em; color: var(--navy-800); } + .lbl { margin: 0; font-family: var(--mono); font-size: 12.5px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--navy-800); } + .btn.big { min-height: 52px; padding: 0 24px; font-size: 16px; } + a.plain { font-weight: 500; font-size: 15.5px; } + /* Dark home panels */ - .panel { background: var(--navy-900); border: 1px solid rgba(255,255,255,.12); padding: 24px 26px 18px; } - .panel .head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 6px 16px; padding-bottom: 12px; } - .panel .head .label { color: var(--sky); } + .panel { background: var(--navy-900); border: 1px solid rgba(255,255,255,.12); padding: 26px 28px 22px; } + .panel .head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 8px 16px; padding-bottom: 14px; } + .panel .head .label { font-size: 12.5px; letter-spacing: .08em; color: var(--sky); } .panel .head .meta { color: var(--on-dark-muted); font-size: 12px; } .panel ul { list-style: none; margin: 0; padding: 0; } .panel li { border-top: 1px solid rgba(255,255,255,.12); } - .panel li a { display: block; padding: 16px 0; color: #FFFFFF; text-decoration: none; } + .panel li a { display: block; padding: 18px 0; color: #FFFFFF; text-decoration: none; } .panel li a:hover .t { text-decoration: underline; text-underline-offset: 4px; } - .panel .t { display: block; font-size: 16.5px; font-weight: 600; line-height: 1.45; } - .panel .m { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 6px; font-size: 13.5px; color: var(--on-dark-muted); } + .panel .t { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 16.5px; font-weight: 600; line-height: 1.45; text-wrap: pretty; } + .panel .m { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 8px; font-size: 13.5px; color: var(--on-dark-muted); } .panel .m .seats { font-family: var(--mono); font-size: 12px; color: var(--amber); } - .panel .all { display: flex; justify-content: space-between; align-items: center; min-height: 52px; border-top: 1px solid rgba(255,255,255,.12); padding-top: 12px; color: var(--amber); font-weight: 600; text-decoration: none; } + .panel .all { display: flex; justify-content: space-between; align-items: center; gap: 12px; min-height: 52px; border-top: 1px solid rgba(255,255,255,.12); padding-top: 14px; color: var(--amber); font-weight: 600; text-decoration: none; } + .panel .all:hover { color: #F7C25F; } + .panel .unlock { display: flex; align-items: center; gap: 8px; margin: 10px 0 0; font-size: 13px; line-height: 1.5; color: var(--on-dark-muted); } + .panel .unlock .ico { width: 16px; height: 16px; } .stats-dark { display: flex; flex-wrap: wrap; border-top: 1px solid rgba(255,255,255,.14); } - .stats-dark div { padding: 18px 40px 0 0; } + .stats-dark div { padding: 20px 40px 0 0; } .stats-dark .n { margin: 0; font-size: 34px; font-weight: 600; line-height: 1.1; color: #FFFFFF; } .stats-dark .l { margin: 6px 0 0; font-family: var(--mono); font-size: 12px; letter-spacing: .06em; text-transform: uppercase; color: var(--on-dark-muted); } + .stats-dark.wide { border-top-color: rgba(255,255,255,.18); } + .stats-dark.wide div { padding: 22px 48px 4px 0; } + .stats-dark.wide .n { font-size: 40px; font-weight: 700; letter-spacing: -.02em; } + .stats-dark.compact { flex-wrap: nowrap; } + .stats-dark.compact div { padding: 10px 14px 0 0; } + .stats-dark.compact .n { font-size: 22px; } + .stats-dark.compact .l { font-size: 9.5px; letter-spacing: .04em; } + + /* Hero buttons + numbers (Home) */ + .hero-actions { padding-top: 4px; } + .hero-actions .cta { display: flex; flex-wrap: wrap; align-items: center; gap: 16px 28px; } + .hero-actions .see { display: inline-flex; align-items: center; min-height: 44px; color: #FFFFFF; font-size: 16px; font-weight: 500; text-underline-offset: 6px; } + .hero-actions .see:hover { color: var(--amber); } + .hero-actions .stats-dark { margin-top: 48px; } + .hero-actions.compact .btn.big { width: 100%; min-height: 44px; font-size: 15px; } + .oneline { margin: 12px 0 0; padding-top: 10px; border-top: 1px solid rgba(255,255,255,.14); font-size: 13px; line-height: 1.5; color: var(--on-dark-muted); } + .oneline b { color: #FFFFFF; font-size: 15px; } + body.dark > #app > .oneline:first-child { margin-top: 2px; } + + /* Events on Home */ + .ehome { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.9fr); gap: 56px; align-items: start; padding: 2px; } + .upcard { display: flex; flex-direction: column; gap: 14px; background: #FFFFFF; border: 1px solid var(--line); padding: 28px; } + .upcard p { margin: 0; } + .upcard .ibox { display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; background: var(--sky-50); color: var(--link); } + .upcard .head { font-size: 19px; font-weight: 600; line-height: 1.35; color: var(--navy-800); } + .upcard .txt { font-size: 15.5px; line-height: 1.65; color: var(--ink-2); } + .upcard .btn { align-self: flex-start; font-size: 15px; } + .upcard .when { display: flex; align-items: baseline; gap: 10px; } + .upcard .day { font-size: 40px; font-weight: 700; line-height: 1; color: var(--navy-800); } + .upcard .mon { font-family: var(--mono); font-size: 12.5px; letter-spacing: .06em; color: var(--muted); } + .upcard h4 { margin: 0; font-size: 19px; line-height: 1.35; color: var(--navy-800); } + .upcard .where { font-size: 15px; color: var(--muted); } + .upcard .acts { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 20px; } + .upcard .cal { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; font-weight: 600; } + .news { display: grid; grid-template-columns: 140px minmax(0, 1fr); gap: 24px; padding: 24px 0; border-bottom: 1px solid var(--line); } + .news .pthumb { display: block; width: 100%; aspect-ratio: 3 / 4; object-fit: cover; border: 1px solid var(--line); background: #EEF2F6; } + .news .pthumb.date { display: flex; flex-direction: column; justify-content: flex-end; gap: 4px; padding: 12px; background: var(--navy-800); border-color: var(--navy-800); color: #FFFFFF; } + .news .pthumb.date span:first-child { font-size: 36px; font-weight: 700; line-height: 1; color: var(--amber); } + .news .pthumb.date span:last-child { font-family: var(--mono); font-size: 11.5px; letter-spacing: .06em; color: var(--on-dark-2); } + .news .body { display: flex; flex-direction: column; gap: 8px; min-width: 0; } + .news .lbl { font-size: 12px; letter-spacing: .06em; color: var(--link); } + .news h4 { margin: 0; font-size: 19px; font-weight: 600; line-height: 1.4; color: var(--navy-800); } + .news .ex { margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; font-size: 15px; line-height: 1.7; color: var(--ink-2); } + .news .more { display: inline-flex; align-items: center; align-self: flex-start; min-height: 40px; font-weight: 600; font-size: 15px; } + + /* Phone layouts of wide sections */ + .compact-box { display: flex; flex-direction: column; gap: 6px; padding: 10px 2px; font-size: 14px; line-height: 1.5; } + .compact-box p { margin: 0; } + .compact-box .head { font-weight: 600; color: var(--navy-800); } + .compact-box .lines { list-style: none; margin: 0; padding: 0; } + .compact-box .lines li { display: flex; gap: 10px; padding: 6px 0; border-top: 1px solid var(--line); } + .compact-box .lines .d { flex: none; min-width: 64px; font-family: var(--mono); font-size: 11.5px; font-weight: 600; color: var(--link); padding-top: 2px; } + .compact-box .lines .t { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; color: var(--navy-800); font-weight: 600; } + .compact-box .nums div { padding: 8px 12px 8px 0; } + .compact-box .nums .n { font-size: 26px; } + .compact-box .nums .l { font-size: 10px; margin-top: 4px; } + .compact-box .nums { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .openfull { display: inline-flex; align-items: center; gap: 6px; margin-top: 6px; min-height: 40px; font-weight: 600; } + .openfull .ico { width: 16px; height: 16px; } /* Events */ .ev { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 24px; padding: 24px 0; border-bottom: 1px solid var(--line); } @@ -158,6 +226,7 @@ .sgrid li { display: flex; } .card { display: flex; flex-direction: column; gap: 12px; width: 100%; padding: 22px; border: 1px solid var(--line); background: #FFFFFF; text-align: left; color: var(--ink); cursor: pointer; text-decoration: none; } .card:hover { border-color: var(--navy-800); } + .card .title { font-size: 17px; } .card:hover .title { text-decoration: underline; text-underline-offset: 4px; } .card[aria-pressed="true"] { border-color: var(--navy-800); box-shadow: inset 0 0 0 1px var(--navy-800); } .card .top { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; width: 100%; } @@ -173,10 +242,17 @@ .sel .clamp { display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; } @media (max-width: 860px) { .sel .grid { grid-template-columns: minmax(0, 1fr); gap: 24px; } .sel { padding: 22px; } } .nums { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } - .nums div { padding: 24px 24px 24px 0; } - .nums .n { margin: 0; font-size: 56px; font-weight: 700; line-height: 1; letter-spacing: -.02em; color: var(--navy-800); } + .nums div { padding: 28px 24px 28px 0; } + .nums .n { margin: 0; font-size: 60px; font-weight: 700; line-height: 1; letter-spacing: -.02em; color: var(--navy-800); } .nums .l { margin: 10px 0 0; font-family: var(--mono); font-size: 12.5px; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); } - .acronyms { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 22px; margin-top: 32px; padding-top: 20px; border-top: 1px solid var(--line-soft); font-family: var(--mono); font-size: 14px; color: var(--muted); } + .sgrid.home { margin-top: 48px; gap: 24px; } + .sgrid.home .card { gap: 16px; padding: 28px; } + .sgrid.home .card .title { font-size: 18px; } + .sgrid.home .card .by { padding-top: 16px; font-size: 14.5px; } + .sgrid.home .card .by .avatar, .sgrid.home .card .by img { width: 36px; height: 36px; font-size: 15px; } + .acronyms { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px 22px; margin-top: 40px; padding-top: 24px; border-top: 1px solid var(--line-soft); font-family: var(--mono); font-size: 14px; color: var(--muted); } + .acronyms .label { font-size: 12px; letter-spacing: .08em; } + .actions { display: flex; flex-wrap: wrap; align-items: center; gap: 16px 28px; margin-top: 40px; } .more { display: flex; justify-content: center; margin-top: 28px; } /* Opportunity details hidden until sign-in */ diff --git a/google-sites/test/preview.mjs b/google-sites/test/preview.mjs index 8a6fc71..1f5cef7 100644 --- a/google-sites/test/preview.mjs +++ b/google-sites/test/preview.mjs @@ -8,17 +8,34 @@ import { render } from './harness.mjs'; const out = resolve(process.argv[2] || new URL('./out', import.meta.url).pathname); const now = process.argv[3]; -const views = ['opportunities', 'open-now', 'stats', 'events-home', 'events', 'showcase-home', 'showcase']; +const views = ['opportunities', 'open-now', 'hero-actions', 'stats', 'events-home', 'events', 'showcase-home', 'showcase-stats', 'showcase']; const pages = views.map((view) => [view, view, {}]).concat([ ['opportunities-member', 'opportunities', { email: 'student@docchula.com' }], - ['opportunities-full', 'opportunities', { email: 'student@docchula.com', params: { full: '1' } }] + ['opportunities-full', 'opportunities', { email: 'student@docchula.com', params: { full: '1' } }], + ['showcase-full', 'showcase', { params: { full: '1' } }] ]); +// Frames each view at the size its Sites embed uses, and at the size Sites shrinks it to on a phone +// (Sites keeps the width-to-height ratio), so both can be checked side by side. +const FRAMES = [ + ['hero-actions', 620, 210, '#0B1630'], ['open-now', 440, 500, '#0B1630'], ['events-home', 1100, 600, '#F4F7FA'], + ['showcase-home', 1100, 800, '#FFFFFF'], ['opportunities', 1100, 1150, '#FFFFFF'], ['events', 1100, 330, '#FFFFFF'], + ['showcase-stats', 900, 120, '#0B1630'], ['showcase', 1100, 1400, '#FFFFFF'] +]; +const frameHtml = (w, h, bg) => FRAMES.map(([v, fw, fh, fbg]) => { + const s = Math.min(1, w / fw); + return `

    ${v} — ${Math.round(fw * s)}×${Math.round(fh * s)}

    ` + + `
    `; +}).join(''); + mkdirSync(out, { recursive: true }); for (const [file, view, extra] of pages) { writeFileSync(resolve(out, file + '.html'), render(view, { fixtures, ...(now ? { now } : {}), ...extra })); } +writeFileSync(resolve(out, 'frames-desktop.html'), '' + frameHtml(1100) + ''); +writeFileSync(resolve(out, 'frames-phone.html'), '' + frameHtml(330) + ''); writeFileSync(resolve(out, 'index.html'), 'Widget previews
      ' + - pages.map(([file]) => `
    • ${file}
    • `).join('') + '
    '); + pages.map(([file]) => `
  • ${file}
  • `).join('') + + '
  • frames-desktop
  • frames-phone
  • '); console.log('Wrote ' + pages.length + ' previews to ' + out); diff --git a/google-sites/test/widgets.test.mjs b/google-sites/test/widgets.test.mjs index 4c6ae6b..308088e 100644 --- a/google-sites/test/widgets.test.mjs +++ b/google-sites/test/widgets.test.mjs @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import * as fixtures from './fixtures.mjs'; import { render, payload } from './harness.mjs'; -const VIEWS = ['opportunities', 'open-now', 'stats', 'events-home', 'events', 'showcase-home', 'showcase']; +const VIEWS = ['opportunities', 'open-now', 'hero-actions', 'stats', 'events-home', 'events', 'showcase-home', 'showcase-stats', 'showcase']; test('every view renders a complete page without the error fallback', () => { for (const view of VIEWS) { @@ -46,12 +46,28 @@ test('events: only upcoming events are sent; real date cells are read regardless assert.equal(data.past, undefined, 'past activities are hand-made posts in Google Sites'); }); -test('events-home: next event, or an empty state once it has passed', () => { +test('events-home: next event, or an empty state once it has passed, plus the two latest activities', () => { const later = payload(render('events-home', { fixtures, now: '2027-01-10T09:00:00+07:00' })); - assert.deepEqual(later, { next: null, upcomingCount: 0 }); + assert.equal(later.next, null); + assert.equal(later.upcomingCount, 0); + assert.deepEqual(later.recent.map((e) => e.date), ['2026-12-05', '2026-11-20']); const now = payload(render('events-home', { fixtures })); assert.equal(now.next.title, 'Sample workshop: writing your first abstract'); assert.equal(now.upcomingCount, 2); + assert.deepEqual(now.recent.map((e) => e.title), ['Sample First Meet', 'Sample statistics workshop']); +}); + +test('hero-actions and showcase-stats send only numbers', () => { + const hero = payload(render('hero-actions', { fixtures })); + assert.equal(hero.openProjects, 3); + assert.equal(hero.stats.presentations, 4); + const sc = payload(render('showcase-stats', { fixtures })); + assert.deepEqual(Object.keys(sc), ['stats']); +}); + +test('links to the standalone page use the club-domain address', () => { + const html = render('opportunities', { fixtures }); + assert.match(html, /"self":"https:\/\/script\.google\.com\/a\/macros\/docchula\.com\/s\/TEST\/exec"/); }); test('showcase: approved rows only, stats across conferences and countries', () => { From 6bd2f1ec89d2a064047971516ff1ea68e0a7e3e8 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Sat, 24 Oct 2026 17:18:54 +0700 Subject: [PATCH 10/21] google-sites - link showcase entries to their original Researcher Profile page --- google-sites/apps-script/App.html | 1 + google-sites/apps-script/Parse.gs | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/google-sites/apps-script/App.html b/google-sites/apps-script/App.html index f87fd62..50dfe6f 100644 --- a/google-sites/apps-script/App.html +++ b/google-sites/apps-script/App.html @@ -583,6 +583,7 @@ '

    ' + esc(s.abstract) + '

    ' + '
    ' : '') + (s.reflection ? '

    Reflection

    ' + esc(s.reflection) + '

    ' : '') + + (s.page ? '

    View the original page

    ' : '') + '
    ' + '
    ' + '
    ' + presenterBadge(s) + diff --git a/google-sites/apps-script/Parse.gs b/google-sites/apps-script/Parse.gs index 3c749b1..79b6e07 100644 --- a/google-sites/apps-script/Parse.gs +++ b/google-sites/apps-script/Parse.gs @@ -328,7 +328,8 @@ var Parse = (function () { advisors: ['advisor', 'อาจารย์ที่ปรึกษา'], abstract: ['abstract', 'บทคัดย่อ'], reflection: ['reflection', 'สิ่งที่ได้เรียนรู้'], - photo: ['photo', 'รูป'] + photo: ['photo', 'รูป'], + page: ['page link'] }; function formatOf(raw) { @@ -371,7 +372,8 @@ var Parse = (function () { reflection: cell(row, col.reflection), presenter: cell(row, col.presenter), cls: cell(row, col.cls).toUpperCase().replace(/\s+/g, ''), - photo: driveImageUrl(cell(row, col.photo)) + photo: driveImageUrl(cell(row, col.photo)), + page: safeUrl(cell(row, col.page)) }); } return out.sort(function (a, b) { return (b.date || b.year) > (a.date || a.year) ? 1 : -1; }); From b03482e3b1d6747646ab8cb6afd7a37ebb83e109 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Sat, 24 Oct 2026 17:24:46 +0700 Subject: [PATCH 11/21] google-sites - add authorize() to re-request missing permissions --- google-sites/apps-script/Code.gs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/google-sites/apps-script/Code.gs b/google-sites/apps-script/Code.gs index 51eda09..94b0c62 100644 --- a/google-sites/apps-script/Code.gs +++ b/google-sites/apps-script/Code.gs @@ -48,6 +48,15 @@ function include(name) { return HtmlService.createHtmlOutputFromFile(name).getContent(); } +/** + * Run from the editor once after deploying, or whenever the site says a section "couldn't load": + * it makes Google ask for any permission that hasn't been granted yet (tick every box). + */ +function authorize() { + ScriptApp.requireAllScopes(ScriptApp.AuthMode.FULL); + return SpreadsheetApp.openById(CONFIG.OPPORTUNITIES_SHEET_ID).getName(); +} + /** Run from the editor to make sheet edits show up immediately instead of within CACHE_SECONDS. */ function clearCache() { var cache = CacheService.getScriptCache(); From d8dbff086b3d80dd42b47a22c2fa5e5dfe20ba11 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Sat, 24 Oct 2026 17:32:11 +0700 Subject: [PATCH 12/21] google-sites - open page links from embeds in a new tab, keep the hero buttons when sheets can't be read Google Sites embeds can't navigate the page around them, so target=_top links did nothing there. --- google-sites/apps-script/App.html | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/google-sites/apps-script/App.html b/google-sites/apps-script/App.html index 50dfe6f..a0c1066 100644 --- a/google-sites/apps-script/App.html +++ b/google-sites/apps-script/App.html @@ -34,7 +34,7 @@ function icon(name) { return ''; } - /** External links open in a new tab; page links use the . */ + /** External links open in a new tab; page links follow the target (see boot). */ function extLink(url, html, cls) { return '' + html + ''; } @@ -363,8 +363,11 @@ (o.seats ? '' + esc(plural(Number(o.seats), 'seat', 'seats')) + '' : '') + ''; }).join('') + '' - : '

    New projects for this year are on their way — check back soon.

    ') + - 'Browse all ' + plural(DATA.total || 0, 'project', 'projects') + icon('arrow') + '' + + : '

    ' + (DATA.error + ? 'This year’s projects are listed on the Opportunities page.' + : 'New projects for this year are on their way — check back soon.') + '

    ') + + 'Browse all ' + + (DATA.total ? plural(DATA.total, 'project', 'projects') : 'projects') + icon('arrow') + '' + (member ? '' : '

    ' + icon('lock') + 'Advisors and contacts unlock with your ' + esc(MEMBER_DOMAIN) + ' account

    ') + ''; } @@ -719,11 +722,17 @@ 'showcase-home': renderShowcaseHome }; + // Google Sites runs embeds in a frame that isn't allowed to navigate the page around it, so links to the + // site's pages open a new tab there. The standalone page (&full=1) can go back to the site in place. + if (!CONFIG.fullPage) document.querySelector('base').setAttribute('target', '_blank'); + // Each embed sits on a Google Sites section; its background has to match that section exactly. var DARK = ['open-now', 'hero-actions', 'stats', 'showcase-stats']; if (DARK.indexOf(VIEW) !== -1) document.body.classList.add('dark'); if (VIEW === 'events-home' && !CONFIG.fullPage) document.body.classList.add('ground'); - if (DATA.error || !VIEWS[VIEW]) { + // The Home hero keeps its buttons and links even when the sheets can't be read. + var WORKS_WITHOUT_DATA = ['hero-actions', 'open-now']; + if (!VIEWS[VIEW] || (DATA.error && WORKS_WITHOUT_DATA.indexOf(VIEW) === -1)) { app.innerHTML = '

    This section couldn\'t load right now. Please refresh the page' + (LINKS.email ? ', or email ' + esc(LINKS.email) + ' if it keeps happening' : '') + '.

    '; return; From f0a98abe1d9c54d76c842ab34587bf12df23639f Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Sat, 24 Oct 2026 17:41:54 +0700 Subject: [PATCH 13/21] google-sites - events widget: Upcoming label and the artifact's wide empty state --- google-sites/apps-script/App.html | 11 +++++++++-- google-sites/apps-script/Styles.html | 6 +++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/google-sites/apps-script/App.html b/google-sites/apps-script/App.html index a0c1066..80c5c40 100644 --- a/google-sites/apps-script/App.html +++ b/google-sites/apps-script/App.html @@ -117,6 +117,11 @@ function emptyBlock(head, body, actionHtml) { return '

    ' + esc(head) + '

    ' + esc(body) + '

    ' + (actionHtml || '') + '
    '; } + /** Wide variant: icon, text, then the action on the right. */ + function emptyRow(iconName, head, body, actionHtml) { + return '
    ' + icon(iconName) + '' + + '

    ' + esc(head) + '

    ' + esc(body) + '

    ' + (actionHtml || '') + '
    '; + } function followButton() { return LINKS.instagram ? extLink(LINKS.instagram, icon('ig') + 'Follow @researchclub.mdcu', 'btn ghost') : ''; } @@ -476,11 +481,13 @@ return; } app.innerHTML = '
    ' + - (CONFIG.fullPage ? '
    MDCU Research Division · Upcoming eventsBack to the website
    ' : '') + + (CONFIG.fullPage + ? '
    MDCU Research Division · Upcoming eventsBack to the website
    ' + : '

    Upcoming

    ') + (upcoming.length ? nextCard(upcoming[0]) + (upcoming.length > 1 ? '
      ' + upcoming.slice(1).map(eventRow).join('') + '
    ' : '') - : emptyBlock('No upcoming events yet', 'New dates are posted on Instagram first. This list updates on its own as soon as the team adds the next event.', followButton())) + + : emptyRow('cal', 'No upcoming events yet', 'New dates are posted on Instagram first. This list updates on its own as soon as the team adds the next event.', followButton())) + '
    '; } diff --git a/google-sites/apps-script/Styles.html b/google-sites/apps-script/Styles.html index bbbbcf0..0a8eef6 100644 --- a/google-sites/apps-script/Styles.html +++ b/google-sites/apps-script/Styles.html @@ -67,6 +67,11 @@ .empty { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; padding: 32px 28px; border: 1px solid var(--line); background: var(--ground); } .empty p { margin: 0; } .empty .head { font-size: 19px; font-weight: 600; color: var(--navy-800); } + .empty.row { flex-direction: row; flex-wrap: wrap; align-items: center; gap: 16px 24px; padding: 22px 24px; } + .empty.row .txt { display: flex; flex-direction: column; gap: 4px; flex: 1 1 320px; min-width: 0; } + .empty.row .head { font-size: 17px; } + .empty.row .txt p + p { font-size: 14.5px; line-height: 1.6; color: var(--ink-2); } + .ibox { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 44px; height: 44px; background: var(--sky-50); color: var(--link); } .error { padding: 24px; border: 1px solid var(--line); background: var(--ground); color: var(--ink-2); } body.dark .error { background: var(--navy-900); border-color: rgba(255,255,255,.14); color: var(--on-dark-2); } @@ -156,7 +161,6 @@ .ehome { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.9fr); gap: 56px; align-items: start; padding: 2px; } .upcard { display: flex; flex-direction: column; gap: 14px; background: #FFFFFF; border: 1px solid var(--line); padding: 28px; } .upcard p { margin: 0; } - .upcard .ibox { display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; background: var(--sky-50); color: var(--link); } .upcard .head { font-size: 19px; font-weight: 600; line-height: 1.35; color: var(--navy-800); } .upcard .txt { font-size: 15.5px; line-height: 1.65; color: var(--ink-2); } .upcard .btn { align-self: flex-start; font-size: 15px; } From a6c4aeff16d256e946068aa82d554589cb41e522 Mon Sep 17 00:00:00 2001 From: Punnawit9285 Date: Sat, 24 Oct 2026 18:10:24 +0700 Subject: [PATCH 14/21] google-sites - match the artifact's finder and showcase headers, open sign-in as its own page Drops the extra Find a project / Browse presentations headings, keeps the Home showcase cards in three columns, and sends Sign in to the standalone finder, where Google can tell the script who is signed in. --- google-sites/apps-script/App.html | 15 ++++++++++----- google-sites/apps-script/Styles.html | 3 +-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/google-sites/apps-script/App.html b/google-sites/apps-script/App.html index 80c5c40..5e4d36c 100644 --- a/google-sites/apps-script/App.html +++ b/google-sites/apps-script/App.html @@ -155,11 +155,16 @@ return 110 + (h % 70); } - /** Opens the members' copy of this widget, or Google sign-in for the club's domain. */ + /** + * Inside Google Sites, browsers often hide who is signed in from the embed, so the link opens the finder as its + * own page (the members' copy when there is one). On that page it goes to Google sign-in and straight back. + */ function signInLink(label, cls) { - if (CONFIG.membersUrl) return extLink(CONFIG.membersUrl + '?view=opportunities&full=1', icon('lock') + esc(label), cls); + var base = CONFIG.membersUrl || CONFIG.self; + var full = base ? base + '?view=opportunities&full=1' : ''; + if (full && !CONFIG.fullPage) return extLink(full, icon('lock') + esc(label), cls); var url = 'https://accounts.google.com/ServiceLogin?hd=' + encodeURIComponent(MEMBER_DOMAIN) + - '&continue=' + encodeURIComponent(LINKS.opportunitiesPage || LINKS.site || ''); + '&continue=' + encodeURIComponent(full || LINKS.opportunitiesPage || LINKS.site || ''); return '' + icon('lock') + esc(label) + ''; } @@ -289,7 +294,7 @@ } app.innerHTML = '
    ' + - (CONFIG.fullPage ? fullPageBar() : '

    Find a project

    ') + + (CONFIG.fullPage ? fullPageBar() : '') + (member ? memberNote() : signInBanner()) + '
    ' + '