diff --git a/src/components/ExpandableCard.css b/src/components/ExpandableCard.css new file mode 100644 index 000000000..54ad0f712 --- /dev/null +++ b/src/components/ExpandableCard.css @@ -0,0 +1,23 @@ +.vc-expandable-card { + color: var(--text-default); +} + +.vc-expandable-card-header { + display: flex; + cursor: pointer; + padding: .75em 1em; + margin: 4px; + border-radius: var(--radius-xs); + + .vc-expandable-card[data-expanded="true"] & { + background: var(--card-secondary-bg); + } +} + +.vc-expandable-card-icon { + margin-left: auto; +} + +.vc-expandable-card-content { + padding: 0.5em 1em; +} \ No newline at end of file diff --git a/src/components/ExpandableCard.tsx b/src/components/ExpandableCard.tsx new file mode 100644 index 000000000..31ee11a24 --- /dev/null +++ b/src/components/ExpandableCard.tsx @@ -0,0 +1,45 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./ExpandableCard.css"; + +import { classes } from "@utils/misc"; +import { Clickable, useState } from "@webpack/common"; +import { PropsWithChildren } from "react"; + +import { Card } from "./Card"; +import { DownArrow, RightArrow } from "./Icons"; + +export type ExpandableSectionProps = PropsWithChildren<{ + renderContent: () => React.ReactNode; + className?: string; + initialExpanded?: boolean; +}>; + +/** + * A card component that can expand and collapse to show/hide content. The header (props.children) is always visible, and the content (props.renderContent) is only visible when expanded. + */ +export function ExpandableSection({ children, renderContent: Content, className, initialExpanded = false }: ExpandableSectionProps) { + const [expanded, setExpanded] = useState(initialExpanded); + + const Icon = expanded ? DownArrow : RightArrow; + + return ( + + setExpanded(c => !c)} > + {children} + + + + {expanded + ?
+ +
+ : null + } +
+ ); +} diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index 6e39f3f49..eb36ede95 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -571,7 +571,7 @@ export function CloudUploadIcon(props: IconProps) { ); } -export const ClockIcon = (props?: any) => { +export function ClockIcon(props: IconProps) { return ( { /> ); -}; +} + +export function DownArrow(props: IconProps) { + return ( + + + + ); +} + +export function RightArrow(props: IconProps) { + return ( + + + + ); +} diff --git a/src/plugins/textReplace/index.tsx b/src/plugins/textReplace/index.tsx index 8122ab395..8e70d85c8 100644 --- a/src/plugins/textReplace/index.tsx +++ b/src/plugins/textReplace/index.tsx @@ -16,22 +16,31 @@ * along with this program. If not, see . */ +import "./styles.css"; + import { definePluginSettings } from "@api/Settings"; +import { Button } from "@components/Button"; +import { ExpandableSection } from "@components/ExpandableCard"; import { Flex } from "@components/Flex"; -import { DeleteIcon } from "@components/Icons"; +import { HeadingSecondary } from "@components/Heading"; +import { Paragraph } from "@components/Paragraph"; +import { Span } from "@components/Span"; +import { TooltipContainer } from "@components/TooltipContainer"; import { Devs } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; import { Logger } from "@utils/Logger"; import definePlugin, { OptionType } from "@utils/types"; -import { Button, Forms, React, TextInput, useState } from "@webpack/common"; +import { React, TextInput, useState } from "@webpack/common"; -const STRING_RULES_KEY = "TextReplace_rulesString"; -const REGEX_RULES_KEY = "TextReplace_rulesRegex"; +const cl = classNameFactory("vc-textReplace-"); type Rule = Record<"find" | "replace" | "onlyIfIncludes", string>; interface TextReplaceProps { title: string; + description: string; rulesArray: Rule[]; + isRegex?: boolean; } const makeEmptyRule: () => Rule = () => ({ @@ -49,15 +58,18 @@ const settings = definePluginSettings({ return ( <> + - ); } @@ -116,21 +128,32 @@ function Input({ initialValue, onChange, placeholder }: { ); } -function TextReplace({ title, rulesArray }: TextReplaceProps) { - const isRegexRules = title === "Using Regex"; +function TextRow({ label, description, value, onChange }: { label: string; description: string; value: string; onChange(value: string): void; }) { + return ( + <> + + {label} + + + + ); +} - async function onClickRemove(index: number) { - if (index === rulesArray.length - 1) return; +const isEmptyRule = (rule: Rule) => !rule.find; + +function TextReplace({ title, description, rulesArray, isRegex = false }: TextReplaceProps) { + function onClickRemove(index: number) { rulesArray.splice(index, 1); } - async function onChange(e: string, index: number, key: string) { - if (index === rulesArray.length - 1) { - rulesArray.push(makeEmptyRule()); - } - + function onChange(e: string, index: number, key: string) { rulesArray[index][key] = e; + // If a rule is empty after editing and is not the last rule, remove it if (rulesArray[index].find === "" && rulesArray[index].replace === "" && rulesArray[index].onlyIfIncludes === "" && index !== rulesArray.length - 1) { rulesArray.splice(index, 1); } @@ -138,49 +161,61 @@ function TextReplace({ title, rulesArray }: TextReplaceProps) { return ( <> - {title} +
+ {title} + {description} +
- { - rulesArray.map((rule, index) => - - - onChange(e, index, "find")} - /> - onChange(e, index, "replace")} - /> - onChange(e, index, "onlyIfIncludes")} - /> + {rulesArray.map((rule, index) => + ( + <> +
+ onChange(e, index, "find")} + /> + onChange(e, index, "replace")} + /> + onChange(e, index, "onlyIfIncludes")} + /> +
+ {isRegex && renderFindError(rule.find)} -
- {isRegexRules && renderFindError(rule.find)} -
- ) - } + + )} + > + + {isEmptyRule(rule) + ? `Empty Rule ${index + 1}` + : `Rule ${index + 1} - ${rule.find}` + } + + + )} +
); @@ -188,12 +223,15 @@ function TextReplace({ title, rulesArray }: TextReplaceProps) { function TextReplaceTesting() { const [value, setValue] = useState(""); + return ( - <> - Test Rules - - - +
+ Rule Tester + + + + +
); } diff --git a/src/plugins/textReplace/styles.css b/src/plugins/textReplace/styles.css new file mode 100644 index 000000000..51a65fe1a --- /dev/null +++ b/src/plugins/textReplace/styles.css @@ -0,0 +1,11 @@ +.vc-textReplace-input-grid { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.25em 1em; + align-items: center; +} + +.vc-textReplace-delete-button { + width: 100%; + margin-block: 0.75em 0.5em +} \ No newline at end of file