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 <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Pranav
2026-05-18 12:30:08 -07:00
committed by GitHub
co-authored by Muhsin Keloth Sivin Varghese
parent e05008e0a4
commit 64585faff0
50 changed files with 1274 additions and 215 deletions
@@ -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
@@ -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, &)
@@ -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
@@ -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
+11
View File
@@ -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
+13 -4
View File
@@ -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);
}
@@ -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 {
<template>
<div v-on-clickaway="closeSearch" class="relative w-full max-w-5xl my-4">
<PublicSearchInput
ref="searchInput"
:search-term="searchTerm"
:search-placeholder="searchTranslations.searchPlaceholder"
:size="size"
:kbd="kbdLabel"
@update:search-term="onUpdateSearchTerm"
@focus="openSearch"
/>
@@ -1,8 +1,8 @@
<script setup>
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { ref } from 'vue';
import { ref, computed } from 'vue';
defineProps({
const props = defineProps({
searchTerm: {
type: [String, Number],
default: '',
@@ -11,10 +11,33 @@ defineProps({
type: String,
default: '',
},
size: {
type: String,
default: 'default', // 'small' | 'default'
validator: value => ['small', 'default'].includes(value),
},
kbd: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:searchTerm', 'focus', 'blur']);
const isFocused = ref(false);
const inputRef = ref(null);
const sizeClasses = computed(() => {
if (props.size === 'small') {
return {
wrapper: 'h-9 px-3 py-1',
input: 'text-sm py-0',
};
}
return {
wrapper: 'h-12 px-5 py-2',
input: 'text-base py-2',
};
});
const onChange = e => {
emit('update:searchTerm', e.target.value);
@@ -29,26 +52,46 @@ const onBlur = e => {
isFocused.value = false;
emit('blur', e.target.value);
};
const focusInput = () => {
if (inputRef.value) inputRef.value.focus();
};
const blurInput = () => {
if (inputRef.value) inputRef.value.blur();
};
defineExpose({ focusInput, blurInput });
</script>
<template>
<div
class="w-full flex items-center rounded-lg border-solid border h-12 bg-white dark:bg-slate-900 px-5 py-2 text-slate-600 dark:text-slate-200"
:class="{
'shadow border-woot-100 dark:border-woot-700': isFocused,
'border-slate-50 dark:border-slate-800 shadow-sm': !isFocused,
}"
class="w-full flex items-center gap-2 rounded-lg border-solid border bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-200 transition-all duration-500 ease-out"
:class="[
sizeClasses.wrapper,
isFocused
? 'shadow border-n-portal ring-4 ring-n-portal-soft'
: 'border-slate-50 dark:border-slate-800 shadow-sm ring-0 ring-transparent',
]"
>
<FluentIcon icon="search" />
<input
ref="inputRef"
:value="searchTerm"
type="text"
class="w-full focus:outline-none text-base h-full bg-white dark:bg-slate-900 px-2 py-2 text-slate-700 dark:text-slate-100 placeholder-slate-500"
class="flex-1 min-w-0 focus:outline-none h-full bg-transparent px-2 text-slate-700 dark:text-slate-100 placeholder-slate-500"
:class="sizeClasses.input"
:placeholder="searchPlaceholder"
role="search"
@input="onChange"
@focus="onFocus"
@blur="onBlur"
/>
<kbd
v-if="kbd"
class="shrink-0 inline-flex items-center text-xs font-medium text-n-slate-11 bg-n-alpha-2 border border-solid border-n-weak rounded px-1.5 py-0.5 font-inter"
>
{{ kbd }}
</kbd>
</div>
</template>
@@ -29,7 +29,7 @@ export default {
setup(props) {
const selectedIndex = ref(-1);
const portalSearchSuggestionsRef = ref(null);
const { highlightContent } = useMessageFormatter();
const { highlightContent, getPlainText } = useMessageFormatter();
const adjustScroll = () => {
nextTick(() => {
portalSearchSuggestionsRef.value.scrollTop = 102 * selectedIndex.value;
@@ -37,9 +37,7 @@ export default {
};
const isSearchItemActive = index => {
return index === selectedIndex.value
? 'bg-slate-25 dark:bg-slate-800'
: 'bg-white dark:bg-slate-900';
return index === selectedIndex.value ? 'bg-n-portal-soft' : '';
};
useKeyboardNavigableList({
@@ -53,6 +51,7 @@ export default {
portalSearchSuggestionsRef,
isSearchItemActive,
highlightContent,
getPlainText,
};
},
@@ -70,7 +69,7 @@ export default {
return this.highlightContent(
content,
this.searchTerm,
'bg-slate-100 dark:bg-slate-700 font-semibold text-slate-600 dark:text-slate-200'
'bg-n-portal-soft text-n-portal font-semibold rounded-sm px-1'
);
},
},
@@ -80,46 +79,46 @@ export default {
<template>
<div
ref="portalSearchSuggestionsRef"
class="p-5 mt-2 overflow-y-auto text-sm bg-white border border-solid rounded-lg shadow-xl hover:shadow-lg dark:bg-slate-900 max-h-96 scroll-py-2 text-slate-700 dark:text-slate-100 border-slate-50 dark:border-slate-800"
class="mt-2 overflow-y-auto bg-white dark:bg-n-slate-2 border border-solid border-n-weak rounded-xl shadow-2xl max-h-96 p-2"
>
<div
v-if="isLoading"
class="text-sm font-medium text-slate-400 dark:text-slate-700"
>
<div v-if="isLoading" class="px-3 py-6 text-sm text-n-slate-11 text-center">
{{ loadingPlaceholder }}
</div>
<ul
v-if="shouldShowResults"
class="flex flex-col gap-4 text-sm bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-100"
role="listbox"
>
<ul v-if="shouldShowResults" class="flex flex-col gap-0.5" role="listbox">
<li
v-for="(article, index) in items"
:id="article.id"
:key="article.id"
class="flex items-center p-4 border border-solid rounded-lg cursor-pointer select-none group hover:bg-slate-25 dark:hover:bg-slate-800 border-slate-100 dark:border-slate-800"
class="rounded-md cursor-pointer select-none group transition-colors hover:bg-n-alpha-2"
:class="isSearchItemActive(index)"
role="option"
tabindex="-1"
@mouse-enter="onHover(index)"
@mouse-leave="onHover(-1)"
>
<a class="flex flex-col gap-1 overflow-y-hidden" :href="article.link">
<a
class="flex items-start gap-3 px-3 py-2.5 overflow-hidden"
:href="article.link"
>
<span
v-dompurify-html="prepareContent(article.title)"
class="flex-auto w-full overflow-hidden text-base font-semibold leading-6 truncate text-ellipsis whitespace-nowrap"
/>
<div
v-dompurify-html="prepareContent(article.content)"
class="overflow-hidden text-sm line-clamp-2 text-ellipsis whitespace-nowrap text-slate-600 dark:text-slate-300"
class="i-lucide-file-text size-4 mt-0.5 flex-shrink-0 text-n-slate-10 group-hover:text-n-slate-11"
aria-hidden="true"
/>
<span class="min-w-0 flex-1 flex flex-col gap-1">
<span
v-dompurify-html="prepareContent(getPlainText(article.title))"
class="block text-base font-520 text-n-slate-12 truncate"
/>
<span
v-dompurify-html="prepareContent(article.content)"
class="block text-sm text-n-slate-11 line-clamp-1"
/>
</span>
</a>
</li>
</ul>
<div
v-if="showEmptyResults"
class="text-sm font-medium text-slate-400 dark:text-slate-700"
class="px-3 py-6 text-sm text-n-slate-11 text-center"
>
{{ emptyPlaceholder }}
</div>
@@ -0,0 +1,136 @@
<script>
import { directive as onClickaway } from 'vue3-click-away';
const OPTION_DEFS = [
{ value: 'system', icon: 'i-lucide-monitor', fallback: 'System' },
{ value: 'light', icon: 'i-lucide-sun', fallback: 'Light' },
{ value: 'dark', icon: 'i-lucide-moon', fallback: 'Dark' },
];
const readLabels = () => {
const el = document.querySelector('#sidebar-theme-toggle');
const data = (el && el.dataset) || {};
return {
trigger: data.labelTrigger || 'Appearance',
options: OPTION_DEFS.map(opt => ({
...opt,
label:
data[
`label${opt.value.charAt(0).toUpperCase()}${opt.value.slice(1)}`
] || opt.fallback,
})),
};
};
export default {
directives: { onClickaway },
data() {
const labels = readLabels();
return {
mode: 'system',
systemPrefersDark: false,
open: false,
options: labels.options,
triggerLabel: labels.trigger,
};
},
computed: {
triggerIcon() {
const opt = this.options.find(o => o.value === this.mode);
return opt ? opt.icon : 'i-lucide-monitor';
},
isDarkActive() {
if (this.mode === 'dark') return true;
if (this.mode === 'light') return false;
return this.systemPrefersDark;
},
},
mounted() {
try {
const stored = localStorage.theme;
this.mode = stored === 'dark' || stored === 'light' ? stored : 'system';
} catch (e) {
this.mode = 'system';
}
if (window.matchMedia) {
this.media = window.matchMedia('(prefers-color-scheme: dark)');
this.systemPrefersDark = this.media.matches;
this.media.addEventListener('change', this.onSystemChange);
}
this.applyTheme();
},
unmounted() {
if (this.media)
this.media.removeEventListener('change', this.onSystemChange);
},
methods: {
onSystemChange(e) {
this.systemPrefersDark = e.matches;
if (this.mode === 'system') this.applyTheme();
},
applyTheme() {
const dark = this.isDarkActive;
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(dark ? 'dark' : 'light');
},
select(value) {
this.mode = value;
try {
if (value === 'system') localStorage.removeItem('theme');
else localStorage.theme = value;
} catch (e) {
/* no-op */
}
this.applyTheme();
this.open = false;
},
toggle() {
this.open = !this.open;
},
close() {
this.open = false;
},
},
};
</script>
<template>
<div v-on-clickaway="close" class="relative">
<button
type="button"
class="flex items-center justify-center w-9 h-9 text-n-slate-11 hover:text-n-slate-12 hover:bg-n-alpha-2 rounded-lg transition"
:aria-label="triggerLabel"
:aria-expanded="open"
@click="toggle"
>
<span class="size-4" :class="[triggerIcon]" aria-hidden="true" />
</button>
<div
v-if="open"
class="absolute end-0 top-full mt-2 bg-n-slate-1 border border-solid border-n-weak rounded-lg shadow-lg p-1 min-w-36 z-50 flex flex-col gap-0.5"
>
<button
v-for="opt in options"
:key="opt.value"
type="button"
class="w-full flex items-center justify-between gap-2.5 px-2.5 py-2 text-sm rounded-md transition"
:class="[
mode === opt.value
? 'bg-n-portal-soft text-n-portal'
: 'text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12',
]"
@click="select(opt.value)"
>
<span class="flex items-center gap-2">
<span class="size-4" :class="[opt.icon]" aria-hidden="true" />
{{ opt.label }}
</span>
<span
v-if="mode === opt.value"
class="i-lucide-check size-3.5"
aria-hidden="true"
/>
</button>
</div>
</div>
</template>
@@ -79,13 +79,13 @@ export default {
},
elementBorderStyles(el) {
if (this.isElementActive(el)) {
return 'border-slate-400 dark:border-slate-50 transition-colors duration-200';
return 'border-n-portal transition-colors duration-200';
}
return 'border-slate-100 dark:border-slate-800';
},
elementTextStyles(el) {
if (this.isElementActive(el)) {
return 'text-slate-900 dark:text-slate-25 transition-colors duration-200';
return 'text-n-portal transition-colors duration-200';
}
return 'text-slate-700 dark:text-slate-100';
},
@@ -113,7 +113,7 @@ export default {
<a
:href="`#${element.slug}`"
data-turbolinks="false"
class="font-medium text-sm tracking-[0.28px] cursor-pointer"
class="font-medium text-sm cursor-pointer"
:class="elementTextStyles(element)"
>
{{ element.title }}
+39 -6
View File
@@ -7,6 +7,7 @@ import { isSameHost } from '@chatwoot/utils';
import slugifyWithCounter from '@sindresorhus/slugify';
import PublicArticleSearch from './components/PublicArticleSearch.vue';
import TableOfContents from './components/TableOfContents.vue';
import SidebarThemeToggle from './components/SidebarThemeToggle.vue';
import { initializeTheme } from './portalThemeHelper.js';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
@@ -78,18 +79,23 @@ export const InitializationHelpers = {
},
initializeSearch: () => {
const isSearchContainerAvailable = document.querySelector('#search-wrap');
if (isSearchContainerAvailable) {
['#search-wrap', '#search-wrap-hero'].forEach(selector => {
const mountPoint = document.querySelector(selector);
if (!mountPoint) return;
const size = mountPoint.dataset.size || 'default';
const showKbd = !!mountPoint.dataset.kbd;
// eslint-disable-next-line vue/one-component-per-file
const app = createApp({
components: { PublicArticleSearch },
template: '<PublicArticleSearch />',
data() {
return { size, showKbd };
},
template: '<PublicArticleSearch :size="size" :show-kbd="showKbd" />',
});
app.use(VueDOMPurifyHTML, domPurifyConfig);
app.directive('on-clickaway', onClickaway);
app.mount('#search-wrap');
}
app.mount(selector);
});
},
initializeTableOfContents: () => {
@@ -109,6 +115,31 @@ export const InitializationHelpers = {
}
},
initializeSidebarThemeToggle: () => {
const mountPoint = document.querySelector('#sidebar-theme-toggle');
if (mountPoint) {
// eslint-disable-next-line vue/one-component-per-file
const app = createApp({
components: { SidebarThemeToggle },
template: '<sidebar-theme-toggle />',
});
app.directive('on-clickaway', onClickaway);
app.mount('#sidebar-theme-toggle');
}
},
initializeDetailsClickAway: () => {
document.addEventListener('click', event => {
document
.querySelectorAll('details[data-close-on-clickaway][open]')
.forEach(details => {
if (!details.contains(event.target)) {
details.removeAttribute('open');
}
});
});
},
appendPlainParamToURLs: () => {
[...document.getElementsByTagName('a')].forEach(aTagElement => {
if (aTagElement.href && aTagElement.href.includes('/hc/')) {
@@ -143,6 +174,8 @@ export const InitializationHelpers = {
InitializationHelpers.navigateToLocalePage();
InitializationHelpers.initializeSearch();
InitializationHelpers.initializeTableOfContents();
InitializationHelpers.initializeSidebarThemeToggle();
InitializationHelpers.initializeDetailsClickAway();
}
},
@@ -9,6 +9,7 @@ vi.mock('dashboard/composables/useKeyboardNavigableList', () => ({
vi.mock('shared/composables/useMessageFormatter', () => ({
useMessageFormatter: () => ({
highlightContent: content => content,
getPlainText: content => content,
}),
}));
+5 -1
View File
@@ -46,7 +46,7 @@ class Portal < ApplicationRecord
scope :active, -> { where(archived: false) }
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token].freeze
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout].freeze
def file_base_data
{
@@ -94,6 +94,10 @@ class Portal < ApplicationRecord
page_title.presence || name
end
def layout
config_value('layout').presence || 'classic'
end
private
def config_json_format
+41
View File
@@ -0,0 +1,41 @@
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1">
<meta name="turbolinks-cache-control" content="no-cache">
<meta name="viewport" content="width=device-width, initial-scale=1">
<%= vite_client_tag %>
<%= vite_javascript_tag 'portal' %>
<style>
.appearance-menu[data-current-theme="system"] .check-mark-icon:is(.light-theme, .dark-theme),
.appearance-menu[data-current-theme="dark"] .check-mark-icon:is(.light-theme, .system-theme),
.appearance-menu[data-current-theme="light"] .check-mark-icon:is(.dark-theme, .system-theme) {
display: none;
}
</style>
<%= csrf_meta_tags %>
<% if content_for?(:head) %>
<%= yield(:head) %>
<% else %>
<title><%= @portal.display_title %></title>
<% end %>
<% if @portal.logo.present? %>
<link rel="icon" href="<%= url_for(@portal.logo) %>">
<% end %>
<% unless @theme_from_params.blank? %>
<%# this adds the theme from params, ensuring that there a localstorage value set %>
<%# this will further trigger the next script to ensure color mode is toggled without a FOUC %>
<script>localStorage.theme = '<%= @theme_from_params %>';</script>
<% end %>
<script>
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
// we can use document.body here but that would mean pushing this script inside the body
// since the body is not created yet. This is done to avoid FOUC, at a tiny cost of Time to Interactive
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
document.documentElement.classList.add('light')
}
</script>
@@ -0,0 +1,92 @@
<% if @article.present? %>
<script>
(function() {
let viewTracked = false;
const trackView = function() {
if (!viewTracked) {
viewTracked = true;
const img = new Image();
img.src = '<%= request.base_url %>/hc/<%= @portal.slug %>/articles/<%= @article.slug %>.png';
}
};
const addTrackingListeners = function() {
const events = ['mouseenter', 'touchstart', 'focus'];
events.forEach(event => {
document.body.addEventListener(event, function() {
setTimeout(trackView, 5000);
}, { once: true });
});
};
addTrackingListeners();
})();
</script>
<% end %>
<style>
html.dark {
--dynamic-portal-bg: <%= generate_portal_bg(@portal.color, 'dark') %>;
--dynamic-portal-bg-gradient: <%= generate_gradient_to_bottom('dark') %>;
--dynamic-hover-bg-color: <%= generate_portal_hover_color(@portal.color , 'dark') %>;
--dynamic-portal-color: <%= @portal.color %>;
--dynamic-portal-color-soft: color-mix(in srgb, <%= @portal.color %> 18%, transparent);
--dynamic-portal-color-faint: color-mix(in srgb, <%= @portal.color %> 6%, transparent);
}
html.light {
--dynamic-portal-bg: <%= generate_portal_bg(@portal.color, 'light') %>;
--dynamic-portal-bg-gradient: <%= generate_gradient_to_bottom('light') %>;
--dynamic-hover-bg-color: <%= generate_portal_hover_color(@portal.color , 'light') %>;
--dynamic-portal-color: <%= @portal.color %>;
--dynamic-portal-color-soft: color-mix(in srgb, <%= @portal.color %> 12%, transparent);
--dynamic-portal-color-faint: color-mix(in srgb, <%= @portal.color %> 5%, transparent);
}
/* Portal background */
#portal-bg {
background: var(--dynamic-portal-bg);
}
/* Portal background gradient */
#portal-bg-gradient {
background-image: var(--dynamic-portal-bg-gradient);
}
/* Category block item hover color */
#category-item:hover {
background-color: var(--dynamic-hover-bg-color);
}
/* Header section */
#header-action-button:hover,
#toggle-appearance:hover,
#toggle-theme-button:hover {
color: var(--dynamic-hover-color);
stroke: var(--dynamic-hover-color);
}
#category-block:hover {
border-color: var(--dynamic-hover-color);
}
#category-block:hover #category-name {
color: var(--dynamic-hover-color);
}
</style>
<script>
window.portalConfig = {
portalSlug: '<%= @portal.slug %>',
portalColor: '<%= @portal.color %>',
theme: '<%= @theme_from_params %>',
customDomain: '<%= @portal.custom_domain %>',
hostURL: '<%= ENV.fetch('FRONTEND_URL', '') %>',
localeCode: '<%= @locale %>',
searchTranslations: {
searchPlaceholder: '<%= I18n.t('public_portal.search.search_placeholder') %>',
emptyPlaceholder: '<%= I18n.t('public_portal.search.empty_placeholder') %>',
loadingPlaceholder: '<%= I18n.t('public_portal.search.loading_placeholder') %>',
resultsTitle: '<%= I18n.t('public_portal.search.results_title') %>',
},
isPlainLayoutEnabled: '<%= @is_plain_layout_enabled %>',
tocHeader: '<%= I18n.t('public_portal.toc_header') %>'
};
</script>
<% if @portal.channel_web_widget.present? && !@is_plain_layout_enabled %>
<%= @portal.channel_web_widget.web_widget_script.html_safe %>
<% end %>
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="<%= I18n.locale %>">
<head>
<%= render 'layouts/portal_head' %>
</head>
<body class="font-inter">
<div id="portal" class="antialiased">
<main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
<%= render 'public/api/v1/portals/documentation_layout/topbar',
portal: @portal, locale: @locale, article: @article, category: @category %>
<div class="bg-white dark:bg-n-slate-2 flex-1 flex font-inter tracking-normal [font-optical-sizing:auto]">
<div class="max-w-screen-2xl w-full mx-auto flex flex-1">
<%= render 'public/api/v1/portals/documentation_layout/sidebar',
portal: @portal, locale: @locale, article: @article, category: @category %>
<main class="flex-1 min-w-0 flex flex-col">
<%= yield %>
<%= render 'public/api/v1/portals/documentation_layout/footer',
portal: @portal, global_config: @global_config %>
</main>
</div>
</div>
</main>
</div>
<%= render 'layouts/portal_scripts' %>
</body>
</html>
+5 -149
View File
@@ -1,160 +1,16 @@
<%#
# Application Layout
This view template is used as the layout
for every page that Administrate generates.
By default, it renders:
- Navigation
- Content for a search bar
(if provided by a `content_for` block in a nested page)
- Flashes
- Links to stylesheets and JavaScripts
- The appearance dropdown styles are added to the top to prevent FOUC
%>
<!DOCTYPE html>
<html lang="<%= I18n.locale %>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1">
<meta name= "turbolinks-cache-control" content= "no-cache">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<%= vite_client_tag %>
<%= vite_javascript_tag 'portal' %>
<style>
.appearance-menu[data-current-theme="system"] .check-mark-icon:is(.light-theme, .dark-theme),
.appearance-menu[data-current-theme="dark"] .check-mark-icon:is(.light-theme, .system-theme),
.appearance-menu[data-current-theme="light"] .check-mark-icon:is(.dark-theme, .system-theme) {
display: none;
}
</style>
<%= csrf_meta_tags %>
<% if content_for?(:head) %>
<%= yield(:head) %>
<% else %>
<title><%= @portal.display_title %></title>
<% end %>
<% if @portal.logo.present? %>
<link rel="icon" href="<%= url_for(@portal.logo) %>">
<% end %>
<% unless @theme_from_params.blank? %>
<%# this adds the theme from params, ensuring that there a localstorage value set %>
<%# this will further trigger the next script to ensure color mode is toggled without a FOUC %>
<script>localStorage.theme = '<%= @theme_from_params %>';</script>
<% end %>
<script>
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
// we can use document.body here but that would mean pushing this script inside the body
// since the body is not created yet. This is done to avoid FOUC, at a tiny cost of Time to Interactive
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
document.documentElement.classList.add('light')
}
</script>
<%= render 'layouts/portal_head' %>
</head>
<body>
<body class="font-default">
<div id="portal" class="antialiased">
<main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
<% if !@is_plain_layout_enabled %>
<%= render "public/api/v1/portals/header", portal: @portal %>
<% end %>
<%= render 'public/api/v1/portals/header', portal: @portal unless @is_plain_layout_enabled %>
<%= yield %>
<% if !(@is_plain_layout_enabled || @portal.account.feature_enabled?('disable_branding')) %>
<%= render "public/api/v1/portals/footer" %>
<% end %>
<%= render 'public/api/v1/portals/footer' unless @is_plain_layout_enabled || @portal.account.feature_enabled?('disable_branding') %>
</main>
</div>
<% if @article.present? %>
<script>
(function() {
let viewTracked = false;
const trackView = function() {
if (!viewTracked) {
viewTracked = true;
const img = new Image();
img.src = '<%= request.base_url %>/hc/<%= @portal.slug %>/articles/<%= @article.slug %>.png';
}
};
const addTrackingListeners = function() {
const events = ['mouseenter', 'touchstart', 'focus'];
events.forEach(event => {
document.body.addEventListener(event, function() {
setTimeout(trackView, 5000);
}, { once: true });
});
};
addTrackingListeners();
})();
</script>
<% end %>
<%= render 'layouts/portal_scripts' %>
</body>
<style>
html.dark {
--dynamic-portal-bg: <%= generate_portal_bg(@portal.color, 'dark') %>;
--dynamic-portal-bg-gradient: <%= generate_gradient_to_bottom('dark') %>;
--dynamic-hover-bg-color: <%= generate_portal_hover_color(@portal.color , 'dark') %>;
}
html.light {
--dynamic-portal-bg: <%= generate_portal_bg(@portal.color, 'light') %>;
--dynamic-portal-bg-gradient: <%= generate_gradient_to_bottom('light') %>;
--dynamic-hover-bg-color: <%= generate_portal_hover_color(@portal.color , 'light') %>;
}
/* Portal background */
#portal-bg {
background: var(--dynamic-portal-bg);
}
/* Portal background gradient */
#portal-bg-gradient {
background-image: var(--dynamic-portal-bg-gradient);
}
/* Category block item hover color */
#category-item:hover {
background-color: var(--dynamic-hover-bg-color);
}
/* Header section */
#header-action-button:hover,
#toggle-appearance:hover,
#toggle-theme-button:hover {
color: var(--dynamic-hover-color);
stroke: var(--dynamic-hover-color);
}
#category-block:hover {
border-color: var(--dynamic-hover-color);
}
#category-block:hover #category-name {
color: var(--dynamic-hover-color);
}
</style>
<script>
window.portalConfig = {
portalSlug: '<%= @portal.slug %>',
portalColor: '<%= @portal.color %>',
theme: '<%= @theme_from_params %>',
customDomain: '<%= @portal.custom_domain %>',
hostURL: '<%= ENV.fetch('FRONTEND_URL', '') %>',
localeCode: '<%= @locale %>',
searchTranslations: {
searchPlaceholder: '<%= I18n.t('public_portal.search.search_placeholder') %>',
emptyPlaceholder: '<%= I18n.t('public_portal.search.empty_placeholder') %>',
loadingPlaceholder: '<%= I18n.t('public_portal.search.loading_placeholder') %>',
resultsTitle: '<%= I18n.t('public_portal.search.results_title') %>',
},
isPlainLayoutEnabled: '<%= @is_plain_layout_enabled %>',
tocHeader: '<%= I18n.t('public_portal.toc_header') %>'
};
</script>
<% if @portal.channel_web_widget.present? && !@is_plain_layout_enabled %>
<%= @portal.channel_web_widget.web_widget_script.html_safe %>
<% end %>
</html>
@@ -1,4 +1,4 @@
content = article.content.to_s.squish
content = ChatwootMarkdownRenderer.new(article.content.to_s).render_markdown_to_plain_text.squish
snippet = excerpt(content, search_query, radius: 110, omission: '...') ||
truncate(content, length: 220, separator: ' ')
@@ -2,8 +2,11 @@
<% if author_count > 0 %>
<div class="flex flex-row items-center gap-1">
<div class="flex items-center ltr:flex-row rtl:flex-row-reverse -space-x-2">
<% category.articles.published.order(position: :asc).map(&:author).uniq.take(3).each do |author| %>
<%= render "public/api/v1/portals/thumbnail", author: author, size: 5 %>
<% z_classes = %w[z-30 z-20 z-10] %>
<% category.articles.published.order(position: :asc).map(&:author).uniq.take(3).each_with_index do |author, idx| %>
<span class="relative <%= z_classes[idx] %>">
<%= render "public/api/v1/portals/thumbnail", author: author, size: 5 %>
</span>
<% end %>
</div>
@@ -18,7 +18,7 @@
<%= portal.header_text %>
</h1>
<p class="text-slate-600 dark:text-slate-200 text-start text-lg leading-normal pt-2 pb-4"><%= I18n.t('public_portal.hero.sub_title') %></p>
<div id="search-wrap" class="w-full"></div>
<div id="search-wrap" class="w-full relative z-30"></div>
</div>
</div>
</section>
@@ -6,7 +6,7 @@
</h3>
</div>
<div class="-mt-4">
<% portal.articles.published.where(category_id: nil).order(position: :asc).take(5).each do |article| %>
<% portal.articles.published.where(category_id: nil, locale: @locale).order(position: :asc).take(5).each do |article| %>
<a
class="leading-7 text-slate-700 dark:text-slate-100"
href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>"
@@ -21,7 +21,7 @@
<% end %>
</div>
<div class="flex justify-between flex-row items-center <%= !@is_plain_layout_enabled && 'px-2' %>">
<span class="text-sm font-medium text-slate-600 dark:text-slate-400"><%= render 'public/api/v1/portals/article_count', article_count: portal.articles.published.where(category_id: nil).size %></span>
<span class="text-sm font-medium text-slate-600 dark:text-slate-400"><%= render 'public/api/v1/portals/article_count', article_count: portal.articles.published.where(category_id: nil, locale: @locale).size %></span>
</div>
</div>
</section>
@@ -0,0 +1,44 @@
<% content_for :head do %>
<%= render 'public/api/v1/portals/documentation_layout/articles/meta_head',
article: @article, portal: @portal, og_image_url: @og_image_url %>
<% end %>
<div class="px-6 md:px-10 py-8 md:py-10">
<div class="flex gap-10">
<article class="flex-1 min-w-0">
<%= render 'public/api/v1/portals/documentation_layout/breadcrumb',
portal: @portal, locale: @locale, category: @article.category, article: @article %>
<%= render 'public/api/v1/portals/documentation_layout/articles/header',
portal: @portal, article: @article %>
<div id="cw-article-content"
class="prose dark:prose-invert max-w-none break-words
prose-headings:font-620
prose-p:font-420 prose-li:font-420 prose-blockquote:font-420 prose-td:font-420
prose-a:text-n-portal prose-a:no-underline hover:prose-a:underline
[&_li>p]:m-0
[&_.tableWrapper]:overflow-x-auto [&_.tableWrapper]:mb-4 [&_.tableWrapper]:rounded-lg [&_.tableWrapper]:border [&_.tableWrapper]:border-solid [&_.tableWrapper]:border-n-weak
[&_table]:!my-0 [&_table]:!min-w-full [&_table]:!border-separate [&_table]:!border-spacing-0
[&_th]:!bg-n-slate-2 [&_th]:!text-n-slate-12 [&_th]:!font-semibold [&_th]:!text-start [&_th]:!px-3 [&_th]:!py-2 [&_th]:!border-0 [&_th]:!border-b [&_th]:!border-solid [&_th]:!border-n-weak
[&_td]:!text-n-slate-11 [&_td]:!align-top [&_td]:!text-start [&_td]:!px-3 [&_td]:!py-2 [&_td]:!border-0 [&_td]:!border-b [&_td]:!border-solid [&_td]:!border-n-weak
[&_th:not(:last-child)]:!border-e [&_td:not(:last-child)]:!border-e
[&_tr:last-child_td]:!border-b-0">
<%= @parsed_content %>
</div>
<% if @article.category %>
<footer class="mt-20 pt-8 border-t border-n-weak flex items-center justify-between gap-4 flex-wrap">
<a href="<%= public_portal_category_path(@portal.slug, @article.category.locale, @article.category.slug) %>"
class="group inline-flex items-center gap-2 px-3 py-2 -ml-3 text-sm font-medium text-n-slate-11 hover:text-n-slate-12 hover:bg-n-alpha-2 rounded-md transition">
<span class="i-lucide-arrow-left size-3.5 transition-transform group-hover:-translate-x-0.5" aria-hidden="true"></span>
<%= I18n.t('public_portal.sidebar.back_to_category', name: @article.category.name) %>
</a>
</footer>
<% end %>
</article>
<aside class="hidden lg:block w-56 shrink-0">
<div class="sticky top-20" id="cw-hc-toc"></div>
</aside>
</div>
</div>
@@ -0,0 +1,25 @@
<% content_for :head do %>
<%= render 'public/api/v1/portals/documentation_layout/categories/meta_head',
category: @category, portal: @portal, og_image_url: @og_image_url %>
<% end %>
<div class="px-6 md:px-10 py-8 md:py-10">
<%= render 'public/api/v1/portals/documentation_layout/breadcrumb',
portal: @portal, locale: @locale, category: @category %>
<%= render 'public/api/v1/portals/documentation_layout/categories/header',
category: @category, articles: @articles, category_authors: @category_authors %>
<% if @articles.empty? %>
<%= render 'public/api/v1/portals/documentation_layout/empty_state' %>
<% else %>
<div class="grid grid-cols-1 gap-4">
<% @articles.each do |article| %>
<%= render 'public/api/v1/portals/documentation_layout/article_card',
portal: @portal,
article: article,
show_category: false %>
<% end %>
</div>
<% end %>
</div>
@@ -0,0 +1,12 @@
<%# locals: (portal:, article:, show_category: true) %>
<a href="<%= public_portal_article_path(portal.slug, article.slug) %>"
class="group flex flex-col p-5 rounded-xl border border-solid border-n-weak hover:border-n-portal hover:shadow-md hover:-translate-y-0.5 transition-all duration-200">
<% if show_category && article.category %>
<span class="inline-flex items-center gap-1 self-start text-xs font-medium text-n-slate-11 mb-3">
<% if article.category.icon.present? %><span><%= article.category.icon %></span><% end %>
<%= article.category.name %>
</span>
<% end %>
<h3 class="text-base font-620 text-n-slate-12 group-hover:text-n-portal transition"><%= article.title %></h3>
<p class="mt-2 text-sm leading-relaxed text-n-slate-11 line-clamp-2"><%= render_category_content(article.content) %></p>
</a>
@@ -0,0 +1,10 @@
<%# locals: (users:, size: :sm, overlap: '-ml-1.5') %>
<% z_classes = %w[z-30 z-20 z-10] %>
<span class="inline-flex items-center">
<% users.first(3).each_with_index do |user, idx| %>
<%= render 'public/api/v1/portals/documentation_layout/user_avatar',
user: user,
size: size,
extra_class: "relative #{z_classes[idx]} #{idx.positive? ? overlap : ''}" %>
<% end %>
</span>
@@ -0,0 +1,31 @@
<%# locals: (portal:, locale:, category: nil, article: nil) %>
<nav class="flex items-center gap-2 text-sm text-n-slate-11 mb-8 flex-wrap">
<a
href="<%= public_portal_locale_path(portal.slug, locale) %>"
class="inline-flex items-center gap-1.5 hover:text-n-slate-12 transition"
>
<span class="i-lucide-house size-3.5" aria-hidden="true"></span>
<%= I18n.t('public_portal.common.home') %>
</a>
<% if category %>
<span class="i-lucide-chevron-right size-3 text-n-slate-9" aria-hidden="true"></span>
<% if article %>
<a
href="<%= public_portal_category_path(portal.slug, category.locale, category.slug) %>"
class="inline-flex items-center gap-1.5 hover:text-n-slate-12 transition"
>
<% if category.icon.present? %><span><%= category.icon %></span><% end %>
<%= category.name %>
</a>
<% else %>
<span class="inline-flex items-center gap-1.5 text-n-slate-12 font-medium truncate">
<% if category.icon.present? %><span><%= category.icon %></span><% end %>
<%= category.name %>
</span>
<% end %>
<% end %>
<% if article %>
<span class="i-lucide-chevron-right size-3 text-n-slate-9" aria-hidden="true"></span>
<span class="text-n-slate-12 font-medium truncate"><%= article.title %></span>
<% end %>
</nav>
@@ -0,0 +1,24 @@
<%# locals: (portal:, category:, contributors: []) %>
<% contributors = Array(contributors) %>
<a href="<%= public_portal_category_path(portal.slug, category.locale, category.slug) %>"
class="group relative flex flex-col p-5 rounded-xl border border-solid border-n-weak hover:border-n-portal hover:shadow-md hover:-translate-y-0.5 transition-all duration-200">
<span class="flex-shrink-0 w-11 h-11 flex items-center justify-center text-2xl rounded-xl bg-n-slate-2 border border-solid border-n-weak">
<%= category.icon.presence || '📁' %>
</span>
<h3 class="mt-4 text-lg font-620 tracking-tight text-n-slate-12 group-hover:text-n-portal transition"><%= category.name %></h3>
<% if category.description.present? %>
<p class="mt-1.5 text-sm leading-relaxed text-n-slate-11"><%= category.description %></p>
<% end %>
<div class="mt-auto pt-5 flex items-center justify-between">
<% if contributors.any? %>
<%= render 'public/api/v1/portals/documentation_layout/avatar_group',
users: contributors, size: :sm, overlap: '-ml-1.5' %>
<% else %>
<span></span>
<% end %>
<span class="inline-flex items-center gap-1 text-sm font-medium text-n-slate-11 group-hover:text-n-portal transition">
<%= I18n.t('public_portal.sidebar.browse') %>
<span class="i-lucide-arrow-right size-3.5 group-hover:translate-x-0.5 transition-transform" aria-hidden="true"></span>
</span>
</div>
</a>
@@ -0,0 +1,3 @@
<div class="rounded-xl border border-dashed border-n-weak p-12">
<p class="text-base text-n-slate-11"><%= local_assigns[:message] || I18n.t('public_portal.common.no_articles') %></p>
</div>
@@ -0,0 +1,50 @@
<%# locals: (portal:, global_config:) %>
<%
socials = (portal.config.is_a?(Hash) && portal.config['social_profiles']) || {}
social_definitions = [
{ key: 'facebook', icon: 'i-ri-facebook-circle-fill', label: 'Facebook' },
{ key: 'x', icon: 'i-ri-twitter-x-fill', label: 'X' },
{ key: 'instagram', icon: 'i-ri-instagram-fill', label: 'Instagram' },
{ key: 'tiktok', icon: 'i-ri-tiktok-fill', label: 'TikTok' },
{ key: 'github', icon: 'i-ri-github-fill', label: 'GitHub' },
{ key: 'whatsapp', icon: 'i-ri-whatsapp-fill', label: 'WhatsApp' }
]
active_socials = social_definitions.select { |s| socials[s[:key]].to_s.strip.present? }
show_branding = !portal.account.feature_enabled?('disable_branding')
%>
<% if show_branding || active_socials.any? %>
<footer class="mt-auto px-6 md:px-10 py-6 border-t border-solid border-n-weak flex items-center justify-between gap-4 flex-wrap">
<% if show_branding %>
<a href="<%= generate_portal_brand_url(global_config['BRAND_URL'], request.referer) %>"
target="_blank"
rel="noopener noreferrer nofollow"
class="inline-flex items-center gap-2 text-xs text-n-slate-10 hover:text-n-slate-12 transition">
<%= I18n.t('public_portal.footer.made_with') %>
<span class="inline-flex items-center gap-1.5 font-medium text-n-slate-11">
<% if global_config['LOGO_THUMBNAIL'].present? %>
<img class="w-3.5 h-3.5 rounded-sm" alt="<%= global_config['BRAND_NAME'] %>" src="<%= global_config['LOGO_THUMBNAIL'] %>" />
<% end %>
<%= global_config['INSTALLATION_NAME'] %>
</span>
</a>
<% else %>
<span></span>
<% end %>
<% if active_socials.any? %>
<div class="flex items-center gap-0.5">
<% active_socials.each do |social| %>
<a href="<%= socials[social[:key]] %>"
target="_blank"
rel="noopener noreferrer nofollow"
class="p-2 rounded-md text-n-slate-10 hover:text-n-slate-12 hover:bg-n-alpha-2 transition"
aria-label="<%= social[:label] %>"
title="<%= social[:label] %>">
<span class="<%= social[:icon] %> size-4 block" aria-hidden="true"></span>
</a>
<% end %>
</div>
<% end %>
</footer>
<% end %>
@@ -0,0 +1,32 @@
<%# locals: (portal:, popular_topics: []) %>
<section class="relative border-b border-solid border-n-weak bg-n-slate-1 dark:bg-n-slate-2 min-h-[50vh] flex items-center">
<div class="absolute inset-0 pointer-events-none text-n-portal-faint [background-image:linear-gradient(to_right,currentColor_1px,transparent_1px),linear-gradient(to_bottom,currentColor_1px,transparent_1px)] [background-size:56px_56px] [mask-image:radial-gradient(ellipse_at_50%_30%,black_35%,transparent_80%)] [-webkit-mask-image:radial-gradient(ellipse_at_50%_30%,black_35%,transparent_80%)]" aria-hidden="true"></div>
<div class="relative w-full px-6 md:px-10 py-12 md:py-16">
<div class="max-w-2xl">
<h1 class="text-4xl md:text-5xl leading-tight font-620 tracking-tight text-n-slate-12 text-balance">
<%= portal.header_text.presence || 'How can we help?' %>
</h1>
<p class="mt-4 text-lg leading-normal text-n-slate-11 text-pretty">
<%= I18n.t('public_portal.hero.sub_title') %>
</p>
<form class="mt-8 group/herosearch relative z-30" onsubmit="return false">
<div id="search-wrap-hero" class="block w-full"></div>
</form>
<% if popular_topics.any? %>
<div class="mt-5 flex items-center gap-2 flex-wrap text-sm">
<span class="text-n-slate-10"><%= I18n.t('public_portal.sidebar.popular_label') %></span>
<% popular_topics.each do |category| %>
<a href="<%= public_portal_category_path(portal.slug, category.locale, category.slug) %>"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-white/60 dark:bg-n-slate-1/60 backdrop-blur border border-solid border-n-weak text-n-slate-11 hover:text-n-portal hover:border-n-portal transition">
<% if category.icon.present? %><span class="text-xs"><%= category.icon %></span><% end %>
<%= category.name %>
</a>
<% end %>
</div>
<% end %>
</div>
</div>
</section>
@@ -0,0 +1,6 @@
<header class="<%= local_assigns[:wrapper_class] || 'mb-8' %>">
<h2 class="text-2xl md:text-3xl font-620 tracking-tight text-n-slate-12"><%= title %></h2>
<% if local_assigns[:subtitle].present? %>
<p class="<%= local_assigns[:subtitle_class] || 'mt-2 text-base text-n-slate-11' %>"><%= subtitle %></p>
<% end %>
</header>
@@ -0,0 +1,89 @@
<%# locals: (portal:, locale:, article: nil, category: nil) %>
<%
current_category_slug = article&.category&.slug || category&.slug
current_article_slug = article&.slug
sidebar_categories = portal.categories.where(locale: locale).order(position: :asc)
sidebar_articles_by_category = portal.articles.published
.where(category_id: sidebar_categories.map(&:id))
.order(:position)
.group_by(&:category_id)
uncategorized_articles = portal.articles.published.where(category_id: nil, locale: locale).order(:position)
%>
<input type="checkbox" id="sidebar-drawer-checkbox" class="peer/sb hidden">
<label for="sidebar-drawer-checkbox" class="md:hidden fixed inset-0 bg-black/40 backdrop-blur-sm z-40 hidden peer-checked/sb:block cursor-pointer" aria-hidden="true"></label>
<aside class="fixed md:sticky inset-y-0 md:inset-y-auto md:top-16 ltr:left-0 rtl:right-0 max-md:z-50 md:z-30 w-72 md:w-64 lg:w-72 shrink-0 h-[100dvh] md:h-[calc(100dvh-4rem)] overflow-y-auto bg-white dark:bg-n-slate-2 ltr:border-r rtl:border-l border-solid border-n-weak max-md:ltr:-translate-x-full max-md:rtl:translate-x-full md:translate-x-0 transition-transform duration-200 peer-checked/sb:translate-x-0">
<div class="md:hidden flex items-center justify-end px-4 h-14">
<label for="sidebar-drawer-checkbox" class="p-1.5 rounded-md text-n-slate-11 hover:bg-n-alpha-2 cursor-pointer" aria-label="<%= I18n.t('public_portal.sidebar.close_sidebar') %>">
<span class="i-lucide-x size-5 block" aria-hidden="true"></span>
</label>
</div>
<nav class="px-3 pt-6 md:pt-8 pb-6 space-y-1">
<a href="<%= public_portal_locale_path(portal.slug, locale) %>"
class="flex items-center gap-2.5 px-3 py-2 text-sm font-medium rounded-md transition <%= current_category_slug.nil? && current_article_slug.nil? ? 'bg-n-portal-soft text-n-portal' : 'text-n-slate-11 hover:text-n-slate-12 hover:bg-n-alpha-2' %>">
<span class="i-lucide-house size-4" aria-hidden="true"></span>
<%= I18n.t('public_portal.common.home') %>
</a>
<div class="pt-6 pb-2 px-3 text-xs font-semibold tracking-wider text-n-slate-10 uppercase"><%= I18n.t('public_portal.sidebar.categories') %></div>
<% sidebar_categories.each do |sidebar_category| %>
<% category_articles = sidebar_articles_by_category[sidebar_category.id] || [] %>
<% next if category_articles.empty? %>
<% is_active = current_category_slug == sidebar_category.slug || (current_article_slug && category_articles.any? { |a| a.slug == current_article_slug }) %>
<% category_url = public_portal_category_path(portal.slug, sidebar_category.locale, sidebar_category.slug) %>
<% if is_active %>
<details class="group" open>
<% else %>
<details class="group">
<% end %>
<summary class="flex items-center gap-2 px-3 py-2 text-sm rounded-md cursor-pointer list-none select-none <%= is_active ? 'text-n-portal font-semibold' : 'text-n-slate-12 font-medium' %> hover:bg-n-alpha-2 transition">
<% if sidebar_category.icon.present? %>
<span class="text-base leading-none w-4 flex items-center justify-center"><%= sidebar_category.icon %></span>
<% else %>
<span class="i-lucide-folder size-4 text-n-slate-10" aria-hidden="true"></span>
<% end %>
<a href="<%= category_url %>" class="flex-1 truncate"><%= sidebar_category.name %></a>
<span class="i-lucide-chevron-right size-3 text-n-slate-10 transition-transform group-open:rotate-90" aria-hidden="true"></span>
</summary>
<ul class="mt-1 mb-2 ms-4 ps-3 border-l border-n-weak space-y-px">
<% category_articles.each do |sidebar_article| %>
<% is_current_article = current_article_slug == sidebar_article.slug %>
<li>
<a href="<%= public_portal_article_path(portal.slug, sidebar_article.slug) %>"
class="relative -ms-[13px] ps-3 block py-1.5 text-sm leading-snug truncate transition <%= is_current_article ? 'text-n-portal font-medium border-l-2 border-n-portal' : 'text-n-slate-11 hover:text-n-slate-12 border-l-2 border-transparent' %>">
<%= sidebar_article.title %>
</a>
</li>
<% end %>
</ul>
</details>
<% end %>
<% if uncategorized_articles.any? %>
<% is_uncategorized_active = uncategorized_articles.any? { |a| a.slug == current_article_slug } %>
<% if is_uncategorized_active %>
<details class="group" open>
<% else %>
<details class="group">
<% end %>
<summary class="flex items-center gap-2 px-3 py-2 text-sm rounded-md cursor-pointer list-none select-none <%= is_uncategorized_active ? 'text-n-portal font-semibold' : 'text-n-slate-12 font-medium' %> hover:bg-n-alpha-2 transition">
<span class="i-lucide-square-dashed size-4 text-n-slate-10" aria-hidden="true"></span>
<span class="flex-1 truncate"><%= I18n.t('public_portal.header.uncategorized') %></span>
<span class="i-lucide-chevron-right size-3 text-n-slate-10 transition-transform group-open:rotate-90" aria-hidden="true"></span>
</summary>
<ul class="mt-1 mb-2 ms-4 ps-3 border-l border-n-weak space-y-px">
<% uncategorized_articles.each do |sidebar_article| %>
<% is_current_article = current_article_slug == sidebar_article.slug %>
<li>
<a href="<%= public_portal_article_path(portal.slug, sidebar_article.slug) %>"
class="relative -ms-[13px] ps-3 block py-1.5 text-sm leading-snug truncate transition <%= is_current_article ? 'text-n-portal font-medium border-l-2 border-n-portal' : 'text-n-slate-11 hover:text-n-slate-12 border-l-2 border-transparent' %>">
<%= sidebar_article.title %>
</a>
</li>
<% end %>
</ul>
</details>
<% end %>
</nav>
</aside>
@@ -0,0 +1,72 @@
<%# locals: (portal:, locale:, article: nil, category: nil) %>
<% home_url = public_portal_locale_path(portal.slug, locale) %>
<% current = article ? :article : (category ? :category : :home) %>
<header class="sticky top-0 z-40 w-full bg-white dark:bg-n-slate-1 border-b border-solid border-n-weak font-inter tracking-normal [font-optical-sizing:auto]">
<div class="max-w-screen-2xl w-full mx-auto flex items-center h-16 px-4 md:px-6 gap-4">
<label for="sidebar-drawer-checkbox" class="md:hidden -ms-1 p-2 rounded-lg text-n-slate-11 hover:bg-n-alpha-2 cursor-pointer" aria-label="<%= I18n.t('public_portal.sidebar.open_sidebar') %>">
<span class="i-lucide-panel-left-open size-5 block" aria-hidden="true"></span>
</label>
<a href="<%= home_url %>" class="flex items-center min-w-0 gap-2.5">
<% if portal.logo.present? %>
<img src="<%= url_for(portal.logo) %>" class="w-8 h-8 rounded-full" draggable="false" />
<% end %>
<span class="text-base font-semibold tracking-tight text-n-slate-12 truncate"><%= portal.name %></span>
<span class="hidden sm:inline-block w-px h-5 bg-n-weak mx-1"></span>
<span class="hidden sm:inline-block text-sm font-medium text-n-slate-11 truncate"><%= I18n.t('public_portal.sidebar.help_center') %></span>
</a>
<nav class="hidden md:flex items-center gap-1 ms-4" aria-label="<%= I18n.t('public_portal.sidebar.primary_nav') %>">
<a href="<%= home_url %>"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md transition <%= current == :home ? 'text-n-portal' : 'text-n-slate-11 hover:text-n-slate-12' %>">
<%= I18n.t('public_portal.common.home') %>
</a>
<% if portal.homepage_link.present? %>
<a href="<%= portal.homepage_link %>" target="_blank" rel="noopener noreferrer nofollow"
class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-n-slate-11 hover:text-n-slate-12 rounded-md transition">
<%= I18n.t('public_portal.header.go_to_homepage') %>
<span class="i-lucide-arrow-up-right size-3.5" aria-hidden="true"></span>
</a>
<% end %>
</nav>
<div class="hidden md:block flex-1 max-w-md mx-auto relative z-30">
<div id="search-wrap" class="block w-full" data-size="small" data-kbd="true"></div>
</div>
<div class="ms-auto flex items-center gap-1.5">
<div id="sidebar-theme-toggle"
data-label-trigger="<%= I18n.t('public_portal.header.appearance.title', default: 'Appearance') %>"
data-label-system="<%= I18n.t('public_portal.header.appearance.system') %>"
data-label-light="<%= I18n.t('public_portal.header.appearance.light') %>"
data-label-dark="<%= I18n.t('public_portal.header.appearance.dark') %>"></div>
<% if portal.public_locale_codes.length > 1 %>
<details class="relative group" data-close-on-clickaway>
<summary class="list-none flex items-center gap-2 px-2.5 py-1.5 rounded-lg cursor-pointer text-n-slate-11 hover:text-n-slate-12 hover:bg-n-alpha-2 transition group-open:bg-n-alpha-2 group-open:text-n-slate-12 [&::-webkit-details-marker]:hidden">
<span class="i-lucide-languages size-4" aria-hidden="true"></span>
<span class="hidden lg:inline text-sm font-medium"><%= language_name(locale) %></span>
<span class="lg:hidden text-xs font-semibold uppercase"><%= locale %></span>
<span class="i-lucide-chevron-down size-3 opacity-60 transition-transform group-open:rotate-180" aria-hidden="true"></span>
</summary>
<div class="absolute end-0 top-full mt-2 bg-n-slate-1 border border-solid border-n-weak rounded-lg shadow-lg p-1 min-w-60 max-h-96 overflow-y-auto z-50 flex flex-col gap-0.5">
<div class="px-2.5 pt-2 pb-1.5 text-xs font-semibold text-n-slate-10 uppercase tracking-wider"><%= I18n.t('public_portal.sidebar.language') %></div>
<% portal.public_locale_codes.each do |code| %>
<% is_current = code == locale %>
<a href="/hc/<%= portal.slug %>/<%= code %>/"
data-turbolinks="false"
class="flex items-center gap-2.5 px-2.5 py-2 text-sm rounded-md transition <%= is_current ? 'bg-n-portal-soft text-n-portal' : 'text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12' %>">
<span class="text-xs font-semibold uppercase min-w-9 text-center text-n-slate-11 bg-n-slate-2 border border-solid border-n-weak rounded px-1.5 py-0.5 flex-shrink-0"><%= code %></span>
<span class="flex-1 truncate <%= is_current ? 'font-medium' : '' %>"><%= language_name(code) %></span>
<% if is_current %>
<span class="i-lucide-check size-3.5 text-n-portal flex-shrink-0" aria-hidden="true"></span>
<% end %>
</a>
<% end %>
</div>
</details>
<% end %>
</div>
</div>
</header>
@@ -0,0 +1,18 @@
<%
size = local_assigns[:size] || :sm
border = local_assigns[:border] || 'ring-2 ring-solid ring-white dark:ring-n-slate-2'
extra = local_assigns[:extra_class] || ''
size_map = {
xs: { box: 'w-4 h-4', text: 'text-xs', rounded: 'rounded' },
sm: { box: 'w-6 h-6', text: 'text-xs', rounded: 'rounded-md' },
md: { box: 'w-7 h-7', text: 'text-xs', rounded: 'rounded-md' },
lg: { box: 'w-10 h-10', text: 'text-sm', rounded: 'rounded-xl' }
}
s = size_map.fetch(size, size_map[:sm])
name = user.available_name
%>
<% if user.avatar_url.present? %>
<img src="<%= user.avatar_url %>" alt="<%= name %>" title="<%= name %>" class="block <%= s[:box] %> <%= s[:rounded] %> object-cover <%= border %> <%= extra %>" />
<% else %>
<span title="<%= name %>" style="background: <%= thumbnail_bg_color(name) %>;" class="<%= s[:box] %> <%= s[:rounded] %> flex items-center justify-center <%= s[:text] %> font-semibold text-white <%= border %> <%= extra %>"><%= name.first&.upcase %></span>
<% end %>
@@ -0,0 +1,42 @@
<%# locals: (portal:, article:) %>
<%
markdown_url = public_portal_article_markdown_url(portal.slug, article.slug)
encoded_prompt = ERB::Util.url_encode(I18n.t('public_portal.article_actions.llm_prompt', url: markdown_url))
chatgpt_url = "https://chatgpt.com/?q=#{encoded_prompt}"
claude_url = "https://claude.ai/new?q=#{encoded_prompt}"
%>
<details class="relative group" data-close-on-clickaway>
<summary class="list-none inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-md cursor-pointer text-n-slate-11 hover:text-n-slate-12 hover:bg-n-alpha-2 border border-solid border-n-weak transition group-open:bg-n-alpha-2 group-open:text-n-slate-12 [&::-webkit-details-marker]:hidden">
<span class="i-lucide-message-square-text size-3" aria-hidden="true"></span>
<%= I18n.t('public_portal.article_actions.label') %>
<span class="i-lucide-chevron-down size-2.5 opacity-60 transition-transform group-open:rotate-180" aria-hidden="true"></span>
</summary>
<div class="absolute end-0 top-full mt-2 bg-n-slate-1 border border-solid border-n-weak rounded-lg shadow-lg p-1 min-w-64 z-30">
<a href="<%= markdown_url %>" target="_blank" rel="noopener"
class="w-full flex items-center gap-2.5 px-2.5 py-2 text-sm rounded-md text-n-slate-12 hover:bg-n-alpha-2 transition">
<span class="shrink-0 inline-flex items-center justify-center size-5 rounded bg-n-alpha-2 border border-solid border-n-weak text-n-slate-12">
<span class="i-lucide-file-text size-3" aria-hidden="true"></span>
</span>
<span class="flex-1"><%= I18n.t('public_portal.article_actions.view_markdown') %></span>
<span class="i-lucide-arrow-up-right size-3.5 text-n-slate-10" aria-hidden="true"></span>
</a>
<div class="my-1 h-px bg-n-weak" role="separator"></div>
<a href="<%= chatgpt_url %>" target="_blank" rel="noopener noreferrer"
class="w-full flex items-center gap-2.5 px-2.5 py-2 text-sm rounded-md text-n-slate-12 hover:bg-n-alpha-2 transition">
<span class="shrink-0 inline-flex items-center justify-center size-5 rounded bg-n-alpha-2 border border-solid border-n-weak text-n-slate-12">
<span class="i-ri-openai-fill size-3" aria-hidden="true"></span>
</span>
<span class="flex-1"><%= I18n.t('public_portal.article_actions.open_in_chatgpt') %></span>
<span class="i-lucide-arrow-up-right size-3.5 text-n-slate-10" aria-hidden="true"></span>
</a>
<a href="<%= claude_url %>" target="_blank" rel="noopener noreferrer"
class="w-full flex items-center gap-2.5 px-2.5 py-2 text-sm rounded-md text-n-slate-12 hover:bg-n-alpha-2 transition">
<span class="shrink-0 inline-flex items-center justify-center size-5 rounded bg-n-alpha-2 border border-solid border-n-weak text-n-slate-12">
<span class="i-ri-claude-fill size-3" aria-hidden="true"></span>
</span>
<span class="flex-1"><%= I18n.t('public_portal.article_actions.open_in_claude') %></span>
<span class="i-lucide-arrow-up-right size-3.5 text-n-slate-10" aria-hidden="true"></span>
</a>
</div>
</details>
@@ -0,0 +1,32 @@
<%# locals: (portal:, article:) %>
<% last_updated_on = article.updated_at.strftime("%b %-d, %Y") %>
<header class="mb-12 border-b border-n-weak">
<div class="flex items-start justify-between gap-4">
<h1 class="text-3xl md:text-4xl leading-snug font-620 tracking-tight text-n-slate-12 min-w-0">
<%= article.title %>
</h1>
<div class="shrink-0 mt-1">
<%= render 'public/api/v1/portals/documentation_layout/articles/actions',
portal: portal, article: article %>
</div>
</div>
<% if article.author.present? %>
<div class="mt-6 flex items-center gap-3">
<%= render 'public/api/v1/portals/documentation_layout/user_avatar',
user: article.author,
size: :lg,
border: 'border border-solid border-n-weak' %>
<div>
<p class="text-sm font-medium text-n-slate-12"><%= article.author.available_name %></p>
<p class="mt-0.5 text-xs text-n-slate-11">
<%= I18n.t('public_portal.common.last_updated_on', last_updated_on: last_updated_on) %>
</p>
</div>
</div>
<% else %>
<p class="mt-6 inline-flex items-center gap-1.5 text-sm text-n-slate-11">
<span class="i-lucide-clock-3 size-3.5" aria-hidden="true"></span>
<%= I18n.t('public_portal.common.last_updated_on', last_updated_on: last_updated_on) %>
</p>
<% end %>
</header>
@@ -0,0 +1,17 @@
<title><%= article.title %> | <%= portal.display_title %></title>
<% if article.meta["title"].present? %>
<meta name="title" content="<%= article.meta["title"] %>">
<meta property="og:title" content="<%= article.meta["title"] %>">
<% end %>
<% if article.meta["description"].present? %>
<meta name="description" content="<%= article.meta["description"] %>">
<meta property="og:description" content="<%= article.meta["description"] %>">
<% end %>
<% if article.meta["tags"].present? %>
<meta name="tags" content="<%= article.meta["tags"].join(',') %>">
<% end %>
<% if og_image_url.present? %>
<meta name="twitter:card" content="summary_large_image">
<meta name="og:image" content="<%= og_image_url.html_safe %>">
<meta property="og:image" content="<%= og_image_url.html_safe %>">
<% end %>
@@ -0,0 +1,33 @@
<% article_count_label = I18n.t(articles.size == 1 ? 'public_portal.common.article' : 'public_portal.common.articles') %>
<% meta_row = capture do %>
<div class="flex items-center gap-2 text-sm text-n-slate-10 flex-wrap">
<span class="tabular-nums"><%= articles.size %> <%= article_count_label %></span>
<% if category_authors.any? %>
<span aria-hidden="true">·</span>
<span class="inline-flex items-center gap-2">
<%= render 'public/api/v1/portals/documentation_layout/avatar_group',
users: category_authors, size: :xs, overlap: '-ml-0.5' %>
<span><%= I18n.t('public_portal.common.by') %> <%= format_authors_label(category_authors) %></span>
</span>
<% end %>
</div>
<% end %>
<header class="mb-8">
<div class="flex items-center gap-4">
<% if category.icon.present? %>
<span class="shrink-0 flex items-center justify-center w-14 h-14 text-3xl rounded-2xl bg-n-slate-1 border border-solid border-n-weak shadow-sm"><%= category.icon %></span>
<% end %>
<div class="min-w-0">
<h1 class="text-3xl leading-snug font-620 tracking-tight text-n-slate-12 text-balance"><%= category.name %></h1>
<div class="hidden sm:block">
<%= meta_row %>
</div>
</div>
</div>
<div class="sm:hidden mt-3">
<%= meta_row %>
</div>
<% if category.description.present? %>
<p class="mt-6 text-lg leading-relaxed text-n-slate-11 text-pretty"><%= category.description %></p>
<% end %>
</header>
@@ -0,0 +1,11 @@
<title><%= category.name %> | <%= portal.display_title %></title>
<meta name="title" content="<%= category.name %> | <%= portal.display_title %>">
<% if category.description.present? %>
<meta name="description" content="<%= category.description %>">
<meta property="og:description" content="<%= category.description %>">
<% end %>
<% if og_image_url.present? %>
<meta name="twitter:card" content="summary_large_image">
<meta name="og:image" content="<%= og_image_url.html_safe %>">
<meta property="og:image" content="<%= og_image_url.html_safe %>">
<% end %>
@@ -0,0 +1,34 @@
<%= render 'public/api/v1/portals/documentation_layout/hero', portal: @portal, popular_topics: @popular_topics %>
<section class="px-6 md:px-10 py-8 md:py-10">
<%= render 'public/api/v1/portals/documentation_layout/section_header',
title: I18n.t('public_portal.sidebar.browse_by_topic'),
subtitle: I18n.t('public_portal.sidebar.browse_by_topic_subtitle') %>
<% if @visible_categories.empty? %>
<%= render 'public/api/v1/portals/documentation_layout/empty_state' %>
<% else %>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<% @visible_categories.each do |category| %>
<%= render 'public/api/v1/portals/documentation_layout/category_card',
portal: @portal,
category: category,
contributors: @category_contributors[category.id] %>
<% end %>
</div>
<% end %>
</section>
<% if @featured.any? %>
<section class="px-6 md:px-10 py-8 md:py-10 border-t border-n-weak">
<%= render 'public/api/v1/portals/documentation_layout/section_header',
title: I18n.t('public_portal.sidebar.popular_articles'),
subtitle: I18n.t('public_portal.sidebar.popular_articles_subtitle'),
subtitle_class: 'mt-1.5 text-base text-n-slate-11' %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<% @featured.each do |article| %>
<%= render 'public/api/v1/portals/documentation_layout/article_card', portal: @portal, article: article %>
<% end %>
</div>
</section>
<% end %>
+22
View File
@@ -413,6 +413,22 @@ ar:
loading_placeholder: جاري البحث...
results_title: نتائج البحث
toc_header: 'في هذه الصفحة'
sidebar:
help_center: مركز المساعدة
categories: الفئات
language: اللغة
theme: المظهر
open_sidebar: فتح الشريط الجانبي
close_sidebar: إغلاق الشريط الجانبي
browse: تصفح
back_to_category: العودة إلى %{name}
browse_by_topic: تصفح حسب الموضوع
browse_by_topic_subtitle: اعثر على أدلة ودروس وأجوبة منظمة حسب الفئة.
popular_articles: المقالات الشائعة
popular_articles_subtitle: ما يقرأه الآخرون الآن.
popular_label: 'مواضيع شائعة:'
authors_others: "%{names} و %{count} آخرون"
primary_nav: التنقل الرئيسي
hero:
sub_title: ابحث عن المقالات هنا أو تصفح الفئات أدناه.
common:
@@ -427,6 +443,12 @@ ar:
others: الآخرين
by: بواسطة
no_articles: لا توجد مقالات
article_actions:
label: فتح في
view_markdown: عرض كـ Markdown
open_in_chatgpt: فتح في ChatGPT
open_in_claude: فتح في Claude
llm_prompt: "اقرأ المقال التالي وساعدني في فهمه: %{url}"
footer:
made_with: صنع بـ
header:
+23
View File
@@ -427,6 +427,22 @@ en:
loading_placeholder: Searching...
results_title: Search results
toc_header: 'On this page'
sidebar:
help_center: Help Center
categories: Categories
language: Language
theme: Theme
open_sidebar: Open sidebar
close_sidebar: Close sidebar
browse: Browse
back_to_category: Back to %{name}
browse_by_topic: Browse by topic
browse_by_topic_subtitle: Find guides, tutorials, and answers organised by category.
popular_articles: Popular articles
popular_articles_subtitle: What other people are reading right now.
popular_label: 'Popular topics:'
authors_others: "%{names} and %{count} others"
primary_nav: Primary
hero:
sub_title: Search for the articles here or browse the categories below.
common:
@@ -441,12 +457,19 @@ en:
others: others
by: By
no_articles: There are no articles here
article_actions:
label: Open in
view_markdown: View as Markdown
open_in_chatgpt: Open in ChatGPT
open_in_claude: Open in Claude
llm_prompt: "Read the following article and help me understand it: %{url}"
footer:
made_with: Made with
header:
go_to_homepage: Website
visit_website: Visit website
appearance:
title: Appearance
system: System
light: Light
dark: Dark
+22
View File
@@ -413,6 +413,22 @@ fr:
loading_placeholder: Recherche en cours...
results_title: Résultats de recherche
toc_header: 'Sur cette page'
sidebar:
help_center: "Centre d'aide"
categories: Catégories
language: Langue
theme: Thème
open_sidebar: Ouvrir la barre latérale
close_sidebar: Fermer la barre latérale
browse: Parcourir
back_to_category: Retour à %{name}
browse_by_topic: Parcourir par sujet
browse_by_topic_subtitle: Trouvez des guides, des tutoriels et des réponses organisés par catégorie.
popular_articles: Articles populaires
popular_articles_subtitle: Ce que les autres lisent en ce moment.
popular_label: 'Sujets populaires :'
authors_others: "%{names} et %{count} autres"
primary_nav: Navigation principale
hero:
sub_title: Recherchez les articles ici ou parcourez les catégories ci-dessous.
common:
@@ -427,6 +443,12 @@ fr:
others: autres
by: Par
no_articles: Il n'y a pas d'articles ici
article_actions:
label: Ouvrir dans
view_markdown: Voir en Markdown
open_in_chatgpt: Ouvrir dans ChatGPT
open_in_claude: Ouvrir dans Claude
llm_prompt: "Lis l'article suivant et aide-moi à le comprendre : %{url}"
footer:
made_with: Réalisé avec
header:
+22
View File
@@ -413,6 +413,22 @@ pt_BR:
loading_placeholder: Procurando...
results_title: Resultados de pesquisa
toc_header: 'Nesta página'
sidebar:
help_center: Central de Ajuda
categories: Categorias
language: Idioma
theme: Tema
open_sidebar: Abrir barra lateral
close_sidebar: Fechar barra lateral
browse: Navegar
back_to_category: Voltar para %{name}
browse_by_topic: Navegar por tópico
browse_by_topic_subtitle: Encontre guias, tutoriais e respostas organizados por categoria.
popular_articles: Artigos populares
popular_articles_subtitle: O que outras pessoas estão lendo agora.
popular_label: 'Tópicos populares:'
authors_others: "%{names} e mais %{count}"
primary_nav: Navegação principal
hero:
sub_title: Pesquise os artigos aqui ou navegue pelas categorias abaixo.
common:
@@ -427,6 +443,12 @@ pt_BR:
others: outros
by: Por
no_articles: Não há artigos aqui
article_actions:
label: Abrir em
view_markdown: Ver como Markdown
open_in_chatgpt: Abrir no ChatGPT
open_in_claude: Abrir no Claude
llm_prompt: "Leia o seguinte artigo e me ajude a entendê-lo: %{url}"
footer:
made_with: Criado com
header:
+5 -3
View File
@@ -585,13 +585,15 @@ Rails.application.routes.draw do
get 'hc/:slug', to: 'public/api/v1/portals#show'
get 'hc/:slug/sitemap.xml', to: 'public/api/v1/portals#sitemap'
get 'hc/:slug/:locale', to: 'public/api/v1/portals#show'
get 'hc/:slug/:locale', to: 'public/api/v1/portals#show', as: :public_portal_locale
get 'hc/:slug/:locale/articles', to: 'public/api/v1/portals/articles#index'
get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'
get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show'
get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show', as: :public_portal_category
get 'hc/:slug/:locale/categories/:category_slug/articles', to: 'public/api/v1/portals/articles#index'
get 'hc/:slug/articles/:article_slug.png', to: 'public/api/v1/portals/articles#tracking_pixel'
get 'hc/:slug/articles/:article_slug', to: 'public/api/v1/portals/articles#show'
get 'hc/:slug/articles/:article_slug.md', to: 'public/api/v1/portals/articles#show_markdown', as: :public_portal_article_markdown,
defaults: { format: :md }
get 'hc/:slug/articles/:article_slug', to: 'public/api/v1/portals/articles#show', as: :public_portal_article
# ----------------------------------------------------------------------
# Used in mailer templates
@@ -140,6 +140,38 @@ RSpec.describe 'Public Articles API', type: :request do
get "/hc/#{portal.slug}/articles/#{article_in_locale.slug}"
expect(response).to have_http_status(:success)
end
it 'resolves the locale from the article itself for an uncategorized article' do
uncategorized_article = create(:article, category: nil, locale: 'es', portal: portal,
account_id: account.id, author_id: agent.id)
get "/hc/#{portal.slug}/articles/#{uncategorized_article.slug}"
expect(response).to have_http_status(:success)
expect(response.body).to include('lang="es"')
end
end
describe 'GET /public/api/v1/portals/:slug/articles/:slug.md (markdown)' do
it 'serves the raw article markdown for a published article' do
get "/hc/#{portal.slug}/articles/#{article.slug}.md"
expect(response).to have_http_status(:success)
expect(response.headers['Content-Type']).to include('text/markdown')
expect(response.body).to eq(article.content)
end
it 'returns 404 for a draft article' do
draft_article = create(:article, category: category, status: :draft, portal: portal, account_id: account.id, author_id: agent.id)
get "/hc/#{portal.slug}/articles/#{draft_article.slug}.md"
expect(response).to have_http_status(:not_found)
end
it 'returns 404 if the article does not exist' do
get "/hc/#{portal.slug}/articles/non-existent-article.md"
expect(response).to have_http_status(:not_found)
end
end
describe 'GET /public/api/v1/portals/:slug/articles/:slug.png (tracking pixel)' do
@@ -11,12 +11,13 @@ RSpec.describe 'Public Categories API', type: :request do
end
describe 'GET /public/api/v1/portals/:portal_slug/categories' do
it 'Fetch all categories in the portal' do
it 'redirects to the locale home page' do
category = portal.categories.first
get "/hc/#{portal.slug}/#{category.locale}/categories"
expect(response).to have_http_status(:success)
expect(response).to have_http_status(:moved_permanently)
expect(response).to redirect_to("/hc/#{portal.slug}/#{category.locale}")
end
end
+3 -2
View File
@@ -22,7 +22,7 @@ const defaultSansFonts = [
const tailwindConfig = {
darkMode: 'class',
content: [
'./enterprise/app/views/**/*.html.erb',
'./enterprise/app/views/**/*.erb',
'./app/javascript/widget/**/*.vue',
'./app/javascript/v3/**/*.vue',
'./app/javascript/dashboard/**/*.vue',
@@ -34,7 +34,7 @@ const tailwindConfig = {
'./app/javascript/dashboard/composables/**/*.js',
'./app/javascript/dashboard/components-next/**/*.js',
'./app/javascript/dashboard/routes/dashboard/**/**/*.js',
'./app/views/**/*.html.erb',
'./app/views/**/*.erb',
],
theme: {
extend: {
@@ -48,6 +48,7 @@ const tailwindConfig = {
440: '440',
460: '460',
520: '520',
620: '620',
},
typography: {
bubble: {
+3
View File
@@ -227,6 +227,9 @@ export const colors = {
black: '#000000',
brand: '#2781F6',
portal: 'var(--dynamic-portal-color)',
'portal-soft': 'var(--dynamic-portal-color-soft)',
'portal-faint': 'var(--dynamic-portal-color-faint)',
background: 'rgb(var(--background-color) / <alpha-value>)',
'input-background': 'rgba(var(--background-input-box))',
surface: {