This commit is contained in:
thororen 2024-04-17 14:29:47 -04:00
parent 538b87062a
commit ea7451bcdc
326 changed files with 24876 additions and 2280 deletions

View file

@ -26,7 +26,7 @@ export default function DonateButton(props: any) {
{...props}
look={Button.Looks.LINK}
color={Button.Colors.TRANSPARENT}
onClick={() => VencordNative.native.openExternal("https://github.com/sponsors/Vendicated")}
onClick={() => VencordNative.native.openExternal("https://github.com/sponsors/vendicated")}
>
<Heart />
Donate

View file

@ -1,7 +1,6 @@
.vc-expandableheader-center-flex {
display: flex;
justify-items: center;
align-items: center;
place-content: center;
}
.vc-expandableheader-btn {
@ -9,4 +8,4 @@
cursor: pointer;
width: 24px;
height: 24px;
}
}

View file

@ -256,6 +256,25 @@ export function DeleteIcon(props: IconProps) {
);
}
/**
* A plugin icon, created by CorellanStoma. https://github.com/CreArts-Community/Settings-Icons
*/
export function PluginIcon(props: IconProps) {
return (
<Icon
{...props}
className={classes(props.className, "vc-plugin-icon")}
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7s2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"
/>
</Icon>
);
}
export function PlusIcon(props: IconProps) {
return (
<Icon

View file

@ -0,0 +1,258 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { useSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { CopyIcon, DeleteIcon } from "@components/Icons";
import { copyWithToast } from "@utils/misc";
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModalLazy } from "@utils/modal";
import { getThemeInfo, UserThemeHeader } from "@utils/themes/bd";
import { Button, Card, Forms, Text, TextInput, useEffect, useState } from "@webpack/common";
import { AddonCard } from "../VencordSettings/AddonCard";
import { ThemeCard } from "./ThemesTab";
export interface OnlineTheme {
link: string;
headers?: UserThemeHeader;
error?: string;
}
const cl = classNameFactory("vc-settings-theme-");
/**
* Trims URLs like so
* https://whatever.example.com/whatever/whatever/whatever/whatever/index.html
* -> whatever/index.html
*/
function trimThemeUrl(url: string) {
let urlObj: URL;
try {
urlObj = new URL(url);
} catch (e) {
return url;
}
return urlObj.pathname.split("/").slice(-2).join("/");
}
async function FetchTheme(link: string) {
const theme: OnlineTheme = await fetch(link, { redirect: "follow" })
.then(res => {
if (res.status >= 400) throw `${res.status} ${res.statusText}`;
const contentType = res.headers.get("Content-Type");
if (!contentType?.startsWith("text/css") && !contentType?.startsWith("text/plain"))
throw "Not a CSS file. Remember to use the raw link!";
return res.text();
})
.then(text => {
const headers = getThemeInfo(text, trimThemeUrl(link));
return { link, headers };
}).catch(e => {
return { link, error: e.toString() };
});
return theme;
}
export function OnlineThemes() {
const settings = useSettings(["themeLinks", "disabledThemeLinks"]);
const [themes, setThemes] = useState<OnlineTheme[]>([]);
async function fetchThemes() {
const themes = await Promise.all(settings.themeLinks.map(link => FetchTheme(link)));
setThemes(themes);
}
async function addTheme(link: string) {
settings.disabledThemeLinks = [...settings.disabledThemeLinks, link];
settings.themeLinks = [...settings.themeLinks, link];
setThemes([...themes, await FetchTheme(link)]);
}
async function removeTheme(link: string) {
settings.disabledThemeLinks = settings.disabledThemeLinks.filter(l => l !== link);
settings.themeLinks = settings.themeLinks.filter(l => l !== link);
setThemes(themes.filter(t => t.link !== link));
}
function setThemeEnabled(link: string, enabled: boolean) {
if (enabled) {
settings.disabledThemeLinks = settings.disabledThemeLinks.filter(l => l !== link);
} else {
settings.disabledThemeLinks = [...settings.disabledThemeLinks, link];
}
settings.themeLinks = [...settings.themeLinks];
}
useEffect(() => {
fetchThemes();
}, []);
function AddThemeModal({ transitionState, onClose }: ModalProps) {
const [disabled, setDisabled] = useState(true);
const [error, setError] = useState<string | null>(null);
const [url, setUrl] = useState("");
async function checkUrl() {
if (!url) {
setDisabled(true);
setError(null);
return;
}
if (themes.some(t => t.link === url)) {
setError("Theme already added");
setDisabled(true);
return;
}
let urlObj: URL | undefined = undefined;
try {
urlObj = new URL(url);
} catch (e) {
setDisabled(true);
setError("Invalid URL");
return;
}
if (urlObj.hostname !== "raw.githubusercontent.com" && !urlObj.hostname.endsWith("github.io")) {
setError("Only raw.githubusercontent.com and github.io URLs are allowed, otherwise the theme will not work");
setDisabled(true);
return;
}
if (!urlObj.pathname.endsWith(".css")) {
setError("Not a CSS file. Remember to use the raw link!");
setDisabled(true);
return;
}
let success = true;
await fetch(url).then(res => {
if (!res.ok) {
setError(`Could not fetch theme: ${res.status} ${res.statusText}`);
setDisabled(true);
success = false;
}
}).catch(e => {
setError(`Could not fetch theme: ${e}`);
setDisabled(true);
success = false;
});
if (!success) return;
setDisabled(false);
setError(null);
}
return <ModalRoot transitionState={transitionState} size={ModalSize.SMALL}>
<ModalHeader>
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>Add Online Theme</Text>
<ModalCloseButton onClick={onClose} />
</ModalHeader>
<ModalContent className={cl("add-theme-content")}>
<Forms.FormText>Only raw.githubusercontent.com and github.io URLs will work</Forms.FormText>
<TextInput placeholder="URL" name="url" onBlur={checkUrl} onChange={setUrl} />
<Forms.FormText className={cl("add-theme-error")}>{error}</Forms.FormText>
</ModalContent>
<ModalFooter className={cl("add-theme-footer")}>
<Button onClick={onClose} color={Button.Colors.RED}>Cancel</Button>
<Button onClick={() => {
addTheme(url);
onClose();
}} color={Button.Colors.BRAND} disabled={disabled}>
Add
</Button>
</ModalFooter>
</ModalRoot>;
}
return (
<>
<Card className="vc-settings-card">
<Forms.FormTitle tag="h5">Find Themes:</Forms.FormTitle>
<Forms.FormText>
Find a theme you like and press "Add Theme" to add it.
To get a raw link to a theme, go to it's GitHub repository,
find the CSS file, and press the "Raw" button, then copy the URL.
</Forms.FormText>
</Card>
<Forms.FormSection title="Online Themes" tag="h5">
<Card className="vc-settings-quick-actions-card">
<Button onClick={() => openModalLazy(async () => {
return modalProps => {
return <AddThemeModal {...modalProps} />;
};
})} size={Button.Sizes.SMALL}>
Add Theme
</Button>
<Button
onClick={() => fetchThemes()}
size={Button.Sizes.SMALL}
>
Refresh
</Button>
</Card>
<div className={cl("grid")}>
{themes.length === 0 && (
<Forms.FormText>Add themes with the "Add Theme" button above</Forms.FormText>
)}
{themes.map(theme => (
(!theme.error && <ThemeCard
key={theme.link}
enabled={!settings.disabledThemeLinks.includes(theme.link)}
onChange={value => {
setThemeEnabled(theme.link, value);
}}
onDelete={() => removeTheme(theme.link)}
theme={theme.headers!}
showDelete={true}
extraButtons={
<div
style={{ cursor: "pointer" }}
onClick={() => copyWithToast(theme.link, "Link copied to clipboard!")}
>
<CopyIcon />
</div>
}
/>
) || (
<AddonCard
key={theme.link}
name={theme.error}
description={theme.link}
enabled={false}
setEnabled={() => { }}
hideSwitch={true}
infoButton={<>
<div
style={{ cursor: "pointer" }}
onClick={() => copyWithToast(theme.link, "Link copied to clipboard!")}
>
<CopyIcon />
</div>
<div
style={{ cursor: "pointer", color: "var(--status-danger" }}
onClick={() => removeTheme(theme.link)}
>
<DeleteIcon />
</div>
</>
}
/>
)
))}
</div>
</Forms.FormSection>
</>
);
}

View file

@ -0,0 +1,431 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./themesStyles.css";
import { Settings, useSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { Flex } from "@components/Flex";
import { CogWheel, DeleteIcon, PluginIcon } from "@components/Icons";
import { Link } from "@components/Link";
import PluginModal from "@components/PluginSettings/PluginModal";
import { AddonCard } from "@components/VencordSettings/AddonCard";
import { SettingsTab, wrapTab } from "@components/VencordSettings/shared";
import { openInviteModal } from "@utils/discord";
import { openModal } from "@utils/modal";
import { showItemInFolder } from "@utils/native";
import { useAwaiter } from "@utils/react";
import type { ThemeHeader } from "@utils/themes";
import { getThemeInfo, stripBOM, type UserThemeHeader } from "@utils/themes/bd";
import { usercssParse } from "@utils/themes/usercss";
import { findByCodeLazy } from "@webpack";
import { Button, Card, Forms, React, showToast, TabBar, Tooltip, useEffect, useMemo, useRef, useState } from "@webpack/common";
import type { ComponentType, Ref, SyntheticEvent } from "react";
import type { UserstyleHeader } from "usercss-meta";
import { isPluginEnabled } from "../../plugins";
import { OnlineThemes } from "./OnlineThemes";
import { UserCSSSettingsModal } from "./UserCSSModal";
type FileInput = ComponentType<{
ref: Ref<HTMLInputElement>;
onChange: (e: SyntheticEvent<HTMLInputElement>) => void;
multiple?: boolean;
filters?: { name?: string; extensions: string[]; }[];
}>;
const FileInput: FileInput = findByCodeLazy("activateUploadDialogue=");
const cl = classNameFactory("vc-settings-theme-");
interface ThemeCardProps {
theme: UserThemeHeader;
enabled: boolean;
onChange: (enabled: boolean) => void;
onDelete: () => void;
showDelete?: boolean;
extraButtons?: React.ReactNode;
}
interface OtherThemeCardProps {
theme: UserThemeHeader;
enabled: boolean;
onChange: (enabled: boolean) => void;
onDelete: () => void;
showDelete?: boolean;
extraButtons?: React.ReactNode;
}
interface UserCSSCardProps {
theme: UserstyleHeader;
enabled: boolean;
onChange: (enabled: boolean) => void;
onDelete: () => void;
}
export function ThemeCard({ theme, enabled, onChange, onDelete, showDelete, extraButtons }: ThemeCardProps) {
return (
<AddonCard
name={theme.name}
description={theme.description}
author={theme.author}
enabled={enabled}
setEnabled={onChange}
infoButton={
(IS_WEB || showDelete) && (<>
{extraButtons}
<div
style={{ cursor: "pointer", color: "var(--status-danger" }}
onClick={onDelete}
>
<DeleteIcon />
</div>
</>
)
}
footer={
<Flex flexDirection="row" style={{ gap: "0.2em" }}>
{!!theme.website && <Link href={theme.website}>Website</Link>}
{!!(theme.website && theme.invite) && " • "}
{!!theme.invite && (
<Link
href={`https://discord.gg/${theme.invite}`}
onClick={async e => {
e.preventDefault();
theme.invite != null && openInviteModal(theme.invite).catch(() => showToast("Invalid or expired invite"));
}}
>
Discord Server
</Link>
)}
</Flex>
}
/>
);
}
function UserCSSThemeCard({ theme, enabled, onChange, onDelete }: UserCSSCardProps) {
const missingPlugins = useMemo(() =>
theme.requiredPlugins?.filter(p => !isPluginEnabled(p)), [theme]);
return (
<AddonCard
name={theme.name ?? "Unknown"}
description={theme.description}
author={theme.author ?? "Unknown"}
enabled={enabled}
setEnabled={onChange}
infoButton={
<>
{missingPlugins && missingPlugins.length > 0 && (
<Tooltip text={"The following plugins are required, but aren't enabled: " + missingPlugins.join(", ")}>
{({ onMouseLeave, onMouseEnter }) => (
<div
style={{ color: "var(--status-warning" }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<PluginIcon />
</div>
)}
</Tooltip>
)}
{theme.vars && (
<div style={{ cursor: "pointer" }} onClick={
() => openModal(modalProps =>
<UserCSSSettingsModal modalProps={modalProps} theme={theme} />)
}>
<CogWheel />
</div>
)}
{IS_WEB && (
<div style={{ cursor: "pointer", color: "var(--status-danger" }} onClick={onDelete}>
<DeleteIcon />
</div>
)}
</>
}
footer={
<Flex flexDirection="row" style={{ gap: "0.2em" }}>
{!!theme.homepageURL && <Link href={theme.homepageURL}>Homepage</Link>}
{!!(theme.homepageURL && theme.supportURL) && " • "}
{!!theme.supportURL && <Link href={theme.supportURL}>Support</Link>}
</Flex>
}
/>
);
}
function OtherThemeCard({ theme, enabled, onChange, onDelete, showDelete, extraButtons }: OtherThemeCardProps) {
return (
<AddonCard
name={theme.name}
description={theme.description}
author={theme.author}
enabled={enabled}
setEnabled={onChange}
infoButton={
(IS_WEB || showDelete) && (<>
{extraButtons}
<div
style={{ cursor: "pointer", color: "var(--status-danger" }}
onClick={onDelete}
>
<DeleteIcon />
</div>
</>
)
}
footer={
<Flex flexDirection="row" style={{ gap: "0.2em" }}>
{!!theme.website && <Link href={theme.website}>Website</Link>}
{!!(theme.website && theme.invite) && " • "}
{!!theme.invite && (
<Link
href={`https://discord.gg/${theme.invite}`}
onClick={async e => {
e.preventDefault();
theme.invite != null && openInviteModal(theme.invite).catch(() => showToast("Invalid or expired invite"));
}}
>
Discord Server
</Link>
)}
</Flex>
}
/>
);
}
enum ThemeTab {
LOCAL,
ONLINE,
REPO
}
function ThemesTab() {
const settings = useSettings(["themeLinks", "disabledThemeLinks", "enabledThemes"]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [currentTab, setCurrentTab] = useState(ThemeTab.LOCAL);
const [userThemes, setUserThemes] = useState<ThemeHeader[] | null>(null);
const [themeDir, , themeDirPending] = useAwaiter(VencordNative.themes.getThemesDir);
useEffect(() => {
refreshLocalThemes();
}, [settings.themeLinks]);
async function refreshLocalThemes() {
const themes = await VencordNative.themes.getThemesList();
const themeInfo: ThemeHeader[] = [];
for (const { fileName, content } of themes) {
if (!fileName.endsWith(".css")) continue;
if ((!IS_WEB || "armcord" in window) && fileName.endsWith(".user.css")) {
// handle it as usercss
const header = await usercssParse(content, fileName);
themeInfo.push({
type: "usercss",
header
});
Settings.userCssVars[header.id] ??= {};
for (const [name, varInfo] of Object.entries(header.vars ?? {})) {
let normalizedValue = "";
switch (varInfo.type) {
case "text":
case "color":
normalizedValue = varInfo.default;
break;
case "select":
normalizedValue = varInfo.options.find(v => v.name === varInfo.default)!.value;
break;
case "checkbox":
normalizedValue = varInfo.default ? "1" : "0";
break;
case "range":
normalizedValue = `${varInfo.default}${varInfo.units}`;
break;
case "number":
normalizedValue = String(varInfo.default);
break;
}
Settings.userCssVars[header.id][name] ??= normalizedValue;
}
} else {
// presumably BD but could also be plain css
themeInfo.push({
type: "other",
header: getThemeInfo(stripBOM(content), fileName)
});
}
}
setUserThemes(themeInfo);
}
// When a local theme is enabled/disabled, update the settings
function onLocalThemeChange(fileName: string, value: boolean) {
if (value) {
if (settings.enabledThemes.includes(fileName)) return;
settings.enabledThemes = [...settings.enabledThemes, fileName];
} else {
settings.enabledThemes = settings.enabledThemes.filter(f => f !== fileName);
}
}
async function onFileUpload(e: SyntheticEvent<HTMLInputElement>) {
e.stopPropagation();
e.preventDefault();
if (!e.currentTarget?.files?.length) return;
const { files } = e.currentTarget;
const uploads = Array.from(files, file => {
const { name } = file;
if (!name.endsWith(".css")) return;
return new Promise<void>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
VencordNative.themes
.uploadTheme(name, reader.result as string)
.then(resolve)
.catch(reject);
};
reader.readAsText(file);
});
});
await Promise.all(uploads);
refreshLocalThemes();
}
function renderLocalThemes() {
return (
<>
<Card className="vc-settings-card">
<Forms.FormTitle tag="h5">Find Themes:</Forms.FormTitle>
<div style={{ marginBottom: ".5em", display: "flex", flexDirection: "column" }}>
<Link style={{ marginRight: ".5em" }} href="https://betterdiscord.app/themes">
BetterDiscord Themes
</Link>
<Link href="https://github.com/search?q=discord+theme">GitHub</Link>
</div>
<Forms.FormText>
If using the BD site, click on "Download" and place the downloaded
.theme.css file into your themes folder.
</Forms.FormText>
</Card>
<Forms.FormSection title="Local Themes">
<Card className="vc-settings-quick-actions-card">
<>
{IS_WEB ? (
<Button size={Button.Sizes.SMALL} disabled={themeDirPending}>
Upload Theme
<FileInput
ref={fileInputRef}
onChange={onFileUpload}
multiple={true}
filters={[{ extensions: ["css"] }]}
/>
</Button>
) : (
<Button
onClick={() => showItemInFolder(themeDir!)}
size={Button.Sizes.SMALL}
disabled={themeDirPending}
>
Open Themes Folder
</Button>
)}
<Button onClick={refreshLocalThemes} size={Button.Sizes.SMALL}>
Load missing Themes
</Button>
<Button onClick={() => VencordNative.quickCss.openEditor()} size={Button.Sizes.SMALL}>
Edit QuickCSS
</Button>
{Vencord.Settings.plugins.ClientTheme.enabled && (
<Button
onClick={() => openModal(modalProps => (
<PluginModal
{...modalProps}
plugin={Vencord.Plugins.plugins.ClientTheme}
onRestartNeeded={() => { }}
/>
))}
size={Button.Sizes.SMALL}
>
Edit ClientTheme
</Button>
)}
</>
</Card>
<div className={cl("grid")}>
{userThemes?.map(({ type, header: theme }: ThemeHeader) => (
type === "other" ? (
<OtherThemeCard
key={theme.fileName}
enabled={settings.enabledThemes.includes(theme.fileName)}
onChange={enabled => onLocalThemeChange(theme.fileName, enabled)}
onDelete={async () => {
onLocalThemeChange(theme.fileName, false);
await VencordNative.themes.deleteTheme(theme.fileName);
refreshLocalThemes();
}}
theme={theme as UserThemeHeader}
/>
) : (
<UserCSSThemeCard
key={theme.fileName}
enabled={settings.enabledThemes.includes(theme.fileName)}
onChange={enabled => onLocalThemeChange(theme.fileName, enabled)}
onDelete={async () => {
onLocalThemeChange(theme.fileName, false);
await VencordNative.themes.deleteTheme(theme.fileName);
refreshLocalThemes();
}}
theme={theme as UserstyleHeader}
/>
)))}
</div>
</Forms.FormSection>
</>
);
}
return (
<SettingsTab title="Themes">
<TabBar
type="top"
look="brand"
className="vc-settings-tab-bar"
selectedItem={currentTab}
onItemSelect={setCurrentTab}
>
<TabBar.Item className="vc-settings-tab-bar-item" id={ThemeTab.LOCAL}>
Local Themes
</TabBar.Item>
<TabBar.Item className="vc-settings-tab-bar-item" id={ThemeTab.ONLINE}>
Online Themes
</TabBar.Item>
</TabBar>
{currentTab === ThemeTab.LOCAL && renderLocalThemes()}
{currentTab === ThemeTab.ONLINE && <OnlineThemes />}
</SettingsTab>
);
}
export default wrapTab(ThemesTab, "Themes");

View file

@ -0,0 +1,114 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { useSettings } from "@api/Settings";
import { Flex } from "@components/Flex";
import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot } from "@utils/modal";
import { Text } from "@webpack/common";
import type { ReactNode } from "react";
import { UserstyleHeader } from "usercss-meta";
import { SettingBooleanComponent, SettingColorComponent, SettingNumberComponent, SettingRangeComponent, SettingSelectComponent, SettingTextComponent } from "./components";
interface UserCSSSettingsModalProps {
modalProps: ModalProps;
theme: UserstyleHeader;
}
export function UserCSSSettingsModal({ modalProps, theme }: UserCSSSettingsModalProps) {
// @ts-expect-error UseSettings<> can't determine this is a valid key
const themeSettings = useSettings(["userCssVars"], false).userCssVars[theme.id];
const controls: ReactNode[] = [];
for (const [name, varInfo] of Object.entries(theme.vars)) {
switch (varInfo.type) {
case "text": {
controls.push(
<SettingTextComponent
label={varInfo.label}
name={name}
themeSettings={themeSettings}
/>
);
break;
}
case "checkbox": {
controls.push(
<SettingBooleanComponent
label={varInfo.label}
name={name}
themeSettings={themeSettings}
/>
);
break;
}
case "color": {
controls.push(
<SettingColorComponent
label={varInfo.label}
name={name}
themeSettings={themeSettings}
/>
);
break;
}
case "number": {
controls.push(
<SettingNumberComponent
label={varInfo.label}
name={name}
themeSettings={themeSettings}
/>
);
break;
}
case "select": {
controls.push(
<SettingSelectComponent
label={varInfo.label}
name={name}
options={varInfo.options}
default={varInfo.default}
themeSettings={themeSettings}
/>
);
break;
}
case "range": {
controls.push(
<SettingRangeComponent
label={varInfo.label}
name={name}
default={varInfo.default}
min={varInfo.min}
max={varInfo.max}
step={varInfo.step}
themeSettings={themeSettings}
/>
);
break;
}
}
}
return (
<ModalRoot {...modalProps}>
<ModalHeader separator={false}>
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>Settings for {theme.name}</Text>
<ModalCloseButton onClick={modalProps.onClose} />
</ModalHeader>
<ModalContent>
<Flex flexDirection="column" style={{ gap: 12, marginBottom: 16 }}>{controls}</Flex>
</ModalContent>
</ModalRoot>
);
}

View file

@ -0,0 +1,39 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Forms, Switch, useState } from "@webpack/common";
interface Props {
label: string;
name: string;
themeSettings: Record<string, string>;
}
export function SettingBooleanComponent({ label, name, themeSettings }: Props) {
const [value, setValue] = useState(themeSettings[name]);
function handleChange(value: boolean) {
const corrected = value ? "1" : "0";
setValue(corrected);
themeSettings[name] = corrected;
}
return (
<Forms.FormSection>
<Switch
key={name}
value={value === "1"}
onChange={handleChange}
hideBorder
style={{ marginBottom: "0.5em" }}
>
{label}
</Switch>
</Forms.FormSection>
);
}

View file

@ -0,0 +1,56 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./colorStyles.css";
import { classNameFactory } from "@api/Styles";
import { findByCodeLazy, findComponentByCodeLazy } from "@webpack";
import { Forms, useMemo, useState } from "@webpack/common";
interface ColorPickerProps {
color: number | null;
showEyeDropper?: boolean;
onChange(value: number | null): void;
}
const ColorPicker = findComponentByCodeLazy<ColorPickerProps>(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR", ".BACKGROUND_PRIMARY)");
// TinyColor is completely unmangled and it's duplicated in two modules! Fun!
const TinyColor: tinycolor.Constructor = findByCodeLazy("this._gradientType=");
const cl = classNameFactory("vc-usercss-settings-color-");
interface Props {
label: string;
name: string;
themeSettings: Record<string, string>;
}
export function SettingColorComponent({ label, name, themeSettings }: Props) {
const [value, setValue] = useState(themeSettings[name]);
function handleChange(value: number) {
const corrected = "#" + (value?.toString(16).padStart(6, "0") ?? "000000");
setValue(corrected);
themeSettings[name] = corrected;
}
const normalizedValue = useMemo(() => parseInt(TinyColor(value).toHex(), 16), [value]);
return (
<Forms.FormSection>
<div className={cl("swatch-row")}>
<span>{label}</span>
<ColorPicker
key={name}
color={normalizedValue}
onChange={handleChange}
/>
</div>
</Forms.FormSection>
);
}

View file

@ -0,0 +1,36 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Forms, TextInput, useState } from "@webpack/common";
interface Props {
label: string;
name: string;
themeSettings: Record<string, string>;
}
export function SettingNumberComponent({ label, name, themeSettings }: Props) {
const [value, setValue] = useState(themeSettings[name]);
function handleChange(value: string) {
setValue(value);
themeSettings[name] = value;
}
return (
<Forms.FormSection>
<Forms.FormTitle tag="h5">{label}</Forms.FormTitle>
<TextInput
type="number"
pattern="-?[0-9]+"
key={name}
value={value}
onChange={handleChange}
/>
</Forms.FormSection>
);
}

View file

@ -0,0 +1,56 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Forms, Slider, useMemo, useState } from "@webpack/common";
interface Props {
label: string;
name: string;
default: number;
min?: number;
max?: number;
step?: number;
themeSettings: Record<string, string>;
}
export function SettingRangeComponent({ label, name, default: def, min, max, step, themeSettings }: Props) {
const [value, setValue] = useState(themeSettings[name]);
function handleChange(value: number) {
const corrected = value.toString();
setValue(corrected);
themeSettings[name] = corrected;
}
const markers = useMemo(() => {
const markers: number[] = [];
// defaults taken from https://github.com/openstyles/stylus/wiki/Writing-UserCSS#default-value
for (let i = (min ?? 0); i <= (max ?? 10); i += (step ?? 1)) {
markers.push(i);
}
return markers;
}, [min, max, step]);
return (
<Forms.FormSection>
<Forms.FormTitle tag="h5">{label}</Forms.FormTitle>
<Slider
initialValue={parseInt(value, 10)}
defaultValue={def}
onValueChange={handleChange}
minValue={min}
maxValue={max}
markers={markers}
stickToMarkers={true}
/>
</Forms.FormSection>
);
}

View file

@ -0,0 +1,55 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { identity } from "@utils/misc";
import { ComponentTypes, Forms, Select, useMemo, useState } from "@webpack/common";
interface Props {
label: string;
name: string;
options: {
name: string;
label: string;
value: string;
}[];
default: string;
themeSettings: Record<string, string>;
}
export function SettingSelectComponent({ label, name, options, default: def, themeSettings }: Props) {
const [value, setValue] = useState(themeSettings[name]);
function handleChange(value: string) {
setValue(value);
themeSettings[name] = value;
}
const opts = useMemo(() => options.map(option => ({
disabled: false,
key: option.name,
value: option.value,
default: def === option.name,
label: option.label
} satisfies ComponentTypes.SelectOption)), [options, def]);
return (
<Forms.FormSection>
<Forms.FormTitle tag="h5">{label}</Forms.FormTitle>
<Select
placeholder={label}
key={name}
options={opts}
closeOnSelect={true}
select={handleChange}
isSelected={v => v === value}
serialize={identity}
/>
</Forms.FormSection>
);
}

View file

@ -0,0 +1,34 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Forms, TextInput, useState } from "@webpack/common";
interface Props {
label: string;
name: string;
themeSettings: Record<string, string>;
}
export function SettingTextComponent({ label, name, themeSettings }: Props) {
const [value, setValue] = useState(themeSettings[name]);
function handleChange(value: string) {
setValue(value);
themeSettings[name] = value;
}
return (
<Forms.FormSection>
<Forms.FormTitle tag="h5">{label}</Forms.FormTitle>
<TextInput
key={name}
value={value}
onChange={handleChange}
/>
</Forms.FormSection>
);
}

View file

@ -0,0 +1,19 @@
.vc-usercss-settings-color-swatch-row {
display: flex;
flex-direction: row;
width: 100%;
align-items: center;
}
.vc-usercss-settings-color-swatch-row>span {
display: block;
flex: 1;
overflow: hidden;
margin-top: 0;
margin-bottom: 0;
color: var(--header-primary);
line-height: 24px;
font-size: 16px;
font-weight: 500;
word-wrap: break-word;
}

View file

@ -0,0 +1,12 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export * from "./SettingBooleanComponent";
export * from "./SettingColorComponent";
export * from "./SettingNumberComponent";
export * from "./SettingRangeComponent";
export * from "./SettingSelectComponent";
export * from "./SettingTextComponent";

View file

@ -0,0 +1,47 @@
.vc-settings-theme-grid {
display: grid;
grid-gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
.vc-settings-theme-card {
display: flex;
flex-direction: column;
background-color: var(--background-secondary-alt);
color: var(--interactive-active);
border-radius: 8px;
padding: 1em;
width: 100%;
transition: 0.1s ease-out;
transition-property: box-shadow, transform, background, opacity;
}
.vc-settings-theme-card-text {
text-overflow: ellipsis;
height: 1.2em;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
}
.vc-settings-theme-author::before {
content: "by ";
}
.vc-settings-theme-add-theme-content {
margin: 10px 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.vc-settings-theme-add-theme-footer {
display: flex;
flex-direction: row;
gap: 10px;
justify-content: flex-end;
}
.vc-settings-theme-add-theme-error {
color: var(--text-danger);
}

View file

@ -37,11 +37,12 @@ interface Props {
onMouseLeave?: MouseEventHandler<HTMLDivElement>;
infoButton?: ReactNode;
hideSwitch?: boolean;
footer?: ReactNode;
author?: ReactNode;
}
export function AddonCard({ disabled, isNew, name, infoButton, footer, author, enabled, setEnabled, description, onMouseEnter, onMouseLeave }: Props) {
export function AddonCard({ disabled, isNew, name, infoButton, footer, author, enabled, setEnabled, description, onMouseEnter, onMouseLeave, hideSwitch }: Props) {
return (
<div
className={cl("card", { "card-disabled": disabled })}
@ -62,11 +63,11 @@ export function AddonCard({ disabled, isNew, name, infoButton, footer, author, e
{infoButton}
<Switch
{!hideSwitch && <Switch
checked={enabled}
onChange={setEnabled}
disabled={disabled}
/>
/>}
</div>
<Text className={cl("note")} variant="text-sm/normal">{description}</Text>

View file

@ -34,7 +34,7 @@ function BackupRestoreTab() {
</Flex>
</Card>
<Text variant="text-md/normal" className={Margins.bottom8}>
You can import and export your Vencord settings as a JSON file.
You can import and export your Equicord settings as a JSON file.
This allows you to easily transfer your settings to another device,
or recover your settings after reinstalling Vencord or Discord.
</Text>

View file

@ -180,7 +180,8 @@ function ReplacementInput({ replacement, setReplacement, replacementError }) {
return (
<>
<Forms.FormTitle>replacement</Forms.FormTitle>
{/* FormTitle adds a class if className is not set, so we set it to an empty string to prevent that */}
<Forms.FormTitle className="">replacement</Forms.FormTitle>
<TextInput
value={replacement?.toString()}
onChange={onChange}
@ -188,7 +189,7 @@ function ReplacementInput({ replacement, setReplacement, replacementError }) {
/>
{!isFunc && (
<div className="vc-text-selectable">
<Forms.FormTitle>Cheat Sheet</Forms.FormTitle>
<Forms.FormTitle className={Margins.top8}>Cheat Sheet</Forms.FormTitle>
{Object.entries({
"\\i": "Special regex escape sequence that matches identifiers (varnames, classnames, etc.)",
"$$": "Insert a $",
@ -266,7 +267,7 @@ function FullPatchInput({ setFind, setMatch, setReplacement }: FullPatchInputPro
}
return <>
<Forms.FormText>Paste your full JSON patch here to fill out the fields</Forms.FormText>
<Forms.FormText className={Margins.bottom8}>Paste your full JSON patch here to fill out the fields</Forms.FormText>
<TextArea value={fullPatch} onChange={setFullPatch} onBlur={update} />
{fullPatchError !== "" && <Forms.FormText style={{ color: "var(--text-danger)" }}>{fullPatchError}</Forms.FormText>}
</>;
@ -321,7 +322,7 @@ function PatchHelper() {
setReplacement={setReplacement}
/>
<Forms.FormTitle>find</Forms.FormTitle>
<Forms.FormTitle className={Margins.top8}>find</Forms.FormTitle>
<TextInput
type="text"
value={find}
@ -329,7 +330,7 @@ function PatchHelper() {
error={findError}
/>
<Forms.FormTitle>match</Forms.FormTitle>
<Forms.FormTitle className={Margins.top8}>match</Forms.FormTitle>
<CheckedTextInput
value={match}
onChange={onMatchChange}
@ -342,6 +343,7 @@ function PatchHelper() {
}}
/>
<div className={Margins.top8} />
<ReplacementInput
replacement={replacement}
setReplacement={setReplacement}

View file

@ -1,356 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { useSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { Flex } from "@components/Flex";
import { DeleteIcon } from "@components/Icons";
import { Link } from "@components/Link";
import PluginModal from "@components/PluginSettings/PluginModal";
import type { UserThemeHeader } from "@main/themes";
import { openInviteModal } from "@utils/discord";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { openModal } from "@utils/modal";
import { showItemInFolder } from "@utils/native";
import { useAwaiter } from "@utils/react";
import { findByPropsLazy, findLazy } from "@webpack";
import { Button, Card, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common";
import type { ComponentType, Ref, SyntheticEvent } from "react";
import { AddonCard } from "./AddonCard";
import { SettingsTab, wrapTab } from "./shared";
type FileInput = ComponentType<{
ref: Ref<HTMLInputElement>;
onChange: (e: SyntheticEvent<HTMLInputElement>) => void;
multiple?: boolean;
filters?: { name?: string; extensions: string[]; }[];
}>;
const InviteActions = findByPropsLazy("resolveInvite");
const FileInput: FileInput = findLazy(m => m.prototype?.activateUploadDialogue && m.prototype.setRef);
const TextAreaProps = findLazy(m => typeof m.textarea === "string");
const cl = classNameFactory("vc-settings-theme-");
function Validator({ link }: { link: string; }) {
const [res, err, pending] = useAwaiter(() => fetch(link).then(res => {
if (res.status > 300) throw `${res.status} ${res.statusText}`;
const contentType = res.headers.get("Content-Type");
if (!contentType?.startsWith("text/css") && !contentType?.startsWith("text/plain"))
throw "Not a CSS file. Remember to use the raw link!";
return "Okay!";
}));
const text = pending
? "Checking..."
: err
? `Error: ${err instanceof Error ? err.message : String(err)}`
: "Valid!";
return <Forms.FormText style={{
color: pending ? "var(--text-muted)" : err ? "var(--text-danger)" : "var(--text-positive)"
}}>{text}</Forms.FormText>;
}
function Validators({ themeLinks }: { themeLinks: string[]; }) {
if (!themeLinks.length) return null;
return (
<>
<Forms.FormTitle className={Margins.top20} tag="h5">Validator</Forms.FormTitle>
<Forms.FormText>This section will tell you whether your themes can successfully be loaded</Forms.FormText>
<div>
{themeLinks.map(link => (
<Card style={{
padding: ".5em",
marginBottom: ".5em",
marginTop: ".5em"
}} key={link}>
<Forms.FormTitle tag="h5" style={{
overflowWrap: "break-word"
}}>
{link}
</Forms.FormTitle>
<Validator link={link} />
</Card>
))}
</div>
</>
);
}
interface ThemeCardProps {
theme: UserThemeHeader;
enabled: boolean;
onChange: (enabled: boolean) => void;
onDelete: () => void;
}
function ThemeCard({ theme, enabled, onChange, onDelete }: ThemeCardProps) {
return (
<AddonCard
name={theme.name}
description={theme.description}
author={theme.author}
enabled={enabled}
setEnabled={onChange}
infoButton={
IS_WEB && (
<div style={{ cursor: "pointer", color: "var(--status-danger" }} onClick={onDelete}>
<DeleteIcon />
</div>
)
}
footer={
<Flex flexDirection="row" style={{ gap: "0.2em" }}>
{!!theme.website && <Link href={theme.website}>Website</Link>}
{!!(theme.website && theme.invite) && " • "}
{!!theme.invite && (
<Link
href={`https://discord.gg/${theme.invite}`}
onClick={async e => {
e.preventDefault();
theme.invite != null && openInviteModal(theme.invite).catch(() => showToast("Invalid or expired invite"));
}}
>
Discord Server
</Link>
)}
</Flex>
}
/>
);
}
enum ThemeTab {
LOCAL,
ONLINE
}
function ThemesTab() {
const settings = useSettings(["themeLinks", "enabledThemes"]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [currentTab, setCurrentTab] = useState(ThemeTab.LOCAL);
const [themeText, setThemeText] = useState(settings.themeLinks.join("\n"));
const [userThemes, setUserThemes] = useState<UserThemeHeader[] | null>(null);
const [themeDir, , themeDirPending] = useAwaiter(VencordNative.themes.getThemesDir);
useEffect(() => {
refreshLocalThemes();
}, []);
async function refreshLocalThemes() {
const themes = await VencordNative.themes.getThemesList();
setUserThemes(themes);
}
// When a local theme is enabled/disabled, update the settings
function onLocalThemeChange(fileName: string, value: boolean) {
if (value) {
if (settings.enabledThemes.includes(fileName)) return;
settings.enabledThemes = [...settings.enabledThemes, fileName];
} else {
settings.enabledThemes = settings.enabledThemes.filter(f => f !== fileName);
}
}
async function onFileUpload(e: SyntheticEvent<HTMLInputElement>) {
e.stopPropagation();
e.preventDefault();
if (!e.currentTarget?.files?.length) return;
const { files } = e.currentTarget;
const uploads = Array.from(files, file => {
const { name } = file;
if (!name.endsWith(".css")) return;
return new Promise<void>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
VencordNative.themes.uploadTheme(name, reader.result as string)
.then(resolve)
.catch(reject);
};
reader.readAsText(file);
});
});
await Promise.all(uploads);
refreshLocalThemes();
}
function renderLocalThemes() {
return (
<>
<Card className="vc-settings-card">
<Forms.FormTitle tag="h5">Find Themes:</Forms.FormTitle>
<div style={{ marginBottom: ".5em", display: "flex", flexDirection: "column" }}>
<Link style={{ marginRight: ".5em" }} href="https://betterdiscord.app/themes">
BetterDiscord Themes
</Link>
<Link href="https://github.com/search?q=discord+theme">GitHub</Link>
</div>
<Forms.FormText>If using the BD site, click on "Download" and place the downloaded .theme.css file into your themes folder.</Forms.FormText>
</Card>
<Forms.FormSection title="Local Themes">
<Card className="vc-settings-quick-actions-card">
<>
{IS_WEB ?
(
<Button
size={Button.Sizes.SMALL}
disabled={themeDirPending}
>
Upload Theme
<FileInput
ref={fileInputRef}
onChange={onFileUpload}
multiple={true}
filters={[{ extensions: ["css"] }]}
/>
</Button>
) : (
<Button
onClick={() => showItemInFolder(themeDir!)}
size={Button.Sizes.SMALL}
disabled={themeDirPending}
>
Open Themes Folder
</Button>
)}
<Button
onClick={refreshLocalThemes}
size={Button.Sizes.SMALL}
>
Load missing Themes
</Button>
<Button
onClick={() => VencordNative.quickCss.openEditor()}
size={Button.Sizes.SMALL}
>
Edit QuickCSS
</Button>
{Vencord.Settings.plugins.ClientTheme.enabled && (
<Button
onClick={() => openModal(modalProps => (
<PluginModal
{...modalProps}
plugin={Vencord.Plugins.plugins.ClientTheme}
onRestartNeeded={() => { }}
/>
))}
size={Button.Sizes.SMALL}
>
Edit ClientTheme
</Button>
)}
</>
</Card>
<div className={cl("grid")}>
{userThemes?.map(theme => (
<ThemeCard
key={theme.fileName}
enabled={settings.enabledThemes.includes(theme.fileName)}
onChange={enabled => onLocalThemeChange(theme.fileName, enabled)}
onDelete={async () => {
onLocalThemeChange(theme.fileName, false);
await VencordNative.themes.deleteTheme(theme.fileName);
refreshLocalThemes();
}}
theme={theme}
/>
))}
</div>
</Forms.FormSection>
</>
);
}
// When the user leaves the online theme textbox, update the settings
function onBlur() {
settings.themeLinks = [...new Set(
themeText
.trim()
.split(/\n+/)
.map(s => s.trim())
.filter(Boolean)
)];
}
function renderOnlineThemes() {
return (
<>
<Card className="vc-settings-card vc-text-selectable">
<Forms.FormTitle tag="h5">Paste links to css files here</Forms.FormTitle>
<Forms.FormText>One link per line</Forms.FormText>
<Forms.FormText>Make sure to use direct links to files (raw or github.io)!</Forms.FormText>
</Card>
<Forms.FormSection title="Online Themes" tag="h5">
<TextArea
value={themeText}
onChange={setThemeText}
className={classes(TextAreaProps.textarea, "vc-settings-theme-links")}
placeholder="Theme Links"
spellCheck={false}
onBlur={onBlur}
rows={10}
/>
<Validators themeLinks={settings.themeLinks} />
</Forms.FormSection>
</>
);
}
return (
<SettingsTab title="Themes">
<TabBar
type="top"
look="brand"
className="vc-settings-tab-bar"
selectedItem={currentTab}
onItemSelect={setCurrentTab}
>
<TabBar.Item
className="vc-settings-tab-bar-item"
id={ThemeTab.LOCAL}
>
Local Themes
</TabBar.Item>
<TabBar.Item
className="vc-settings-tab-bar-item"
id={ThemeTab.ONLINE}
>
Online Themes
</TabBar.Item>
</TabBar>
{currentTab === ThemeTab.LOCAL && renderLocalThemes()}
{currentTab === ThemeTab.ONLINE && renderOnlineThemes()}
</SettingsTab>
);
}
export default wrapTab(ThemesTab, "Themes");

View file

@ -201,7 +201,7 @@ function Updater() {
};
return (
<SettingsTab title="Vencord Updater">
<SettingsTab title="Equicord Updater">
<Forms.FormTitle tag="h5">Updater Settings</Forms.FormTitle>
<Switch
value={settings.notifyAboutUpdates}
@ -214,14 +214,14 @@ function Updater() {
<Switch
value={settings.autoUpdate}
onChange={(v: boolean) => settings.autoUpdate = v}
note="Automatically update Vencord without confirmation prompt"
note="Automatically update Equicord without confirmation prompt"
>
Automatically update
</Switch>
<Switch
value={settings.autoUpdateNotification}
onChange={(v: boolean) => settings.autoUpdateNotification = v}
note="Shows a notification when Vencord automatically updates"
note="Shows a notification when Equicord automatically updates"
disabled={!settings.autoUpdate}
>
Get notified when an automatic update completes

View file

@ -32,7 +32,7 @@ import { SettingsTab, wrapTab } from "./shared";
const cl = classNameFactory("vc-settings-");
const DEFAULT_DONATE_IMAGE = "https://cdn.discordapp.com/emojis/1026533090627174460.png";
const SHIGGY_DONATE_IMAGE = "https://media.discordapp.net/stickers/1039992459209490513.png";
const SHIGGY_DONATE_IMAGE = "https://i.imgur.com/UpcDwX0.png";
type KeysOfType<Object, Type> = {
[K in keyof Object]: Object[K] extends Type ? K : never;
@ -93,7 +93,7 @@ function VencordSettings() {
];
return (
<SettingsTab title="Vencord Settings">
<SettingsTab title="Equicord Settings">
<DonateCard image={donateImage} />
<Forms.FormSection title="Quick Actions">
<Card className={cl("quick-actions-card")}>
@ -327,4 +327,4 @@ function DonateCard({ image }: DonateCardProps) {
);
}
export default wrapTab(VencordSettings, "Vencord Settings");
export default wrapTab(VencordSettings, "Equicord Settings");

View file

@ -17,7 +17,6 @@
*/
import "./settingsStyles.css";
import "./themesStyles.css";
import ErrorBoundary from "@components/ErrorBoundary";
import { handleComponentFailed } from "@components/handleComponentFailed";

View file

@ -27,3 +27,21 @@
.vc-settings-theme-author::before {
content: "by ";
}
.vc-settings-theme-add-theme-content {
margin: 10px 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.vc-settings-theme-add-theme-footer {
display: flex;
flex-direction: row;
gap: 10px;
justify-content: flex-end;
}
.vc-settings-theme-add-theme-error {
color: var(--text-danger);
}