Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/web/src/components/HelpCenter/HelpCenterButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"use client";

import type { HelpTarget } from "./types";

import { useState } from "react";
import { useTranslation } from "react-i18next";
import { AiOutlineQuestionCircle } from "react-icons/ai";

import HelpCenterModal from "./HelpCenterModal";

import { Button, Tooltip } from "@/components/bakaui";

export interface HelpCenterButtonProps extends HelpTarget {
/** When set, renders a labelled button instead of the icon-only "?" button. */
label?: string;
size?: "sm" | "md";
className?: string;
}

/**
* The "?" entry point. Drop it next to any UI that involves a help center
* topic; it opens the help center at the given topic/section.
*/
const HelpCenterButton = ({
topic,
section,
concept,
label,
size = "sm",
className,
}: HelpCenterButtonProps) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);

const button = label ? (
<Button
className={className}
size={size}
startContent={<AiOutlineQuestionCircle className="text-base" />}
variant="light"
onPress={() => setVisible(true)}
>
{label}
</Button>
) : (
<Tooltip content={t("helpCenter.button.tooltip")}>
<Button
isIconOnly
aria-label={t("helpCenter.button.tooltip")}
className={className}
size={size}
variant="light"
onPress={() => setVisible(true)}
>
<AiOutlineQuestionCircle className="text-lg text-default-500" />
</Button>
</Tooltip>
);

return (
<>
{button}
{visible && (
<HelpCenterModal
concept={concept}
section={section}
topic={topic}
visible={visible}
onClose={() => setVisible(false)}
/>
)}
</>
);
};

HelpCenterButton.displayName = "HelpCenterButton";

export default HelpCenterButton;
144 changes: 144 additions & 0 deletions src/web/src/components/HelpCenter/HelpCenterModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"use client";

import type { HelpTarget, HelpTopicId } from "./types";

import { useState } from "react";
import { useTranslation } from "react-i18next";
import { AiOutlineQuestionCircle } from "react-icons/ai";

import { helpTopics } from "./topics";

import { Button, Modal } from "@/components/bakaui";

export interface HelpCenterModalProps extends HelpTarget {
visible: boolean;
onClose: () => void;
/** First-run mode: opened automatically for new users, closes via a single primary action. */
firstRun?: boolean;
}

interface ActiveEntry {
topicId: HelpTopicId;
/** Undefined = the topic's overview entry is selected. */
conceptId?: string;
}

/**
* The help center host: a left navigation of topics and their concepts, and a
* right pane with the selected entry's content. Content is rendered by topic
* components so the same content can later be hosted by a standalone page.
*/
const HelpCenterModal = ({
visible,
onClose,
topic,
section,
concept,
firstRun,
}: HelpCenterModalProps) => {
const { t } = useTranslation();
const initialTopicId = topic ?? helpTopics[0]!.id;
const [active, setActive] = useState<ActiveEntry>({
topicId: initialTopicId,
conceptId: concept,
});

const activeTopic = helpTopics.find((item) => item.id === active.topicId) ?? helpTopics[0]!;
const { Content, ConceptContent } = activeTopic;

return (
<Modal
footer={
firstRun ? (
<div className="flex justify-end w-full">
<Button color="primary" onPress={onClose}>
{t("helpCenter.action.getStarted")}
</Button>
</div>
) : (
false
)
}
isDismissable={!firstRun}
size="6xl"
title={
<div className="flex items-center gap-2">
<AiOutlineQuestionCircle className="text-lg" />
<span>{t("helpCenter.title")}</span>
</div>
}
visible={visible}
onClose={onClose}
>
<div className="flex gap-3 min-h-0">
{/* Left navigation: one overview entry per topic + its concept entries */}
<div className="flex flex-col gap-0.5 w-44 shrink-0 max-h-[72vh] overflow-y-auto pr-1">
{helpTopics.map((topicDef) => {
const isOverviewActive =
topicDef.id === active.topicId && active.conceptId == undefined;

return (
<div key={topicDef.id} className="flex flex-col gap-0.5">
<Button
className="justify-start"
color={isOverviewActive ? "primary" : "default"}
size="sm"
startContent={topicDef.icon}
variant={isOverviewActive ? "flat" : "light"}
onPress={() => setActive({ topicId: topicDef.id })}
>
{t(topicDef.titleKey)}
</Button>

{topicDef.concepts && topicDef.concepts.length > 0 && (
<>
{topicDef.conceptGroupLabelKey && (
<div className="px-2 pt-2 pb-0.5 text-xs text-default-400">
{t(topicDef.conceptGroupLabelKey)}
</div>
)}
{topicDef.concepts.map((item) => {
const isActive =
topicDef.id === active.topicId && active.conceptId === item.id;

return (
<Button
key={item.id}
className={`justify-start pl-5 h-7 min-h-7 ${
isActive ? "" : "text-default-600"
}`}
color={isActive ? "primary" : "default"}
size="sm"
variant={isActive ? "flat" : "light"}
onPress={() => setActive({ topicId: topicDef.id, conceptId: item.id })}
>
{t(item.labelKey)}
</Button>
);
})}
</>
)}
</div>
);
})}
</div>

{/* Right pane */}
<div className="flex-1 min-w-0 max-h-[72vh] overflow-y-auto overflow-x-hidden pr-1 pb-2 border-l border-default-100 pl-3">
{active.conceptId != undefined && ConceptContent ? (
<ConceptContent conceptId={active.conceptId} />
) : (
<Content
firstRun={firstRun}
section={active.topicId === initialTopicId ? section : undefined}
/>
)}
</div>
</div>
</Modal>
);
};

HelpCenterModal.displayName = "HelpCenterModal";

export default HelpCenterModal;
4 changes: 4 additions & 0 deletions src/web/src/components/HelpCenter/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { default as HelpCenterButton } from "./HelpCenterButton";
export { default as HelpCenterModal } from "./HelpCenterModal";
export { useFirstRunHelp, PATH_MARK_FIRST_RUN_KEY } from "./useFirstRunHelp";
export type { HelpTarget, HelpTopicId, HelpSectionId } from "./types";
29 changes: 29 additions & 0 deletions src/web/src/components/HelpCenter/topics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { HelpTopicDefinition, HelpTopicId } from "./types";

import { AiOutlineTags } from "react-icons/ai";

import PathMarkTopic from "./topics/pathMark";
import PathMarkConceptDetail from "./topics/pathMark/ConceptDetail";
import { pathMarkConcepts } from "./topics/pathMark/concepts";

/**
* Registry of all help center topics. Other guides (onboarding, resource
* profile, file mover, ...) join the help center by adding an entry here.
*/
export const helpTopics: HelpTopicDefinition[] = [
{
id: "pathMark",
titleKey: "helpCenter.topic.pathMark",
icon: <AiOutlineTags className="text-lg" />,
Content: PathMarkTopic,
conceptGroupLabelKey: "helpCenter.pathMark.section.concepts",
concepts: pathMarkConcepts.map((concept) => ({
id: concept.id,
labelKey: `helpCenter.pathMark.concept.${concept.id}.name`,
})),
ConceptContent: PathMarkConceptDetail,
},
];

export const getHelpTopic = (id: HelpTopicId): HelpTopicDefinition =>
helpTopics.find((topic) => topic.id === id) ?? helpTopics[0]!;
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"use client";

import { useTranslation } from "react-i18next";
import { AiOutlineCheck, AiOutlineClose, AiOutlineMinus } from "react-icons/ai";

type SupportLevel = "yes" | "partial" | "no";

interface ComparisonRow {
id: string;
/** [scraper-style manager, manual tagging tool, Bakabase path marks] */
levels: [SupportLevel, SupportLevel, SupportLevel];
}

const rows: ComparisonRow[] = [
{ id: "noRestructure", levels: ["no", "yes", "yes"] },
{ id: "anyLevel", levels: ["no", "partial", "yes"] },
{ id: "regexMatch", levels: ["no", "no", "yes"] },
{ id: "dynamicProperty", levels: ["partial", "no", "yes"] },
{ id: "dynamicLibrary", levels: ["no", "no", "yes"] },
{ id: "stackedRules", levels: ["no", "partial", "yes"] },
{ id: "preview", levels: ["no", "no", "yes"] },
{ id: "anyFileType", levels: ["partial", "partial", "yes"] },
{ id: "keepDataOnMove", levels: ["partial", "no", "yes"] },
{ id: "portableRules", levels: ["no", "no", "yes"] },
];

const LevelIcon = ({ level }: { level: SupportLevel }) => {
switch (level) {
case "yes":
return <AiOutlineCheck className="text-success text-base mx-auto" />;
case "partial":
return <AiOutlineMinus className="text-warning text-base mx-auto" />;
case "no":
return <AiOutlineClose className="text-default-300 text-base mx-auto" />;
}
};

const k = (key: string) => `helpCenter.pathMark.comparison.${key}`;

const ComparisonSection = () => {
const { t } = useTranslation();

return (
<div className="flex flex-col gap-3">
<p className="text-sm text-default-500">{t(k("intro"))}</p>

<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-default-200">
<th className="text-left font-medium text-default-500 py-2 pr-2">
{t(k("column.capability"))}
</th>
<th className="font-medium text-default-500 py-2 px-2 whitespace-nowrap">
{t(k("column.scraper"))}
</th>
<th className="font-medium text-default-500 py-2 px-2 whitespace-nowrap">
{t(k("column.manual"))}
</th>
<th className="font-medium text-primary py-2 px-2 whitespace-nowrap bg-primary/5 rounded-t">
{t(k("column.bakabase"))}
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id} className="border-b border-default-100">
<td className="py-2 pr-2 text-default-700">{t(k(`row.${row.id}`))}</td>
<td className="py-2 px-2 text-center">
<LevelIcon level={row.levels[0]} />
</td>
<td className="py-2 px-2 text-center">
<LevelIcon level={row.levels[1]} />
</td>
<td className="py-2 px-2 text-center bg-primary/5">
<LevelIcon level={row.levels[2]} />
</td>
</tr>
))}
</tbody>
</table>
</div>

<div className="flex items-center gap-4 text-xs text-default-400">
<span className="flex items-center gap-1">
<AiOutlineCheck className="text-success" />
{t(k("legend.yes"))}
</span>
<span className="flex items-center gap-1">
<AiOutlineMinus className="text-warning" />
{t(k("legend.partial"))}
</span>
<span className="flex items-center gap-1">
<AiOutlineClose className="text-default-300" />
{t(k("legend.no"))}
</span>
</div>

<p className="text-xs text-default-400">{t(k("note"))}</p>
</div>
);
};

ComparisonSection.displayName = "ComparisonSection";

export default ComparisonSection;
Loading
Loading