Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,57 @@
import { Link } from "react-router";
import type { KbArticleDto } from "../dtos/KbArticleDto";
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
import type { Colors } from "~/application/enums/shared/Colors";
import clsx from "clsx";
import type { KnowledgeBaseDto } from "../dtos/KnowledgeBaseDto";
import EmptyState from "~/components/ui/emptyState/EmptyState";
import { useTranslation } from "react-i18next";
import { ChevronRightIcon, StarIcon } from "~/components/ui/icons/vendor";
export default function KbFeaturedArticles({ kb, items }: { kb: KnowledgeBaseDto; items: KbArticleDto[] }) {
const { t } = useTranslation();
return (
<div className="space-y-4">
<div className="flex items-center space-x-4">
<FeaturedArticlesIcon color={kb.color} />
<h2 className="text-2xl font-bold">{t("knowledgeBase.featuredArticles")}</h2>
</div>
{items.length === 0 && <EmptyState className="bg-background" captions={{ thereAreNo: t("knowledgeBase.article.emptyFeatured") }} />}
<div className={clsx("grid gap-2", items.length === 1 ? "grid-cols-1" : "sm:grid-cols-2")}>
{items.map((item) => {
return (
<Link
to={item.href}
key={item.id}
className="rounded-md border border-border bg-background px-3 py-4 shadow-2xs hover:bg-secondary hover:border-secondary-foreground"
>
<div className="flex items-center justify-between space-x-2">
<div className="grow font-medium">{item.title}</div>
<div className="shrink-0">
{/* <svg className="h-5 w-5" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24">
<path d="M5 12h14M12 5l7 7-7 7"></path>
</svg> */}
<ChevronRightIcon className="h-5 w-5" />
</div>
</div>
</Link>
);
})}
</div>
</div>
);
}
function FeaturedArticlesIcon({ color }: { color: Colors }) {
return (
<StarIcon className={clsx("h-5 w-5", ColorTextUtils.getText500(color))} />
);
}
// function CategoriesIcon() {
// return (
// <svg className="h-7 w-7" fill="currentColor" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 48 48">
// <path d="M 10.5 5 C 8.019 5 6 7.019 6 9.5 L 6 31.5 C 6 33.981 8.019 36 10.5 36 L 16 36 L 16 33 L 10.5 33 C 9.673 33 9 32.327 9 31.5 L 9 31 L 16 31 L 16 16.5 C 16 16.331 16.013391 16.166 16.025391 16 L 13.5 16 C 12.671 16 12 15.329 12 14.5 C 12 13.671 12.671 13 13.5 13 L 17.03125 13 C 18.18825 11.199 20.205 10 22.5 10 L 30 10 L 30 9.5 C 30 7.019 27.981 5 25.5 5 L 10.5 5 z M 22.5 12 C 20.019 12 18 14.019 18 16.5 L 18 38.5 C 18 40.981 20.019 43 22.5 43 L 40.5 43 C 41.329 43 42 42.329 42 41.5 C 42 40.671 41.329 40 40.5 40 L 22.5 40 C 21.673 40 21 39.327 21 38.5 L 21 38 L 40.5 38 C 41.329 38 42 37.329 42 36.5 L 42 16.5 C 42 14.019 39.981 12 37.5 12 L 22.5 12 z M 25.5 20 L 34.5 20 C 35.329 20 36 20.671 36 21.5 C 36 22.329 35.329 23 34.5 23 L 25.5 23 C 24.671 23 24 22.329 24 21.5 C 24 20.671 24.671 20 25.5 20 z"></path>
// </svg>
// );
// }
@@ -0,0 +1,106 @@
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import type { KnowledgeBaseDto } from "../dtos/KnowledgeBaseDto";
import clsx from "clsx";
import ColorBackgroundUtils from "~/utils/shared/colors/ColorBackgroundUtils";
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
import ColorFocusUtils from "~/utils/shared/colors/ColorFocusUtils";
import ColorRingUtils from "~/utils/shared/colors/ColorRingUtils";
import ColorGradientUtils from "~/utils/shared/colors/ColorGradientUtils";
import { logoLight, logoDark } from "~/assets/brand/default";
import KnowledgeBaseUtils from "../utils/KnowledgeBaseUtils";
import KbSearch from "./KbSearch";
interface Props {
kb: KnowledgeBaseDto;
// currentVersion: string | undefined;
withTitleAndDescription: boolean;
withSearch?: boolean;
}
export default function KbHeader({ kb, withTitleAndDescription, withSearch = true }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const params = useParams();
const [searchParams] = useSearchParams();
const logoAlt = t("knowledgeBase.header.logoAlt", { title: kb.title });
return (
<div>
<div className={clsx("bg-linear-to-r text-white", ColorGradientUtils.getFrom800To900(kb.color))}>
<div className="mx-auto max-w-5xl space-y-8 px-8 py-6">
<div className="flex h-12 items-center justify-between space-x-2">
<Link to={KnowledgeBaseUtils.getKbUrl({ kb, params })} className="flex select-none items-center space-x-2">
{kb.logo === "light" ? (
<img className="h-8 w-auto" src={logoLight} alt={logoAlt} />
) : kb.logo === "dark" ? (
<img className="h-8 w-auto" src={logoDark} alt={logoAlt} />
) : (
<img className="h-8 w-auto" src={kb.logo} alt={logoAlt} />
)}
{!withTitleAndDescription && (
<Fragment>
<span className={clsx("hidden px-2 sm:block", ColorTextUtils.getText300(kb.color))}>|</span>
<span className="hidden font-semibold sm:block">{kb.title}</span>
</Fragment>
)}
</Link>
<div className="flex items-center space-x-4">
{kb.links.map((link) => {
return (
<a key={link.href} href={link.href} className={clsx("transition-colors duration-150 hover:text-white", ColorTextUtils.getText300(kb.color))}>
{link.name}
</a>
);
})}
<div className="flex items-center space-x-2">
{kb.languages.length > 1 && (
<select
id="lang"
name="lang"
defaultValue={params.lang || kb.defaultLanguage}
className={clsx(
"block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-white ring-1 ring-inset focus:ring-2 sm:text-sm sm:leading-6",
ColorBackgroundUtils.getBg700(kb.color),
ColorRingUtils.getRing800(kb.color),
ColorFocusUtils.getRing600(kb.color)
)}
onChange={(e) => {
navigate(
`${KnowledgeBaseUtils.getKbUrl({
kb,
params: {
lang: e.target.value,
},
})}?${searchParams.toString()}`
);
}}
>
{kb.languages.map((lang) => {
const language = KnowledgeBaseUtils.supportedLanguages.find((f) => f.value === lang);
if (!language) return null;
return (
<Fragment key={lang}>
<option key={lang} value={lang}>
{KnowledgeBaseUtils.getLanguageName(language.value, t)}
</option>
</Fragment>
);
})}
</select>
)}
</div>
</div>
</div>
{withTitleAndDescription && (
<div className="space-y-4">
<h1 className="text-2xl font-semibold tracking-tight sm:text-4xl">{kb.title}</h1>
<p className={clsx("text-base", ColorTextUtils.getText300(kb.color))}>{kb.description}</p>
</div>
)}
{withSearch && <KbSearch kb={kb} autoFocus={withTitleAndDescription} />}
</div>
</div>
</div>
);
}
@@ -0,0 +1,162 @@
import clsx from "clsx";
import { InfoIcon, SearchIcon, XIcon } from "~/components/ui/icons/vendor";
import ColorBackgroundUtils from "~/utils/shared/colors/ColorBackgroundUtils";
import ColorFocusUtils from "~/utils/shared/colors/ColorFocusUtils";
import ColorFocusWithinUtils from "~/utils/shared/colors/ColorFocusWithinUtils";
import ColorPlaceholderUtils from "~/utils/shared/colors/ColorPlaceholderUtils";
import ColorRingUtils from "~/utils/shared/colors/ColorRingUtils";
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
import { KnowledgeBaseDto } from "../dtos/KnowledgeBaseDto";
import { Link, useLoaderData, useSearchParams } from "react-router";
import { useState, useEffect } from "react";
import { useDebounce } from "use-debounce";
import { KbSearchResultDto } from "../dtos/KbSearchResultDto";
import ColorBorderUtils from "~/utils/shared/colors/ColorBorderUtils";
import { Colors } from "~/application/enums/shared/Colors";
import { KbArticleDto } from "../dtos/KbArticleDto";
import ColorHoverUtils from "~/utils/shared/colors/ColorHoverUtils";
import { useTranslation } from "react-i18next";
export default function KbSearch({ kb, autoFocus }: { kb: KnowledgeBaseDto; autoFocus: boolean }) {
const { t } = useTranslation();
let data = useLoaderData<
| {
search: KbSearchResultDto | undefined;
}
| undefined
>();
let [search, setSearch] = useState(data?.search?.query);
let [debouncedQuery] = useDebounce(search, 500);
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
if (debouncedQuery) {
searchParams.set("q", debouncedQuery);
} else {
searchParams.delete("q");
}
if (searchParams.toString()) {
setSearchParams(searchParams);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedQuery]);
return (
<div className="w-full space-y-2">
<div className={clsx("relative", ColorTextUtils.getText400(kb.color), ColorFocusWithinUtils.getText600(kb.color))}>
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
<SearchIcon className="h-5 w-5" />
</div>
<input
autoFocus={autoFocus}
autoComplete="off"
className={clsx(
" focus:bg-background block w-full rounded-md border-0 px-6 py-5 pl-12 outline-hidden ring-1 ring-inset transition-all focus:ring-2 focus:ring-inset",
ColorBackgroundUtils.getBg800(kb.color),
ColorTextUtils.getText300(kb.color),
ColorPlaceholderUtils.getText400(kb.color),
ColorFocusUtils.getText600(kb.color),
ColorFocusUtils.getPlaceholder600(kb.color),
ColorFocusUtils.getRing600(kb.color),
ColorRingUtils.getRing800(kb.color)
)}
type="text"
aria-label={t("knowledgeBase.search.label")}
placeholder={t("knowledgeBase.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button type="button" className="absolute inset-y-0 right-0 flex items-center pr-4" onClick={() => setSearch("")} aria-label={t("knowledgeBase.search.clear")}>
<XIcon className="h-5 w-5" />
</button>
)}
</div>
{search && data?.search !== undefined && (
<div>
{!data.search || data.search.articles?.length === 0 ? (
<div className={clsx("rounded-md border", ColorBorderUtils.get300(kb.color), ColorBackgroundUtils.getBg50(kb.color))}>
<div className="rounded-md p-4">
<div className="flex">
<div className="shrink-0">
<InfoIcon className={clsx("h-5 w-5", ColorTextUtils.getText400(kb.color))} />
</div>
<div className="ml-3 w-full">
<h3 className={clsx("text-sm font-medium leading-5", ColorTextUtils.getText800(kb.color))}>
{t("knowledgeBase.search.noResults", { query: data.search.query })}
</h3>
<div className={clsx("mt-2 text-sm leading-5", ColorTextUtils.getText800(kb.color))}>
<div>
<p className="text-sm">{t("knowledgeBase.search.tryDifferent")}</p>
</div>
</div>
</div>
</div>
</div>
</div>
) : (
<div>
<div className="space-y-2">
{data.search.articles.map((item) => (
<Card key={item.id} kb={kb} item={item} highlightText={debouncedQuery ?? ""} />
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
function Highlight({ className, color, children, highlightText }: { className: string; color: Colors; children: string; highlightText: string }) {
const parts = children.split(new RegExp(`(${highlightText})`, "gi"));
return (
<span className={clsx(className, " line-clamp-3")}>
{parts.map((part, i) =>
part.toLowerCase() === highlightText.toLowerCase() ? (
<span key={i} className={clsx(ColorBackgroundUtils.getBg400(color), ColorTextUtils.getText100(color))}>
{part}
</span>
) : (
part
)
)}
</span>
);
}
function Card({ kb, item, highlightText }: { kb: KnowledgeBaseDto; item: KbArticleDto; highlightText: string }) {
const contentLines = item.contentPublishedAsText.split("\n");
const highlightedLines = contentLines
.map((line, i) => ({ line, isHighlighted: line.toLowerCase().includes(highlightText.toLowerCase()), i }))
.filter(({ isHighlighted }) => isHighlighted)
.slice(0, 3);
return (
<div key={item.title} className={clsx("group rounded-md p-2 shadow-md", ColorBackgroundUtils.getBg800(kb.color), ColorHoverUtils.getBg900(kb.color))}>
<Link to={item.href} className="w-full">
<div className={clsx("space-y-1")}>
<h2>
<Highlight className={clsx(ColorTextUtils.getText100(kb.color))} color={kb.color} highlightText={highlightText}>
{item.title}
</Highlight>
</h2>
<p>
<Highlight className={clsx(ColorTextUtils.getText300(kb.color))} color={kb.color} highlightText={highlightText}>
{item.description}
</Highlight>
</p>
{highlightedLines.map(({ line, i }) => (
<p key={i}>
<Highlight className={clsx(ColorTextUtils.getText300(kb.color))} color={kb.color} highlightText={highlightText}>
{line}
</Highlight>
</p>
))}
</div>
</Link>
</div>
);
}
@@ -0,0 +1,28 @@
export default function KbSkeletons({ n = 5 }: { n?: number }) {
return (
<div className="space-y-3">
{Array.from({ length: n }).map((_, i) => {
return (
<div key={i} className="border-border hover:border-theme-500 bg-background group rounded-md border">
<div className="w-full">
<div className="flex items-center space-x-8 p-6">
<div className="shrink-0">
<div className="h-12 w-12 animate-pulse rounded-md bg-gray-300"></div>
</div>
<div className="flex flex-col">
<div className="h-4 w-32 animate-pulse rounded-md bg-gray-300 font-bold"></div>
<div className="mt-2 h-3 w-48 animate-pulse rounded-md bg-gray-300 text-sm"></div>
<div className="mt-6 flex items-center space-x-2">
<div className="h-4 w-16 animate-pulse rounded-md bg-gray-300 text-sm"></div>
<div className="text-sm text-gray-300">|</div>
<div className="h-4 w-48 animate-pulse rounded-md bg-gray-300 text-sm"></div>
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,55 @@
import type { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
import type { KbArticleDto } from "~/modules/knowledgeBase/dtos/KbArticleDto";
import type { KbCategoryDto } from "~/modules/knowledgeBase/dtos/KbCategoryDto";
import KbArticleContent from "./KbArticleContent";
import { useParams } from "react-router";
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
export default function KbArticle({
kb,
category,
item,
userState,
actions,
withMenu = true,
}: {
kb: KnowledgeBaseDto;
category: KbCategoryDto;
item: KbArticleDto;
userState: {
hasThumbsUp: boolean;
hasThumbsDown: boolean;
};
actions: {
onThumbsUp: () => void;
onThumbsDown: () => void;
};
withMenu?: boolean;
}) {
const params = useParams();
return (
<div className="space-y-6">
{withMenu && (
<BreadcrumbSimple
menu={[
// {
// title: kb.title,
// routePath: KnowledgeBaseUtils.getKbUrl({ kb, params }),
// },
{
title: category.title,
routePath: KnowledgeBaseUtils.getCategoryUrl({ kb, category, params }),
},
{
title: item.title,
routePath: item.href,
},
]}
/>
)}
<KbArticleContent item={item} content={item.contentPublished} userState={userState} actions={actions} />
</div>
);
}
@@ -0,0 +1,98 @@
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import ThumbsDownIcon from "~/components/ui/icons/ThumbsDownIcon";
import ThumbsDownIconFilled from "~/components/ui/icons/ThumbsDownIconFilled";
import ThumbsUpIcon from "~/components/ui/icons/ThumbsUpIcon";
import ThumbsUpIconFilled from "~/components/ui/icons/ThumbsUpIconFilled";
import { KbArticleDto } from "~/modules/knowledgeBase/dtos/KbArticleDto";
import { renderSanitizedMarkdown } from "~/utils/html-sanitizer";
import { useLocaleFormatters } from "~/locale/format";
export default function KbArticleContent({
item,
content,
actions,
userState,
}: {
item: KbArticleDto;
content: string;
userState?: {
hasThumbsUp: boolean;
hasThumbsDown: boolean;
};
actions?: {
onThumbsUp: () => void;
onThumbsDown: () => void;
};
}) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
return (
<div>
<div className="flex flex-col space-y-6">
<div className="flex items-center space-x-8">
<div className="flex w-full flex-col">
<div className="flex items-center space-x-3">
<h1 className="text-xl font-bold md:text-3xl">{item.title}</h1>
</div>
<p className="text-muted-foreground mt-2 font-light">{item.description}</p>
<div className="flex justify-between space-x-2">
<li className="mt-5 flex items-center space-x-2">
{item.createdByUser?.avatar && (
<img
src={item.createdByUser.avatar}
alt={`${item.createdByUser.firstName} ${item.createdByUser.lastName}`}
className="h-8 w-8 rounded-full"
/>
)}
<div>
{item.createdByUser && (
<dl className="whitespace-no-wrap text-sm font-medium leading-5">
<dt className="sr-only">{t("knowledgeBase.article.author")}</dt>
<dd className="">
<span className="text-muted-foreground font-normal">{t("shared.by")}</span> {item.createdByUser.firstName} {item.createdByUser.lastName}
</dd>
</dl>
)}
<dl className="whitespace-no-wrap text-xs leading-5">{formatters.relativeDate(item.createdAt)}</dl>
</div>
</li>
{actions && (
<div className="flex items-center space-x-2">
<button type="button" onClick={actions.onThumbsUp} className="text-muted-foreground hover:text-foreground flex items-center space-x-2">
{userState?.hasThumbsUp ? <ThumbsUpIconFilled className="h-4 w-4" /> : <ThumbsUpIcon className="h-4 w-4" />}
</button>
<button type="button" onClick={actions.onThumbsDown} className="text-muted-foreground hover:text-foreground flex items-center space-x-2">
{userState?.hasThumbsDown ? <ThumbsDownIconFilled className="h-4 w-4" /> : <ThumbsDownIcon className="h-4 w-4" />}
</button>
</div>
)}
</div>
</div>
</div>
</div>
<div className="border-border border-b pt-6"></div>
<div className="grid gap-8 sm:grid-cols-12">
<div className="overflow-auto sm:col-span-9">
<div className="prose dark:prose-dark">
<div dangerouslySetInnerHTML={{ __html: renderSanitizedMarkdown(content) }} />
</div>
</div>
{item.relatedArticles.length > 0 && (
<div className="space-y-3 py-5 sm:col-span-3">
<div className="text-muted-foreground text-sm font-medium">{t("knowledgeBase.article.relatedArticles")}</div>
<div className="space-y-2">
{item.relatedArticles.map((x) => (
<Link to={x.href} key={x.href} className="flex items-center space-x-2 hover:underline">
<div className="text-sm">{x.title}</div>
</Link>
))}
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,38 @@
import { Link } from "react-router";
import RightIcon from "~/components/ui/icons/RightIcon";
import clsx from "clsx";
import ColorHoverUtils from "~/utils/shared/colors/ColorHoverUtils";
import type { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
export default function KbArticles({
kb,
items,
}: {
kb: KnowledgeBaseDto;
items: {
order: number;
title: string;
description: string;
href: string;
sectionId: string | null;
}[];
}) {
return (
<div className="rounded-md border border-border bg-background py-3">
{items.map((item) => {
return (
<div key={item.title} className={clsx("group", ColorHoverUtils.getBorder500(kb.color))}>
<Link to={item.href}>
<div className="flex items-center justify-between space-x-2 px-6 py-3 hover:bg-secondary">
<div className="">
<div className={clsx(" text-muted-foreground group-hover:text-foreground")}>{item.title}</div>
</div>
<RightIcon className={clsx("h-5 w-5 shrink-0 text-muted-foreground group-hover:text-foreground")} />
</div>
</Link>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,32 @@
import EmptyState from "~/components/ui/emptyState/EmptyState";
import type { KbCategoryDto } from "../../dtos/KbCategoryDto";
import type { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
import KbArticles from "./KbArticles";
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
import { useTranslation } from "react-i18next";
export default function KbArticlesBySection({ kb, item }: { kb: KnowledgeBaseDto; item: KbCategoryDto }) {
const { t } = useTranslation();
return (
<div>
{item.articles.length === 0 ? (
<EmptyState className="bg-background" captions={{ thereAreNo: t("knowledgeBase.article.empty") }} />
) : (
<div className="space-y-6">
{KnowledgeBaseUtils.getCategoryArticlesBySections({ kb, category: item }).map((item, idx) => {
return (
<div key={idx} className="space-y-3">
{item.section && (
<div className="flex items-center space-x-2">
<div className="text-xl font-bold">{item.section.title}</div>
</div>
)}
<KbArticles kb={kb} items={item.articles} />
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,35 @@
import OrderListButtons from "~/components/ui/sort/OrderListButtons";
import TableSimple from "~/components/ui/tables/TableSimple";
import { useTranslation } from "react-i18next";
export default function KbSortArticles({
items,
}: {
items: {
id: string;
order: number;
title: string;
}[];
}) {
const { t } = useTranslation();
return (
<div className="space-y-1">
<label className="text-muted-foreground block text-xs font-medium">{t("knowledgeBase.authoring.articles.title")}</label>
<TableSimple
items={items}
headers={[
{
name: "order",
title: "",
value: (i, idx) => <OrderListButtons index={idx} items={items} editable={true} />,
},
{
name: "title",
title: t("shared.title"),
value: (item) => item.title,
},
]}
/>
</div>
);
}
@@ -0,0 +1,69 @@
import clsx from "clsx";
import { lazy, Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
import InputText from "~/components/ui/input/InputText";
import { KnowledgeBaseArticleWithDetails } from "~/modules/knowledgeBase/db/kbArticles.db.server";
import useLocalStorage from "~/utils/hooks/use-local-storage";
const NovelEditor = lazy(() => import("~/modules/novel/ui/editor"));
export default function KbArticleForm({ item }: { item: KnowledgeBaseArticleWithDetails }) {
const { t } = useTranslation();
const [content, setContent] = useLocalStorage(item.id, item.contentDraft);
const [contentType, setContentType] = useState(item?.contentType ?? "wysiwyg");
return (
<div className="space-y-2">
<div className="grid gap-3">
<div className="space-y-2">
<div className="flex justify-between space-x-2">
{/* <label className="text-sm font-medium text-muted-foreground">Article</label> */}
<div className="flex items-center space-x-1">
<button type="button" onClick={() => setContentType("wysiwyg")} className="text-muted-foreground text-xs hover:underline">
<div className={clsx(contentType === "wysiwyg" ? "font-bold" : "")}>{t("knowledgeBase.authoring.editor.wysiwyg")}</div>
</button>
<div>•</div>
<button type="button" onClick={() => setContentType("markdown")} className="text-muted-foreground text-xs hover:underline">
<div className={clsx(contentType === "markdown" ? "font-bold" : "")}>{t("knowledgeBase.authoring.editor.markdown")}</div>
</button>
</div>
</div>
<input name="contentType" value={contentType} readOnly hidden />
{contentType === "wysiwyg" ? (
<div>
<input type="hidden" name="content" value={content} hidden readOnly />
<Suspense fallback={null}>
<NovelEditor
content={content}
onChange={(e) => {
if (e.text) {
setContent(e.html ?? "");
}
}}
/>
</Suspense>
{/* <InputText autoFocus name="content" placeholder="Content" value={content} setValue={setContent} /> */}
</div>
) : contentType === "markdown" ? (
<InputText
className="col-span-12"
rows={6}
editor="monaco"
editorLanguage="markdown"
editorTheme="vs-dark"
editorSize="screen"
editorFontSize={14}
name="content"
value={content}
setValue={(e) => {
if (e) {
setContent(e.toString());
}
}}
/>
) : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,193 @@
import { Form, useNavigation } from "react-router";
import { useEffect, useRef, useState } from "react";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import InputCheckboxWithDescription from "~/components/ui/input/InputCheckboxWithDescription";
import InputSelector from "~/components/ui/input/InputSelector";
import InputText, { RefInputText } from "~/components/ui/input/InputText";
import { KnowledgeBaseArticleSimple, KnowledgeBaseArticleWithDetails } from "~/modules/knowledgeBase/db/kbArticles.db.server";
import UrlUtils from "~/utils/app/UrlUtils";
import InputCombobox from "~/components/ui/input/InputCombobox";
import { useTranslation } from "react-i18next";
import InputSelect from "~/components/ui/input/InputSelect";
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
import { KnowledgeBaseCategorySimple } from "../../db/kbCategories.db.server";
export default function KbArticleSettingsForm({
allKnowledgeBases,
allArticles,
allCategories,
item,
onDelete,
}: {
allKnowledgeBases: { id: string; title: string }[];
allArticles: KnowledgeBaseArticleSimple[];
allCategories: KnowledgeBaseCategorySimple[];
item: KnowledgeBaseArticleWithDetails;
onDelete: () => void;
}) {
const { t } = useTranslation();
const navigation = useNavigation();
const inputTitle = useRef<RefInputText>(null);
const [knowledgeBaseId, setKnowledgeBaseId] = useState(item?.knowledgeBaseId ?? "");
const [categoryId, setCategoryId] = useState<string | undefined>(item?.categoryId ?? "");
const [category, setCategory] = useState<KnowledgeBaseCategorySimple | undefined>(undefined);
const [sectionId, setSectionId] = useState<string | undefined>(item?.sectionId ?? "");
const [language, setLanguage] = useState(item?.language ?? KnowledgeBaseUtils.defaultLanguage);
const [slug, setSlug] = useState(item?.slug ?? "");
const [title, setTitle] = useState(item?.title ?? "");
const [description, setDescription] = useState(item?.description ?? "");
const [seoImage, setSeoImage] = useState(item?.seoImage ?? "");
const [isFeatured, setIsFeatured] = useState(item?.featuredOrder ? true : false);
const [relatedArticles, setRelatedArticles] = useState(item.relatedArticles.map((f) => f.relatedArticleId) ?? []);
useEffect(() => {
if (!item) {
const slug = UrlUtils.slugify(title);
setSlug(slug);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [title]);
useEffect(() => {
setCategory(allCategories.find((f) => f.id === categoryId));
}, [allCategories, categoryId]);
return (
<Form method="post" className="space-y-6 pb-52">
<input name="action" value="edit" readOnly hidden />
<div className="space-y-3">
<div className="grid gap-3">
<InputText ref={inputTitle} name="title" title={t("knowledgeBase.adminArticles.fields.title")} value={title} setValue={setTitle} required />
<InputText
name="slug"
title={t("knowledgeBase.adminArticleSettings.fields.slug")}
value={slug}
setValue={setSlug}
required
hint={
<button type="button" className="text-muted-foreground" onClick={() => setSlug(UrlUtils.slugify(title))}>
{t("knowledgeBase.adminArticleSettings.actions.getFromTitle")}
</button>
}
/>
<InputText
name="description"
title={t("knowledgeBase.adminArticles.fields.description")}
value={description}
setValue={setDescription}
maxLength={300}
// required
/>
<InputSelector
name="knowledgeBaseId"
title={t("knowledgeBase.adminArticleSettings.fields.knowledgeBase")}
value={knowledgeBaseId}
setValue={(e) => setKnowledgeBaseId(e?.toString() ?? "")}
options={allKnowledgeBases.map((f) => {
return {
value: f.id,
name: f.title,
};
})}
/>
<InputSelect
name="language"
title={t("knowledgeBase.adminArticleSettings.fields.language")}
value={language}
setValue={(e) => setLanguage(e?.toString() || "")}
options={KnowledgeBaseUtils.getSupportedLanguageOptions(t)}
/>
<InputSelector
name="categoryId"
title={t("knowledgeBase.adminArticles.fields.category")}
value={categoryId}
setValue={(e) => setCategoryId(e?.toString())}
options={allCategories
.filter((f) => f.knowledgeBaseId === knowledgeBaseId && f.language === language)
.map((f) => {
return {
value: f.id,
name: f.title,
};
})}
/>
{category && category.sections.length > 0 && (
<InputSelector
name="sectionId"
title={t("knowledgeBase.adminArticleSettings.fields.section")}
value={sectionId}
setValue={(e) => setSectionId(e?.toString())}
options={category.sections.map((f) => {
return {
value: f.id,
name: f.title,
};
})}
/>
)}
<InputText name="seoImage" title={t("knowledgeBase.adminBaseForm.fields.seoImage")} value={seoImage} setValue={setSeoImage} />
{seoImage && (
<div>
<img className="xl:border-border overflow-hidden rounded-lg shadow-xl xl:border-b" src={seoImage} alt={title} />
</div>
)}
<InputCheckboxWithDescription
name="isFeatured"
title={t("knowledgeBase.adminArticles.fields.featured")}
value={isFeatured}
setValue={setIsFeatured}
description={t("knowledgeBase.adminArticleSettings.descriptions.featured")}
/>
<div>
{relatedArticles?.map((item, idx) => {
return <input key={idx} type="hidden" name={`relatedArticles[]`} value={item} />;
})}
<InputCombobox
name="relatedArticles"
title={t("knowledgeBase.adminArticleSettings.fields.relatedArticles")}
value={relatedArticles}
onChange={(e) => setRelatedArticles(e as string[])}
hint={
<button type="button" className="text-muted-foreground" onClick={() => setRelatedArticles([])}>
{t("shared.clear")}
</button>
}
minDisplayCount={1}
options={allArticles
.filter((f) => f.id !== item.id && f.knowledgeBaseId === knowledgeBaseId && f.language === language)
.map((f) => {
let title = f.title;
if (f.category) {
title = `${f.title} (${f.category.title})`;
}
if (f.category && f.section) {
title = `${f.title} (${f.category.title} / ${f.section.title})`;
}
return {
name: title,
value: f.id,
};
})}
/>
</div>
</div>
</div>
<div className="mt-5 flex justify-between space-x-2 sm:mt-6">
<div>
<ButtonSecondary disabled={navigation.state === "submitting"} type="button" className="w-full" onClick={onDelete} destructive>
<div>{t("shared.delete")}</div>
</ButtonSecondary>
</div>
<div className="flex space-x-2">
<LoadingButton actionName="edit" type="submit" disabled={navigation.state === "submitting"}>
{t("shared.save")}
</LoadingButton>
</div>
</div>
</Form>
);
}
@@ -0,0 +1,114 @@
import { Form, useNavigate, useNavigation } from "react-router";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import InputText, { RefInputText } from "~/components/ui/input/InputText";
import UrlUtils from "~/utils/app/UrlUtils";
import { KnowledgeBaseDto } from "~/modules/knowledgeBase/dtos/KnowledgeBaseDto";
import EntityIcon from "~/components/layouts/icons/EntityIcon";
import { KnowledgeBaseCategoryWithDetails } from "../../helpers/KbCategoryModelHelper";
import KbSortArticles from "../articles/KbSortArticles";
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
import QuestionMarkFilledIcon from "~/components/ui/icons/QuestionMarkFilledIcon";
export default function KbCategoryForm({
knowledgeBase,
language,
item,
onDelete,
}: {
knowledgeBase: KnowledgeBaseDto;
language: string;
item?: KnowledgeBaseCategoryWithDetails;
onDelete?: () => void;
}) {
const { t } = useTranslation();
const navigation = useNavigation();
const navigate = useNavigate();
// const [showFilterModal, setShowFilterModal] = useState<{ item?: { type: FeatureFlagsFilterType; value: string | null }; idx?: number }>();
const mainInput = useRef<RefInputText>(null);
useEffect(() => {
setTimeout(() => {
mainInput.current?.input.current?.focus();
}, 100);
}, []);
const [slug, setSlug] = useState(item?.slug || "");
const [title, setTitle] = useState(item?.title || "");
const [description, setDescription] = useState(item?.description || "");
const [icon, setIcon] = useState(
item?.icon || ""
// `<svg stroke="currentColor" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5"> <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" /> </svg>`
);
// const [language, setLanguage] = useState(item?.language || "");
const [seoImage, setSeoImage] = useState(item?.seoImage || "");
useEffect(() => {
if (!item) {
setSlug(UrlUtils.slugify(title.toLowerCase()));
}
}, [item, title]);
return (
<div>
<Form method="post" className="inline-block w-full overflow-hidden p-1 text-left align-bottom sm:align-middle">
<input type="hidden" name="action" value={item ? "edit" : "new"} hidden readOnly />
<div className="space-y-2">
<InputText ref={mainInput} autoFocus name="title" title={t("knowledgeBase.adminCategoryForm.fields.title")} value={title} setValue={setTitle} required />
<InputText name="slug" title={t("knowledgeBase.adminCategoryForm.fields.slug")} value={slug} setValue={setSlug} required />
<InputText name="description" title={t("knowledgeBase.adminCategoryForm.fields.description")} value={description} setValue={setDescription} />
<InputText
name="icon"
title={t("knowledgeBase.adminCategoryForm.fields.icon")}
value={icon}
setValue={(e) => setIcon(e.toString() ?? "")}
hint={t("knowledgeBase.adminCategoryForm.hints.icon")}
button={
<div className="absolute inset-y-0 right-0 flex py-0.5 pr-0.5 ">
<kbd className="border-border text-muted-foreground bg-secondary inline-flex w-10 items-center justify-center rounded border px-1 text-center font-sans text-xs font-medium">
{icon ? (
<EntityIcon className="text-muted-foreground h-7 w-7" icon={icon} title={title} />
) : (
<QuestionMarkFilledIcon className="text-muted-foreground h-5 w-5" aria-hidden="true" />
)}
</kbd>
</div>
}
/>
<InputText name="seoImage" title={t("knowledgeBase.adminCategoryForm.fields.seoImage")} value={seoImage} setValue={setSeoImage} hint={t("knowledgeBase.adminCategoryForm.hints.seoImage")} />
{seoImage && (
<div className="col-span-12">
<img className="xl:border-border overflow-hidden rounded-lg shadow-xl xl:border-b" src={seoImage} alt={title} />
</div>
)}
{item && <KbSortArticles items={item.articles.filter((f) => !f.sectionId).sort((a, b) => a.order - b.order)} />}
</div>
<div className="mt-5 flex justify-between space-x-2 sm:mt-6">
<div>
{onDelete && (
<ButtonSecondary
disabled={navigation.state === "submitting" || (item?.articles ?? []).length > 0}
type="button"
className="w-full"
onClick={onDelete}
destructive
>
<div>{t("shared.delete")}</div>
</ButtonSecondary>
)}
</div>
<div className="flex space-x-2">
<ButtonSecondary onClick={() => navigate(adminPath(`knowledge-base/bases/${knowledgeBase.slug}/categories/${language}`, { mode: "host-aware" }))}>{t("shared.cancel")}</ButtonSecondary>
<LoadingButton actionName={item ? "edit" : "new"} type="submit" disabled={navigation.state === "submitting"}>
{t("shared.save")}
</LoadingButton>
</div>
</div>
</Form>
</div>
);
}
@@ -0,0 +1,75 @@
import { Form, useNavigate, useNavigation } from "react-router";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import InputText, { RefInputText } from "~/components/ui/input/InputText";
import { KnowledgeBaseDto } from "~/modules/knowledgeBase/dtos/KnowledgeBaseDto";
import { KnowledgeBaseCategorySectionWithDetails } from "~/modules/knowledgeBase/db/kbCategorySections.db.server";
import { KnowledgeBaseCategoryWithDetails } from "../../helpers/KbCategoryModelHelper";
import KbSortArticles from "../articles/KbSortArticles";
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
export default function KbCategorySectionForm({
knowledgeBase,
category,
language,
item,
onDelete,
}: {
knowledgeBase: KnowledgeBaseDto;
category: KnowledgeBaseCategoryWithDetails;
language: string;
item?: KnowledgeBaseCategorySectionWithDetails;
onDelete?: () => void;
}) {
const { t } = useTranslation();
const navigation = useNavigation();
const navigate = useNavigate();
// const [showFilterModal, setShowFilterModal] = useState<{ item?: { type: FeatureFlagsFilterType; value: string | null }; idx?: number }>();
const mainInput = useRef<RefInputText>(null);
useEffect(() => {
setTimeout(() => {
mainInput.current?.input.current?.focus();
}, 100);
}, []);
const [title, setTitle] = useState(item?.title || "");
const [description, setDescription] = useState(item?.description || "");
return (
<div>
<Form method="post" className="inline-block w-full overflow-hidden p-1 text-left align-bottom sm:align-middle">
<input type="hidden" name="action" value={item ? "edit" : "new"} hidden readOnly />
<div className="space-y-2">
<InputText ref={mainInput} autoFocus name="title" title={t("knowledgeBase.adminCategorySectionForm.fields.title")} value={title} setValue={setTitle} required />
<InputText name="description" title={t("knowledgeBase.adminCategorySectionForm.fields.description")} value={description} setValue={setDescription} />
{item && <KbSortArticles items={item.articles.sort((a, b) => a.order - b.order)} />}
</div>
<div className="mt-5 flex justify-between space-x-2 sm:mt-6">
<div>
{onDelete && (
<ButtonSecondary
disabled={navigation.state === "submitting" || (item?.articles ?? []).length > 0}
type="button"
className="w-full"
onClick={onDelete}
destructive
>
<div>{t("shared.delete")}</div>
</ButtonSecondary>
)}
</div>
<div className="flex space-x-2">
<ButtonSecondary onClick={() => navigate(adminPath(`knowledge-base/bases/${knowledgeBase.slug}/categories/${language}`, { mode: "host-aware" }))}>{t("shared.cancel")}</ButtonSecondary>
<LoadingButton actionName={item ? "edit" : "new"} type="submit" disabled={navigation.state === "submitting"}>
{t("shared.save")}
</LoadingButton>
</div>
</div>
</Form>
</div>
);
}
@@ -0,0 +1,88 @@
import OrderIndexButtons from "~/components/ui/sort/OrderIndexButtons";
import TableSimple from "~/components/ui/tables/TableSimple";
import { KbNavLinkDto } from "~/modules/knowledgeBase/dtos/KbNavLinkDto";
import { useTranslation } from "react-i18next";
import { PlusIcon } from "~/components/ui/icons/vendor";
export default function KbNavLinksTable({
items,
setItems,
}: {
items: KbNavLinkDto[];
setItems: React.Dispatch<
React.SetStateAction<
{
name: string;
href: string;
order: number;
}[]
>
>;
}) {
const { t } = useTranslation();
return (
<div>
<div className="mb-1 flex items-center justify-between space-x-2 text-xs">
<label className="text-muted-foreground font-medium">{t("knowledgeBase.adminBaseForm.navLinks.title")}</label>
<button type="button" onClick={() => setItems([])} className="text-muted-foreground hover:text-foreground/80">
{t("shared.clear")}
</button>
</div>
<div className="">
<TableSimple
items={items.sort((a, b) => a.order - b.order)}
headers={[
{
name: "order",
title: "",
value: (item, idx) => (
<OrderIndexButtons
idx={idx}
items={items.map((f, idx) => {
return {
idx: idx,
order: f.order,
};
})}
onChange={(newItems) => {
setItems(
newItems.map((f, i) => {
return { ...items[i], order: f.order };
})
);
}}
/>
),
},
{
name: "title",
title: t("knowledgeBase.adminBaseForm.navLinks.columns.title"),
value: (item) => item.name,
editable: () => true,
setValue: (value, idx) => setItems((e) => e.map((item, i) => (i === idx ? { ...item, name: value } : item))),
},
{
name: "href",
title: t("knowledgeBase.adminBaseForm.navLinks.columns.link"),
value: (item) => item.href,
editable: () => true,
setValue: (value, idx) => setItems((e) => e.map((item, i) => (i === idx ? { ...item, href: value } : item))),
},
]}
/>
<button
type="button"
onClick={() => {
setItems([...items, { name: t("knowledgeBase.adminBaseForm.navLinks.defaultName", { index: items.length + 1 }), href: "/", order: items.length + 1 }]);
}}
className="border-border text-muted-foreground focus:text-foreground bg-background hover:bg-secondary/90 mt-2 flex items-center space-x-1 rounded-md border px-2 py-1 text-xs focus:ring-3 focus:ring-gray-300 focus:ring-offset-1"
>
<PlusIcon className="h-5 w-5" />
<span className="font-medium uppercase">{t("shared.add")}</span>
</button>
</div>
</div>
);
}
@@ -0,0 +1,215 @@
import { Form, useNavigate, useNavigation } from "react-router";
import { useEffect, useRef, useState } from "react";
import { Colors } from "~/application/enums/shared/Colors";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import InputCheckboxWithDescription from "~/components/ui/input/InputCheckboxWithDescription";
import InputCombobox from "~/components/ui/input/InputCombobox";
import InputSelect from "~/components/ui/input/InputSelect";
import InputSelector from "~/components/ui/input/InputSelector";
import InputText, { RefInputText } from "~/components/ui/input/InputText";
import { KnowledgeBaseDto } from "~/modules/knowledgeBase/dtos/KnowledgeBaseDto";
import UrlUtils from "~/utils/app/UrlUtils";
import { getColors } from "~/utils/shared/ColorUtils";
import KbNavLinksTable from "./KbNavLinksTable";
import KnowledgeBaseUtils from "~/modules/knowledgeBase/utils/KnowledgeBaseUtils";
import { logoLight, logoDark } from "~/assets/brand/default";
import clsx from "clsx";
import ColorBackgroundUtils from "~/utils/shared/colors/ColorBackgroundUtils";
import { useTranslation } from "react-i18next";
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
export default function KnowledgeBaseForm({ item, onDelete }: { item?: KnowledgeBaseDto; onDelete?: () => void }) {
const { t } = useTranslation();
const navigation = useNavigation();
const navigate = useNavigate();
// const [showFilterModal, setShowFilterModal] = useState<{ item?: { type: FeatureFlagsFilterType; value: string | null }; idx?: number }>();
const mainInput = useRef<RefInputText>(null);
useEffect(() => {
setTimeout(() => {
mainInput.current?.input.current?.focus();
}, 100);
}, []);
const [basePath, setBasePath] = useState<string | number | undefined>(item ? item.basePath : "/");
const [slug, setSlug] = useState(item ? item.slug : "docs");
const [title, setTitle] = useState(item?.title || "");
const [description, setDescription] = useState(item?.description || "");
const [defaultLanguage, setDefaultLanguage] = useState<string | undefined>(item?.defaultLanguage || KnowledgeBaseUtils.defaultLanguage);
const [layout, setLayout] = useState<"list" | "articles" | "grid">(item?.layout || ("list" as any));
const [color, setColor] = useState(item?.color || Colors.BLUE);
const [enabled, setEnabled] = useState(item?.enabled ?? false);
// const [metatags, setMetatags] = useState<MetaTagsDto>(item?.metatags || []);
const [languages, setLanguages] = useState<string[]>(item?.languages || [KnowledgeBaseUtils.defaultLanguage]);
const [logo, setLogo] = useState<"light" | "dark" | string>(item?.logo || "dark");
const [seoImage, setSeoImage] = useState(item?.seoImage || "");
const [links, setLinks] = useState<{ name: string; href: string; order: number }[]>(item?.links || []);
useEffect(() => {
if (!item) {
setSlug(UrlUtils.slugify(title.toLowerCase()));
}
}, [item, title]);
return (
<div>
<Form method="post" className="inline-block w-full overflow-hidden p-1 text-left align-bottom sm:align-middle">
<input type="hidden" name="action" value={item ? "edit" : "new"} hidden readOnly />
{links.map((item, index) => {
return <input type="hidden" name="links[]" value={JSON.stringify(item)} key={index} hidden readOnly />;
})}
<div className="space-y-2">
<InputText ref={mainInput} autoFocus name="title" title={t("knowledgeBase.adminBaseForm.fields.title")} value={title} setValue={setTitle} required />
<InputSlug setBasePath={setBasePath} />
<InputText name="description" title={t("knowledgeBase.adminBaseForm.fields.description")} value={description} setValue={setDescription} />
<div>
{languages?.map((item, idx) => {
return <input key={idx} type="hidden" name={`languages[]`} value={item} />;
})}
<InputCombobox
name="languages"
title={t("knowledgeBase.adminBaseForm.fields.languages")}
value={languages}
onChange={(e) => setLanguages(e as string[])}
options={KnowledgeBaseUtils.getSupportedLanguageOptions(t)}
/>
</div>
<InputSelect
name="defaultLanguage"
title={t("knowledgeBase.adminBaseForm.fields.defaultLanguage")}
value={defaultLanguage}
setValue={(e) => setDefaultLanguage(e?.toString() || "en")}
options={languages.map((item) => {
return {
value: item,
name: KnowledgeBaseUtils.getLanguageName(item, t) || item,
};
})}
/>
<InputSelect
name="layout"
title={t("knowledgeBase.adminBaseForm.fields.layout")}
value={layout}
setValue={(e) => setLayout((e?.toString() as any) || "list")}
options={[
{
value: "list",
name: t("knowledgeBase.layouts.list"),
},
{
value: "articles",
name: t("knowledgeBase.layouts.articles"),
},
{
value: "grid",
name: t("knowledgeBase.layouts.grid"),
},
{
value: "docs",
name: t("knowledgeBase.layouts.docs"),
},
]}
/>
<InputSelector
name="color"
title={t("knowledgeBase.adminBaseForm.fields.color")}
value={color}
setValue={(e) => setColor(e as number)}
withColors={true}
options={
getColors().map((color) => {
return {
name: t(`app.shared.colors.${Colors[color ?? 0]}`),
value: color,
color,
};
}) ?? []
}
/>
<InputText
name="logo"
title={t("knowledgeBase.adminBaseForm.fields.logo")}
value={logo}
setValue={setLogo}
hint={t("knowledgeBase.adminBaseForm.hints.logo")}
button={
<div className="absolute inset-y-0 right-0 flex py-0.5 pr-0.5 ">
<kbd
className={clsx(
"border-border text-muted-foreground inline-flex w-auto items-center justify-center rounded border px-1 text-center font-sans text-xs font-medium",
ColorBackgroundUtils.getBg700(color)
)}
>
{logo === "light" ? (
<img className="h-7 w-auto" src={logoLight} alt={t("knowledgeBase.adminBaseForm.preview.logoAlt")} />
) : logo === "dark" ? (
<img className="h-7 w-auto" src={logoDark} alt={t("knowledgeBase.adminBaseForm.preview.logoAlt")} />
) : logo.startsWith("http") ? (
<img className="h-7 w-auto" src={logo} alt={t("knowledgeBase.adminBaseForm.preview.logoAlt")} />
) : (
<div className="italic text-white">{t("knowledgeBase.adminBaseForm.preview.invalidLogo")}</div>
)}
</kbd>
</div>
}
/>
<InputText name="seoImage" title={t("knowledgeBase.adminBaseForm.fields.seoImage")} value={seoImage} setValue={setSeoImage} hint={t("knowledgeBase.adminBaseForm.hints.url")} />
{seoImage && (
<div className="col-span-12">
<img className="xl:border-border overflow-hidden rounded-lg shadow-xl xl:border-b" src={seoImage} alt={title} />
</div>
)}
<InputCheckboxWithDescription
name="enabled"
title={t("knowledgeBase.adminBaseForm.fields.enabled")}
value={enabled}
setValue={setEnabled}
description={t("knowledgeBase.adminBaseForm.descriptions.enabled")}
/>
<KbNavLinksTable items={links} setItems={setLinks} />
</div>
<div className="mt-5 flex justify-between space-x-2 sm:mt-6">
<div>
{onDelete && (
<ButtonSecondary disabled={navigation.state === "submitting"} type="button" className="w-full" onClick={onDelete} destructive>
<div>{t("shared.delete")}</div>
</ButtonSecondary>
)}
</div>
<div className="flex space-x-2">
<ButtonSecondary onClick={() => navigate(adminPath("knowledge-base/bases", { mode: "host-aware" }))}>{t("shared.cancel")}</ButtonSecondary>
<LoadingButton actionName={item ? "edit" : "new"} type="submit" disabled={navigation.state === "submitting"}>
{t("shared.save")}
</LoadingButton>
</div>
</div>
</Form>
</div>
);
}
function InputSlug({
setBasePath,
}: {
setBasePath: React.Dispatch<React.SetStateAction<string | number | undefined>>;
}) {
const { t } = useTranslation();
useEffect(() => {
setBasePath("/");
}, [setBasePath]);
return (
<div className="space-y-2">
<input type="hidden" name="basePath" value={"/"} hidden readOnly />
<input type="hidden" name="slug" value={"docs"} hidden readOnly />
<InputText title={t("knowledgeBase.adminBaseForm.fields.url")} value={UrlUtils.join("/", "docs")} setValue={() => {}} disabled />
</div>
);
}
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import path from "node:path";
const repoRoot = process.cwd();
const sourceFiles = [
"apps/web/app/modules/knowledgeBase/components/bases/KnowledgeBaseForm.tsx",
"apps/web/app/modules/knowledgeBase/components/bases/KbCategoryForm.tsx",
"apps/web/app/modules/knowledgeBase/components/bases/KbCategorySectionForm.tsx",
"apps/web/app/modules/knowledgeBase/components/bases/KbArticleSettingsForm.tsx",
"apps/web/app/modules/knowledgeBase/components/bases/KbNavLinksTable.tsx",
"apps/web/app/modules/knowledgeBase/components/categories/KbCategoriesGrid.tsx",
"apps/web/app/modules/knowledgeBase/components/categories/KbCategoriesList.tsx",
"apps/web/app/modules/knowledgeBase/components/templates/PreviewKbsTemplate.tsx",
"apps/web/app/routes/admin/knowledge-base.tsx",
"apps/web/app/routes/admin/knowledge-base/index.tsx",
"apps/web/app/routes/admin/knowledge-base/bases.tsx",
"apps/web/app/routes/admin/knowledge-base/bases.import.tsx",
"apps/web/app/routes/admin/knowledge-base/danger.tsx",
"apps/web/app/routes/admin/knowledge-base/articles.tsx",
"apps/web/app/routes/admin/knowledge-base/bases.$slug/categories.tsx",
"apps/web/app/routes/admin/knowledge-base/bases.$slug/categories.$lang.tsx",
"apps/web/app/routes/admin/knowledge-base/bases.$slug/articles.tsx",
"apps/web/app/routes/admin/knowledge-base/bases.$slug/articles.$lang.tsx",
"apps/web/app/routes/admin/knowledge-base/bases.$slug/articles.$lang.$id.tsx",
"apps/web/app/routes/admin/knowledge-base/bases.$slug/articles.$lang.$id.edit.tsx",
];
describe("knowledge base authoring i18n", () => {
it("uses semantic keys for visible admin authoring labels", () => {
const sources = sourceFiles.map((file) => fs.readFileSync(path.join(repoRoot, file), "utf8")).join("\n");
expect(sources).toContain("knowledgeBase.adminBaseForm.fields.title");
expect(sources).toContain("knowledgeBase.adminCategoryForm.fields.title");
expect(sources).toContain("knowledgeBase.adminCategorySectionForm.fields.description");
expect(sources).toContain("knowledgeBase.adminCategories.confirmDelete.category.title");
expect(sources).toContain("knowledgeBase.adminCategories.counts.articles");
expect(sources).toContain("knowledgeBase.adminCategories.counts.sections");
expect(sources).toContain("formatters.number(item.sections.length)");
expect(sources).toContain("formatters.number(category.articles.filter((f) => f.sectionId === item.id).length)");
expect(sources).toContain("knowledgeBase.adminBaseForm.navLinks.defaultName");
expect(sources).toContain("knowledgeBase.adminArticleSettings.fields.relatedArticles");
expect(sources).toContain("knowledgeBase.adminArticleDetail.actions.publish");
expect(sources).toContain("knowledgeBase.adminBases.metaTitle");
expect(sources).toContain("knowledgeBase.adminBases.actions.categories");
expect(sources).toContain("knowledgeBase.adminBases.counts.articles");
expect(sources).toContain("formattedCount: formatters.number(i.count.articles)");
expect(sources).toContain("value: (i) => formatters.number(i.count.views)");
expect(sources).toContain("knowledgeBase.adminBases.empty.title");
expect(sources).toContain("knowledgeBase.adminImport.title");
expect(sources).toContain("knowledgeBase.adminImport.configuration");
expect(sources).toContain("knowledgeBase.adminDanger.resetAllData");
expect(sources).toContain("knowledgeBase.adminDanger.resetAllDataDescription");
expect(sources).toContain("knowledgeBase.authoring.metaTitle");
expect(sources).toContain("knowledgeBase.authoring.navigation.overview");
expect(sources).toContain("knowledgeBase.authoring.breadcrumbs.knowledgeBases");
expect(sources).toContain("knowledgeBase.authoring.categories.count");
expect(sources).toContain("knowledgeBase.authoring.articles.count");
expect(sources).toContain("knowledgeBase.category.articleCount");
expect(sources).toContain("knowledgeBase.authoring.actions.saveDraft");
expect(sources).toContain("knowledgeBase.authoring.articleSettings.title");
expect(sources).toContain("knowledgeBase.templates.preview.headings.knowledgeBases");
expect(sources).toContain("knowledgeBase.templates.preview.columns.knowledgeBase");
expect(sources).toContain("KnowledgeBaseUtils.getSupportedLanguageOptions(t)");
expect(sources).toContain("KnowledgeBaseUtils.getLanguageName(item, t)");
expect(sources).toContain("KnowledgeBaseUtils.getLanguageName(f, t)");
expect(sources).toContain("KnowledgeBaseUtils.getLanguageName(params.lang!, t)");
expect(sources).toContain("KnowledgeBaseUtils.getLanguageName(language, t)");
expect(sources).toContain("QuestionMarkFilledIcon");
expect(sources).not.toContain("ui.autogenerated");
expect(sources).not.toContain("KnowledgeBaseUtils.getLanguageName(f)</div>");
expect(sources).not.toContain("KnowledgeBaseUtils.getLanguageName(f)}");
expect(sources).not.toContain("KnowledgeBaseUtils.getLanguageName(params.lang!)");
expect(sources).not.toContain("KnowledgeBaseUtils.getLanguageName(language))");
expect(sources).not.toContain('text-red-600">?</span>');
expect(sources).not.toContain("adminCategories.counts.sections\", { count: item.sections.length })");
expect(sources).not.toContain("adminCategories.counts.articles\", { count: category.articles.filter((f) => f.sectionId === item.id).length })");
});
it("keeps the remaining authoring strings out of JSX literals", () => {
const forbidden = [
'{"Title"}',
'{"Layout"}',
'{"Color"}',
'{"Logo"}',
'{"Enabled"}',
'{"Delete"}',
'{"Cancel"}',
'{"Save"}',
'{"Language"}',
'{"Category"}',
'{"Section"}',
'{"Featured"}',
'{"Nav links"}',
'{"Clear"}',
'{"Add"}',
">Settings<",
">Preview<",
">Publish<",
">Import<",
">Danger<",
">article<",
">articles<",
"Reset all Knowledge Base data?",
"Delete all Knowledge Base data",
"Get started by creating a new knowledge base.",
];
for (const file of sourceFiles) {
const source = fs.readFileSync(path.join(repoRoot, file), "utf8");
for (const literal of forbidden) {
expect(source).not.toContain(literal);
}
}
});
});
@@ -0,0 +1,37 @@
import type { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
import type { KbCategoryDto } from "../../dtos/KbCategoryDto";
import KbCategoriesList from "./KbCategoriesList";
import KbCategoriesGrid from "./KbCategoriesGrid";
import KbCategoriesTopArticles from "./KbCategoriesTopArticles";
import EmptyState from "~/components/ui/emptyState/EmptyState";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
export default function KbCategories({ items, kb }: { items: KbCategoryDto[]; kb: KnowledgeBaseDto }) {
const { t } = useTranslation();
return (
<div>
{items.length === 0 ? (
<div className="space-y-4">
<h2 className="text-2xl font-bold">{t("knowledgeBase.category.plural")}</h2>
<EmptyState className="bg-background" captions={{ thereAreNo: t("knowledgeBase.category.empty") }} />
</div>
) : (
<Fragment>
{kb.layout === "list" ? (
<KbCategoriesList kb={kb} items={items} />
) : kb.layout === "articles" ? (
<KbCategoriesTopArticles kb={kb} items={items} />
) : kb.layout === "grid" ? (
<KbCategoriesGrid kb={kb} items={items} columns={3} />
) : kb.layout === "docs" ? (
<div className="space-y-4">
<h2 className="text-2xl font-bold">{t("knowledgeBase.category.plural")}</h2>
<KbCategoriesGrid kb={kb} items={items} columns={3} />
</div>
) : null}
</Fragment>
)}
</div>
);
}
@@ -0,0 +1,67 @@
import { Link } from "react-router";
import clsx from "clsx";
import type { KbCategoryDto } from "~/modules/knowledgeBase/dtos/KbCategoryDto";
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
import ColorGroupHoverUtils from "~/utils/shared/colors/ColorGroupHoverUtils";
import { useTranslation } from "react-i18next";
import { useLocaleFormatters } from "~/locale/format";
import { sanitizeIconSvg } from "~/utils/html-sanitizer";
export default function KbCategoriesGrid({ kb, items, columns }: { kb: KnowledgeBaseDto; items: KbCategoryDto[]; columns: number }) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
return (
<div
className={clsx(
"grid grid-cols-1 gap-4",
columns === 2 && "md:grid-cols-2",
columns === 3 && "md:grid-cols-3",
columns === 4 && "md:grid-cols-4",
columns === 5 && "md:grid-cols-5",
columns === 6 && "md:grid-cols-6"
)}
>
{items.map((item) => {
return (
<div key={item.title} className="border-border bg-background hover:bg-secondary hover:border-secondary-foreground group rounded-md border">
<Link to={item.href} className="w-full">
<div className="flex flex-col space-y-3 p-6">
<div className="shrink-0">
{item.icon.startsWith("<svg") ? (
<div
dangerouslySetInnerHTML={{
__html: sanitizeIconSvg(
item.icon,
clsx("h-8 w-8", ColorTextUtils.getText500(kb.color), ColorGroupHoverUtils.getText700(kb.color))
),
}}
/>
) : item.icon.includes("http") ? (
<img className="h-8 w-8" src={item.icon} alt={item.title} />
) : (
<></>
)}
</div>
<div className="flex items-center space-x-8">
<div className="flex w-full flex-col">
<div className="font-bold">{item.title}</div>
<div className="text-muted-foreground mt-1 text-sm">{item.description}</div>
<div className="mt-6 flex items-center justify-between space-x-2">
<div className="flex items-center space-x-2">
<div className="text-sm">
{t("knowledgeBase.category.articleCount", { count: item.articles.length, formattedCount: formatters.number(item.articles.length) })}
</div>
</div>
{/* <div className="text-sm text-muted-foreground">{item.version}</div> */}
</div>
</div>
</div>
</div>
</Link>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,60 @@
import { Link } from "react-router";
import clsx from "clsx";
import type { KbCategoryDto } from "~/modules/knowledgeBase/dtos/KbCategoryDto";
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
import ColorGroupHoverUtils from "~/utils/shared/colors/ColorGroupHoverUtils";
import { useTranslation } from "react-i18next";
import { useLocaleFormatters } from "~/locale/format";
export default function KbCategoriesList({ kb, items }: { kb: KnowledgeBaseDto; items: KbCategoryDto[] }) {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
return (
<div className="space-y-4">
<h2 className="text-2xl font-bold">{t("knowledgeBase.category.plural")}</h2>
<div className={clsx("grid grid-cols-1 gap-4")}>
{items.map((item) => {
return (
<div key={item.title} className="border-border bg-background hover:bg-secondary hover:border-secondary-foreground group rounded-md border">
<Link to={item.href} className="w-full">
<div className="flex items-center space-x-8 p-6">
<div className="shrink-0">
{item.icon.startsWith("<svg") ? (
<div
dangerouslySetInnerHTML={{
__html:
item.icon.replace(
"<svg",
`<svg class='${clsx("h-12 w-12", ColorTextUtils.getText500(kb.color), ColorGroupHoverUtils.getText700(kb.color))}'`
) ?? "",
}}
/>
) : item.icon.includes("http") ? (
<img className="h-12 w-12" src={item.icon} alt={item.title} />
) : (
<></>
)}
</div>
<div className="flex w-full flex-col">
<div className="font-bold">{item.title}</div>
<div className="text-muted-foreground mt-1 text-sm">{item.description}</div>
<div className="mt-6 flex items-center justify-between space-x-2">
<div className="flex items-center space-x-2">
<div className="text-sm">
{t("knowledgeBase.category.articleCount", { count: item.articles.length, formattedCount: formatters.number(item.articles.length) })}
</div>
</div>
{/* <div className="text-sm text-muted-foreground">{item.version}</div> */}
</div>
</div>
</div>
</Link>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,63 @@
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import type { KbCategoryDto } from "../../dtos/KbCategoryDto";
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
import ColorGroupHoverUtils from "~/utils/shared/colors/ColorGroupHoverUtils";
export default function KbCategoriesTopArticles({ kb, items }: { kb: KnowledgeBaseDto; items: KbCategoryDto[] }) {
const { t } = useTranslation();
function getTopArticles(item: KbCategoryDto) {
return item.articles.slice(0, 3);
}
return (
<div className="space-y-4">
{items.map((item) => {
return (
<div key={item.slug} className="space-y-4">
<div className="flex items-baseline justify-between space-x-2">
<div className="flex items-center space-x-4">
{item.icon.startsWith("<svg") ? (
<div
dangerouslySetInnerHTML={{
__html:
item.icon.replace(
"<svg",
`<svg class='${clsx("h-6 w-6", ColorTextUtils.getText500(kb.color), ColorGroupHoverUtils.getText700(kb.color))}'`
) ?? "",
}}
/>
) : item.icon.includes("http") ? (
<img className="h-6 w-6" src={item.icon} alt={item.title} />
) : null}
<Link to={item.href} className="hover:underline">
<h2 className="text-2xl font-bold">{item.title}</h2>
</Link>
</div>
<Link to={item.href} className="text-muted-foreground text-sm hover:underline">
{t("knowledgeBase.category.viewAll")}
</Link>
</div>
<div className={clsx("grid grid-cols-3 gap-4")}>
{getTopArticles(item).map((item) => {
return (
<div key={item.title} className="border-border bg-background hover:bg-secondary hover:border-secondary-foreground group rounded-md border">
<Link to={item.href} className="w-full">
<div className="flex items-center space-x-8 p-6">
<div className="flex w-full flex-col space-y-1">
<div className="group-hover:text-foreground/80 font-medium">{item.title}</div>
<div className="text-muted-foreground text-sm">{item.description}</div>
</div>
</div>
</Link>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,96 @@
import type { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
import type { KbCategoryDto } from "../../dtos/KbCategoryDto";
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
import clsx from "clsx";
import { Link, useParams } from "react-router";
import KbArticlesBySection from "../articles/KbArticlesBySection";
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
import ColorGroupHoverUtils from "~/utils/shared/colors/ColorGroupHoverUtils";
import { useTranslation } from "react-i18next";
import { sanitizeIconSvg } from "~/utils/html-sanitizer";
export default function KbCategory({ kb, item, allCategories }: { kb: KnowledgeBaseDto; item: KbCategoryDto; allCategories: KbCategoryDto[] }) {
const { t } = useTranslation();
const params = useParams();
return (
<div className="space-y-6">
<BreadcrumbSimple
menu={[
{
title: kb.title,
routePath: KnowledgeBaseUtils.getKbUrl({ kb, params }),
},
{
title: item.title ?? "",
routePath: item.href,
},
]}
/>
<div className="flex flex-col space-y-6">
<div className="flex items-center space-x-8">
<div className="flex w-full flex-col">
<div className="flex items-center space-x-3">
{item.icon && (
<div className="shrink-0">
{item.icon.startsWith("<svg") ? (
<div
dangerouslySetInnerHTML={{
__html: sanitizeIconSvg(
item.icon,
clsx("h-7 w-7", ColorTextUtils.getText500(kb.color), ColorGroupHoverUtils.getText700(kb.color))
),
}}
/>
) : item.icon.includes("http") ? (
<img className="h-7 w-7" src={item.icon} alt={item.title} />
) : (
<></>
)}
</div>
)}
<div className="text-xl font-bold md:text-2xl">{item.title}</div>
</div>
<div className="text-muted-foreground mt-2 font-normal">{item.description}</div>
{/* <div className="mt-6 flex items-center space-x-2 justify-between">
<div className="flex items-center space-x-2">
<div className="text-sm">
{item.articles.length} <span>{item.articles.length === 1 ? "article" : "articles"}</span>
</div>
</div>
<div className="text-sm text-muted-foreground">{item.version}</div>
</div> */}
</div>
</div>
</div>
<div className="border-border border-b"></div>
<div className="grid grid-cols-12 gap-8">
<div className="col-span-3 hidden space-y-3 md:block">
<div className="text font-bold">{t("knowledgeBase.category.plural")}</div>
<div className="space-y-1">
{allCategories.map((category) => {
return (
<Link to={category.href} key={category.title}>
<div
className={clsx(
"group rounded-md border border-transparent p-2",
category.slug === item.slug ? "bg-secondary font-bold" : "hover:bg-secondary"
)}
>
<div className="text-sm">{category.title}</div>
</div>
</Link>
);
})}
</div>
</div>
<div className="col-span-12 pt-2 md:col-span-9">
<KbArticlesBySection kb={kb} item={item} />
</div>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const repoRoot = process.cwd();
const sourceFiles = [
"apps/web/app/modules/knowledgeBase/components/KbHeader.tsx",
"apps/web/app/modules/knowledgeBase/components/KbFeaturedArticles.tsx",
"apps/web/app/modules/knowledgeBase/components/articles/KbArticlesBySection.tsx",
"apps/web/app/modules/knowledgeBase/components/articles/KbArticleContent.tsx",
"apps/web/app/modules/knowledgeBase/components/categories/KbCategoriesGrid.tsx",
"apps/web/app/modules/knowledgeBase/components/categories/KbCategoriesList.tsx",
"apps/web/app/modules/knowledgeBase/components/layouts/KbDocsHeader.tsx",
];
const publicRouteFiles = [
"apps/web/app/modules/knowledgeBase/routes/api/KbRoutes.Article.Api.tsx",
"apps/web/app/modules/knowledgeBase/routes/views/KbRoutes.Article.View.tsx",
"apps/web/app/modules/knowledgeBase/routes/views/KbRoutes.Category.View.tsx",
];
describe("knowledge base public i18n", () => {
it("keeps public article empty states and dates locale-aware", () => {
const sources = sourceFiles.map((file) => fs.readFileSync(path.join(repoRoot, file), "utf8")).join("\n");
expect(sources).toContain("knowledgeBase.article.emptyFeatured");
expect(sources).toContain("knowledgeBase.article.empty");
expect(sources).toContain("knowledgeBase.header.logoAlt");
expect(sources).toContain("useLocaleFormatters");
expect(sources).not.toContain('alt="Logo"');
expect(sources).not.toContain('"No featured articles"');
expect(sources).not.toContain('"No articles"');
expect(sources).not.toContain("DateUtils.dateAgo");
expect(sources).not.toContain("ui.autogenerated");
});
it("keeps public category article counts locale-formatted", () => {
const sources = [
"apps/web/app/modules/knowledgeBase/components/categories/KbCategoriesGrid.tsx",
"apps/web/app/modules/knowledgeBase/components/categories/KbCategoriesList.tsx",
]
.map((file) => fs.readFileSync(path.join(repoRoot, file), "utf8"))
.join("\n");
const en = JSON.parse(fs.readFileSync(path.join(repoRoot, "apps/web/public/locales/en/translations.json"), "utf8"));
const de = JSON.parse(fs.readFileSync(path.join(repoRoot, "apps/web/public/locales/de/translations.json"), "utf8"));
expect(sources).toContain("useLocaleFormatters");
expect(sources).toContain("formattedCount: formatters.number(item.articles.length)");
expect(sources).not.toContain("{ count: item.articles.length })");
expect(en.knowledgeBase.category.articleCount_one).toContain("{{formattedCount}}");
expect(en.knowledgeBase.category.articleCount_other).toContain("{{formattedCount}}");
expect(de.knowledgeBase.category.articleCount_one).toContain("{{formattedCount}}");
expect(de.knowledgeBase.category.articleCount_other).toContain("{{formattedCount}}");
});
it("keeps public route errors on public translation keys", () => {
const sources = publicRouteFiles.map((file) => fs.readFileSync(path.join(repoRoot, file), "utf8")).join("\n");
const en = JSON.parse(fs.readFileSync(path.join(repoRoot, "apps/web/public/locales/en/translations.json"), "utf8"));
const de = JSON.parse(fs.readFileSync(path.join(repoRoot, "apps/web/public/locales/de/translations.json"), "utf8"));
expect(sources).toContain("knowledgeBase.public.errors.articleNotFound");
expect(sources).toContain("knowledgeBase.public.errors.categoryNotFound");
expect(sources).toContain("knowledgeBase.public.errors.invalidAction");
expect(sources).not.toContain("knowledgeBase.admin.errors.notFound");
expect(sources).not.toContain("knowledgeBase.admin.errors.invalidAction");
expect(en.knowledgeBase.public.errors.articleNotFound).toBeTruthy();
expect(en.knowledgeBase.public.errors.categoryNotFound).toBeTruthy();
expect(en.knowledgeBase.public.errors.invalidAction).toBeTruthy();
expect(de.knowledgeBase.public.errors.articleNotFound).toBeTruthy();
expect(de.knowledgeBase.public.errors.categoryNotFound).toBeTruthy();
expect(de.knowledgeBase.public.errors.invalidAction).toBeTruthy();
});
});
@@ -0,0 +1,103 @@
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import ColorBackgroundUtils from "~/utils/shared/colors/ColorBackgroundUtils";
import ColorTextUtils from "~/utils/shared/colors/ColorTextUtils";
import ColorFocusUtils from "~/utils/shared/colors/ColorFocusUtils";
import ColorRingUtils from "~/utils/shared/colors/ColorRingUtils";
import ColorGradientUtils from "~/utils/shared/colors/ColorGradientUtils";
import { logoLight, logoDark } from "~/assets/brand/default";
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
interface Props {
kb: KnowledgeBaseDto;
}
const withTitleAndDescription = true;
export default function KbDocsHeader({ kb }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const params = useParams();
const [searchParams] = useSearchParams();
const logoAlt = t("knowledgeBase.header.logoAlt", { title: kb.title });
return (
<div>
<div className={clsx("bg-linear-to-r text-white", ColorGradientUtils.getFrom800To900(kb.color))}>
<div className="mx-auto max-w-(--breakpoint-2xl) space-y-8 px-8 py-4">
<div className="flex items-center justify-between space-x-2">
<Link to={KnowledgeBaseUtils.getKbUrl({ kb, params })} className="flex select-none items-center space-x-2">
{kb.logo === "light" ? (
<img className="h-8 w-auto" src={logoLight} alt={logoAlt} />
) : kb.logo === "dark" ? (
<img className="h-8 w-auto" src={logoDark} alt={logoAlt} />
) : (
<img className="h-8 w-auto" src={kb.logo} alt={logoAlt} />
)}
{!withTitleAndDescription && (
<Fragment>
<span className={clsx("hidden px-2 sm:block", ColorTextUtils.getText300(kb.color))}>|</span>
<span className="hidden font-semibold sm:block">{kb.title}</span>
</Fragment>
)}
</Link>
<div className="flex items-center space-x-4">
{kb.links.map((link) => {
return (
<a key={link.href} href={link.href} className={clsx("transition-colors duration-150 hover:text-white", ColorTextUtils.getText300(kb.color))}>
{link.name}
</a>
);
})}
<div className="flex items-center space-x-2">
{kb.languages.length > 1 && (
<select
id="lang"
name="lang"
defaultValue={params.lang || kb.defaultLanguage}
className={clsx(
"block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-white ring-1 ring-inset focus:ring-2 sm:text-sm sm:leading-6",
ColorBackgroundUtils.getBg700(kb.color),
ColorRingUtils.getRing800(kb.color),
ColorFocusUtils.getRing600(kb.color)
)}
onChange={(e) => {
navigate(
`${KnowledgeBaseUtils.getKbUrl({
kb,
params: {
lang: e.target.value,
},
})}?${searchParams.toString()}`
);
}}
>
{kb.languages.map((lang) => {
const language = KnowledgeBaseUtils.supportedLanguages.find((f) => f.value === lang);
if (!language) return null;
return (
<Fragment key={lang}>
<option key={lang} value={lang}>
{KnowledgeBaseUtils.getLanguageName(language.value, t)}
</option>
</Fragment>
);
})}
</select>
)}
</div>
</div>
</div>
{/* {withTitleAndDescription && (
<div className="space-y-4">
<h1 className="text-2xl font-semibold tracking-tight sm:text-4xl">{kb.title}</h1>
<p className={clsx("text-base", ColorTextUtils.getText300(kb.color))}>{kb.description}</p>
</div>
)} */}
{/* {withSearch && <KbSearch kb={kb} autoFocus={withTitleAndDescription} />} */}
</div>
</div>
</div>
);
}
@@ -0,0 +1,174 @@
import { useLocation, useSubmit } from "react-router";
import { useTranslation } from "react-i18next";
import { KnowledgeBaseDto } from "../../dtos/KnowledgeBaseDto";
import { KbCategoryDto } from "../../dtos/KbCategoryDto";
import clsx from "clsx";
import { MobileNav } from "~/components/ui/docs/DocsMobileNav";
import { Fragment } from "react";
import RightIcon from "~/components/ui/icons/RightIcon";
import { ScrollArea } from "~/components/ui/scroll-area";
import { SideBarItem } from "~/application/sidebar/SidebarItem";
import { KbArticleDto } from "../../dtos/KbArticleDto";
import KbArticle from "../articles/KbArticle";
import KnowledgeBaseUtils from "../../utils/KnowledgeBaseUtils";
import KbDocsHeader from "./KbDocsHeader";
import KbFeaturedArticles from "../KbFeaturedArticles";
import KbCategories from "../categories/KbCategories";
import { DocsSidebarNav } from "~/components/ui/docs/DocsSidebarNav";
export default function KbDocsLayout({
kb,
categories,
featured,
article,
}: {
kb: KnowledgeBaseDto;
categories: KbCategoryDto[];
featured?: KbArticleDto[];
article?: {
article: KbArticleDto;
category: KbCategoryDto;
userState: {
hasThumbsUp: boolean;
hasThumbsDown: boolean;
};
} | null;
}) {
// const outlet = useOutlet();
const location = useLocation();
const { t } = useTranslation();
const items: SideBarItem[] = [];
categories.forEach((category) => {
const sections = KnowledgeBaseUtils.getCategoryArticlesBySections({ kb, category });
items.push({
title: category.title,
path: category.href,
items: [{ title: t("shared.overview"), path: category.href }],
});
let categoryItem = items[items.length - 1];
sections.forEach((section) => {
if (section.articles.length === 0) return;
if (section.section) {
items.push({
title: section.section.title,
path: "",
items: [],
});
categoryItem = items[items.length - 1];
}
section.articles.forEach((article) => {
categoryItem.items?.push({
title: article.title,
path: article.href,
});
});
});
});
const currentItem = () => {
const findRecursive = (items: SideBarItem[]): SideBarItem | undefined => {
for (const item of items) {
if (item.path && item.title && item.path === location.pathname) return item;
if (item.items) {
const found = findRecursive(item.items);
if (found) return found;
}
}
};
return findRecursive(items);
};
return (
<div>
<KbDocsHeader kb={kb} />
<div className="mx-auto max-w-(--breakpoint-2xl) flex-1 items-start px-4 pt-3 sm:px-6 md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10">
<nav className={clsx("not-prose border-border mb-2 flex items-center truncate border-b pb-2 md:hidden")}>
<ol className="flex items-center space-x-2">
<MobileNav items={items} />
{currentItem() && (
<Fragment>
<RightIcon className="text-muted-foreground h-4 w-4 shrink-0" />
<div>
<h3 className="text-muted-foreground text-base">{currentItem()?.title}</h3>
</div>
</Fragment>
)}
</ol>
</nav>
<aside className="fixed top-14 z-30 -ml-2 hidden h-[calc(100vh-90px)] w-full shrink-0 md:sticky md:block">
<ScrollArea className="h-full pb-6 pr-6 lg:pb-8">
<DocsSidebarNav items={items} />
</ScrollArea>
</aside>
{article ? (
<Article kb={kb} article={article} />
) : (
<div>
<div className="mx-auto max-w-5xl px-2 py-6 sm:px-8">
<div className="space-y-8">
{featured && featured.length > 0 && <KbFeaturedArticles kb={kb} items={featured} />}
<KbCategories kb={kb} items={categories} />
</div>
</div>
</div>
)}
</div>
</div>
);
}
function Article({
kb,
article,
}: {
kb: KnowledgeBaseDto;
article: {
article: KbArticleDto;
category: KbCategoryDto;
userState: {
hasThumbsUp: boolean;
hasThumbsDown: boolean;
};
};
}) {
const submit = useSubmit();
function onAction(name: string) {
const form = new FormData();
form.set("action", name);
submit(form, {
method: "post",
});
}
return (
<div className="px-2 py-2 sm:px-8 sm:py-8">
<div className="space-y-3">
<KbArticle
kb={kb}
category={article.category}
item={article.article}
userState={{
hasThumbsUp: article.userState.hasThumbsUp,
hasThumbsDown: article.userState.hasThumbsDown,
}}
actions={{
onThumbsUp: () => onAction("thumbsUp"),
onThumbsDown: () => onAction("thumbsDown"),
}}
withMenu={false}
/>
</div>
</div>
);
}
// function getArticles({ kb, category, sectionId }: { kb: KnowledgeBaseDto; category: KbCategoryDto; sectionId: string | null }) {
// return category.articles
// .filter((f) => f.publishedAt !== null && (sectionId ? f.sectionId === sectionId : true))
// .sort((a, b) => a.order - b.order)
// .map((article) => ({
// title: article.title,
// path: KnowledgeBaseUtils.getArticleUrl({ kb, article, params: {} }),
// }));
// }
@@ -0,0 +1,71 @@
import TableSimple from "~/components/ui/tables/TableSimple";
import { KnowledgeBasesTemplateDto } from "../../dtos/KnowledgeBasesTemplateDto";
import { t as i18nT } from "i18next";
export default function PreviewKbsTemplate({ template }: { template: KnowledgeBasesTemplateDto }) {
return (
<div className="space-y-2">
<h3 className="font-medium">{i18nT("knowledgeBase.templates.preview.headings.knowledgeBases")}</h3>
<TableSimple
items={template.knowledgeBases}
headers={[
{ name: "slug", title: i18nT("knowledgeBase.templates.preview.columns.slug"), value: (i) => i.slug },
{
name: "title",
title: i18nT("knowledgeBase.templates.preview.columns.title"),
value: (i) => (
<div className="flex-col">
<div>{i.title}</div>
<div className="text-muted-foreground text-xs">{i.description}</div>
</div>
),
},
{ name: "enabled", title: i18nT("knowledgeBase.templates.preview.columns.enabled"), value: (i) => i.enabled },
]}
/>
<h3 className="font-medium">{i18nT("knowledgeBase.templates.preview.headings.categories")}</h3>
<TableSimple
items={template.categories}
headers={[
{ name: "slug", title: i18nT("knowledgeBase.templates.preview.columns.slug"), value: (i) => i.slug },
{
name: "title",
title: i18nT("knowledgeBase.templates.preview.columns.title"),
value: (i) => (
<div className="flex-col">
<div>{i.title}</div>
<div className="text-muted-foreground text-xs">{i.description}</div>
</div>
),
},
{
name: "sections",
title: i18nT("knowledgeBase.templates.preview.columns.sections"),
value: (i) => i.sections.map((s) => s.title).join(", "),
},
{ name: "knowledgeBase", title: i18nT("knowledgeBase.templates.preview.columns.knowledgeBase"), value: (i) => i.knowledgeBaseSlug },
]}
/>
<h3 className="font-medium">{i18nT("knowledgeBase.templates.preview.headings.articles")}</h3>
<TableSimple
items={template.articles}
headers={[
{ name: "slug", title: i18nT("knowledgeBase.templates.preview.columns.slug"), value: (i) => i.slug },
{
name: "title",
title: i18nT("knowledgeBase.templates.preview.columns.title"),
value: (i) => (
<div className="flex-col">
<div>{i.title}</div>
<div className="text-muted-foreground text-xs">{i.description}</div>
</div>
),
},
{ name: "knowledgeBase", title: i18nT("knowledgeBase.templates.preview.columns.knowledgeBase"), value: (i) => i.knowledgeBaseSlug },
]}
/>
</div>
);
}