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:
co-authored by
Muhsin Keloth
Sivin Varghese
parent
e05008e0a4
commit
64585faff0
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %>
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user