From 0ea4c78aac0afd0c80c546243f717ab214778cd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 05:51:53 +0000 Subject: [PATCH] feat(dashboard): show event name in page title and social meta Registration and event form pages all shipped the static "Buzz Dashboard" title, so search results and link previews showed nothing about the event. The dashboard is one SPA served from a single jinja shell, so the route is resolved server-side in page_meta.py and rendered into the title and og/twitter tags. usePageMeta keeps the title correct on client-side navigation. meta_image and short_description on Buzz Event are now used for the first time. Titles: - /b/register/ -> " - Register" - /b//
-> " - Talk Proposal" - /b/event-proposal -> Buzz Settings banner title - everything else -> "Buzz Dashboard" Only published events and published form rows resolve, so an unpublished event cannot leak its title to guests. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01462VDacTH4karkuxhXboZf --- buzz/page_meta.py | 92 ++++++++++ buzz/test_page_meta.py | 157 ++++++++++++++++++ buzz/www/dashboard.py | 7 + dashboard/index.html | 14 +- .../src/components/BaseCustomEventForm.vue | 9 +- dashboard/src/pages/BookTickets.vue | 7 +- dashboard/src/pages/EventProposalForm.vue | 7 +- dashboard/src/router.ts | 14 +- 8 files changed, 302 insertions(+), 5 deletions(-) create mode 100644 buzz/page_meta.py create mode 100644 buzz/test_page_meta.py diff --git a/buzz/page_meta.py b/buzz/page_meta.py new file mode 100644 index 00000000..fd7e211c --- /dev/null +++ b/buzz/page_meta.py @@ -0,0 +1,92 @@ +# Copyright (c) 2025, BWH Studios and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.utils.data import get_url + +from buzz.events.doctype.buzz_event.buzz_event import RESERVED_EVENT_ROUTES + +DEFAULT_TITLE = "Buzz Dashboard" +TITLE_SEPARATOR = " - " +MAX_DESCRIPTION_LENGTH = 160 + + +def get_page_meta(app_path: str | None) -> dict: + """Return the browser title and social meta tags for a dashboard route. + + The whole dashboard is one SPA served from a single Jinja shell + (www/dashboard.html), so the route has to be resolved here for crawlers and + link previews to see anything other than the default title. + """ + segments = [segment for segment in (app_path or "").split("/") if segment] + try: + meta = resolve_meta(segments) + except Exception: + # This renders the shell for every dashboard route, so a bad title must + # never take the whole SPA down with it. + frappe.log_error("Failed to resolve dashboard page meta") + meta = {} + + title = meta.get("title") or DEFAULT_TITLE + image = meta.get("image") + return { + "title": title, + "description": clean_description(meta.get("description")) or title, + "image": get_url(image) if image else "", + } + + +def resolve_meta(segments: list[str]) -> dict: + if len(segments) == 2 and segments[0] == "register": + event = get_published_event(segments[1]) + return get_event_meta(event, _("Register")) if event else {} + + if segments == ["event-proposal"]: + # Not tied to an event: this is the form for proposing a new one. + banner_title = frappe.db.get_single_value("Buzz Settings", "event_proposal_banner_title") + return {"title": banner_title or _("Propose an Event")} + + # Mirrors the vue-router catch-all //, matched last there too. + if len(segments) == 2 and segments[0] not in RESERVED_EVENT_ROUTES: + event = get_published_event(segments[0]) + if not event: + return {} + form_doctype = get_published_form_doctype(event.name, segments[1]) + return get_event_meta(event, _(form_doctype)) if form_doctype else {} + + return {} + + +def get_event_meta(event: frappe._dict, suffix: str) -> dict: + return { + "title": f"{event.title}{TITLE_SEPARATOR}{suffix}", + "description": event.short_description, + "image": event.meta_image or event.banner_image, + } + + +def get_published_event(event_route: str) -> frappe._dict | None: + # Filtering on is_published matters: this runs for Guests and db reads + # bypass permissions, so an unpublished event would leak its title. + return frappe.db.get_value( + "Buzz Event", + {"route": event_route, "is_published": 1}, + ["name", "title", "short_description", "meta_image", "banner_image"], + as_dict=True, + ) + + +def get_published_form_doctype(event: str, form_route: str) -> str | None: + return frappe.db.get_value( + "Buzz Event Form", + {"parent": event, "parenttype": "Buzz Event", "route": form_route, "publish": 1}, + "form_doctype", + ) + + +def clean_description(description: str | None) -> str: + text = " ".join((description or "").split()) + if len(text) > MAX_DESCRIPTION_LENGTH: + text = text[:MAX_DESCRIPTION_LENGTH].rstrip() + "..." + return text diff --git a/buzz/test_page_meta.py b/buzz/test_page_meta.py new file mode 100644 index 00000000..c1a3022a --- /dev/null +++ b/buzz/test_page_meta.py @@ -0,0 +1,157 @@ +# Copyright (c) 2025, BWH Studios and contributors +# For license information, please see license.txt + +import frappe +from frappe.tests import IntegrationTestCase + +from buzz.page_meta import DEFAULT_TITLE, get_page_meta + + +def ensure_prompt_named_record(doctype, name): + # Event Category / Event Host use autoname "prompt" -> name set explicitly. + if frappe.db.exists(doctype, name): + return name + doc = frappe.new_doc(doctype) + doc.name = name + doc.insert(ignore_permissions=True) + return doc.name + + +class TestPageMeta(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.category = ensure_prompt_named_record("Event Category", "Test Page Meta Category") + cls.host = ensure_prompt_named_record("Event Host", "Test Page Meta Host") + + def make_event(self, is_published=1, route=None, form_route=None, publish_form=1, **kwargs): + event = frappe.new_doc("Buzz Event") + event.update( + { + "title": f"Test Event {frappe.generate_hash(length=6)}", + "start_date": "2030-01-01", + "end_date": "2030-01-01", + "start_time": "10:00:00", + "end_time": "18:00:00", + "medium": "Online", + "category": self.category, + "host": self.host, + "is_published": is_published, + } + ) + if route: + event.route = route + event.update(kwargs) + event.set("custom_forms", []) + if form_route: + event.append( + "custom_forms", + {"form_doctype": "Talk Proposal", "route": form_route, "publish": publish_form}, + ) + event.insert(ignore_permissions=True) + event.reload() + return event + + +class TestRegisterPageMeta(TestPageMeta): + def test_title_has_event_name(self): + event = self.make_event() + meta = get_page_meta(f"register/{event.route}") + self.assertIn(event.title, meta["title"]) + self.assertIn("Register", meta["title"]) + + def test_short_description_used_as_description(self): + event = self.make_event(short_description="Three days of Frappe talks.") + meta = get_page_meta(f"register/{event.route}") + self.assertEqual(meta["description"], "Three days of Frappe talks.") + + def test_description_falls_back_to_title(self): + event = self.make_event() + meta = get_page_meta(f"register/{event.route}") + self.assertEqual(meta["description"], meta["title"]) + + def test_image_is_absolute_url(self): + event = self.make_event(meta_image="/files/meta.png") + meta = get_page_meta(f"register/{event.route}") + self.assertTrue(meta["image"].startswith("http")) + self.assertTrue(meta["image"].endswith("/files/meta.png")) + + def test_banner_image_used_when_no_meta_image(self): + event = self.make_event(banner_image="/files/banner.png") + meta = get_page_meta(f"register/{event.route}") + self.assertTrue(meta["image"].endswith("/files/banner.png")) + + def test_no_image_returns_empty_string(self): + event = self.make_event() + meta = get_page_meta(f"register/{event.route}") + self.assertEqual(meta["image"], "") + + def test_unpublished_event_title_does_not_leak(self): + event = self.make_event(is_published=0, route="unpublished-page-meta-event") + meta = get_page_meta(f"register/{event.route}") + self.assertEqual(meta["title"], DEFAULT_TITLE) + self.assertNotIn(event.title, meta["title"]) + + def test_unknown_event_route_returns_default(self): + meta = get_page_meta("register/no-such-event") + self.assertEqual(meta["title"], DEFAULT_TITLE) + + +class TestCustomFormPageMeta(TestPageMeta): + def test_title_has_event_name_and_form(self): + event = self.make_event(form_route="talks") + meta = get_page_meta(f"{event.route}/talks") + self.assertIn(event.title, meta["title"]) + self.assertIn("Talk Proposal", meta["title"]) + + def test_unpublished_form_returns_default(self): + event = self.make_event(form_route="talks", publish_form=0) + meta = get_page_meta(f"{event.route}/talks") + self.assertEqual(meta["title"], DEFAULT_TITLE) + + def test_unknown_form_route_returns_default(self): + event = self.make_event(form_route="talks") + meta = get_page_meta(f"{event.route}/no-such-form") + self.assertEqual(meta["title"], DEFAULT_TITLE) + + def test_unpublished_event_form_returns_default(self): + event = self.make_event(is_published=0, route="unpublished-form-page-meta-event", form_route="talks") + meta = get_page_meta(f"{event.route}/talks") + self.assertEqual(meta["title"], DEFAULT_TITLE) + + def test_reserved_route_is_not_treated_as_event(self): + # /b/account/bookings is an app route, not /. + meta = get_page_meta("account/bookings") + self.assertEqual(meta["title"], DEFAULT_TITLE) + + +class TestEventProposalPageMeta(TestPageMeta): + def set_banner_title(self, title): + # set_single_value over save(): Buzz Settings has unrelated mandatory + # fields, and this clears the single-value cache get_page_meta reads. + frappe.db.set_single_value("Buzz Settings", "event_proposal_banner_title", title) + + def test_banner_title_from_settings(self): + self.set_banner_title("Host a Buzz Event") + meta = get_page_meta("event-proposal") + self.assertEqual(meta["title"], "Host a Buzz Event") + + def test_falls_back_when_banner_title_blank(self): + self.set_banner_title("") + meta = get_page_meta("event-proposal") + self.assertEqual(meta["title"], "Propose an Event") + + +class TestFallbackPageMeta(TestPageMeta): + def test_no_app_path_returns_default(self): + for app_path in (None, "", "/"): + self.assertEqual(get_page_meta(app_path)["title"], DEFAULT_TITLE) + + def test_account_route_returns_default(self): + self.assertEqual(get_page_meta("account/tickets/TKT-0001")["title"], DEFAULT_TITLE) + + def test_default_meta_is_never_empty_title(self): + meta = get_page_meta("check-in") + self.assertEqual(meta["title"], DEFAULT_TITLE) + self.assertEqual(meta["description"], DEFAULT_TITLE) + self.assertEqual(meta["image"], "") diff --git a/buzz/www/dashboard.py b/buzz/www/dashboard.py index 93a243be..1c1f8549 100644 --- a/buzz/www/dashboard.py +++ b/buzz/www/dashboard.py @@ -5,6 +5,8 @@ from frappe import _ from frappe.utils import get_system_timezone +from buzz.page_meta import get_page_meta + no_cache = 1 @@ -14,6 +16,11 @@ def get_context(): context = frappe._dict() context.boot = get_boot() context.boot.csrf_token = csrf_token + + meta = get_page_meta(frappe.form_dict.get("app_path")) + context.title = meta["title"] + context.meta_description = meta["description"] + context.meta_image = meta["image"] return context diff --git a/dashboard/index.html b/dashboard/index.html index ffb213a6..745702e2 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -4,7 +4,19 @@ - Buzz Dashboard + + {{ title | e }} + + + + + + + + +
diff --git a/dashboard/src/components/BaseCustomEventForm.vue b/dashboard/src/components/BaseCustomEventForm.vue index ce76f0c5..43f702b8 100644 --- a/dashboard/src/components/BaseCustomEventForm.vue +++ b/dashboard/src/components/BaseCustomEventForm.vue @@ -157,7 +157,7 @@ import EventDetailsHeader from "@/components/EventDetailsHeader.vue"; import FormFieldSections from "@/components/FormFieldSections.vue"; import LoginRequired from "@/components/LoginRequired.vue"; import type { FrappeError } from "@/types"; -import { Button, Dialog, Spinner, createResource, toast } from "frappe-ui"; +import { Button, Dialog, Spinner, createResource, toast, usePageMeta } from "frappe-ui"; import { marked } from "marked"; import { computed, reactive, ref } from "vue"; import LucideAlertCircle from "~icons/lucide/alert-circle"; @@ -202,6 +202,13 @@ const props = defineProps({ }); const formData = ref(null); + +usePageMeta(() => { + const eventTitle = formData.value?.event?.title; + const formTitle = formData.value?.form_title; + return eventTitle && formTitle ? { title: `${eventTitle} - ${formTitle}` } : null; +}); + const formValues = reactive>({}); const customFieldValues = ref>({}); const submitted = ref(false); diff --git a/dashboard/src/pages/BookTickets.vue b/dashboard/src/pages/BookTickets.vue index 48c73590..1db2241a 100644 --- a/dashboard/src/pages/BookTickets.vue +++ b/dashboard/src/pages/BookTickets.vue @@ -71,7 +71,7 @@ import type { OfflineMethod, } from "@/types"; import { session } from "@/data/session"; -import { Spinner, createResource } from "frappe-ui"; +import { Spinner, createResource, usePageMeta } from "frappe-ui"; import { computed, reactive, ref, watch } from "vue"; import BookingForm from "../components/BookingForm.vue"; @@ -105,6 +105,11 @@ const props = defineProps({ const isGuest = computed(() => !session.isLoggedIn); +usePageMeta(() => { + const eventTitle = eventBookingData.eventDetails?.title; + return eventTitle ? { title: `${eventTitle} - ${__("Register")}` } : null; +}); + const goToHome = () => { window.location.href = "/"; }; diff --git a/dashboard/src/pages/EventProposalForm.vue b/dashboard/src/pages/EventProposalForm.vue index 2e59ebbb..f7665270 100644 --- a/dashboard/src/pages/EventProposalForm.vue +++ b/dashboard/src/pages/EventProposalForm.vue @@ -82,7 +82,7 @@ import CustomFieldInput from "@/components/CustomFieldInput.vue"; import FormFieldSections from "@/components/FormFieldSections.vue"; import LoginRequired from "@/components/LoginRequired.vue"; import type { FrappeError, FrappeField } from "@/types"; -import { Button, Spinner, createResource, toast } from "frappe-ui"; +import { Button, Spinner, createResource, toast, usePageMeta } from "frappe-ui"; import { marked } from "marked"; import { computed, reactive, ref } from "vue"; import LucideAlertCircle from "~icons/lucide/alert-circle"; @@ -101,6 +101,11 @@ const submitted = ref(false); const login_required = ref(false); const load_error = ref(null); +usePageMeta(() => { + const bannerTitle = form_data.value?.banner_title; + return bannerTitle ? { title: bannerTitle } : null; +}); + const rendered_success_message = computed(() => { const msg = form_data.value?.success_message; if (!msg) return ""; diff --git a/dashboard/src/router.ts b/dashboard/src/router.ts index c55c2e60..21df19a6 100644 --- a/dashboard/src/router.ts +++ b/dashboard/src/router.ts @@ -1,5 +1,8 @@ import { userResource } from "@/data/user" -import { type RouteRecordRaw, createRouter, createWebHistory } from "vue-router" +import { type RouteRecordRaw, START_LOCATION, createRouter, createWebHistory } from "vue-router" + +// Mirrors DEFAULT_TITLE in buzz/page_meta.py, which renders the initial title. +const DEFAULT_TITLE = "Buzz Dashboard" const routes: RouteRecordRaw[] = [ { @@ -147,4 +150,13 @@ router.beforeEach(async (to, from, next) => { next() }) +router.afterEach((to, from) => { + // Pages set their own title via usePageMeta. Reset on client-side navigation + // so a page without one doesn't keep the previous page's title. The first + // load is skipped to preserve the server-rendered title. + if (from !== START_LOCATION) { + document.title = DEFAULT_TITLE + } +}) + export default router