From 64585faff00d40c178bbb0a07bcb91ab5fd1a7c2 Mon Sep 17 00:00:00 2001 From: Pranav Date: Mon, 18 May 2026 12:30:08 -0700 Subject: [PATCH] feat: Add a documentation layout design for public help center portal (#14403) https://github.com/user-attachments/assets/fc4d15f9-2b54-4627-940f-94772ec739b1 --------- Co-authored-by: Muhsin Keloth Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../api/v1/portals/articles_controller.rb | 14 +- .../public/api/v1/portals/base_controller.rb | 12 +- .../api/v1/portals/categories_controller.rb | 13 +- .../public/api/v1/portals_controller.rb | 27 +++ app/helpers/portal_helper.rb | 11 ++ app/javascript/portal/application.scss | 17 +- .../portal/components/PublicArticleSearch.vue | 36 ++++ .../portal/components/PublicSearchInput.vue | 59 ++++++- .../portal/components/SearchSuggestions.vue | 51 +++--- .../portal/components/SidebarThemeToggle.vue | 136 ++++++++++++++++ .../portal/components/TableOfContents.vue | 6 +- app/javascript/portal/portalHelpers.js | 45 ++++- .../portal/specs/SearchSuggestions.spec.js | 1 + app/models/portal.rb | 6 +- app/views/layouts/_portal_head.html.erb | 41 +++++ app/views/layouts/_portal_scripts.html.erb | 92 +++++++++++ .../layouts/portal.html+documentation.erb | 26 +++ app/views/layouts/portal.html.erb | 154 +----------------- .../v1/models/_search_article.json.jbuilder | 2 +- .../public/api/v1/portals/_authors.html.erb | 7 +- .../public/api/v1/portals/_hero.html.erb | 2 +- .../v1/portals/_uncategorized-block.html.erb | 4 +- .../articles/show.html+documentation.erb | 44 +++++ .../categories/show.html+documentation.erb | 25 +++ .../_article_card.html.erb | 12 ++ .../_avatar_group.html.erb | 10 ++ .../documentation_layout/_breadcrumb.html.erb | 31 ++++ .../_category_card.html.erb | 24 +++ .../_empty_state.html.erb | 3 + .../documentation_layout/_footer.html.erb | 50 ++++++ .../documentation_layout/_hero.html.erb | 32 ++++ .../_section_header.html.erb | 6 + .../documentation_layout/_sidebar.html.erb | 89 ++++++++++ .../documentation_layout/_topbar.html.erb | 72 ++++++++ .../_user_avatar.html.erb | 18 ++ .../articles/_actions.html.erb | 42 +++++ .../articles/_header.html.erb | 32 ++++ .../articles/_meta_head.html.erb | 17 ++ .../categories/_header.html.erb | 33 ++++ .../categories/_meta_head.html.erb | 11 ++ .../v1/portals/show.html+documentation.erb | 34 ++++ config/locales/ar.yml | 22 +++ config/locales/en.yml | 23 +++ config/locales/fr.yml | 22 +++ config/locales/pt_BR.yml | 22 +++ config/routes.rb | 8 +- .../v1/portals/articles_controller_spec.rb | 32 ++++ .../v1/portals/categories_controller_spec.rb | 5 +- tailwind.config.js | 5 +- theme/colors.js | 3 + 50 files changed, 1274 insertions(+), 215 deletions(-) create mode 100644 app/javascript/portal/components/SidebarThemeToggle.vue create mode 100644 app/views/layouts/_portal_head.html.erb create mode 100644 app/views/layouts/_portal_scripts.html.erb create mode 100644 app/views/layouts/portal.html+documentation.erb create mode 100644 app/views/public/api/v1/portals/articles/show.html+documentation.erb create mode 100644 app/views/public/api/v1/portals/categories/show.html+documentation.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_article_card.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_avatar_group.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_breadcrumb.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_category_card.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_empty_state.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_footer.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_hero.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_section_header.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_sidebar.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_user_avatar.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/articles/_actions.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/articles/_header.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/categories/_header.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb create mode 100644 app/views/public/api/v1/portals/show.html+documentation.erb diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index 2bbfafcc7..6a83d08fb 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -1,9 +1,11 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController - before_action :ensure_custom_domain_request, only: [:show, :index] + before_action :ensure_custom_domain_request, only: [:show, :index, :show_markdown] before_action :portal + before_action :set_portal_layout + before_action :set_view_variant before_action :ensure_portal_feature_enabled before_action :set_category, except: [:index, :show, :tracking_pixel] - before_action :set_article, only: [:show] + before_action :set_article, only: [:show, :show_markdown] layout 'portal' def index @@ -21,6 +23,13 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B def show @og_image_url = helpers.set_og_image_url(@portal.name, @article.title) + @parsed_content = render_article_content(@article.content.to_s) + end + + def show_markdown + return head :not_found unless @article&.published? + + render plain: @article.content.to_s, content_type: 'text/markdown; charset=utf-8' end def tracking_pixel @@ -62,7 +71,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B def set_article @article = @portal.articles.find_by(slug: permitted_params[:article_slug]) - @parsed_content = render_article_content(@article.content.to_s) end def set_category diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index 46158bce9..2991b84d2 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -7,6 +7,8 @@ class Public::Api::V1::Portals::BaseController < PublicController around_action :set_locale after_action :allow_iframe_requests + PORTAL_LAYOUTS = %w[classic documentation].freeze + private def show_plain_layout @@ -17,6 +19,14 @@ class Public::Api::V1::Portals::BaseController < PublicController @theme_from_params = params[:theme] if %w[dark light].include?(params[:theme]) end + def set_portal_layout + @portal_layout = PORTAL_LAYOUTS.include?(@portal&.layout) ? @portal.layout : 'classic' + end + + def set_view_variant + request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled + end + def portal @portal ||= Portal.find_by!(slug: params[:slug], archived: false) end @@ -42,7 +52,7 @@ class Public::Api::V1::Portals::BaseController < PublicController article_locale = if article.category.present? article.category.locale else - article.portal.default_locale + article.locale end @locale = validate_and_get_locale(article_locale) I18n.with_locale(@locale, &) diff --git a/app/controllers/public/api/v1/portals/categories_controller.rb b/app/controllers/public/api/v1/portals/categories_controller.rb index 3fb200269..d1ebe92c6 100644 --- a/app/controllers/public/api/v1/portals/categories_controller.rb +++ b/app/controllers/public/api/v1/portals/categories_controller.rb @@ -1,12 +1,18 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController before_action :ensure_custom_domain_request, only: [:show, :index] before_action :portal + before_action :set_portal_layout + before_action :set_view_variant before_action :ensure_portal_feature_enabled before_action :set_category, only: [:show] + before_action :load_category_articles, only: [:show], if: -> { @portal_layout == 'documentation' } layout 'portal' def index - @categories = @portal.categories.order(position: :asc) + respond_to do |format| + format.html { redirect_to public_portal_locale_path(@portal.slug, params[:locale]), status: :moved_permanently } + format.json { @categories = @portal.categories.order(position: :asc) } + end end def show @@ -21,4 +27,9 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals: Rails.logger.info "Category: not found for slug: #{params[:category_slug]}" render_404 && return if @category.blank? end + + def load_category_articles + @articles = @category.articles.published.order(:position).includes(:author) + @category_authors = @articles.filter_map(&:author).uniq + end end diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index a187ca8a8..63f44b052 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -2,7 +2,10 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl before_action :ensure_custom_domain_request, only: [:show] before_action :redirect_to_portal_with_locale, only: [:show] before_action :portal + before_action :set_portal_layout + before_action :set_view_variant before_action :ensure_portal_feature_enabled + before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' } layout 'portal' def show @@ -28,4 +31,28 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl portal redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}" end + + def load_home_data + base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category) + @visible_categories = @portal.categories + .where(locale: @locale) + .joins(:articles).where(articles: { status: :published }) + .order(position: :asc) + .group('categories.id') + @popular_topics = @visible_categories.first(3) + @featured = base_articles.order_by_views.limit(6) + @category_contributors = build_category_contributors(@visible_categories) + end + + def build_category_contributors(categories) + category_ids = categories.map(&:id) + return {} if category_ids.empty? + + @portal.articles + .published + .where(locale: @locale, category_id: category_ids) + .includes(:author) + .group_by(&:category_id) + .transform_values { |articles| articles.filter_map(&:author).uniq.first(3) } + end end diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb index 15de0fbd7..65166145c 100644 --- a/app/helpers/portal_helper.rb +++ b/app/helpers/portal_helper.rb @@ -96,4 +96,15 @@ module PortalHelper colors[username.length % colors.size] end + + def format_authors_label(authors) + return if authors.blank? + + names = authors.map(&:available_name) + return names.to_sentence if names.size <= 3 + + I18n.t('public_portal.sidebar.authors_others', + names: names.first(2).join(', '), + count: authors.size - 2) + end end diff --git a/app/javascript/portal/application.scss b/app/javascript/portal/application.scss index 4aaf36191..6d83f701a 100644 --- a/app/javascript/portal/application.scss +++ b/app/javascript/portal/application.scss @@ -5,9 +5,18 @@ @import 'widget/assets/scss/reset'; @import 'shared/assets/fonts/InterDisplay/inter-display'; +@import 'shared/assets/fonts/inter'; +@import 'dashboard/assets/scss/next-colors'; html, body { + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + height: 100%; + letter-spacing: 0.2px; +} + +.font-default { font-family: 'InterDisplay', -apple-system, @@ -23,10 +32,6 @@ body { 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji'; - -moz-osx-font-smoothing: grayscale; - -webkit-font-smoothing: antialiased; - height: 100%; - letter-spacing: 0.2px; } // Taking these utils from tailwind 3.x.x, need to remove once we upgrade @@ -49,3 +54,7 @@ body { } } } + +.turbolinks-progress-bar { + background-color: var(--dynamic-portal-color); +} diff --git a/app/javascript/portal/components/PublicArticleSearch.vue b/app/javascript/portal/components/PublicArticleSearch.vue index e27f56d43..086073fed 100644 --- a/app/javascript/portal/components/PublicArticleSearch.vue +++ b/app/javascript/portal/components/PublicArticleSearch.vue @@ -9,6 +9,17 @@ export default { PublicSearchInput, SearchSuggestions, }, + props: { + size: { + type: String, + default: 'default', + validator: value => ['small', 'default'].includes(value), + }, + showKbd: { + type: Boolean, + default: false, + }, + }, emits: ['input', 'blur'], data() { return { @@ -36,6 +47,13 @@ export default { const { searchTranslations = {} } = window.portalConfig; return searchTranslations; }, + kbdLabel() { + if (!this.showKbd) return ''; + const isMac = /Mac|iPhone|iPad|iPod/i.test( + navigator.platform || navigator.userAgent + ); + return isMac ? '⌘ K' : 'Ctrl K'; + }, }, watch: { @@ -44,7 +62,12 @@ export default { }, }, + mounted() { + if (this.showKbd) document.addEventListener('keydown', this.onKeydown); + }, + unmounted() { + if (this.showKbd) document.removeEventListener('keydown', this.onKeydown); clearTimeout(this.typingTimer); }, @@ -83,6 +106,16 @@ export default { clearSearchTerm() { this.searchTerm = ''; }, + onKeydown(e) { + if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { + e.preventDefault(); + if (this.$refs.searchInput) this.$refs.searchInput.focusInput(); + } + if (e.key === 'Escape') { + this.closeSearch(); + if (this.$refs.searchInput) this.$refs.searchInput.blurInput(); + } + }, async fetchArticlesByQuery() { const query = this.normalizedSearchTerm; if (!query) { @@ -112,8 +145,11 @@ export default {