diff --git a/src/api/Settings.ts b/src/api/Settings.ts index 1bd49fcdf..4be6da317 100644 --- a/src/api/Settings.ts +++ b/src/api/Settings.ts @@ -44,6 +44,8 @@ export interface Settings { eagerPatches: boolean; enabledThemes: string[]; enabledThemeLinks: string[]; + enableOnlineThemes: boolean; + pinnedThemes: string[]; themeNames: Record; enableReactDevtools: boolean; themeLinks: string[]; @@ -110,6 +112,8 @@ const DefaultSettings: Settings = { eagerPatches: IS_REPORTER, enabledThemes: [], enabledThemeLinks: [], + enableOnlineThemes: true, + pinnedThemes: [], themeNames: {}, enableReactDevtools: false, frameless: false, diff --git a/src/components/ThemeSettings/ThemesTab.tsx b/src/components/ThemeSettings/ThemesTab.tsx index a4a151496..fd83cc412 100644 --- a/src/components/ThemeSettings/ThemesTab.tsx +++ b/src/components/ThemeSettings/ThemesTab.tsx @@ -21,11 +21,12 @@ import "./themesStyles.css"; import { isPluginEnabled } from "@api/PluginManager"; import { Settings, useSettings } from "@api/Settings"; import { classNameFactory } from "@api/Styles"; +import { Alert } from "@components/Alert"; import { Button } from "@components/Button"; -import { Card } from "@components/Card"; import { Divider } from "@components/Divider"; import { ErrorCard } from "@components/ErrorCard"; import { Flex } from "@components/Flex"; +import { FormSwitch } from "@components/FormSwitch"; import { Heading } from "@components/Heading"; import { CogWheel, DeleteIcon, FolderIcon, PaintbrushIcon, PencilIcon, PluginIcon, PlusIcon, RestartIcon } from "@components/Icons"; import { Link } from "@components/Link"; @@ -33,7 +34,7 @@ import { Paragraph } from "@components/Paragraph"; import { AddonCard, openPluginModal, QuickAction, QuickActionCard, SettingsTab, wrapTab } from "@components/settings"; import { OnlineThemeCard } from "@components/settings/OnlineThemeCard"; import { CspBlockedUrls, useCspErrors } from "@utils/cspViolations"; -import { openInviteModal } from "@utils/discord"; +import { copyWithToast, openInviteModal } from "@utils/discord"; import { Margins } from "@utils/margins"; import { classes } from "@utils/misc"; import { openModal } from "@utils/modal"; @@ -43,13 +44,39 @@ import type { ThemeHeader } from "@utils/themes"; import { getThemeInfo, stripBOM, type UserThemeHeader } from "@utils/themes/bd"; import { usercssParse } from "@utils/themes/usercss"; import { getStylusWebStoreUrl } from "@utils/web"; -import { findLazy } from "@webpack"; -import { Alerts, React, showToast, TabBar, TextInput, Tooltip, useEffect, useMemo, useRef, useState } from "@webpack/common"; +import { findComponentByCodeLazy, findLazy } from "@webpack"; +import { Alerts, Menu, React, Select, showToast, TextInput, Toasts, Tooltip, useEffect, useMemo, useState } from "@webpack/common"; +import { ContextMenuApi } from "@webpack/common/menu"; import type { ComponentType, Ref, SyntheticEvent } from "react"; import type { UserstyleHeader } from "usercss-meta"; import Plugins from "~plugins"; +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"); + +function LocalThemeIcon({ size }: { size?: string; }) { + const sizeVal = size === "sm" ? 16 : 24; + return ( + + + + ); +} + +function OnlineThemeIcon({ size }: { size?: string; }) { + const sizeVal = size === "sm" ? 16 : 24; + return ( + + + + ); +} + import { UserCSSSettingsModal } from "./UserCSSModal"; type FileInput = ComponentType<{ @@ -62,6 +89,22 @@ const FileInput: FileInput = findLazy(m => m.prototype?.activateUploadDialogue & 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 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}`; @@ -93,6 +136,15 @@ interface OtherThemeCardProps { 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; } interface UserCSSCardProps { @@ -146,9 +198,11 @@ function UserCSSThemeCard({ theme, enabled, onChange, onDelete, onSettingsReset } footer={ - + {!!theme.homepageURL && Homepage} - {!!(theme.homepageURL && theme.supportURL) && " • "} + {!!(theme.homepageURL && theme.supportURL) && ( + • + )} {!!theme.supportURL && Support} } @@ -156,7 +210,86 @@ function UserCSSThemeCard({ theme, enabled, onChange, onDelete, onSettingsReset ); } -function OtherThemeCard({ theme, enabled, onChange, onDelete, showDeleteButton, onEditName }: OtherThemeCardProps) { +function OtherThemeCard({ theme, enabled, onChange, onDelete, showDeleteButton, onEditName, disabled, onPin, isPinned, onRefresh, onOpenFolder, onCopyUrl, onDownload, themeLink, isLocal }: OtherThemeCardProps) { + const openThemeMenu = (e: React.MouseEvent) => { + ContextMenuApi.openContextMenu(e, () => ( + + {onPin && ( + + )} + {theme.website && ( + window.open(theme.website, "_blank")} + /> + )} + {theme.invite && ( + { + openInviteModal(theme.invite!).catch(() => + showToast("Invalid or expired invite") + ); + }} + /> + )} + {onCopyUrl && themeLink && ( + + )} + {onDownload && ( + + )} + {onOpenFolder && ( + + )} + {onRefresh && ( + + )} + {(IS_WEB || showDeleteButton) && onDelete && ( + <> + + onDelete()} + /> + + )} + + )); + }; + return ( - + (IS_WEB || showDeleteButton || onPin) && ( +
+
) } footer={ - + + + {({ onMouseLeave, onMouseEnter }) => ( +
+ {isLocal ? : } +
+ )} +
+ {isPinned && ( + + {({ onMouseLeave, onMouseEnter }) => ( +
+ +
+ )} +
+ )} {!!theme.website && Website} - {!!(theme.website && theme.invite) && " • "} + {!!(theme.website && theme.invite) && ( + • + )} {!!theme.invite && ( (null); - const [currentTab, setCurrentTab] = useState(ThemeTab.LOCAL); + const fileInputRef = useState(null)[1]; const [currentThemeLink, setCurrentThemeLink] = useState(""); const [themeLinkValid, setThemeLinkValid] = useState(false); const [userThemes, setUserThemes] = useState(null); @@ -216,6 +382,8 @@ function ThemesTab() { return settings.themeNames ?? {}; }); const [themeDir, , themeDirPending] = useAwaiter(VencordNative.themes.getThemesDir); + const [searchQuery, setSearchQuery] = useState(""); + const [filter, setFilter] = useState(ThemeFilter.All); useEffect(() => { updateThemes(); @@ -244,7 +412,6 @@ function ThemesTab() { if (!fileName.endsWith(".css")) continue; if ((!IS_WEB || "legcord" in window) && fileName.endsWith(".user.css")) { - // handle it as usercss const header = await usercssParse(content, fileName); themeInfo.push({ @@ -277,7 +444,6 @@ function ThemesTab() { Settings.userCssVars[header.id][name] ??= normalizedValue; } } else { - // presumably BD but could also be plain css themeInfo.push({ type: "other", header: getThemeInfo(stripBOM(content), fileName) @@ -288,7 +454,6 @@ function ThemesTab() { setUserThemes(themeInfo); } - // When a local theme is enabled/disabled, update the settings function onLocalThemeChange(fileName: string, value: boolean) { if (value) { if (settings.enabledThemes.includes(fileName)) return; @@ -323,103 +488,6 @@ function ThemesTab() { refreshLocalThemes(); } - function LocalThemes() { - return ( - <> - - Find Themes: -
- - BetterDiscord Themes - - GitHub -
- If using the BD site, click on "Download" and place the downloaded .theme.css file into your themes folder. -
- -
- Local Themes - - <> - {IS_WEB ? - ( - - Upload Theme - - - } - Icon={PlusIcon} - /> - ) : ( - showItemInFolder(themeDir!)} - disabled={themeDirPending} - Icon={FolderIcon} - /> - )} - - VencordNative.quickCss.openEditor()} - Icon={PaintbrushIcon} - /> - - {Settings.plugins.ClientTheme.enabled && ( - openPluginModal(Plugins.ClientTheme)} - Icon={PencilIcon} - /> - )} - - - -
- {userThemes?.map(({ type, header: theme }: ThemeHeader) => ( - type === "other" ? ( - onLocalThemeChange(theme.fileName, enabled)} - onDelete={async () => { - onLocalThemeChange(theme.fileName, false); - await VencordNative.themes.deleteTheme(theme.fileName); - refreshLocalThemes(); - }} - theme={theme as UserThemeHeader} - /> - ) : ( - onLocalThemeChange(theme.fileName, enabled)} - onDelete={async () => { - onLocalThemeChange(theme.fileName, false); - await VencordNative.themes.deleteTheme(theme.fileName); - refreshLocalThemes(); - }} - onSettingsReset={refreshLocalThemes} - theme={theme as UserstyleHeader} - /> - )))} -
-
- - ); - } - function addThemeLink(link: string) { if (!themeLinkValid) return; if (settings.themeLinks.includes(link)) return; @@ -457,94 +525,343 @@ function ThemesTab() { function deleteThemeLink(link: string) { settings.themeLinks = settings.themeLinks.filter(f => f !== link); - + settings.pinnedThemes = settings.pinnedThemes.filter(f => f !== link); refreshOnlineThemes(); } - function OnlineThemes() { + function togglePinTheme(themeId: string) { + if (settings.pinnedThemes.includes(themeId)) { + settings.pinnedThemes = settings.pinnedThemes.filter(f => f !== themeId); + } else { + settings.pinnedThemes = [...settings.pinnedThemes, themeId]; + } + } - const themes = (onlineThemes ?? []).map(theme => ({ - ...theme, - customName: themeNames[theme.link] ?? null, - })); + 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(); + const updatedTheme = { ...getThemeInfo(css, link), link }; - return ( - <> -
- Online Themes - - Make sure to use direct links to files (raw or github.io)! - - - - - {currentThemeLink && } - + 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); + } + } -
- {themes.map(theme => { - const { label, link } = (() => { - const match = /^@(light|dark) (.*)/.exec(theme.link); - if (!match) return { label: theme, link: theme }; + 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"; - const [, mode, link] = match; - return { label: `[${mode} mode only] ${link}`, link }; - })(); + 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", + themeType: "other", + name: customName ?? theme.name ?? theme.fileName, + enabled: settings.enabledThemeLinks.includes(theme.link), + header: { ...theme, customName }, + link: theme.link + }); + } + + for (const { type, header } of userThemes ?? []) { + const name = type === "usercss" + ? (header as UserstyleHeader).name ?? "Unknown" + : (header as UserThemeHeader).name ?? (header as UserThemeHeader).fileName; + + themes.push({ + type: "local", + themeType: type, + name, + enabled: settings.enabledThemes.includes(header.fileName), + header + }); + } + + return themes; + }, [onlineThemes, userThemes, themeNames, settings.enabledThemeLinks, settings.enabledThemes]); + + 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 as UserThemeHeader).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 ( + + + + Theme Management + + 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. + + + Quick Actions + + 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. + + + + {IS_WEB ? ( + + Upload Theme + + + } + Icon={PlusIcon} + /> + ) : ( + showItemInFolder(themeDir!)} + disabled={themeDirPending} + Icon={FolderIcon} + /> + )} + + VencordNative.quickCss.openEditor()} + Icon={PaintbrushIcon} + /> + {Settings.plugins.ClientTheme.enabled && ( + openPluginModal(Plugins.ClientTheme)} + Icon={PencilIcon} + /> + )} + + + + + Online Themes + + 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. + + { + settings.enableOnlineThemes = value; + if (!value) { + settings.enabledThemeLinks = []; + } + }} + /> + + + Looking for themes? Check out BetterDiscord Themes or search on GitHub. When downloading from BetterDiscord, click "Download" and place the .theme.css file into your themes folder. + + +
+ + +
+ {currentThemeLink && ( +
+ +
+ )} + + + + Installed Themes + + Manage your themes here. Local themes load from your themes folder, online themes from URLs. Themes with a cog wheel icon have customizable settings. + + + {allThemes.length} theme{allThemes.length !== 1 ? "s" : ""} installed ({localCount} local, {onlineCount} online) · {enabledCount} enabled + + +
+ +
+