Fully Nuke Usercss
This commit is contained in:
parent
f680c4db14
commit
9296cec693
34 changed files with 938 additions and 2027 deletions
|
|
@ -45,7 +45,7 @@ window.VencordNative = {
|
|||
deleteTheme: (fileName: string) => DataStore.del(fileName, themeStore),
|
||||
getThemesDir: async () => "",
|
||||
getThemesList: () => DataStore.entries(themeStore).then(entries =>
|
||||
entries.map(([name, css]) => ({ fileName: name as string, content: css }))
|
||||
entries.map(([name, css]) => getThemeInfo(css, name.toString()))
|
||||
),
|
||||
getThemeData: (fileName: string) => DataStore.get(fileName, themeStore),
|
||||
getSystemValues: async () => ({}),
|
||||
|
|
|
|||
|
|
@ -47,9 +47,6 @@
|
|||
"@intrnl/xxhash64": "^0.1.2",
|
||||
"@song-spotlight/api": "^2.2.0",
|
||||
"@streamparser/json": "^0.0.22",
|
||||
"@types/less": "^3.0.8",
|
||||
"@types/stylus": "^0.48.42",
|
||||
"@types/tinycolor2": "^1.4.6",
|
||||
"@vap/core": "0.0.12",
|
||||
"@vap/shiki": "0.10.5",
|
||||
"fflate": "^0.8.3",
|
||||
|
|
@ -60,7 +57,6 @@
|
|||
"monaco-editor": "^0.55.1",
|
||||
"nanoid": "^5.1.16",
|
||||
"native-file-system-adapter": "^3.0.1",
|
||||
"usercss-meta": "^0.12.0",
|
||||
"virtual-merge": "^1.0.1",
|
||||
"yaml-js": "^0.3.1"
|
||||
},
|
||||
|
|
@ -101,7 +97,7 @@
|
|||
"typescript-transform-paths": "^3.5.6",
|
||||
"zip-local": "^0.3.5"
|
||||
},
|
||||
"packageManager": "pnpm@11.10.0",
|
||||
"packageManager": "pnpm@11.12.0",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
diff --git a/index.d.ts b/index.d.ts
|
||||
index eb4f07d47b932fb9cc8c8cd451ab107f648bd013..18a3e15a1997734e1773718e5be55d252ed9478c 100644
|
||||
--- a/index.d.ts
|
||||
+++ b/index.d.ts
|
||||
@@ -306,7 +306,5 @@ interface LessStatic {
|
||||
}
|
||||
|
||||
declare module "less" {
|
||||
- export = less;
|
||||
+ export = LessStatic;
|
||||
}
|
||||
-
|
||||
-declare var less: LessStatic;
|
||||
50
pnpm-lock.yaml
generated
50
pnpm-lock.yaml
generated
|
|
@ -4,9 +4,6 @@ settings:
|
|||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
patchedDependencies:
|
||||
'@types/less@3.0.8': 641e6c93bb737bac7fc283416857bd095cd85bcbcba63becb7a8bbcc78f73076
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
|
|
@ -26,15 +23,6 @@ importers:
|
|||
'@streamparser/json':
|
||||
specifier: ^0.0.22
|
||||
version: 0.0.22
|
||||
'@types/less':
|
||||
specifier: ^3.0.8
|
||||
version: 3.0.8(patch_hash=641e6c93bb737bac7fc283416857bd095cd85bcbcba63becb7a8bbcc78f73076)
|
||||
'@types/stylus':
|
||||
specifier: ^0.48.42
|
||||
version: 0.48.42
|
||||
'@types/tinycolor2':
|
||||
specifier: ^1.4.6
|
||||
version: 1.4.6
|
||||
'@vap/core':
|
||||
specifier: 0.0.12
|
||||
version: 0.0.12
|
||||
|
|
@ -65,9 +53,6 @@ importers:
|
|||
native-file-system-adapter:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
usercss-meta:
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.0
|
||||
virtual-merge:
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.2
|
||||
|
|
@ -622,9 +607,6 @@ packages:
|
|||
'@types/jsonfile@6.1.4':
|
||||
resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==}
|
||||
|
||||
'@types/less@3.0.8':
|
||||
resolution: {integrity: sha512-Gjm4+H9noDJgu5EdT3rUw5MhPBag46fiOy27BefvWkNL8mlZnKnCaVVVTLKj6RYXed9b62CPKnPav9govyQDzA==}
|
||||
|
||||
'@types/lodash@4.17.19':
|
||||
resolution: {integrity: sha512-NYqRyg/hIQrYPT9lbOeYc3kIRabJDn/k4qQHIXUpx88CBDww2fD15Sg5kbXlW86zm2XEW4g0QxkTI3/Kfkc7xQ==}
|
||||
|
||||
|
|
@ -634,9 +616,6 @@ packages:
|
|||
'@types/node@22.20.0':
|
||||
resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==}
|
||||
|
||||
'@types/node@25.6.0':
|
||||
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
|
||||
|
||||
'@types/node@26.0.1':
|
||||
resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==}
|
||||
|
||||
|
|
@ -656,12 +635,6 @@ packages:
|
|||
'@types/react@19.1.0':
|
||||
resolution: {integrity: sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==}
|
||||
|
||||
'@types/stylus@0.48.42':
|
||||
resolution: {integrity: sha512-CPGlr5teL4sqdap+EOowMifLuNGeIoLwc0VQ7u/BPxo+ocqiNa5jeVt0H0IVBblEh6ZwX1sGpIQIFnSSr8NBQA==}
|
||||
|
||||
'@types/tinycolor2@1.4.6':
|
||||
resolution: {integrity: sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
|
|
@ -2341,9 +2314,6 @@ packages:
|
|||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
undici-types@7.19.2:
|
||||
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
|
||||
|
||||
undici-types@8.3.0:
|
||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||
|
||||
|
|
@ -2358,10 +2328,6 @@ packages:
|
|||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
usercss-meta@0.12.0:
|
||||
resolution: {integrity: sha512-zKrXCKdpeIwtVe87omxGo9URf+7mbozduMZEg79dmT4KB3XJwfIkEi/Uk0PcTwR/nZLtAK1+k7isgbGB/g6E7Q==}
|
||||
engines: {node: '>=8.3'}
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
|
|
@ -2761,8 +2727,6 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/node': 26.0.1
|
||||
|
||||
'@types/less@3.0.8(patch_hash=641e6c93bb737bac7fc283416857bd095cd85bcbcba63becb7a8bbcc78f73076)': {}
|
||||
|
||||
'@types/lodash@4.17.19': {}
|
||||
|
||||
'@types/lodash@4.17.24': {}
|
||||
|
|
@ -2771,10 +2735,6 @@ snapshots:
|
|||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@25.6.0':
|
||||
dependencies:
|
||||
undici-types: 7.19.2
|
||||
|
||||
'@types/node@26.0.1':
|
||||
dependencies:
|
||||
undici-types: 8.3.0
|
||||
|
|
@ -2795,12 +2755,6 @@ snapshots:
|
|||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/stylus@0.48.42':
|
||||
dependencies:
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/tinycolor2@1.4.6': {}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
|
|
@ -4929,8 +4883,6 @@ snapshots:
|
|||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici-types@7.19.2: {}
|
||||
|
||||
undici-types@8.3.0: {}
|
||||
|
||||
unicorn-magic@0.4.0: {}
|
||||
|
|
@ -4941,8 +4893,6 @@ snapshots:
|
|||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
usercss-meta@0.12.0: {}
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
virtual-merge@1.0.2: {}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
packages:
|
||||
- packages/*
|
||||
patchedDependencies:
|
||||
"@types/less@3.0.8": patches/@types__less@3.0.8.patch
|
||||
peerDependencyRules:
|
||||
ignoreMissing:
|
||||
- eslint-plugin-import
|
||||
|
|
@ -18,4 +16,4 @@ allowBuilds:
|
|||
pmOnFail: warn
|
||||
verifyDepsBeforeRun: prompt
|
||||
minimumReleaseAgeExclude:
|
||||
- '@song-spotlight/api@2.2.0'
|
||||
- "@song-spotlight/api@2.2.0"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import type { Settings } from "@api/Settings";
|
||||
import type { CspRequestResult } from "@main/csp/manager";
|
||||
import type { PluginIpcMappings } from "@main/ipcPlugins";
|
||||
import { UserThemeHeader } from "@main/themes";
|
||||
import { IpcEvents } from "@shared/IpcEvents";
|
||||
import type { IpcRes } from "@utils/types";
|
||||
import { ipcRenderer } from "electron/renderer";
|
||||
|
|
@ -34,9 +35,10 @@ export default {
|
|||
uploadTheme: async (fileName: string, fileData: string): Promise<void> => {
|
||||
throw new Error("uploadTheme is WEB only");
|
||||
},
|
||||
deleteTheme: (fileName: string) => invoke<void>(IpcEvents.DELETE_THEME, fileName),
|
||||
getThemesDir: () => invoke<string>(IpcEvents.GET_THEMES_DIR),
|
||||
getThemesList: () => invoke<Array<{ fileName: string; content: string; }>>(IpcEvents.GET_THEMES_LIST),
|
||||
deleteTheme: async (fileName: string): Promise<void> => {
|
||||
throw new Error("deleteTheme is WEB only");
|
||||
},
|
||||
getThemesList: () => invoke<Array<UserThemeHeader>>(IpcEvents.GET_THEMES_LIST),
|
||||
getThemeData: (fileName: string) => invoke<string | undefined>(IpcEvents.GET_THEME_DATA, fileName),
|
||||
getSystemValues: () => invoke<Record<string, string>>(IpcEvents.GET_THEME_SYSTEM_VALUES),
|
||||
|
||||
|
|
|
|||
|
|
@ -101,12 +101,6 @@ export interface Settings {
|
|||
};
|
||||
|
||||
ignoreResetWarning: boolean;
|
||||
|
||||
userCssVars: {
|
||||
[themeId: string]: {
|
||||
[varName: string]: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const DefaultSettings: Settings = {
|
||||
|
|
@ -153,8 +147,6 @@ const DefaultSettings: Settings = {
|
|||
},
|
||||
|
||||
ignoreResetWarning: false,
|
||||
|
||||
userCssVars: {}
|
||||
};
|
||||
|
||||
const settings = !IS_REPORTER ? VencordNative.settings.get() : {} as Settings;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,207 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Settings, useSettings } from "@api/Settings";
|
||||
import { BaseText } from "@components/BaseText";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { CopyIcon, PasteIcon, ResetIcon } from "@components/Icons";
|
||||
import { copyWithToast } from "@utils/discord";
|
||||
import { RenderModalProps } from "@vencord/discord-types";
|
||||
import { Modal, showToast, Toasts, Tooltip } 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: RenderModalProps;
|
||||
theme: UserstyleHeader;
|
||||
onSettingsReset: () => void;
|
||||
}
|
||||
|
||||
function ExportButton({ themeSettings }: { themeSettings: Settings["userCssVars"][""]; }) {
|
||||
return <Tooltip text={"Copy theme settings"}>
|
||||
{({ onMouseLeave, onMouseEnter }) => (
|
||||
<div
|
||||
style={{ cursor: "pointer" }}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
|
||||
onClick={() => {
|
||||
copyWithToast(JSON.stringify(themeSettings), "Copied theme settings to clipboard.");
|
||||
}}>
|
||||
<CopyIcon />
|
||||
</div>
|
||||
)}
|
||||
</Tooltip>;
|
||||
}
|
||||
|
||||
function ImportButton({ themeSettings }: { themeSettings: Settings["userCssVars"][""]; }) {
|
||||
return <Tooltip text={"Paste theme settings"}>
|
||||
{({ onMouseLeave, onMouseEnter }) => (
|
||||
<div
|
||||
style={{ cursor: "pointer" }}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
|
||||
onClick={async () => {
|
||||
const clip = (await navigator.clipboard.read())[0];
|
||||
|
||||
if (!clip) return showToast("Your clipboard is empty.", Toasts.Type.FAILURE);
|
||||
|
||||
if (!clip.types.includes("text/plain"))
|
||||
return showToast("Your clipboard doesn't have valid settings data.", Toasts.Type.FAILURE);
|
||||
|
||||
try {
|
||||
var potentialSettings: Record<string, string> =
|
||||
JSON.parse(await clip.getType("text/plain").then(b => b.text()));
|
||||
} catch (e) {
|
||||
return showToast("Your clipboard doesn't have valid settings data.", Toasts.Type.FAILURE);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(potentialSettings)) {
|
||||
if (Object.prototype.hasOwnProperty.call(themeSettings, key))
|
||||
themeSettings[key] = value;
|
||||
}
|
||||
|
||||
showToast("Pasted theme settings from clipboard.", Toasts.Type.SUCCESS);
|
||||
}}>
|
||||
<PasteIcon />
|
||||
</div>
|
||||
)}
|
||||
</Tooltip>;
|
||||
}
|
||||
|
||||
interface ResetButtonProps {
|
||||
themeSettings: Settings["userCssVars"];
|
||||
themeId: string;
|
||||
close: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
function ResetButton({ themeSettings, themeId, close, onReset }: ResetButtonProps) {
|
||||
return <Tooltip text={"Reset settings to default"}>
|
||||
{({ onMouseLeave, onMouseEnter }) => (
|
||||
<div
|
||||
style={{ cursor: "pointer" }}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
|
||||
onClick={() => {
|
||||
close(); // close the modal first to stop rendering
|
||||
delete themeSettings[themeId];
|
||||
onReset();
|
||||
}}>
|
||||
<ResetIcon />
|
||||
</div>
|
||||
)}
|
||||
</Tooltip>;
|
||||
}
|
||||
|
||||
export function UserCSSSettingsModal({ modalProps, theme, onSettingsReset }: UserCSSSettingsModalProps) {
|
||||
// @ts-expect-error UseSettings<> can't determine this is a valid key
|
||||
const { userCssVars } = useSettings(["userCssVars"], false);
|
||||
|
||||
const themeVars = 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={themeVars}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "checkbox": {
|
||||
controls.push(
|
||||
<SettingBooleanComponent
|
||||
label={varInfo.label}
|
||||
name={name}
|
||||
themeSettings={themeVars}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "color": {
|
||||
controls.push(
|
||||
<SettingColorComponent
|
||||
label={varInfo.label}
|
||||
name={name}
|
||||
themeSettings={themeVars}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "number": {
|
||||
controls.push(
|
||||
<SettingNumberComponent
|
||||
label={varInfo.label}
|
||||
name={name}
|
||||
themeSettings={themeVars}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "select": {
|
||||
controls.push(
|
||||
<SettingSelectComponent
|
||||
label={varInfo.label}
|
||||
name={name}
|
||||
options={varInfo.options}
|
||||
default={varInfo.default}
|
||||
themeSettings={themeVars}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "range": {
|
||||
controls.push(
|
||||
<SettingRangeComponent
|
||||
label={varInfo.label}
|
||||
name={name}
|
||||
default={varInfo.default}
|
||||
min={varInfo.min}
|
||||
max={varInfo.max}
|
||||
step={varInfo.step}
|
||||
themeSettings={themeVars}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
{...modalProps}
|
||||
title={
|
||||
<Flex style={{ alignItems: "center", gap: 4 }}>
|
||||
<BaseText size="lg" weight="semibold" style={{ flexGrow: 1 }}>Settings for {theme.name}</BaseText>
|
||||
<Flex style={{ gap: 4, marginRight: 4 }} className="vc-settings-usercss-ie-buttons">
|
||||
<ExportButton themeSettings={themeVars} />
|
||||
<ImportButton themeSettings={themeVars} />
|
||||
<ResetButton themeSettings={userCssVars} themeId={theme.id} close={modalProps.onClose} onReset={onSettingsReset} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<Flex flexDirection="column" style={{ gap: 12, marginBottom: 16 }}>
|
||||
{controls}
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { FormSwitch } from "@components/FormSwitch";
|
||||
import { classNameFactory } from "@utils/css";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
name: string;
|
||||
themeSettings: Record<string, string>;
|
||||
}
|
||||
|
||||
const cl = classNameFactory("vc-settings-boolean-");
|
||||
|
||||
export function SettingBooleanComponent({ label, name, themeSettings }: Props) {
|
||||
function handleChange(value: boolean) {
|
||||
const corrected = value ? "1" : "0";
|
||||
|
||||
themeSettings[name] = corrected;
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<FormSwitch
|
||||
title={label}
|
||||
key={name}
|
||||
className={cl("switch")}
|
||||
value={themeSettings[name] === "1"}
|
||||
onChange={handleChange}
|
||||
hideBorder
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
/*
|
||||
* 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 "@utils/css";
|
||||
import { findByCodeLazy } from "@webpack";
|
||||
import { ColorPicker, useMemo } from "@webpack/common";
|
||||
import type tinycolor from "tinycolor2";
|
||||
|
||||
// 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) {
|
||||
function handleChange(value: number) {
|
||||
const corrected = "#" + (value?.toString(16).padStart(6, "0") ?? "000000");
|
||||
|
||||
themeSettings[name] = corrected;
|
||||
}
|
||||
|
||||
const normalizedValue = useMemo(() => parseInt(TinyColor(themeSettings[name]).toHex(), 16), [themeSettings[name]]);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className={cl("swatch-row")}>
|
||||
<span>{label}</span>
|
||||
<ColorPicker
|
||||
key={name}
|
||||
color={normalizedValue}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Heading } from "@components/Heading";
|
||||
import { TextInput } from "@webpack/common";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
name: string;
|
||||
themeSettings: Record<string, string>;
|
||||
}
|
||||
|
||||
export function SettingNumberComponent({ label, name, themeSettings }: Props) {
|
||||
function handleChange(value: string) {
|
||||
themeSettings[name] = value;
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Heading>{label}</Heading>
|
||||
<TextInput
|
||||
type="number"
|
||||
pattern="-?[0-9]+"
|
||||
key={name}
|
||||
value={themeSettings[name]}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Heading } from "@components/Heading";
|
||||
import { Slider, useMemo } 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) {
|
||||
function handleChange(value: number) {
|
||||
const corrected = value.toString();
|
||||
|
||||
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 (
|
||||
<section>
|
||||
<Heading>{label}</Heading>
|
||||
<Slider
|
||||
initialValue={parseInt(themeSettings[name], 10)}
|
||||
defaultValue={def}
|
||||
onValueChange={handleChange}
|
||||
minValue={min}
|
||||
maxValue={max}
|
||||
|
||||
markers={markers}
|
||||
stickToMarkers={true}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Heading } from "@components/Heading";
|
||||
import { identity } from "@utils/misc";
|
||||
import { SelectOption } from "@vencord/discord-types";
|
||||
import { Select, useMemo } 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) {
|
||||
function handleChange(value: string) {
|
||||
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 SelectOption)), [options, def]);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Heading>{label}</Heading>
|
||||
<Select
|
||||
placeholder={label}
|
||||
key={name}
|
||||
options={opts}
|
||||
closeOnSelect={true}
|
||||
|
||||
select={handleChange}
|
||||
isSelected={v => v === themeSettings[name]}
|
||||
serialize={identity}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Heading } from "@components/Heading";
|
||||
import { TextInput } from "@webpack/common";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
name: string;
|
||||
themeSettings: Record<string, string>;
|
||||
}
|
||||
|
||||
export function SettingTextComponent({ label, name, themeSettings }: Props) {
|
||||
function handleChange(value: string) {
|
||||
themeSettings[name] = value;
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Heading>{label}</Heading>
|
||||
<TextInput
|
||||
key={name}
|
||||
value={themeSettings[name]}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
.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(--text-strong);
|
||||
line-height: 24px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
/*
|
||||
* 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";
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
.vc-settings-boolean-switch {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@
|
|||
|
||||
import "./styles.css";
|
||||
|
||||
export { default as ThemesTab } from "../../ThemeSettings/ThemesTab";
|
||||
export * from "./BaseTab";
|
||||
export { default as ChangelogTab } from "./changelog";
|
||||
export { default as PatchHelperTab } from "./patchHelper";
|
||||
|
|
@ -15,5 +14,6 @@ export { openContributorModal } from "./plugins/ContributorModal";
|
|||
export { openPluginModal } from "./plugins/PluginModal";
|
||||
export { default as BackupAndRestoreTab } from "./sync/BackupAndRestoreTab";
|
||||
export { default as CloudTab } from "./sync/CloudTab";
|
||||
export { default as ThemesTab } from "./themes";
|
||||
export { default as UpdaterTab } from "./updater";
|
||||
export { default as VencordTab } from "./vencord";
|
||||
|
|
|
|||
98
src/components/settings/tabs/themes/OnlineThemes.tsx
Normal file
98
src/components/settings/tabs/themes/OnlineThemes.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Button } from "@components/Button";
|
||||
import { FormSwitch } from "@components/FormSwitch";
|
||||
import { Heading } from "@components/Heading";
|
||||
import { Link } from "@components/Link";
|
||||
import { Notice } from "@components/Notice";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { classNameFactory } from "@utils/css";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { useAwaiter } from "@utils/react";
|
||||
import { React, TextInput } from "@webpack/common";
|
||||
|
||||
const cl = classNameFactory("vc-settings-theme-");
|
||||
|
||||
export function Validator({ link, onValidate }: { link: string; onValidate: (valid: boolean) => void; }) {
|
||||
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")) {
|
||||
onValidate(false);
|
||||
throw "Not a CSS file. Remember to use the raw link!";
|
||||
}
|
||||
|
||||
onValidate(true);
|
||||
return "Okay!";
|
||||
}));
|
||||
|
||||
const text = pending
|
||||
? "Checking..."
|
||||
: err
|
||||
? `Error: ${err instanceof Error ? err.message : String(err)}`
|
||||
: "Valid!";
|
||||
|
||||
return <Paragraph style={{
|
||||
color: pending ? "var(--text-muted)" : err ? "var(--text-feedback-critical)" : "var(--status-positive)"
|
||||
}}>{text}</Paragraph>;
|
||||
}
|
||||
|
||||
export interface OnlineThemesSectionProps {
|
||||
enableOnlineThemes: boolean;
|
||||
setEnableOnlineThemes: (value: boolean) => void;
|
||||
currentThemeLink: string;
|
||||
setCurrentThemeLink: (value: string) => void;
|
||||
themeLinkValid: boolean;
|
||||
setThemeLinkValid: (value: boolean) => void;
|
||||
addThemeLink: (link: string) => void;
|
||||
}
|
||||
|
||||
export function OnlineThemesSection({
|
||||
enableOnlineThemes,
|
||||
setEnableOnlineThemes,
|
||||
currentThemeLink,
|
||||
setCurrentThemeLink,
|
||||
themeLinkValid,
|
||||
setThemeLinkValid,
|
||||
addThemeLink
|
||||
}: OnlineThemesSectionProps) {
|
||||
return (
|
||||
<>
|
||||
<Heading className={Margins.top20}>Online Themes</Heading>
|
||||
<Paragraph className={Margins.bottom16}>
|
||||
Load themes directly from URLs instead of local files. Online themes auto-update when the source changes, so you always have the latest version without manual downloads.
|
||||
</Paragraph>
|
||||
<FormSwitch
|
||||
title="Enable Online Themes"
|
||||
description="Toggle online theme loading. When disabled, all online themes will be turned off and you won't be able to add new ones."
|
||||
value={enableOnlineThemes}
|
||||
onChange={setEnableOnlineThemes}
|
||||
/>
|
||||
|
||||
<Notice.Info className={Margins.bottom16} style={{ width: "100%" }}>
|
||||
Looking for themes? Check out <Link href="https://betterdiscord.app/themes">BetterDiscord Themes</Link> or search on <Link href="https://github.com/search?q=discord+theme">GitHub</Link>. When downloading from BetterDiscord, click "Download" and place the .theme.css file into your themes folder.
|
||||
</Notice.Info>
|
||||
|
||||
<div className={cl("link-row")}>
|
||||
<TextInput
|
||||
placeholder="https://example.com/theme.css"
|
||||
value={currentThemeLink}
|
||||
onChange={setCurrentThemeLink}
|
||||
disabled={!enableOnlineThemes}
|
||||
/>
|
||||
<Button onClick={() => addThemeLink(currentThemeLink)} disabled={!themeLinkValid || !enableOnlineThemes}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{currentThemeLink && (
|
||||
<div className={Margins.top8}>
|
||||
<Validator link={currentThemeLink} onValidate={setThemeLinkValid} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
76
src/components/settings/tabs/themes/QuickActions.tsx
Normal file
76
src/components/settings/tabs/themes/QuickActions.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Settings } from "@api/Settings";
|
||||
import { FolderIcon, PaintbrushIcon, PencilIcon, PlusIcon, RestartIcon } from "@components/Icons";
|
||||
import { QuickAction, QuickActionCard } from "@components/settings";
|
||||
import { openPluginModal } from "@components/settings/tabs/plugins/PluginModal";
|
||||
import { findLazy } from "@webpack";
|
||||
import { React } from "@webpack/common";
|
||||
import type { ComponentType, Ref, SyntheticEvent } from "react";
|
||||
|
||||
import Plugins from "~plugins";
|
||||
|
||||
type FileInputType = ComponentType<{
|
||||
ref: Ref<HTMLInputElement>;
|
||||
onChange: (e: SyntheticEvent<HTMLInputElement>) => void;
|
||||
multiple?: boolean;
|
||||
filters?: { name?: string; extensions: string[]; }[];
|
||||
}>;
|
||||
|
||||
const FileInput: FileInputType = findLazy(m => m.prototype?.activateUploadDialogue && m.prototype.setRef);
|
||||
|
||||
export interface QuickActionsSectionProps {
|
||||
fileInputRef: any;
|
||||
onFileUpload: (e: SyntheticEvent<HTMLInputElement>) => void;
|
||||
refreshLocalThemes: () => void;
|
||||
}
|
||||
|
||||
export function QuickActionsSection({ fileInputRef, onFileUpload, refreshLocalThemes }: QuickActionsSectionProps) {
|
||||
return (
|
||||
<QuickActionCard>
|
||||
{IS_WEB ? (
|
||||
<QuickAction
|
||||
text={
|
||||
<span style={{ position: "relative" }}>
|
||||
Upload Theme
|
||||
<FileInput
|
||||
ref={fileInputRef}
|
||||
onChange={onFileUpload}
|
||||
multiple={true}
|
||||
filters={[{ extensions: ["css"] }]}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
Icon={PlusIcon}
|
||||
/>
|
||||
) : (
|
||||
<QuickAction
|
||||
text="Open Themes Folder"
|
||||
action={() => VencordNative.themes.openFolder()}
|
||||
Icon={FolderIcon}
|
||||
/>
|
||||
)}
|
||||
<QuickAction
|
||||
text="Load missing Themes"
|
||||
action={refreshLocalThemes}
|
||||
Icon={RestartIcon}
|
||||
/>
|
||||
<QuickAction
|
||||
text="Edit QuickCSS"
|
||||
action={() => VencordNative.quickCss.openEditor()}
|
||||
Icon={PaintbrushIcon}
|
||||
/>
|
||||
{Settings.plugins.ClientTheme.enabled && (
|
||||
<QuickAction
|
||||
text="Edit ClientTheme"
|
||||
action={() => openPluginModal(Plugins.ClientTheme)}
|
||||
Icon={PencilIcon}
|
||||
/>
|
||||
)}
|
||||
</QuickActionCard>
|
||||
);
|
||||
}
|
||||
267
src/components/settings/tabs/themes/ThemeCard.tsx
Normal file
267
src/components/settings/tabs/themes/ThemeCard.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { ThemeActivationMode } from "@api/Settings";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { CogWheel, DeleteIcon, FolderIcon } from "@components/Icons";
|
||||
import { Link } from "@components/Link";
|
||||
import { OnlineThemeCard } from "@components/settings/OnlineThemeCard";
|
||||
import { UserThemeHeader } from "@main/themes";
|
||||
import { classNameFactory } from "@utils/css";
|
||||
import { openInviteModal } from "@utils/discord";
|
||||
import { findComponentByCodeLazy } from "@webpack";
|
||||
import { Menu, React, showToast, Tooltip, useState } from "@webpack/common";
|
||||
import { ContextMenuApi } from "@webpack/common/menu";
|
||||
|
||||
const PinIcon = findComponentByCodeLazy("1-.06-.63L6.16");
|
||||
const HomeIcon = findComponentByCodeLazy("m2.4 8.4 8.38-6.46a2");
|
||||
const RefreshIcon = findComponentByCodeLazy("M21 2a1 1 0 0 1 1 1v6");
|
||||
const LinkIcon = findComponentByCodeLazy("M16.32 14.72a1 1 0 0 1 0-1.41l2.51-2.51");
|
||||
const DiscordIcon = findComponentByCodeLazy("1.6 5.64-2.87");
|
||||
const DownloadIcon = findComponentByCodeLazy("1.42l3.3 3.3V3a1");
|
||||
|
||||
const cl = classNameFactory("vc-settings-theme-");
|
||||
|
||||
const themeActivationModeOptions: { value: ThemeActivationMode; label: string; }[] = [
|
||||
{ value: "always", label: "Always on" },
|
||||
{ value: "light", label: "Light only" },
|
||||
{ value: "dark", label: "Dark only" }
|
||||
];
|
||||
|
||||
export function getThemeActivationModeLabel(mode: ThemeActivationMode) {
|
||||
return themeActivationModeOptions.find(option => option.value === mode)?.label ?? "Always on";
|
||||
}
|
||||
|
||||
export function ThemeActivationMenu({ themeId, activationMode, onActivationModeChange, children }: {
|
||||
themeId: string;
|
||||
activationMode: ThemeActivationMode;
|
||||
onActivationModeChange?: (mode: ThemeActivationMode) => void;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const [selectedMode, setSelectedMode] = useState(activationMode);
|
||||
|
||||
return (
|
||||
<Menu.Menu navId={`theme-card-menu-${themeId}`} onClose={ContextMenuApi.closeContextMenu}>
|
||||
{onActivationModeChange && (
|
||||
<Menu.MenuItem id={`theme-activation-${themeId}`} label="Theme activation">
|
||||
{themeActivationModeOptions.map(option => (
|
||||
<Menu.MenuRadioItem
|
||||
key={option.value}
|
||||
id={`theme-activation-${themeId}-${option.value}`}
|
||||
group={`theme-activation-${themeId}`}
|
||||
label={option.label}
|
||||
checked={selectedMode === option.value}
|
||||
action={() => {
|
||||
setSelectedMode(option.value);
|
||||
onActivationModeChange(option.value);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Menu.MenuItem>
|
||||
)}
|
||||
{children}
|
||||
</Menu.Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocalThemeIcon({ size }: { size?: string; }) {
|
||||
const sizeVal = size === "sm" ? 16 : 24;
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={sizeVal} height={sizeVal} fill="currentColor">
|
||||
<path d="M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function OnlineThemeIcon({ size }: { size?: string; }) {
|
||||
const sizeVal = size === "sm" ? 16 : 24;
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={sizeVal} height={sizeVal} fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ThemeCardProps {
|
||||
theme: UserThemeHeader;
|
||||
enabled: boolean;
|
||||
onChange: (enabled: boolean) => void;
|
||||
onDelete?: () => void;
|
||||
showDeleteButton?: boolean;
|
||||
onEditName?: (newName: string) => void;
|
||||
disabled?: boolean;
|
||||
onPin?: () => void;
|
||||
isPinned?: boolean;
|
||||
onRefresh?: () => void;
|
||||
onOpenFolder?: () => void;
|
||||
onCopyUrl?: () => void;
|
||||
onDownload?: () => void;
|
||||
themeLink?: string;
|
||||
isLocal?: boolean;
|
||||
activationMode?: ThemeActivationMode;
|
||||
onActivationModeChange?: (mode: ThemeActivationMode) => void;
|
||||
}
|
||||
|
||||
export function ThemeCard({ theme, enabled, onChange, onDelete, showDeleteButton, onEditName, disabled, onPin, isPinned, onRefresh, onOpenFolder, onCopyUrl, onDownload, themeLink, isLocal, activationMode = "always", onActivationModeChange }: ThemeCardProps) {
|
||||
const openThemeMenu = (e: React.MouseEvent) => {
|
||||
ContextMenuApi.openContextMenu(e, () => (
|
||||
<ThemeActivationMenu
|
||||
themeId={themeLink ?? theme.fileName}
|
||||
activationMode={activationMode}
|
||||
onActivationModeChange={onActivationModeChange}
|
||||
>
|
||||
{onPin && (
|
||||
<Menu.MenuItem
|
||||
id="pin-theme"
|
||||
label={isPinned ? "Unpin" : "Pin"}
|
||||
icon={PinIcon}
|
||||
action={onPin}
|
||||
/>
|
||||
)}
|
||||
{theme.website && (
|
||||
<Menu.MenuItem
|
||||
id="open-website"
|
||||
label="Open Website"
|
||||
icon={HomeIcon}
|
||||
action={() => window.open(theme.website, "_blank")}
|
||||
/>
|
||||
)}
|
||||
{theme.invite && (
|
||||
<Menu.MenuItem
|
||||
id="join-discord"
|
||||
label="Join Discord"
|
||||
icon={DiscordIcon}
|
||||
action={() => {
|
||||
openInviteModal(theme.invite!).catch(() =>
|
||||
showToast("Invalid or expired invite")
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{onCopyUrl && themeLink && (
|
||||
<Menu.MenuItem
|
||||
id="copy-url"
|
||||
label="Copy URL"
|
||||
icon={LinkIcon}
|
||||
action={onCopyUrl}
|
||||
/>
|
||||
)}
|
||||
{onDownload && (
|
||||
<Menu.MenuItem
|
||||
id="download-theme"
|
||||
label="Download"
|
||||
icon={DownloadIcon}
|
||||
action={onDownload}
|
||||
/>
|
||||
)}
|
||||
{onOpenFolder && (
|
||||
<Menu.MenuItem
|
||||
id="open-folder"
|
||||
label="Open in Folder"
|
||||
icon={FolderIcon}
|
||||
action={onOpenFolder}
|
||||
/>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<Menu.MenuItem
|
||||
id="refresh-theme"
|
||||
label="Refresh"
|
||||
icon={RefreshIcon}
|
||||
action={onRefresh}
|
||||
/>
|
||||
)}
|
||||
{(IS_WEB || showDeleteButton) && onDelete && (
|
||||
<>
|
||||
<Menu.MenuSeparator />
|
||||
<Menu.MenuItem
|
||||
id="delete-theme"
|
||||
label="Delete"
|
||||
color="danger"
|
||||
icon={DeleteIcon}
|
||||
action={() => onDelete()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ThemeActivationMenu>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<OnlineThemeCard
|
||||
customName={theme.customName}
|
||||
name={theme.name || theme.fileName || "Unknown Theme"}
|
||||
description={theme.description || "No description provided."}
|
||||
author={theme.author || "Unknown"}
|
||||
enabled={enabled}
|
||||
setEnabled={onChange}
|
||||
disabled={disabled}
|
||||
infoButton={
|
||||
(IS_WEB || showDeleteButton || onPin) && (
|
||||
<div
|
||||
className={cl("menu-button")}
|
||||
onClick={openThemeMenu}
|
||||
>
|
||||
<CogWheel />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
footer={
|
||||
<Flex flexDirection="row" gap="0.4em" alignItems="center">
|
||||
<Tooltip text={isLocal ? "Local Theme" : "Online Theme"}>
|
||||
{({ onMouseLeave, onMouseEnter }) => (
|
||||
<div
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
style={{ color: "var(--text-muted)", display: "flex" }}
|
||||
>
|
||||
{isLocal ? <LocalThemeIcon size="sm" /> : <OnlineThemeIcon size="sm" />}
|
||||
</div>
|
||||
)}
|
||||
</Tooltip>
|
||||
{isPinned && (
|
||||
<Tooltip text="Pinned">
|
||||
{({ onMouseLeave, onMouseEnter }) => (
|
||||
<div
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className={cl("footer-pin-icon")}
|
||||
>
|
||||
<PinIcon size="xs" />
|
||||
</div>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
{!!theme.website && <Link href={theme.website}>Website</Link>}
|
||||
{!!(theme.website && theme.invite) && (
|
||||
<span style={{ color: "var(--text-muted)" }}>•</span>
|
||||
)}
|
||||
{!!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>
|
||||
)}
|
||||
{activationMode !== "always" && (
|
||||
<>
|
||||
{!!(theme.website || theme.invite) && <span style={{ color: "var(--text-muted)" }}>•</span>}
|
||||
<span style={{ color: "var(--text-muted)" }}>{getThemeActivationModeLabel(activationMode)}</span>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
|
||||
onEditName={onEditName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
484
src/components/settings/tabs/themes/index.tsx
Normal file
484
src/components/settings/tabs/themes/index.tsx
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2026 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { Settings, type ThemeActivationMode, useSettings } from "@api/Settings";
|
||||
import { Divider } from "@components/Divider";
|
||||
import { Heading } from "@components/Heading";
|
||||
import { Link } from "@components/Link";
|
||||
import { Paragraph } from "@components/Paragraph";
|
||||
import { SettingsTab, wrapTab } from "@components/settings/tabs/BaseTab";
|
||||
import { getThemeInfo, UserThemeHeader } from "@main/themes";
|
||||
import { classNameFactory } from "@utils/css";
|
||||
import { copyWithToast } from "@utils/discord";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { classes } from "@utils/misc";
|
||||
import { getStylusWebStoreUrl } from "@utils/web";
|
||||
import { React, Select, showToast, TextInput, Toasts, useEffect, useMemo, useRef, useState } from "@webpack/common";
|
||||
|
||||
import { OnlineThemesSection } from "./OnlineThemes";
|
||||
import { QuickActionsSection } from "./QuickActions";
|
||||
import { ThemeCard } from "./ThemeCard";
|
||||
|
||||
const cl = classNameFactory("vc-settings-theme-");
|
||||
|
||||
enum ThemeFilter {
|
||||
All = "all",
|
||||
Online = "online",
|
||||
Local = "local",
|
||||
Enabled = "enabled",
|
||||
Disabled = "disabled"
|
||||
}
|
||||
|
||||
const filterOptions = [
|
||||
{ label: "Show All", value: ThemeFilter.All },
|
||||
{ label: "Online Themes", value: ThemeFilter.Online },
|
||||
{ label: "Local Themes", value: ThemeFilter.Local },
|
||||
{ label: "Enabled", value: ThemeFilter.Enabled },
|
||||
{ label: "Disabled", value: ThemeFilter.Disabled }
|
||||
];
|
||||
|
||||
function inferThemeActivationMode(css: string) {
|
||||
let text = css.replace(/^\uFEFF/, "");
|
||||
|
||||
while (true) {
|
||||
const trimmed = text.trimStart();
|
||||
if (trimmed !== text) text = trimmed;
|
||||
|
||||
const comment = /^\/\*[\s\S]*?\*\/\s*/.exec(text);
|
||||
if (!comment) break;
|
||||
text = text.slice(comment[0].length);
|
||||
}
|
||||
|
||||
const match = /^@(light|dark)\b/i.exec(text);
|
||||
return match?.[1].toLowerCase() as ThemeActivationMode | undefined;
|
||||
}
|
||||
|
||||
function inferAndStoreThemeActivationMode(themeId: string, css: string) {
|
||||
const activationMode = Settings.themeActivationModes?.[themeId] ?? inferThemeActivationMode(css);
|
||||
if (!activationMode || themeId in (Settings.themeActivationModes ?? {})) return;
|
||||
|
||||
Settings.themeActivationModes = {
|
||||
...(Settings.themeActivationModes ?? {}),
|
||||
[themeId]: activationMode,
|
||||
};
|
||||
}
|
||||
|
||||
interface UnifiedTheme {
|
||||
type: "local" | "online";
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
header: UserThemeHeader;
|
||||
link?: string;
|
||||
activationMode: ThemeActivationMode;
|
||||
}
|
||||
|
||||
function ThemesTab() {
|
||||
const settings = useSettings(["themeLinks", "enabledThemeLinks", "enabledThemes", "enableOnlineThemes", "pinnedThemes", "themeActivationModes.*"]);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [currentThemeLink, setCurrentThemeLink] = useState("");
|
||||
const [themeLinkValid, setThemeLinkValid] = useState(false);
|
||||
const [userThemes, setUserThemes] = useState<UserThemeHeader[] | null>(null);
|
||||
const [onlineThemes, setOnlineThemes] = useState<(UserThemeHeader & { link: string; })[] | null>(null);
|
||||
const [themeNames, setThemeNames] = useState<Record<string, string>>(() => {
|
||||
return settings.themeNames ?? {};
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filter, setFilter] = useState(ThemeFilter.All);
|
||||
|
||||
useEffect(() => {
|
||||
void updateThemes();
|
||||
}, []);
|
||||
|
||||
async function updateThemes() {
|
||||
await Promise.allSettled([refreshLocalThemes(), refreshOnlineThemes()]);
|
||||
}
|
||||
|
||||
async function refreshLocalThemes() {
|
||||
const themes = await VencordNative.themes.getThemesList();
|
||||
setUserThemes(themes);
|
||||
}
|
||||
|
||||
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: React.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 addThemeLink(link: string) {
|
||||
if (!themeLinkValid) return;
|
||||
if (settings.themeLinks.includes(link)) return;
|
||||
|
||||
settings.themeLinks = [...settings.themeLinks, link];
|
||||
setCurrentThemeLink("");
|
||||
refreshOnlineThemes();
|
||||
}
|
||||
|
||||
async function refreshOnlineThemes() {
|
||||
const themes = await Promise.all(
|
||||
settings.themeLinks.map(async link => {
|
||||
try {
|
||||
const res = await fetch(link);
|
||||
if (!res.ok) throw new Error(`Failed to fetch ${link}`);
|
||||
const css = await res.text();
|
||||
inferAndStoreThemeActivationMode(link, css);
|
||||
|
||||
return { ...getThemeInfo(css, link), link };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
setOnlineThemes(themes.filter(theme => theme !== null));
|
||||
}
|
||||
|
||||
function onThemeLinkEnabledChange(link: string, enabled: boolean) {
|
||||
if (enabled) {
|
||||
if (settings.enabledThemeLinks.includes(link)) return;
|
||||
settings.enabledThemeLinks = [...settings.enabledThemeLinks, link];
|
||||
} else {
|
||||
settings.enabledThemeLinks = settings.enabledThemeLinks.filter(f => f !== link);
|
||||
}
|
||||
}
|
||||
|
||||
function clearThemeState(themeId: string) {
|
||||
settings.pinnedThemes = settings.pinnedThemes.filter(f => f !== themeId);
|
||||
settings.enabledThemes = settings.enabledThemes.filter(f => f !== themeId);
|
||||
settings.enabledThemeLinks = settings.enabledThemeLinks.filter(f => f !== themeId);
|
||||
settings.themeNames = Object.fromEntries(Object.entries(settings.themeNames).filter(([key]) => key !== themeId));
|
||||
|
||||
const themeActivationModes = { ...(settings.themeActivationModes ?? {}) };
|
||||
delete themeActivationModes[themeId];
|
||||
settings.themeActivationModes = themeActivationModes;
|
||||
}
|
||||
|
||||
function deleteThemeLink(link: string) {
|
||||
settings.themeLinks = settings.themeLinks.filter(f => f !== link);
|
||||
clearThemeState(link);
|
||||
refreshOnlineThemes();
|
||||
}
|
||||
|
||||
function setThemeActivationMode(themeId: string, mode: ThemeActivationMode) {
|
||||
const themeActivationModes = { ...(settings.themeActivationModes ?? {}) };
|
||||
|
||||
if (mode === "always") {
|
||||
delete themeActivationModes[themeId];
|
||||
} else {
|
||||
themeActivationModes[themeId] = mode;
|
||||
}
|
||||
|
||||
settings.themeActivationModes = themeActivationModes;
|
||||
}
|
||||
|
||||
function togglePinTheme(themeId: string) {
|
||||
if (settings.pinnedThemes.includes(themeId)) {
|
||||
settings.pinnedThemes = settings.pinnedThemes.filter(f => f !== themeId);
|
||||
} else {
|
||||
settings.pinnedThemes = [...settings.pinnedThemes, themeId];
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshOnlineTheme(link: string) {
|
||||
try {
|
||||
const res = await fetch(link);
|
||||
if (!res.ok) throw new Error(`Failed to fetch ${link}`);
|
||||
const css = await res.text();
|
||||
inferAndStoreThemeActivationMode(link, css);
|
||||
|
||||
const updatedTheme = { ...getThemeInfo(css, link), link };
|
||||
|
||||
setOnlineThemes(prev =>
|
||||
prev?.map(t => t.link === link ? updatedTheme : t) ?? null
|
||||
);
|
||||
showToast("Theme refreshed!", Toasts.Type.SUCCESS);
|
||||
} catch {
|
||||
showToast("Failed to refresh theme", Toasts.Type.FAILURE);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadTheme(link: string, name: string) {
|
||||
try {
|
||||
const res = await fetch(link);
|
||||
if (!res.ok) throw new Error(`Failed to fetch ${link}`);
|
||||
const css = await res.text();
|
||||
const fileName = name.replace(/[^a-z0-9]/gi, "-") + ".css";
|
||||
|
||||
if (IS_DISCORD_DESKTOP) {
|
||||
DiscordNative.fileManager.saveWithDialog(new TextEncoder().encode(css), fileName);
|
||||
} else {
|
||||
const blob = new Blob([css], { type: "text/css" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch {
|
||||
showToast("Failed to download theme", Toasts.Type.FAILURE);
|
||||
}
|
||||
}
|
||||
|
||||
const allThemes = useMemo((): UnifiedTheme[] => {
|
||||
const themes: UnifiedTheme[] = [];
|
||||
|
||||
for (const theme of onlineThemes ?? []) {
|
||||
const customName = themeNames[theme.link] ?? null;
|
||||
themes.push({
|
||||
type: "online",
|
||||
name: customName ?? theme.name ?? theme.fileName,
|
||||
enabled: settings.enabledThemeLinks.includes(theme.link),
|
||||
header: { ...theme, customName },
|
||||
link: theme.link,
|
||||
activationMode: settings.themeActivationModes?.[theme.link] ?? "always",
|
||||
});
|
||||
}
|
||||
|
||||
for (const header of userThemes ?? []) {
|
||||
const name = header.name ?? header.fileName;
|
||||
|
||||
themes.push({
|
||||
type: "local",
|
||||
name,
|
||||
enabled: settings.enabledThemes.includes(header.fileName),
|
||||
header,
|
||||
activationMode: settings.themeActivationModes?.[header.fileName] ?? "always",
|
||||
});
|
||||
}
|
||||
|
||||
return themes;
|
||||
}, [onlineThemes, userThemes, themeNames, settings.enabledThemeLinks, settings.enabledThemes, settings.themeActivationModes]);
|
||||
|
||||
const filteredThemes = useMemo(() => {
|
||||
let themes = allThemes;
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
themes = themes.filter(t => t.name.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
switch (filter) {
|
||||
case ThemeFilter.Online:
|
||||
themes = themes.filter(t => t.type === "online");
|
||||
break;
|
||||
case ThemeFilter.Local:
|
||||
themes = themes.filter(t => t.type === "local");
|
||||
break;
|
||||
case ThemeFilter.Enabled:
|
||||
themes = themes.filter(t => t.enabled);
|
||||
break;
|
||||
case ThemeFilter.Disabled:
|
||||
themes = themes.filter(t => !t.enabled);
|
||||
break;
|
||||
}
|
||||
|
||||
const getThemeId = (t: UnifiedTheme) => t.type === "online" ? t.link! : t.header.fileName;
|
||||
themes.sort((a, b) => {
|
||||
const aId = getThemeId(a);
|
||||
const bId = getThemeId(b);
|
||||
const aPinIndex = settings.pinnedThemes.indexOf(aId);
|
||||
const bPinIndex = settings.pinnedThemes.indexOf(bId);
|
||||
const aIsPinned = aPinIndex !== -1;
|
||||
const bIsPinned = bPinIndex !== -1;
|
||||
|
||||
if (aIsPinned && !bIsPinned) return -1;
|
||||
if (!aIsPinned && bIsPinned) return 1;
|
||||
if (aIsPinned && bIsPinned) return aPinIndex - bPinIndex;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return themes;
|
||||
}, [allThemes, searchQuery, filter, settings.pinnedThemes]);
|
||||
|
||||
const localCount = allThemes.filter(t => t.type === "local").length;
|
||||
const onlineCount = allThemes.filter(t => t.type === "online").length;
|
||||
const enabledCount = allThemes.filter(t => t.enabled).length;
|
||||
|
||||
return (
|
||||
<SettingsTab>
|
||||
<Heading className={Margins.top16}>Theme Management</Heading>
|
||||
<Paragraph className={Margins.bottom16}>
|
||||
Customize Discord's appearance with themes. Add local .css files or load themes directly from URLs. Themes with a cog wheel icon have customizable settings you can modify.
|
||||
</Paragraph>
|
||||
|
||||
<Heading>Quick Actions</Heading>
|
||||
<Paragraph className={Margins.bottom16}>
|
||||
Shortcuts for managing your themes. Open your themes folder to add new themes, use QuickCSS for quick style tweaks, or reload themes after making changes.
|
||||
</Paragraph>
|
||||
|
||||
<QuickActionsSection
|
||||
fileInputRef={fileInputRef}
|
||||
onFileUpload={onFileUpload}
|
||||
refreshLocalThemes={refreshLocalThemes}
|
||||
/>
|
||||
|
||||
<Divider className={Margins.top20} />
|
||||
|
||||
<OnlineThemesSection
|
||||
enableOnlineThemes={settings.enableOnlineThemes ?? true}
|
||||
setEnableOnlineThemes={value => {
|
||||
settings.enableOnlineThemes = value;
|
||||
if (!value) {
|
||||
settings.enabledThemeLinks = [];
|
||||
}
|
||||
}}
|
||||
currentThemeLink={currentThemeLink}
|
||||
setCurrentThemeLink={setCurrentThemeLink}
|
||||
themeLinkValid={themeLinkValid}
|
||||
setThemeLinkValid={setThemeLinkValid}
|
||||
addThemeLink={addThemeLink}
|
||||
/>
|
||||
|
||||
<Divider className={Margins.top20} />
|
||||
|
||||
<Heading className={Margins.top20}>Installed Themes</Heading>
|
||||
<Paragraph className={Margins.bottom8}>
|
||||
Manage your themes here. Local themes load from your themes folder, online themes from URLs. Themes with a cog wheel icon have customizable settings.
|
||||
</Paragraph>
|
||||
<Paragraph color="text-subtle" className={Margins.bottom16}>
|
||||
{allThemes.length} theme{allThemes.length !== 1 ? "s" : ""} installed ({localCount} local, {onlineCount} online) · {enabledCount} enabled
|
||||
</Paragraph>
|
||||
|
||||
<div className={cl("filter-row")}>
|
||||
<TextInput
|
||||
placeholder="Search for a theme..."
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
/>
|
||||
<div>
|
||||
<Select
|
||||
options={filterOptions}
|
||||
select={setFilter}
|
||||
isSelected={v => v === filter}
|
||||
serialize={v => v}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{userThemes === null ? (
|
||||
<Paragraph color="text-muted" className={Margins.top16}>Loading themes...</Paragraph>
|
||||
) : filteredThemes.length === 0 ? (
|
||||
<Paragraph color="text-muted" className={Margins.top16}>
|
||||
{allThemes.length === 0
|
||||
? "No themes installed yet. Add theme files to your themes folder or add an online theme above to get started."
|
||||
: "No themes match your search or filter criteria."
|
||||
}
|
||||
</Paragraph>
|
||||
) : (
|
||||
<div className={classes(cl("grid"), Margins.top16)}>
|
||||
{filteredThemes.map(theme => {
|
||||
if (theme.type === "online") {
|
||||
const onlineTheme = theme.header as UserThemeHeader & { link: string; };
|
||||
const onlineThemesDisabled = !(settings.enableOnlineThemes ?? true);
|
||||
return (
|
||||
<ThemeCard
|
||||
key={onlineTheme.link}
|
||||
theme={onlineTheme}
|
||||
enabled={theme.enabled}
|
||||
onChange={enabled => onThemeLinkEnabledChange(onlineTheme.link, enabled)}
|
||||
onDelete={() => {
|
||||
onThemeLinkEnabledChange(onlineTheme.link, false);
|
||||
deleteThemeLink(onlineTheme.link);
|
||||
}}
|
||||
showDeleteButton
|
||||
disabled={onlineThemesDisabled}
|
||||
onPin={() => togglePinTheme(onlineTheme.link)}
|
||||
isPinned={settings.pinnedThemes.includes(onlineTheme.link)}
|
||||
themeLink={onlineTheme.link}
|
||||
onCopyUrl={() => copyWithToast(onlineTheme.link, "Theme URL copied!")}
|
||||
onRefresh={() => refreshOnlineTheme(onlineTheme.link)}
|
||||
onDownload={() => downloadTheme(onlineTheme.link, onlineTheme.name ?? "theme")}
|
||||
isLocal={false}
|
||||
activationMode={theme.activationMode}
|
||||
onActivationModeChange={mode => setThemeActivationMode(onlineTheme.link, mode)}
|
||||
onEditName={newName => {
|
||||
const updatedNames = { ...themeNames, [onlineTheme.link]: newName };
|
||||
setThemeNames(updatedNames);
|
||||
settings.themeNames = {
|
||||
...settings.themeNames,
|
||||
[onlineTheme.link]: newName,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const localTheme = theme.header;
|
||||
return (
|
||||
<ThemeCard
|
||||
key={localTheme.fileName}
|
||||
enabled={theme.enabled}
|
||||
onChange={enabled => onLocalThemeChange(localTheme.fileName, enabled)}
|
||||
onDelete={async () => {
|
||||
onLocalThemeChange(localTheme.fileName, false);
|
||||
clearThemeState(localTheme.fileName);
|
||||
await VencordNative.themes.deleteTheme(localTheme.fileName);
|
||||
refreshLocalThemes();
|
||||
}}
|
||||
showDeleteButton
|
||||
onPin={() => togglePinTheme(localTheme.fileName)}
|
||||
isPinned={settings.pinnedThemes.includes(localTheme.fileName)}
|
||||
onRefresh={refreshLocalThemes}
|
||||
isLocal
|
||||
theme={localTheme}
|
||||
activationMode={theme.activationMode}
|
||||
onActivationModeChange={mode => setThemeActivationMode(localTheme.fileName, mode)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SettingsTab>
|
||||
);
|
||||
}
|
||||
|
||||
function UserscriptThemesTab() {
|
||||
return (
|
||||
<SettingsTab>
|
||||
<Heading className={Margins.top16}>Themes Not Supported</Heading>
|
||||
<Paragraph className={Margins.bottom8}>
|
||||
Themes are not available on the Userscript version.
|
||||
</Paragraph>
|
||||
<Paragraph color="text-subtle">
|
||||
You can install themes using the <Link href={getStylusWebStoreUrl()}>Stylus extension</Link> instead.
|
||||
</Paragraph>
|
||||
</SettingsTab>
|
||||
);
|
||||
}
|
||||
|
||||
export default IS_USERSCRIPT
|
||||
? wrapTab(UserscriptThemesTab, "Themes")
|
||||
: wrapTab(ThemesTab, "Themes");
|
||||
|
|
@ -28,18 +28,6 @@
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
.vc-settings-usercss-ie-buttons>div {
|
||||
color: var(--interactive-icon-default, var(--interactive-normal));
|
||||
opacity: 0.5;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vc-settings-usercss-ie-buttons>div:hover {
|
||||
color: var(--interactive-icon-hover, var(--interactive-hover));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vc-settings-csp-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -79,4 +67,4 @@
|
|||
|
||||
.vc-settings-theme-footer-pin-icon svg path {
|
||||
fill: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,9 +4,6 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
// brutally ripped out of usercss
|
||||
// (remove when usercss is merged)
|
||||
|
||||
import "./colorStyles.css";
|
||||
|
||||
import { classNameFactory } from "@utils/css";
|
||||
|
|
|
|||
|
|
@ -61,11 +61,7 @@ export const ThemeInfoModal: React.FC<ThemeInfoModalProps> = ({ author, theme, .
|
|||
variant: "primary",
|
||||
disabled: !theme.content || theme.id === "preview",
|
||||
onClick: async () => {
|
||||
const validThemesDir = await VencordNative.themes.getThemesDir() + `/${theme?.name}.theme.css`;
|
||||
if (!validThemesDir) {
|
||||
showToast(`Failed to download ${theme.name}!`, Toasts.Type.FAILURE);
|
||||
return;
|
||||
}
|
||||
const fileName = `${theme?.name}.theme.css`;
|
||||
|
||||
const exists = await Native.themeExists(theme);
|
||||
if (exists) {
|
||||
|
|
@ -95,7 +91,7 @@ export const ThemeInfoModal: React.FC<ThemeInfoModalProps> = ({ author, theme, .
|
|||
<p>A theme with the same name <b>already exists</b> in your themes directory! Do you want to overwrite it?</p>
|
||||
<div className="vce-overwrite-modal">
|
||||
<code style={{ wordWrap: "break-word" }}>
|
||||
{validThemesDir}
|
||||
{fileName}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ function ThemeTab() {
|
|||
const onStatusChange = (status: SearchStatus) => setSearchValue(prev => ({ ...prev, status }));
|
||||
|
||||
const themeFilter = (theme: Theme) => {
|
||||
const enabled = themeLinks.includes(`${apiUrl}/${theme.name}`);
|
||||
const enabled = themeLinks.includes(`${apiUrl}/${theme.id}`);
|
||||
|
||||
const tags = new Set(theme.tags.map(tag => tag?.toLowerCase()));
|
||||
|
||||
|
|
|
|||
|
|
@ -16,9 +16,6 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type StylusRenderer = require("stylus/lib/renderer");
|
||||
import type LessStatic from "less";
|
||||
|
||||
import { makeLazy } from "./lazy";
|
||||
|
||||
// The below code is only used on the Desktop (electron) build of Vencord.
|
||||
|
|
@ -29,18 +26,3 @@ export const shikiOnigasmSrc = "https://cdn.jsdelivr.net/npm/@vap/shiki@0.10.3/d
|
|||
|
||||
// @ts-expect-error
|
||||
export const getStegCloak = /* #__PURE__*/ makeLazy(() => import("https://cdn.jsdelivr.net/npm/stegcloak-dist@1.0.0/index.js"));
|
||||
|
||||
export const getStylus = /* #__PURE__*/ makeLazy(async () => {
|
||||
const stylusScript = await fetch("https://cdn.jsdelivr.net/npm/stylus-lang-bundle@0.58.1/dist/stylus-renderer.min.js").then(r => r.text());
|
||||
// the stylus bundle doesn't have a header that checks for export conditions so we can just patch the script to
|
||||
// return the renderer itself
|
||||
const patchedScript = stylusScript.replace("var StylusRenderer=", "return ");
|
||||
return Function(patchedScript)() as typeof StylusRenderer;
|
||||
});
|
||||
|
||||
export const getLess = /* #__PURE__*/ makeLazy(async () => {
|
||||
const lessScript = await fetch("https://cdn.jsdelivr.net/npm/less@4.2.0/dist/less.min.js").then(r => r.text());
|
||||
const module = { exports: {} };
|
||||
Function("module", "exports", lessScript)(module, module.exports);
|
||||
return module.exports as LessStatic;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { UserstyleHeader } from "usercss-meta";
|
||||
|
||||
import type { UserThemeHeader } from "./bd";
|
||||
|
||||
export type ThemeHeader = {
|
||||
type: "other";
|
||||
header: UserThemeHeader;
|
||||
} | {
|
||||
type: "usercss";
|
||||
header: UserstyleHeader;
|
||||
};
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Settings } from "@api/Settings";
|
||||
import { getLess, getStylus } from "@utils/dependencies";
|
||||
import { Logger } from "@utils/Logger";
|
||||
|
||||
import { usercssParse } from ".";
|
||||
|
||||
const UserCSSLogger = new Logger("UserCSS:Compiler", "#d2acf5");
|
||||
|
||||
const preprocessors: { [preprocessor: string]: (text: string, vars: Record<string, string>) => Promise<string>; } = {
|
||||
async default(text: string, vars: Record<string, string>) {
|
||||
const variables = Object.entries(vars)
|
||||
.map(([name, value]) => `--${name}: ${value}`)
|
||||
.join("; ");
|
||||
|
||||
return `/* ==Equicord== */\n:root{${variables}}\n/* ==/Equicord== */${text}`;
|
||||
},
|
||||
|
||||
async uso(text: string, vars: Record<string, string>) {
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
text = text.replace(new RegExp(`\\/\\*\\[\\[${k}\\]\\]\\*\\/`, "g"), v);
|
||||
}
|
||||
|
||||
return text;
|
||||
},
|
||||
|
||||
async stylus(text: string, vars: Record<string, string>) {
|
||||
const StylusRenderer = await getStylus();
|
||||
|
||||
const variables = Object.entries(vars)
|
||||
.map(([name, value]) => `${name} = ${value}`)
|
||||
.join("\n");
|
||||
|
||||
const stylusDoc = `// ==Vencord==\n${variables}\n// ==/Vencord==\n${text}`;
|
||||
|
||||
return new StylusRenderer(stylusDoc).render();
|
||||
},
|
||||
|
||||
async less(text: string, vars: Record<string, string>) {
|
||||
const less = await getLess();
|
||||
|
||||
const variables = Object.entries(vars)
|
||||
.map(([name, value]) => `@${name}: ${value};`)
|
||||
.join("\n");
|
||||
|
||||
const lessDoc = `// ==Vencord==\n${variables}\n// ==/Vencord==\n${text}`;
|
||||
|
||||
return less.render(lessDoc).then(r => r.css);
|
||||
}
|
||||
};
|
||||
|
||||
export async function compileUsercss(fileName: string) {
|
||||
const themeData = await VencordNative.themes.getThemeData(fileName);
|
||||
|
||||
if (!themeData) return null;
|
||||
|
||||
// UserCSS preprocessor order look like this:
|
||||
// - use the preprocessor defined
|
||||
// - if variables are set, `uso`
|
||||
// - otherwise, `default`
|
||||
const { vars = {}, preprocessor = Object.keys(vars).length > 0 ? "uso" : "default", id } = await usercssParse(themeData, fileName);
|
||||
|
||||
const preprocessorFn = preprocessors[preprocessor];
|
||||
|
||||
if (!preprocessorFn) {
|
||||
UserCSSLogger.error("File", fileName, "requires preprocessor", preprocessor, "which isn't known to Equicord");
|
||||
return null;
|
||||
}
|
||||
|
||||
const varsToPass = {
|
||||
vencord: "true"
|
||||
};
|
||||
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
varsToPass[k] = Settings.userCssVars[id]?.[k] ?? v.default;
|
||||
|
||||
switch (v.type) {
|
||||
case "checkbox": {
|
||||
if (["less", "stylus"].includes(preprocessor)) {
|
||||
varsToPass[k] = varsToPass[k] === "1" ? "true" : "false";
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "range": {
|
||||
varsToPass[k] = `${varsToPass[k]}${v.units ?? "px"}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case "text": {
|
||||
if (preprocessor === "stylus") {
|
||||
varsToPass[k] = `"${varsToPass[k].replace(/"/g, "\" + '\"' + \"")}"`;
|
||||
} else {
|
||||
varsToPass[k] = `"${varsToPass[k].replace(/\//g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await preprocessorFn(themeData, varsToPass);
|
||||
} catch (error) {
|
||||
UserCSSLogger.error("File", fileName, "failed to compile with preprocessor", preprocessor, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Logger } from "@utils/Logger";
|
||||
import { parse as originalParse, UserstyleHeader } from "usercss-meta";
|
||||
|
||||
const UserCSSLogger = new Logger("UserCSS", "#d2acf5");
|
||||
|
||||
export async function usercssParse(text: string, fileName: string): Promise<UserstyleHeader> {
|
||||
const { metadata, errors } = originalParse(text.replace(/\r/g, ""), {
|
||||
allowErrors: true,
|
||||
unknownKey: "assign"
|
||||
});
|
||||
|
||||
if (errors.length) {
|
||||
UserCSSLogger.warn("Parsed", fileName, "with errors:", errors);
|
||||
}
|
||||
|
||||
const requiredPlugins = metadata["vc-requiredPlugins"]?.split(",").map(p => p.trim());
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
fileName,
|
||||
id: await getUserCssId(metadata),
|
||||
requiredPlugins
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUserCssId(header: UserstyleHeader): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const nameBuf = encoder.encode(header.name);
|
||||
const namespaceBuf = encoder.encode(header.namespace);
|
||||
|
||||
const nameHash = new Uint8Array(await window.crypto.subtle.digest("SHA-256", nameBuf));
|
||||
const namespaceHash = new Uint8Array(await window.crypto.subtle.digest("SHA-256", namespaceBuf));
|
||||
|
||||
const idHash = await window.crypto.subtle.digest("SHA-256", new Uint8Array([...nameHash, ...namespaceHash]));
|
||||
|
||||
return window.btoa(String.fromCharCode(...new Uint8Array(idHash))).substring(0, 43); // base64 adds one more padding character
|
||||
}
|
||||
134
src/utils/themes/usercss/usercss-meta.d.ts
vendored
134
src/utils/themes/usercss/usercss-meta.d.ts
vendored
|
|
@ -1,134 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
declare module "usercss-meta" {
|
||||
import { Simplify } from "type-fest";
|
||||
|
||||
export type UserCSSVariable = Simplify<{ name: string; label: string; } & (
|
||||
| {
|
||||
type: "text";
|
||||
default: string;
|
||||
}
|
||||
| {
|
||||
type: "color";
|
||||
// Hex, rgb(), rgba()
|
||||
default: string;
|
||||
}
|
||||
| {
|
||||
type: "checkbox";
|
||||
default: string;
|
||||
}
|
||||
| {
|
||||
type: "range";
|
||||
default: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
units?: string;
|
||||
}
|
||||
| {
|
||||
type: "number";
|
||||
default: number;
|
||||
}
|
||||
| {
|
||||
type: "select";
|
||||
default: string;
|
||||
options: { name: string; label: string; value: string; }[];
|
||||
}
|
||||
)>;
|
||||
|
||||
export interface UserstyleHeader {
|
||||
/**
|
||||
* The unique ID of the UserCSS style.
|
||||
*
|
||||
* @vencord Specific to Vencord, not part of the original module.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The file name of the UserCSS style.
|
||||
*
|
||||
* @vencord Specific to Vencord, not part of the original module.
|
||||
*/
|
||||
fileName: string;
|
||||
|
||||
/**
|
||||
* The required plugins for this style.
|
||||
*
|
||||
* @vencord Specific to Vencord, not part of the original module.
|
||||
* @see {@link vc-requiredPlugins}
|
||||
*/
|
||||
requiredPlugins?: string[];
|
||||
|
||||
/**
|
||||
* The name of your style.
|
||||
*
|
||||
* The combination of {@link name} and {@link namespace} must be unique.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The namespace of the style. Helps to distinguish between styles with the same name.
|
||||
*
|
||||
* The combination of {@link name} and {@link namespace} must be unique.
|
||||
*/
|
||||
namespace: string;
|
||||
/**
|
||||
* The version of your style.
|
||||
*/
|
||||
version: string;
|
||||
|
||||
/**
|
||||
* A short significant description.
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* The author of the style.
|
||||
*/
|
||||
author?: string;
|
||||
/**
|
||||
* The project's homepage.
|
||||
*
|
||||
* This is not an update URL. See {@link updateURL}.
|
||||
*/
|
||||
homepageURL?: string;
|
||||
/**
|
||||
* The URL the user can report issues to the style author.
|
||||
*/
|
||||
supportURL?: string;
|
||||
/**
|
||||
* The URL used when updating the style.
|
||||
*/
|
||||
updateURL?: string;
|
||||
/**
|
||||
* The SPDX license identifier for this style. If none is included, the style is assumed to be All Rights Reserved.
|
||||
*/
|
||||
license?: string;
|
||||
/**
|
||||
* The CSS preprocessor used to write this style.
|
||||
*/
|
||||
preprocessor?: "default" | "uso" | "less" | "stylus";
|
||||
|
||||
/**
|
||||
* A list of variables the style defines.
|
||||
*/
|
||||
vars: Record<string, UserCSSVariable>;
|
||||
|
||||
/**
|
||||
* Required plugins for this style to work. Comma-separated list of plugin names.
|
||||
*
|
||||
* @vencord This is a Vencord-specific extension, however we wish for this to become a standard for client mods
|
||||
* to implement, hence the more generic namespaced name.
|
||||
*/
|
||||
"vc-requiredPlugins"?: string;
|
||||
}
|
||||
|
||||
type UserCSSParseOptions = {
|
||||
allowErrors: boolean;
|
||||
unknownKey: "assign";
|
||||
};
|
||||
|
||||
export function parse(text: string, options: UserCSSParseOptions): { metadata: UserstyleHeader; errors: { code: string; args: any; }[]; };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue