diff --git a/.claude/memory/memory.md b/.claude/memory/memory.md new file mode 100644 index 00000000..0e4cf627 --- /dev/null +++ b/.claude/memory/memory.md @@ -0,0 +1,171 @@ +# Buzz — project memory + +## Theme system + +Ported from the standalone `frappe_themes` + `buzz_themes` apps into buzz; both were +then uninstalled. Jinja-only — no SPA/Vue theming, `dashboard/` is not involved. + +### Layout + + buzz/buzz_themes/ engine. Module path for module "Buzz Themes". + theme_resolver.py, jinja_helpers.py, doctype/, + buzz_theme/ (exported theme records), tests/ + buzz/themes// PRIVATE templates: components/, pages/, + styles/tokens.css, tailwind.input.css + buzz/public/themes// PUBLIC, real directories: tailwind.output.css, + scripts/, images/ + +The private/public split is load-bearing. These were once symlinks from public -> +private, which served the raw Jinja at `/assets/buzz/themes//pages/...` — +data model, filters and all. Never symlink them back. + +One toolchain at `buzz/themes/package.json` builds all three CSS outputs. Each +build must `cd` into its own theme dir: running them from `buzz/themes/` changes +Tailwind v4's source-detection root and every theme absorbs the others' utilities. + +### Inheritance — the dedup mechanism + +`Buzz Base Theme` owns the shared assets (alpine/lucide vendor JS, theme.js, +components.js, images/placeholder.svg). The three real themes set +`parent_theme = "Buzz Base Theme"`. `buzz_theme_asset_url()` walks the chain +child-first and returns the first theme that HAS the file, so a child overrides by +shipping the same relative path. This removed 1.12 MiB of byte-identical blobs. + +### Theme resolution — one theme per site + + 1. ?preview_theme= developer_mode only + 2. Buzz Settings.default_theme + 3. nothing -> renderer declines, normal Frappe routing + +Per-event theming existed briefly and was removed on purpose: a listing page and +the events it linked to rendered in different design languages, so clicking a card +changed the design mid-journey. Hosted platforms attach the theme to the organizer +and let an event vary only content. Do not reintroduce `Buzz Event.theme`. + +With `default_theme` unset the engine is inert — the safety valve. + +### Configuration — nothing about identity is hardcoded + +Brand and footer come from Frappe's own **Website Settings**: `app_name`, +`app_logo`, `brand_html`, `banner_image`, `footer_logo`, `copyright`, `address`, +`footer_items` (links AND socials — no bespoke social fields). `build_base_context` +calls `get_website_settings()`; without it themed pages render missing the +site-wide context Frappe's own renderers provide. + +Per-theme presentational copy lives in a Single per theme (`Stickerpack Theme +Settings`, `Buzz Events Theme Settings`, `Sketchbook Theme Settings`), linked from +`Buzz Theme.theme_settings` and read in templates via `buzz_theme_config()`. Every +field falls back to the original literal so a blank never renders an empty heading. + +### Gotchas paid for the hard way + +**A fixture whose `modified` is not newer than the DB row is SILENTLY SKIPPED.** +Editing `buzz/buzz_themes/buzz_theme//.json` does nothing until you also +bump `modified`. Cost time three separate times. + +**Records of a custom doctype do not import on migrate** unless the doctype is in +the `importable_doctypes` hook (`frappe/model/sync.py`). The module folder is an +EXPORT target only. + +**Adding a module to an installed app needs a Module Def created by hand** — +`add_module_defs()` runs only at install, never on migrate. Until it exists, +`sync_for()` skips the module and the doctypes are silently not created. + +**Jinja methods share one global namespace keyed by FUNCTION NAME** +(`frappe/utils/jinja.py`, `out[function_name] = obj`, last app wins). Buzz's helpers +are `buzz_theme_asset_url` / `buzz_theme_config` because the unprefixed names lost +to `frappe_themes` and every page rendered with empty asset URLs — a silent failure +that looks like broken CSS, not an error. + +**A template that `{% extends %}` discards top-level OUTPUT nodes.** +`{{ frappe.throw(...) }}` at the top of an extending page never runs; only `{% set %}` +does. The not-found guards silently did nothing and a missing event fell through to +`get_cached_doc(..., None)`, whose `DoesNotExistError` became **403 for guests**. +Guards must be `{% set not_found = frappe.throw(...) %}`. + +**`(some_time|string)[:5]` is wrong.** Time fields are `timedelta`; `str()` gives +`9:00:00` unpadded, so the slice renders `9:00:`. Use +`frappe.utils.get_time(x).strftime('%H:%M')`. + +**`free_webinar` no longer exists** — `buzz.patches.rename_free_webinar_to_free_event` +renamed it to `free_event`, and `free_event` is a PRICING flag, not a delivery mode. +"Is this a webinar" is `medium == "Online"`. + +**Website Settings `address` arrives on the context as `footer_address`**, and +`get_website_settings()` already sets `context.boot` — don't call `get_boot_data()` +again. + +**DocType names are globally unique; a module does not namespace them.** Hence +`Buzz Theme` / `Buzz Theme Settings` / `Buzz Themed Route`. Renaming a doctype +without renaming its `.py`/`.json`/`.js` and controller class fails at RUNTIME with +ImportError, not at migrate — it passes a migration and breaks on first page load. + +**`bench execute --kwargs` uses `eval`** — pass Python literals (`True`, not `true`). + +### Routes + +`DEFAULT_ROUTES` + `seed_default_routes()` live in `buzz_theme_settings.py` and are +called from BOTH `after_install`/`on_migrate` (`buzz/install.py`) and +`buzz.patches.seed_buzz_theme_routes`. Seeding is idempotent, keyed on `url_pattern`. + +**A patch alone does NOT seed a fresh site.** Frappe writes every patch into Patch Log +as applied WITHOUT running it when a site is installed, so `buzzv2.localhost` came up +with 5 themes but 0 routes, and the log entry then blocks any re-run. Anything a new +site needs must be in `after_install`, never only in a patch. (Theme *records* have the +opposite problem: they import on `migrate` but not on `install-app`, so a fresh site +needs one `bench migrate` before any Buzz Theme row exists.) + +Live patterns: `^$`, `^home$`, `^events/$`, `^category/$`. +The old `^events$` was dropped from the defaults — no bundled theme ships +`pages/events.html` — but it survives on sites already carrying it. + +`dynamic_pages_enabled` is on, so any `pages/.html` is servable. +`RESERVED_PATH_SEGMENTS` in `theme_resolver.py` stops a theme hijacking `/login`, +`/app`, `/api`, `/b` etc — page_renderer hooks run BEFORE Frappe's own renderers +(`frappe/website/path_resolver.py`). + +### Bundled themes + +`buzz_default_theme` (dark/light minimal, ported from the Builder pages), +`buzz_events_theme` (ticket stub), `sketchbook_theme` (handwritten, ruled paper), +`stickerpack_theme` (neo-brutalist). All inherit `Buzz Base Theme`. + +`buzz_default_theme` takes its palette from **frappe-ui's own semantic tokens** — +resolved out of `dashboard/node_modules/frappe-ui/tailwind/generated/colors.json` +(`themedVariables.light/dark` -> surface / ink / outline) and written into +`styles/tokens.css` as CSS variables. That is why themed pages and the Vue +dashboard share one grey ramp; re-resolve from that JSON rather than eyeballing +hexes if frappe-ui bumps its tokens. + +**`tokens.css` is imported UNLAYERED, so every rule in it beats every Tailwind +utility — specificity is irrelevant.** `@import "tailwindcss"` declares +`@layer theme, base, components, utilities`; an unlayered rule outranks all of +them. So a bare `.card { background: ... }` silently wins over `bg-*` in the +markup, and `a.card { display: block }` beat `.flex` (this looked like a +specificity bug and was misdiagnosed as one). Wrap the component half of every +theme's tokens.css in `@layer components { ... }`, leaving only the `:root` / +`html[data-theme]` variable blocks unlayered. Verify with: +`python3 -c "css=open('tailwind.output.css').read(); i=css.find('.card{'); print(css[:i].count('{')-css[:i].count('}'))"` +— 1 means layered, 0 means it will override your markup. + +Only `buzz_default_theme` is layered so far; the other three themes still have +this latent. + +Venue maps use `openstreetmap.org/export/embed.html` (keyless, needs a bbox). +The old `staticmap.openstreetmap.de` is NXDOMAIN — that service was withdrawn. + +Theme JS/CSS is excluded from prettier+eslint in `.pre-commit-config.yaml`, and +`buzz/public/themes/` had to be added there when the scripts moved out of +`buzz/themes/`. + +## Environment + +`gh` holds two accounts; only **Rl0007** can push to `buildwithhussain/buzz`. If a +push 403s as `ghostinmyterminal`, run `gh auth switch --user Rl0007`. + +`apps/builder` has an UNCOMMITTED local fix: it imported `POSTHOG_HOST_FIELD` / +`POSTHOG_PROJECT_FIELD`, which frappe deleted in `2a48f956bf`, and that broke +`/desk` and `/app` with a 500 for every logged-in user. The repo has no `origin`, +so any update to builder silently re-breaks the desk the same way. + +buzz.localhost admin password is `admin`. diff --git a/.gitignore b/.gitignore index 97715e66..0caef3aa 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,6 @@ plans/ # Claude Code local state .claude/worktrees/ .claude/settings.local.json + +# Local frappe-ui checkout for yarn link +dashboard/frappe-ui/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f3a153f7..1c3a46bd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,7 +42,9 @@ repos: .*node_modules.*| .*boilerplate.*| buzz/templates/includes/.*| - buzz/public/js/lib/.* + buzz/public/js/lib/.*| + buzz/public/themes/.*/scripts/vendor/.*| + buzz/themes/.* )$ @@ -60,7 +62,9 @@ repos: .*node_modules.*| .*boilerplate.*| buzz/templates/includes/.*| - buzz/public/js/lib/.* + buzz/public/js/lib/.*| + buzz/public/themes/.*/scripts/vendor/.*| + buzz/themes/.* )$ ci: diff --git a/buzz/buzz_themes/__init__.py b/buzz/buzz_themes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/buzz_themes/buzz_theme/__init__.py b/buzz/buzz_themes/buzz_theme/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/buzz_themes/buzz_theme/buzz_base_theme/__init__.py b/buzz/buzz_themes/buzz_theme/buzz_base_theme/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/buzz_themes/buzz_theme/buzz_base_theme/buzz_base_theme.json b/buzz/buzz_themes/buzz_theme/buzz_base_theme/buzz_base_theme.json new file mode 100644 index 00000000..12ceb5fc --- /dev/null +++ b/buzz/buzz_themes/buzz_theme/buzz_base_theme/buzz_base_theme.json @@ -0,0 +1,13 @@ +{ + "creation": "2026-08-08 00:00:00.000000", + "docstatus": 0, + "doctype": "Buzz Theme", + "idx": 0, + "is_standard": 1, + "modified": "2026-08-08 00:00:00.000000", + "modified_by": "Administrator", + "module": "Buzz Themes", + "name": "Buzz Base Theme", + "owner": "Administrator", + "theme_name": "Buzz Base Theme" +} diff --git a/buzz/buzz_themes/buzz_theme/buzz_default_theme/__init__.py b/buzz/buzz_themes/buzz_theme/buzz_default_theme/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/buzz_themes/buzz_theme/buzz_default_theme/buzz_default_theme.json b/buzz/buzz_themes/buzz_theme/buzz_default_theme/buzz_default_theme.json new file mode 100644 index 00000000..e88a51b5 --- /dev/null +++ b/buzz/buzz_themes/buzz_theme/buzz_default_theme/buzz_default_theme.json @@ -0,0 +1,15 @@ +{ + "creation": "2026-08-14 09:00:00.000000", + "docstatus": 0, + "doctype": "Buzz Theme", + "idx": 0, + "is_standard": 1, + "modified": "2026-08-14 09:00:00.000000", + "modified_by": "Administrator", + "module": "Buzz Themes", + "name": "Buzz Default Theme", + "owner": "Administrator", + "parent_theme": "Buzz Base Theme", + "theme_name": "Buzz Default Theme", + "theme_settings": "Buzz Default Theme Settings" +} diff --git a/buzz/buzz_themes/buzz_theme/buzz_events_theme/__init__.py b/buzz/buzz_themes/buzz_theme/buzz_events_theme/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/buzz_themes/buzz_theme/buzz_events_theme/buzz_events_theme.json b/buzz/buzz_themes/buzz_theme/buzz_events_theme/buzz_events_theme.json new file mode 100644 index 00000000..cd79b51e --- /dev/null +++ b/buzz/buzz_themes/buzz_theme/buzz_events_theme/buzz_events_theme.json @@ -0,0 +1,15 @@ +{ + "creation": "2026-08-08 00:00:00.000000", + "docstatus": 0, + "doctype": "Buzz Theme", + "idx": 0, + "is_standard": 1, + "modified": "2026-08-11 12:00:00.000000", + "modified_by": "Administrator", + "module": "Buzz Themes", + "name": "Buzz Events Theme", + "owner": "Administrator", + "parent_theme": "Buzz Base Theme", + "theme_name": "Buzz Events Theme", + "theme_settings": "Buzz Events Theme Settings" +} diff --git a/buzz/buzz_themes/buzz_theme/sketchbook_theme/__init__.py b/buzz/buzz_themes/buzz_theme/sketchbook_theme/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/buzz_themes/buzz_theme/sketchbook_theme/sketchbook_theme.json b/buzz/buzz_themes/buzz_theme/sketchbook_theme/sketchbook_theme.json new file mode 100644 index 00000000..4cb72670 --- /dev/null +++ b/buzz/buzz_themes/buzz_theme/sketchbook_theme/sketchbook_theme.json @@ -0,0 +1,15 @@ +{ + "creation": "2026-08-08 00:00:00.000000", + "docstatus": 0, + "doctype": "Buzz Theme", + "idx": 0, + "is_standard": 1, + "modified": "2026-08-11 12:00:00.000000", + "modified_by": "Administrator", + "module": "Buzz Themes", + "name": "Sketchbook Theme", + "owner": "Administrator", + "parent_theme": "Buzz Base Theme", + "theme_name": "Sketchbook Theme", + "theme_settings": "Sketchbook Theme Settings" +} diff --git a/buzz/buzz_themes/buzz_theme/stickerpack_theme/__init__.py b/buzz/buzz_themes/buzz_theme/stickerpack_theme/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/buzz_themes/buzz_theme/stickerpack_theme/stickerpack_theme.json b/buzz/buzz_themes/buzz_theme/stickerpack_theme/stickerpack_theme.json new file mode 100644 index 00000000..4e306962 --- /dev/null +++ b/buzz/buzz_themes/buzz_theme/stickerpack_theme/stickerpack_theme.json @@ -0,0 +1,15 @@ +{ + "creation": "2026-08-08 00:00:00.000000", + "docstatus": 0, + "doctype": "Buzz Theme", + "idx": 0, + "is_standard": 1, + "modified": "2026-08-11 12:00:00.000000", + "modified_by": "Administrator", + "module": "Buzz Themes", + "name": "Stickerpack Theme", + "owner": "Administrator", + "parent_theme": "Buzz Base Theme", + "theme_name": "Stickerpack Theme", + "theme_settings": "Stickerpack Theme Settings" +} diff --git a/buzz/buzz_themes/doctype/__init__.py b/buzz/buzz_themes/doctype/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/buzz_themes/doctype/buzz_default_theme_settings/__init__.py b/buzz/buzz_themes/doctype/buzz_default_theme_settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/buzz_themes/doctype/buzz_default_theme_settings/buzz_default_theme_settings.json b/buzz/buzz_themes/doctype/buzz_default_theme_settings/buzz_default_theme_settings.json new file mode 100644 index 00000000..eb4f2d47 --- /dev/null +++ b/buzz/buzz_themes/doctype/buzz_default_theme_settings/buzz_default_theme_settings.json @@ -0,0 +1,189 @@ +{ + "actions": [], + "creation": "2026-08-14 09:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "navigation_section", + "nav_links", + "footer_note", + "home_section", + "welcome_heading", + "welcome_body", + "yatra_heading", + "upcoming_conferences_heading", + "past_conferences_heading", + "past_webinars_heading", + "category_section", + "upcoming_heading", + "past_heading", + "event_section", + "register_label", + "about_heading", + "schedule_heading", + "speakers_heading", + "venue_heading", + "host_heading", + "sponsors_heading" + ], + "fields": [ + { + "fieldname": "navigation_section", + "fieldtype": "Section Break", + "label": "Navigation & Footer" + }, + { + "description": "Links shown in the header. Leave empty to fall back to Dashboard and Propose Event.", + "fieldname": "nav_links", + "fieldtype": "Table", + "label": "Navigation Links", + "options": "Buzz Theme Nav Link" + }, + { + "default": "Built with Buzz", + "fieldname": "footer_note", + "fieldtype": "Data", + "label": "Footer Note" + }, + { + "fieldname": "home_section", + "fieldtype": "Section Break", + "label": "Home Page" + }, + { + "description": "Defaults to \"Welcome to \".", + "fieldname": "welcome_heading", + "fieldtype": "Data", + "label": "Welcome Heading" + }, + { + "default": "This is where the community shows up \u2014 to share what they have been working on, discuss common problems, and have honest conversations.\n\nBrowse through the upcoming conferences, webinars, and meetups to join in.", + "description": "Separate paragraphs with a blank line.", + "fieldname": "welcome_body", + "fieldtype": "Small Text", + "label": "Welcome Body" + }, + { + "default": "Upcoming Yatra Meetups", + "fieldname": "yatra_heading", + "fieldtype": "Data", + "label": "Yatra Section Heading" + }, + { + "default": "Upcoming Conferences", + "fieldname": "upcoming_conferences_heading", + "fieldtype": "Data", + "label": "Upcoming Conferences Heading" + }, + { + "default": "Past Conferences", + "fieldname": "past_conferences_heading", + "fieldtype": "Data", + "label": "Past Conferences Heading" + }, + { + "default": "Past Webinars", + "fieldname": "past_webinars_heading", + "fieldtype": "Data", + "label": "Past Webinars Heading" + }, + { + "fieldname": "category_section", + "fieldtype": "Section Break", + "label": "Category Page" + }, + { + "default": "Upcoming", + "fieldname": "upcoming_heading", + "fieldtype": "Data", + "label": "Upcoming Heading" + }, + { + "default": "In the Past", + "fieldname": "past_heading", + "fieldtype": "Data", + "label": "Past Heading" + }, + { + "fieldname": "event_section", + "fieldtype": "Section Break", + "label": "Event Page" + }, + { + "default": "Register", + "fieldname": "register_label", + "fieldtype": "Data", + "label": "Register Button Label" + }, + { + "default": "About the event", + "fieldname": "about_heading", + "fieldtype": "Data", + "label": "About Heading" + }, + { + "default": "Schedule", + "fieldname": "schedule_heading", + "fieldtype": "Data", + "label": "Schedule Heading" + }, + { + "default": "Speakers", + "fieldname": "speakers_heading", + "fieldtype": "Data", + "label": "Speakers Heading" + }, + { + "default": "Venue details", + "fieldname": "venue_heading", + "fieldtype": "Data", + "label": "Venue Heading" + }, + { + "default": "Meet the host", + "fieldname": "host_heading", + "fieldtype": "Data", + "label": "Host Heading" + }, + { + "default": "Sponsors", + "fieldname": "sponsors_heading", + "fieldtype": "Data", + "label": "Sponsors Heading" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2026-08-14 09:55:00", + "modified_by": "Administrator", + "module": "Buzz Themes", + "name": "Buzz Default Theme Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "Website Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/buzz/buzz_themes/doctype/buzz_default_theme_settings/buzz_default_theme_settings.py b/buzz/buzz_themes/doctype/buzz_default_theme_settings/buzz_default_theme_settings.py new file mode 100644 index 00000000..5f0f5d43 --- /dev/null +++ b/buzz/buzz_themes/doctype/buzz_default_theme_settings/buzz_default_theme_settings.py @@ -0,0 +1,18 @@ +import frappe +from frappe.model.document import Document + +# Canonical dashboard routes: /dashboard/* is redirected to /b/* by website_redirects in hooks.py, +# so link at /b directly and skip the hop. Proposals live at /event-proposal (see dashboard/src/router.ts). +DEFAULT_NAV_LINKS = [ + {"label": "Dashboard", "url": "/b", "open_in_new_tab": 0}, + {"label": "Propose Event", "url": "/b/event-proposal", "open_in_new_tab": 0}, +] + + +class BuzzDefaultThemeSettings(Document): + def get_nav_links(self): + """Header links, falling back to the bundled defaults while the table is empty.""" + if self.nav_links: + return self.nav_links + + return [frappe._dict(link) for link in DEFAULT_NAV_LINKS] diff --git a/buzz/buzz_themes/doctype/buzz_events_theme_settings/__init__.py b/buzz/buzz_themes/doctype/buzz_events_theme_settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/buzz_themes/doctype/buzz_events_theme_settings/buzz_events_theme_settings.json b/buzz/buzz_themes/doctype/buzz_events_theme_settings/buzz_events_theme_settings.json new file mode 100644 index 00000000..e04967e4 --- /dev/null +++ b/buzz/buzz_themes/doctype/buzz_events_theme_settings/buzz_events_theme_settings.json @@ -0,0 +1,98 @@ +{ + "actions": [], + "creation": "2026-08-11 12:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "hero_section", + "hero_tagline", + "hero_description", + "featured_section", + "featured_label", + "sections_section", + "yatra_heading", + "conferences_heading" + ], + "fields": [ + { + "fieldname": "hero_section", + "fieldtype": "Section Break", + "label": "Hero" + }, + { + "default": "Conferences, Meetups, Webinars", + "description": "Appended to the site name in the page title and social preview.", + "fieldname": "hero_tagline", + "fieldtype": "Data", + "label": "Hero Tagline" + }, + { + "default": "Conferences, yatras, meetups, and webinars.", + "fieldname": "hero_description", + "fieldtype": "Data", + "label": "Hero Description" + }, + { + "fieldname": "featured_section", + "fieldtype": "Section Break", + "label": "Featured Event" + }, + { + "default": "Featured conference", + "fieldname": "featured_label", + "fieldtype": "Data", + "label": "Featured Label" + }, + { + "fieldname": "sections_section", + "fieldtype": "Section Break", + "label": "Sections" + }, + { + "default": "Upcoming Yatra", + "fieldname": "yatra_heading", + "fieldtype": "Data", + "label": "Yatra Heading" + }, + { + "default": "Upcoming Conferences", + "fieldname": "conferences_heading", + "fieldtype": "Data", + "label": "Conferences Heading" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2026-08-11 12:00:00", + "modified_by": "Administrator", + "module": "Buzz Themes", + "name": "Buzz Events Theme Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "Website Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/buzz/buzz_themes/doctype/buzz_events_theme_settings/buzz_events_theme_settings.py b/buzz/buzz_themes/doctype/buzz_events_theme_settings/buzz_events_theme_settings.py new file mode 100644 index 00000000..3e66fa40 --- /dev/null +++ b/buzz/buzz_themes/doctype/buzz_events_theme_settings/buzz_events_theme_settings.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class BuzzEventsThemeSettings(Document): + pass diff --git a/buzz/buzz_themes/doctype/buzz_theme/__init__.py b/buzz/buzz_themes/doctype/buzz_theme/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/buzz_themes/doctype/buzz_theme/buzz_theme.js b/buzz/buzz_themes/doctype/buzz_theme/buzz_theme.js new file mode 100644 index 00000000..aa963833 --- /dev/null +++ b/buzz/buzz_themes/doctype/buzz_theme/buzz_theme.js @@ -0,0 +1,43 @@ +// Copyright (c) 2026, hussain@buildwithhussain.com and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Buzz Theme", { + refresh(frm) { + if (frm.is_new() || frm.doc.theme_settings) { + return; + } + + const settings_doctype_name = `${frm.doc.theme_name} Settings`; + frappe.db.exists("DocType", settings_doctype_name).then((exists) => { + if (exists) { + frm.dashboard.add_comment( + __("DocType {0} already exists. Link it in the Theme Settings field.", [ + settings_doctype_name, + ]), + "yellow", + true + ); + return; + } + + frm.add_custom_button(__("Scaffold Theme Settings"), () => { + frappe.confirm( + __("This will create a new DocType named {0}. Continue?", [ + settings_doctype_name, + ]), + () => { + frm.call("scaffold_theme_settings").then((response) => { + if (response.message) { + frappe.show_alert({ + message: __("Created {0}", [response.message]), + indicator: "green", + }); + frm.reload_doc(); + } + }); + } + ); + }); + }); + }, +}); diff --git a/buzz/buzz_themes/doctype/buzz_theme/buzz_theme.json b/buzz/buzz_themes/doctype/buzz_theme/buzz_theme.json new file mode 100644 index 00000000..a4c9fde6 --- /dev/null +++ b/buzz/buzz_themes/doctype/buzz_theme/buzz_theme.json @@ -0,0 +1,106 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:theme_name", + "creation": "2026-05-17 12:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "theme_name", + "module", + "is_standard", + "theme_settings", + "column_break_main", + "parent_theme", + "section_break_config", + "config" + ], + "fields": [ + { + "fieldname": "theme_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Theme Name", + "reqd": 1, + "unique": 1 + }, + { + "default": "Buzz Themes", + "description": "Module the theme JSON is exported under when developer_mode is on.", + "fieldname": "module", + "fieldtype": "Link", + "label": "Module", + "options": "Module Def" + }, + { + "default": "0", + "description": "When checked and developer_mode is on, saving writes this theme to disk so it ships with the app.", + "fieldname": "is_standard", + "fieldtype": "Check", + "label": "Is Standard" + }, + { + "description": "Single DocType holding this theme's settings. Use the 'Scaffold Theme Settings' button to create one.", + "fieldname": "theme_settings", + "fieldtype": "Link", + "label": "Theme Settings", + "options": "DocType" + }, + { + "fieldname": "column_break_main", + "fieldtype": "Column Break" + }, + { + "description": "Inherit from this theme. Overrides fall back to the parent theme before the defaults.", + "fieldname": "parent_theme", + "fieldtype": "Link", + "label": "Parent Theme", + "options": "Buzz Theme" + }, + { + "fieldname": "section_break_config", + "fieldtype": "Section Break", + "hidden": 1, + "label": "Theme Configuration" + }, + { + "fieldname": "config", + "fieldtype": "JSON", + "label": "Config" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-05-17 12:00:00", + "modified_by": "Administrator", + "module": "Buzz Themes", + "name": "Buzz Theme", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "Website Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/buzz/buzz_themes/doctype/buzz_theme/buzz_theme.py b/buzz/buzz_themes/doctype/buzz_theme/buzz_theme.py new file mode 100644 index 00000000..585cc95d --- /dev/null +++ b/buzz/buzz_themes/doctype/buzz_theme/buzz_theme.py @@ -0,0 +1,297 @@ +import os +import re +import shutil + +import frappe +from frappe.model.document import Document +from frappe.modules.utils import export_module_json + +THEME_NAME_PATTERN = re.compile(r"^[A-Za-z0-9 _-]+$") + + +class BuzzTheme(Document): + def validate(self): + validate_theme_name(self.theme_name) + if self.parent_theme: + self.validate_no_circular_inheritance() + + def after_insert(self): + if frappe.conf.developer_mode: + scaffold_theme(self.theme_name, self.module) + + @frappe.whitelist(methods=["POST"]) + def scaffold_theme_settings(self): + self.check_permission("write") + + settings_doctype_name = f"{self.theme_name} Settings" + if frappe.db.exists("DocType", settings_doctype_name): + frappe.throw(f"DocType '{settings_doctype_name}' already exists") + + create_theme_settings_doctype(settings_doctype_name, self.module) + self.db_set("theme_settings", settings_doctype_name) + return settings_doctype_name + + def after_rename(self, old_name, new_name, merge=False): + validate_theme_name(new_name) + validate_theme_name(old_name) + + old_slug = frappe.scrub(old_name) + new_slug = frappe.scrub(new_name) + + if old_slug != new_slug: + app_path = frappe.get_app_path(get_app_for_module(self.module)) + for base in ( + os.path.join(app_path, "themes"), + os.path.join(app_path, "public", "themes"), + ): + old_path = get_contained_path(base, old_slug) + new_path = get_contained_path(base, new_slug) + if os.path.isdir(old_path): + os.rename(old_path, new_path) + + if self.module and frappe.conf.developer_mode: + old_export_dir = get_theme_export_dir(self.module, old_slug) + new_export_dir = get_theme_export_dir(self.module, new_slug) + if os.path.isdir(old_export_dir): + os.rename(old_export_dir, new_export_dir) + if os.path.isdir(new_export_dir): + for filename in os.listdir(new_export_dir): + if filename.endswith(".json") and filename != f"{new_slug}.json": + os.remove(os.path.join(new_export_dir, filename)) + + self.db_set("theme_name", new_name) + + if frappe.conf.developer_mode and self.is_standard and self.module: + self.theme_name = new_name + export_module_json(self, is_standard=True, module=self.module) + + def on_update(self): + if not frappe.conf.developer_mode: + return + + doc_before = self.get_doc_before_save() + if doc_before: + export_fields = [ + "theme_name", + "parent_theme", + "config", + "is_standard", + "module", + "theme_settings", + ] + if not any(self.get(field) != doc_before.get(field) for field in export_fields): + return + + export_module_json(self, is_standard=bool(self.is_standard), module=self.module) + + def on_change(self): + clear_theme_cache() + + def on_trash(self): + if not frappe.conf.developer_mode or self.is_standard: + return + + validate_theme_name(self.name) + paths = [get_theme_dir(self.name), get_theme_public_dir(self.name)] + if self.module: + paths.append(get_theme_export_dir(self.module, frappe.scrub(self.name))) + for path in paths: + if os.path.isdir(path): + shutil.rmtree(path) + + def validate_no_circular_inheritance(self): + visited = {self.name} + current = self.parent_theme + while current: + if current in visited: + frappe.throw(f"Circular theme inheritance detected: {current}") + visited.add(current) + current = frappe.db.get_value("Buzz Theme", current, "parent_theme") + + +def create_theme_settings_doctype(doctype_name, module=None): + is_developer_mode = bool(frappe.conf.get("developer_mode")) + doctype = frappe.new_doc("DocType") + doctype.update( + { + "name": doctype_name, + "module": module or "Buzz Themes", + "custom": 0 if is_developer_mode else 1, + "issingle": 1, + "fields": [ + { + "fieldname": "info_section", + "fieldtype": "Section Break", + "label": "Theme Settings", + }, + ], + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "share": 1, + "print": 1, + "email": 1, + }, + { + "role": "Website Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "share": 1, + "print": 1, + "email": 1, + }, + ], + } + ) + doctype.insert() + return doctype + + +DEFAULT_THEME_APP = "buzz" + + +def validate_theme_name(theme_name): + if not THEME_NAME_PATTERN.match(theme_name or ""): + frappe.throw( + frappe._("Theme name may only contain letters, numbers, spaces, hyphens and underscores") + ) + + +def is_within_directory(directory, target): + directory = os.path.realpath(directory) + target = os.path.realpath(target) + return target == directory or target.startswith(directory + os.sep) + + +def get_contained_path(base_dir, *segments): + path = os.path.join(base_dir, *segments) + if not is_within_directory(base_dir, path): + frappe.throw(frappe._("Invalid theme path: {0}").format(path)) + return path + + +def get_app_for_module(module): + app = frappe.db.get_value("Module Def", module, "app_name") if module else None + return app or DEFAULT_THEME_APP + + +def get_theme_app(theme_name): + return get_app_for_module(frappe.db.get_value("Buzz Theme", theme_name, "module")) + + +def get_theme_dir(theme_name): + base_dir = os.path.join(frappe.get_app_path(get_theme_app(theme_name)), "themes") + return get_contained_path(base_dir, frappe.scrub(theme_name)) + + +def get_theme_public_dir(theme_name): + base_dir = os.path.join(frappe.get_app_path(get_theme_app(theme_name)), "public", "themes") + return get_contained_path(base_dir, frappe.scrub(theme_name)) + + +def get_theme_export_dir(module, slug): + return get_contained_path(frappe.get_module_path(module), "buzz_theme", slug) + + +def scaffold_theme(theme_name, module=None): + app_path = frappe.get_app_path(get_app_for_module(module)) + slug = frappe.scrub(theme_name) + theme_dir = get_contained_path(os.path.join(app_path, "themes"), slug) + + if os.path.exists(theme_dir): + return + + for folder in ( + "pages", + "components/includes", + "components/macros", + ): + os.makedirs(get_contained_path(theme_dir, folder), exist_ok=True) + + theme_public_dir = get_contained_path(os.path.join(app_path, "public", "themes"), slug) + for folder in ("css", "js", "images"): + os.makedirs(get_contained_path(theme_public_dir, folder), exist_ok=True) + + +def get_theme_context_uncached(theme_name): + if not theme_name: + return {"theme_name": None, "names": [], "dirs": [], "apps": {}, "settings_doctype": None} + + names = get_theme_names(theme_name) + apps = {} + dirs = [] + for name in names: + app = get_theme_app(name) + apps[name] = app + theme_dir = os.path.join(frappe.get_app_path(app), "themes", frappe.scrub(name)) + if os.path.isdir(theme_dir): + dirs.append(theme_dir) + + return { + "theme_name": theme_name, + "names": names, + "dirs": dirs, + "apps": apps, + "settings_doctype": frappe.db.get_value("Buzz Theme", theme_name, "theme_settings"), + } + + +PREVIEW_THEME_PARAM = "preview_theme" + + +def resolve_default_theme(): + return frappe.get_single_value("Buzz Settings", "default_theme") + + +def resolve_theme(): + return requested_preview_theme() or resolve_default_theme() + + +def get_theme_context(theme_name): + if not theme_name: + return get_theme_context_uncached(None) + return frappe.cache.hget( + "buzz_theme_context", theme_name, generator=lambda: get_theme_context_uncached(theme_name) + ) + + +def get_render_theme_context(): + return get_theme_context(resolve_theme()) + + +def requested_preview_theme(): + if not frappe.conf.developer_mode: + return None + + if not (hasattr(frappe.local, "request") and frappe.local.request): + return None + + theme_name = frappe.local.request.args.get(PREVIEW_THEME_PARAM) + if not theme_name: + return None + + if not frappe.db.exists("Buzz Theme", theme_name): + return None + + return theme_name + + +def get_theme_names(theme_name): + names = [] + visited = set() + current = theme_name + while current and current not in visited: + visited.add(current) + names.append(current) + current = frappe.db.get_value("Buzz Theme", current, "parent_theme") + return names + + +def clear_theme_cache(): + frappe.cache.delete_value("buzz_theme_context") diff --git a/buzz/buzz_themes/doctype/buzz_theme_nav_link/__init__.py b/buzz/buzz_themes/doctype/buzz_theme_nav_link/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/buzz_themes/doctype/buzz_theme_nav_link/buzz_theme_nav_link.json b/buzz/buzz_themes/doctype/buzz_theme_nav_link/buzz_theme_nav_link.json new file mode 100644 index 00000000..38f0e77d --- /dev/null +++ b/buzz/buzz_themes/doctype/buzz_theme_nav_link/buzz_theme_nav_link.json @@ -0,0 +1,50 @@ +{ + "actions": [], + "creation": "2026-08-14 09:40:00", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "label", + "url", + "open_in_new_tab" + ], + "fields": [ + { + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label", + "reqd": 1 + }, + { + "fieldname": "url", + "fieldtype": "Data", + "in_list_view": 1, + "label": "URL", + "options": "URL", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "open_in_new_tab", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Open in New Tab" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-08-14 09:55:00", + "modified_by": "Administrator", + "module": "Buzz Themes", + "name": "Buzz Theme Nav Link", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/buzz/buzz_themes/doctype/buzz_theme_nav_link/buzz_theme_nav_link.py b/buzz/buzz_themes/doctype/buzz_theme_nav_link/buzz_theme_nav_link.py new file mode 100644 index 00000000..213b174c --- /dev/null +++ b/buzz/buzz_themes/doctype/buzz_theme_nav_link/buzz_theme_nav_link.py @@ -0,0 +1,27 @@ +import frappe +from frappe.model.document import Document +from frappe.utils import validate_url + +ALLOWED_URL_SCHEMES = ["http", "https"] + + +class BuzzThemeNavLink(Document): + def validate(self): + self.validate_link_url() + + def validate_link_url(self): + """Header links are rendered straight into href, so a `javascript:` URL here would be + stored XSS on every themed page. frappe's own validate_url accepts any scheme unless + told otherwise, and rejects site-relative paths outright, so both cases are checked here.""" + url = (self.url or "").strip() + + if url.startswith("/"): + return + + if not validate_url(url, valid_schemes=ALLOWED_URL_SCHEMES): + frappe.throw( + frappe._( + "{0} is not a valid link. Use a site path like /events or a full http(s) URL." + ).format(frappe.bold(url)), + title=frappe._("Invalid Header Link"), + ) diff --git a/buzz/buzz_themes/doctype/buzz_theme_settings/__init__.py b/buzz/buzz_themes/doctype/buzz_theme_settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/buzz_themes/doctype/buzz_theme_settings/buzz_theme_settings.json b/buzz/buzz_themes/doctype/buzz_theme_settings/buzz_theme_settings.json new file mode 100644 index 00000000..1154a86c --- /dev/null +++ b/buzz/buzz_themes/doctype/buzz_theme_settings/buzz_theme_settings.json @@ -0,0 +1,65 @@ +{ + "actions": [], + "creation": "2026-05-17 12:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "dynamic_pages_enabled", + "section_break_routes", + "routes" + ], + "fields": [ + { + "default": "1", + "description": "If enabled, any URL like /foo/bar will look for pages/foo/bar.html in the active theme as a fallback when no explicit route matches.", + "fieldname": "dynamic_pages_enabled", + "fieldtype": "Check", + "label": "Enable Dynamic Pages" + }, + { + "fieldname": "section_break_routes", + "fieldtype": "Section Break", + "label": "Routes" + }, + { + "fieldname": "routes", + "fieldtype": "Table", + "label": "Themed Routes", + "options": "Buzz Themed Route" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2026-05-17 12:00:00", + "modified_by": "Administrator", + "module": "Buzz Themes", + "name": "Buzz Theme Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "Website Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/buzz/buzz_themes/doctype/buzz_theme_settings/buzz_theme_settings.py b/buzz/buzz_themes/doctype/buzz_theme_settings/buzz_theme_settings.py new file mode 100644 index 00000000..0edbc3be --- /dev/null +++ b/buzz/buzz_themes/doctype/buzz_theme_settings/buzz_theme_settings.py @@ -0,0 +1,85 @@ +import re + +import frappe +from frappe.model.document import Document + +# The routes every bundled theme ships pages for. Seeded from after_install AND from +# buzz.patches.seed_buzz_theme_routes: frappe logs patches as applied without running them +# on a fresh install, so a patch alone leaves new sites with no themed routes at all. +DEFAULT_ROUTES = [ + {"url_pattern": "^$", "template_path": "pages/home.html", "requires_auth": 0}, + {"url_pattern": "^home$", "template_path": "pages/home.html", "requires_auth": 0}, + { + "url_pattern": "^events/(?P[^/]+)$", + "template_path": "pages/events/detail.html", + "requires_auth": 0, + }, + { + "url_pattern": "^category/(?P[^/]+)$", + "template_path": "pages/category/detail.html", + "requires_auth": 0, + }, +] + + +def seed_default_routes(): + """Add any missing default route. Idempotent, so install and the patch can both call it.""" + settings = frappe.get_single("Buzz Theme Settings") + existing_patterns = {row.url_pattern for row in settings.routes} + + missing_routes = [route for route in DEFAULT_ROUTES if route["url_pattern"] not in existing_patterns] + if not missing_routes: + return + + for route in missing_routes: + settings.append("routes", route) + + settings.save(ignore_permissions=True) + + +class BuzzThemeSettings(Document): + def validate(self): + for row in self.get("routes") or []: + try: + re.compile(row.url_pattern) + except re.error as exc: + frappe.throw(f"Invalid regex in route '{row.url_pattern}': {exc}") + + def on_update(self): + clear_settings_cache() + + +def clear_settings_cache(): + frappe.cache.delete_value("buzz_theme_settings_compiled") + frappe.local.buzz_theme_compiled_routes = None + + +def get_compiled_routes_uncached(): + settings = frappe.get_cached_doc("Buzz Theme Settings") + compiled = [] + for row in settings.get("routes") or []: + try: + pattern = re.compile(row.url_pattern) + except re.error: + continue + compiled.append( + { + "pattern": pattern, + "template_path": row.template_path, + "requires_auth": bool(row.requires_auth), + } + ) + return { + "routes": compiled, + "dynamic_pages_enabled": bool(settings.dynamic_pages_enabled), + } + + +def get_compiled_routes(): + routes = getattr(frappe.local, "buzz_theme_compiled_routes", None) + if routes is None: + routes = frappe.cache.get_value( + "buzz_theme_settings_compiled", generator=get_compiled_routes_uncached + ) + frappe.local.buzz_theme_compiled_routes = routes + return routes diff --git a/buzz/buzz_themes/doctype/buzz_themed_route/__init__.py b/buzz/buzz_themes/doctype/buzz_themed_route/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/buzz_themes/doctype/buzz_themed_route/buzz_themed_route.json b/buzz/buzz_themes/doctype/buzz_themed_route/buzz_themed_route.json new file mode 100644 index 00000000..4b3d11dd --- /dev/null +++ b/buzz/buzz_themes/doctype/buzz_themed_route/buzz_themed_route.json @@ -0,0 +1,51 @@ +{ + "actions": [], + "creation": "2026-05-17 12:00:00", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "url_pattern", + "template_path", + "requires_auth" + ], + "fields": [ + { + "description": "Regex pattern matched against the request path (with leading slash stripped). Use named groups like (?P<slug>[^/]+) to expose as context variables.", + "fieldname": "url_pattern", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "URL Pattern", + "reqd": 1, + "ignore_xss_filter": 1 + }, + { + "description": "Path relative to the theme folder, e.g. pages/events/list.html", + "fieldname": "template_path", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Template Path", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "requires_auth", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Requires Auth" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-05-17 12:00:00", + "modified_by": "Administrator", + "module": "Buzz Themes", + "name": "Buzz Themed Route", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/buzz/buzz_themes/doctype/buzz_themed_route/buzz_themed_route.py b/buzz/buzz_themes/doctype/buzz_themed_route/buzz_themed_route.py new file mode 100644 index 00000000..77d39443 --- /dev/null +++ b/buzz/buzz_themes/doctype/buzz_themed_route/buzz_themed_route.py @@ -0,0 +1,6 @@ +import frappe +from frappe.model.document import Document + + +class BuzzThemedRoute(Document): + pass diff --git a/buzz/buzz_themes/doctype/sketchbook_theme_settings/__init__.py b/buzz/buzz_themes/doctype/sketchbook_theme_settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/buzz_themes/doctype/sketchbook_theme_settings/sketchbook_theme_settings.json b/buzz/buzz_themes/doctype/sketchbook_theme_settings/sketchbook_theme_settings.json new file mode 100644 index 00000000..8f181da8 --- /dev/null +++ b/buzz/buzz_themes/doctype/sketchbook_theme_settings/sketchbook_theme_settings.json @@ -0,0 +1,98 @@ +{ + "actions": [], + "creation": "2026-08-11 12:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "hero_section", + "hero_description", + "featured_section", + "featured_caption", + "sections_section", + "yatra_heading", + "conferences_heading", + "planning_note" + ], + "fields": [ + { + "fieldname": "hero_section", + "fieldtype": "Section Break", + "label": "Hero" + }, + { + "default": "Conferences, yatras, meetups, and webinars.", + "description": "Used as the social preview description.", + "fieldname": "hero_description", + "fieldtype": "Data", + "label": "Hero Description" + }, + { + "fieldname": "featured_section", + "fieldtype": "Section Break", + "label": "Featured Event" + }, + { + "default": "the big one!", + "fieldname": "featured_caption", + "fieldtype": "Data", + "label": "Featured Caption" + }, + { + "fieldname": "sections_section", + "fieldtype": "Section Break", + "label": "Sections" + }, + { + "default": "upcoming yatra", + "fieldname": "yatra_heading", + "fieldtype": "Data", + "label": "Yatra Heading" + }, + { + "default": "upcoming conferences", + "fieldname": "conferences_heading", + "fieldtype": "Data", + "label": "Conferences Heading" + }, + { + "default": "more conferences announced soon", + "fieldname": "planning_note", + "fieldtype": "Data", + "label": "Planning Note" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2026-08-11 12:00:00", + "modified_by": "Administrator", + "module": "Buzz Themes", + "name": "Sketchbook Theme Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "Website Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/buzz/buzz_themes/doctype/sketchbook_theme_settings/sketchbook_theme_settings.py b/buzz/buzz_themes/doctype/sketchbook_theme_settings/sketchbook_theme_settings.py new file mode 100644 index 00000000..36053d36 --- /dev/null +++ b/buzz/buzz_themes/doctype/sketchbook_theme_settings/sketchbook_theme_settings.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class SketchbookThemeSettings(Document): + pass diff --git a/buzz/buzz_themes/doctype/stickerpack_theme_settings/__init__.py b/buzz/buzz_themes/doctype/stickerpack_theme_settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/buzz_themes/doctype/stickerpack_theme_settings/stickerpack_theme_settings.json b/buzz/buzz_themes/doctype/stickerpack_theme_settings/stickerpack_theme_settings.json new file mode 100644 index 00000000..2e8757b0 --- /dev/null +++ b/buzz/buzz_themes/doctype/stickerpack_theme_settings/stickerpack_theme_settings.json @@ -0,0 +1,85 @@ +{ + "actions": [], + "creation": "2026-08-11 12:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "hero_section", + "hero_heading", + "hero_tagline", + "hero_chips", + "featured_section", + "featured_heading" + ], + "fields": [ + { + "fieldname": "hero_section", + "fieldtype": "Section Break", + "label": "Hero" + }, + { + "default": "come build together!", + "fieldname": "hero_heading", + "fieldtype": "Data", + "label": "Hero Heading" + }, + { + "default": "conferences · yatras · meetups · webinars", + "fieldname": "hero_tagline", + "fieldtype": "Data", + "label": "Hero Tagline" + }, + { + "default": "26 SEASON\nOPEN SOURCE", + "description": "One chip per line.", + "fieldname": "hero_chips", + "fieldtype": "Small Text", + "label": "Hero Chips" + }, + { + "fieldname": "featured_section", + "fieldtype": "Section Break", + "label": "Featured Event" + }, + { + "default": "this season's flagship", + "fieldname": "featured_heading", + "fieldtype": "Data", + "label": "Featured Heading" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2026-08-11 12:00:00", + "modified_by": "Administrator", + "module": "Buzz Themes", + "name": "Stickerpack Theme Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "Website Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/buzz/buzz_themes/doctype/stickerpack_theme_settings/stickerpack_theme_settings.py b/buzz/buzz_themes/doctype/stickerpack_theme_settings/stickerpack_theme_settings.py new file mode 100644 index 00000000..9551991a --- /dev/null +++ b/buzz/buzz_themes/doctype/stickerpack_theme_settings/stickerpack_theme_settings.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class StickerpackThemeSettings(Document): + pass diff --git a/buzz/buzz_themes/jinja_helpers.py b/buzz/buzz_themes/jinja_helpers.py new file mode 100644 index 00000000..7056db43 --- /dev/null +++ b/buzz/buzz_themes/jinja_helpers.py @@ -0,0 +1,67 @@ +import os +import re + +import frappe +from frappe.utils.html_utils import unescape_html + +from buzz.buzz_themes.doctype.buzz_theme.buzz_theme import get_render_theme_context, is_within_directory + + +def buzz_theme_asset_url(path): + context = get_render_theme_context() + if not context["theme_name"]: + return "" + + relative_path = path.lstrip("/") + apps = context["apps"] + + for name in context["names"]: + app = apps[name] + slug = frappe.scrub(name) + theme_public_dir = os.path.join(frappe.get_app_path(app), "public", "themes", slug) + asset_path = os.path.join(theme_public_dir, relative_path) + if is_within_directory(theme_public_dir, asset_path) and os.path.isfile(asset_path): + return f"/assets/{app}/themes/{slug}/{relative_path}" + + active_name = context["names"][0] + active_app = apps[active_name] + active_slug = frappe.scrub(active_name) + return f"/assets/{active_app}/themes/{active_slug}/{relative_path}" + + +def buzz_theme_config(): + context = get_render_theme_context() + settings_doctype = context.get("settings_doctype") + if not settings_doctype: + return frappe._dict() + + return frappe.get_cached_doc(settings_doctype) + + +# Venue.google_maps_embed_code is a Code field, and Frappe deliberately skips XSS sanitization +# for Code fieldtypes, so the pasted value is attacker-controlled markup for anyone holding the +# Event Manager role. Never render the paste: pull the src out, prove it is a Google Maps embed, +# and let the theme build its own iframe around the URL. +GOOGLE_MAPS_EMBED_PREFIXES = ( + "https://www.google.com/maps/embed", + "https://maps.google.com/maps", +) +IFRAME_SRC_PATTERN = re.compile(r"""\bsrc\s*=\s*["']([^"']+)["']""", re.IGNORECASE) + + +def buzz_map_embed_url(embed_code): + if not embed_code: + return "" + + candidate = embed_code.strip() + if "<" in candidate: + match = IFRAME_SRC_PATTERN.search(candidate) + if not match: + return "" + candidate = match.group(1).strip() + + candidate = unescape_html(candidate) + if not candidate.startswith(GOOGLE_MAPS_EMBED_PREFIXES): + return "" + + return candidate diff --git a/buzz/buzz_themes/tests/__init__.py b/buzz/buzz_themes/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/buzz_themes/tests/test_theme_engine.py b/buzz/buzz_themes/tests/test_theme_engine.py new file mode 100644 index 00000000..765d9054 --- /dev/null +++ b/buzz/buzz_themes/tests/test_theme_engine.py @@ -0,0 +1,647 @@ +import os +import shutil +import tempfile +from contextlib import contextmanager + +import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase +from frappe.utils import set_request + +from buzz.buzz_themes.doctype.buzz_theme.buzz_theme import ( + clear_theme_cache, + get_render_theme_context, + get_theme_names, + is_within_directory, + validate_theme_name, +) +from buzz.buzz_themes.doctype.buzz_theme_settings.buzz_theme_settings import ( + DEFAULT_ROUTES, + clear_settings_cache, + get_compiled_routes, + seed_default_routes, +) +from buzz.buzz_themes.jinja_helpers import buzz_map_embed_url, buzz_theme_asset_url, buzz_theme_config +from buzz.buzz_themes.theme_resolver import ThemePageRenderer, find_theme_file + + +@contextmanager +def developer_mode(enabled): + original_value = frappe.local.conf.get("developer_mode") + frappe.local.conf.developer_mode = 1 if enabled else 0 + try: + yield + finally: + frappe.local.conf.developer_mode = original_value + + +def write_theme_file(base_dir, relative_path, content): + full_path = os.path.join(base_dir, relative_path) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + with open(full_path, "w") as target_file: + target_file.write(content) + return full_path + + +class TestThemeNameValidation(UnitTestCase): + def test_rejects_names_that_could_reach_the_filesystem(self): + for theme_name in ("../../../evil name", "..", "a/../b", "", + '', + '', + "https://evil.example/maps/embed", + "javascript:alert(1)", + ) + for embed_code in attacks: + with self.subTest(embed_code=embed_code): + self.assertEqual(buzz_map_embed_url(embed_code), "") + + def test_rejects_a_google_lookalike_host(self): + self.assertEqual( + buzz_map_embed_url(''), "" + ) + + def test_empty_input_is_empty_output(self): + for embed_code in ("", None, " "): + with self.subTest(embed_code=embed_code): + self.assertEqual(buzz_map_embed_url(embed_code), "") + + +class TestThemeAssetUrl(ThemeEngineTestCase): + def test_asset_url_points_at_the_active_theme(self): + theme = self.create_theme(assets={"tailwind.output.css": "body{}"}) + self.set_default_theme(theme.name) + + self.assertEqual( + buzz_theme_asset_url("tailwind.output.css"), + f"/assets/buzz/themes/{frappe.scrub(theme.name)}/tailwind.output.css", + ) + + def test_leading_slash_is_normalised(self): + theme = self.create_theme(assets={"scripts/app.js": "// noop"}) + self.set_default_theme(theme.name) + + self.assertEqual( + buzz_theme_asset_url("/scripts/app.js"), + f"/assets/buzz/themes/{frappe.scrub(theme.name)}/scripts/app.js", + ) + + def test_missing_asset_falls_back_to_base_theme_url(self): + theme = self.create_theme() + self.set_default_theme(theme.name) + + self.assertEqual( + buzz_theme_asset_url("images/logo.svg"), + f"/assets/buzz/themes/{frappe.scrub(theme.name)}/images/logo.svg", + ) + + def test_child_theme_asset_overrides_parent(self): + parent_theme = self.create_theme(assets={"tailwind.output.css": "parent"}) + child_theme = self.create_theme( + parent_theme=parent_theme.name, assets={"tailwind.output.css": "child"} + ) + self.set_default_theme(child_theme.name) + + self.assertEqual( + buzz_theme_asset_url("tailwind.output.css"), + f"/assets/buzz/themes/{frappe.scrub(child_theme.name)}/tailwind.output.css", + ) + + def test_parent_asset_is_used_when_child_ships_none(self): + parent_theme = self.create_theme(assets={"tailwind.output.css": "parent"}) + child_theme = self.create_theme(parent_theme=parent_theme.name) + self.set_default_theme(child_theme.name) + + self.assertEqual( + buzz_theme_asset_url("tailwind.output.css"), + f"/assets/buzz/themes/{frappe.scrub(parent_theme.name)}/tailwind.output.css", + ) + + def test_asset_url_is_empty_without_a_configured_theme(self): + self.set_default_theme(None) + self.assertEqual(buzz_theme_asset_url("tailwind.output.css"), "") + + +class TestThemeInheritance(ThemeEngineTestCase): + def test_chain_is_resolved_child_first(self): + grandparent_theme = self.create_theme() + parent_theme = self.create_theme(parent_theme=grandparent_theme.name) + child_theme = self.create_theme(parent_theme=parent_theme.name) + + self.assertEqual( + get_theme_names(child_theme.name), + [child_theme.name, parent_theme.name, grandparent_theme.name], + ) + + def test_template_only_in_parent_is_found(self): + parent_theme = self.create_theme(templates={"pages/about.html": "

Parent About

"}) + child_theme = self.create_theme(parent_theme=parent_theme.name) + self.set_default_theme(child_theme.name) + + context = get_render_theme_context() + self.assertEqual( + find_theme_file(context["dirs"], "pages/about.html"), + os.path.join(self.theme_dir(parent_theme.name), "pages/about.html"), + ) + + def test_circular_inheritance_is_rejected(self): + first_theme = self.create_theme() + second_theme = self.create_theme(parent_theme=first_theme.name) + + first_theme.parent_theme = second_theme.name + with self.assertRaises(frappe.ValidationError): + first_theme.save() + + +class TestCanRender(ThemeEngineTestCase): + def test_declines_when_no_theme_is_configured(self): + self.set_default_theme(None) + self.set_routes([("^probe$", "pages/probe.html", 0)]) + + self.assertFalse(self.make_renderer("probe").can_render()) + + def test_explicit_route_selects_its_template(self): + theme = self.create_theme(templates={"pages/events/detail.html": "

Detail

"}) + self.set_default_theme(theme.name) + self.set_routes([(r"^events/(?P[^/]+)$", "pages/events/detail.html", 0)]) + + renderer = self.make_renderer("events/foss-event") + self.assertTrue(renderer.can_render()) + self.assertEqual(renderer.template_path, "pages/events/detail.html") + self.assertEqual(renderer.match.group("event_route"), "foss-event") + + def test_explicit_route_with_missing_template_does_not_fall_through(self): + theme = self.create_theme(templates={"pages/probe.html": "

Dynamic

"}) + self.set_default_theme(theme.name) + self.set_routes([("^probe$", "pages/gone.html", 0)], dynamic_pages_enabled=1) + + self.assertFalse(self.make_renderer("probe").can_render()) + + def test_dynamic_page_is_served_when_enabled(self): + theme = self.create_theme(templates={"pages/guide/intro.html": "

Intro

"}) + self.set_default_theme(theme.name) + self.set_routes([], dynamic_pages_enabled=1) + + renderer = self.make_renderer("guide/intro") + self.assertTrue(renderer.can_render()) + self.assertEqual(renderer.template_path, "pages/guide/intro.html") + + def test_dynamic_page_declines_when_disabled(self): + theme = self.create_theme(templates={"pages/guide/intro.html": "

Intro

"}) + self.set_default_theme(theme.name) + self.set_routes([], dynamic_pages_enabled=0) + + self.assertFalse(self.make_renderer("guide/intro").can_render()) + + def test_reserved_first_segments_are_never_hijacked(self): + reserved_paths = ("login", "app", "app/buzz-event", "api/method/ping", "assets/buzz/x.css", "desk") + templates = {f"pages/{path}.html": "

Hijacked

" for path in reserved_paths} + theme = self.create_theme(templates=templates) + self.set_default_theme(theme.name) + self.set_routes([], dynamic_pages_enabled=1) + + for path in reserved_paths: + with self.subTest(path=path): + self.assertFalse(self.make_renderer(path).can_render()) + + def test_dynamic_page_cannot_escape_the_pages_directory(self): + theme = self.create_theme( + templates={ + "pages/home.html": "

Home

", + "components/secret_partial.html": "

Partial

", + } + ) + self.set_default_theme(theme.name) + self.set_routes([], dynamic_pages_enabled=1) + + for path in ("../components/secret_partial", "home/../../components/secret_partial"): + with self.subTest(path=path): + self.assertFalse(self.make_renderer(path).can_render()) + + def test_dynamic_page_rejects_unsafe_segments(self): + theme = self.create_theme(templates={"pages/home.html": "

Home

"}) + self.set_default_theme(theme.name) + self.set_routes([], dynamic_pages_enabled=1) + + for path in ("ho me", "home$", "home;x", "ho%me"): + with self.subTest(path=path): + self.assertFalse(self.make_renderer(path).can_render()) + + def test_dynamic_page_inherits_requires_auth_from_a_route_on_the_same_template(self): + theme = self.create_theme(templates={"pages/user/account.html": "

Secret

"}) + self.set_default_theme(theme.name) + self.set_routes([("^account$", "pages/user/account.html", 1)], dynamic_pages_enabled=1) + + renderer = self.make_renderer("user/account") + self.assertTrue(renderer.can_render()) + self.assertTrue(renderer.requires_auth) + + with self.set_user("Guest"), self.assertRaises(frappe.PermissionError): + renderer.render() + + def test_non_reserved_lookalike_segment_still_renders(self): + theme = self.create_theme(templates={"pages/application.html": "

Application

"}) + self.set_default_theme(theme.name) + self.set_routes([], dynamic_pages_enabled=1) + + self.assertTrue(self.make_renderer("application").can_render()) + + +class TestRender(ThemeEngineTestCase): + def test_matched_route_renders_the_theme_template(self): + theme = self.create_theme(templates={"pages/events/detail.html": "

Event {{ event_route }}

"}) + self.set_default_theme(theme.name) + self.set_routes([(r"^events/(?P[^/]+)$", "pages/events/detail.html", 0)]) + + renderer = self.make_renderer("events/foss-event") + self.assertTrue(renderer.can_render()) + response = renderer.render() + + self.assertEqual(response.status_code, 200) + self.assertIn("Event foss-event", response.get_data(as_text=True)) + + def test_child_template_overrides_parent_at_render_time(self): + parent_theme = self.create_theme(templates={"pages/home.html": "

Parent Home

"}) + child_theme = self.create_theme( + parent_theme=parent_theme.name, templates={"pages/home.html": "

Child Home

"} + ) + self.set_default_theme(child_theme.name) + self.set_routes([("^home$", "pages/home.html", 0)]) + + renderer = self.make_renderer("home") + self.assertTrue(renderer.can_render()) + + self.assertIn("Child Home", renderer.render().get_data(as_text=True)) + + def test_requires_auth_route_rejects_guest(self): + theme = self.create_theme(templates={"pages/account.html": "

Account

"}) + self.set_default_theme(theme.name) + self.set_routes([("^account$", "pages/account.html", 1)]) + + renderer = self.make_renderer("account") + self.assertTrue(renderer.can_render()) + + with self.set_user("Guest"), self.assertRaises(frappe.PermissionError): + renderer.render() + + def test_requires_auth_route_renders_for_logged_in_user(self): + theme = self.create_theme(templates={"pages/account.html": "

Account

"}) + self.set_default_theme(theme.name) + self.set_routes([("^account$", "pages/account.html", 1)]) + + renderer = self.make_renderer("account") + self.assertTrue(renderer.can_render()) + + self.assertIn("Account", renderer.render().get_data(as_text=True)) + + +class TestRouteValidation(ThemeEngineTestCase): + def test_invalid_regex_is_rejected_on_save(self): + with self.assertRaises(frappe.ValidationError): + self.set_routes([("^events/(?P[^/]+$", "pages/events/detail.html", 0)]) + + def test_saving_settings_invalidates_the_compiled_routes_cache(self): + self.set_routes([("^first$", "pages/first.html", 0)]) + self.assertEqual( + [route["template_path"] for route in get_compiled_routes()["routes"]], ["pages/first.html"] + ) + + self.set_routes([("^second$", "pages/second.html", 0)]) + self.assertEqual( + [route["template_path"] for route in get_compiled_routes()["routes"]], ["pages/second.html"] + ) + + def test_dynamic_pages_flag_is_carried_into_compiled_routes(self): + self.set_routes([], dynamic_pages_enabled=0) + self.assertFalse(get_compiled_routes()["dynamic_pages_enabled"]) + + self.set_routes([], dynamic_pages_enabled=1) + self.assertTrue(get_compiled_routes()["dynamic_pages_enabled"]) + + +class TestThemeCacheInvalidation(ThemeEngineTestCase): + def test_changing_default_theme_changes_the_render_context(self): + first_theme = self.create_theme() + second_theme = self.create_theme() + + self.set_default_theme(first_theme.name) + self.assertEqual(get_render_theme_context()["theme_name"], first_theme.name) + + self.set_default_theme(second_theme.name) + self.assertEqual(get_render_theme_context()["theme_name"], second_theme.name) + self.assertEqual(get_render_theme_context()["dirs"], [self.theme_dir(second_theme.name)]) + + def test_adding_a_parent_theme_refreshes_the_cached_chain(self): + parent_theme = self.create_theme() + child_theme = self.create_theme() + self.set_default_theme(child_theme.name) + + self.assertEqual(get_render_theme_context()["names"], [child_theme.name]) + + child_theme.parent_theme = parent_theme.name + child_theme.save() + + self.assertEqual(get_render_theme_context()["names"], [child_theme.name, parent_theme.name]) + + +class TestOnTrash(ThemeEngineTestCase): + def test_standard_theme_files_survive_a_delete(self): + theme = self.create_theme(is_standard=1, templates={"pages/home.html": "

Shipped

"}) + theme_dir = self.theme_dir(theme.name) + + with developer_mode(True): + frappe.delete_doc("Buzz Theme", theme.name, force=True) + + self.assertTrue(os.path.isdir(theme_dir)) + self.assertTrue(os.path.isfile(os.path.join(theme_dir, "pages/home.html"))) + + def test_non_standard_theme_files_are_removed_in_developer_mode(self): + theme = self.create_theme(is_standard=0, templates={"pages/home.html": "

Scaffolded

"}) + theme_dir = self.theme_dir(theme.name) + + with developer_mode(True): + frappe.delete_doc("Buzz Theme", theme.name, force=True) + + self.assertFalse(os.path.isdir(theme_dir)) + + def test_non_standard_theme_files_survive_outside_developer_mode(self): + theme = self.create_theme(is_standard=0, templates={"pages/home.html": "

Site data

"}) + theme_dir = self.theme_dir(theme.name) + + with developer_mode(False): + frappe.delete_doc("Buzz Theme", theme.name, force=True) + + self.assertTrue(os.path.isdir(theme_dir)) + + +class TestNavLinkValidation(IntegrationTestCase): + def make_nav_link(self, url): + return frappe.get_doc({"doctype": "Buzz Theme Nav Link", "label": "Somewhere", "url": url}) + + def test_site_relative_path_is_accepted(self): + self.make_nav_link("/b/event-proposal").validate() + + def test_https_url_is_accepted(self): + self.make_nav_link("https://example.com/docs").validate() + + def test_javascript_scheme_is_rejected(self): + with self.assertRaises(frappe.ValidationError): + self.make_nav_link("javascript:alert(1)").validate() + + def test_data_scheme_is_rejected(self): + with self.assertRaises(frappe.ValidationError): + self.make_nav_link("data:text/html,").validate() + + +class TestDefaultThemeNavLinks(IntegrationTestCase): + def setUp(self): + self.settings = frappe.get_doc("Buzz Default Theme Settings") + self.addCleanup(self.restore_nav_links, list(self.settings.nav_links)) + self.settings.set("nav_links", []) + + def restore_nav_links(self, original_rows): + settings = frappe.get_doc("Buzz Default Theme Settings") + settings.set("nav_links", []) + for row in original_rows: + settings.append( + "nav_links", {"label": row.label, "url": row.url, "open_in_new_tab": row.open_in_new_tab} + ) + settings.save() + frappe.db.commit() + + def test_empty_table_falls_back_to_bundled_links(self): + links = self.settings.get_nav_links() + + self.assertEqual([link.label for link in links], ["Dashboard", "Propose Event"]) + self.assertEqual([link.url for link in links], ["/b", "/b/event-proposal"]) + + def test_configured_links_replace_the_fallback_entirely(self): + self.settings.append("nav_links", {"label": "Events", "url": "/category/conferences"}) + + links = self.settings.get_nav_links() + + self.assertEqual([link.label for link in links], ["Events"]) + + def test_configured_link_keeps_the_new_tab_flag(self): + self.settings.append( + "nav_links", {"label": "Docs", "url": "https://example.com", "open_in_new_tab": 1} + ) + + self.assertTrue(self.settings.get_nav_links()[0].open_in_new_tab) + + +class TestThemeConfigHelper(ThemeEngineTestCase): + def test_config_returns_the_linked_settings_single(self): + theme = self.create_theme() + frappe.db.set_value("Buzz Theme", theme.name, "theme_settings", "Buzz Default Theme Settings") + clear_theme_cache() + self.set_default_theme(theme.name) + + self.assertEqual(buzz_theme_config().doctype, "Buzz Default Theme Settings") + + def test_config_is_an_empty_dict_when_no_settings_are_linked(self): + theme = self.create_theme() + self.set_default_theme(theme.name) + + self.assertEqual(buzz_theme_config(), frappe._dict()) + + +class TestDefaultRouteSeeding(IntegrationTestCase): + def setUp(self): + settings = frappe.get_single("Buzz Theme Settings") + self.addCleanup(self.restore_routes, [row.as_dict() for row in settings.routes]) + + def restore_routes(self, original_rows): + settings = frappe.get_single("Buzz Theme Settings") + settings.set("routes", []) + for row in original_rows: + settings.append("routes", row) + settings.save(ignore_permissions=True) + + def current_patterns(self): + return [row.url_pattern for row in frappe.get_single("Buzz Theme Settings").routes] + + def test_seeding_an_empty_settings_adds_every_default_route(self): + settings = frappe.get_single("Buzz Theme Settings") + settings.set("routes", []) + settings.save(ignore_permissions=True) + + seed_default_routes() + + self.assertEqual(self.current_patterns(), [route["url_pattern"] for route in DEFAULT_ROUTES]) + + def test_seeding_twice_does_not_duplicate_routes(self): + seed_default_routes() + patterns_after_first_run = self.current_patterns() + + seed_default_routes() + + self.assertEqual(self.current_patterns(), patterns_after_first_run) + + def test_seeding_preserves_custom_routes(self): + settings = frappe.get_single("Buzz Theme Settings") + settings.set("routes", []) + settings.append( + "routes", + {"url_pattern": "^sponsors$", "template_path": "pages/sponsors.html", "requires_auth": 0}, + ) + settings.save(ignore_permissions=True) + + seed_default_routes() + + self.assertIn("^sponsors$", self.current_patterns()) + self.assertIn("^home$", self.current_patterns()) diff --git a/buzz/buzz_themes/theme_resolver.py b/buzz/buzz_themes/theme_resolver.py new file mode 100644 index 00000000..f73067c6 --- /dev/null +++ b/buzz/buzz_themes/theme_resolver.py @@ -0,0 +1,284 @@ +import importlib.util +import os +import re + +import frappe +from frappe.utils.jinja import get_jenv +from frappe.utils.jinja_globals import is_rtl +from frappe.website.doctype.website_settings.website_settings import get_website_settings +from frappe.website.utils import build_response +from jinja2 import BaseLoader, TemplateNotFound + +from buzz.buzz_themes.doctype.buzz_theme.buzz_theme import get_render_theme_context, is_within_directory +from buzz.buzz_themes.doctype.buzz_theme_settings.buzz_theme_settings import get_compiled_routes + +RESERVED_PATH_SEGMENTS = frozenset( + { + "api", + "app", + "assets", + "b", + "backups", + "dashboard", + "desk", + "files", + "login", + "private", + "socket.io", + } +) + +# A dynamic page name maps straight onto a file under the theme's pages/ directory, so every +# segment has to be inert on a filesystem. Werkzeug hands us the path un-normalised, which +# means an encoded "..%2f" would otherwise walk out of pages/ and render any partial. +DYNAMIC_PATH_SEGMENT_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") + + +def is_safe_dynamic_path(request_path): + segments = request_path.split("/") + return all(DYNAMIC_PATH_SEGMENT_PATTERN.match(segment) for segment in segments) + + +def find_theme_file(theme_dirs, relative_path): + if ".." in relative_path.split("/"): + return None + + for theme_dir in theme_dirs: + candidate = os.path.join(theme_dir, relative_path) + if is_within_directory(theme_dir, candidate) and os.path.isfile(candidate): + return candidate + return None + + +def get_template_auth_requirement(routes, template_path): + return any(route["requires_auth"] for route in routes if route["template_path"] == template_path) + + +page_controller_modules = {} + + +def load_page_controller(theme_dirs, template_relative_path): + controller_relative_path = f"{os.path.splitext(template_relative_path)[0]}.py" + controller_path = find_theme_file(theme_dirs, controller_relative_path) + if not controller_path: + return None + + modified_time = os.path.getmtime(controller_path) + cached = page_controller_modules.get(controller_path) + if cached and cached[0] == modified_time: + return cached[1] + + module_name = get_controller_module_name(theme_dirs, controller_path, controller_relative_path) + spec = importlib.util.spec_from_file_location(module_name, controller_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + page_controller_modules[controller_path] = (modified_time, module) + return module + + +def get_controller_module_name(theme_dirs, controller_path, relative_path): + theme_slug = "" + for theme_dir in theme_dirs: + if os.path.join(theme_dir, relative_path) == controller_path: + theme_slug = os.path.basename(theme_dir.rstrip(os.sep)) + break + + page_slug = os.path.splitext(relative_path)[0] + for separator in (os.sep, "/", "-", "."): + page_slug = page_slug.replace(separator, "_") + + return f"buzz.buzz_themes.theme_pages.{theme_slug}.{page_slug}" + + +def run_page_controller(theme_dirs, template_relative_path, context): + module = load_page_controller(theme_dirs, template_relative_path) + if not module or not hasattr(module, "get_context"): + return + + data = module.get_context(context) + if data: + context.update(data) + + +class ThemePageRenderer: + def __init__(self, path, http_status_code=None): + self.path = path + self.http_status_code = http_status_code + self.theme_dirs = None + self.template_path = None + self.match = None + self.requires_auth = False + + def can_render(self): + context = get_render_theme_context() + if not context["theme_name"] or not context["dirs"]: + return False + + if hasattr(frappe.local, "request") and frappe.local.request: + request_path = frappe.local.request.path.strip("/") + else: + request_path = self.path.strip("/") + + settings = get_compiled_routes() + + matched_route = None + match = None + for route in settings["routes"]: + candidate = route["pattern"].match(request_path) + if candidate: + matched_route = route + match = candidate + break + + if matched_route: + template_path = matched_route["template_path"] + requires_auth = matched_route["requires_auth"] + elif ( + settings["dynamic_pages_enabled"] + and request_path + and request_path.split("/")[0] not in RESERVED_PATH_SEGMENTS + and is_safe_dynamic_path(request_path) + ): + template_path = f"pages/{request_path}.html" + # The auth flag belongs to the template, not to the URL that reached it. Without this + # lookup a gated template stays reachable by its file path once dynamic pages are on. + requires_auth = get_template_auth_requirement(settings["routes"], template_path) + else: + return False + + if not find_theme_file(context["dirs"], template_path): + return False + + self.theme_dirs = context["dirs"] + self.template_path = template_path + self.match = match + self.requires_auth = requires_auth + return True + + def render(self): + if self.requires_auth and frappe.session.user == "Guest": + raise frappe.PermissionError + + context = get_base_context(self.match) + run_page_controller(self.theme_dirs, self.template_path, context) + html = self.render_with_theme_loader(context) + return build_response(self.path, html, self.http_status_code or 200) + + def render_with_theme_loader(self, context): + jenv = get_jenv() + theme_env = get_theme_environment(jenv, self.theme_dirs) + template = theme_env.get_template(f"theme://{self.template_path}", globals=jenv.globals) + return template.render(context) + + +class ThemeFallbackLoader(BaseLoader): + def __init__(self, theme_dirs, fallback_loader): + self.theme_dirs = tuple(theme_dirs) + self.fallback_loader = fallback_loader + + def get_source(self, environment, template): + if template.startswith("theme://"): + relative_path = template[len("theme://") :] + full_path = find_theme_file(self.theme_dirs, relative_path) + if full_path: + return read_template_source(full_path) + raise TemplateNotFound(template) + + relative_path = template + if relative_path.startswith("templates/"): + relative_path = relative_path[len("templates/") :] + + for theme_dir in self.theme_dirs: + for candidate_relative in (relative_path, os.path.join("components", relative_path)): + candidate = os.path.join(theme_dir, candidate_relative) + if is_within_directory(theme_dir, candidate) and os.path.isfile(candidate): + return read_template_source(candidate) + + return self.fallback_loader.get_source(environment, template) + + +def read_template_source(full_path): + modified_time = os.path.getmtime(full_path) + # nosemgrep: frappe-semgrep-rules.rules.security.frappe-security-file-traversal + with open(full_path) as source_file: + source = source_file.read() + return ( + source, + full_path, + lambda path=full_path, modified=modified_time: os.path.getmtime(path) == modified, + ) + + +theme_environments = {} + + +def get_theme_environment(jenv, theme_dirs): + key = tuple(theme_dirs) + theme_env = theme_environments.get(key) + if theme_env is None: + loader = ThemeFallbackLoader(theme_dirs, jenv.loader) + # Frappe's shared jenv renders with autoescape off, which makes every {{ }} in a theme an + # XSS sink for any field Frappe does not sanitize on save (Code and Data fields, and + # anything written through db.set_value, which skips validation). Themes are ours to + # define, so they opt in to escape-by-default and mark the deliberate HTML with |safe. + theme_env = jenv.overlay(loader=loader, autoescape=True) + theme_env.auto_reload = bool(frappe.conf.get("developer_mode") or frappe._dev_server) + theme_environments[key] = theme_env + + # The overlay is cached for the process but jenv is rebuilt per request, and its globals carry + # request state (session.user, csrf_token, form_dict). Without this reassignment a macro + # imported without "with context" would read whichever request built the overlay first. + theme_env.globals = jenv.globals + return theme_env + + +def get_base_context(match): + context = frappe._dict( + is_rtl=is_rtl(), + csrf_token=frappe.sessions.get_csrf_token(), + ) + + if match: + set_route_groups(match, context) + + set_website_settings(context) + set_website_context_hooks(context) + + return context + + +DEFAULT_APP_NAME = "Buzz" + + +def set_website_settings(context): + context.app_name = DEFAULT_APP_NAME + context.app_logo = None + context.boot = {} + + # Also fills context.boot, so a themed page gets the same site-wide context frappe's own + # renderers build. A failure here means the site is broken, not that this page should quietly + # render unbranded - swallowing it used to write one Error Log row per request. + context.update(get_website_settings()) + + # get_website_settings() leaves out the branding fields the themes render the brand from, + # so a site never has to restate its own identity. + app_name, app_logo = frappe.db.get_value("Website Settings", None, ["app_name", "app_logo"]) + context.app_name = app_name or DEFAULT_APP_NAME + context.app_logo = app_logo + + +def set_route_groups(match, context): + groups = match.groupdict() or {} + if not groups: + return + + # Route captures belong to the render context only. Merging them into form_dict would let a + # capture named cmd/user/doctype shadow a real request argument. + context.update(groups) + + +def set_website_context_hooks(context): + for hook_method in frappe.get_hooks("update_website_context"): + values = frappe.get_attr(hook_method)(context) + if values: + context.update(values) diff --git a/buzz/events/doctype/buzz_settings/buzz_settings.json b/buzz/events/doctype/buzz_settings/buzz_settings.json index ece41503..96d1be46 100644 --- a/buzz/events/doctype/buzz_settings/buzz_settings.json +++ b/buzz/events/doctype/buzz_settings/buzz_settings.json @@ -7,6 +7,7 @@ "field_order": [ "general_section", "support_email", + "default_theme", "ticketing_section", "allow_transfer_ticket_before_event_start_days", "column_break_ehsr", @@ -88,6 +89,12 @@ "label": "Support Email", "options": "Email" }, + { + "fieldname": "default_theme", + "fieldtype": "Link", + "label": "Default Theme", + "options": "Buzz Theme" + }, { "fieldname": "proposals_tab", "fieldtype": "Tab Break", diff --git a/buzz/events/doctype/event_category/event_category.json b/buzz/events/doctype/event_category/event_category.json index ea4b4080..b9266f38 100644 --- a/buzz/events/doctype/event_category/event_category.json +++ b/buzz/events/doctype/event_category/event_category.json @@ -12,45 +12,59 @@ "column_break_mrmh", "banner_image", "meta_image", + "icon_name", + "subtitle", "icon_svg" ], "fields": [ - { - "fieldname": "icon_svg", - "fieldtype": "Code", - "label": "Icon SVG", - "options": "HTML" - }, { "default": "1", "fieldname": "enabled", "fieldtype": "Check", "label": "Enabled" }, - { - "fieldname": "banner_image", - "fieldtype": "Attach Image", - "label": "Banner Image" - }, { "fieldname": "slug", "fieldtype": "Data", "label": "Slug", "unique": 1 }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Description" + }, { "fieldname": "column_break_mrmh", "fieldtype": "Column Break" }, { - "fieldname": "description", - "fieldtype": "Small Text", - "label": "Description" + "fieldname": "banner_image", + "fieldtype": "Attach Image", + "label": "Banner Image" }, { "fieldname": "meta_image", "fieldtype": "Attach Image", "label": "Meta Image" + }, + { + "fieldname": "icon_name", + "fieldtype": "Data", + "label": "Icon Name", + "description": "Name of a bundled icon, e.g. users, monitor, route, coffee, map-pin. Themes render this inline." + }, + { + "fieldname": "subtitle", + "fieldtype": "Data", + "label": "Subtitle", + "description": "Short kicker shown above the category name, e.g. Flagship, Online, Roadshow." + }, + { + "fieldname": "icon_svg", + "fieldtype": "Code", + "label": "Icon SVG", + "options": "HTML" } ], "grid_page_length": 50, diff --git a/buzz/hooks.py b/buzz/hooks.py index 17648037..b05d5445 100644 --- a/buzz/hooks.py +++ b/buzz/hooks.py @@ -25,6 +25,18 @@ {"from_route": "/b/", "to_route": "dashboard"}, ] +page_renderer = ["buzz.buzz_themes.theme_resolver.ThemePageRenderer"] + +importable_doctypes = ["Buzz Theme"] + +jinja = { + "methods": [ + "buzz.buzz_themes.jinja_helpers.buzz_theme_asset_url", + "buzz.buzz_themes.jinja_helpers.buzz_theme_config", + "buzz.buzz_themes.jinja_helpers.buzz_map_embed_url", + ], +} + # Keep old /dashboard/* links working: redirect to the shortened /b/* scheme. # Ordered specific -> catch-all; the first matching source wins. # forward_query_parameters is required on every rule: without it the query diff --git a/buzz/install.py b/buzz/install.py index 16f74183..84b5da32 100644 --- a/buzz/install.py +++ b/buzz/install.py @@ -1,5 +1,6 @@ import frappe +from buzz.buzz_themes.doctype.buzz_theme_settings.buzz_theme_settings import seed_default_routes from buzz.events.doctype.buzz_team.buzz_team import create_default_team_for from buzz.utils import delete_custom_fields, get_custom_fields_creator @@ -165,6 +166,7 @@ def after_install(): create_event_categories() create_talk_proposal_statuses() create_custom_fields() + seed_default_routes() def on_migrate(): @@ -172,6 +174,9 @@ def on_migrate(): create_event_categories() create_talk_proposal_statuses() create_custom_fields() + # seed_default_routes() is deliberately not called here: it is idempotent by pattern, so a + # site that removed a bundled route would get it back on every migrate. after_install and the + # one-shot patch are the only places a route table should be seeded. def after_app_install(app_name: str): @@ -234,6 +239,7 @@ def create_event_categories(): categories = [ { "name": "Meetups", + "icon_name": "coffee", "icon_svg": """ """, @@ -241,6 +247,7 @@ def create_event_categories(): }, { "name": "Conferences", + "icon_name": "users", "icon_svg": """ @@ -249,6 +256,7 @@ def create_event_categories(): }, { "name": "Local", + "icon_name": "map-pin", "icon_svg": """ @@ -258,6 +266,7 @@ def create_event_categories(): }, { "name": "Webinars", + "icon_name": "monitor", "icon_svg": """ """, @@ -265,6 +274,7 @@ def create_event_categories(): }, { "name": "Zoom Meeting", + "icon_name": "video", "icon_svg": """ diff --git a/buzz/modules.txt b/buzz/modules.txt index c593d6b7..56a19165 100644 --- a/buzz/modules.txt +++ b/buzz/modules.txt @@ -2,4 +2,5 @@ Buzz Events Ticketing Proposals -Buzz Marketing \ No newline at end of file +Buzz Marketing +Buzz Themes \ No newline at end of file diff --git a/buzz/patches.txt b/buzz/patches.txt index 9c957259..a835ea64 100644 --- a/buzz/patches.txt +++ b/buzz/patches.txt @@ -5,6 +5,7 @@ buzz.patches.migrate_offline_payment_to_methods # Patches added in this section will be executed before doctypes are migrated # Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations +buzz.patches.rename_theme_module_to_buzz_themes [post_model_sync] # Patches added in this section will be executed after doctypes are migrated @@ -18,3 +19,6 @@ buzz.patches.rename_free_webinar_to_free_event buzz.patches.create_default_teams #2 buzz.patches.assign_default_team #2 buzz.patches.create_team_settings_for_existing_teams +buzz.patches.seed_buzz_theme_routes +buzz.patches.drop_buzz_event_theme_column +buzz.patches.set_icon_name_for_event_categories diff --git a/buzz/patches/drop_buzz_event_theme_column.py b/buzz/patches/drop_buzz_event_theme_column.py new file mode 100644 index 00000000..56523d5b --- /dev/null +++ b/buzz/patches/drop_buzz_event_theme_column.py @@ -0,0 +1,10 @@ +import frappe + + +def execute(): + if not frappe.db.has_column("Buzz Event", "theme"): + return + + frappe.db.sql_ddl("ALTER TABLE `tabBuzz Event` DROP COLUMN `theme`") + + frappe.client_cache.delete_value("table_columns::tabBuzz Event") diff --git a/buzz/patches/rename_theme_module_to_buzz_themes.py b/buzz/patches/rename_theme_module_to_buzz_themes.py new file mode 100644 index 00000000..1e840007 --- /dev/null +++ b/buzz/patches/rename_theme_module_to_buzz_themes.py @@ -0,0 +1,26 @@ +import frappe + +OLD_MODULE = "Theme" +NEW_MODULE = "Buzz Themes" +THEME_DOCTYPES = ("Buzz Theme", "Buzz Theme Settings", "Buzz Themed Route") + + +def execute(): + if not frappe.db.exists("Module Def", OLD_MODULE): + return + + if not frappe.db.exists("Module Def", NEW_MODULE): + module_def = frappe.new_doc("Module Def") + module_def.module_name = NEW_MODULE + module_def.app_name = "buzz" + module_def.insert(ignore_permissions=True) + + for doctype in THEME_DOCTYPES: + if frappe.db.exists("DocType", doctype): + frappe.db.set_value("DocType", doctype, "module", NEW_MODULE, update_modified=False) + + if frappe.db.has_column("Buzz Theme", "module"): + for theme_name in frappe.get_all("Buzz Theme", filters={"module": OLD_MODULE}, pluck="name"): + frappe.db.set_value("Buzz Theme", theme_name, "module", NEW_MODULE, update_modified=False) + + frappe.delete_doc("Module Def", OLD_MODULE, ignore_permissions=True, force=True) diff --git a/buzz/patches/seed_buzz_theme_routes.py b/buzz/patches/seed_buzz_theme_routes.py new file mode 100644 index 00000000..3549eba0 --- /dev/null +++ b/buzz/patches/seed_buzz_theme_routes.py @@ -0,0 +1,5 @@ +from buzz.buzz_themes.doctype.buzz_theme_settings.buzz_theme_settings import seed_default_routes + + +def execute(): + seed_default_routes() diff --git a/buzz/patches/set_icon_name_for_event_categories.py b/buzz/patches/set_icon_name_for_event_categories.py new file mode 100644 index 00000000..44d78529 --- /dev/null +++ b/buzz/patches/set_icon_name_for_event_categories.py @@ -0,0 +1,21 @@ +import frappe + +# The themes used to render Event Category.icon_svg raw. That field is a Code field, which Frappe +# does not XSS-sanitize, so themes now draw a named icon instead. Backfill the bundled categories +# so an existing site keeps a meaningful icon instead of falling back to the generic one. +BUNDLED_CATEGORY_ICONS = { + "Conferences": "users", + "Local": "map-pin", + "Meetups": "coffee", + "Webinars": "monitor", + "Yatra": "route", + "Zoom Meeting": "video", +} + + +def execute(): + categories = frappe.get_all("Event Category", filters={"icon_name": ["in", ["", None]]}, fields=["name"]) + for category in categories: + icon_name = BUNDLED_CATEGORY_ICONS.get(category.name) + if icon_name: + frappe.db.set_value("Event Category", category.name, "icon_name", icon_name) diff --git a/buzz/public/themes/buzz_base_theme/images/placeholder.svg b/buzz/public/themes/buzz_base_theme/images/placeholder.svg new file mode 100644 index 00000000..a58edd74 --- /dev/null +++ b/buzz/public/themes/buzz_base_theme/images/placeholder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/buzz/public/themes/buzz_base_theme/scripts/components.js b/buzz/public/themes/buzz_base_theme/scripts/components.js new file mode 100644 index 00000000..73f57101 --- /dev/null +++ b/buzz/public/themes/buzz_base_theme/scripts/components.js @@ -0,0 +1,190 @@ +document.addEventListener("alpine:init", function () { + Alpine.data("stickyCta", function () { + return { + visible: false, + init: function () { + var self = this; + var hero = document.getElementById("hero"); + function update() { + if (!hero) { + self.visible = window.scrollY > 240; + return; + } + var rect = hero.getBoundingClientRect(); + self.visible = rect.bottom < 80; + } + window.addEventListener("scroll", update, { passive: true }); + update(); + }, + }; + }); + + Alpine.data("eventFilter", function () { + return { + filter: "upcoming", + set: function (value) { + this.filter = value; + }, + matches: function (status) { + return this.filter === "all" || this.filter === status; + }, + }; + }); + + Alpine.data("loginDialog", function () { + return { + open: false, + view: "login", + loading: false, + error: "", + success: "", + form: { email: "", password: "", full_name: "" }, + context: null, + csrf: function () { + var element = document.querySelector('meta[name="csrf-token"]'); + return element ? element.getAttribute("content") : ""; + }, + async loadContext() { + if (this.context) return; + try { + const response = await fetch( + "/api/method/buzz.api.auth.get_login_context?redirect_to=" + + encodeURIComponent(window.location.href), + { headers: { "X-Frappe-CSRF-Token": this.csrf() } } + ); + const data = await response.json().catch(() => ({})); + this.context = (data && data.message) || {}; + } catch (error) { + this.context = {}; + } + }, + show() { + this.open = true; + this.view = "login"; + this.reset(); + this.loadContext(); + }, + hide() { + this.open = false; + }, + switchView(view) { + this.view = view; + this.error = ""; + this.success = ""; + }, + reset() { + this.error = ""; + this.success = ""; + this.form = { email: "", password: "", full_name: "" }; + }, + titleFor() { + return { login: "scribble in", signup: "fresh page", forgot: "lost the page?" }[ + this.view + ]; + }, + labelFor() { + return { login: "sign in", signup: "new sketch", forgot: "reset" }[this.view]; + }, + parseError(data, fallback) { + if (data && data._server_messages) { + try { + const messages = JSON.parse(data._server_messages); + if (messages.length) return JSON.parse(messages[0]).message || fallback; + } catch (error) {} + } + return (data && data.message) || fallback; + }, + async submitLogin() { + this.loading = true; + this.error = ""; + try { + const response = await fetch("/api/method/login", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Frappe-CSRF-Token": this.csrf(), + }, + body: new URLSearchParams({ + usr: this.form.email, + pwd: this.form.password, + }), + }); + if (response.ok) { + window.location.reload(); + return; + } + const data = await response.json().catch(() => ({})); + this.error = this.parseError(data, "wrong email or password"); + } catch (error) { + this.error = error.message || "network hiccup"; + } finally { + this.loading = false; + } + }, + async submitSignup() { + this.loading = true; + this.error = ""; + this.success = ""; + try { + const response = await fetch( + "/api/method/frappe.core.doctype.user.user.sign_up", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Frappe-CSRF-Token": this.csrf(), + }, + body: new URLSearchParams({ + email: this.form.email, + full_name: this.form.full_name, + redirect_to: window.location.pathname, + }), + } + ); + const data = await response.json().catch(() => ({})); + if (response.ok) this.success = "check your email to verify."; + else this.error = this.parseError(data, "couldn't sign up. try again."); + } catch (error) { + this.error = error.message || "network hiccup"; + } finally { + this.loading = false; + } + }, + async submitForgot() { + this.loading = true; + this.error = ""; + this.success = ""; + try { + const response = await fetch( + "/api/method/frappe.core.doctype.user.user.reset_password", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Frappe-CSRF-Token": this.csrf(), + }, + body: new URLSearchParams({ user: this.form.email }), + } + ); + const data = await response.json().catch(() => ({})); + if (response.ok) this.success = "reset link sent."; + else this.error = this.parseError(data, "couldn't send link."); + } catch (error) { + this.error = error.message || "network hiccup"; + } finally { + this.loading = false; + } + }, + async logout() { + try { + await fetch("/api/method/logout", { + method: "POST", + headers: { "X-Frappe-CSRF-Token": this.csrf() }, + }); + } finally { + window.location.reload(); + } + }, + }; + }); +}); diff --git a/buzz/public/themes/buzz_base_theme/scripts/theme.js b/buzz/public/themes/buzz_base_theme/scripts/theme.js new file mode 100644 index 00000000..6e9d26ee --- /dev/null +++ b/buzz/public/themes/buzz_base_theme/scripts/theme.js @@ -0,0 +1,20 @@ +(function () { + function apply(theme) { + if (theme === "dark") document.documentElement.setAttribute("data-theme", "dark"); + else document.documentElement.removeAttribute("data-theme"); + } + function current() { + return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light"; + } + function toggle() { + var next = current() === "dark" ? "light" : "dark"; + apply(next); + try { + localStorage.setItem("bp-theme", next); + } catch (error) {} + } + document.addEventListener("DOMContentLoaded", function () { + var button = document.getElementById("themeToggle"); + if (button) button.addEventListener("click", toggle); + }); +})(); diff --git a/buzz/public/themes/buzz_base_theme/scripts/vendor/alpine.min.js b/buzz/public/themes/buzz_base_theme/scripts/vendor/alpine.min.js new file mode 100644 index 00000000..aafdc242 --- /dev/null +++ b/buzz/public/themes/buzz_base_theme/scripts/vendor/alpine.min.js @@ -0,0 +1,2859 @@ +(() => { + var ee = !1, + re = !1, + W = [], + ne = -1, + ie = !1; + function Ve(t) { + Dn(t); + } + function Ue() { + ie = !0; + } + function qe() { + (ie = !1), We(); + } + function Dn(t) { + W.includes(t) || W.push(t), We(); + } + function Ke(t) { + let e = W.indexOf(t); + e !== -1 && e > ne && W.splice(e, 1); + } + function We() { + if (!re && !ee) { + if (ie) return; + (ee = !0), queueMicrotask(In); + } + } + function In() { + (ee = !1), (re = !0); + for (let t = 0; t < W.length; t++) W[t](), (ne = t); + (W.length = 0), (ne = -1), (re = !1); + } + var C, + R, + j, + se, + oe = !0; + function Ge(t) { + (oe = !1), t(), (oe = !0); + } + function Je(t) { + (C = t.reactive), + (j = t.release), + (R = (e) => + t.effect(e, { + scheduler: (r) => { + oe ? Ve(r) : r(); + }, + })), + (se = t.raw); + } + function ae(t) { + R = t; + } + function Ye(t) { + let e = () => {}; + return [ + (n) => { + let i = R(n); + return ( + t._x_effects || + ((t._x_effects = new Set()), + (t._x_runEffects = () => { + t._x_effects.forEach((o) => o()); + })), + t._x_effects.add(i), + (e = () => { + i !== void 0 && (t._x_effects.delete(i), j(i)); + }), + i + ); + }, + () => { + e(); + }, + ]; + } + function St(t, e) { + let r = !0, + n, + i, + o = R(() => { + let s = t(), + a = JSON.stringify(s); + if (!r && (typeof s == "object" || s !== n)) { + let c = typeof n == "object" ? JSON.parse(i) : n; + queueMicrotask(() => { + e(s, c); + }); + } + (n = s), (i = a), (r = !1); + }); + return () => j(o); + } + async function Xe(t) { + Ue(); + try { + await t(), await Promise.resolve(); + } finally { + qe(); + } + } + var Ze = [], + Qe = [], + tr = []; + function er(t) { + tr.push(t); + } + function et(t, e) { + typeof e == "function" + ? (t._x_cleanups || (t._x_cleanups = []), t._x_cleanups.push(e)) + : ((e = t), Qe.push(e)); + } + function At(t) { + Ze.push(t); + } + function Ot(t, e, r) { + t._x_attributeCleanups || (t._x_attributeCleanups = {}), + t._x_attributeCleanups[e] || (t._x_attributeCleanups[e] = []), + t._x_attributeCleanups[e].push(r); + } + function ce(t, e) { + t._x_attributeCleanups && + Object.entries(t._x_attributeCleanups).forEach(([r, n]) => { + (e === void 0 || e.includes(r)) && + (n.forEach((i) => i()), delete t._x_attributeCleanups[r]); + }); + } + function rr(t) { + for (t._x_effects?.forEach(Ke); t._x_cleanups?.length; ) t._x_cleanups.pop()(); + } + var le = new MutationObserver(pe), + ue = !1; + function ut() { + le.observe(document, { + subtree: !0, + childList: !0, + attributes: !0, + attributeOldValue: !0, + }), + (ue = !0); + } + function fe() { + kn(), le.disconnect(), (ue = !1); + } + var lt = []; + function kn() { + let t = le.takeRecords(); + lt.push(() => t.length > 0 && pe(t)); + let e = lt.length; + queueMicrotask(() => { + if (lt.length === e) for (; lt.length > 0; ) lt.shift()(); + }); + } + function m(t) { + if (!ue) return t(); + fe(); + let e = t(); + return ut(), e; + } + var de = !1, + vt = []; + function nr() { + de = !0; + } + function ir() { + (de = !1), pe(vt), (vt = []); + } + function pe(t) { + if (de) { + vt = vt.concat(t); + return; + } + let e = [], + r = new Set(), + n = new Map(), + i = new Map(); + for (let o = 0; o < t.length; o++) + if ( + !t[o].target._x_ignoreMutationObserver && + (t[o].type === "childList" && + (t[o].removedNodes.forEach((s) => { + s.nodeType === 1 && s._x_marker && r.add(s); + }), + t[o].addedNodes.forEach((s) => { + if (s.nodeType === 1) { + if (r.has(s)) { + r.delete(s); + return; + } + s._x_marker || e.push(s); + } + })), + t[o].type === "attributes") + ) { + let s = t[o].target, + a = t[o].attributeName, + c = t[o].oldValue, + l = () => { + n.has(s) || n.set(s, []), + n.get(s).push({ name: a, value: s.getAttribute(a) }); + }, + u = () => { + i.has(s) || i.set(s, []), i.get(s).push(a); + }; + s.hasAttribute(a) && c === null ? l() : s.hasAttribute(a) ? (u(), l()) : u(); + } + i.forEach((o, s) => { + ce(s, o); + }), + n.forEach((o, s) => { + Ze.forEach((a) => a(s, o)); + }); + for (let o of r) e.some((s) => s.contains(o)) || Qe.forEach((s) => s(o)); + for (let o of e) o.isConnected && tr.forEach((s) => s(o)); + (e = null), (r = null), (n = null), (i = null); + } + function Ct(t) { + return P(F(t)); + } + function N(t, e, r) { + return ( + (t._x_dataStack = [e, ...F(r || t)]), + () => { + t._x_dataStack = t._x_dataStack.filter((n) => n !== e); + } + ); + } + function F(t) { + return t._x_dataStack + ? t._x_dataStack + : typeof ShadowRoot == "function" && t instanceof ShadowRoot + ? F(t.host) + : t.parentNode + ? F(t.parentNode) + : []; + } + function P(t) { + return new Proxy({ objects: t }, $n); + } + function or(t, e) { + return t === null || t === Object.prototype + ? null + : Object.prototype.hasOwnProperty.call(t, e) + ? t + : or(Object.getPrototypeOf(t), e); + } + var $n = { + ownKeys({ objects: t }) { + return Array.from(new Set(t.flatMap((e) => Object.keys(e)))); + }, + has({ objects: t }, e) { + return e == Symbol.unscopables + ? !1 + : t.some((r) => Object.prototype.hasOwnProperty.call(r, e) || Reflect.has(r, e)); + }, + get({ objects: t }, e, r) { + return e == "toJSON" ? Ln : Reflect.get(t.find((n) => Reflect.has(n, e)) || {}, e, r); + }, + set({ objects: t }, e, r, n) { + let i; + for (let s of t) if (((i = or(s, e)), i)) break; + i || (i = t[t.length - 1]); + let o = Object.getOwnPropertyDescriptor(i, e); + return o?.set && o?.get ? o.set.call(n, r) || !0 : Reflect.set(i, e, r); + }, + }; + function Ln() { + return Reflect.ownKeys(this).reduce((e, r) => ((e[r] = Reflect.get(this, r)), e), {}); + } + function rt(t) { + let e = (n) => typeof n == "object" && !Array.isArray(n) && n !== null, + r = (n, i = "") => { + Object.entries(Object.getOwnPropertyDescriptors(n)).forEach( + ([o, { value: s, enumerable: a }]) => { + if ( + a === !1 || + s === void 0 || + (typeof s == "object" && s !== null && s.__v_skip) + ) + return; + let c = i === "" ? o : `${i}.${o}`; + typeof s == "object" && s !== null && s._x_interceptor + ? (n[o] = s.initialize(t, c, o)) + : e(s) && s !== n && !(s instanceof Element) && r(s, c); + } + ); + }; + return r(t); + } + function Tt(t, e = () => {}) { + let r = { + initialValue: void 0, + _x_interceptor: !0, + initialize(n, i, o) { + return t( + this.initialValue, + () => jn(n, i), + (s) => me(n, i, s), + i, + o + ); + }, + }; + return ( + e(r), + (n) => { + if (typeof n == "object" && n !== null && n._x_interceptor) { + let i = r.initialize.bind(r); + r.initialize = (o, s, a) => { + let c = n.initialize(o, s, a); + return (r.initialValue = c), i(o, s, a); + }; + } else r.initialValue = n; + return r; + } + ); + } + function jn(t, e) { + return e.split(".").reduce((r, n) => r[n], t); + } + function me(t, e, r) { + if ((typeof e == "string" && (e = e.split(".")), e.length === 1)) t[e[0]] = r; + else { + if (e.length === 0) throw error; + return t[e[0]] || (t[e[0]] = {}), me(t[e[0]], e.slice(1), r); + } + } + var sr = {}; + function x(t, e) { + sr[t] = e; + } + function H(t, e) { + let r = Fn(e); + return ( + Object.entries(sr).forEach(([n, i]) => { + Object.defineProperty(t, `$${n}`, { + get() { + return i(e, r); + }, + enumerable: !1, + }); + }), + t + ); + } + function Fn(t) { + let [e, r] = he(t), + n = { interceptor: Tt, ...e }; + return et(t, r), n; + } + function ar(t, e, r, ...n) { + try { + return r(...n); + } catch (i) { + nt(i, t, e); + } + } + function nt(...t) { + return cr(...t); + } + var cr = Bn; + function lr(t) { + cr = t; + } + function Bn(t, e, r = void 0) { + (t = Object.assign(t ?? { message: "No error message given." }, { el: e, expression: r })), + console.warn( + `Alpine Expression Error: ${t.message} + +${ + r + ? 'Expression: "' + + r + + `" + +` + : "" +}`, + e + ), + setTimeout(() => { + throw t; + }, 0); + } + var it = !0; + function Mt(t) { + let e = it; + it = !1; + let r = t(); + return (it = e), r; + } + function T(t, e, r = {}) { + let n; + return _(t, e)((i) => (n = i), r), n; + } + function _(...t) { + return ur(...t); + } + var ur = () => {}; + function fr(t) { + ur = t; + } + var dr; + function pr(t) { + dr = t; + } + function mr(t, e) { + let r = {}; + H(r, t); + let n = [r, ...F(t)], + i = typeof e == "function" ? zn(n, e) : Vn(n, e, t); + return ar.bind(null, t, e, i); + } + function zn(t, e) { + return (r = () => {}, { scope: n = {}, params: i = [], context: o } = {}) => { + if (!it) { + ft(r, e, P([n, ...t]), i); + return; + } + let s = e.apply(P([n, ...t]), i); + ft(r, s); + }; + } + var _e = {}; + function Hn(t, e) { + if (_e[t]) return _e[t]; + let r = Object.getPrototypeOf(async function () {}).constructor, + n = + /^[\n\s]*if.*\(.*\)/.test(t.trim()) || /^(let|const)\s/.test(t.trim()) + ? `(async()=>{ ${t} })()` + : t, + o = (() => { + try { + let s = new r( + ["__self", "scope"], + `with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;` + ); + return Object.defineProperty(s, "name", { value: `[Alpine] ${t}` }), s; + } catch (s) { + return nt(s, e, t), Promise.resolve(); + } + })(); + return (_e[t] = o), o; + } + function Vn(t, e, r) { + let n = Hn(e, r); + return (i = () => {}, { scope: o = {}, params: s = [], context: a } = {}) => { + (n.result = void 0), (n.finished = !1); + let c = P([o, ...t]); + if (typeof n == "function") { + let l = n.call(a, n, c).catch((u) => nt(u, r, e)); + n.finished + ? (ft(i, n.result, c, s, r), (n.result = void 0)) + : l + .then((u) => { + ft(i, u, c, s, r); + }) + .catch((u) => nt(u, r, e)) + .finally(() => (n.result = void 0)); + } + }; + } + function ft(t, e, r, n, i) { + if (it && typeof e == "function") { + let o = e.apply(r, n); + o instanceof Promise ? o.then((s) => ft(t, s, r, n)).catch((s) => nt(s, i, e)) : t(o); + } else typeof e == "object" && e instanceof Promise ? e.then((o) => t(o)) : t(e); + } + function hr(...t) { + return dr(...t); + } + function _r(t, e, r = {}) { + let n = {}; + H(n, t); + let i = [n, ...F(t)], + o = P([r.scope ?? {}, ...i]), + s = r.params ?? []; + if (e.includes("await")) { + let a = Object.getPrototypeOf(async function () {}).constructor, + c = + /^[\n\s]*if.*\(.*\)/.test(e.trim()) || /^(let|const)\s/.test(e.trim()) + ? `(async()=>{ ${e} })()` + : e; + return new a(["scope"], `with (scope) { let __result = ${c}; return __result }`).call( + r.context, + o + ); + } else { + let a = + /^[\n\s]*if.*\(.*\)/.test(e.trim()) || /^(let|const)\s/.test(e.trim()) + ? `(()=>{ ${e} })()` + : e, + l = new Function( + ["scope"], + `with (scope) { let __result = ${a}; return __result }` + ).call(r.context, o); + return typeof l == "function" && it ? l.apply(o, s) : l; + } + } + var ye = "x-"; + function O(t = "") { + return ye + t; + } + function gr(t) { + ye = t; + } + var Rt = {}; + function p(t, e) { + return ( + (Rt[t] = e), + { + before(r) { + if (!Rt[r]) { + console.warn( + String.raw`Cannot find directive \`${r}\`. \`${t}\` will use the default order of execution` + ); + return; + } + let n = G.indexOf(r); + G.splice(n >= 0 ? n : G.indexOf("DEFAULT"), 0, t); + }, + } + ); + } + function xr(t) { + return Object.keys(Rt).includes(t); + } + function pt(t, e, r) { + if (((e = Array.from(e)), t._x_virtualDirectives)) { + let o = Object.entries(t._x_virtualDirectives).map(([a, c]) => ({ + name: a, + value: c, + })), + s = be(o); + (o = o.map((a) => + s.find((c) => c.name === a.name) + ? { name: `x-bind:${a.name}`, value: `"${a.value}"` } + : a + )), + (e = e.concat(o)); + } + let n = {}; + return e + .map(wr((o, s) => (n[o] = s))) + .filter(Sr) + .map(qn(n, r)) + .sort(Kn) + .map((o) => Un(t, o)); + } + function be(t) { + return Array.from(t) + .map(wr()) + .filter((e) => !Sr(e)); + } + var ge = !1, + dt = new Map(), + yr = Symbol(); + function br(t) { + ge = !0; + let e = Symbol(); + (yr = e), dt.set(e, []); + let r = () => { + for (; dt.get(e).length; ) dt.get(e).shift()(); + dt.delete(e); + }, + n = () => { + (ge = !1), r(); + }; + t(r), n(); + } + function he(t) { + let e = [], + r = (a) => e.push(a), + [n, i] = Ye(t); + return ( + e.push(i), + [ + { + Alpine: B, + effect: n, + cleanup: r, + evaluateLater: _.bind(_, t), + evaluate: T.bind(T, t), + }, + () => e.forEach((a) => a()), + ] + ); + } + function Un(t, e) { + let r = () => {}, + n = Rt[e.type] || r, + [i, o] = he(t); + Ot(t, e.original, o); + let s = () => { + t._x_ignore || + t._x_ignoreSelf || + (n.inline && n.inline(t, e, i), + (n = n.bind(n, t, e, i)), + ge ? dt.get(yr).push(n) : n()); + }; + return (s.runCleanups = o), s; + } + var Nt = + (t, e) => + ({ name: r, value: n }) => ( + r.startsWith(t) && (r = r.replace(t, e)), { name: r, value: n } + ), + Pt = (t) => t; + function wr(t = () => {}) { + return ({ name: e, value: r }) => { + let { name: n, value: i } = Er.reduce((o, s) => s(o), { name: e, value: r }); + return n !== e && t(n, e), { name: n, value: i }; + }; + } + var Er = []; + function ot(t) { + Er.push(t); + } + function Sr({ name: t }) { + return vr().test(t); + } + var vr = () => new RegExp(`^${ye}([^:^.]+)\\b`); + function qn(t, e) { + return ({ name: r, value: n }) => { + r === n && (n = ""); + let i = r.match(vr()), + o = r.match(/:([a-zA-Z0-9\-_:]+)/), + s = r.match(/\.[^.\]]+(?=[^\]]*$)/g) || [], + a = e || t[r] || r; + return { + type: i ? i[1] : null, + value: o ? o[1] : null, + modifiers: s.map((c) => c.replace(".", "")), + expression: n, + original: a, + }; + }; + } + var xe = "DEFAULT", + G = [ + "ignore", + "ref", + "id", + "data", + "anchor", + "bind", + "init", + "for", + "model", + "modelable", + "transition", + "show", + "if", + xe, + "teleport", + ]; + function Kn(t, e) { + let r = G.indexOf(t.type) === -1 ? xe : t.type, + n = G.indexOf(e.type) === -1 ? xe : e.type; + return G.indexOf(r) - G.indexOf(n); + } + function J(t, e, r = {}, n = {}) { + return t.dispatchEvent( + new CustomEvent(e, { detail: r, bubbles: !0, composed: !0, cancelable: !0, ...n }) + ); + } + function D(t, e) { + if (typeof ShadowRoot == "function" && t instanceof ShadowRoot) { + Array.from(t.children).forEach((i) => D(i, e)); + return; + } + let r = !1; + if ((e(t, () => (r = !0)), r)) return; + let n = t.firstElementChild; + for (; n; ) D(n, e, !1), (n = n.nextElementSibling); + } + function E(t, ...e) { + console.warn(`Alpine Warning: ${t}`, ...e); + } + var Ar = !1; + function Or() { + Ar && + E( + "Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems." + ), + (Ar = !0), + document.body || + E( + "Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` diff --git a/buzz/themes/buzz_default_theme/components/header.html b/buzz/themes/buzz_default_theme/components/header.html new file mode 100644 index 00000000..b8dadac9 --- /dev/null +++ b/buzz/themes/buzz_default_theme/components/header.html @@ -0,0 +1,13 @@ +{% import "components/macros/branding.html" as branding with context %} +{% set header_config = buzz_theme_config() %} +{# buzz_theme_config() is a plain _dict when no theme settings Single is linked, hence the guard. #} +{% set nav_links = header_config.get_nav_links() if header_config.get_nav_links else [] %} + diff --git a/buzz/themes/buzz_default_theme/components/macros/sections.html b/buzz/themes/buzz_default_theme/components/macros/sections.html new file mode 100644 index 00000000..f2d9a8df --- /dev/null +++ b/buzz/themes/buzz_default_theme/components/macros/sections.html @@ -0,0 +1,10 @@ +{% import "components/macros/icons.html" as icons %} +{# Section chrome shared by the home and category pages. #} + +{% macro section_header(title, heading_id=None) %} +

{{ title }}

+{% endmacro %} + +{% macro view_all_link(href, link_label="View all") %} +{{ link_label }} {{ icons.icon("arrow-right", "w-3.5 h-3.5") }} +{% endmacro %} diff --git a/buzz/themes/buzz_default_theme/components/scripts_tail.html b/buzz/themes/buzz_default_theme/components/scripts_tail.html new file mode 100644 index 00000000..18c4060c --- /dev/null +++ b/buzz/themes/buzz_default_theme/components/scripts_tail.html @@ -0,0 +1 @@ + diff --git a/buzz/themes/buzz_default_theme/components/theme_toggle.html b/buzz/themes/buzz_default_theme/components/theme_toggle.html new file mode 100644 index 00000000..d857c819 --- /dev/null +++ b/buzz/themes/buzz_default_theme/components/theme_toggle.html @@ -0,0 +1,4 @@ + diff --git a/buzz/themes/buzz_default_theme/pages/404.html b/buzz/themes/buzz_default_theme/pages/404.html new file mode 100644 index 00000000..61ee7645 --- /dev/null +++ b/buzz/themes/buzz_default_theme/pages/404.html @@ -0,0 +1,14 @@ +{% extends "components/base.html" %} + +{% block title %}Not found — {{ app_name }}{% endblock %} +{% block og_title %}Not found — {{ app_name }}{% endblock %} +{% block og_description %}This page does not exist.{% endblock %} + +{% block body %} +
+
404
+

This page does not exist

+

The link may be broken, or the event may have moved.

+ Back home +
+{% endblock %} diff --git a/buzz/themes/buzz_default_theme/pages/category/detail.html b/buzz/themes/buzz_default_theme/pages/category/detail.html new file mode 100644 index 00000000..0499fbb2 --- /dev/null +++ b/buzz/themes/buzz_default_theme/pages/category/detail.html @@ -0,0 +1,78 @@ +{% extends "components/base.html" %} +{% import "components/macros/sections.html" as sections %} + +{% set category_name = frappe.db.get_value("Event Category", {"slug": category_slug, "enabled": 1}, "name") %} +{% if not category_name %} + {% set not_found = frappe.throw(_("Category not found"), frappe.PageDoesNotExistError) %} +{% endif %} +{% set category = frappe.get_cached_doc("Event Category", category_name) %} + +{% set today = frappe.utils.today() %} +{% set all_events = frappe.get_all("Buzz Event", + filters={"is_published": 1, "category": category_name}, + fields=["name", "title", "route", "start_date", "venue", + "card_image", "banner_image", "short_description"], + order_by="start_date desc") %} + +{% set upcoming = [] %} +{% set past = [] %} +{% for event in all_events %} + {% if event.start_date and (event.start_date|string) < today %} + {% set _ = past.append(event) %} + {% else %} + {% set _ = upcoming.append(event) %} + {% endif %} +{% endfor %} + +{% set theme_config = buzz_theme_config() %} + +{% block title %}{{ category.name }} — {{ app_name }}{% endblock %} +{% block og_title %}{{ category.name }} — {{ app_name }}{% endblock %} +{% block og_description %}{{ category.description or category.name }}{% endblock %} +{% block og_url %}{{ frappe.utils.get_url() }}/category/{{ category.slug }}{% endblock %} + +{% block body %} + +{% if category.banner_image %} +
+ {{ category.name }} +
+{% endif %} + +
+

{{ category.name }}

+ {% if category.description %} +

{{ category.description }}

+ {% endif %} +
+ +{% if upcoming %} +
+ {{ sections.section_header(theme_config.upcoming_heading or 'Upcoming', 'upcoming-heading') }} +
+ {% for event_row in upcoming %} + {% with is_past = False %}{% include "components/event_row.html" %}{% endwith %} + {% endfor %} +
+
+{% endif %} + +{% if past %} +
+ {{ sections.section_header(theme_config.past_heading or 'In the Past', 'past-heading') }} +
+ {% for event_row in past %} + {% with is_past = True %}{% include "components/event_row.html" %}{% endwith %} + {% endfor %} +
+
+{% endif %} + +{% if not upcoming and not past %} +
+

No {{ category.name | lower }} on the calendar yet.

+ Back home +
+{% endif %} + +{% endblock %} diff --git a/buzz/themes/buzz_default_theme/pages/events/detail.html b/buzz/themes/buzz_default_theme/pages/events/detail.html new file mode 100644 index 00000000..1f641f74 --- /dev/null +++ b/buzz/themes/buzz_default_theme/pages/events/detail.html @@ -0,0 +1,252 @@ +{% extends "components/base.html" %} +{% import "components/macros/icons.html" as icons %} + +{% set event_name = frappe.db.get_value("Buzz Event", {"route": event_route, "is_published": 1}, "name") %} +{% if not event_name %} + {% set not_found = frappe.throw(_("Event not found"), frappe.PageDoesNotExistError) %} +{% endif %} +{% set event = frappe.get_cached_doc("Buzz Event", event_name) %} +{% set venue = frappe.get_cached_doc("Event Venue", event.venue) if event.venue else None %} +{% set host = frappe.get_cached_doc("Event Host", event.host) if event.host else None %} +{% set sponsors = frappe.get_all("Event Sponsor", filters={"event": event.name}, + fields=["company_name", "tier", "company_logo", "website"]) %} + +{% set today = frappe.utils.today() %} +{% set is_past = event.start_date and (event.start_date|string) < today %} +{% set is_online = event.medium == 'Online' %} + +{% set talk_names = event.schedule | selectattr("type", "equalto", "Talk") + | map(attribute="talk") | reject("none") | list | unique | list %} +{% set talk_title_map = {} %} +{% set first_speaker_map = {} %} +{% if talk_names %} + {% for talk in frappe.get_all("Event Talk", filters={"name": ["in", talk_names]}, fields=["name", "title"]) %} + {% set _ = talk_title_map.update({talk.name | string: talk.title}) %} + {% endfor %} + {% for talk_speaker in frappe.get_all("Talk Speaker", filters={"parenttype": "Event Talk", "parent": ["in", talk_names]}, + fields=["parent", "speaker"], order_by="parent asc, idx asc") %} + {% if (talk_speaker.parent | string) not in first_speaker_map %} + {% set _ = first_speaker_map.update({talk_speaker.parent | string: talk_speaker.speaker}) %} + {% endif %} + {% endfor %} +{% endif %} + +{% set speaker_names = ((first_speaker_map.values() | list) + + (event.featured_speakers | map(attribute="speaker") | reject("none") | list)) | unique | list %} +{% set speaker_map = {} %} +{% if speaker_names %} + {% for speaker in frappe.get_all("Speaker Profile", filters={"name": ["in", speaker_names]}, + fields=["name", "display_name", "designation", "company", "display_image"]) %} + {% set _ = speaker_map.update({speaker.name | string: speaker}) %} + {% endfor %} +{% endif %} + +{% set schedule_items = [] %} +{% for session in event.schedule %} + {% set session_title = '' %} + {% set session_speaker = '' %} + {% if session.type == 'Talk' and session.talk %} + {% set session_title = talk_title_map.get(session.talk | string) or '' %} + {% set first_speaker = first_speaker_map.get(session.talk | string) %} + {% set speaker_profile = speaker_map.get(first_speaker | string) if first_speaker else None %} + {% set session_speaker = (speaker_profile.display_name or '') if speaker_profile else '' %} + {% else %} + {% set session_title = session.description or ('Break' if session.type == 'Break' else '') %} + {% endif %} + {% set _ = schedule_items.append({ + "start_time": frappe.utils.get_time(session.start_time).strftime('%H:%M') if session.start_time else '', + "end_time": frappe.utils.get_time(session.end_time).strftime('%H:%M') if session.end_time else '', + "title": session_title, + "speaker_name": session_speaker, + "is_break": session.type == 'Break' + }) %} +{% endfor %} + +{% set featured_speakers = [] %} +{% for featured_speaker in event.featured_speakers %} + {% set speaker_profile = speaker_map.get(featured_speaker.speaker | string) if featured_speaker.speaker else None %} + {% if speaker_profile %} + {% set role_parts = [] %} + {% if speaker_profile.designation %}{% set _ = role_parts.append(speaker_profile.designation) %}{% endif %} + {% if speaker_profile.company %}{% set _ = role_parts.append(speaker_profile.company) %}{% endif %} + {% set _ = featured_speakers.append({ + "name": speaker_profile.display_name or '', + "role": role_parts | join(', '), + "image_url": speaker_profile.display_image + }) %} + {% endif %} +{% endfor %} + +{% set start_date = event.start_date %} +{% set end_date = event.end_date %} +{% if start_date and end_date and end_date != start_date %} + {% if start_date.month == end_date.month and start_date.year == end_date.year %} + {% set date_display = start_date.strftime('%d') ~ ' - ' ~ end_date.strftime('%d %B, %Y') %} + {% else %} + {% set date_display = start_date.strftime('%d %B') ~ ' - ' ~ end_date.strftime('%d %B, %Y') %} + {% endif %} +{% elif start_date %} + {% set date_display = start_date.strftime('%d %B, %Y') %} +{% else %} + {% set date_display = '' %} +{% endif %} + +{% set start_time_display = frappe.utils.get_time(event.start_time).strftime('%I:%M %p') if event.start_time else '' %} +{% set end_time_display = frappe.utils.get_time(event.end_time).strftime('%I:%M %p') if event.end_time else '' %} +{% set time_zone_label = event.time_zone_label or (event.time_zone.split('/') | last if event.time_zone else '') %} +{% set hero_image = event.banner_image or event.card_image %} +{% set register_href = event.registration_url if (event.external_registration_page and event.registration_url) else ('/b/register/' ~ (event.route or '')) %} +{% set theme_config = buzz_theme_config() %} + +{% block title %}{{ event.title }} — {{ app_name }}{% endblock %} +{% block og_type %}event{% endblock %} +{% block og_title %}{{ event.title }}{% endblock %} +{% block og_description %}{{ event.short_description or event.title }}{% endblock %} +{% block og_image %}{{ event.meta_image or event.banner_image or '' }}{% endblock %} +{% block og_url %}{{ frappe.utils.get_url() }}/events/{{ event.route }}{% endblock %} + +{% block body %} + +{% if hero_image %} +
+ {{ event.title }} +
+{% endif %} + +
+

{{ event.title }}

+ +
+ {% if date_display %} +
+ {{ icons.icon("calendar", "w-4 h-4 shrink-0 text-[var(--text-muted)]") }} + + {{ date_display }} + {%- if start_time_display %} · {{ start_time_display }}{% if end_time_display %} - {{ end_time_display }}{% endif %}{% endif %} + {%- if time_zone_label %} {{ time_zone_label }}{% endif %} + +
+ {% endif %} + +
+ {{ icons.icon('video' if is_online else 'map-pin', "w-4 h-4 shrink-0 text-[var(--text-muted)]") }} + {% if is_online %}Online{% elif venue %}{{ venue.address or venue.name }}{% else %}Venue to be announced{% endif %} +
+
+ + {% if not is_past %} + + {% endif %} +
+ +
+ +{% if event.about %} +
+

{{ theme_config.about_heading or 'About the event' }}

+
{{ event.about | safe }}
+
+{% endif %} + +{% if schedule_items %} +
+

{{ theme_config.schedule_heading or 'Schedule' }}

+ {% if time_zone_label %}

All times {{ time_zone_label }}

{% endif %} +
+ {% for item in schedule_items %} +
+ + {{ item.start_time }}{% if item.end_time %} – {{ item.end_time }}{% endif %} + + + {{ item.title }} + {% if item.speaker_name %} · {{ item.speaker_name }}{% endif %} + +
+ {% endfor %} +
+
+{% endif %} + +{% if featured_speakers %} +
+

{{ theme_config.speakers_heading or 'Speakers' }}

+
+ {% for speaker in featured_speakers %} +
+ {% if speaker.image_url %} + {{ speaker.name }} + {% else %} +
+ {{ speaker.name[:1] | upper }} +
+ {% endif %} +
+
{{ speaker.name }}
+ {% if speaker.role %}
{{ speaker.role }}
{% endif %} +
+
+ {% endfor %} +
+
+{% endif %} + +{% if venue and not is_online %} +
+

{{ theme_config.venue_heading or 'Venue details' }}

+

{{ venue.address or venue.name }}

+ {% if venue.latitude and venue.longitude and (venue.latitude | float != 0 or venue.longitude | float != 0) %} +
+ +
+ {% endif %} +
+{% endif %} + +{% if host %} +
+

{{ theme_config.host_heading or 'Meet the host' }}

+
+
+ {% if host.logo %} + {{ host.name }} + {% else %} +
+ {{ host.name[:2] | upper }} +
+ {% endif %} +
+
{{ host.name }}
+ {% if host.by_line %}
{{ host.by_line }}
{% endif %} + {% if host.country %}
{{ host.country }}
{% endif %} +
+
+ {% if host.about %} +
{{ host.about | safe }}
+ {% endif %} +
+
+{% endif %} + +{% if sponsors %} +
+

{{ theme_config.sponsors_heading or 'Sponsors' }}

+
+ {% for sponsor in sponsors %} + {% if sponsor.company_logo %} + + {{ sponsor.company_name }} + + {% else %} + {{ sponsor.company_name }} + {% endif %} + {% endfor %} +
+
+{% endif %} + +{% endblock %} diff --git a/buzz/themes/buzz_default_theme/pages/home.html b/buzz/themes/buzz_default_theme/pages/home.html new file mode 100644 index 00000000..035fb23d --- /dev/null +++ b/buzz/themes/buzz_default_theme/pages/home.html @@ -0,0 +1,110 @@ +{% extends "components/base.html" %} +{% import "components/macros/sections.html" as sections %} + +{% block title %}{{ app_name }}{% endblock %} +{% block og_title %}{{ app_name }}{% endblock %} +{% block og_description %}Conferences, meetups, and webinars.{% endblock %} +{% block og_url %}{{ frappe.utils.get_url() }}/home{% endblock %} + +{% block body %} + +{% set today = frappe.utils.today() %} +{% set all_events = frappe.get_all("Buzz Event", + filters={"is_published": 1}, + fields=["name", "title", "route", "category", "start_date", "venue", + "card_image", "banner_image", "short_description", "medium"], + order_by="start_date asc") %} + +{% set upcoming_conferences = [] %} +{% set past_conferences = [] %} +{% set upcoming_yatra = [] %} +{% set past_webinars = [] %} +{% for event in all_events %} + {% set is_past = event.start_date and (event.start_date|string) < today %} + {% if event.category == 'Conferences' %} + {% if is_past %}{% set _ = past_conferences.append(event) %}{% else %}{% set _ = upcoming_conferences.append(event) %}{% endif %} + {% elif event.category == 'Yatra' and not is_past %} + {% set _ = upcoming_yatra.append(event) %} + {% elif event.category == 'Webinars' and is_past %} + {% set _ = past_webinars.append(event) %} + {% endif %} +{% endfor %} + +{# Past lists come back oldest-first from the shared ascending query; most recent reads better. #} +{% set past_conferences = past_conferences | reverse | list %} +{% set past_webinars = past_webinars | reverse | list %} + +{% set category_slugs = {} %} +{% for category in frappe.get_all("Event Category", filters={"enabled": 1}, fields=["name", "slug"]) %} + {% set _ = category_slugs.update({category.name: category.slug}) %} +{% endfor %} + +{% set preview_count = 3 %} +{% set theme_config = buzz_theme_config() %} + +{% if banner_image %} +
+ {{ app_name }} +
+{% endif %} + +
+

{{ theme_config.welcome_heading or ('Welcome to ' ~ app_name) }}

+ {% if theme_config.welcome_body %} +
+ {% for paragraph in theme_config.welcome_body.split('\n\n') %} + {% if paragraph | trim %}

{{ paragraph | trim }}

{% endif %} + {% endfor %} +
+ {% endif %} +
+ +{% if upcoming_yatra %} +
+ {{ sections.section_header(theme_config.yatra_heading or 'Upcoming Yatra Meetups', 'yatra-heading') }} +
+ {% for event_row in upcoming_yatra[:preview_count] %} + {% with is_past = False %}{% include "components/event_card.html" %}{% endwith %} + {% endfor %} +
+ {{ sections.view_all_link('/category/' ~ (category_slugs.get('Yatra') or 'yatra')) }} +
+{% endif %} + +{% if upcoming_conferences %} +
+ {{ sections.section_header(theme_config.upcoming_conferences_heading or 'Upcoming Conferences', 'upcoming-conf-heading') }} +
+ {% for event_row in upcoming_conferences[:preview_count] %} + {% with is_past = False %}{% include "components/event_card.html" %}{% endwith %} + {% endfor %} +
+ {{ sections.view_all_link('/category/' ~ (category_slugs.get('Conferences') or 'conferences')) }} +
+{% endif %} + +{% if past_conferences %} +
+ {{ sections.section_header(theme_config.past_conferences_heading or 'Past Conferences', 'past-conf-heading') }} +
+ {% for event_row in past_conferences[:preview_count] %} + {% with is_past = True %}{% include "components/event_card.html" %}{% endwith %} + {% endfor %} +
+ {{ sections.view_all_link('/category/' ~ (category_slugs.get('Conferences') or 'conferences')) }} +
+{% endif %} + +{% if past_webinars %} +
+ {{ sections.section_header(theme_config.past_webinars_heading or 'Past Webinars', 'past-web-heading') }} +
+ {% for event_row in past_webinars[:preview_count] %} + {% with is_past = True %}{% include "components/event_row.html" %}{% endwith %} + {% endfor %} +
+ {{ sections.view_all_link('/category/' ~ (category_slugs.get('Webinars') or 'webinars')) }} +
+{% endif %} + +{% endblock %} diff --git a/buzz/themes/buzz_default_theme/styles/tokens.css b/buzz/themes/buzz_default_theme/styles/tokens.css new file mode 100644 index 00000000..7c4be781 --- /dev/null +++ b/buzz/themes/buzz_default_theme/styles/tokens.css @@ -0,0 +1,245 @@ +/* Buzz Default — design tokens. + Colours are frappe-ui's own semantic tokens (surface / ink / outline), resolved + from frappe-ui/tailwind/generated/colors.json, so themed pages and the Vue + dashboard (ticket booking, check-in) render the same greys. */ + +:root { + --surface-base: #ffffff; + --surface-gray-1: #f8f8f8; + --surface-gray-2: #f3f3f3; + --surface-gray-3: #ededed; + --surface-gray-4: #e2e2e2; + + --ink-gray-9: #0f0f0f; + --ink-gray-8: #171717; + --ink-gray-7: #383838; + --ink-gray-6: #525252; + --ink-gray-5: #7c7c7c; + --ink-gray-4: #999999; + + --outline-gray-1: #ededed; + --outline-gray-2: #e2e2e2; + --outline-gray-3: #c7c7c7; + + --blue-500: #0d8ef8; + + /* roles — every component reads these, never a raw token */ + --page-bg: var(--surface-base); + --card-bg: var(--surface-gray-1); + --card-bg-hover: var(--surface-gray-2); + --placeholder-bg: var(--surface-gray-3); + --hairline: var(--outline-gray-1); + --text-primary: var(--ink-gray-9); + --text-secondary: var(--ink-gray-6); + /* ink-gray-4 (#999) is only 2.9:1 on white — muted text still has to clear AA. */ + --text-muted: var(--ink-gray-5); + --focus-ring: var(--blue-500); +} + +html[data-theme="dark"] { + --surface-base: #171717; + --surface-gray-1: #1f1f1f; + --surface-gray-2: #292929; + --surface-gray-3: #383838; + --surface-gray-4: #424242; + + --ink-gray-9: #f8f8f8; + --ink-gray-8: #d9d9d9; + --ink-gray-7: #afafaf; + --ink-gray-6: #999999; + --ink-gray-5: #7a7a7a; + --ink-gray-4: #7a7a7a; + + --outline-gray-1: #242424; + --outline-gray-2: #383838; + --outline-gray-3: #424242; + + /* outline-gray-1 is 1.06:1 against the dark card — every border would vanish. */ + --hairline: var(--outline-gray-2); + --text-muted: var(--ink-gray-6); +} + +/* Everything below is component CSS. It MUST live in @layer components: this file is + imported unlayered, and unlayered rules outrank every Tailwind utility regardless of + specificity, so an unlayered component rule cannot be overridden from the markup. */ +@layer components { + /* ---------- typography ---------- */ + + html, body { + background: var(--page-bg); + color: var(--text-primary); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-feature-settings: 'cv10' 1; + -webkit-font-smoothing: antialiased; + } + + /* The condensed numerals are the one piece of display type this theme keeps. */ + .numeral { font-family: 'League Gothic', 'Inter', sans-serif; } + + .label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + } + + /* ---------- page chrome ---------- */ + + .page-shell { max-width: 800px; margin: 0 auto; padding: 0 20px 80px; } + + .site-header { + display: flex; align-items: center; justify-content: space-between; + flex-wrap: wrap; + gap: 8px 16px; + padding: 16px 0 12px; + } + + .site-header .brand { + font-size: 19px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--text-primary); + } + + .site-header .brand-logo { height: 26px; width: auto; display: block; } + + .nav-links { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px 20px; } + + .nav-links a { + display: inline-flex; + align-items: center; + min-height: 44px; + font-size: 14px; + color: var(--text-primary); + } + .nav-links a:hover { color: var(--text-secondary); } + + .site-footer { + border-top: 1px solid var(--hairline); + background: var(--card-bg); + margin-top: 72px; + } + + .site-footer .footer-inner { + max-width: 800px; margin: 0 auto; + padding: 22px 20px; + display: flex; flex-wrap: wrap; gap: 12px; + align-items: center; justify-content: space-between; + font-size: 13px; + color: var(--text-secondary); + } + + .site-footer .footer-links { display: inline-flex; flex-wrap: wrap; gap: 16px; } + .site-footer a:hover { color: var(--text-primary); } + .site-footer .footer-logo { height: 20px; width: auto; display: block; } + + /* ---------- primitives ---------- */ + + .card { + background: var(--card-bg); + border: 1px solid var(--hairline); + border-radius: 12px; + } + + /* No `display` here. tokens.css is imported UNLAYERED, and unlayered CSS beats every + @layer — including Tailwind's utilities — whatever the specificity. A `display` set + here silently wins over `flex`/`block` in the markup. */ + a.card { transition: background-color .15s ease, border-color .15s ease; } + a.card:hover { background: var(--card-bg-hover); border-color: var(--outline-gray-2); } + + .section-heading { + font-size: 17px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--text-primary); + } + + .view-all { + display: inline-flex; align-items: center; gap: 6px; + font-size: 14px; font-weight: 500; + color: var(--text-primary); + } + .view-all:hover { gap: 9px; } + .view-all { transition: gap .15s ease; } + + /* Date badge — MONTH over a condensed day numeral, the Builder page's signature. */ + .date-badge { + display: flex; flex-direction: column; align-items: center; + line-height: 1; + min-width: 34px; + } + .date-badge .badge-month { + font-size: 10px; font-weight: 700; + letter-spacing: 0.06em; text-transform: uppercase; + color: var(--text-muted); + } + .date-badge .badge-day { + font-family: 'League Gothic', 'Inter', sans-serif; + font-size: 30px; + line-height: 0.95; + margin-top: 3px; + color: var(--text-primary); + } + + .meta-row { + display: flex; align-items: center; gap: 8px; + font-size: 14px; + color: var(--text-secondary); + } + + .prose-block { font-size: 15px; line-height: 1.65; color: var(--text-secondary); } + .prose-block p { margin-bottom: 12px; } + .prose-block p:last-child { margin-bottom: 0; } + .prose-block a { color: var(--text-primary); text-decoration: underline; } + + .cta-btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + background: var(--text-primary); + color: var(--page-bg); + border-radius: 8px; + padding: 11px 20px; + font-size: 14px; + font-weight: 600; + transition: opacity .15s ease; + } + .cta-btn:hover { opacity: 0.85; } + + .cta-btn--ghost { + background: transparent; + color: var(--text-primary); + border: 1px solid var(--outline-gray-2); + } + .cta-btn--ghost:hover { background: var(--card-bg-hover); opacity: 1; } + + /* ---------- theme toggle ---------- */ + + .theme-toggle { + width: 44px; height: 44px; + display: inline-flex; align-items: center; justify-content: center; + border: 1px solid var(--outline-gray-2); + border-radius: 8px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: background-color .15s ease; + } + .theme-toggle:hover { background: var(--card-bg-hover); color: var(--text-primary); } + .theme-toggle .icon-sun { display: none; } + .theme-toggle .icon-moon { display: block; } + html[data-theme="dark"] .theme-toggle .icon-sun { display: block; } + html[data-theme="dark"] .theme-toggle .icon-moon { display: none; } + + /* ---------- state ---------- */ + + .is-past { opacity: 0.7; } + .is-past:hover { opacity: 1; } + + :focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; } + + @media print { + .no-print, .theme-toggle { display: none !important; } + body { background: #ffffff !important; color: #000000; } + .card { border-color: #e2e2e2; } + } +} diff --git a/buzz/themes/buzz_default_theme/tailwind.input.css b/buzz/themes/buzz_default_theme/tailwind.input.css new file mode 100644 index 00000000..d990c7c1 --- /dev/null +++ b/buzz/themes/buzz_default_theme/tailwind.input.css @@ -0,0 +1,6 @@ +@source "./pages/**/*.html"; +@source "./components/**/*.html"; + +@import "tailwindcss"; + +@import "./styles/tokens.css"; diff --git a/buzz/themes/buzz_events_theme/components/base.html b/buzz/themes/buzz_events_theme/components/base.html new file mode 100644 index 00000000..5143c7eb --- /dev/null +++ b/buzz/themes/buzz_events_theme/components/base.html @@ -0,0 +1,28 @@ + + + + + {% include "components/head_top.html" %} + + + + + + {% set og_image %}{% block og_image %}{{ banner_image or '' }}{% endblock %}{% endset %} + {% if og_image | trim %}{% endif %} + + + + {% block title %}{{ title_prefix or app_name }}{% endblock %} + + {% include "components/head_bottom.html" %} + + + + {% block body %}{% endblock %} + + {% if frappe.session.user == "Guest" %}{% include "components/login_dialog.html" %}{% endif %} + + {% include "components/scripts_tail.html" %} + + diff --git a/buzz/themes/buzz_events_theme/components/category_pill.html b/buzz/themes/buzz_events_theme/components/category_pill.html new file mode 100644 index 00000000..e2d5891b --- /dev/null +++ b/buzz/themes/buzz_events_theme/components/category_pill.html @@ -0,0 +1,9 @@ +{% macro category_pill(label, is_past=False) %} + {% if is_past %} + {{ label }} + {% else %} + {{ label }} + {% endif %} +{% endmacro %} + +{% if label is defined %}{{ category_pill(label, is_past=is_past|default(False)) }}{% endif %} diff --git a/buzz/themes/buzz_events_theme/components/event_card.html b/buzz/themes/buzz_events_theme/components/event_card.html new file mode 100644 index 00000000..6513c737 --- /dev/null +++ b/buzz/themes/buzz_events_theme/components/event_card.html @@ -0,0 +1,31 @@ +{% import "components/macros/icons.html" as icons %} +{% from "components/category_pill.html" import category_pill %} +{% set image = event.get('card_image') or event.get('banner_image') %} +{% set past_classes = ' opacity-85 hover:opacity-100' if is_past else '' %} + +
+ {% if image %} + + {% endif %} + {{ event.start_date }} + {% if is_past %} + PAST + {% endif %} +
+
+

{{ event.title }}

+ {% if event.venue_address %} +
+ {{ icons.icon("map-pin") }} + {{ event.venue_address }} +
+ {% endif %} +
+ {{ category_pill(event.category_label, is_past=is_past) }} + + {% if is_past %}Recap →{% else %}Ticket →{% endif %} + +
+
+
diff --git a/buzz/themes/buzz_events_theme/components/event_card_wide.html b/buzz/themes/buzz_events_theme/components/event_card_wide.html new file mode 100644 index 00000000..64cc4296 --- /dev/null +++ b/buzz/themes/buzz_events_theme/components/event_card_wide.html @@ -0,0 +1,33 @@ +{% import "components/macros/icons.html" as icons %} +{% from "components/category_pill.html" import category_pill %} +{% set image = event.get('card_image') or event.get('banner_image') %} +{% set past_classes = ' opacity-85 hover:opacity-100' if is_past else '' %} + +
+
+ {% if image %} + + {% endif %} + {{ event.start_date }} + {% if is_past %} + PAST + {% endif %} +
+
+

{{ event.title }}

+ {% if event.venue_address %} +
+ {{ icons.icon("map-pin") }} + {{ event.venue_address }} +
+ {% endif %} +
+ {{ category_pill(event.category_label, is_past=is_past) }} + + {% if is_past %}Recap →{% else %}Ticket →{% endif %} + +
+
+
+
diff --git a/buzz/themes/buzz_events_theme/components/footer.html b/buzz/themes/buzz_events_theme/components/footer.html new file mode 100644 index 00000000..cf12ae6d --- /dev/null +++ b/buzz/themes/buzz_events_theme/components/footer.html @@ -0,0 +1,17 @@ +{% import "components/macros/branding.html" as branding with context %} +
+
+ {{ branding.brand(footer_logo or app_logo, "h-7 w-auto", "★ ") }} + {% if footer_items %} + + {% endif %} +
+
+ Thank you for joining us · Powered by Buzz{% if copyright %} · © {{ copyright }}{% endif %} +
+ {% if footer_address %} +
{{ footer_address }}
+ {% endif %} +
diff --git a/buzz/themes/buzz_events_theme/components/head_bottom.html b/buzz/themes/buzz_events_theme/components/head_bottom.html new file mode 100644 index 00000000..28a08175 --- /dev/null +++ b/buzz/themes/buzz_events_theme/components/head_bottom.html @@ -0,0 +1,5 @@ + + + + + diff --git a/buzz/themes/buzz_events_theme/components/head_top.html b/buzz/themes/buzz_events_theme/components/head_top.html new file mode 100644 index 00000000..80d3fd70 --- /dev/null +++ b/buzz/themes/buzz_events_theme/components/head_top.html @@ -0,0 +1,5 @@ + + + + + diff --git a/buzz/themes/buzz_events_theme/components/header.html b/buzz/themes/buzz_events_theme/components/header.html new file mode 100644 index 00000000..2ee51da0 --- /dev/null +++ b/buzz/themes/buzz_events_theme/components/header.html @@ -0,0 +1,30 @@ +{% import "components/macros/icons.html" as icons %} +{% import "components/macros/branding.html" as branding with context %} +
+ {{ branding.brand(app_logo, "h-7 w-auto", "★ ") }} + +
+ {% include "components/theme_toggle.html" %} + {% if frappe.session.user == "Guest" %} + + {% else %} + + {% endif %} +
+
diff --git a/buzz/themes/buzz_events_theme/components/login_dialog.html b/buzz/themes/buzz_events_theme/components/login_dialog.html new file mode 100644 index 00000000..f8713dec --- /dev/null +++ b/buzz/themes/buzz_events_theme/components/login_dialog.html @@ -0,0 +1,130 @@ +{% import "components/macros/icons.html" as icons %} + diff --git a/buzz/themes/buzz_events_theme/components/schedule_card.html b/buzz/themes/buzz_events_theme/components/schedule_card.html new file mode 100644 index 00000000..1dfa555e --- /dev/null +++ b/buzz/themes/buzz_events_theme/components/schedule_card.html @@ -0,0 +1,20 @@ +{% set is_break = item.get('is_break') %} +{% if is_break %} + {% set wrap_classes = 'bg-pill text-pill-fg border border-line rounded-[10px] grid grid-cols-[auto_1fr] overflow-hidden' %} + {% set stub_classes = 'px-4 py-3.5 border-r border-dashed border-transparent min-w-[92px] text-center' %} +{% else %} + {% set wrap_classes = 'bg-pass text-ink border border-line rounded-[10px] grid grid-cols-[auto_1fr] overflow-hidden' %} + {% set stub_classes = 'px-4 py-3.5 bg-bg border-r border-dashed border-line min-w-[92px] text-center' %} +{% endif %} +
+
+
{{ item.start_time }}
+
→ {{ item.end_time }}
+
+
+
{{ item.title }}
+ {% if item.speaker_name and not is_break %} +
{{ item.speaker_name }}
+ {% endif %} +
+
diff --git a/buzz/themes/buzz_events_theme/components/scripts_tail.html b/buzz/themes/buzz_events_theme/components/scripts_tail.html new file mode 100644 index 00000000..864ee630 --- /dev/null +++ b/buzz/themes/buzz_events_theme/components/scripts_tail.html @@ -0,0 +1,2 @@ + + diff --git a/buzz/themes/buzz_events_theme/components/section_header.html b/buzz/themes/buzz_events_theme/components/section_header.html new file mode 100644 index 00000000..bd192be5 --- /dev/null +++ b/buzz/themes/buzz_events_theme/components/section_header.html @@ -0,0 +1,10 @@ +{% import "components/macros/icons.html" as icons %} +{% set link_label = link_label or 'View all' %} +
+

{{ title }}

+ {% if link_href %} + + {{ link_label }} {{ icons.icon("arrow-right") }} + + {% endif %} +
diff --git a/buzz/themes/buzz_events_theme/components/speaker_row.html b/buzz/themes/buzz_events_theme/components/speaker_row.html new file mode 100644 index 00000000..841ff6a8 --- /dev/null +++ b/buzz/themes/buzz_events_theme/components/speaker_row.html @@ -0,0 +1,9 @@ +
+ {{ speaker.name }} +
+
{{ speaker.name }}
+ {% if speaker.role %} +
{{ speaker.role }}
+ {% endif %} +
+
diff --git a/buzz/themes/buzz_events_theme/components/sponsor_card.html b/buzz/themes/buzz_events_theme/components/sponsor_card.html new file mode 100644 index 00000000..8de0d22a --- /dev/null +++ b/buzz/themes/buzz_events_theme/components/sponsor_card.html @@ -0,0 +1,14 @@ +
+
+ {{ name }} +
+
+ {% if tier_label %} +
{{ tier_label }}
+ {% endif %} +
{{ name }}
+ {% if tagline %} +
{{ tagline }}
+ {% endif %} +
+
diff --git a/buzz/themes/buzz_events_theme/components/sticky_cta.html b/buzz/themes/buzz_events_theme/components/sticky_cta.html new file mode 100644 index 00000000..a56611a9 --- /dev/null +++ b/buzz/themes/buzz_events_theme/components/sticky_cta.html @@ -0,0 +1,13 @@ +{% import "components/macros/icons.html" as icons %} +{% set secondary_label = secondary_label or 'Propose' %} + diff --git a/buzz/themes/buzz_events_theme/components/theme_toggle.html b/buzz/themes/buzz_events_theme/components/theme_toggle.html new file mode 100644 index 00000000..d65a6322 --- /dev/null +++ b/buzz/themes/buzz_events_theme/components/theme_toggle.html @@ -0,0 +1,4 @@ + diff --git a/buzz/themes/buzz_events_theme/components/webinar_row.html b/buzz/themes/buzz_events_theme/components/webinar_row.html new file mode 100644 index 00000000..54a7d63d --- /dev/null +++ b/buzz/themes/buzz_events_theme/components/webinar_row.html @@ -0,0 +1,21 @@ +{% import "components/macros/icons.html" as icons %} +{% set wide_classes = ' md:col-span-2' if event.get('is_wide') else '' %} + +
+
+
{{ event.month_short }}
+
{{ event.day }}
+
+
+

{{ event.title }}

+ {% if event.short_description %} +

+ {{ event.short_description }} +

+ {% endif %} +
+ Recording {{ icons.icon("arrow-right", "w-3 h-3") }} +
+
+
+
diff --git a/buzz/themes/buzz_events_theme/pages/404.html b/buzz/themes/buzz_events_theme/pages/404.html new file mode 100644 index 00000000..d289b0c4 --- /dev/null +++ b/buzz/themes/buzz_events_theme/pages/404.html @@ -0,0 +1,28 @@ +{% extends "components/base.html" %} +{% import "components/macros/icons.html" as icons %} + +{% block title %}Not found — {{ app_name }}{% endblock %} +{% block og_title %}Not found — {{ app_name }}{% endblock %} +{% block og_description %}This route doesn't exist on the manifest.{% endblock %} + +{% block body %} +
+ {% include "components/header.html" %} + +
+
+
Void ticket
+

404

+

+ This route doesn't exist on the manifest. The ticket scanner couldn't find what you're looking for. +

+ + Back to events {{ icons.icon("arrow-right", "w-4 h-4") }} + +
+
+ + {% include "components/footer.html" %} +
+{% endblock %} diff --git a/buzz/themes/buzz_events_theme/pages/category/detail.html b/buzz/themes/buzz_events_theme/pages/category/detail.html new file mode 100644 index 00000000..7c433a09 --- /dev/null +++ b/buzz/themes/buzz_events_theme/pages/category/detail.html @@ -0,0 +1,112 @@ +{% extends "components/base.html" %} + +{% set today = frappe.utils.today() %} +{% set category_name = frappe.db.get_value("Event Category", {"slug": category_slug, "enabled": 1}, "name") %} +{% if not category_name %} + {% set not_found = frappe.throw(_("Category not found"), frappe.PageDoesNotExistError) %} +{% endif %} +{% set category = frappe.get_cached_doc("Event Category", category_name) %} + +{% set all_events = frappe.get_all("Buzz Event", + filters={"is_published": 1, "category": category_name}, + fields=["name", "title", "route", "start_date", "venue", + "card_image", "banner_image", "short_description"], + order_by="start_date desc") %} + +{% set venue_names = all_events | map(attribute='venue') | reject('none') | list | unique | list %} +{% set venues = frappe.get_all("Event Venue", + filters={"name": ["in", venue_names]}, + fields=["name", "address"]) if venue_names else [] %} +{% set venue_map = {} %} +{% for venue in venues %} + {% set _ = venue_map.update({venue.name: venue.address}) %} +{% endfor %} + +{% set upcoming_count = 0 %} +{% set past_count = 0 %} +{% for event in all_events %} + {% if event.start_date and (event.start_date|string) < today %} + {% set past_count = past_count + 1 %} + {% else %} + {% set upcoming_count = upcoming_count + 1 %} + {% endif %} +{% endfor %} + +{% block title %}{{ category.name }} — {{ app_name }}{% endblock %} +{% block og_title %}{{ category.name }} — {{ app_name }}{% endblock %} +{% block og_description %}{{ category.description or category.name }}{% endblock %} +{% block og_url %}{{ frappe.utils.get_url() }}/category/{{ category.slug }}{% endblock %} + +{% block body %} +
+ {% include "components/header.html" %} + +
+
Browse
+

{{ category.name }}

+ {% if category.description %} +

{{ category.description }}

+ {% endif %} +
+ +
+
+ + + +
+ + {% if all_events %} +
+ {% for event_row in all_events %} + {% set is_past = event_row.start_date and (event_row.start_date|string) < today %} + {% set status = 'past' if is_past else 'upcoming' %} + {% set event = { + "route": event_row.route, + "title": event_row.title, + "card_image": event_row.card_image or event_row.banner_image, + "start_date": event_row.start_date.strftime('%d %b').upper() if event_row.start_date else '', + "venue_address": venue_map.get(event_row.venue, ''), + "category_label": category.name.upper() + } %} +
+ {% include "components/event_card.html" %} +
+ {% endfor %} +
+ + {% if upcoming_count == 0 %} +
+

No upcoming {{ category.name|lower }} events

+

Check back soon, or browse past events.

+
+ {% endif %} + {% if past_count == 0 %} +
+

No past {{ category.name|lower }} events

+

Nothing in the archive yet.

+
+ {% endif %} + {% else %} +
+

No {{ category.name|lower }} events yet

+

Check back soon.

+
+ {% endif %} +
+ + {% include "components/footer.html" %} +
+{% endblock %} diff --git a/buzz/themes/buzz_events_theme/pages/events/detail.html b/buzz/themes/buzz_events_theme/pages/events/detail.html new file mode 100644 index 00000000..0d8afdbe --- /dev/null +++ b/buzz/themes/buzz_events_theme/pages/events/detail.html @@ -0,0 +1,443 @@ +{% extends "components/base.html" %} +{% import "components/macros/icons.html" as icons %} +{% import "components/macros/maps.html" as maps %} + +{% set event_name = frappe.db.get_value("Buzz Event", {"route": event_route, "is_published": 1}, "name") %} +{% if not event_name %} + {% set not_found = frappe.throw(_("Event not found"), frappe.PageDoesNotExistError) %} +{% endif %} +{% set event = frappe.get_cached_doc("Buzz Event", event_name) %} +{% set venue = frappe.get_cached_doc("Event Venue", event.venue) if event.venue else None %} +{% set host = frappe.get_cached_doc("Event Host", event.host) if event.host else None %} +{% set category = frappe.get_cached_doc("Event Category", event.category) if event.category else None %} +{% set sponsors = frappe.get_all("Event Sponsor", filters={"event": event.name}, + fields=["company_name", "tier", "company_logo", "website", "country"]) %} + +{% set paid_ticket_count = frappe.db.count("Event Ticket Type", + {"event": event.name, "is_published": 1, "price": [">", 0]}) %} +{% set is_free = event.free_event or paid_ticket_count == 0 %} +{% set tier_label = "FREE" if is_free else "PAID" %} + +{% set talk_names = event.schedule | selectattr("type", "equalto", "Talk") + | map(attribute="talk") | reject("none") | list | unique | list %} +{% set talk_title_map = {} %} +{% set first_speaker_map = {} %} +{% if talk_names %} + {% for talk in frappe.get_all("Event Talk", filters={"name": ["in", talk_names]}, fields=["name", "title"]) %} + {% set _ = talk_title_map.update({talk.name | string: talk.title}) %} + {% endfor %} + {% for row in frappe.get_all("Talk Speaker", filters={"parenttype": "Event Talk", "parent": ["in", talk_names]}, + fields=["parent", "speaker"], order_by="parent asc, idx asc") %} + {% if (row.parent | string) not in first_speaker_map %}{% set _ = first_speaker_map.update({row.parent | string: row.speaker}) %}{% endif %} + {% endfor %} +{% endif %} + +{% set tier_names = sponsors | map(attribute="tier") | reject("none") | list | unique | list %} +{% set tier_title_map = {} %} +{% if tier_names %} + {% for tier in frappe.get_all("Sponsorship Tier", filters={"name": ["in", tier_names]}, + fields=["name", "title"]) %} + {% set _ = tier_title_map.update({tier.name | string: tier.title}) %} + {% endfor %} +{% endif %} + +{% set speaker_names = ((first_speaker_map.values() | list) + + (event.featured_speakers | map(attribute="speaker") | reject("none") | list)) | unique | list %} +{% set speaker_map = {} %} +{% if speaker_names %} + {% for speaker in frappe.get_all("Speaker Profile", filters={"name": ["in", speaker_names]}, + fields=["name", "display_name", "designation", "company", "display_image"]) %} + {% set _ = speaker_map.update({speaker.name | string: speaker}) %} + {% endfor %} +{% endif %} + +{% set schedule_items = [] %} +{% for session in event.schedule %} + {% set session_title = '' %} + {% set session_speaker = '' %} + {% if session.type == 'Talk' and session.talk %} + {% set session_title = talk_title_map.get(session.talk | string) or '' %} + {% set first_speaker = first_speaker_map.get(session.talk | string) %} + {% set speaker_profile = speaker_map.get(first_speaker | string) if first_speaker else None %} + {% set session_speaker = (speaker_profile.display_name or '') if speaker_profile else '' %} + {% elif session.type == 'Break' %} + {% set session_title = session.description or 'Break' %} + {% else %} + {% set session_title = session.description or '' %} + {% endif %} + {% set _ = schedule_items.append({ + "start_time": frappe.utils.get_time(session.start_time).strftime('%H:%M') if session.start_time else '', + "end_time": frappe.utils.get_time(session.end_time).strftime('%H:%M') if session.end_time else '', + "title": session_title, + "speaker_name": session_speaker, + "is_break": session.type == 'Break' + }) %} +{% endfor %} + +{% set city_code = (venue.name[:3]|upper) if venue and venue.name else 'TBD' %} +{% set event_code_short = (event.route or event.name)|truncate(10, True, '')|upper %} +{% set category_label = (category.name|upper) if category else 'EVENT' %} +{% set date_display = event.start_date.strftime('%d %b %Y') if event.start_date else 'TBA' %} +{% set date_compact = event.start_date.strftime('%d.%m.%y') if event.start_date else 'TBA' %} +{% set date_pill = event.start_date.strftime('%d %b')|upper if event.start_date else '' %} +{% set time_zone_label = event.time_zone_label or (event.time_zone.split('/')|last|upper if event.time_zone else '') %} +{% set start_time_display = frappe.utils.get_time(event.start_time).strftime('%H:%M') if event.start_time else 'TBA' %} +{% set is_webinar = event.medium == 'Online' %} +{% set partners_heading = 'Hosted & sponsored by' if (host and sponsors) else ('Sponsored by' if sponsors else 'Hosted by') %} +{% set hall_label = venue.address.split('\n')[0] if venue and venue.address else (venue.name if venue else 'TBA') %} + +{% set register_href = '/dashboard/book-tickets/' + (event.route or '') %} +{% set secondary_href = '/dashboard/events/' + (event.route or '') + '/forms/enquire-sponsorship' %} + +{% block title %}{{ event.title }} — {{ app_name }}{% endblock %} +{% block og_title %}{{ event.title }}{% endblock %} +{% block og_description %}{{ event.short_description or event.title }}{% endblock %} +{% block og_image %}{{ event.meta_image or event.banner_image or banner_image or '' }}{% endblock %} +{% block og_url %}{{ frappe.utils.get_url() }}/events/{{ event.route }}{% endblock %} + +{% block body %} +
+ + {% include "components/header.html" %} + +
+ +
+
+
+
Event ticket
+

+ {{ event.title }} +

+
+
+ {{ category_label }} / {{ event_code_short }} +
+
+ + {% if event.banner_image %} + {{ event.title }} banner + {% endif %} + + {% if event.short_description %} +

+ {{ event.short_description }} +

+ {% endif %} + +
+
+
Holder
+
YOU
+
attending in person
+
+
+
+
Venue
+
{{ city_code }}
+ {% if venue %} +
{{ venue.name }}{% if date_pill %} · {{ date_pill }}{% endif %}
+ {% endif %} +
+
+ +
+
+
Date
+
{{ date_display }}
+
+
+
Starts
+
{{ start_time_display }}{% if time_zone_label %} {{ time_zone_label }}{% endif %}
+
+
+
Hall
+
{{ hall_label }}
+
+
+
Tier
+
Community
+
+
+ + +
+ + +
+ + +
+ +{% include "components/sticky_cta.html" %} +{% endblock %} diff --git a/buzz/themes/buzz_events_theme/pages/home.html b/buzz/themes/buzz_events_theme/pages/home.html new file mode 100644 index 00000000..6e578a44 --- /dev/null +++ b/buzz/themes/buzz_events_theme/pages/home.html @@ -0,0 +1,255 @@ +{% extends "components/base.html" %} +{% import "components/macros/icons.html" as icons %} +{% import "components/macros/categories.html" as categories_macro %} + +{% set theme_config = buzz_theme_config() %} +{% set hero_tagline = theme_config.hero_tagline or 'Conferences, Meetups, Webinars' %} + +{% block title %}{{ app_name }} — {{ hero_tagline }}{% endblock %} +{% block og_title %}{{ app_name }} — {{ hero_tagline }}{% endblock %} +{% block og_description %}{{ theme_config.hero_description or 'Conferences, yatras, meetups, and webinars.' }}{% endblock %} +{% block og_url %}{{ frappe.utils.get_url() }}/home{% endblock %} + +{% block body %} +
+ {% include "components/header.html" %} + + {% set today = frappe.utils.today() %} + {% set all_events = frappe.get_all("Buzz Event", + filters={"is_published": 1}, + fields=["name", "title", "route", "category", "start_date", "venue", "host", + "card_image", "banner_image", "short_description", "medium"], + order_by="start_date asc") %} + + {% set venue_names = all_events | map(attribute='venue') | reject('none') | list | unique | list %} + {% set venues = frappe.get_all("Event Venue", + filters={"name": ["in", venue_names]}, + fields=["name", "address"]) if venue_names else [] %} + {% set venue_map = {} %} + {% for venue in venues %} + {% set _ = venue_map.update({venue.name: venue.address}) %} + {% endfor %} + + {% set categories = frappe.get_all("Event Category", + filters={"enabled": 1}, + fields=["name", "slug", "description", "icon_name", "subtitle"], + order_by="name asc") %} + + {% set upcoming_conferences = [] %} + {% set past_conferences = [] %} + {% set upcoming_yatra = [] %} + {% set past_webinars = [] %} + {% for event in all_events %} + {% set is_past = event.start_date and (event.start_date|string) < today %} + {% if event.category == 'Conferences' %} + {% if is_past %}{% set _ = past_conferences.append(event) %}{% else %}{% set _ = upcoming_conferences.append(event) %}{% endif %} + {% elif event.category == 'Yatra' and not is_past %} + {% set _ = upcoming_yatra.append(event) %} + {% elif event.category == 'Webinars' and is_past %} + {% set _ = past_webinars.append(event) %} + {% endif %} + {% endfor %} + + {% set featured = upcoming_conferences[0] if upcoming_conferences else None %} + + {% if featured %} + {% set featured_image = featured.card_image or featured.banner_image %} + {% set featured_date = featured.start_date.strftime('%d %b').upper() if featured.start_date else '' %} + {% set featured_venue = venue_map.get(featured.venue, '') %} +
+ + + +
+ {% if featured_image %} + {{ featured.title }} banner + {% endif %} +
+
+
+
+
{{ theme_config.featured_label or 'Featured conference' }}
+
{{ featured_date }}
+
+

+ {{ featured.title }} +

+ {% if featured.short_description %} +

+ {{ featured.short_description }} +

+ {% endif %} +
+ +
+
+
Venue
+
{{ featured_venue or '—' }}
+
+
+
Date
+
{{ featured.start_date.strftime('%d %b %Y') if featured.start_date else '—' }}
+
+
+ +
+ View ticket + {{ icons.icon("arrow-right", "w-4 h-4 transition-transform group-hover:translate-x-1") }} +
+
+
+
+ {% endif %} + +
+ {% set title = theme_config.yatra_heading or 'Upcoming Yatra' %} + {% set link_href = '/category/yatra' %} + {% set link_label = 'View all' %} + {% include "components/section_header.html" %} + + {% if upcoming_yatra %} +
+ {% for event_row in upcoming_yatra %} + {% set event = { + 'route': event_row.route, + 'title': event_row.title, + 'card_image': event_row.card_image or event_row.banner_image, + 'start_date': event_row.start_date.strftime('%d %b').upper() if event_row.start_date else '', + 'venue_address': venue_map.get(event_row.venue, ''), + 'category_label': 'YATRA' + } %} + {% set is_past = False %} + {% if loop.index == 3 %} + {% include "components/event_card_wide.html" %} + {% else %} + {% include "components/event_card.html" %} + {% endif %} + {% endfor %} +
+ {% else %} +
Nothing scheduled yet.
+ {% endif %} +
+ + {% set upcoming_conf_rest = upcoming_conferences[1:] if featured else upcoming_conferences %} +
+ {% set title = theme_config.conferences_heading or 'Upcoming Conferences' %} + {% set link_href = '/category/conferences' %} + {% set link_label = 'View all' %} + {% include "components/section_header.html" %} + + {% if upcoming_conf_rest %} +
+ {% for event_row in upcoming_conf_rest %} + {% set event = { + 'route': event_row.route, + 'title': event_row.title, + 'card_image': event_row.card_image or event_row.banner_image, + 'start_date': event_row.start_date.strftime('%d %b').upper() if event_row.start_date else '', + 'venue_address': venue_map.get(event_row.venue, ''), + 'category_label': 'CONFERENCE' + } %} + {% set is_past = False %} + {% include "components/event_card.html" %} + {% endfor %} + + {% if upcoming_conf_rest|length < 2 %} +
+
2026 · in planning
+
More conferences
announced soon
+
+ {% endif %} +
+ {% else %} + +
+
+
2026 · in planning
+
More conferences
announced soon
+
+
+ {% endif %} +
+ +
+

Browse by category

+ {% if categories %} + + {% else %} +
No categories yet.
+ {% endif %} +
+ +
+ {% set title = 'Past Conferences' %} + {% set link_href = '/category/conferences' %} + {% set link_label = 'View all' %} + {% include "components/section_header.html" %} + + {% if past_conferences %} +
+ {% for event_row in past_conferences %} + {% set event = { + 'route': event_row.route, + 'title': event_row.title, + 'card_image': event_row.card_image or event_row.banner_image, + 'start_date': event_row.start_date.strftime('%d %b').upper() if event_row.start_date else '', + 'venue_address': venue_map.get(event_row.venue, ''), + 'category_label': 'CONFERENCE' + } %} + {% set is_past = True %} + {% if loop.index == 3 %} + {% include "components/event_card_wide.html" %} + {% else %} + {% include "components/event_card.html" %} + {% endif %} + {% endfor %} +
+ {% else %} +
Nothing scheduled yet.
+ {% endif %} +
+ +
+ {% set title = 'Past Webinars' %} + {% set link_href = '/category/webinars' %} + {% set link_label = 'View all' %} + {% include "components/section_header.html" %} + + {% if past_webinars %} +
+ {% for event_row in past_webinars %} + {% set event = { + 'route': event_row.route, + 'title': event_row.title, + 'short_description': event_row.short_description, + 'month_short': event_row.start_date.strftime('%b') if event_row.start_date else '', + 'day': event_row.start_date.strftime('%d') if event_row.start_date else '', + 'is_wide': (loop.index == 3) + } %} + {% include "components/webinar_row.html" %} + {% endfor %} +
+ {% else %} +
Nothing scheduled yet.
+ {% endif %} +
+ + {% include "components/footer.html" %} +
+{% endblock %} diff --git a/buzz/themes/buzz_events_theme/styles/tokens.css b/buzz/themes/buzz_events_theme/styles/tokens.css new file mode 100644 index 00000000..8641ef07 --- /dev/null +++ b/buzz/themes/buzz_events_theme/styles/tokens.css @@ -0,0 +1,112 @@ +@import "tailwindcss"; + +/* ---------- design tokens ---------- */ +/* Tokens registered via @theme become Tailwind utilities (bg-bg, text-ink, ...). */ +/* Dark-mode swap happens by overriding the same variables under [data-theme="dark"]. */ + +@theme { + --font-ui: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + + --color-bg: #ebe8e1; + --color-pass: #ffffff; + --color-ink: #0f0f0f; + --color-ink-soft: rgba(15, 15, 15, 0.7); + --color-line: rgba(0, 0, 0, 0.3); + --color-line-strong: rgba(0, 0, 0, 0.4); + --color-pill: #0f0f0f; + --color-pill-fg: #ffffff; + --color-cta: #0f0f0f; + --color-cta-fg: #ffffff; + --color-cta-hover: #0044aa; + --color-focus-ring: #0044aa; +} + +html[data-theme="dark"] { + --color-bg: #0a0a0a; + --color-pass: #161616; + --color-ink: #ebe8e1; + --color-ink-soft: rgba(235, 232, 225, 0.7); + --color-line: rgba(235, 232, 225, 0.25); + --color-line-strong: rgba(235, 232, 225, 0.35); + --color-pill: #ebe8e1; + --color-pill-fg: #0f0f0f; + --color-cta: #ebe8e1; + --color-cta-fg: #0f0f0f; + --color-cta-hover: #ff5722; + --color-focus-ring: #ff5722; +} + +body { + font-family: var(--font-mono); + background: var(--color-bg); + color: var(--color-ink); + transition: background 0.3s, color 0.3s; + -webkit-font-smoothing: antialiased; +} + +/* ---------- irreducible bits — pseudo-elements + gradients Tailwind can't express ---------- */ + +.perf-strip { position: relative; } +.perf-strip::before, +.perf-strip::after { + content: ""; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--color-bg); + transition: background 0.3s; +} +.perf-strip::before { left: -12px; } +.perf-strip::after { right: -12px; } + +.perf { + background: + radial-gradient(circle at 0 50%, transparent 8px, var(--color-pass) 8px), + radial-gradient(circle at 100% 50%, transparent 8px, var(--color-pass) 8px); + border-left: 1px dashed var(--color-line); +} + +.barcode { + background: repeating-linear-gradient( + to right, + var(--color-ink) 0, + var(--color-ink) 1.5px, + transparent 1.5px, + transparent 3px, + var(--color-ink) 3px, + var(--color-ink) 4.5px, + transparent 4.5px, + transparent 6px, + var(--color-ink) 6px, + var(--color-ink) 9px, + transparent 9px, + transparent 12px + ); +} + +.theme-toggle .icon-sun { display: none; } +html[data-theme="dark"] .theme-toggle .icon-moon { display: none; } +html[data-theme="dark"] .theme-toggle .icon-sun { display: block; } + +[x-cloak] { display: none !important; } + +:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 3px; + border-radius: 2px; +} + +@media print { + body { background: #fff !important; color: #000 !important; } + .no-print, .theme-toggle, .sticky-cta, header, footer { display: none !important; } + main { max-width: 100% !important; padding: 0 !important; } + .perf-strip::before, .perf-strip::after { display: none !important; } + .barcode { print-color-adjust: exact; -webkit-print-color-adjust: exact; } + .print-hide-below { display: none !important; } +} + +@font-face { font-display: swap; } diff --git a/buzz/themes/buzz_events_theme/tailwind.input.css b/buzz/themes/buzz_events_theme/tailwind.input.css new file mode 100644 index 00000000..9032afae --- /dev/null +++ b/buzz/themes/buzz_events_theme/tailwind.input.css @@ -0,0 +1,4 @@ +@source "./pages/**/*.html"; +@source "./components/**/*.html"; + +@import "./styles/tokens.css"; diff --git a/buzz/themes/package.json b/buzz/themes/package.json new file mode 100644 index 00000000..95a9d67f --- /dev/null +++ b/buzz/themes/package.json @@ -0,0 +1,20 @@ +{ + "name": "buzz_themes", + "private": true, + "version": "0.0.0", + "scripts": { + "build:css": "yarn build:buzz_default_theme && yarn build:buzz_events_theme && yarn build:sketchbook_theme && yarn build:stickerpack_theme", + "build:buzz_default_theme": "cd buzz_default_theme && tailwindcss -i ./tailwind.input.css -o ../../public/themes/buzz_default_theme/tailwind.output.css --minify", + "build:buzz_events_theme": "cd buzz_events_theme && tailwindcss -i ./tailwind.input.css -o ../../public/themes/buzz_events_theme/tailwind.output.css --minify", + "build:sketchbook_theme": "cd sketchbook_theme && tailwindcss -i ./tailwind.input.css -o ../../public/themes/sketchbook_theme/tailwind.output.css --minify", + "build:stickerpack_theme": "cd stickerpack_theme && tailwindcss -i ./tailwind.input.css -o ../../public/themes/stickerpack_theme/tailwind.output.css --minify", + "watch:buzz_default_theme": "cd buzz_default_theme && tailwindcss -i ./tailwind.input.css -o ../../public/themes/buzz_default_theme/tailwind.output.css --watch", + "watch:buzz_events_theme": "cd buzz_events_theme && tailwindcss -i ./tailwind.input.css -o ../../public/themes/buzz_events_theme/tailwind.output.css --watch", + "watch:sketchbook_theme": "cd sketchbook_theme && tailwindcss -i ./tailwind.input.css -o ../../public/themes/sketchbook_theme/tailwind.output.css --watch", + "watch:stickerpack_theme": "cd stickerpack_theme && tailwindcss -i ./tailwind.input.css -o ../../public/themes/stickerpack_theme/tailwind.output.css --watch" + }, + "devDependencies": { + "tailwindcss": "^4.0.14", + "@tailwindcss/cli": "^4.0.14" + } +} diff --git a/buzz/themes/sketchbook_theme/components/base.html b/buzz/themes/sketchbook_theme/components/base.html new file mode 100644 index 00000000..e4b8c707 --- /dev/null +++ b/buzz/themes/sketchbook_theme/components/base.html @@ -0,0 +1,38 @@ + + + + + {% include "components/head_top.html" %} + + + + + + {% set og_image %}{% block og_image %}{{ banner_image or '' }}{% endblock %}{% endset %} + {% if og_image | trim %}{% endif %} + + + + {% block title %}{{ title_prefix or app_name }}{% endblock %} + + {% include "components/head_bottom.html" %} + + + + + +
+ {% include "components/header.html" %} + + {% block body %}{% endblock %} + + {% include "components/footer.html" %} +
+ + {% block body_extra %}{% endblock %} + + {% if frappe.session.user == "Guest" %}{% include "components/login_dialog.html" %}{% endif %} + + {% include "components/scripts_tail.html" %} + + diff --git a/buzz/themes/sketchbook_theme/components/event_card.html b/buzz/themes/sketchbook_theme/components/event_card.html new file mode 100644 index 00000000..70737708 --- /dev/null +++ b/buzz/themes/sketchbook_theme/components/event_card.html @@ -0,0 +1,24 @@ +{% import "components/macros/icons.html" as icons %} + +
+ {% if event.image %} + + {% else %} +
+ {% endif %} + {% if event.date_short %}{{ event.date_short }}{% endif %} +
+
+

{{ event.title }}

+ {% if event.venue_short %} +
+ {{ icons.icon("map-pin") }} + {{ event.venue_short }} +
+ {% endif %} +
+ {{ event.category_label }} + {% if event.is_past %}recap →{% else %}ticket →{% endif %} +
+
+
diff --git a/buzz/themes/sketchbook_theme/components/footer.html b/buzz/themes/sketchbook_theme/components/footer.html new file mode 100644 index 00000000..923aced3 --- /dev/null +++ b/buzz/themes/sketchbook_theme/components/footer.html @@ -0,0 +1,16 @@ +{% import "components/macros/branding.html" as branding with context %} +
+ + {% if footer_address %} + + {% endif %} + +
diff --git a/buzz/themes/sketchbook_theme/components/head_bottom.html b/buzz/themes/sketchbook_theme/components/head_bottom.html new file mode 100644 index 00000000..4d308ae4 --- /dev/null +++ b/buzz/themes/sketchbook_theme/components/head_bottom.html @@ -0,0 +1,5 @@ + + + + + diff --git a/buzz/themes/sketchbook_theme/components/head_top.html b/buzz/themes/sketchbook_theme/components/head_top.html new file mode 100644 index 00000000..5ed2664c --- /dev/null +++ b/buzz/themes/sketchbook_theme/components/head_top.html @@ -0,0 +1,5 @@ + + + + + diff --git a/buzz/themes/sketchbook_theme/components/header.html b/buzz/themes/sketchbook_theme/components/header.html new file mode 100644 index 00000000..23950e4a --- /dev/null +++ b/buzz/themes/sketchbook_theme/components/header.html @@ -0,0 +1,28 @@ +{% import "components/macros/branding.html" as branding with context %} +
+ {{ branding.brand(app_logo, "brand-logo", "★ ") }} +
+ {% include "components/theme_toggle.html" %} + {% if frappe.session.user == "Guest" %} + + {% else %} + + {% endif %} +
+
diff --git a/buzz/themes/sketchbook_theme/components/login_dialog.html b/buzz/themes/sketchbook_theme/components/login_dialog.html new file mode 100644 index 00000000..f89338ef --- /dev/null +++ b/buzz/themes/sketchbook_theme/components/login_dialog.html @@ -0,0 +1,102 @@ + diff --git a/buzz/themes/sketchbook_theme/components/scripts_tail.html b/buzz/themes/sketchbook_theme/components/scripts_tail.html new file mode 100644 index 00000000..864ee630 --- /dev/null +++ b/buzz/themes/sketchbook_theme/components/scripts_tail.html @@ -0,0 +1,2 @@ + + diff --git a/buzz/themes/sketchbook_theme/components/theme_toggle.html b/buzz/themes/sketchbook_theme/components/theme_toggle.html new file mode 100644 index 00000000..18f117d3 --- /dev/null +++ b/buzz/themes/sketchbook_theme/components/theme_toggle.html @@ -0,0 +1,4 @@ + diff --git a/buzz/themes/sketchbook_theme/components/webinar_row.html b/buzz/themes/sketchbook_theme/components/webinar_row.html new file mode 100644 index 00000000..d063c74a --- /dev/null +++ b/buzz/themes/sketchbook_theme/components/webinar_row.html @@ -0,0 +1,11 @@ + +
+
{{ webinar.date_month }}
+
{{ webinar.date_day }}
+
+
+

{{ webinar.title }}

+ {% if webinar.blurb %}

{{ webinar.blurb }}

{% endif %} +
recording →
+
+
diff --git a/buzz/themes/sketchbook_theme/pages/404.html b/buzz/themes/sketchbook_theme/pages/404.html new file mode 100644 index 00000000..06874f12 --- /dev/null +++ b/buzz/themes/sketchbook_theme/pages/404.html @@ -0,0 +1,23 @@ +{% extends "components/base.html" %} + +{% block title %}Not found — {{ app_name }}{% endblock %} +{% block og_title %}Not found — {{ app_name }}{% endblock %} +{% block og_description %}This page got scribbled out.{% endblock %} + +{% block body %} +
+

error · four-oh-four

+
+ page scribbled
+ out +
+
+

this page got scribbled out

+

we looked everywhere in the notebook. it's not here.

+
+

+ try the front cover instead,
+ or browse a yatra. +

+
+{% endblock %} diff --git a/buzz/themes/sketchbook_theme/pages/category/detail.html b/buzz/themes/sketchbook_theme/pages/category/detail.html new file mode 100644 index 00000000..17cc284f --- /dev/null +++ b/buzz/themes/sketchbook_theme/pages/category/detail.html @@ -0,0 +1,158 @@ +{% extends "components/base.html" %} + +{% set today = frappe.utils.today() %} +{% set category_name = frappe.db.get_value("Event Category", {"slug": category_slug, "enabled": 1}, "name") %} +{% if not category_name %} + {% set not_found = frappe.throw(_("Category not found"), frappe.PageDoesNotExistError) %} +{% endif %} +{% set category = frappe.get_cached_doc("Event Category", category_name) %} + +{% set all_categories = frappe.get_all("Event Category", + filters={"enabled": 1}, + fields=["name", "slug"], + order_by="name asc") %} + +{% set all_events = frappe.get_all("Buzz Event", + filters={"is_published": 1, "category": category_name}, + fields=["name", "title", "route", "start_date", "venue", + "card_image", "banner_image", "short_description"], + order_by="start_date desc") %} + +{% set venue_names = all_events | map(attribute='venue') | reject('none') | list | unique | list %} +{% set venues = frappe.get_all("Event Venue", + filters={"name": ["in", venue_names]}, + fields=["name", "address"]) if venue_names else [] %} +{% set venue_map = {} %} +{% for venue in venues %} + {% set _ = venue_map.update({venue.name: venue.address}) %} +{% endfor %} + +{% set upcoming = [] %} +{% set past = [] %} +{% for event in all_events %} + {% if event.start_date and (event.start_date|string) < today %} + {% set _ = past.append(event) %} + {% else %} + {% set _ = upcoming.append(event) %} + {% endif %} +{% endfor %} + +{% set swatches = ['yellow', 'pink', 'blue', 'green', 'orange', 'purple'] %} +{% set category_slug_lower = (category.slug or '') | lower %} +{% set is_webinars = category_slug_lower == 'webinars' %} + +{% block title %}{{ category.name }} — {{ app_name }}{% endblock %} +{% block og_title %}{{ category.name }} — {{ app_name }}{% endblock %} +{% block og_description %}{{ category.description or category.name }}{% endblock %} +{% block og_url %}{{ frappe.utils.get_url() }}/category/{{ category.slug }}{% endblock %} + +{% block body %} + +
+
browse by · {{ category.name | lower }}
+

+ {% if category_slug_lower == 'yatra' %} + a tour
across india + {% elif category_slug_lower == 'conferences' %} + the big
conferences + {% elif category_slug_lower == 'meetups' %} + local
meetups + {% elif is_webinars %} + online
webinars + {% else %} + {{ category.name.split(' ')[0] | lower }}{% if category.name.split(' ') | length > 1 %}
{{ category.name.split(' ')[1:] | join(' ') | lower }}{% endif %} + {% endif %} +

+ {% if category.description %} +

{{ category.description }}

+ {% endif %} + +
+ {% for category_link in all_categories %} + + {{ category_link.name | lower }} + + {% endfor %} +
+
+ +{% if upcoming %} +
+
+

upcoming

+ {{ upcoming | length }} {{ 'stop' if upcoming | length == 1 else 'stops' }} +
+
+ {% for event_row in upcoming %} + {% if is_webinars %} + {% set webinar = { + 'route': event_row.route, + 'title': event_row.title, + 'blurb': event_row.short_description, + 'date_month': event_row.start_date.strftime('%b').upper() if event_row.start_date else '', + 'date_day': event_row.start_date.strftime('%d') if event_row.start_date else '' + } %} + {% include "components/webinar_row.html" %} + {% else %} + {% set event = { + 'route': event_row.route, + 'title': event_row.title, + 'image': event_row.card_image or event_row.banner_image, + 'date_short': event_row.start_date.strftime('%d %b').upper() if event_row.start_date else '', + 'venue_short': venue_map.get(event_row.venue, '').split(',')[0] if venue_map.get(event_row.venue, '') else '', + 'category_label': category.name.upper(), + 'swatch': swatches[loop.index0 % 6], + 'is_past': False + } %} + {% include "components/event_card.html" %} + {% endif %} + {% endfor %} +
+
+{% endif %} + +{% if past %} +
+
+

past {{ category.name | lower }}

+ {{ past | length }} +
+
+ {% for event_row in past %} + {% if is_webinars %} + {% set webinar = { + 'route': event_row.route, + 'title': event_row.title, + 'blurb': event_row.short_description, + 'date_month': event_row.start_date.strftime('%b').upper() if event_row.start_date else '', + 'date_day': event_row.start_date.strftime('%d') if event_row.start_date else '' + } %} + {% include "components/webinar_row.html" %} + {% else %} + {% set event = { + 'route': event_row.route, + 'title': event_row.title, + 'image': event_row.card_image or event_row.banner_image, + 'date_short': event_row.start_date.strftime('%d %b').upper() if event_row.start_date else '', + 'venue_short': venue_map.get(event_row.venue, '').split(',')[0] if venue_map.get(event_row.venue, '') else '', + 'category_label': category.name.upper(), + 'swatch': swatches[loop.index0 % 6], + 'is_past': True + } %} + {% include "components/event_card.html" %} + {% endif %} + {% endfor %} +
+
+{% endif %} + +{% if not upcoming and not past %} +
+

no {{ category.name | lower }} on the page yet.

+

check back soon.

+
+{% endif %} + +{% endblock %} diff --git a/buzz/themes/sketchbook_theme/pages/events/detail.html b/buzz/themes/sketchbook_theme/pages/events/detail.html new file mode 100644 index 00000000..c4713bf7 --- /dev/null +++ b/buzz/themes/sketchbook_theme/pages/events/detail.html @@ -0,0 +1,330 @@ +{% extends "components/base.html" %} +{% import "components/macros/maps.html" as maps %} + +{% set event_name = frappe.db.get_value("Buzz Event", {"route": event_route, "is_published": 1}, "name") %} +{% if not event_name %} + {% set not_found = frappe.throw(_("Event not found"), frappe.PageDoesNotExistError) %} +{% endif %} +{% set event = frappe.get_cached_doc("Buzz Event", event_name) %} +{% set venue = frappe.get_cached_doc("Event Venue", event.venue) if event.venue else None %} +{% set host = frappe.get_cached_doc("Event Host", event.host) if event.host else None %} +{% set category = frappe.get_cached_doc("Event Category", event.category) if event.category else None %} +{% set sponsors = frappe.get_all("Event Sponsor", filters={"event": event.name}, + fields=["company_name", "tier", "company_logo", "website", "country"]) %} + +{% set today = frappe.utils.today() %} +{% set is_past = event.start_date and (event.start_date|string) < today %} + +{% set talk_names = event.schedule | selectattr("type", "equalto", "Talk") + | map(attribute="talk") | reject("none") | list | unique | list %} +{% set talk_title_map = {} %} +{% set first_speaker_map = {} %} +{% if talk_names %} + {% for talk in frappe.get_all("Event Talk", filters={"name": ["in", talk_names]}, fields=["name", "title"]) %} + {% set _ = talk_title_map.update({talk.name | string: talk.title}) %} + {% endfor %} + {% for row in frappe.get_all("Talk Speaker", filters={"parenttype": "Event Talk", "parent": ["in", talk_names]}, + fields=["parent", "speaker"], order_by="parent asc, idx asc") %} + {% if (row.parent | string) not in first_speaker_map %}{% set _ = first_speaker_map.update({row.parent | string: row.speaker}) %}{% endif %} + {% endfor %} +{% endif %} + +{% set speaker_names = ((first_speaker_map.values() | list) + + (event.featured_speakers | map(attribute="speaker") | reject("none") | list)) | unique | list %} +{% set speaker_map = {} %} +{% if speaker_names %} + {% for speaker in frappe.get_all("Speaker Profile", filters={"name": ["in", speaker_names]}, + fields=["name", "display_name", "designation", "company", "display_image"]) %} + {% set _ = speaker_map.update({speaker.name | string: speaker}) %} + {% endfor %} +{% endif %} + +{% set schedule_items = [] %} +{% for session in event.schedule %} + {% set session_title = '' %} + {% set session_speaker = '' %} + {% if session.type == 'Talk' and session.talk %} + {% set session_title = talk_title_map.get(session.talk | string) or '' %} + {% set first_speaker = first_speaker_map.get(session.talk | string) %} + {% set speaker_profile = speaker_map.get(first_speaker | string) if first_speaker else None %} + {% set session_speaker = (speaker_profile.display_name or '') if speaker_profile else '' %} + {% elif session.type == 'Break' %} + {% set session_title = session.description or 'Break' %} + {% else %} + {% set session_title = session.description or '' %} + {% endif %} + {% set _ = schedule_items.append({ + "start_time": frappe.utils.get_time(session.start_time).strftime('%H:%M') if session.start_time else '', + "end_time": frappe.utils.get_time(session.end_time).strftime('%H:%M') if session.end_time else '', + "title": session_title, + "speaker_name": session_speaker, + "is_break": session.type == 'Break' + }) %} +{% endfor %} + +{% set featured_speakers = [] %} +{% for featured_speaker in event.featured_speakers %} + {% set speaker_profile = speaker_map.get(featured_speaker.speaker | string) if featured_speaker.speaker else None %} + {% if speaker_profile %} + {% set role_parts = [] %} + {% if speaker_profile.designation %}{% set _ = role_parts.append(speaker_profile.designation) %}{% endif %} + {% if speaker_profile.company %}{% set _ = role_parts.append(speaker_profile.company) %}{% endif %} + {% set _ = featured_speakers.append({ + "name": speaker_profile.display_name or '', + "role": role_parts|join(', '), + "image_url": speaker_profile.display_image + }) %} + {% endif %} +{% endfor %} + +{% set swatches = ['yellow', 'pink', 'blue', 'green', 'orange', 'purple'] %} +{% set category_label = (category.name|upper) if category else 'EVENT' %} +{% set date_full = event.start_date.strftime('%a, %d %b %Y') if event.start_date else 'TBA' %} +{% set start_time_display = frappe.utils.get_time(event.start_time).strftime('%H:%M') if event.start_time else '' %} +{% set time_zone_label = event.time_zone_label or (event.time_zone.split('/')|last if event.time_zone else '') %} +{% set is_webinar = event.medium == 'Online' %} +{% set partners_heading = 'hosted & sponsored by' if (host and sponsors) else ('sponsored by' if sponsors else 'hosted by') %} +{% set hero_image = event.banner_image or event.card_image %} +{% set register_href = '/dashboard/book-tickets/' + (event.route or '') %} +{% set sponsor_href = '/dashboard/events/' + (event.route or '') + '/forms/enquire-sponsorship' %} + +{% set title_words = (event.title or '').split(' ') %} + +{% block title %}{{ event.title }} — {{ app_name }}{% endblock %} +{% block og_type %}event{% endblock %} +{% block og_title %}{{ event.title }}{% endblock %} +{% block og_description %}{{ event.short_description or event.title }}{% endblock %} +{% block og_image %}{{ event.meta_image or event.banner_image or banner_image or '' }}{% endblock %} +{% block og_url %}{{ frappe.utils.get_url() }}/events/{{ event.route }}{% endblock %} + +{% block body %} + +
+
+
{{ category_label }} · {{ (event.route or event.name)|truncate(14, True, '')|upper }}
+
page 01 of ∞ →
+
+ +

+ {% if title_words | length >= 3 %} + {{ title_words[0] }} {{ title_words[1:-1] | join(' ') }} {{ title_words[-1] }} + {% elif title_words | length == 2 %} + {{ title_words[0] }} {{ title_words[1] }} + {% else %} + {{ event.title }} + {% endif %} +

+ + {% if event.short_description %} +

+ {{ event.short_description }} +

+ {% endif %} + +
+
+
when ↓
+
+ {{ date_full }} + {% if start_time_display %} +
starts {{ start_time_display }}{% if time_zone_label %} {{ time_zone_label }}{% endif %}
+ {% endif %} +
+
+ {% if venue %} +
+
where ↓
+
+ {{ venue.name }}
+ {% if venue.address %}({{ venue.address.split('\n')[0] }}){% endif %} +
+
+ {% else %} +
+
format ↓
+
+ online — recording later +
+
+ {% endif %} +
+ + {% if hero_image %} +
+
+ {{ event.title }} banner +
★ {{ (event.route or 'this one') | replace('-', ' ') }}!
+
+ {% if event.medium %} +
+ ↑ {{ event.medium }} · in the room +
+ {% endif %} +
+ {% endif %} + + +
+ + + +

+ {% if is_past %}thanks for being there ★{% else %}seats are limited. don't dawdle ★{% endif %} +

+ +{% endblock %} + +{% block body_extra %} + +{% endblock %} diff --git a/buzz/themes/sketchbook_theme/pages/home.html b/buzz/themes/sketchbook_theme/pages/home.html new file mode 100644 index 00000000..2dc8c936 --- /dev/null +++ b/buzz/themes/sketchbook_theme/pages/home.html @@ -0,0 +1,202 @@ +{% extends "components/base.html" %} +{% import "components/macros/icons.html" as icons %} +{% import "components/macros/categories.html" as categories_macro %} + +{% set theme_config = buzz_theme_config() %} + +{% block title %}{{ app_name }}{% endblock %} +{% block og_title %}{{ app_name }}{% endblock %} +{% block og_description %}{{ theme_config.hero_description or 'Conferences, yatras, meetups, and webinars.' }}{% endblock %} +{% block og_url %}{{ frappe.utils.get_url() }}/home{% endblock %} + +{% block body %} + +{% set today = frappe.utils.today() %} +{% set all_events = frappe.get_all("Buzz Event", + filters={"is_published": 1}, + fields=["name", "title", "route", "category", "start_date", "venue", + "card_image", "banner_image", "short_description", "medium"], + order_by="start_date asc") %} + +{% set venue_names = all_events | map(attribute='venue') | reject('none') | list | unique | list %} +{% set venues = frappe.get_all("Event Venue", + filters={"name": ["in", venue_names]}, + fields=["name", "address"]) if venue_names else [] %} +{% set venue_map = {} %} +{% for venue in venues %} + {% set _ = venue_map.update({venue.name: venue.address}) %} +{% endfor %} + +{% set categories = frappe.get_all("Event Category", + filters={"enabled": 1}, + fields=["name", "slug", "description", "icon_name", "subtitle"], + order_by="name asc") %} + +{% set upcoming_conferences = [] %} +{% set past_conferences = [] %} +{% set upcoming_yatra = [] %} +{% set past_webinars = [] %} +{% for event in all_events %} + {% set is_past = event.start_date and (event.start_date|string) < today %} + {% if event.category == 'Conferences' %} + {% if is_past %}{% set _ = past_conferences.append(event) %}{% else %}{% set _ = upcoming_conferences.append(event) %}{% endif %} + {% elif event.category == 'Yatra' and not is_past %} + {% set _ = upcoming_yatra.append(event) %} + {% elif event.category == 'Webinars' and is_past %} + {% set _ = past_webinars.append(event) %} + {% endif %} +{% endfor %} + +{% set featured = upcoming_conferences[0] if upcoming_conferences else None %} +{% set swatches = ['yellow', 'pink', 'blue', 'green', 'orange', 'purple'] %} + +{% if featured %} + {% set featured_image = featured.card_image or featured.banner_image %} + {% set featured_date_long = featured.start_date.strftime('%a, %d %b %Y') if featured.start_date else '' %} + {% set featured_venue = venue_map.get(featured.venue, '') %} +
+ + + + +
+{% endif %} + +{% if upcoming_yatra %} +
+
+

{{ theme_config.yatra_heading or 'upcoming yatra' }}

+ all stops {{ icons.icon("arrow-right") }} +
+
+ {% for event_row in upcoming_yatra %} + {% set event = { + 'route': event_row.route, + 'title': event_row.title, + 'image': event_row.card_image or event_row.banner_image, + 'date_short': event_row.start_date.strftime('%d %b').upper() if event_row.start_date else '', + 'venue_short': venue_map.get(event_row.venue, '').split(',')[0] if venue_map.get(event_row.venue, '') else '', + 'category_label': 'YATRA', + 'swatch': swatches[loop.index0 % 6], + 'is_past': False + } %} + {% include "components/event_card.html" %} + {% endfor %} +
+
+{% endif %} + +{% set upcoming_conf_rest = upcoming_conferences[1:] if featured else upcoming_conferences %} +
+
+

{{ theme_config.conferences_heading or 'upcoming conferences' }}

+ all {{ icons.icon("arrow-right") }} +
+
+ {% for event_row in upcoming_conf_rest %} + {% set event = { + 'route': event_row.route, + 'title': event_row.title, + 'image': event_row.card_image or event_row.banner_image, + 'date_short': event_row.start_date.strftime('%d %b').upper() if event_row.start_date else '', + 'venue_short': venue_map.get(event_row.venue, '').split(',')[0] if venue_map.get(event_row.venue, '') else '', + 'category_label': 'CONFERENCE', + 'swatch': swatches[loop.index0 % 6], + 'is_past': False + } %} + {% include "components/event_card.html" %} + {% endfor %} +
+
2026 · in planning
+
{{ theme_config.planning_note or 'more conferences announced soon' }}
+
+
+
+ +
+
+

browse by

+
+
+ {% for category in categories %} + {% set swatch = swatches[loop.index0 % 6] %} + +
+
{{ category.name | lower }}
+ {{ categories_macro.category_subtitle(category) }} +
+ {{ categories_macro.category_icon(category) }} +
+ {% endfor %} +
+
+ +{% if past_conferences %} +
+
+

past conferences

+ all {{ icons.icon("arrow-right") }} +
+
+ {% for event_row in past_conferences %} + {% set event = { + 'route': event_row.route, + 'title': event_row.title, + 'image': event_row.card_image or event_row.banner_image, + 'date_short': event_row.start_date.strftime('%d %b').upper() if event_row.start_date else '', + 'venue_short': venue_map.get(event_row.venue, '').split(',')[0] if venue_map.get(event_row.venue, '') else '', + 'category_label': 'CONFERENCE', + 'swatch': swatches[loop.index0 % 6], + 'is_past': True + } %} + {% include "components/event_card.html" %} + {% endfor %} +
+
+{% endif %} + +{% if past_webinars %} +
+ +
+ {% for event_row in past_webinars %} + {% set webinar = { + 'route': event_row.route, + 'title': event_row.title, + 'blurb': event_row.short_description, + 'date_month': event_row.start_date.strftime('%b').upper() if event_row.start_date else '', + 'date_day': event_row.start_date.strftime('%d') if event_row.start_date else '' + } %} + {% include "components/webinar_row.html" %} + {% endfor %} +
+
+{% endif %} + +{% endblock %} diff --git a/buzz/themes/sketchbook_theme/styles/tokens.css b/buzz/themes/sketchbook_theme/styles/tokens.css new file mode 100644 index 00000000..0820371e --- /dev/null +++ b/buzz/themes/sketchbook_theme/styles/tokens.css @@ -0,0 +1,1373 @@ +@import "tailwindcss"; + +/* ============================================================= + Sketchbook theme tokens + Light + dark, swatch palette, ornaments, primitives. + Reference: themes/sketchbook/reference.html + ============================================================= */ + +@theme { + --font-marker: "Caveat", cursive; + --font-body: "Patrick Hand", cursive; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; +} + +:root { + /* Core */ + --bg: #fffdf3; + --surface: #ffffff; + --ink: #0d0d0d; + --ink-soft: rgba(13, 13, 13, 0.7); + --line: rgba(0, 0, 0, 0.06); + --line-strong: rgba(0, 0, 0, 0.3); + --pill-bg: var(--ink); + --pill-fg: var(--surface); + --cta-bg: #b34a00; + --cta-fg: #ffffff; + --cta-hover: #0d0d0d; + --shadow: 4px 4px 0 #0d0d0d; + --shadow-soft: 4px 6px 12px rgba(0, 0, 0, 0.2); + --focus-ring: #b34a00; + --accent: #b34a00; + --photo-frame: #ffffff; + --organic-radius: 60% 40% 50% 50% / 50% 60% 40% 50%; + + /* Swatches (used as surface.featured fills) */ + --swatch-yellow: #fef3c7; + --swatch-pink: #fbcfe8; + --swatch-blue: #bfdbfe; + --swatch-green: #bbf7d0; + --swatch-orange: #fed7aa; + --swatch-purple: #e9d5ff; +} + +html[data-theme="dark"] { + --bg: #1a1612; + --surface: #2a221c; + --ink: #f0e8d0; + --ink-soft: rgba(240, 232, 208, 0.7); + --line: rgba(255, 255, 255, 0.08); + --line-strong: rgba(255, 255, 255, 0.2); + --pill-bg: var(--ink); + --pill-fg: var(--surface); + --cta-bg: #ff8a4a; + --cta-fg: #1a1612; + --cta-hover: #f0e8d0; + --shadow: 4px 4px 0 rgba(240, 232, 208, 0.4); + --shadow-soft: 4px 6px 12px rgba(0, 0, 0, 0.5); + --accent: #ff8a4a; + --photo-frame: #2a221c; + + --swatch-yellow: #5a4a1a; + --swatch-pink: #5a1a3a; + --swatch-blue: #1a2a5a; + --swatch-green: #1a4a2a; + --swatch-orange: #5a3a1a; + --swatch-purple: #3a1a5a; +} + +/* ============================================================= + Base body + font registration + ============================================================= */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + background: var(--bg); + color: var(--ink); + font-family: "Patrick Hand", cursive; + font-size: 18px; + line-height: 1.5; + transition: background 0.3s, color 0.3s; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +a { + color: inherit; + text-decoration: none; +} + +a:hover { + text-decoration: none; +} + +img { + max-width: 100%; + display: block; +} + +/* Keyboard focus — visible across themes */ +:focus-visible { + outline: 3px solid var(--focus-ring); + outline-offset: 3px; + border-radius: 4px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.skip-link { + position: absolute; + top: -100px; + left: 1rem; + z-index: 200; + background: var(--ink); + color: var(--bg); + padding: 0.5rem 1rem; + font-family: "Caveat", cursive; + font-size: 22px; + border-radius: 4px; + transition: top 0.2s; +} + +.skip-link:focus { + top: 1rem; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + .box, + .box:nth-child(2n), + .box:nth-child(3n), + .box:nth-child(5n), + .featured-banner, + .webinar-row, + .pricing-card, + .speaker-card, + .event-card, + .display-h1, + .section-header__title, + .cta, + .circle, + .photo, + .arrow { + transform: none !important; + } +} + +/* ============================================================= + Type families + ============================================================= */ + +.marker { + font-family: "Caveat", cursive; + font-weight: 500; +} + +.mono { + font-family: "JetBrains Mono", monospace; +} + +.label { + font-family: "JetBrains Mono", monospace; + font-size: 10px; + letter-spacing: 0.25em; + text-transform: uppercase; + opacity: 0.5; +} + +/* ============================================================= + Page shell + ruled background + ============================================================= */ + +.page-shell { + max-width: 920px; + margin: 0 auto; + padding: 4rem 1.5rem 6rem; +} + +.ruled { + background-image: repeating-linear-gradient( + transparent 0 28px, + var(--line) 28px 29px + ); + line-height: 29px; +} + +/* ============================================================= + .box — surface.base + 2.5px solid border, 6px radius, hard offset shadow, -1deg rotate + nth-child rules vary tilt naturally (siblings alternate). + Override classes: .tilt-l (-1deg), .tilt-r (1deg), .tilt-flat + ============================================================= */ + +.box { + background: var(--surface); + border: 2.5px solid var(--ink); + border-radius: 6px; + box-shadow: var(--shadow); + transform: rotate(-1deg); +} + +.box:nth-child(2n) { + transform: rotate(1deg); +} + +.box:nth-child(3n) { + transform: rotate(-2deg); +} + +.box:nth-child(5n) { + transform: rotate(2deg); +} + +.tilt-l { + transform: rotate(-1deg) !important; +} +.tilt-r { + transform: rotate(1deg) !important; +} +.tilt-l2 { + transform: rotate(-2deg) !important; +} +.tilt-r2 { + transform: rotate(2deg) !important; +} +.tilt-flat { + transform: rotate(0deg) !important; +} + +/* Escalate-soft: keep the box but tint with a swatch. */ +.swatch-yellow { + background: var(--swatch-yellow); +} +.swatch-pink { + background: var(--swatch-pink); +} +.swatch-blue { + background: var(--swatch-blue); +} +.swatch-green { + background: var(--swatch-green); +} +.swatch-orange { + background: var(--swatch-orange); +} +.swatch-purple { + background: var(--swatch-purple); +} + +/* ============================================================= + Decorations: scribble, circle, arrow, photo + ============================================================= */ + +.arrow { + display: inline-block; + transform: rotate(-5deg); + color: var(--accent); +} + +.scribble { + text-decoration: underline wavy var(--accent); + text-underline-offset: 6px; + text-decoration-thickness: 2px; +} + +.circle { + border: 3px solid var(--accent); + border-radius: var(--organic-radius); + padding: 4px 12px; + transform: rotate(-2deg); + display: inline-block; + line-height: 1.1; + color: inherit; +} + +.circle.tilt-r2 { + transform: rotate(2deg); +} + +.photo { + transform: rotate(-2deg); + border: 4px solid var(--photo-frame); + box-shadow: var(--shadow-soft); + background: var(--photo-frame); +} + +/* ============================================================= + Top rule bar / header + ============================================================= */ + +.rule-bar { + display: flex; + justify-content: space-between; + align-items: baseline; + padding-bottom: 8px; + margin-bottom: 32px; + border-bottom: 2px solid var(--line-strong); +} + +.rule-bar .brand { + font-family: "Caveat", cursive; + font-size: 40px; + line-height: 1; + font-weight: 500; +} + +.rule-bar .brand-logo { + height: 40px; + width: auto; + display: block; +} + +.rule-bar .signin-link { + font-family: "Caveat", cursive; + font-size: 24px; + display: inline-flex; + align-items: baseline; + gap: 6px; +} + +.rule-bar .signin-link .arrow { + font-size: 24px; +} + +/* ============================================================= + Footer + ============================================================= */ + +.site-footer { + margin-top: 4rem; + padding-top: 0.75rem; + border-top: 2px solid var(--line-strong); +} + +.site-footer-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 1.5rem; + font-family: "JetBrains Mono", monospace; + font-size: 10px; + letter-spacing: 0.25em; + text-transform: uppercase; + opacity: 0.5; +} + +.site-footer .footer-links { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; +} + +.site-footer .footer-links a { + opacity: 0.7; +} + +.site-footer .footer-links a:hover { + opacity: 1; +} + +.site-footer-address { + margin-top: 0.75rem; + font-family: "JetBrains Mono", monospace; + font-size: 10px; + letter-spacing: 0.25em; + text-transform: uppercase; + opacity: 0.5; + font-style: normal; + white-space: pre-line; +} + +.site-footer .footer-logo { + height: 22px; + width: auto; + display: block; +} + +.site-footer-tagline { + margin-top: 0.75rem; + font-family: "Patrick Hand", cursive; + font-size: 14px; + opacity: 0.7; + text-align: center; +} + +/* ============================================================= + Theme toggle button (top-right, 44x44) + ============================================================= */ + +.theme-toggle { + width: 34px; + height: 34px; + border-radius: 50%; + border: 2px solid currentColor; + background: transparent; + color: inherit; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform 0.2s; + flex-shrink: 0; + align-self: center; + padding: 0; +} + +.theme-toggle:hover { + transform: rotate(20deg); +} + +.theme-toggle .icon-moon, +.theme-toggle .icon-sun { + display: none; +} + +html:not([data-theme="dark"]) .theme-toggle .icon-moon { + display: block; +} + +html[data-theme="dark"] .theme-toggle .icon-sun { + display: block; +} + +/* ============================================================= + CTA — primary action + ============================================================= */ + +.cta { + background: var(--cta-bg); + color: var(--cta-fg); + border: 2.5px solid var(--ink); + border-radius: 6px; + box-shadow: var(--shadow); + padding: 16px 48px; + font-family: "Caveat", cursive; + font-size: 40px; + line-height: 1; + transform: rotate(-1deg); + display: inline-block; + transition: background 0.2s, transform 0.2s; +} + +.cta:hover { + background: var(--cta-hover); + color: var(--cta-fg); + transform: rotate(-1deg) translateY(-2px); +} + +.cta-sm { + padding: 10px 22px; + font-size: 24px; +} + +/* ============================================================= + Event card (list page) + ============================================================= */ + +.event-card { + display: block; + background: var(--surface); + border: 2.5px solid var(--ink); + border-radius: 6px; + box-shadow: var(--shadow); + padding: 12px; + transition: transform 0.2s; +} + +.event-card.tilt-l { + transform: rotate(-1deg); +} +.event-card.tilt-r { + transform: rotate(1deg); +} +.event-card.tilt-l2 { + transform: rotate(-2deg); +} +.event-card.tilt-r2 { + transform: rotate(2deg); +} + +.event-card:hover { + transform: translateY(-3px) rotate(0deg); +} + +.event-card__media { + position: relative; + border: 4px solid var(--photo-frame); + box-shadow: var(--shadow-soft); + background: var(--photo-frame); + margin: 4px 8px 12px; + transform: rotate(-2deg); + overflow: hidden; +} + +.event-card.tilt-r .event-card__media, +.event-card.tilt-r2 .event-card__media { + transform: rotate(2deg); +} + +.event-card__media img { + width: 100%; + height: 180px; + object-fit: cover; + display: block; +} + +.event-card__date { + position: absolute; + top: 8px; + left: 8px; + background: var(--surface); + color: var(--ink); + border: 2.5px solid var(--ink); + border-radius: 4px; + padding: 2px 10px; + font-family: "Caveat", cursive; + font-size: 22px; + line-height: 1; + transform: rotate(-4deg); +} + +.event-card__body { + padding: 4px 8px 6px; +} + +.event-card__title { + font-family: "Caveat", cursive; + font-size: 26px; + line-height: 1; + margin: 0 0 6px; +} + +.event-card__venue { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 15px; + opacity: 0.7; +} + +.event-card__venue [data-lucide] { + width: 14px; + height: 14px; +} + +.event-card__meta { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 10px; + gap: 12px; +} + +.event-card__cta { + font-family: "Caveat", cursive; + font-size: 22px; + color: var(--accent); +} + +/* Past-event deprecation: faded + lies flat + diagonal PAST stamp. */ +.event-card.is-past { + opacity: 0.65; + transform: rotate(0deg); +} + +.event-card.is-past:hover { + transform: translateY(-2px) rotate(0deg); + opacity: 0.85; +} + +.event-card.is-past .event-card__media::after { + content: "PAST"; + position: absolute; + top: 38%; + left: 50%; + transform: translate(-50%, -50%) rotate(-12deg); + background: var(--ink); + color: var(--bg); + padding: 6px 18px; + font-family: "Caveat", cursive; + font-size: 36px; + letter-spacing: 0.15em; + border: 2.5px solid var(--bg); + box-shadow: 0 0 0 2.5px var(--ink); + pointer-events: none; +} + +/* ============================================================= + Featured banner — the giant escalate-soft card on the list + ============================================================= */ + +.featured-banner { + display: grid; + grid-template-columns: 1fr; + gap: 1.5rem; + background: var(--swatch-yellow); + border: 2.5px solid var(--ink); + border-radius: 6px; + box-shadow: var(--shadow); + padding: 1.5rem; + transform: rotate(-1deg); +} + +@media (min-width: 720px) { + .featured-banner { + grid-template-columns: 1.2fr 1fr; + padding: 2rem 2.5rem; + gap: 2rem; + } +} + +.featured-banner__title { + font-family: "Caveat", cursive; + font-size: 56px; + line-height: 0.95; + margin: 0.5rem 0 0; +} + +@media (min-width: 720px) { + .featured-banner__title { + font-size: 80px; + } +} + +.featured-banner__photo { + align-self: center; +} + +.featured-banner__photo img { + width: 100%; + height: 220px; + object-fit: cover; +} + +/* ============================================================= + Compact webinar row (image-less) + .box bg-white p-4 ; marker date 28px on left ; title + 2-line desc right + ============================================================= */ + +.webinar-row { + display: flex; + align-items: stretch; + gap: 1rem; + background: var(--surface); + border: 2.5px solid var(--ink); + border-radius: 6px; + box-shadow: var(--shadow); + padding: 1rem 1.1rem; + transform: rotate(-1deg); + opacity: 0.65; + transition: transform 0.2s, opacity 0.2s; +} + +.webinar-row:nth-child(2n) { + transform: rotate(1deg); +} + +.webinar-row:hover { + opacity: 0.95; + transform: translateY(-2px) rotate(0deg); +} + +.webinar-row__date { + flex-shrink: 0; + width: 64px; + text-align: center; + border-right: 2px solid var(--line-strong); + padding-right: 12px; +} + +.webinar-row__date .month { + font-family: "JetBrains Mono", monospace; + font-size: 10px; + letter-spacing: 0.2em; + text-transform: uppercase; + opacity: 0.6; +} + +.webinar-row__date .day { + font-family: "Caveat", cursive; + font-size: 32px; + line-height: 1; + margin-top: 4px; +} + +.webinar-row__body { + flex: 1; +} + +.webinar-row__title { + font-family: "Caveat", cursive; + font-size: 22px; + line-height: 1.05; + margin: 0; +} + +.webinar-row__desc { + font-size: 14px; + opacity: 0.75; + margin-top: 6px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.webinar-row__cta { + margin-top: 8px; + font-family: "Caveat", cursive; + font-size: 18px; + color: var(--accent); +} + +/* ============================================================= + Section header (Caveat 44px + small arrow link on right) + ============================================================= */ + +.section-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + margin: 4rem 0 1rem; +} + +.section-header__title, +.section-h2 { + font-family: "Caveat", cursive; + font-size: 44px; + line-height: 1; + margin: 0; + transform: rotate(-1deg); + display: inline-block; +} + +.section-header__title .star, +.section-h2 .star { + color: var(--accent); + margin-right: 4px; +} + +.section-header__link { + font-family: "Caveat", cursive; + font-size: 24px; + display: inline-flex; + align-items: baseline; + gap: 4px; + opacity: 0.85; +} + +.section-header__link:hover { + opacity: 1; +} + +/* ============================================================= + Category chips (.circle variants, big enough to tap) + ============================================================= */ + +.category-strip { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + margin-top: 0.5rem; +} + +@media (min-width: 720px) { + .category-strip { + grid-template-columns: repeat(4, 1fr); + } +} + +.category-chip { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 14px 18px; + border: 3px solid var(--accent); + border-radius: var(--organic-radius); + background: var(--surface); + transform: rotate(-2deg); + transition: transform 0.2s; +} + +.category-chip:nth-child(2n) { + transform: rotate(2deg); +} +.category-chip:nth-child(3n) { + transform: rotate(-1deg); +} +.category-chip:nth-child(4n) { + transform: rotate(1deg); +} + +.category-chip:hover { + transform: rotate(0deg) translateY(-2px); +} + +.category-chip__label { + font-family: "Caveat", cursive; + font-size: 26px; + line-height: 1; +} + +.category-chip__sub { + display: block; + font-family: "JetBrains Mono", monospace; + font-size: 10px; + letter-spacing: 0.2em; + text-transform: uppercase; + opacity: 0.55; + margin-top: 2px; +} + +.category-chip [data-lucide] { + width: 22px; + height: 22px; + color: var(--accent); + flex-shrink: 0; +} + +/* ============================================================= + Detail page bits + ============================================================= */ + +.display-h1 { + font-family: "Caveat", cursive; + font-size: 64px; + line-height: 0.92; + margin: 1.5rem 0 0; + transform: rotate(-1deg); +} + +@media (min-width: 720px) { + .display-h1 { + font-size: 120px; + } +} + +@media (min-width: 1024px) { + .display-h1 { + font-size: 160px; + } +} + +.display-h1 .accent { + color: var(--accent); +} + +/* "the day, in scribbles" — schedule (S5: handwritten list) */ +.programme { + margin: 1rem 0 0; + padding: 0; + list-style: none; + counter-reset: programme; +} + +.programme li { + font-family: "Patrick Hand", cursive; + font-size: 22px; + line-height: 1.5; + padding: 4px 0; +} + +.programme .num { + display: inline-block; + min-width: 32px; + font-family: "Caveat", cursive; + color: var(--accent); + opacity: 0.55; + margin-right: 4px; +} + +.programme .stime { + font-family: "Caveat", cursive; + font-size: 24px; + color: var(--accent); + margin-right: 4px; + display: inline-block; + min-width: 80px; +} + +.programme .arr { + margin: 0 6px; + color: var(--accent); +} + +.programme .spk { + margin-left: 8px; +} + +.programme .spk .name { + border: 2.5px solid var(--accent); + border-radius: var(--organic-radius); + padding: 0 8px; + display: inline-block; + line-height: 1.1; + transform: rotate(-2deg); + font-family: "Caveat", cursive; + font-size: 20px; +} + +.programme li.is-break { + font-family: "Caveat", cursive; + font-size: 26px; +} + +.programme li.is-highlight .stitle { + text-decoration: underline wavy var(--accent); + text-underline-offset: 4px; +} + +/* Speakers grid — SP2: hand-drawn ring headshots */ +.speakers-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 28px 18px; + margin-top: 0.75rem; +} + +@media (min-width: 720px) { + .speakers-grid { + grid-template-columns: repeat(4, 1fr); + } +} + +.speaker-card { + text-align: center; +} + +.speaker-card__ring { + width: 110px; + height: 110px; + margin: 0 auto; + border: 3px solid var(--accent); + border-radius: var(--organic-radius); + overflow: hidden; + background: var(--bg); + padding: 4px; + display: flex; + align-items: center; + justify-content: center; +} + +.speaker-card:nth-child(odd) .speaker-card__ring { + transform: rotate(-3deg); +} + +.speaker-card:nth-child(even) .speaker-card__ring { + transform: rotate(3deg); +} + +.speaker-card__ring img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: var(--organic-radius); +} + +.speaker-card__initials { + font-family: "Caveat", cursive; + font-size: 48px; + color: var(--accent); + line-height: 1; +} + +.speaker-card__name { + font-family: "Caveat", cursive; + font-size: 24px; + margin-top: 12px; + line-height: 1.1; +} + +.speaker-card__role { + font-family: "Patrick Hand", cursive; + font-size: 14px; + opacity: 0.7; + margin-top: 2px; + padding: 0 6px; + line-height: 1.2; +} + +/* Pricing tiers */ +.pricing-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; + margin-top: 1rem; +} + +@media (min-width: 720px) { + .pricing-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +.pricing-card { + background: var(--surface); + border: 2.5px solid var(--ink); + border-radius: 6px; + box-shadow: var(--shadow); + padding: 1.25rem 1rem; + text-align: center; +} +.pricing-card:nth-child(1) { + transform: rotate(-1deg); +} +.pricing-card:nth-child(2) { + transform: rotate(0deg); +} +.pricing-card:nth-child(3) { + transform: rotate(1deg); +} + +.pricing-card.is-featured { + background: var(--swatch-yellow); +} + +.pricing-card__tier { + font-family: "Caveat", cursive; + font-size: 22px; + color: var(--accent); +} + +.pricing-card__price { + font-family: "Caveat", cursive; + font-size: 72px; + line-height: 1; + margin-top: 4px; +} + +.pricing-card__note { + font-size: 14px; + opacity: 0.7; + margin-top: 4px; +} + +.pricing-card__bullets { + list-style: none; + padding: 0; + margin: 12px 0 0; + font-size: 15px; + opacity: 0.85; +} + +.pricing-card__bullets li { + padding: 2px 0; +} + +/* Sticky mobile CTA (Alpine x-show) */ +.sticky-cta { + position: fixed; + bottom: 12px; + left: 12px; + right: 12px; + z-index: 80; + display: flex; + gap: 8px; + padding: 8px; + background: var(--surface); + border: 2.5px solid var(--ink); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.sticky-cta a { + flex: 1; + text-align: center; + font-family: "Caveat", cursive; + font-size: 22px; + line-height: 1; + padding: 10px 12px; + border-radius: 4px; + border: 2px solid var(--ink); +} + +.sticky-cta a.primary { + background: var(--cta-bg); + color: var(--cta-fg); +} + +.sticky-cta a.secondary { + background: var(--surface); + color: var(--ink); +} + +@media (min-width: 720px) { + .sticky-cta { + display: none; + } +} + +[x-cloak] { + display: none !important; +} + +/* ============================================================= + 404 — "this page got scribbled out" + ============================================================= */ + +.notfound { + padding: 4rem 1rem; + text-align: center; +} + +.notfound__box { + position: relative; + max-width: 480px; + margin: 2rem auto; + background: var(--surface); + border: 2.5px solid var(--ink); + border-radius: 6px; + box-shadow: var(--shadow); + padding: 3rem 1.5rem; + transform: rotate(-1deg); +} + +.notfound__box::before, +.notfound__box::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient( + to bottom right, + transparent calc(50% - 2px), + var(--accent) calc(50% - 2px), + var(--accent) calc(50% + 2px), + transparent calc(50% + 2px) + ); + opacity: 0.85; +} + +.notfound__box::after { + background: linear-gradient( + to top right, + transparent calc(50% - 2px), + var(--accent) calc(50% - 2px), + var(--accent) calc(50% + 2px), + transparent calc(50% + 2px) + ); +} + +.notfound__big { + font-family: "Caveat", cursive; + font-size: 96px; + line-height: 1; + margin: 0; +} + +.notfound__note { + font-family: "Caveat", cursive; + font-size: 28px; + margin-top: 1rem; + opacity: 0.75; +} + +/* ============================================================= + Print: ticket-page mode for the detail hero + Strategy (b): print the masthead + giant H1 + when/where blocks only. + ============================================================= */ + +@media print { + body { + background: #fff; + color: #000; + } + .no-print, + .theme-toggle, + .sticky-cta, + .site-footer, + .print-hide-below { + display: none !important; + } + .page-shell { + padding: 1.5rem; + max-width: 100%; + } + .box, + .featured-banner, + .event-card, + .speaker-card, + .pricing-card, + .webinar-row { + box-shadow: none; + transform: none; + } +} + +/* Utility */ +.ink-border-b { + border-bottom: 2.5px solid var(--ink); +} + +/* ============================================================= + Dark-mode overrides for built-in Tailwind palette swatches. + Lets templates use bg-yellow-200 etc. naturally; dark adapts. + ============================================================= */ + +html[data-theme="dark"] .bg-white { background: #2a221c !important; } +html[data-theme="dark"] .bg-yellow-200 { background: #5a4a1a !important; } +html[data-theme="dark"] .bg-pink-200 { background: #5a1a3a !important; } +html[data-theme="dark"] .bg-blue-200 { background: #1a2a5a !important; } +html[data-theme="dark"] .bg-green-200 { background: #1a4a2a !important; } +html[data-theme="dark"] .bg-orange-200 { background: #5a3a1a !important; } +html[data-theme="dark"] .bg-purple-200 { background: #3a1a5a !important; } +html[data-theme="dark"] .border-black\/30 { border-color: rgba(255, 255, 255, 0.2) !important; } +html[data-theme="dark"] .text-\[\#b34a00\] { color: #ff8a4a !important; } +html[data-theme="dark"] .bg-\[\#b34a00\] { background: #ff8a4a !important; color: #1a1612 !important; } + +/* Login dialog (.box-based) */ +.login-backdrop { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} +.login-backdrop__shade { + position: absolute; + inset: 0; + background: rgba(13, 13, 13, 0.55); +} +html[data-theme="dark"] .login-backdrop__shade { + background: rgba(0, 0, 0, 0.65); +} +.login-card { + position: relative; + background: var(--surface); + border: 2.5px solid var(--ink); + border-radius: 6px; + box-shadow: var(--shadow); + transform: rotate(-1deg); + max-width: 28rem; + width: 100%; + padding: 1.75rem; + z-index: 10; +} +.login-card__close { + position: absolute; + top: 8px; + right: 10px; + background: transparent; + border: 0; + color: inherit; + opacity: 0.6; + cursor: pointer; + font-family: "Caveat", cursive; + font-size: 28px; + line-height: 1; + width: 36px; + height: 36px; +} +.login-card__close:hover { opacity: 1; } +.login-input { + width: 100%; + background: var(--bg); + border: 2px solid var(--ink); + border-radius: 4px; + padding: 10px 12px; + font-family: "Patrick Hand", cursive; + font-size: 18px; + color: var(--ink); + outline: none; + transition: border-color 0.2s; +} +.login-input:focus { border-color: var(--accent); } +.login-input::placeholder { opacity: 0.45; } +.login-label { + font-family: "JetBrains Mono", monospace; + font-size: 10px; + letter-spacing: 0.25em; + text-transform: uppercase; + opacity: 0.6; + display: block; + margin-bottom: 4px; +} +.login-btn { + width: 100%; + background: var(--cta-bg); + color: var(--cta-fg); + border: 2.5px solid var(--ink); + border-radius: 6px; + padding: 12px; + font-family: "Caveat", cursive; + font-size: 28px; + line-height: 1; + cursor: pointer; + transition: background 0.2s; +} +.login-btn:hover { background: var(--cta-hover); } +.login-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.login-link { + font-family: "Caveat", cursive; + font-size: 20px; + color: var(--accent); + text-decoration: underline wavy var(--accent); + text-underline-offset: 4px; + background: transparent; + border: 0; + cursor: pointer; +} +.login-provider { + width: 100%; + background: var(--surface); + border: 2.5px solid var(--ink); + border-radius: 6px; + padding: 10px 12px; + font-family: "Caveat", cursive; + font-size: 22px; + cursor: pointer; + transition: background 0.2s; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; +} +.login-provider:hover { background: var(--bg); } +.login-error, +.login-success { + border: 2px solid var(--accent); + border-radius: 6px; + padding: 8px 10px; + font-size: 14px; + margin-bottom: 10px; +} +.login-divider { + display: flex; + align-items: center; + gap: 8px; + margin: 12px 0; + font-family: "JetBrains Mono", monospace; + font-size: 10px; + letter-spacing: 0.25em; + text-transform: uppercase; + opacity: 0.55; +} +.login-divider::before, +.login-divider::after { + content: ""; + flex: 1; + border-top: 2px dashed var(--line-strong); +} diff --git a/buzz/themes/sketchbook_theme/tailwind.input.css b/buzz/themes/sketchbook_theme/tailwind.input.css new file mode 100644 index 00000000..d990c7c1 --- /dev/null +++ b/buzz/themes/sketchbook_theme/tailwind.input.css @@ -0,0 +1,6 @@ +@source "./pages/**/*.html"; +@source "./components/**/*.html"; + +@import "tailwindcss"; + +@import "./styles/tokens.css"; diff --git a/buzz/themes/stickerpack_theme/components/base.html b/buzz/themes/stickerpack_theme/components/base.html new file mode 100644 index 00000000..8fc10e84 --- /dev/null +++ b/buzz/themes/stickerpack_theme/components/base.html @@ -0,0 +1,38 @@ + + + + + {% include "components/head_top.html" %} + + + + + + {% set og_image %}{% block og_image %}{{ banner_image or '' }}{% endblock %}{% endset %} + {% if og_image | trim %}{% endif %} + + + + {% block title %}{{ title_prefix or app_name }}{% endblock %} + + {% include "components/head_bottom.html" %} + + + + skip to content + +
+ {% include "components/header.html" %} + + {% block body %}{% endblock %} + + {% include "components/footer.html" %} +
+ + {% block body_extra %}{% endblock %} + + {% if frappe.session.user == "Guest" %}{% include "components/login_dialog.html" %}{% endif %} + + {% include "components/scripts_tail.html" %} + + diff --git a/buzz/themes/stickerpack_theme/components/event_card.html b/buzz/themes/stickerpack_theme/components/event_card.html new file mode 100644 index 00000000..b0e3f794 --- /dev/null +++ b/buzz/themes/stickerpack_theme/components/event_card.html @@ -0,0 +1,36 @@ +{% import "components/macros/icons.html" as icons %} +{% import "components/macros/accents.html" as accents %} +{% set is_past = event.is_past %} +{% set href = event.recap_url if (is_past and event.recap_url) else ("/events/" ~ event.route) %} + + +
+ {% if event.image %} + + {% else %} +
+ {% endif %} + {% if event.date_short %} + {{ event.date_short }} + {% endif %} + {% if is_past %} + PAST + {% endif %} +
+
+
{{ event.title }}
+ {% if event.venue_short %} +
+ {{ icons.icon("map-pin", "w-3.5 h-3.5") }} + {{ event.venue_short }} +
+ {% endif %} +
+ {{ event.category_label }} + {% if is_past %}recap →{% else %}ticket →{% endif %} +
+
+
diff --git a/buzz/themes/stickerpack_theme/components/footer.html b/buzz/themes/stickerpack_theme/components/footer.html new file mode 100644 index 00000000..689153c4 --- /dev/null +++ b/buzz/themes/stickerpack_theme/components/footer.html @@ -0,0 +1,14 @@ +{% import "components/macros/branding.html" as branding with context %} +
+ {{ branding.brand(footer_logo or app_logo, "footer-logo") }} + {% if footer_items %} + {{ branding.footer_links() }} + {% endif %} + {% if copyright %}© {{ copyright }}{% endif %} +
+{% if footer_address %} + +{% endif %} +

+ thank you for joining us · powered by buzz +

diff --git a/buzz/themes/stickerpack_theme/components/head_bottom.html b/buzz/themes/stickerpack_theme/components/head_bottom.html new file mode 100644 index 00000000..12573f5e --- /dev/null +++ b/buzz/themes/stickerpack_theme/components/head_bottom.html @@ -0,0 +1,5 @@ + + + + + diff --git a/buzz/themes/stickerpack_theme/components/head_top.html b/buzz/themes/stickerpack_theme/components/head_top.html new file mode 100644 index 00000000..6c0f7b94 --- /dev/null +++ b/buzz/themes/stickerpack_theme/components/head_top.html @@ -0,0 +1,5 @@ + + + + + diff --git a/buzz/themes/stickerpack_theme/components/header.html b/buzz/themes/stickerpack_theme/components/header.html new file mode 100644 index 00000000..481b1ec9 --- /dev/null +++ b/buzz/themes/stickerpack_theme/components/header.html @@ -0,0 +1,29 @@ +{% import "components/macros/icons.html" as icons %} +{% import "components/macros/branding.html" as branding with context %} +
+ {{ branding.brand(app_logo, "brand-logo", "★ ") }} +
+ {% include "components/theme_toggle.html" %} + {% if frappe.session.user == "Guest" %} + + {% else %} + + {% endif %} +
+
diff --git a/buzz/themes/stickerpack_theme/components/login_dialog.html b/buzz/themes/stickerpack_theme/components/login_dialog.html new file mode 100644 index 00000000..ca16ba56 --- /dev/null +++ b/buzz/themes/stickerpack_theme/components/login_dialog.html @@ -0,0 +1,102 @@ + diff --git a/buzz/themes/stickerpack_theme/components/macros/accents.html b/buzz/themes/stickerpack_theme/components/macros/accents.html new file mode 100644 index 00000000..cee07205 --- /dev/null +++ b/buzz/themes/stickerpack_theme/components/macros/accents.html @@ -0,0 +1,23 @@ + +{% macro accent_style(token) -%} +{%- set accent_map = { + "primary": "var(--accent-primary)", + "hot": "var(--accent-hot)", + "warm": "var(--accent-warm)", + "cool": "var(--accent-cool)", + "bold": "var(--accent-bold)", + "ink": "var(--accent-ink)", +} -%} +{%- set dark_accents = ["primary", "hot", "bold", "ink"] -%} +{%- set bg = accent_map.get(token, "var(--surface)") -%} +{%- set fg = "#ffffff" if token in dark_accents else "#161616" -%} +background: {{ bg }}; color: {{ fg }}; +{%- endmacro %} + +{% macro rot_class(n) -%} +{%- if n and n < 0 -%} +-rotate-{{ -n }} +{%- elif n and n > 0 -%} +rotate-{{ n }} +{%- endif -%} +{%- endmacro %} diff --git a/buzz/themes/stickerpack_theme/components/scripts_tail.html b/buzz/themes/stickerpack_theme/components/scripts_tail.html new file mode 100644 index 00000000..864ee630 --- /dev/null +++ b/buzz/themes/stickerpack_theme/components/scripts_tail.html @@ -0,0 +1,2 @@ + + diff --git a/buzz/themes/stickerpack_theme/components/theme_toggle.html b/buzz/themes/stickerpack_theme/components/theme_toggle.html new file mode 100644 index 00000000..4e4da1f5 --- /dev/null +++ b/buzz/themes/stickerpack_theme/components/theme_toggle.html @@ -0,0 +1,4 @@ + diff --git a/buzz/themes/stickerpack_theme/components/webinar_row.html b/buzz/themes/stickerpack_theme/components/webinar_row.html new file mode 100644 index 00000000..73cfd9a5 --- /dev/null +++ b/buzz/themes/stickerpack_theme/components/webinar_row.html @@ -0,0 +1,13 @@ + +
+
{{ webinar.date_month }}
+
{{ webinar.date_day }}
+
+
+

{{ webinar.title }}

+ {% if webinar.blurb %}

{{ webinar.blurb }}

{% endif %} + recording → +
+
diff --git a/buzz/themes/stickerpack_theme/pages/404.html b/buzz/themes/stickerpack_theme/pages/404.html new file mode 100644 index 00000000..354a5428 --- /dev/null +++ b/buzz/themes/stickerpack_theme/pages/404.html @@ -0,0 +1,20 @@ +{% extends "components/base.html" %} + +{% block title %}Not found — {{ app_name }}{% endblock %} +{% block og_title %}Not found — {{ app_name }}{% endblock %} +{% block og_description %}That sticker peeled off.{% endblock %} + +{% block body %} +
+
+
+ empty square +
+ STICKER MISSING + 404 +
+

page peeled off!

+

That URL doesn't lead anywhere we know. Try the home page.

+ ★ BACK HOME → +
+{% endblock %} diff --git a/buzz/themes/stickerpack_theme/pages/category/detail.html b/buzz/themes/stickerpack_theme/pages/category/detail.html new file mode 100644 index 00000000..d6fab2aa --- /dev/null +++ b/buzz/themes/stickerpack_theme/pages/category/detail.html @@ -0,0 +1,165 @@ +{% extends "components/base.html" %} +{% import "components/macros/accents.html" as accents %} + +{% set today = frappe.utils.today() %} +{% set category_name = frappe.db.get_value("Event Category", {"slug": category_slug, "enabled": 1}, "name") %} +{% if not category_name %} + {% set not_found = frappe.throw(_("Category not found"), frappe.PageDoesNotExistError) %} +{% endif %} +{% set category = frappe.get_cached_doc("Event Category", category_name) %} + +{% set all_categories = frappe.get_all("Event Category", + filters={"enabled": 1}, + fields=["name", "slug"], + order_by="name asc") %} + +{% set all_events = frappe.get_all("Buzz Event", + filters={"is_published": 1, "category": category_name}, + fields=["name", "title", "route", "start_date", "venue", + "card_image", "banner_image", "short_description"], + order_by="start_date desc") %} + +{% set venue_names = all_events | map(attribute='venue') | reject('none') | list | unique | list %} +{% set venues = frappe.get_all("Event Venue", + filters={"name": ["in", venue_names]}, + fields=["name", "address"]) if venue_names else [] %} +{% set venue_map = {} %} +{% for venue in venues %} + {% set _ = venue_map.update({venue.name: venue.address}) %} +{% endfor %} + +{% set upcoming = [] %} +{% set past = [] %} +{% for event in all_events %} + {% if event.start_date and (event.start_date|string) < today %} + {% set _ = past.append(event) %} + {% else %} + {% set _ = upcoming.append(event) %} + {% endif %} +{% endfor %} + +{% set accent_cycle = ['warm', 'hot', 'cool', 'ink', 'primary', 'bold'] %} +{% set rot_cycle = [1, -1, 2, -2, 3, -3] %} +{% set category_slug_lower = (category.slug or '') | lower %} +{% set is_webinars = category_slug_lower == 'webinars' %} + +{% block title %}{{ category.name }} — {{ app_name }}{% endblock %} +{% block og_title %}{{ category.name }} — {{ app_name }}{% endblock %} +{% block og_description %}{{ category.description or category.name }}{% endblock %} +{% block og_url %}{{ frappe.utils.get_url() }}/category/{{ category.slug }}{% endblock %} + +{% block body %} + +
+
+ {{ category.name | upper }} + {{ all_events | length }} EVENTS +
+ +

+ {{ category.name | lower }}
+ archive! +

+ + {% if category.description %} +

{{ category.description }}

+ {% endif %} +
+ +{% if upcoming %} +
+
+

★ upcoming

+ {{ upcoming | length }} {{ 'stop' if upcoming | length == 1 else 'stops' }} +
+
+ {% for event_row in upcoming %} + {% if is_webinars %} + {% set webinar = { + 'route': event_row.route, + 'title': event_row.title, + 'blurb': event_row.short_description, + 'date_month': event_row.start_date.strftime('%b').upper() if event_row.start_date else '', + 'date_day': event_row.start_date.strftime('%d') if event_row.start_date else '' + } %} + {% include "components/webinar_row.html" %} + {% else %} + {% set event = { + 'route': event_row.route, + 'title': event_row.title, + 'image': event_row.card_image or event_row.banner_image, + 'date_short': event_row.start_date.strftime('%d %b').upper() if event_row.start_date else '', + 'venue_short': venue_map.get(event_row.venue, '').split(',')[0] if venue_map.get(event_row.venue, '') else '', + 'category_label': category.name.upper(), + 'accent': accent_cycle[loop.index0 % 6], + 'rot': rot_cycle[loop.index0 % 6], + 'is_past': False + } %} + {% include "components/event_card.html" %} + {% endif %} + {% endfor %} +
+
+{% endif %} + +{% if past %} +
+
+

past {{ category.name | lower }}

+ {{ past | length }} +
+
+ {% for event_row in past %} + {% if is_webinars %} + {% set webinar = { + 'route': event_row.route, + 'title': event_row.title, + 'blurb': event_row.short_description, + 'date_month': event_row.start_date.strftime('%b').upper() if event_row.start_date else '', + 'date_day': event_row.start_date.strftime('%d') if event_row.start_date else '' + } %} + {% include "components/webinar_row.html" %} + {% else %} + {% set event = { + 'route': event_row.route, + 'title': event_row.title, + 'image': event_row.card_image or event_row.banner_image, + 'date_short': event_row.start_date.strftime('%d %b').upper() if event_row.start_date else '', + 'venue_short': venue_map.get(event_row.venue, '').split(',')[0] if venue_map.get(event_row.venue, '') else '', + 'category_label': category.name.upper(), + 'accent': 'ink', + 'rot': 0, + 'is_past': True + } %} + {% include "components/event_card.html" %} + {% endif %} + {% endfor %} +
+
+{% endif %} + +{% if not upcoming and not past %} +
+
+
nothing here
yet ★
+

No {{ category.name | lower }} on the calendar. Watch this space.

+
+
+{% endif %} + +
+
★ or try
+
+ {% for category_link in all_categories %} + {% if category_link.slug != category.slug %} + + {{ category_link.name | upper }} + + {% endif %} + {% endfor %} +
+
+ +{% endblock %} diff --git a/buzz/themes/stickerpack_theme/pages/events/detail.html b/buzz/themes/stickerpack_theme/pages/events/detail.html new file mode 100644 index 00000000..753d2995 --- /dev/null +++ b/buzz/themes/stickerpack_theme/pages/events/detail.html @@ -0,0 +1,313 @@ +{% extends "components/base.html" %} +{% import "components/macros/icons.html" as icons %} +{% import "components/macros/accents.html" as accents %} +{% import "components/macros/maps.html" as maps %} + +{% set event_name = frappe.db.get_value("Buzz Event", {"route": event_route, "is_published": 1}, "name") %} +{% if not event_name %} + {% set not_found = frappe.throw(_("Event not found"), frappe.PageDoesNotExistError) %} +{% endif %} +{% set event = frappe.get_cached_doc("Buzz Event", event_name) %} +{% set venue = frappe.get_cached_doc("Event Venue", event.venue) if event.venue else None %} +{% set host = frappe.get_cached_doc("Event Host", event.host) if event.host else None %} +{% set category = frappe.get_cached_doc("Event Category", event.category) if event.category else None %} +{% set sponsors = frappe.get_all("Event Sponsor", filters={"event": event.name}, + fields=["company_name", "tier", "company_logo", "website", "country"]) %} + +{% set today = frappe.utils.today() %} +{% set is_past = event.start_date and (event.start_date|string) < today %} + +{% set talk_names = event.schedule | selectattr("type", "equalto", "Talk") + | map(attribute="talk") | reject("none") | list | unique | list %} +{% set talk_title_map = {} %} +{% set first_speaker_map = {} %} +{% if talk_names %} + {% for talk in frappe.get_all("Event Talk", filters={"name": ["in", talk_names]}, fields=["name", "title"]) %} + {% set _ = talk_title_map.update({talk.name | string: talk.title}) %} + {% endfor %} + {% for row in frappe.get_all("Talk Speaker", filters={"parenttype": "Event Talk", "parent": ["in", talk_names]}, + fields=["parent", "speaker"], order_by="parent asc, idx asc") %} + {% if (row.parent | string) not in first_speaker_map %}{% set _ = first_speaker_map.update({row.parent | string: row.speaker}) %}{% endif %} + {% endfor %} +{% endif %} + +{% set speaker_names = ((first_speaker_map.values() | list) + + (event.featured_speakers | map(attribute="speaker") | reject("none") | list)) | unique | list %} +{% set speaker_map = {} %} +{% if speaker_names %} + {% for speaker in frappe.get_all("Speaker Profile", filters={"name": ["in", speaker_names]}, + fields=["name", "display_name", "designation", "company", "display_image"]) %} + {% set _ = speaker_map.update({speaker.name | string: speaker}) %} + {% endfor %} +{% endif %} + +{% set schedule_items = [] %} +{% for session in event.schedule %} + {% set session_title = '' %} + {% set session_speaker = '' %} + {% if session.type == 'Talk' and session.talk %} + {% set session_title = talk_title_map.get(session.talk | string) or '' %} + {% set first_speaker = first_speaker_map.get(session.talk | string) %} + {% set speaker_profile = speaker_map.get(first_speaker | string) if first_speaker else None %} + {% set session_speaker = (speaker_profile.display_name or '') if speaker_profile else '' %} + {% elif session.type == 'Break' %} + {% set session_title = session.description or 'Break' %} + {% else %} + {% set session_title = session.description or '' %} + {% endif %} + {% set _ = schedule_items.append({ + "start_time": frappe.utils.get_time(session.start_time).strftime('%H:%M') if session.start_time else '', + "end_time": frappe.utils.get_time(session.end_time).strftime('%H:%M') if session.end_time else '', + "title": session_title, + "speaker_name": session_speaker, + "is_break": session.type == 'Break' + }) %} +{% endfor %} + +{% set featured_speakers = [] %} +{% for featured_speaker in event.featured_speakers %} + {% set speaker_profile = speaker_map.get(featured_speaker.speaker | string) if featured_speaker.speaker else None %} + {% if speaker_profile %} + {% set role_parts = [] %} + {% if speaker_profile.designation %}{% set _ = role_parts.append(speaker_profile.designation) %}{% endif %} + {% if speaker_profile.company %}{% set _ = role_parts.append(speaker_profile.company) %}{% endif %} + {% set _ = featured_speakers.append({ + "name": speaker_profile.display_name or '', + "role": role_parts|join(', '), + "image_url": speaker_profile.display_image + }) %} + {% endif %} +{% endfor %} + +{% set accent_cycle = ['warm', 'hot', 'cool', 'ink', 'primary', 'bold'] %} +{% set rot_cycle = [1, -1, 2, -2, 3, -3] %} +{% set category_label = (category.name|upper) if category else 'EVENT' %} +{% set date_full = event.start_date.strftime('%a, %d %b %Y') if event.start_date else 'TBA' %} +{% set start_time_display = frappe.utils.get_time(event.start_time).strftime('%H:%M') if event.start_time else '' %} +{% set time_zone_label = event.time_zone_label or (event.time_zone.split('/')|last if event.time_zone else '') %} +{% set is_webinar = event.medium == 'Online' %} +{% set partners_heading = 'hosted & sponsored by' if (host and sponsors) else ('sponsored by' if sponsors else 'hosted by') %} +{% set hero_image = event.banner_image or event.card_image %} +{% set register_href = '/dashboard/book-tickets/' + (event.route or '') %} +{% set sponsor_href = '/dashboard/events/' + (event.route or '') + '/forms/enquire-sponsorship' %} + +{% block title %}{{ event.title }} — {{ app_name }}{% endblock %} +{% block og_type %}event{% endblock %} +{% block og_title %}{{ event.title }}{% endblock %} +{% block og_description %}{{ event.short_description or event.title }}{% endblock %} +{% block og_image %}{{ event.meta_image or event.banner_image or '' }}{% endblock %} +{% block og_url %}{{ frappe.utils.get_url() }}/events/{{ event.route }}{% endblock %} + +{% block body %} + +
+
+ {{ category_label }} + {% if is_past %}PAST EVENT{% endif %} +
+ +

{{ event.title }}

+ + {% if event.short_description %} +

{{ event.short_description }}

+ {% endif %} + +
+
+
★ what is this
+

{{ event.short_description or event.title }}

+
+
+
when ↓
+

{{ date_full }}{% if start_time_display %}
starts {{ start_time_display }}{% if time_zone_label %} {{ time_zone_label }}{% endif %}{% endif %}

+
+
+
where ↓
+

{% if venue %}{{ venue.name }}{% else %}online{% endif %}

+
+
+ + {% if hero_image %} +
+
+ {{ event.title }} banner + {% if venue %}
{{ venue.name }}
{% endif %} +
+
+
don't
be late!
+

{% if start_time_display %}Starts {{ start_time_display }}{% if time_zone_label %} {{ time_zone_label }}{% endif %}.{% endif %} Once sold out, that's it.

+
+
+ {% endif %} + + +
+ + + +

+ {% if is_past %}thanks for being there ★{% else %}seats are limited. don't dawdle ★{% endif %} +

+ +{% endblock %} + +{% block body_extra %} + +{% endblock %} diff --git a/buzz/themes/stickerpack_theme/pages/home.html b/buzz/themes/stickerpack_theme/pages/home.html new file mode 100644 index 00000000..0ff4ccbc --- /dev/null +++ b/buzz/themes/stickerpack_theme/pages/home.html @@ -0,0 +1,235 @@ +{% extends "components/base.html" %} +{% import "components/macros/accents.html" as accents %} + +{% block title %}{{ app_name }}{% endblock %} +{% block og_title %}{{ app_name }}{% endblock %} +{% block og_description %}Conferences, yatras, meetups, and webinars.{% endblock %} +{% block og_url %}{{ frappe.utils.get_url() }}/home{% endblock %} + +{% block body %} + +{% set today = frappe.utils.today() %} +{% set all_events = frappe.get_all("Buzz Event", + filters={"is_published": 1}, + fields=["name", "title", "route", "category", "start_date", "venue", + "card_image", "banner_image", "short_description", "medium"], + order_by="start_date asc") %} + +{% set venue_names = all_events | map(attribute='venue') | reject('none') | list | unique | list %} +{% set venues = frappe.get_all("Event Venue", + filters={"name": ["in", venue_names]}, + fields=["name", "address"]) if venue_names else [] %} +{% set venue_map = {} %} +{% for venue in venues %} + {% set _ = venue_map.update({venue.name: venue.address}) %} +{% endfor %} + +{% set categories = frappe.get_all("Event Category", + filters={"enabled": 1}, + fields=["name", "slug", "description", "icon_name", "subtitle"], + order_by="name asc") %} + +{% set upcoming_conferences = [] %} +{% set past_conferences = [] %} +{% set upcoming_yatra = [] %} +{% set past_webinars = [] %} +{% for event in all_events %} + {% set is_past = event.start_date and (event.start_date|string) < today %} + {% if event.category == 'Conferences' %} + {% if is_past %}{% set _ = past_conferences.append(event) %}{% else %}{% set _ = upcoming_conferences.append(event) %}{% endif %} + {% elif event.category == 'Yatra' and not is_past %} + {% set _ = upcoming_yatra.append(event) %} + {% elif event.category == 'Webinars' and is_past %} + {% set _ = past_webinars.append(event) %} + {% endif %} +{% endfor %} + +{% set featured = upcoming_conferences[0] if upcoming_conferences else None %} +{% set accent_cycle = ['warm', 'hot', 'cool', 'ink', 'primary', 'bold'] %} +{% set rot_cycle = [1, -1, 2, -2, 3, -3] %} + +{% set theme_config = buzz_theme_config() %} +{% set hero_words = (theme_config.hero_heading or '').split() or ['come', 'build', 'together!'] %} +{% set hero_chips = (theme_config.hero_chips or '26 SEASON\nOPEN SOURCE').splitlines() %} +{% set chip_styles = ['bg-[var(--accent-warm)] text-[#161616] rotate-2', 'bg-[var(--accent-bold)] text-white rotate-3', 'bg-[var(--accent-hot)] text-white -rotate-2'] %} + +
+

{{ app_name }}

+ +
+ {{ app_name | upper }} ★ + {{ all_events | length }} EVENTS + {% for chip in hero_chips %} + {% if chip | trim %} + {{ chip | trim }} + {% endif %} + {% endfor %} +
+ +

+ {% for word in hero_words[:-1] %}{{ word }}
{% endfor %} + {{ hero_words[-1] }} +

+ +

★ {{ theme_config.hero_tagline or 'conferences · yatras · meetups · webinars' }}

+
+ +{% if featured %} +{% set featured_image = featured.card_image or featured.banner_image %} +{% set featured_date_short = featured.start_date.strftime('%d %b').upper() if featured.start_date else '' %} +{% set featured_venue = venue_map.get(featured.venue, '') %} +
+ + + +
+
+ {% if featured_image %} + {{ featured.title }} + {% endif %} +
+
+
+
+ Featured conference + {% if featured_date_short %} + {{ featured_date_short }}{% if featured_venue %} · {{ featured_venue.split(',')[0] }}{% endif %} + {% endif %} +
+

{{ featured.title }}

+ {% if featured.short_description %} +

{{ featured.short_description }}

+ {% endif %} +
+
+
+
Category
+
{{ featured.category }}
+
+ view ticket → +
+
+
+
+
+{% endif %} + +{% if upcoming_yatra %} +
+
+

★ upcoming yatra

+ all yatras → +
+ +
+ {% for event_row in upcoming_yatra %} + {% set event = { + 'route': event_row.route, + 'title': event_row.title, + 'image': event_row.card_image or event_row.banner_image, + 'date_short': event_row.start_date.strftime('%d %b').upper() if event_row.start_date else '', + 'venue_short': venue_map.get(event_row.venue, '').split(',')[0] if venue_map.get(event_row.venue, '') else '', + 'category_label': 'YATRA', + 'accent': accent_cycle[loop.index0 % 6], + 'rot': rot_cycle[loop.index0 % 6], + 'is_past': False + } %} + {% include "components/event_card.html" %} + {% endfor %} +
+
+{% endif %} + +{% set upcoming_conf_rest = upcoming_conferences[1:] if featured else upcoming_conferences %} +{% if upcoming_conf_rest or not featured %} +
+
+

★ upcoming conferences

+ all conferences → +
+ +
+ {% for event_row in upcoming_conf_rest %} + {% set event = { + 'route': event_row.route, + 'title': event_row.title, + 'image': event_row.card_image or event_row.banner_image, + 'date_short': event_row.start_date.strftime('%d %b').upper() if event_row.start_date else '', + 'venue_short': venue_map.get(event_row.venue, '').split(',')[0] if venue_map.get(event_row.venue, '') else '', + 'category_label': 'CONFERENCE', + 'accent': accent_cycle[loop.index0 % 6], + 'rot': rot_cycle[loop.index0 % 6], + 'is_past': False + } %} + {% include "components/event_card.html" %} + {% endfor %} + +
+ 2026 · in planning +
more soon ↓
+
+
+
+{% endif %} + +
+

Browse by category

+
+ {% for category in categories %} + + {{ category.name | upper }} + + {% endfor %} +
+
+ +{% if past_conferences %} +
+
+

★ past conferences

+ all → +
+ +
+ {% for event_row in past_conferences %} + {% set event = { + 'route': event_row.route, + 'title': event_row.title, + 'image': event_row.card_image or event_row.banner_image, + 'date_short': event_row.start_date.strftime('%d %b').upper() if event_row.start_date else '', + 'venue_short': venue_map.get(event_row.venue, '').split(',')[0] if venue_map.get(event_row.venue, '') else '', + 'category_label': 'CONFERENCE', + 'accent': 'ink', + 'rot': 0, + 'is_past': True + } %} + {% include "components/event_card.html" %} + {% endfor %} +
+
+{% endif %} + +{% if past_webinars %} +
+
+

★ past webinars

+ all → +
+
+ {% for event_row in past_webinars %} + {% set webinar = { + 'route': event_row.route, + 'title': event_row.title, + 'blurb': event_row.short_description, + 'date_month': event_row.start_date.strftime('%b').upper() if event_row.start_date else '', + 'date_day': event_row.start_date.strftime('%d') if event_row.start_date else '' + } %} + {% include "components/webinar_row.html" %} + {% endfor %} +
+
+{% endif %} + +{% endblock %} diff --git a/buzz/themes/stickerpack_theme/styles/tokens.css b/buzz/themes/stickerpack_theme/styles/tokens.css new file mode 100644 index 00000000..a0dc4161 --- /dev/null +++ b/buzz/themes/stickerpack_theme/styles/tokens.css @@ -0,0 +1,321 @@ +/* Sticker Pack — design tokens (light + dark) */ + +:root { + --bg: #faf6ed; + --grid-dot: rgba(0, 0, 0, 0.07); + --surface: #ffffff; + --ink: #161616; + --ink-soft: rgba(22, 22, 22, 0.7); + --line: rgba(22, 22, 22, 0.15); + --line-strong: #161616; + + --cta-bg: #ff5722; + --cta-fg: #ffffff; + --cta-hover: #ff2d8b; + + --shadow-color: #161616; + --focus-ring: #ff5722; + + --accent-primary: #ff5722; /* orange */ + --accent-hot: #ff2d8b; /* pink */ + --accent-warm: #ffd400; /* yellow */ + --accent-cool: #22d3ee; /* cyan */ + --accent-bold: #a855f7; /* purple */ + --accent-ink: #161616; /* black */ + + --tape-bg: rgba(255, 220, 100, 0.85); +} + +html[data-theme="dark"] { + --bg: #000000; + --grid-dot: rgba(255, 255, 255, 0.04); + --surface: #0a0a0a; + --ink: #ffffff; + --ink-soft: rgba(255, 255, 255, 0.7); + --line: rgba(255, 255, 255, 0.15); + --line-strong: #ffffff; + + --shadow-color: #ffffff; + + /* OLED black tolerates saturated accents; only nudge warm/cool slightly */ + --accent-warm: #f5c500; + --accent-cool: #1abfd9; + --tape-bg: rgba(245, 197, 0, 0.85); +} + +/* ---------- typography ---------- */ +html, body { + background: var(--bg); + color: var(--ink); + font-family: 'Bricolage Grotesque', sans-serif; + background-image: radial-gradient(circle at 1px 1px, var(--grid-dot) 1px, transparent 0); + background-size: 20px 20px; +} + +.hand { font-family: 'Caveat', cursive; } +.mono { font-family: 'JetBrains Mono', monospace; } + +/* ---------- primitives ---------- */ + +.square { + background: var(--surface); + border: 3px solid var(--line-strong); + box-shadow: 4px 4px 0 var(--shadow-color); +} + +.sticker { + display: inline-block; + padding: 14px 22px; + border-radius: 999px; + font-weight: 800; + box-shadow: 4px 4px 0 var(--ink), 0 0 0 3px var(--ink) inset; +} + +.sticker--sm { + padding: 6px 12px; + font-size: 11px; + letter-spacing: 0.05em; +} + +.photo-tape { position: relative; } +.photo-tape::before { + content: ""; + position: absolute; + top: -10px; left: 50%; + transform: translateX(-50%) rotate(-2deg); + width: 100px; height: 22px; + background: var(--tape-bg); + z-index: 2; +} + +.label { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + letter-spacing: 0.25em; + text-transform: uppercase; + opacity: 0.6; +} + +.cta-btn { + display: block; + background: var(--cta-bg); + color: var(--cta-fg); + padding: 20px 40px; + text-align: center; + font-weight: 900; + font-size: 28px; + letter-spacing: 0.025em; + border: 3px solid var(--line-strong); + box-shadow: 4px 4px 0 var(--shadow-color); + transform: rotate(-1deg); + transition: background-color .15s ease, transform .15s ease; +} +.cta-btn:hover { background: var(--cta-hover); transform: rotate(0deg); } + +/* ---------- page chrome ---------- */ + +.page-shell { max-width: 1100px; margin: 0 auto; padding: 16px 24px 64px; } + +.rule-bar { + display: flex; align-items: center; justify-content: space-between; + padding: 8px 0 28px; +} +@media (max-width: 640px) { + .rule-bar { padding-right: 56px; } +} +.rule-bar .brand { + font-family: 'Caveat', cursive; + font-size: 40px; + line-height: 1; + color: var(--ink); +} + +.rule-bar .brand-logo { + height: 40px; + width: auto; + display: block; +} +.rule-bar .signin-link { + font-weight: 700; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.04em; + display: inline-flex; align-items: center; gap: 6px; + color: var(--ink); +} + +.site-footer { + margin-top: 64px; + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + letter-spacing: 0.25em; + text-transform: uppercase; + opacity: 0.6; + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: space-between; +} + +.site-footer .footer-links { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} + +.site-footer .footer-links a:hover { + opacity: 0.7; +} + +.site-footer .footer-logo { + height: 22px; + width: auto; + display: block; +} + +.site-footer-address { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + letter-spacing: 0.25em; + text-transform: uppercase; + opacity: 0.6; + font-style: normal; + white-space: pre-line; + text-align: center; + margin-top: 16px; +} + +/* ---------- theme toggle ---------- */ +.theme-toggle { + position: fixed; top: 16px; right: 16px; + width: 44px; height: 44px; + display: flex; align-items: center; justify-content: center; + background: var(--surface); + border: 3px solid var(--line-strong); + box-shadow: 4px 4px 0 var(--shadow-color); + border-radius: 999px; + color: var(--ink); + cursor: pointer; + z-index: 60; + transition: transform .15s ease; +} +.theme-toggle:hover { transform: rotate(-6deg); } +.theme-toggle .icon-sun { display: none; } +.theme-toggle .icon-moon { display: block; } +html[data-theme="dark"] .theme-toggle .icon-sun { display: block; } +html[data-theme="dark"] .theme-toggle .icon-moon { display: none; } + +/* ---------- accessibility ---------- */ +:focus-visible { + outline: 3px solid var(--focus-ring); + outline-offset: 3px; +} + +/* ---------- sticky CTA (detail page mobile) ---------- */ +.sticky-cta { + position: fixed; left: 12px; right: 12px; bottom: 12px; + z-index: 50; + display: flex; gap: 8px; +} +@media (min-width: 768px) { + .sticky-cta { display: none; } +} + +/* ---------- print ---------- */ +@media print { + .no-print, .theme-toggle, .sticky-cta, .print-hide-below { display: none !important; } + body { background: white !important; background-image: none !important; color: black; } + .square { box-shadow: none; border-width: 2px; } +} + +/* ---------- past-event deprecation ---------- */ +.is-past .square, +.square.is-past { + opacity: 0.72; + transform: none !important; + filter: saturate(0.7); +} +.is-past .square:hover, +.square.is-past:hover { + opacity: 0.92; +} + +/* ---------- schedule rail (S1) ---------- */ +.schedule-rail { position: relative; padding-left: 36px; } +.schedule-rail::before { + content: ""; position: absolute; left: 12px; top: 6px; bottom: 6px; + width: 4px; background: var(--ink); +} +.schedule-row { + position: relative; padding-bottom: 14px; + display: flex; align-items: center; gap: 14px; flex-wrap: wrap; +} +.schedule-row:last-child { padding-bottom: 0; } +.schedule-row::before { + content: ""; position: absolute; left: -30px; top: 6px; + width: 18px; height: 18px; border-radius: 50%; + background: var(--accent-warm); + border: 3px solid var(--ink); + box-shadow: 2px 2px 0 var(--shadow-color); +} +.schedule-row.is-break::before { background: var(--accent-primary); } +.schedule-time { + font-family: 'JetBrains Mono', monospace; font-weight: 700; font-size: 11px; + background: var(--ink); color: var(--surface); + padding: 6px 12px; border-radius: 999px; + letter-spacing: 0.08em; + box-shadow: 3px 3px 0 var(--shadow-color); + transform: rotate(-2deg); + white-space: nowrap; +} +.schedule-title { font-weight: 700; font-size: 16px; line-height: 1.2; } +.schedule-speaker { + font-family: 'Caveat', cursive; font-size: 20px; + opacity: 0.8; margin-left: 4px; +} +.schedule-row.is-break .schedule-title { + color: var(--accent-primary); + font-family: 'Caveat', cursive; + font-size: 26px; font-weight: 700; + transform: rotate(-1deg); display: inline-block; +} + +/* ---------- speaker sticker-callout rows (SP3) ---------- */ +.speaker-grid { + display: grid; grid-template-columns: 1fr; gap: 16px; +} +@media (min-width: 700px) { .speaker-grid { grid-template-columns: repeat(2, 1fr); } } + +.speaker-row { + display: flex; align-items: center; gap: 16px; + padding: 8px 4px; +} +.speaker-avatar { + width: 56px; height: 56px; border-radius: 50%; + object-fit: cover; flex-shrink: 0; + border: 3px solid var(--ink); + box-shadow: 3px 3px 0 var(--shadow-color); + background: var(--accent-warm); +} +.speaker-name-pill { + display: inline-block; + font-weight: 800; font-size: 13px; + padding: 5px 11px; + border-radius: 999px; + background: var(--accent-primary); color: white; + box-shadow: 3px 3px 0 var(--ink), 0 0 0 3px var(--ink) inset; + transform: rotate(-2deg); + line-height: 1.1; +} +.speaker-row:nth-child(4n+1) .speaker-name-pill { background: var(--accent-primary); color: #ffffff; } +.speaker-row:nth-child(4n+2) .speaker-name-pill { background: var(--accent-warm); color: #161616; } +.speaker-row:nth-child(4n+3) .speaker-name-pill { background: var(--accent-cool); color: #161616; } +.speaker-row:nth-child(4n) .speaker-name-pill { background: var(--accent-bold); color: #ffffff; } +.speaker-role { + font-family: 'Caveat', cursive; font-size: 20px; + margin-top: 4px; line-height: 1.1; opacity: 0.85; +} + +/* ---------- utilities ---------- */ +[x-cloak] { display: none !important; } diff --git a/buzz/themes/stickerpack_theme/tailwind.input.css b/buzz/themes/stickerpack_theme/tailwind.input.css new file mode 100644 index 00000000..d990c7c1 --- /dev/null +++ b/buzz/themes/stickerpack_theme/tailwind.input.css @@ -0,0 +1,6 @@ +@source "./pages/**/*.html"; +@source "./components/**/*.html"; + +@import "tailwindcss"; + +@import "./styles/tokens.css"; diff --git a/buzz/themes/yarn.lock b/buzz/themes/yarn.lock new file mode 100644 index 00000000..573dcb31 --- /dev/null +++ b/buzz/themes/yarn.lock @@ -0,0 +1,468 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@emnapi/core@^1.11.1": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.11.3.tgz#5e95348a42cd1e06f0b9aa380cd74091daa4d520" + integrity sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg== + dependencies: + "@emnapi/wasi-threads" "1.2.3" + tslib "^2.4.0" + +"@emnapi/runtime@^1.11.1": + version "1.11.3" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.11.3.tgz#84257ae3b0531eb2aec1ffa23d70700da007ba95" + integrity sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.2.3", "@emnapi/wasi-threads@^1.2.2": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz#c9bf72fd4be5b928aee894820e8d814ed73916e9" + integrity sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g== + dependencies: + tslib "^2.4.0" + +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.13" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/remapping@^2.3.5": + version "2.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1" + integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@jridgewell/trace-mapping@^0.3.24": + version "0.3.31" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@napi-rs/wasm-runtime@^1.1.4": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz#c70706532e5827c0932ca6bf43ee2c512f29c639" + integrity sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw== + dependencies: + "@tybys/wasm-util" "^0.10.3" + +"@parcel/watcher-android-arm64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz#507f836d7e2042f798c7d07ad19c3546f9848ac1" + integrity sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA== + +"@parcel/watcher-darwin-arm64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz#3d26dce38de6590ef79c47ec2c55793c06ad4f67" + integrity sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw== + +"@parcel/watcher-darwin-x64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz#99f3af3869069ccf774e4ddfccf7e64fd2311ef8" + integrity sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg== + +"@parcel/watcher-freebsd-x64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz#14d6857741a9f51dfe51d5b08b7c8afdbc73ad9b" + integrity sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ== + +"@parcel/watcher-linux-arm-glibc@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz#43c3246d6892381db473bb4f663229ad20b609a1" + integrity sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA== + +"@parcel/watcher-linux-arm-musl@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz#663750f7090bb6278d2210de643eb8a3f780d08e" + integrity sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q== + +"@parcel/watcher-linux-arm64-glibc@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz#ba60e1f56977f7e47cd7e31ad65d15fdcbd07e30" + integrity sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w== + +"@parcel/watcher-linux-arm64-musl@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz#f7fbcdff2f04c526f96eac01f97419a6a99855d2" + integrity sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg== + +"@parcel/watcher-linux-x64-glibc@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz#4d2ea0f633eb1917d83d483392ce6181b6a92e4e" + integrity sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A== + +"@parcel/watcher-linux-x64-musl@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz#277b346b05db54f55657301dd77bdf99d63606ee" + integrity sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg== + +"@parcel/watcher-win32-arm64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz#7e9e02a26784d47503de1d10e8eab6cceb524243" + integrity sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw== + +"@parcel/watcher-win32-ia32@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz#2d0f94fa59a873cdc584bf7f6b1dc628ddf976e6" + integrity sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ== + +"@parcel/watcher-win32-x64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz#ae52693259664ba6f2228fa61d7ee44b64ea0947" + integrity sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA== + +"@parcel/watcher@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.5.1.tgz#342507a9cfaaf172479a882309def1e991fb1200" + integrity sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg== + dependencies: + detect-libc "^1.0.3" + is-glob "^4.0.3" + micromatch "^4.0.5" + node-addon-api "^7.0.0" + optionalDependencies: + "@parcel/watcher-android-arm64" "2.5.1" + "@parcel/watcher-darwin-arm64" "2.5.1" + "@parcel/watcher-darwin-x64" "2.5.1" + "@parcel/watcher-freebsd-x64" "2.5.1" + "@parcel/watcher-linux-arm-glibc" "2.5.1" + "@parcel/watcher-linux-arm-musl" "2.5.1" + "@parcel/watcher-linux-arm64-glibc" "2.5.1" + "@parcel/watcher-linux-arm64-musl" "2.5.1" + "@parcel/watcher-linux-x64-glibc" "2.5.1" + "@parcel/watcher-linux-x64-musl" "2.5.1" + "@parcel/watcher-win32-arm64" "2.5.1" + "@parcel/watcher-win32-ia32" "2.5.1" + "@parcel/watcher-win32-x64" "2.5.1" + +"@tailwindcss/cli@^4.0.14": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/cli/-/cli-4.3.3.tgz#763edd474c2f43ce82cdcedc80c0b3b23a86ba6f" + integrity sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw== + dependencies: + "@parcel/watcher" "2.5.1" + "@tailwindcss/node" "4.3.3" + "@tailwindcss/oxide" "4.3.3" + enhanced-resolve "^5.24.1" + mri "^1.2.0" + picocolors "^1.1.1" + tailwindcss "4.3.3" + +"@tailwindcss/node@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/node/-/node-4.3.3.tgz#38ff04309ff036ea3589a7bad9069c44ec9d3883" + integrity sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg== + dependencies: + "@jridgewell/remapping" "^2.3.5" + enhanced-resolve "^5.24.1" + jiti "^2.7.0" + lightningcss "1.32.0" + magic-string "^0.30.21" + source-map-js "^1.2.1" + tailwindcss "4.3.3" + +"@tailwindcss/oxide-android-arm64@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz#848f93034155daf7892185028dfb18143bfc7d07" + integrity sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw== + +"@tailwindcss/oxide-darwin-arm64@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz#5c779e32c1c361beb75136fd8e2c627fcb9a5b28" + integrity sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw== + +"@tailwindcss/oxide-darwin-x64@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz#ba292ff52fa3264139f7fe7874719ce0a4666875" + integrity sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw== + +"@tailwindcss/oxide-freebsd-x64@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz#07e589487b9a636235a4ade48cefc1f9eeeec57f" + integrity sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw== + +"@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz#0c8abc228d9e19e0065ddb707eba52e21855b3ff" + integrity sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ== + +"@tailwindcss/oxide-linux-arm64-gnu@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz#5067dc7afd15d2b97adc77a91177dc4bec8d1b8b" + integrity sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w== + +"@tailwindcss/oxide-linux-arm64-musl@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz#29ae5f279be2ce64db368711985b6ad8e4562e57" + integrity sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA== + +"@tailwindcss/oxide-linux-x64-gnu@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz#7d693b40f69875744b499481359b227fd3ac3a3c" + integrity sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w== + +"@tailwindcss/oxide-linux-x64-musl@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz#6a55d664f47c5ff9ad3f46bf05fe07d410b8cfd8" + integrity sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img== + +"@tailwindcss/oxide-wasm32-wasi@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz#408a9bc620e68f1aaf0dfea33da7bd3b65f9e2ef" + integrity sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ== + dependencies: + "@emnapi/core" "^1.11.1" + "@emnapi/runtime" "^1.11.1" + "@emnapi/wasi-threads" "^1.2.2" + "@napi-rs/wasm-runtime" "^1.1.4" + "@tybys/wasm-util" "^0.10.2" + tslib "^2.8.1" + +"@tailwindcss/oxide-win32-arm64-msvc@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz#3d582b00180fa4680e0b6c90be69fa5f4a209092" + integrity sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ== + +"@tailwindcss/oxide-win32-x64-msvc@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz#ce4c663fef3b4fde67611a4c1391866f8c0aa79b" + integrity sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw== + +"@tailwindcss/oxide@4.3.3": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide/-/oxide-4.3.3.tgz#6266109d025cfcb04f8e4c58954a6f630a415b7f" + integrity sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA== + optionalDependencies: + "@tailwindcss/oxide-android-arm64" "4.3.3" + "@tailwindcss/oxide-darwin-arm64" "4.3.3" + "@tailwindcss/oxide-darwin-x64" "4.3.3" + "@tailwindcss/oxide-freebsd-x64" "4.3.3" + "@tailwindcss/oxide-linux-arm-gnueabihf" "4.3.3" + "@tailwindcss/oxide-linux-arm64-gnu" "4.3.3" + "@tailwindcss/oxide-linux-arm64-musl" "4.3.3" + "@tailwindcss/oxide-linux-x64-gnu" "4.3.3" + "@tailwindcss/oxide-linux-x64-musl" "4.3.3" + "@tailwindcss/oxide-wasm32-wasi" "4.3.3" + "@tailwindcss/oxide-win32-arm64-msvc" "4.3.3" + "@tailwindcss/oxide-win32-x64-msvc" "4.3.3" + +"@tybys/wasm-util@^0.10.2", "@tybys/wasm-util@^0.10.3": + version "0.10.3" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz#015cba9e9dd47ce14d03d2a8c5d547bfb169665d" + integrity sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg== + dependencies: + tslib "^2.4.0" + +braces@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +detect-libc@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg== + +detect-libc@^2.0.3: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + +enhanced-resolve@^5.24.1: + version "5.24.5" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz#b4dad3255b7545f07ba5535189868e9f85f47573" + integrity sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.3.3" + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +graceful-fs@^4.2.4: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +jiti@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.7.0.tgz#974228f2f4ca2bc21885a1797b45fea68e950c64" + integrity sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ== + +lightningcss-android-arm64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968" + integrity sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg== + +lightningcss-darwin-arm64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz#50b71871b01c8199584b649e292547faea7af9b5" + integrity sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ== + +lightningcss-darwin-x64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz#35f3e97332d130b9ca181e11b568ded6aebc6d5e" + integrity sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w== + +lightningcss-freebsd-x64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz#9777a76472b64ed6ff94342ad64c7bafd794a575" + integrity sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig== + +lightningcss-linux-arm-gnueabihf@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz#13ae652e1ab73b9135d7b7da172f666c410ad53d" + integrity sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw== + +lightningcss-linux-arm64-gnu@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz#417858795a94592f680123a1b1f9da8a0e1ef335" + integrity sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ== + +lightningcss-linux-arm64-musl@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz#6be36692e810b718040802fd809623cffe732133" + integrity sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg== + +lightningcss-linux-x64-gnu@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz#0b7803af4eb21cfd38dd39fe2abbb53c7dd091f6" + integrity sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA== + +lightningcss-linux-x64-musl@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz#88dc8ba865ddddb1ac5ef04b0f161804418c163b" + integrity sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg== + +lightningcss-win32-arm64-msvc@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz#4f30ba3fa5e925f5b79f945e8cc0d176c3b1ab38" + integrity sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw== + +lightningcss-win32-x64-msvc@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz#141aa5605645064928902bb4af045fa7d9f4220a" + integrity sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q== + +lightningcss@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.32.0.tgz#b85aae96486dcb1bf49a7c8571221273f4f1e4a9" + integrity sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ== + dependencies: + detect-libc "^2.0.3" + optionalDependencies: + lightningcss-android-arm64 "1.32.0" + lightningcss-darwin-arm64 "1.32.0" + lightningcss-darwin-x64 "1.32.0" + lightningcss-freebsd-x64 "1.32.0" + lightningcss-linux-arm-gnueabihf "1.32.0" + lightningcss-linux-arm64-gnu "1.32.0" + lightningcss-linux-arm64-musl "1.32.0" + lightningcss-linux-x64-gnu "1.32.0" + lightningcss-linux-x64-musl "1.32.0" + lightningcss-win32-arm64-msvc "1.32.0" + lightningcss-win32-x64-msvc "1.32.0" + +magic-string@^0.30.21: + version "0.30.21" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + +micromatch@^4.0.5: + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + +mri@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b" + integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA== + +node-addon-api@^7.0.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" + integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" + integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +tailwindcss@4.3.3, tailwindcss@^4.0.14: + version "4.3.3" + resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.3.3.tgz#c006861611c213c1877893ab5b23daa16be2bb55" + integrity sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ== + +tapable@^2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.3.tgz#5da7c9992c46038221267985ab28421a8879f160" + integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +tslib@^2.4.0, tslib@^2.8.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== diff --git a/dashboard/package.json b/dashboard/package.json index ec8cbc03..e5710137 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -17,7 +17,7 @@ "@vueuse/router": "^13.6.0", "canvas-confetti": "^1.9.3", "feather-icons": "^4.29.2", - "frappe-ui": "1.0.0-beta.25", + "frappe-ui": "1.0.0-beta.37", "socket.io-client": "^4.7.2", "vue": "^3.5.13", "vue-router": "^4.5.0" diff --git a/dashboard/src/components/PhoneInput.vue b/dashboard/src/components/PhoneInput.vue index 118d5aa6..83e89658 100644 --- a/dashboard/src/components/PhoneInput.vue +++ b/dashboard/src/components/PhoneInput.vue @@ -90,8 +90,11 @@ function emitValue() { emit("update:modelValue", formatPhone(dialCode.value, localNumber.value)); } -function onDialCodeChange(code: string | null) { - if (code) { +// frappe-ui's Combobox declares its emit as (...args: unknown[]), so a narrowly +// typed handler is not assignable to it. Narrow at the boundary instead. +function onDialCodeChange(...args: unknown[]) { + const code = args[0]; + if (typeof code === "string" && code) { dialCode.value = code; emitValue(); } diff --git a/dashboard/yarn.lock b/dashboard/yarn.lock index 46af3d1b..7c61c0fb 100644 --- a/dashboard/yarn.lock +++ b/dashboard/yarn.lock @@ -1755,6 +1755,11 @@ entities@^7.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b" integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA== +es-module-lexer@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" + integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== + escalade@^3.1.1: version "3.1.1" resolved "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz" @@ -1863,10 +1868,10 @@ fraction.js@^4.1.2: resolved "https://registry.npmjs.org/fraction.js/-/fraction.js-4.1.2.tgz" integrity sha512-o2RiJQ6DZaR/5+Si0qJUIy637QMRudSi9kU/FFzx9EZazrIdnBgpU+3sEWCxAVhH2RtxW2Oz+T4p2o8uOPVcgA== -frappe-ui@1.0.0-beta.25: - version "1.0.0-beta.25" - resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-1.0.0-beta.25.tgz#99aa2463de799ae98550e9be7e4770e79b431680" - integrity sha512-RiHl6Nv3vy8ywtQfWG9pvWYiH9aRdhzteXPiF2+Qjsh0owBVnim0ZP6ZE03jhtmaZ5QgrPovZu+rcBTOORWzuA== +frappe-ui@1.0.0-beta.37: + version "1.0.0-beta.37" + resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-1.0.0-beta.37.tgz#9ce3f12b78dd2408a389c330a0bd8f9087b3ff31" + integrity sha512-OfnE9Wwy3Pxd9XKTYfulFOh8OUqdmbBy9T9bv8EIoRqE84iYJbWnYwlloyDfowP9MHJCFG1j0ysKC+fVITX+hA== dependencies: "@codemirror/lang-css" "^6.3.0" "@codemirror/lang-html" "^6.4.9" @@ -1925,6 +1930,7 @@ frappe-ui@1.0.0-beta.25: dayjs "^1.11.13" dompurify "^3.4.0" echarts "^5.6.0" + es-module-lexer "^1.7.0" feather-icons "^4.28.0" fuzzysort "^3.1.0" grid-layout-plus "^1.1.0" @@ -1932,6 +1938,7 @@ frappe-ui@1.0.0-beta.25: idb-keyval "^6.2.0" lowlight "^3.3.0" lucide-static "^1.16.0" + magic-string "^0.30.21" marked "^15.0.12" ora "5.4.1" prettier "^3.3.2"