mirror of
https://github.com/System-End/Vencord.git
synced 2026-04-19 19:45:09 +00:00
CustomRPC: add party option & overhaul settings ui
This commit is contained in:
parent
dfbffd1342
commit
dcefa49d34
4 changed files with 353 additions and 210 deletions
|
|
@ -32,5 +32,9 @@ export interface Activity {
|
|||
metadata?: {
|
||||
button_urls?: Array<string>;
|
||||
};
|
||||
party?: {
|
||||
id?: string;
|
||||
size?: [number, number];
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
275
src/plugins/customRPC/RpcSettings.tsx
Normal file
275
src/plugins/customRPC/RpcSettings.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./settings.css";
|
||||
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
import { Divider } from "@components/Divider";
|
||||
import { Heading } from "@components/Heading";
|
||||
import { resolveError } from "@components/settings/tabs/plugins/components/Common";
|
||||
import { debounce } from "@shared/debounce";
|
||||
import { ActivityType } from "@vencord/discord-types/enums";
|
||||
import { Select, Text, TextInput, useState } from "@webpack/common";
|
||||
|
||||
import { setRpc, settings, TimestampMode } from ".";
|
||||
|
||||
const cl = classNameFactory("vc-customRPC-settings-");
|
||||
|
||||
type SettingsKey = keyof typeof settings.store;
|
||||
|
||||
interface TextOption<T> {
|
||||
settingsKey: SettingsKey;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
transform?: (value: string) => T;
|
||||
isValid?: (value: T) => true | string;
|
||||
}
|
||||
|
||||
interface SelectOption<T> {
|
||||
settingsKey: SettingsKey;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
options: { label: string; value: T; default?: boolean; }[];
|
||||
}
|
||||
|
||||
const makeValidator = (maxLength: number, isRequired = false) => (value: string) => {
|
||||
if (isRequired && !value) return "This field is required.";
|
||||
if (value.length > maxLength) return `Must be not longer than ${maxLength} characters.`;
|
||||
return true;
|
||||
};
|
||||
|
||||
const maxLength128 = makeValidator(128);
|
||||
|
||||
function isAppIdValid(value: string) {
|
||||
if (!/^\d{16,21}$/.test(value)) return "Must be a valid Discord ID.";
|
||||
return true;
|
||||
}
|
||||
|
||||
const updateRPC = debounce(() => {
|
||||
setRpc(true);
|
||||
if (Vencord.Plugins.isPluginEnabled("CustomRPC")) setRpc();
|
||||
});
|
||||
|
||||
function isStreamLinkDisabled() {
|
||||
return settings.store.type !== ActivityType.STREAMING;
|
||||
}
|
||||
|
||||
function isStreamLinkValid(value: string) {
|
||||
if (!isStreamLinkDisabled() && !/https?:\/\/(www\.)?(twitch\.tv|youtube\.com)\/\w+/.test(value)) return "Streaming link must be a valid URL.";
|
||||
if (value && value.length > 512) return "Streaming link must be not longer than 512 characters.";
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseNumber(value: string) {
|
||||
return value ? parseInt(value, 10) : 0;
|
||||
}
|
||||
|
||||
function isNumberValid(value: number) {
|
||||
if (isNaN(value)) return "Must be a number.";
|
||||
if (value < 0) return "Must be a positive number.";
|
||||
return true;
|
||||
}
|
||||
|
||||
function isImageKeyValid(value: string) {
|
||||
if (/https?:\/\/(cdn|media)\.discordapp\.(com|net)\//.test(value)) return "Don't use a Discord link. Use an Imgur image link instead.";
|
||||
if (/https?:\/\/(?!i\.)?imgur\.com\//.test(value)) return "Imgur link must be a direct link to the image (e.g. https://i.imgur.com/...). Right click the image and click 'Copy image address'";
|
||||
if (/https?:\/\/(?!media\.)?tenor\.com\//.test(value)) return "Tenor link must be a direct link to the image (e.g. https://media.tenor.com/...). Right click the GIF and click 'Copy image address'";
|
||||
return true;
|
||||
}
|
||||
|
||||
function PairSetting<T>(props: { data: [TextOption<T>, TextOption<T>]; }) {
|
||||
const [left, right] = props.data;
|
||||
|
||||
return (
|
||||
<div className={cl("pair")}>
|
||||
<SingleSetting {...left} />
|
||||
<SingleSetting {...right} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleSetting<T>({ settingsKey, label, disabled, isValid, transform }: TextOption<T>) {
|
||||
const [state, setState] = useState(settings.store[settingsKey] ?? "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function handleChange(newValue: any) {
|
||||
if (transform) newValue = transform(newValue);
|
||||
|
||||
const valid = isValid?.(newValue) ?? true;
|
||||
|
||||
setState(newValue);
|
||||
setError(resolveError(valid));
|
||||
|
||||
if (valid === true) {
|
||||
settings.store[settingsKey] = newValue;
|
||||
updateRPC();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cl("single", { disabled })}>
|
||||
<Heading tag="h5">{label}</Heading>
|
||||
<TextInput
|
||||
type="text"
|
||||
placeholder={"Enter a value"}
|
||||
value={state}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{error && <Text className={cl("error")} variant="text-sm/normal">{error}</Text>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSetting<T>({ settingsKey, label, options, disabled }: SelectOption<T>) {
|
||||
return (
|
||||
<div className={cl("single", { disabled })}>
|
||||
<Heading tag="h5">{label}</Heading>
|
||||
<Select
|
||||
placeholder={"Select an option"}
|
||||
options={options}
|
||||
maxVisibleItems={5}
|
||||
closeOnSelect={true}
|
||||
select={v => settings.store[settingsKey] = v}
|
||||
isSelected={v => v === settings.store[settingsKey]}
|
||||
serialize={v => String(v)}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RPCSettings() {
|
||||
const s = settings.use();
|
||||
|
||||
return (
|
||||
<div className={cl("root")}>
|
||||
<SelectSetting
|
||||
settingsKey="type"
|
||||
label="Activity Type"
|
||||
options={[
|
||||
{
|
||||
label: "Playing",
|
||||
value: ActivityType.PLAYING,
|
||||
default: true
|
||||
},
|
||||
{
|
||||
label: "Streaming",
|
||||
value: ActivityType.STREAMING
|
||||
},
|
||||
{
|
||||
label: "Listening",
|
||||
value: ActivityType.LISTENING
|
||||
},
|
||||
{
|
||||
label: "Watching",
|
||||
value: ActivityType.WATCHING
|
||||
},
|
||||
{
|
||||
label: "Competing",
|
||||
value: ActivityType.COMPETING
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
<PairSetting data={[
|
||||
{ settingsKey: "appID", label: "Application ID", isValid: isAppIdValid },
|
||||
{ settingsKey: "appName", label: "Application Name", isValid: makeValidator(128, true) },
|
||||
]} />
|
||||
|
||||
<SingleSetting settingsKey="details" label="Detail (line 1)" isValid={maxLength128} />
|
||||
<SingleSetting settingsKey="state" label="State (line 2)" isValid={maxLength128} />
|
||||
|
||||
<SingleSetting
|
||||
settingsKey="streamLink"
|
||||
label="Stream Link (Twitch or YouTube, only if activity type is Streaming)"
|
||||
disabled={s.type !== ActivityType.STREAMING}
|
||||
isValid={isStreamLinkValid}
|
||||
/>
|
||||
|
||||
<PairSetting data={[
|
||||
{
|
||||
settingsKey: "partySize",
|
||||
label: "Party Size",
|
||||
transform: parseNumber,
|
||||
isValid: isNumberValid
|
||||
},
|
||||
{
|
||||
settingsKey: "partyMaxSize",
|
||||
label: "Maximum Party Size",
|
||||
transform: parseNumber,
|
||||
isValid: isNumberValid
|
||||
},
|
||||
]} />
|
||||
|
||||
<Divider />
|
||||
|
||||
<PairSetting data={[
|
||||
{ settingsKey: "imageBig", label: "Large Image URL/Key", isValid: isImageKeyValid },
|
||||
{ settingsKey: "imageBigTooltip", label: "Large Image Text", isValid: maxLength128 },
|
||||
]} />
|
||||
|
||||
<PairSetting data={[
|
||||
{ settingsKey: "imageSmall", label: "Small Image URL/Key", isValid: isImageKeyValid },
|
||||
{ settingsKey: "imageSmallTooltip", label: "Small Image Text", isValid: maxLength128 },
|
||||
]} />
|
||||
|
||||
<Divider />
|
||||
|
||||
<PairSetting data={[
|
||||
{ settingsKey: "buttonOneText", label: "Button1 Text", isValid: makeValidator(31) },
|
||||
{ settingsKey: "buttonOneURL", label: "Button1 URL" },
|
||||
]} />
|
||||
<PairSetting data={[
|
||||
{ settingsKey: "buttonTwoText", label: "Button2 Text", isValid: makeValidator(31) },
|
||||
{ settingsKey: "buttonTwoURL", label: "Button2 URL" },
|
||||
]} />
|
||||
|
||||
<Divider />
|
||||
|
||||
<SelectSetting
|
||||
settingsKey="timestampMode"
|
||||
label="Timestamp Mode"
|
||||
options={[
|
||||
{
|
||||
label: "None",
|
||||
value: TimestampMode.NONE,
|
||||
default: true
|
||||
},
|
||||
{
|
||||
label: "Since discord open",
|
||||
value: TimestampMode.NOW
|
||||
},
|
||||
{
|
||||
label: "Same as your current time (not reset after 24h)",
|
||||
value: TimestampMode.TIME
|
||||
},
|
||||
{
|
||||
label: "Custom",
|
||||
value: TimestampMode.CUSTOM
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
<PairSetting data={[
|
||||
{
|
||||
settingsKey: "startTime",
|
||||
label: "Start Timestamp (in milliseconds)",
|
||||
transform: parseNumber,
|
||||
isValid: isNumberValid,
|
||||
disabled: s.timestampMode !== TimestampMode.CUSTOM,
|
||||
},
|
||||
{
|
||||
settingsKey: "endTime",
|
||||
label: "End Timestamp (in milliseconds)",
|
||||
transform: parseNumber,
|
||||
isValid: isNumberValid,
|
||||
disabled: s.timestampMode !== TimestampMode.CUSTOM,
|
||||
},
|
||||
]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { definePluginSettings, Settings } from "@api/Settings";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { getUserSettingLazy } from "@api/UserSettings";
|
||||
import { Divider } from "@components/Divider";
|
||||
import { ErrorCard } from "@components/ErrorCard";
|
||||
|
|
@ -33,6 +33,8 @@ import { ActivityType } from "@vencord/discord-types/enums";
|
|||
import { findByCodeLazy, findComponentByCodeLazy } from "@webpack";
|
||||
import { ApplicationAssetUtils, Button, FluxDispatcher, Forms, React, UserStore } from "@webpack/common";
|
||||
|
||||
import { RPCSettings } from "./RpcSettings";
|
||||
|
||||
const useProfileThemeStyle = findByCodeLazy("profileThemeStyle:", "--profile-gradient-primary-color");
|
||||
const ActivityView = findComponentByCodeLazy(".party?(0", ".card");
|
||||
|
||||
|
|
@ -49,209 +51,32 @@ export const enum TimestampMode {
|
|||
CUSTOM,
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
appID: {
|
||||
type: OptionType.STRING,
|
||||
description: "Application ID (required)",
|
||||
onChange: onChange,
|
||||
isValid: (value: string) => {
|
||||
if (!value) return "Application ID is required.";
|
||||
if (value && !/^\d+$/.test(value)) return "Application ID must be a number.";
|
||||
return true;
|
||||
}
|
||||
export const settings = definePluginSettings({
|
||||
config: {
|
||||
type: OptionType.COMPONENT,
|
||||
component: RPCSettings
|
||||
},
|
||||
appName: {
|
||||
type: OptionType.STRING,
|
||||
description: "Application name (required)",
|
||||
onChange: onChange,
|
||||
isValid: (value: string) => {
|
||||
if (!value) return "Application name is required.";
|
||||
if (value.length > 128) return "Application name must be not longer than 128 characters.";
|
||||
return true;
|
||||
}
|
||||
},
|
||||
details: {
|
||||
type: OptionType.STRING,
|
||||
description: "Details (line 1)",
|
||||
onChange: onChange,
|
||||
isValid: (value: string) => {
|
||||
if (value && value.length > 128) return "Details (line 1) must be not longer than 128 characters.";
|
||||
return true;
|
||||
}
|
||||
},
|
||||
state: {
|
||||
type: OptionType.STRING,
|
||||
description: "State (line 2)",
|
||||
onChange: onChange,
|
||||
isValid: (value: string) => {
|
||||
if (value && value.length > 128) return "State (line 2) must be not longer than 128 characters.";
|
||||
return true;
|
||||
}
|
||||
},
|
||||
type: {
|
||||
type: OptionType.SELECT,
|
||||
description: "Activity type",
|
||||
onChange: onChange,
|
||||
options: [
|
||||
{
|
||||
label: "Playing",
|
||||
value: ActivityType.PLAYING,
|
||||
default: true
|
||||
},
|
||||
{
|
||||
label: "Streaming",
|
||||
value: ActivityType.STREAMING
|
||||
},
|
||||
{
|
||||
label: "Listening",
|
||||
value: ActivityType.LISTENING
|
||||
},
|
||||
{
|
||||
label: "Watching",
|
||||
value: ActivityType.WATCHING
|
||||
},
|
||||
{
|
||||
label: "Competing",
|
||||
value: ActivityType.COMPETING
|
||||
}
|
||||
]
|
||||
},
|
||||
streamLink: {
|
||||
type: OptionType.STRING,
|
||||
description: "Twitch.tv or Youtube.com link (only for Streaming activity type)",
|
||||
onChange: onChange,
|
||||
disabled: isStreamLinkDisabled,
|
||||
isValid: isStreamLinkValid
|
||||
},
|
||||
timestampMode: {
|
||||
type: OptionType.SELECT,
|
||||
description: "Timestamp mode",
|
||||
onChange: onChange,
|
||||
options: [
|
||||
{
|
||||
label: "None",
|
||||
value: TimestampMode.NONE,
|
||||
default: true
|
||||
},
|
||||
{
|
||||
label: "Since discord open",
|
||||
value: TimestampMode.NOW
|
||||
},
|
||||
{
|
||||
label: "Same as your current time (not reset after 24h)",
|
||||
value: TimestampMode.TIME
|
||||
},
|
||||
{
|
||||
label: "Custom",
|
||||
value: TimestampMode.CUSTOM
|
||||
}
|
||||
]
|
||||
},
|
||||
startTime: {
|
||||
type: OptionType.NUMBER,
|
||||
description: "Start timestamp in milliseconds (only for custom timestamp mode)",
|
||||
onChange: onChange,
|
||||
disabled: isTimestampDisabled,
|
||||
isValid: (value: number) => {
|
||||
if (value && value < 0) return "Start timestamp must be greater than 0.";
|
||||
return true;
|
||||
}
|
||||
},
|
||||
endTime: {
|
||||
type: OptionType.NUMBER,
|
||||
description: "End timestamp in milliseconds (only for custom timestamp mode)",
|
||||
onChange: onChange,
|
||||
disabled: isTimestampDisabled,
|
||||
isValid: (value: number) => {
|
||||
if (value && value < 0) return "End timestamp must be greater than 0.";
|
||||
return true;
|
||||
}
|
||||
},
|
||||
imageBig: {
|
||||
type: OptionType.STRING,
|
||||
description: "Big image key/link",
|
||||
onChange: onChange,
|
||||
isValid: isImageKeyValid
|
||||
},
|
||||
imageBigTooltip: {
|
||||
type: OptionType.STRING,
|
||||
description: "Big image tooltip",
|
||||
onChange: onChange,
|
||||
isValid: (value: string) => {
|
||||
if (value && value.length > 128) return "Big image tooltip must be not longer than 128 characters.";
|
||||
return true;
|
||||
}
|
||||
},
|
||||
imageSmall: {
|
||||
type: OptionType.STRING,
|
||||
description: "Small image key/link",
|
||||
onChange: onChange,
|
||||
isValid: isImageKeyValid
|
||||
},
|
||||
imageSmallTooltip: {
|
||||
type: OptionType.STRING,
|
||||
description: "Small image tooltip",
|
||||
onChange: onChange,
|
||||
isValid: (value: string) => {
|
||||
if (value && value.length > 128) return "Small image tooltip must be not longer than 128 characters.";
|
||||
return true;
|
||||
}
|
||||
},
|
||||
buttonOneText: {
|
||||
type: OptionType.STRING,
|
||||
description: "Button 1 text",
|
||||
onChange: onChange,
|
||||
isValid: (value: string) => {
|
||||
if (value && value.length > 31) return "Button 1 text must be not longer than 31 characters.";
|
||||
return true;
|
||||
}
|
||||
},
|
||||
buttonOneURL: {
|
||||
type: OptionType.STRING,
|
||||
description: "Button 1 URL",
|
||||
onChange: onChange
|
||||
},
|
||||
buttonTwoText: {
|
||||
type: OptionType.STRING,
|
||||
description: "Button 2 text",
|
||||
onChange: onChange,
|
||||
isValid: (value: string) => {
|
||||
if (value && value.length > 31) return "Button 2 text must be not longer than 31 characters.";
|
||||
return true;
|
||||
}
|
||||
},
|
||||
buttonTwoURL: {
|
||||
type: OptionType.STRING,
|
||||
description: "Button 2 URL",
|
||||
onChange: onChange
|
||||
}
|
||||
});
|
||||
|
||||
function onChange() {
|
||||
setRpc(true);
|
||||
if (Settings.plugins.CustomRPC.enabled) setRpc();
|
||||
}
|
||||
|
||||
function isStreamLinkDisabled() {
|
||||
return settings.store.type !== ActivityType.STREAMING;
|
||||
}
|
||||
|
||||
function isStreamLinkValid(value: string) {
|
||||
if (!isStreamLinkDisabled() && !/https?:\/\/(www\.)?(twitch\.tv|youtube\.com)\/\w+/.test(value)) return "Streaming link must be a valid URL.";
|
||||
if (value && value.length > 512) return "Streaming link must be not longer than 512 characters.";
|
||||
return true;
|
||||
}
|
||||
|
||||
function isTimestampDisabled() {
|
||||
return settings.store.timestampMode !== TimestampMode.CUSTOM;
|
||||
}
|
||||
|
||||
function isImageKeyValid(value: string) {
|
||||
if (/https?:\/\/(cdn|media)\.discordapp\.(com|net)\//.test(value)) return "Don't use a Discord link. Use an Imgur image link instead.";
|
||||
if (/https?:\/\/(?!i\.)?imgur\.com\//.test(value)) return "Imgur link must be a direct link to the image (e.g. https://i.imgur.com/...). Right click the image and click 'Copy image address'";
|
||||
if (/https?:\/\/(?!media\.)?tenor\.com\//.test(value)) return "Tenor link must be a direct link to the image (e.g. https://media.tenor.com/...). Right click the GIF and click 'Copy image address'";
|
||||
return true;
|
||||
}
|
||||
}).withPrivateSettings<{
|
||||
appID?: string;
|
||||
appName?: string;
|
||||
details?: string;
|
||||
state?: string;
|
||||
type?: ActivityType;
|
||||
streamLink?: string;
|
||||
timestampMode?: TimestampMode;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
imageBig?: string;
|
||||
imageBigTooltip?: string;
|
||||
imageSmall?: string;
|
||||
imageSmallTooltip?: string;
|
||||
buttonOneText?: string;
|
||||
buttonOneURL?: string;
|
||||
buttonTwoText?: string;
|
||||
buttonTwoURL?: string;
|
||||
partySize?: number;
|
||||
partyMaxSize?: number;
|
||||
}>();
|
||||
|
||||
async function createActivity(): Promise<Activity | undefined> {
|
||||
const {
|
||||
|
|
@ -270,7 +95,10 @@ async function createActivity(): Promise<Activity | undefined> {
|
|||
buttonOneText,
|
||||
buttonOneURL,
|
||||
buttonTwoText,
|
||||
buttonTwoURL
|
||||
buttonTwoURL,
|
||||
partyMaxSize,
|
||||
partySize,
|
||||
timestampMode
|
||||
} = settings.store;
|
||||
|
||||
if (!appName) return;
|
||||
|
|
@ -280,13 +108,13 @@ async function createActivity(): Promise<Activity | undefined> {
|
|||
name: appName,
|
||||
state,
|
||||
details,
|
||||
type,
|
||||
type: type ?? ActivityType.PLAYING,
|
||||
flags: 1 << 0,
|
||||
};
|
||||
|
||||
if (type === ActivityType.STREAMING) activity.url = streamLink;
|
||||
|
||||
switch (settings.store.timestampMode) {
|
||||
switch (timestampMode) {
|
||||
case TimestampMode.NOW:
|
||||
activity.timestamps = {
|
||||
start: Date.now()
|
||||
|
|
@ -338,6 +166,11 @@ async function createActivity(): Promise<Activity | undefined> {
|
|||
};
|
||||
}
|
||||
|
||||
if (partyMaxSize && partySize) {
|
||||
activity.party = {
|
||||
size: [partySize, partyMaxSize]
|
||||
};
|
||||
}
|
||||
|
||||
for (const k in activity) {
|
||||
if (k === "type") continue;
|
||||
|
|
@ -349,7 +182,7 @@ async function createActivity(): Promise<Activity | undefined> {
|
|||
return activity;
|
||||
}
|
||||
|
||||
async function setRpc(disable?: boolean) {
|
||||
export async function setRpc(disable?: boolean) {
|
||||
const activity: Activity | undefined = await createActivity();
|
||||
|
||||
FluxDispatcher.dispatch({
|
||||
|
|
@ -380,7 +213,7 @@ export default definePlugin({
|
|||
],
|
||||
|
||||
settingsAboutComponent: () => {
|
||||
const activity = useAwaiter(createActivity);
|
||||
const [activity] = useAwaiter(createActivity, { fallbackValue: undefined, deps: Object.values(settings.store) });
|
||||
const gameActivityEnabled = ShowCurrentGame.useSetting();
|
||||
const { profileThemeStyle } = useProfileThemeStyle({});
|
||||
|
||||
|
|
@ -426,8 +259,8 @@ export default definePlugin({
|
|||
<Divider className={Margins.top8} />
|
||||
|
||||
<div style={{ width: "284px", ...profileThemeStyle, marginTop: 8, borderRadius: 8, background: "var(--background-mod-faint)" }}>
|
||||
{activity[0] && <ActivityView
|
||||
activity={activity[0]}
|
||||
{activity && <ActivityView
|
||||
activity={activity}
|
||||
user={UserStore.getCurrentUser()}
|
||||
currentUser={UserStore.getCurrentUser()}
|
||||
/>}
|
||||
|
|
|
|||
31
src/plugins/customRPC/settings.css
Normal file
31
src/plugins/customRPC/settings.css
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
.vc-customRPC-settings-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
|
||||
& h5 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.vc-customRPC-settings-pair {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
align-items: start;
|
||||
gap: 0.5em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vc-customRPC-settings-single {
|
||||
display: grid;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.vc-customRPC-settings-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.vc-customRPC-settings-error {
|
||||
color: var(--text-danger);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue