From 9e3658d2056bb4e7ede654a4c722e22a561da73e Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Tue, 6 Oct 2026 17:30:25 +0530 Subject: [PATCH 01/21] feat(teams): publish a team's public page at /community/ Buzz Team becomes a website generator. A published team gets a server-rendered page with its logo, short description, about, links and a day-grouped Upcoming / Past timeline of its own events, built on the event pages' layout and espresso tab buttons. Owners, admins and managers edit the page from the team's settings in the dashboard through buzz.api.teams.update_public_page. The route is filled on first publish and only a System Manager changes it. The site header now supplies the visitor's name and avatar, which a website generator's page does not get, so signed-in visitors can open the page. --- buzz/api/teams/__init__.py | 11 + buzz/api/teams/schemas.py | 7 + buzz/api/teams/services.py | 54 ++- buzz/api/teams/test_teams.py | 64 +++- buzz/events/doctype/buzz_team/buzz_team.json | 65 +++- buzz/events/doctype/buzz_team/buzz_team.py | 30 +- buzz/events/doctype/buzz_team/team_page.py | 100 +++++ .../buzz_team/templates/buzz_team.html | 96 +++++ .../doctype/buzz_team/test_team_page.py | 89 +++++ .../buzz_team/test_team_page_render.py | 67 ++++ .../doctype/buzz_team/test_team_route.py | 62 +++ buzz/public/css/espresso.bundle.scss | 1 + .../css/espresso/components/tab_buttons.css | 352 ++++++++++++++++++ buzz/public/js/team_page.bundle.ts | 29 ++ buzz/public/themes/team.css | 238 ++++++++++++ buzz/www/event/index.py | 22 +- buzz/www/site_header.py | 7 + .../dashboard/events/EventLinks.vue | 6 +- .../dashboard/teams/ManageTeamPanel.vue | 10 +- .../dashboard/teams/TeamPublicPage.vue | 118 ++++++ dashboard/src/data/teams.ts | 6 + dashboard/src/types.ts | 5 + dashboard/src/utils/teamRoles.ts | 5 + 23 files changed, 1425 insertions(+), 19 deletions(-) create mode 100644 buzz/events/doctype/buzz_team/team_page.py create mode 100644 buzz/events/doctype/buzz_team/templates/buzz_team.html create mode 100644 buzz/events/doctype/buzz_team/test_team_page.py create mode 100644 buzz/events/doctype/buzz_team/test_team_page_render.py create mode 100644 buzz/events/doctype/buzz_team/test_team_route.py create mode 100644 buzz/public/css/espresso/components/tab_buttons.css create mode 100644 buzz/public/js/team_page.bundle.ts create mode 100644 buzz/public/themes/team.css create mode 100644 dashboard/src/components/dashboard/teams/TeamPublicPage.vue diff --git a/buzz/api/teams/__init__.py b/buzz/api/teams/__init__.py index a3e464b7..dbbcf278 100644 --- a/buzz/api/teams/__init__.py +++ b/buzz/api/teams/__init__.py @@ -59,6 +59,17 @@ def update_team(team: str, team_name: str, logo: str | None = None) -> None: services.update_team(team, team_name, logo) +@frappe.whitelist(methods=["POST"]) +def update_public_page( + team: str, + is_published: bool, + links: list[dict], + short_description: str | None = None, + about: str | None = None, +) -> None: + services.update_public_page(team, is_published, links, short_description, about) + + @frappe.whitelist(methods=["POST"]) def resend_invite(team: str, email: str) -> None: invitations.resend_invite(team, email) diff --git a/buzz/api/teams/schemas.py b/buzz/api/teams/schemas.py index 584f7977..cdf7d959 100644 --- a/buzz/api/teams/schemas.py +++ b/buzz/api/teams/schemas.py @@ -1,3 +1,4 @@ +from buzz.api.events.schemas import EventExternalLink from buzz.api.schemas import APIResponse @@ -35,6 +36,12 @@ class TeamOverview(APIResponse): team_name: str slug: str | None logo: str | None + is_published: bool + # Absolute page URL while published; None otherwise. + public_url: str | None + short_description: str | None + about: str | None + links: list[EventExternalLink] my_role: str members: list[TeamMember] # Kept apart from members: these people cannot do anything on the team yet. diff --git a/buzz/api/teams/services.py b/buzz/api/teams/services.py index 26d5398c..db9f370a 100644 --- a/buzz/api/teams/services.py +++ b/buzz/api/teams/services.py @@ -1,6 +1,8 @@ import frappe from frappe.query_builder import Case +from frappe.utils import get_url +from buzz.api.events.schemas import EventExternalLink from buzz.api.teams.exceptions import ( CannotEditTeam, CannotGrantOwnership, @@ -9,9 +11,18 @@ UnknownTeamRole, ) from buzz.api.teams.schemas import TeamInvite, TeamMember, TeamOverview -from buzz.permissions import can_manage_members, team_role_of - -TEAM_FIELDS = ("name", "team_name", "slug", "logo") +from buzz.permissions import WRITE_ROLES, can_manage_members, team_role_of + +TEAM_FIELDS = ( + "name", + "team_name", + "slug", + "logo", + "is_published", + "route", + "short_description", + "about", +) def validate_role(team_role: str) -> None: @@ -42,8 +53,11 @@ def team_overview(team: str) -> TeamOverview: if not details: NotATeamMember.throw() + route = details.pop("route") return TeamOverview( **details, + public_url=get_url(f"/{route}") if details.is_published and route else None, + links=links_of(team), my_role=role, members=members_of(team), invites=pending_invites_for(team), @@ -162,3 +176,37 @@ def update_team(team: str, team_name: str, logo: str | None) -> None: doc.team_name = team_name doc.logo = logo or None doc.save(ignore_permissions=True) + + +def links_of(team: str) -> list[EventExternalLink]: + rows = frappe.get_all( + "Event External Link", + filters={"parenttype": "Buzz Team", "parent": team, "parentfield": "links"}, + fields=["icon", "label", "url"], + order_by="idx", + ) + return [EventExternalLink(**row) for row in rows] + + +def update_public_page( + team: str, + is_published: bool, + links: list[dict], + short_description: str | None, + about: str | None, +) -> None: + """Publish or edit a team's public page. + + Owner/Admin/Manager, the roles that edit the team's events. Desk write on Buzz Team is + System Manager only, so the guard here is the authorization — the same shape as + `update_team`. The route is not editable here: it is filled on first publish. + """ + if team_role_of(frappe.session.user, team) not in WRITE_ROLES: + CannotEditTeam.throw() + + doc = frappe.get_doc("Buzz Team", team) + doc.is_published = int(is_published) + doc.short_description = (short_description or "").strip() or None + doc.about = about or None + doc.set("links", [{key: link.get(key) for key in ("icon", "label", "url")} for link in links]) + doc.save(ignore_permissions=True) diff --git a/buzz/api/teams/test_teams.py b/buzz/api/teams/test_teams.py index 0047cc1f..b2e0be01 100644 --- a/buzz/api/teams/test_teams.py +++ b/buzz/api/teams/test_teams.py @@ -1,7 +1,14 @@ import frappe from frappe.tests import IntegrationTestCase -from buzz.api.teams import change_roles, get_my_teams, get_team_overview, remove_members, update_team +from buzz.api.teams import ( + change_roles, + get_my_teams, + get_team_overview, + remove_members, + update_public_page, + update_team, +) from buzz.api.teams.exceptions import ( CannotEditTeam, CannotGrantOwnership, @@ -427,3 +434,58 @@ def test_a_blank_name_is_refused(self): frappe.set_user(owner) with self.assertRaises(frappe.ValidationError): update_team(team, " ", None) + + +class TestUpdatePublicPage(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + frappe.set_user("Administrator") + cls.owner = UserFactory.create_once("public-page-owner@example.com").name + cls.manager = UserFactory.create_once("public-page-manager@example.com").name + cls.viewer = UserFactory.create_once("public-page-viewer@example.com").name + cls.team = BuzzTeamFactory.create_owned_by(cls.owner, team_name="Public Page Builders").name + upsert_membership(cls.team, cls.manager, "Manager") + upsert_membership(cls.team, cls.viewer, "Viewer") + + def tearDown(self): + frappe.set_user("Administrator") + + def publish_as(self, user: str, **overrides): + frappe.set_user(user) + payload = {"team": self.team, "is_published": True, "links": []} | overrides + update_public_page(**payload) + + def test_a_manager_publishes_the_page(self): + self.publish_as(self.manager, short_description="Builders in public.") + + overview = get_team_overview(self.team) + + self.assertTrue(overview.is_published) + self.assertEqual(overview.short_description, "Builders in public.") + self.assertTrue( + overview.public_url.endswith(f"/community/{frappe.db.get_value('Buzz Team', self.team, 'slug')}") + ) + + def test_links_are_saved_in_order(self): + links = [ + {"icon": "globe", "label": "Site", "url": "https://example.com"}, + {"icon": "message-circle", "label": "Forum", "url": "https://forum.example.com"}, + ] + self.publish_as(self.owner, links=links) + + overview = get_team_overview(self.team) + + self.assertEqual([link.label for link in overview.links], ["Site", "Forum"]) + + def test_a_viewer_cannot_edit_the_page(self): + with self.assertRaises(CannotEditTeam): + self.publish_as(self.viewer, short_description="Hijacked") + + frappe.set_user("Administrator") + self.assertNotEqual(frappe.db.get_value("Buzz Team", self.team, "short_description"), "Hijacked") + + def test_an_unpublished_page_has_no_public_url(self): + self.publish_as(self.owner, is_published=False) + + self.assertIsNone(get_team_overview(self.team).public_url) diff --git a/buzz/events/doctype/buzz_team/buzz_team.json b/buzz/events/doctype/buzz_team/buzz_team.json index 1c6f5e30..fc797d66 100644 --- a/buzz/events/doctype/buzz_team/buzz_team.json +++ b/buzz/events/doctype/buzz_team/buzz_team.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_guest_to_view": 1, "allow_rename": 1, "autoname": "BTEAM.####", "creation": "2026-07-31 10:00:00", @@ -9,7 +10,15 @@ "team_name", "slug", "section_break_hvmt", - "logo" + "logo", + "public_page_tab", + "is_published", + "route", + "column_break_public", + "short_description", + "section_break_about", + "about", + "links" ], "fields": [ { @@ -36,11 +45,57 @@ "fieldname": "logo", "fieldtype": "Attach Image", "label": "Logo" + }, + { + "fieldname": "public_page_tab", + "fieldtype": "Tab Break", + "label": "Public Page" + }, + { + "default": "0", + "fieldname": "is_published", + "fieldtype": "Check", + "label": "Published" + }, + { + "description": "Where the public page lives. Filled as community/ on first publish.", + "fieldname": "route", + "fieldtype": "Data", + "label": "Route", + "no_copy": 1, + "permlevel": 1, + "unique": 1 + }, + { + "fieldname": "column_break_public", + "fieldtype": "Column Break" + }, + { + "fieldname": "short_description", + "fieldtype": "Small Text", + "label": "Short Description" + }, + { + "fieldname": "section_break_about", + "fieldtype": "Section Break" + }, + { + "fieldname": "about", + "fieldtype": "Text Editor", + "label": "About" + }, + { + "fieldname": "links", + "fieldtype": "Table", + "label": "Links", + "options": "Event External Link" } ], "grid_page_length": 50, + "has_web_view": 1, "image_field": "logo", "index_web_pages_for_search": 1, + "is_published_field": "is_published", "links": [ { "group": "Members", @@ -53,7 +108,7 @@ "link_fieldname": "team" } ], - "modified": "2026-08-04 17:38:48.007790", + "modified": "2026-10-06 14:00:00.000000", "modified_by": "Administrator", "module": "Events", "name": "Buzz Team", @@ -81,6 +136,12 @@ "role": "Event Manager", "select": 1, "share": 1 + }, + { + "permlevel": 1, + "read": 1, + "role": "System Manager", + "write": 1 } ], "row_format": "Dynamic", diff --git a/buzz/events/doctype/buzz_team/buzz_team.py b/buzz/events/doctype/buzz_team/buzz_team.py index 7ff49962..4cc89e52 100644 --- a/buzz/events/doctype/buzz_team/buzz_team.py +++ b/buzz/events/doctype/buzz_team/buzz_team.py @@ -3,12 +3,13 @@ import frappe from frappe import _ -from frappe.model.document import Document from frappe.model.naming import append_number_if_name_exists +from frappe.website.website_generator import WebsiteGenerator from buzz.events.doctype.buzz_team_membership.buzz_team_membership import upsert_membership from buzz.events.doctype.buzz_team_settings.buzz_team_settings import create_team_settings from buzz.permissions import can_manage_members, my_team_names +from buzz.www.site_header import apply_site_context SEARCH_LIMIT = 10 STANDARD_USERS = ("Administrator", "Guest") @@ -43,7 +44,7 @@ def set_team_from_sole_membership(doc, event=None): doc.team = teams[0] -class BuzzTeam(Document): +class BuzzTeam(WebsiteGenerator): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -52,17 +53,42 @@ class BuzzTeam(Document): if TYPE_CHECKING: from frappe.types import DF + from buzz.events.doctype.event_external_link.event_external_link import EventExternalLink + + about: DF.TextEditor | None + is_published: DF.Check + links: DF.Table[EventExternalLink] logo: DF.AttachImage | None + route: DF.Data | None + short_description: DF.SmallText | None slug: DF.Data | None team_name: DF.Data # end: auto-generated types + def autoname(self): + # WebsiteGenerator would name the team after its title; the BTEAM.#### series names it. + pass + def onload(self): + super().onload() self.set_onload("can_manage_members", can_manage_members(self.name)) def validate(self): if not self.slug: self.set_slug() + # After the slug: the first publish builds the route from it. + super().validate() + + def make_route(self) -> str: + return f"community/{self.slug}" + + def get_context(self, context): + from buzz.events.doctype.buzz_team.team_page import TeamPage + + apply_site_context(context) + # The header differs per visitor and the timeline changes by the hour. + context.no_cache = 1 + context.update(TeamPage(self).as_context()) def set_slug(self): slug = frappe.website.utils.cleanup_page_name(self.team_name).replace("_", "-") diff --git a/buzz/events/doctype/buzz_team/team_page.py b/buzz/events/doctype/buzz_team/team_page.py new file mode 100644 index 00000000..58e36821 --- /dev/null +++ b/buzz/events/doctype/buzz_team/team_page.py @@ -0,0 +1,100 @@ +import textwrap +from itertools import groupby + +import frappe +from frappe import _ +from frappe.utils import date_diff, format_date, get_url, getdate, today + +from buzz.www.event.date_range import format_time +from buzz.www.event.index import public_links +from buzz.www.event.meta import DESCRIPTION_LENGTH, plain_text +from buzz.www.events import ( + CARD_FIELDS, + LISTING_LIMIT, + TIME_FIELDS, + exclude_ended_events, + has_ended, + upcoming_filters, +) + +PAST_LIMIT = 30 +EVENT_FIELDS = CARD_FIELDS + TIME_FIELDS + + +def day_labels(day) -> dict: + relative = {0: _("Today"), 1: _("Tomorrow")}.get(date_diff(day, today())) + return {"label": relative or format_date(day, "d MMM"), "weekday": format_date(day, "EEEE")} + + +def group_by_day(cards: list[dict]) -> list[dict]: + return [ + {"date": date, **day_labels(getdate(date)), "events": list(group)} + for date, group in groupby(cards, key=lambda card: card["date"]) + ] + + +class TeamPage: + """Context for a team's public page: identity, links and its own event timeline.""" + + def __init__(self, team): + self.team = team + + def as_context(self) -> dict: + upcoming = [self.card(event) for event in self.upcoming_events()] + past = [self.card(event) for event in self.past_events()] + return { + "title": self.team.team_name, + "links": public_links(self.team.links), + "events_hosted": frappe.db.count("Buzz Event", {"team": self.team.name, "is_published": 1}), + "upcoming_days": group_by_day(upcoming), + "past_days": group_by_day(past), + "meta": self.meta(), + } + + def upcoming_events(self) -> list: + events = frappe.get_all( + "Buzz Event", + filters=upcoming_filters() | {"team": self.team.name}, + fields=EVENT_FIELDS, + order_by="start_date asc, start_time asc", + limit=LISTING_LIMIT, + ) + return exclude_ended_events(events) + + def past_events(self) -> list: + # By start date: a one-day event leaves end_date blank, and has_ended makes the exact cut. + events = frappe.get_all( + "Buzz Event", + filters={ + "team": self.team.name, + "is_published": 1, + "route": ["is", "set"], + "start_date": ["<=", today()], + }, + fields=EVENT_FIELDS, + order_by="start_date desc, start_time desc", + limit=PAST_LIMIT, + ) + return [event for event in events if has_ended(event)] + + def card(self, event) -> dict: + is_online = event.medium == "Online" + return { + "date": str(getdate(event.start_date)), + "title": event.title, + "url": f"/events/{event.route}", + "image": event.card_image or event.banner_image, + "time": format_time(event.start_time), + "host_name": self.team.team_name, + "host_logo": self.team.logo, + "place": _("Online") if is_online else event.venue_name, + "is_online": is_online, + } + + def meta(self) -> dict: + text = self.team.short_description or plain_text(self.team.about) + return { + "url": get_url(f"/{self.team.route}"), + "description": textwrap.shorten(text, DESCRIPTION_LENGTH, placeholder="…") if text else "", + "image": get_url(self.team.logo) if self.team.logo else "", + } diff --git a/buzz/events/doctype/buzz_team/templates/buzz_team.html b/buzz/events/doctype/buzz_team/templates/buzz_team.html new file mode 100644 index 00000000..4dd3bdb7 --- /dev/null +++ b/buzz/events/doctype/buzz_team/templates/buzz_team.html @@ -0,0 +1,96 @@ +{% extends "buzz/templates/event_page/layout.html" %} + +{% block title %}{% autoescape true %}{{ title }}{% endautoescape %}{% endblock %} + +{% block meta_block %}{% autoescape true %} + + + + + + + {% if meta.image %}{% endif %} + + + +{% endautoescape %}{% endblock %} + +{% block content %}{% autoescape true %} + {% macro event_card(event) %} + + {% if event.image %}{% else %}{% endif %} + + {% if event.time %}{{ event.time }}{% endif %} + {{ event.title }} + + {% if event.host_logo %}{% else %}{% endif %} + {{ _("By {0}").format(event.host_name) }} + + {% if event.place %} + + + {{ event.place }} + + {% endif %} + + + {% endmacro %} + + {% macro timeline(days, empty_text) %} + {% for day in days %} +
+

{{ day.label }}{{ day.weekday }}

+
{% for event in day.events %}{{ event_card(event) }}{% endfor %}
+
+ {% else %} +

{{ empty_text }}

+ {% endfor %} + {% endmacro %} + +
+
+ {% if doc.logo %}{% else %}{% endif %} +
+

{{ doc.team_name }}

+

{% if events_hosted == 1 %}{{ _("1 event hosted") }}{% else %}{{ _("{0} events hosted").format(events_hosted) }}{% endif %}

+
+
+ +
+
+
+ + +
+
{{ timeline(upcoming_days, _("No upcoming events.")) }}
+ +
+ + {% if doc.short_description or doc.about or links %} + + {% endif %} +
+
+ +{% endautoescape %}{% endblock %} + +{% block script %} + {{ super() }} + +{% endblock %} diff --git a/buzz/events/doctype/buzz_team/test_team_page.py b/buzz/events/doctype/buzz_team/test_team_page.py new file mode 100644 index 00000000..e48c50d0 --- /dev/null +++ b/buzz/events/doctype/buzz_team/test_team_page.py @@ -0,0 +1,89 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, format_date, getdate, today + +from buzz.events.doctype.buzz_team.team_page import TeamPage, day_labels +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory + + +def event_titles(days: list[dict]) -> list[str]: + return [event["title"] for day in days for event in day["events"]] + + +class TestTeamPage(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + frappe.set_user("Administrator") + cls.team = BuzzTeamFactory.create_owned_by(team_name="Timeline Builders") + cls.other_team = BuzzTeamFactory.create_owned_by(team_name="Elsewhere Builders") + cls.create_event("Next Week Meetup", 7, start_time="18:30:00", end_time="20:00:00") + cls.create_event("Last Month Meetup", -30) + cls.create_event("Draft Meetup", 7, is_published=0) + cls.create_event("Other Team Meetup", 7, team=cls.other_team.name) + cls.create_event("Ended This Morning", 0, start_time="00:00:00", end_time="00:00:01") + + @classmethod + def create_event(cls, title, offset, **overrides): + day = add_days(today(), offset) + attributes = {"title": title, "team": cls.team.name, "start_date": day, "end_date": day} + return BuzzEventFactory.create(**(attributes | overrides)) + + def context(self) -> dict: + return TeamPage(frappe.get_doc("Buzz Team", self.team.name)).as_context() + + def test_upcoming_lists_only_the_teams_published_upcoming_events(self): + titles = event_titles(self.context()["upcoming_days"]) + + self.assertIn("Next Week Meetup", titles) + self.assertNotIn("Draft Meetup", titles) + self.assertNotIn("Other Team Meetup", titles) + self.assertNotIn("Last Month Meetup", titles) + + def test_an_event_that_ended_today_moves_to_past(self): + context = self.context() + + self.assertNotIn("Ended This Morning", event_titles(context["upcoming_days"])) + self.assertIn("Ended This Morning", event_titles(context["past_days"])) + + def test_past_is_newest_first(self): + titles = event_titles(self.context()["past_days"]) + + self.assertLess(titles.index("Ended This Morning"), titles.index("Last Month Meetup")) + + def test_card_carries_time_host_and_place(self): + card = next( + event + for day in self.context()["upcoming_days"] + for event in day["events"] + if event["title"] == "Next Week Meetup" + ) + + self.assertEqual(card["time"], "18:30") + self.assertEqual(card["host_name"], "Timeline Builders") + self.assertTrue(card["is_online"]) + self.assertTrue(card["url"].startswith("/events/")) + + def test_day_labels_are_relative_then_dates(self): + self.assertEqual(day_labels(getdate(today()))["label"], "Today") + self.assertEqual(day_labels(getdate(add_days(today(), 1)))["label"], "Tomorrow") + later = getdate(add_days(today(), 5)) + self.assertEqual(day_labels(later)["label"], format_date(later, "d MMM")) + self.assertEqual(day_labels(later)["weekday"], format_date(later, "EEEE")) + + def test_events_hosted_counts_published_events(self): + self.assertEqual(self.context()["events_hosted"], 3) + + def test_links_drop_non_web_urls(self): + team = frappe.get_doc("Buzz Team", self.team.name) + team.set( + "links", + [ + {"icon": "globe", "label": "Site", "url": "https://example.com"}, + {"icon": "link", "label": "Sneaky", "url": "javascript:alert(1)"}, + ], + ) + + labels = [link["label"] for link in TeamPage(team).as_context()["links"]] + + self.assertEqual(labels, ["Site"]) diff --git a/buzz/events/doctype/buzz_team/test_team_page_render.py b/buzz/events/doctype/buzz_team/test_team_page_render.py new file mode 100644 index 00000000..2bd8dbef --- /dev/null +++ b/buzz/events/doctype/buzz_team/test_team_page_render.py @@ -0,0 +1,67 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.website.serve import get_response, get_response_content + +from buzz.tests.factories import BuzzEventFactory, BuzzTeamFactory + + +class TestTeamPageRender(IntegrationTestCase): + def setUp(self): + frappe.set_user("Guest") + self.addCleanup(frappe.set_user, "Administrator") + + def published_team(self, team_name: str, **overrides): + frappe.set_user("Administrator") + team = BuzzTeamFactory.create_owned_by(team_name=team_name, is_published=1, **overrides) + frappe.set_user("Guest") + return team + + def test_a_guest_sees_the_published_page(self): + team = self.published_team("Rendered Builders", short_description="We build in public.") + frappe.set_user("Administrator") + BuzzEventFactory.create(title="Rendered Meetup", team=team.name) + frappe.set_user("Guest") + + html = get_response_content(f"/{team.route}") + + self.assertIn("Rendered Builders", html) + self.assertIn("We build in public.", html) + self.assertIn("Rendered Meetup", html) + self.assertIn("es-tab-buttons", html) + + def test_a_signed_in_visitor_sees_the_page(self): + team = self.published_team("Signed In Builders") + frappe.set_user("Administrator") + + response = get_response(f"/{team.route}") + + self.assertEqual(response.status_code, 200) + + def test_a_team_with_no_events_still_renders(self): + team = self.published_team("Empty Builders") + + html = get_response_content(f"/{team.route}") + + self.assertIn("No upcoming events.", html) + self.assertIn("No past events.", html) + + def test_unpublishing_takes_the_page_down_at_once(self): + team = self.published_team("Fleeting Builders") + self.assertEqual(get_response(f"/{team.route}").status_code, 200) + + frappe.set_user("Administrator") + team.reload() + team.is_published = 0 + team.save() + frappe.set_user("Guest") + + self.assertEqual(get_response(f"/{team.route}").status_code, 404) + + def test_the_context_opts_out_of_the_page_cache(self): + # Developer mode never caches pages, so the flag itself is what can be checked. + team = self.published_team("Fresh Builders") + context = frappe._dict() + + frappe.get_doc("Buzz Team", team.name).get_context(context) + + self.assertEqual(context.no_cache, 1) diff --git a/buzz/events/doctype/buzz_team/test_team_route.py b/buzz/events/doctype/buzz_team/test_team_route.py new file mode 100644 index 00000000..cc568583 --- /dev/null +++ b/buzz/events/doctype/buzz_team/test_team_route.py @@ -0,0 +1,62 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.website.page_renderers.document_page import DocumentPage +from frappe.website.page_renderers.not_found_page import NotFoundPage +from frappe.website.path_resolver import PathResolver + +from buzz.tests.factories import BuzzTeamFactory + + +class TestTeamRoute(IntegrationTestCase): + def setUp(self): + frappe.set_user("Administrator") + + def publish(self, team): + team.is_published = 1 + team.save() + return team + + def test_publishing_sets_the_community_route(self): + team = self.publish(BuzzTeamFactory.create_owned_by(team_name="Route Builders")) + + self.assertEqual(team.route, f"community/{team.slug}") + + def test_an_unpublished_team_has_no_route(self): + team = BuzzTeamFactory.create_owned_by(team_name="Quiet Builders") + + self.assertFalse(team.route) + + def test_a_published_team_resolves_to_its_document(self): + team = self.publish(BuzzTeamFactory.create_owned_by(team_name="Resolved Builders")) + + _, renderer = PathResolver(team.route).resolve() + + self.assertIsInstance(renderer, DocumentPage) + self.assertEqual((renderer.doctype, renderer.docname), ("Buzz Team", team.name)) + + def test_an_unpublished_team_does_not_resolve(self): + team = self.publish(BuzzTeamFactory.create_owned_by(team_name="Hidden Builders")) + team.is_published = 0 + team.save() + + _, renderer = PathResolver(team.route).resolve() + + self.assertIsInstance(renderer, NotFoundPage) + + def test_a_hand_set_top_level_route_resolves(self): + team = self.publish(BuzzTeamFactory.create_owned_by(team_name="Featured Builders")) + route = f"featured-builders-{frappe.generate_hash(length=6)}" + team.route = route + team.save() + + _, renderer = PathResolver(route).resolve() + + self.assertEqual(renderer.docname, team.name) + + def test_a_slug_edit_keeps_the_route(self): + team = self.publish(BuzzTeamFactory.create_owned_by(team_name="Stable Builders")) + route = team.route + team.slug = f"renamed-{frappe.generate_hash(length=6)}" + team.save() + + self.assertEqual(team.route, route) diff --git a/buzz/public/css/espresso.bundle.scss b/buzz/public/css/espresso.bundle.scss index 9237d165..18ff05e9 100644 --- a/buzz/public/css/espresso.bundle.scss +++ b/buzz/public/css/espresso.bundle.scss @@ -4,3 +4,4 @@ @import "./espresso/effects"; @import "./espresso/typography"; @import "./espresso/components/button"; +@import "./espresso/components/tab_buttons"; diff --git a/buzz/public/css/espresso/components/tab_buttons.css b/buzz/public/css/espresso/components/tab_buttons.css new file mode 100644 index 00000000..809554ce --- /dev/null +++ b/buzz/public/css/espresso/components/tab_buttons.css @@ -0,0 +1,352 @@ +/* Copied from frappe frappe/public/css/espresso/components/tab_buttons.css @ 70b411faae. Re-copy, do not edit. */ +/* Espresso Tab Buttons — a segmented single-select where exactly one + * button is pressed (frappe-ui's TabButtons), built from Pills. For + * choices, not navigation: page-size selectors, calendar day/week/month, + * view toggles. No panels — that's what Tabs (es-tabs) are for. + * + * Two classes, mirroring frappe-ui's split: + * .es-pill the unitary piece (frappe-ui's Pill) — styled entirely + * by its own data-attributes, so it also works standalone + * .es-tab-buttons the group: the rail behind the pills, the gaps, the + * orientation + * + *
+ * + * + *
+ * + * Group attributes: + * data-type="subtle|ghost|underline|browser-tab" subtle is the default + * data-size="sm|md" sm is the default + * data-orientation="vertical" + * data-direction="right" which edge vertical browser-tabs attach to + * + * Pill attributes (data-state="active|inactive" is what frappe-ui's Pill + * emits, so Vue apps get this styling from their component as-is): + * data-state="active|inactive" + * data-variant="outline|underline|browser-tab" none = default look + * data-size="md" none = sm + * data-active-style="subtle" none = raised + * data-icon-only square, label is aria-only + * data-tab-base="default|left|right" browser-tab edge shape + * + * Sizing comes from padding + line-height only, never a fixed height. */ + +/* ================= the pill ================= */ + +.es-pill { + display: inline-flex; + box-sizing: border-box; + flex-shrink: 0; + align-items: center; + justify-content: center; + gap: calc(var(--spacing) * 2); + padding: 5px calc(var(--spacing) * 2); + border: 0; + background: transparent; + border-radius: 7px; + font-size: var(--text-sm); + line-height: calc(var(--spacing) * 4); + color: var(--ink-gray-5); + white-space: nowrap; + user-select: none; + cursor: pointer; + text-decoration: none; + transition: background-color 150ms ease-out, color 150ms ease-out, + box-shadow 150ms ease-out, border-color 150ms ease-out; +} + +.es-pill[data-size="md"] { + padding: calc(var(--spacing) * 1.5) calc(var(--spacing) * 2.5); + border-radius: 9px; +} + +.es-pill[data-state="active"] { + color: var(--ink-gray-8); +} + +.es-pill:disabled, +.es-pill[data-disabled] { + opacity: 0.6; + pointer-events: none; +} + +.es-pill:focus-visible { + outline: var(--focus-outline-default, 2px solid var(--outline-gray-3)); + outline-offset: 0; +} + +/* icons: 16px at sm, 18px at md (Pill.vue size-4 / size-[18px]) */ +.es-pill > svg, +.es-pill > img, +.es-pill > .icon { + width: calc(var(--spacing) * 4); + height: calc(var(--spacing) * 4); + flex-shrink: 0; + margin: 0; +} + +.es-pill[data-size="md"] > svg, +.es-pill[data-size="md"] > img, +.es-pill[data-size="md"] > .icon { + width: 18px; + height: 18px; +} + +/* icon-only pills are square (Pill.vue: p-[5px]), label goes aria-only */ +.es-pill[data-icon-only] { + gap: calc(var(--spacing) * 1.5); + padding: 5px; +} + +.es-pill[data-size="md"][data-icon-only] { + padding: calc(var(--spacing) * 1.5); +} + +/* ---- default look: quiet, raised when pressed ---- */ +.es-pill:not([data-variant]):hover { + /* Pill.vue: hover:bg-surface-gray-3/80 — the token at 80% opacity */ + background: color-mix(in srgb, var(--surface-gray-3) 80%, transparent); + color: var(--ink-gray-7); +} + +.es-pill:not([data-variant])[data-state="active"] { + background: var(--surface-elevation-3); + box-shadow: var(--elevation-base); + color: var(--ink-gray-8); +} + +/* on a ghost group there is no rail to rise out of — the pressed pill + just tints (Pill's activeStyle="subtle") */ +.es-pill[data-active-style="subtle"][data-state="active"] { + background: var(--surface-gray-2); + box-shadow: none; +} + +/* ---- outline look ---- */ +.es-pill[data-variant="outline"] { + border: 1px solid var(--outline-gray-1); +} + +.es-pill[data-variant="outline"]:hover { + background: var(--surface-gray-2); + color: var(--ink-gray-7); +} + +.es-pill[data-variant="outline"][data-state="active"] { + border-color: transparent; + background: var(--surface-base); + box-shadow: var(--elevation-sm); +} + +/* ---- underline look: reads like a tab, works like a radio ---- */ +.es-pill[data-variant="underline"] { + position: relative; + border-radius: 0; + padding: 6px 0; + /* an invisible slot on the rail edge, so activating doesn't shift text */ + border-bottom: 1px solid transparent; +} + +.es-pill[data-variant="underline"][data-size="md"] { + padding: 7px 0; +} + +.es-pill[data-variant="underline"]:hover { + color: var(--ink-gray-7); +} + +.es-pill[data-variant="underline"][data-state="active"] { + font-weight: var(--font-weight-medium); + color: var(--ink-gray-8); +} + +/* the indicator overlays the group's rail segment so it reads as a darker + stretch of the same line, not a separate bar floating beside the label */ +.es-pill[data-variant="underline"][data-state="active"]::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: -1px; + height: 1px; + background: var(--ink-gray-8); +} + +/* vertical underline pills: the rail (and indicator) sit on the end edge */ +[data-orientation="vertical"] .es-pill[data-variant="underline"] { + padding: 6px calc(var(--spacing) * 2) 6px 0; + border-bottom: 0; + border-inline-end: 1px solid transparent; +} + +[data-orientation="vertical"] .es-pill[data-variant="underline"][data-size="md"] { + padding: 7px calc(var(--spacing) * 2) 7px 0; +} + +[data-orientation="vertical"] + .es-pill[data-variant="underline"][data-state="active"]::after { + left: auto; + right: -2px; + bottom: calc(var(--spacing) * 1.5); + top: calc(var(--spacing) * 1.5); + width: 1px; + height: auto; +} + +/* ---- browser-tab look ---- */ +.es-pill[data-variant="browser-tab"] { + border: 1px solid transparent; +} + +.es-pill[data-variant="browser-tab"]:hover { + background: var(--surface-gray-2); + color: var(--ink-gray-7); +} + +/* the active tab "punches through" the rail: a background-colored strip + extends a few pixels past the rail line so the tab visibly continues + into the area below instead of terminating at the rail (which the eye + reads as a closed corner). Border one step darker than the rail so the + tab reads as a distinct raised shape; no drop shadow — it would paint + below the rail and re-close the edge this opens. */ +.es-pill[data-variant="browser-tab"][data-state="active"] { + position: relative; + background: var(--surface-base); + border-color: var(--outline-gray-2); +} + +.es-pill[data-variant="browser-tab"][data-state="active"]::after { + content: ""; + position: absolute; + background: var(--surface-base); +} + +/* top-attached (horizontal groups) */ +.es-pill[data-variant="browser-tab"][data-tab-base="default"] { + border-radius: 7px 7px 0 0; +} + +.es-pill[data-variant="browser-tab"][data-tab-base="default"][data-size="md"] { + border-radius: 9px 9px 0 0; +} + +/* the rail-side edge stays 1px wide but transparent — zeroing its WIDTH + (border-bottom: 0) resets the color to currentColor, and on deselect + the border-color transition would animate that near-black back to + transparent: a dark line flashing under the tab you just left */ +.es-pill[data-variant="browser-tab"][data-tab-base="default"][data-state="active"] { + border-bottom-color: transparent; +} + +.es-pill[data-variant="browser-tab"][data-tab-base="default"][data-state="active"]::after { + left: -1px; + right: -1px; + bottom: -3px; + height: 3px; +} + +/* side-attached (vertical groups; base = the edge touching the rail) */ +.es-pill[data-variant="browser-tab"][data-tab-base="left"] { + border-radius: 0 7px 7px 0; +} + +.es-pill[data-variant="browser-tab"][data-tab-base="left"][data-state="active"] { + border-left-color: transparent; +} + +.es-pill[data-variant="browser-tab"][data-tab-base="left"][data-state="active"]::after { + left: -3px; + top: -1px; + bottom: -1px; + width: 3px; +} + +.es-pill[data-variant="browser-tab"][data-tab-base="right"] { + border-radius: 7px 0 0 7px; +} + +.es-pill[data-variant="browser-tab"][data-tab-base="right"][data-state="active"] { + border-right-color: transparent; +} + +.es-pill[data-variant="browser-tab"][data-tab-base="right"][data-state="active"]::after { + right: -3px; + top: -1px; + bottom: -1px; + width: 3px; +} + +@media (prefers-reduced-motion: reduce) { + .es-pill { + transition: none; + } +} + +/* ================= the group ================= */ + +.es-tab-buttons { + display: inline-flex; + align-items: center; + gap: calc(var(--spacing) * 1); + /* the 1px frame that shows the rail around the raised pill */ + padding: 1px; + background: var(--surface-gray-2); + /* rail radius = pill radius + the 1px frame */ + border-radius: 8px; +} + +.es-tab-buttons[data-size="md"] { + gap: calc(var(--spacing) * 1.5); + border-radius: 10px; +} + +.es-tab-buttons[data-type="ghost"] { + background: transparent; +} + +.es-tab-buttons[data-type="underline"] { + padding: 0; + gap: calc(var(--spacing) * 6); + background: transparent; + border-radius: 0; + border-bottom: 1px solid var(--outline-gray-1); +} + +.es-tab-buttons[data-type="browser-tab"] { + padding: 0; + gap: calc(var(--spacing) * 1); + background: transparent; + border-radius: 0; + border-bottom: 1px solid var(--outline-gray-1); +} + +/* ---- vertical groups ---- */ +.es-tab-buttons[data-orientation="vertical"] { + flex-direction: column; +} + +/* every pill hugs the start edge and spans the column, like vertical tabs */ +.es-tab-buttons[data-orientation="vertical"] .es-pill { + width: 100%; + justify-content: flex-start; + text-align: start; +} + +.es-tab-buttons[data-orientation="vertical"][data-type="underline"] { + border-bottom: 0; + border-inline-end: 1px solid var(--outline-gray-1); + gap: calc(var(--spacing) * 1.5); +} + +.es-tab-buttons[data-orientation="vertical"][data-type="browser-tab"] { + border-bottom: 0; + border-inline-start: 1px solid var(--outline-gray-1); +} + +.es-tab-buttons[data-orientation="vertical"][data-type="browser-tab"][data-direction="right"] { + border-inline-start: 0; + border-inline-end: 1px solid var(--outline-gray-1); +} diff --git a/buzz/public/js/team_page.bundle.ts b/buzz/public/js/team_page.bundle.ts new file mode 100644 index 00000000..5cbedbc7 --- /dev/null +++ b/buzz/public/js/team_page.bundle.ts @@ -0,0 +1,29 @@ +// Upcoming / Past on a team page: espresso tab buttons that switch panels. +const pills = [...document.querySelectorAll("[data-tab-buttons] .es-pill")] + +function showPanel(name: string) { + for (const pill of pills) { + const active = pill.dataset.tab === name + pill.dataset.state = active ? "active" : "inactive" + pill.setAttribute("aria-checked", String(active)) + pill.tabIndex = active ? 0 : -1 + } + for (const panel of document.querySelectorAll("[data-panel]")) { + panel.hidden = panel.dataset.panel !== name + } +} + +// Arrow keys move within the group, as a radiogroup expects. +function moveFocus(from: HTMLButtonElement, offset: number) { + const next = pills[(pills.indexOf(from) + offset + pills.length) % pills.length] + showPanel(next.dataset.tab || "") + next.focus() +} + +for (const pill of pills) { + pill.addEventListener("click", () => showPanel(pill.dataset.tab || "")) + pill.addEventListener("keydown", (event) => { + if (event.key === "ArrowRight") moveFocus(pill, 1) + if (event.key === "ArrowLeft") moveFocus(pill, -1) + }) +} diff --git a/buzz/public/themes/team.css b/buzz/public/themes/team.css new file mode 100644 index 00000000..b8620e23 --- /dev/null +++ b/buzz/public/themes/team.css @@ -0,0 +1,238 @@ +/* Team page: identity, a day timeline with the dashboard's rail, and an about sidebar. */ + +.team-identity { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border); +} + +.team-logo { + display: grid; + flex-shrink: 0; + place-items: center; + width: 56px; + height: 56px; + border-radius: 14px; + background: var(--surface-raised); + box-shadow: 0 0 0 1px var(--border); + color: var(--ink-strong); + font-weight: 600; + object-fit: cover; +} + +.team-identity-text { + flex: 1; + min-width: 240px; +} + +.team-identity .event-title { + font-size: 34px; + line-height: 1.1; +} + +.team-stats { + margin-top: 4px; + color: var(--ink-muted); + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +.team-columns { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 40px; + margin-top: 32px; +} + +.team-columns:not(:has(.team-aside)) { + grid-template-columns: minmax(0, 1fr); +} + +[data-tab-buttons] { + margin-bottom: 24px; +} + +/* The dashboard timeline's rail: a dot at each day, a line fading down past its cards. */ +.team-day { + position: relative; + padding: 0 0 28px 24px; +} + +.team-day::before { + content: ""; + position: absolute; + top: 7px; + left: 0; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--surface-gray-4); +} + +.team-day::after { + content: ""; + position: absolute; + top: 21px; + bottom: 0; + left: 3.5px; + width: 1px; + background: linear-gradient(to bottom, var(--outline-gray-2) 75%, transparent); +} + +.team-day-heading { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 12px; + color: var(--ink-gray-8); + font-size: 15px; + font-weight: 600; +} + +.team-day-heading span { + color: var(--ink-gray-5); + font-weight: 400; +} + +.team-day-events { + display: grid; + gap: 12px; +} + +/* Mirrors the dashboard's EventCard. */ +.team-event-card { + display: flex; + gap: 16px; + padding: 12px; + border: 1px solid var(--outline-gray-2); + border-radius: var(--radius-8); + transition: + transform 120ms var(--ease-out), + border-color 150ms ease; +} + +.team-event-card:active { + transform: scale(0.995); +} + +.team-event-card-image { + flex-shrink: 0; + width: 120px; + height: 120px; + border-radius: var(--radius-4); + background: var(--surface-gray-2); + object-fit: cover; + object-position: top; +} + +.team-event-card-body { + display: flex; + flex: 1; + flex-direction: column; + gap: 8px; + min-width: 0; + padding-block: 4px; +} + +.team-event-card-time, +.team-event-card-place { + color: var(--ink-gray-5); + font-size: 14px; + font-variant-numeric: tabular-nums; +} + +.team-event-card-title { + color: var(--ink-gray-8); + font-size: 16px; + font-weight: 600; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.team-event-card-host { + display: flex; + align-items: flex-start; + gap: 8px; + color: var(--ink-gray-6); + font-size: 13px; + overflow-wrap: anywhere; +} + +.team-event-card-avatar { + display: grid; + flex-shrink: 0; + place-items: center; + width: 16px; + height: 16px; + margin-top: 1px; + border-radius: 50%; + background: var(--surface-gray-3); + color: var(--ink-gray-6); + font-size: 9px; + font-weight: 600; + object-fit: cover; +} + +.team-aside { + position: sticky; + top: 76px; + align-self: start; +} + +.team-about { + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); +} + +.team-about .label { + margin-top: 0; +} + +.team-tagline { + margin-bottom: 12px; + color: var(--ink-strong); +} + +.team-about .prose { + font-size: 14px; +} + +.team-links { + gap: 2px; + margin-top: 16px; +} + +@media (hover: hover) and (pointer: fine) { + .team-event-card:hover { + border-color: var(--outline-gray-3); + } +} + +@media (max-width: 900px) { + .team-columns { + grid-template-columns: 1fr; + } + + .team-aside { + position: static; + order: -1; + } +} + +@media (max-width: 760px) { + .team-event-card-image { + width: 80px; + height: 80px; + } +} + +@media (prefers-reduced-motion: reduce) { + .team-event-card:active { + transform: none; + } +} diff --git a/buzz/www/event/index.py b/buzz/www/event/index.py index d89b4f1e..e19e7456 100644 --- a/buzz/www/event/index.py +++ b/buzz/www/event/index.py @@ -56,6 +56,18 @@ def web_url(url: str | None) -> str | None: return url if url and urlparse(url).scheme in ("http", "https") else None +def public_links(rows) -> list[dict]: + return [ + { + "label": row.label, + "url": url, + "icon_svg": LINK_ICON_PATHS.get(row.icon) or LINK_ICON_PATHS["link"], + } + for row in rows + if (url := web_url(row.url)) + ] + + def format_full_date(date) -> str: pattern = "EEEE, d MMMM" if date.year == getdate(nowdate()).year else "EEEE, d MMMM y" return format_date(date, pattern) @@ -167,15 +179,7 @@ def hosts(self) -> list: return [host for host in hosts if host] def links(self) -> list[dict]: - return [ - { - "label": row.label, - "url": url, - "icon_svg": LINK_ICON_PATHS.get(row.icon) or LINK_ICON_PATHS["link"], - } - for row in self.event.external_links - if (url := web_url(row.url)) - ] + return public_links(self.event.external_links) @cached_property def schedule(self) -> list[dict]: diff --git a/buzz/www/site_header.py b/buzz/www/site_header.py index 791cf724..5f03e3ea 100644 --- a/buzz/www/site_header.py +++ b/buzz/www/site_header.py @@ -1,4 +1,5 @@ import frappe +from frappe.utils.user import get_fullname_and_avatar from frappe.website.doctype.website_settings.website_settings import get_website_settings from buzz.events.doctype.buzz_theme.buzz_theme import resolve_theme, theme_css @@ -27,8 +28,14 @@ def as_context(self) -> dict: "brand": self.brand(), "favicon": self.settings.favicon or DEFAULT_FAVICON, "is_guest": self.is_guest, + **self.user_info(), } + def user_info(self) -> dict: + # www pages get these from TemplatePage; a website generator's DocumentPage does not. + info = get_fullname_and_avatar(frappe.session.user) + return {"user": info.name, "fullname": info.fullname, "user_image": info.avatar} + def brand(self) -> dict: # Same logo the dashboard's navbar shows (brand_image in get_user_info) logo = self.settings.banner_image or self.settings.app_logo or DEFAULT_LOGO diff --git a/dashboard/src/components/dashboard/events/EventLinks.vue b/dashboard/src/components/dashboard/events/EventLinks.vue index c3c61454..274ae251 100644 --- a/dashboard/src/components/dashboard/events/EventLinks.vue +++ b/dashboard/src/components/dashboard/events/EventLinks.vue @@ -6,6 +6,10 @@ import EventLinkDialog from "@/components/dashboard/events/EventLinkDialog.vue" import type { EventExternalLink } from "@/types" import { hostOf, linkIconClass } from "@/utils/eventLinks" +const props = withDefaults(defineProps<{ hint?: string }>(), { + hint: "Venue map, slides, community chat — anything attendees should have one tap away.", +}) + const links = defineModel({ required: true }) const dialogOpen = ref(false) @@ -71,7 +75,7 @@ function remove() {

- {{ __("Venue map, slides, community chat — anything attendees should have one tap away.") }} + {{ __(props.hint) }}

diff --git a/dashboard/src/components/dashboard/teams/ManageTeamPanel.vue b/dashboard/src/components/dashboard/teams/ManageTeamPanel.vue index 38c46978..ba8c8ed4 100644 --- a/dashboard/src/components/dashboard/teams/ManageTeamPanel.vue +++ b/dashboard/src/components/dashboard/teams/ManageTeamPanel.vue @@ -13,9 +13,10 @@ import { computed, reactive, ref, watch } from "vue" import AvatarUploader from "@/components/common/AvatarUploader.vue" import AddMembersDialog from "@/components/dashboard/teams/AddMembersDialog.vue" import TeamMembersTable from "@/components/dashboard/teams/TeamMembersTable.vue" +import TeamPublicPage from "@/components/dashboard/teams/TeamPublicPage.vue" import { reloadTeams, updateTeam, useTeamOverview } from "@/data/teams" import { serverErrorMessage } from "@/utils/serverError" -import { canManageMembers } from "@/utils/teamRoles" +import { canEditPublicPage, canManageMembers } from "@/utils/teamRoles" const props = defineProps<{ team: string; teamName: string }>() defineEmits<{ back: [] }>() @@ -37,6 +38,7 @@ const isAdding = ref(false) const overview = useTeamOverview(props.team) const canManage = computed(() => canManageMembers(overview.data?.my_role)) +const canEditPage = computed(() => canEditPublicPage(overview.data?.my_role)) const form = reactive({ team_name: props.teamName, logo: null as string | null }) @@ -118,6 +120,12 @@ async function refresh() { + +

{{ __("Team Members") }}

diff --git a/dashboard/src/components/dashboard/teams/TeamPublicPage.vue b/dashboard/src/components/dashboard/teams/TeamPublicPage.vue new file mode 100644 index 00000000..fea455ec --- /dev/null +++ b/dashboard/src/components/dashboard/teams/TeamPublicPage.vue @@ -0,0 +1,118 @@ + + +