413 lines
16 KiB
TypeScript
413 lines
16 KiB
TypeScript
import { ActionFunctionArgs, LoaderFunctionArgs, redirect, useActionData, useLoaderData } from "react-router";
|
|
import { Link, useNavigate, useOutlet, useSubmit } from "react-router";
|
|
import { useTranslation } from "react-i18next";
|
|
import { FilterablePropertyDto } from "~/application/dtos/data/FilterablePropertyDto";
|
|
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
|
import { Colors } from "~/application/enums/shared/Colors";
|
|
import ColorBadge from "~/components/ui/badges/ColorBadge";
|
|
import DateCell from "~/components/ui/dates/DateCell";
|
|
import PlusIcon from "~/components/ui/icons/PlusIcon";
|
|
import InputCheckbox from "~/components/ui/input/InputCheckbox";
|
|
import InputFilters from "~/components/ui/input/InputFilters";
|
|
import EditPageLayout from "~/components/ui/layouts/EditPageLayout";
|
|
import ActionResultModal from "~/components/ui/modals/ActionResultModal";
|
|
import SlideOverWideEmpty from "~/components/ui/slideOvers/SlideOverWideEmpty";
|
|
import TableSimple from "~/components/ui/tables/TableSimple";
|
|
import { useLocaleFormatters } from "~/locale/format";
|
|
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
|
|
import { getTranslations } from "~/locale/i18next.server";
|
|
import {
|
|
KnowledgeBaseArticleWithDetails,
|
|
getAllKnowledgeBaseArticlesWithPagination,
|
|
getKbArticleById,
|
|
updateKnowledgeBaseArticle,
|
|
} from "~/modules/knowledgeBase/db/kbArticles.db.server";
|
|
import { getAllKnowledgeBaseCategories, updateKnowledgeBaseCategory } from "~/modules/knowledgeBase/db/kbCategories.db.server";
|
|
import { updateKnowledgeBaseCategorySection } from "~/modules/knowledgeBase/db/kbCategorySections.db.server";
|
|
import { KnowledgeBaseDto } from "~/modules/knowledgeBase/dtos/KnowledgeBaseDto";
|
|
import KnowledgeBaseService from "~/modules/knowledgeBase/service/KnowledgeBaseService.server";
|
|
import KnowledgeBaseUtils from "~/modules/knowledgeBase/utils/KnowledgeBaseUtils";
|
|
import { verifyUserHasPermission } from "~/utils/helpers/.server/PermissionsService";
|
|
import { getFiltersFromCurrentUrl, getPaginationFromCurrentUrl } from "~/utils/helpers/RowPaginationHelper";
|
|
import { getUserInfo, validateCSRFToken } from "~/utils/session.server";
|
|
import Dropdown from "~/components/ui/dropdowns/Dropdown";
|
|
import { Menu } from "@headlessui/react";
|
|
import clsx from "clsx";
|
|
import { appendCSRFToken } from "~/utils/csrf";
|
|
import type { TFunction } from "i18next";
|
|
|
|
type LoaderData = {
|
|
knowledgeBases: KnowledgeBaseDto[];
|
|
items: KnowledgeBaseArticleWithDetails[];
|
|
pagination: PaginationDto;
|
|
filterableProperties: FilterablePropertyDto[];
|
|
};
|
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|
await verifyUserHasPermission(request, "admin.kb.view");
|
|
const { t } = await getTranslations(request);
|
|
const urlSearchParams = new URL(request.url).searchParams;
|
|
const currentPagination = getPaginationFromCurrentUrl(urlSearchParams);
|
|
const filterableProperties: FilterablePropertyDto[] = [
|
|
{
|
|
name: "title",
|
|
title: t("knowledgeBase.adminArticles.fields.title"),
|
|
},
|
|
{
|
|
name: "description",
|
|
title: t("knowledgeBase.adminArticles.fields.description"),
|
|
},
|
|
{
|
|
name: "categoryId",
|
|
title: t("knowledgeBase.adminArticles.fields.category"),
|
|
options: [
|
|
{ value: "null", name: t("knowledgeBase.adminArticles.filters.noCategoryValue") },
|
|
...(await getAllKnowledgeBaseCategories({ knowledgeBaseSlug: undefined, language: undefined })).map((item) => {
|
|
return {
|
|
value: item.id,
|
|
name: item.title,
|
|
};
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
name: "content",
|
|
title: t("knowledgeBase.adminArticles.fields.content"),
|
|
},
|
|
];
|
|
const filters = getFiltersFromCurrentUrl(request, filterableProperties);
|
|
const filtered = {
|
|
title: filters.properties.find((f) => f.name === "title")?.value ?? filters.query ?? undefined,
|
|
description: filters.properties.find((f) => f.name === "description")?.value ?? filters.query ?? undefined,
|
|
categoryId: filters.properties.find((f) => f.name === "categoryId")?.value ?? undefined,
|
|
content: filters.properties.find((f) => f.name === "content")?.value ?? filters.query ?? undefined,
|
|
};
|
|
const { items, pagination } = await getAllKnowledgeBaseArticlesWithPagination({
|
|
knowledgeBaseSlug: undefined,
|
|
language: undefined,
|
|
pagination: currentPagination,
|
|
filters: {
|
|
title: filtered.title,
|
|
description: filtered.description,
|
|
categoryId: filtered.categoryId === "null" ? null : filtered.categoryId,
|
|
content: filtered.content,
|
|
},
|
|
});
|
|
const data: LoaderData = {
|
|
knowledgeBases: await KnowledgeBaseService.getAll({ request }),
|
|
items,
|
|
pagination,
|
|
filterableProperties,
|
|
};
|
|
return data;
|
|
};
|
|
|
|
type ActionData = {
|
|
error?: string;
|
|
};
|
|
|
|
function formatLanguageName(language: string, locale: string, t: TFunction) {
|
|
try {
|
|
return (new Intl.DisplayNames([locale], { type: "language" }).of(language) ?? KnowledgeBaseUtils.getLanguageName(language, t)) || language;
|
|
} catch {
|
|
return KnowledgeBaseUtils.getLanguageName(language, t) || language;
|
|
}
|
|
}
|
|
|
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
|
await verifyUserHasPermission(request, "admin.kb.update");
|
|
const userInfo = await getUserInfo(request);
|
|
await validateCSRFToken(request);
|
|
const { t } = await getTranslations(request);
|
|
const form = await request.formData();
|
|
const action = form.get("action")?.toString() ?? "";
|
|
|
|
if (action === "new") {
|
|
await verifyUserHasPermission(request, "admin.kb.create");
|
|
const kbId = await form.get("kbId")?.toString();
|
|
const kb = await KnowledgeBaseService.getById({ id: kbId!, request });
|
|
if (!kb) {
|
|
return Response.json({ error: t("knowledgeBase.admin.errors.knowledgeBaseNotFound") }, { status: 404 });
|
|
}
|
|
const created = await KnowledgeBaseService.newArticle({
|
|
kb,
|
|
params: {
|
|
lang: kb.languages.length > 0 ? kb.languages[0] : "en",
|
|
},
|
|
userId: userInfo.userId,
|
|
position: "last",
|
|
});
|
|
return redirect(adminPath(`knowledge-base/bases/${kb.slug}/articles/${KnowledgeBaseUtils.defaultLanguage}/${created.id}/edit`, { mode: "host-aware" }));
|
|
} else if (action === "set-orders") {
|
|
const items: { id: string; order: number }[] = form.getAll("orders[]").map((f: FormDataEntryValue) => {
|
|
return JSON.parse(f.toString());
|
|
});
|
|
|
|
await Promise.all(
|
|
items.map(async ({ id, order }) => {
|
|
await updateKnowledgeBaseCategory(id, {
|
|
order: Number(order),
|
|
});
|
|
})
|
|
);
|
|
return Response.json({ updated: true });
|
|
} else if (action === "set-section-orders") {
|
|
const items: { id: string; order: number }[] = form.getAll("orders[]").map((f: FormDataEntryValue) => {
|
|
return JSON.parse(f.toString());
|
|
});
|
|
|
|
await Promise.all(
|
|
items.map(async ({ id, order }) => {
|
|
await updateKnowledgeBaseCategorySection(id, {
|
|
order: Number(order),
|
|
});
|
|
})
|
|
);
|
|
return Response.json({ updated: true });
|
|
}
|
|
if (action === "toggle") {
|
|
const id = form.get("id")?.toString() ?? "";
|
|
const isFeatured = form.get("isFeatured")?.toString() === "true";
|
|
|
|
const item = await getKbArticleById(id);
|
|
if (!item) {
|
|
return Response.json({ error: t("knowledgeBase.admin.errors.notFound") }, { status: 404 });
|
|
}
|
|
const kb = await KnowledgeBaseService.getById({ id: item.knowledgeBaseId, request });
|
|
if (!kb) {
|
|
return Response.json({ error: t("knowledgeBase.admin.errors.knowledgeBaseNotFound") }, { status: 404 });
|
|
}
|
|
|
|
let featuredOrder = item.featuredOrder;
|
|
if (isFeatured) {
|
|
if (!item.featuredOrder) {
|
|
const featuredArticles = await KnowledgeBaseService.getFeaturedArticles({
|
|
kb,
|
|
params: {},
|
|
request,
|
|
});
|
|
let maxOrder = 0;
|
|
if (featuredArticles.length > 0) {
|
|
maxOrder = Math.max(...featuredArticles.map((p) => p.featuredOrder ?? 0));
|
|
}
|
|
featuredOrder = maxOrder + 1;
|
|
}
|
|
} else {
|
|
featuredOrder = null;
|
|
}
|
|
await updateKnowledgeBaseArticle(item.id, {
|
|
featuredOrder,
|
|
});
|
|
|
|
return Response.json({ success: t("knowledgeBase.admin.success.updated") });
|
|
}
|
|
return Response.json({ error: t("knowledgeBase.admin.errors.invalidAction") }, { status: 400 });
|
|
};
|
|
|
|
export default function () {
|
|
const { t } = useTranslation();
|
|
const formatters = useLocaleFormatters();
|
|
const data = useLoaderData<LoaderData>();
|
|
const actionData = useActionData<ActionData>();
|
|
const outlet = useOutlet();
|
|
const navigate = useNavigate();
|
|
const submit = useSubmit();
|
|
|
|
function onToggle(item: KnowledgeBaseArticleWithDetails, isFeatured: boolean) {
|
|
const form = new FormData();
|
|
appendCSRFToken(form);
|
|
form.set("action", "toggle");
|
|
form.set("isFeatured", isFeatured ? "true" : "false");
|
|
form.set("id", item.id.toString());
|
|
submit(form, {
|
|
method: "post",
|
|
});
|
|
}
|
|
return (
|
|
<EditPageLayout
|
|
title={t("knowledgeBase.adminArticles.titleWithCount", { count: data.pagination.totalItems, formattedCount: formatters.number(data.pagination.totalItems) })}
|
|
withHome={false}
|
|
menu={[
|
|
{ title: t("knowledgeBase.adminArticles.breadcrumbs.knowledgeBases"), routePath: adminPath("knowledge-base/bases", { mode: "host-aware" }) },
|
|
{ title: t("knowledgeBase.article.plural") },
|
|
]}
|
|
buttons={
|
|
<>
|
|
<InputFilters filters={data.filterableProperties} />
|
|
<Dropdown
|
|
right={false}
|
|
// onClick={() => alert("Dropdown click")}
|
|
button={
|
|
<div className="flex items-center space-x-2">
|
|
<div>{t("knowledgeBase.article.new")}</div>
|
|
<PlusIcon className="h-5 w-5" />
|
|
</div>
|
|
}
|
|
btnClassName={clsx(
|
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
|
"h-9 px-4 py-2"
|
|
)}
|
|
disabled={data.knowledgeBases.length === 0}
|
|
options={
|
|
<div className="h-64 overflow-auto">
|
|
{data.knowledgeBases.map((kb) => {
|
|
return (
|
|
<Menu.Item key={kb.id}>
|
|
{({ active }) => (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const form = new FormData();
|
|
appendCSRFToken(form);
|
|
form.set("action", "new");
|
|
form.set("kbId", kb.id.toString());
|
|
submit(form, {
|
|
method: "post",
|
|
});
|
|
}}
|
|
className={clsx("w-full text-left", active ? "text-foreground bg-secondary/90" : "text-foreground/80", "block px-4 py-2 text-sm")}
|
|
>
|
|
{kb.title}
|
|
</button>
|
|
)}
|
|
</Menu.Item>
|
|
);
|
|
})}
|
|
</div>
|
|
}
|
|
></Dropdown>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-2">
|
|
<TableSimple
|
|
items={data.items}
|
|
pagination={data.pagination}
|
|
actions={[
|
|
{
|
|
title: t("knowledgeBase.adminArticles.actions.settings"),
|
|
onClickRoute: (_, item) =>
|
|
adminPath(`knowledge-base/bases/${item.knowledgeBase.slug}/articles/${item.language}/${item.id}/settings`, { mode: "host-aware" }),
|
|
},
|
|
{
|
|
title: t("knowledgeBase.adminArticles.actions.edit"),
|
|
onClickRoute: (_, item) =>
|
|
adminPath(`knowledge-base/bases/${item.knowledgeBase.slug}/articles/${item.language}/${item.id}/edit`, { mode: "host-aware" }),
|
|
},
|
|
]}
|
|
headers={[
|
|
{
|
|
title: t("knowledgeBase.title"),
|
|
value: (i) => i.knowledgeBase.title,
|
|
},
|
|
|
|
{
|
|
name: "title",
|
|
title: t("knowledgeBase.adminArticles.fields.title"),
|
|
className: "w-full",
|
|
value: (i) => (
|
|
<div className="space-y-1">
|
|
<Link
|
|
to={adminPath(`knowledge-base/bases/${i.knowledgeBase.slug}/articles/${i.language}/${i.id}`, { mode: "host-aware" })}
|
|
className="flex items-center space-x-2 font-medium hover:underline"
|
|
>
|
|
<div>{!i.publishedAt ? <ColorBadge size="sm" color={Colors.GRAY} /> : <ColorBadge size="sm" color={Colors.TEAL} />}</div>
|
|
<div>{i.title}</div>
|
|
</Link>
|
|
{/* <div className="text-muted-foreground text-sm">{i.description}</div> */}
|
|
{/* <div className="text-sm text-muted-foreground">{i.slug}</div>
|
|
<div className="text-muted-foreground text-sm">{i.description}</div> */}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
name: "category",
|
|
title: t("knowledgeBase.adminArticles.fields.category"),
|
|
value: (i) => (
|
|
<div>
|
|
{i.category ? (
|
|
<div className="flex flex-col">
|
|
<div>{i.category.title}</div>
|
|
|
|
{i.section && <div className="text-muted-foreground text-xs">{i.section.title}</div>}
|
|
</div>
|
|
) : (
|
|
<Link to={`${i.id}/settings`} className="text-muted-foreground text-xs italic hover:underline">
|
|
{t("knowledgeBase.adminArticles.empty.noCategory")}
|
|
</Link>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: t("shared.language"),
|
|
value: (i) => formatLanguageName(i.language, formatters.locale, t),
|
|
},
|
|
{
|
|
name: "characters",
|
|
title: t("knowledgeBase.adminArticles.metrics.characters"),
|
|
value: (i) => formatters.number(i.contentPublishedAsText.length, { maximumFractionDigits: 0 }),
|
|
},
|
|
{
|
|
name: "views",
|
|
title: t("knowledgeBase.adminArticles.metrics.views"),
|
|
value: (i) => formatters.number(i._count.views, { maximumFractionDigits: 0 }),
|
|
},
|
|
{
|
|
name: "upvotes",
|
|
title: t("knowledgeBase.adminArticles.metrics.upvotes"),
|
|
value: (i) => formatters.number(i._count.upvotes, { maximumFractionDigits: 0 }),
|
|
},
|
|
{
|
|
name: "downvotes",
|
|
title: t("knowledgeBase.adminArticles.metrics.downvotes"),
|
|
value: (i) => formatters.number(i._count.downvotes, { maximumFractionDigits: 0 }),
|
|
},
|
|
{
|
|
name: "featured",
|
|
title: t("knowledgeBase.adminArticles.fields.featured"),
|
|
value: (i) => {
|
|
return <InputCheckbox asToggle value={i.featuredOrder ? true : false} setValue={(checked) => onToggle(i, Boolean(checked))} />;
|
|
},
|
|
},
|
|
{
|
|
name: "createdBy",
|
|
title: t("shared.createdBy"),
|
|
value: (i) => (
|
|
<div className="flex flex-col">
|
|
<DateCell date={i.createdAt} displays={["ymd"]} />
|
|
<div>
|
|
{i.createdByUser ? (
|
|
<div>
|
|
{i.createdByUser.firstName} {i.createdByUser.lastName}
|
|
</div>
|
|
) : (
|
|
<div className="text-muted-foreground text-xs italic hover:underline">{t("knowledgeBase.adminArticles.empty.noAuthor")}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
<ActionResultModal actionData={actionData} showSuccess={false} />
|
|
|
|
<SlideOverWideEmpty
|
|
title={t("knowledgeBase.adminArticles.settingsTitle")}
|
|
open={!!outlet}
|
|
onClose={() => {
|
|
navigate(".", { replace: true });
|
|
}}
|
|
className="sm:max-w-sm"
|
|
overflowYScroll={true}
|
|
>
|
|
<div className="-mx-1 -mt-3">
|
|
<div className="space-y-4">{outlet}</div>
|
|
</div>
|
|
</SlideOverWideEmpty>
|
|
</EditPageLayout>
|
|
);
|
|
}
|