mirror of
https://github.com/Equicord/Equicord.git
synced 2025-06-15 17:43:08 -04:00
forked!!
This commit is contained in:
parent
538b87062a
commit
ea7451bcdc
326 changed files with 24876 additions and 2280 deletions
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
258
src/components/ThemeSettings/OnlineThemes.tsx
Normal file
258
src/components/ThemeSettings/OnlineThemes.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
431
src/components/ThemeSettings/ThemesTab.tsx
Normal file
431
src/components/ThemeSettings/ThemesTab.tsx
Normal 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");
|
114
src/components/ThemeSettings/UserCSSModal.tsx
Normal file
114
src/components/ThemeSettings/UserCSSModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
19
src/components/ThemeSettings/components/colorStyles.css
Normal file
19
src/components/ThemeSettings/components/colorStyles.css
Normal 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;
|
||||
}
|
12
src/components/ThemeSettings/components/index.ts
Normal file
12
src/components/ThemeSettings/components/index.ts
Normal 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";
|
47
src/components/ThemeSettings/themesStyles.css
Normal file
47
src/components/ThemeSettings/themesStyles.css
Normal 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);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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");
|
|
@ -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
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -17,7 +17,6 @@
|
|||
*/
|
||||
|
||||
import "./settingsStyles.css";
|
||||
import "./themesStyles.css";
|
||||
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { handleComponentFailed } from "@components/handleComponentFailed";
|
||||
|
|
|
@ -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);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue