├── README.md
├── components
├── LikesComponent.tsx
├── ThemeCard.tsx
├── ThemeInfoModal.tsx
├── ThemeTab.tsx
└── styles.css
├── index.tsx
├── native.ts
├── types.ts
└── utils
├── Icons.tsx
├── auth.tsx
└── settings.tsx
/README.md:
--------------------------------------------------------------------------------
1 | ## ThemeLibrary
2 |
3 | Add Themes directly within Vencord!
4 |
5 | ### Features
6 |
7 | - Ability to submit themes
8 | - Like your favourite themes!
9 | - Download themes directly within Vencord
10 | - ... more may or may not be added in the future
11 |
12 | ## Installation
13 |
14 | The installation guide can be found [here](https://discord.com/channels/1015060230222131221/1257038407503446176/1257038407503446176) or via [the official Vencord Docs](https://docs.vencord.dev/installing/custom-plugins/)!
15 |
16 | ## Preview
17 |
18 |
19 |
20 | ### Don't want to install this but still find some themes?
21 |
22 | You can also find all themes via https://discord-themes.com
--------------------------------------------------------------------------------
/components/LikesComponent.tsx:
--------------------------------------------------------------------------------
1 | /*
2 | * Vencord, a Discord client mod
3 | * Copyright (c) 2024 Vendicated and contributors
4 | * SPDX-License-Identifier: GPL-3.0-or-later
5 | */
6 |
7 | import * as DataStore from "@api/DataStore";
8 | import { Button, useEffect, useRef, useState } from "@webpack/common";
9 |
10 | import type { Theme, ThemeLikeProps } from "../types";
11 | import { isAuthorized } from "../utils/auth";
12 | import { LikeIcon } from "../utils/Icons";
13 | import { logger, themeRequest } from "./ThemeTab";
14 |
15 | export const LikesComponent = ({ themeId, likedThemes: initialLikedThemes }: { themeId: Theme["id"], likedThemes: ThemeLikeProps | undefined; }) => {
16 | const [likesCount, setLikesCount] = useState(0);
17 | const [likedThemes, setLikedThemes] = useState(initialLikedThemes);
18 | const debounce = useRef(false);
19 |
20 | useEffect(() => {
21 | const likes = getThemeLikes(themeId);
22 | setLikesCount(likes);
23 | }, [likedThemes, themeId]);
24 |
25 | function getThemeLikes(themeId: Theme["id"]): number {
26 | const themeLike = likedThemes?.likes.find(like => like.themeId === themeId as unknown as Number);
27 | return themeLike ? themeLike.likes : 0;
28 | }
29 |
30 | const handleLikeClick = async (themeId: Theme["id"]) => {
31 | if (!isAuthorized()) return;
32 | const theme = likedThemes?.likes.find(like => like.themeId === themeId as unknown as Number);
33 | const hasLiked: boolean = theme?.hasLiked ?? false;
34 | const endpoint = hasLiked ? "/likes/remove" : "/likes/add";
35 | const token = await DataStore.get("ThemeLibrary_uniqueToken");
36 |
37 | // doing this so the delay is not visible to the user
38 | if (debounce.current) return;
39 | setLikesCount(likesCount + (hasLiked ? -1 : 1));
40 | debounce.current = true;
41 |
42 | try {
43 | const response = await themeRequest(endpoint, {
44 | method: "POST",
45 | headers: {
46 | "Content-Type": "application/json",
47 | "Authorization": `Bearer ${token}`,
48 | },
49 | body: JSON.stringify({
50 | themeId: themeId,
51 | }),
52 | });
53 |
54 | if (!response.ok) return logger.error("Couldnt update likes, response not ok");
55 |
56 | const fetchLikes = async () => {
57 | try {
58 | const token = await DataStore.get("ThemeLibrary_uniqueToken");
59 | const response = await themeRequest("/likes/get", {
60 | headers: {
61 | "Authorization": `Bearer ${token}`,
62 | },
63 | });
64 | const data = await response.json();
65 | setLikedThemes(data);
66 | } catch (err) {
67 | logger.error(err);
68 | }
69 | };
70 |
71 | fetchLikes();
72 | } catch (err) {
73 | logger.error(err);
74 | }
75 | debounce.current = false;
76 | };
77 |
78 | const hasLiked = likedThemes?.likes.some(like => like.themeId === themeId as unknown as Number && like?.hasLiked === true) ?? false;
79 |
80 | return (
81 |
82 | handleLikeClick(themeId)}
83 | size={Button.Sizes.MEDIUM}
84 | color={Button.Colors.PRIMARY}
85 | look={Button.Looks.OUTLINED}
86 | disabled={themeId === "preview"}
87 | style={{ marginLeft: "8px" }}
88 | >
89 | {LikeIcon(hasLiked || themeId === "preview")} {themeId === "preview" ? 143 : likesCount}
90 |
91 |
92 | );
93 | };
94 |
--------------------------------------------------------------------------------
/components/ThemeCard.tsx:
--------------------------------------------------------------------------------
1 | /*
2 | * Vencord, a Discord client mod
3 | * Copyright (c) 2024 Vendicated and contributors
4 | * SPDX-License-Identifier: GPL-3.0-or-later
5 | */
6 |
7 | import { generateId } from "@api/Commands";
8 | import { Settings } from "@api/Settings";
9 | import { OpenExternalIcon } from "@components/Icons";
10 | import { proxyLazy } from "@utils/lazy";
11 | import { Margins } from "@utils/margins";
12 | import { ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
13 | import { Button, Card, FluxDispatcher, Forms, Parser, React, UserStore, UserUtils } from "@webpack/common";
14 | import { User } from "discord-types/general";
15 | import { Constructor } from "type-fest";
16 |
17 | import type { Theme, ThemeLikeProps } from "../types";
18 | import { LikesComponent } from "./LikesComponent";
19 | import { ThemeInfoModal } from "./ThemeInfoModal";
20 | import { apiUrl } from "./ThemeTab";
21 |
22 | interface ThemeCardProps {
23 | theme: Theme;
24 | themeLinks: string[];
25 | likedThemes?: ThemeLikeProps;
26 | setThemeLinks: (links: string[]) => void;
27 | removePreview?: boolean;
28 | removeButtons?: boolean;
29 | }
30 |
31 | const UserRecord: Constructor> = proxyLazy(() => UserStore.getCurrentUser().constructor) as any;
32 |
33 | function makeDummyUser(user: { username: string; id?: string; avatar?: string; }) {
34 | const newUser = new UserRecord({
35 | username: user.username,
36 | id: user.id ?? generateId(),
37 | avatar: user.avatar,
38 | bot: true,
39 | });
40 | FluxDispatcher.dispatch({
41 | type: "USER_UPDATE",
42 | user: newUser,
43 | });
44 | return newUser;
45 | }
46 |
47 | export const ThemeCard: React.FC = ({ theme, themeLinks, likedThemes, setThemeLinks, removeButtons, removePreview }) => {
48 |
49 | const getUser = (id: string, username: string) => UserUtils.getUser(id) ?? makeDummyUser({ username, id });
50 |
51 | const handleAddRemoveTheme = () => {
52 | const onlineThemeLinks = themeLinks.includes(`${apiUrl}/${theme.id}`)
53 | ? themeLinks.filter(link => link !== `${apiUrl}/${theme.id}`)
54 | : [...themeLinks, `${apiUrl}/${theme.id}`];
55 |
56 | setThemeLinks(onlineThemeLinks);
57 | Vencord.Settings.themeLinks = onlineThemeLinks;
58 | };
59 |
60 | const handleThemeAttributesCheck = () => {
61 | const requiresThemeAttributes = theme.requiresThemeAttributes ?? false;
62 |
63 | if (requiresThemeAttributes && !Settings.plugins.ThemeAttributes.enabled) {
64 | openModal(modalProps => (
65 |
66 |
67 | Hold on!
68 |
69 |
70 |
71 | This theme requires the ThemeAttributes plugin to work properly!
72 | Do you want to enable it?
73 |
74 |
75 |
76 | {
80 | Settings.plugins.ThemeAttributes.enabled = true;
81 | modalProps.onClose();
82 | handleAddRemoveTheme();
83 | }}
84 | >
85 | Enable Plugin
86 |
87 | modalProps.onClose()}
92 | >
93 | Cancel
94 |
95 |
96 |
97 | ));
98 | } else {
99 | handleAddRemoveTheme();
100 | }
101 | };
102 |
103 | const handleViewSource = () => {
104 | const content = window.atob(theme.content);
105 | const metadata = content.match(/\/\*\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*+\//g)?.[0] || "";
106 | const source = metadata.match(/@source\s+(.+)/)?.[1] || "";
107 |
108 | if (source) {
109 | VencordNative.native.openExternal(source);
110 | } else {
111 | VencordNative.native.openExternal(`${apiUrl}/${theme.id}`);
112 | }
113 | };
114 |
115 | return (
116 |
117 |
118 | {theme.name}
119 |
120 |
121 | {Parser.parse(theme.description)}
122 |
123 | {!removePreview && (
124 |
125 | )}
126 |
127 |
128 | {theme.tags && (
129 |
130 | {theme.tags.map(tag => (
131 |
132 | {tag}
133 |
134 | ))}
135 |
136 | )}
137 | {!removeButtons && (
138 |
139 |
147 | {themeLinks.includes(`${apiUrl}/${theme.id}`) ? "Remove Theme" : "Add Theme"}
148 |
149 | {
151 | const authors = Array.isArray(theme.author)
152 | ? await Promise.all(theme.author.map(author => getUser(author.discord_snowflake, author.discord_name)))
153 | : [await getUser(theme.author.discord_snowflake, theme.author.discord_name)];
154 |
155 | openModal(props => );
156 | }}
157 | size={Button.Sizes.MEDIUM}
158 | color={Button.Colors.BRAND}
159 | look={Button.Looks.FILLED}
160 | >
161 | Theme Info
162 |
163 |
164 |
172 | View Source
173 |
174 |
175 | )}
176 |
177 |
178 |
179 | );
180 | };
181 |
--------------------------------------------------------------------------------
/components/ThemeInfoModal.tsx:
--------------------------------------------------------------------------------
1 | /*
2 | * Vencord, a Discord client mod
3 | * Copyright (c) 2024 Vendicated and contributors
4 | * SPDX-License-Identifier: GPL-3.0-or-later
5 | */
6 |
7 | import { CodeBlock } from "@components/CodeBlock";
8 | import { Heart } from "@components/Heart";
9 | import { openInviteModal } from "@utils/discord";
10 | import { Margins } from "@utils/margins";
11 | import { classes } from "@utils/misc";
12 | import { ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
13 | import type { PluginNative } from "@utils/types";
14 | import { findComponentByCodeLazy } from "@webpack";
15 | import { Button, Forms, Parser, React, showToast, Toasts } from "@webpack/common";
16 | import { copyToClipboard } from "@utils/clipboard";
17 |
18 | import { Theme, ThemeInfoModalProps } from "../types";
19 | import { ClockIcon, DownloadIcon, WarningIcon } from "../utils/Icons";
20 | import { logger } from "./ThemeTab";
21 |
22 | const Native = VencordNative.pluginHelpers.ThemeLibrary as PluginNative;
23 | const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers");
24 |
25 | async function downloadTheme(themesDir: string, theme: Theme) {
26 | try {
27 | await Native.downloadTheme(themesDir, theme);
28 | showToast(`Downloaded ${theme.name}!`, Toasts.Type.SUCCESS);
29 | } catch (err: unknown) {
30 | logger.error(err);
31 | showToast(`Failed to download ${theme.name}! (check console)`, Toasts.Type.FAILURE);
32 | }
33 | }
34 |
35 | export const ThemeInfoModal: React.FC = ({ author, theme, ...props }) => {
36 | const { type, content, likes, guild, tags, last_updated, requiresThemeAttributes } = theme;
37 |
38 | const themeContent = window.atob(content);
39 | const metadata = themeContent.match(/\/\*\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*+\//g)?.[0] || "";
40 | const donate = metadata.match(/@donate\s+(.+)/)?.[1] || "";
41 | const version = metadata.match(/@version\s+(.+)/)?.[1] || "";
42 | const invite = metadata.match(/@invite\s+(.+)/)?.[1] || "";
43 |
44 | const authors = Array.isArray(author) ? author : [author];
45 |
46 | const lastUpdated = Math.floor(new Date(last_updated ?? 0).getTime() / 1000);
47 |
48 | return (
49 |
50 |
51 | {type} Details
52 |
53 |
54 | {authors.length > 1 ? "Authors" : "Author"}
55 |
56 |
57 |
58 |
69 |
70 | {authors.map(author => author.username).join(", ")}
71 |
72 |
73 | {version && (
74 | <>
75 |
Version
76 |
77 | {version}
78 |
79 | >
80 | )}
81 |
Likes
82 |
83 | {likes === 0 ? `Nobody liked this ${type} yet.` : `${likes} users liked this ${type}!`}
84 |
85 | {donate && (
86 | <>
87 |
Donate
88 |
89 | You can support the author by donating below!
90 |
91 |
92 | VencordNative.native.openExternal(donate)}>
93 |
94 | Donate
95 |
96 |
97 | >
98 | )}
99 | {(guild || invite) && (
100 | <>
101 |
Support Server
102 | {guild && (
103 |
104 | {guild.name}
105 |
106 | )}
107 |
108 | {
113 | e.preventDefault();
114 | const useInvite = guild ? guild.invite_link?.split("discord.gg/")[1] : invite;
115 | useInvite != null && openInviteModal(useInvite).catch(() => showToast("Invalid or expired invite!", Toasts.Type.FAILURE));
116 | }}
117 | >
118 | Join Discord Server
119 |
120 |
121 | >
122 | )}
123 |
Source
124 |
125 | openModal(modalProps => (
128 |
129 |
130 | Theme Source
131 |
132 |
133 |
136 |
137 |
138 |
139 |
140 | modalProps.onClose()}
144 | >
145 | Close
146 |
147 | {
149 | copyToClipboard(themeContent);
150 | showToast("Copied to Clipboard", Toasts.Type.SUCCESS);
151 | }}>Copy to Clipboard
152 |
153 |
154 | ))}
155 | >
156 | View Theme Source
157 |
158 |
159 | {tags && (
160 | <>
161 |
Tags
162 |
163 | {tags.map(tag => (
164 |
165 | {tag}
166 |
167 | ))}
168 |
169 | >
170 | )}
171 | {requiresThemeAttributes && (
172 |
173 | This theme requires the ThemeAttributes plugin!
174 |
175 | )}
176 | {last_updated && (
177 |
178 | This theme was last updated {Parser.parse("")} ({Parser.parse("")})
179 |
180 | )}
181 |
182 |
183 |
184 |
185 | props.onClose()}
189 | >
190 | Close
191 |
192 | {
198 | const themesDir = await VencordNative.themes.getThemesDir();
199 | const exists = await Native.themeExists(themesDir, theme);
200 | // using another function so we get the proper file path instead of just guessing
201 | // which slash to use (im looking at you windows)
202 | const validThemesDir = await Native.getThemesDir(themesDir, theme);
203 | // check if theme exists, and ask if they want to overwrite
204 | if (exists) {
205 | openModal(modalProps => (
206 |
207 |
208 | Conflict!
209 |
210 |
211 |
214 |
215 |
A theme with the same name already exist in your themes directory! Do you want to overwrite it?
216 |
217 |
218 | {validThemesDir}
219 |
220 |
221 |
222 |
223 |
224 |
225 | {
229 | await downloadTheme(themesDir, theme);
230 | modalProps.onClose();
231 | }}
232 | >
233 | Overwrite
234 |
235 | modalProps.onClose()}
239 | >
240 | Keep my file
241 |
242 |
243 |
244 | ));
245 | } else {
246 | await downloadTheme(themesDir, theme);
247 | }
248 | }}
249 | >
250 |
251 | Download
252 |
253 |
254 |
255 |
256 | );
257 | };
258 |
--------------------------------------------------------------------------------
/components/ThemeTab.tsx:
--------------------------------------------------------------------------------
1 | /*
2 | * Vencord, a Discord client mod
3 | * Copyright (c) 2024 Vendicated and contributors
4 | * SPDX-License-Identifier: GPL-3.0-or-later
5 | */
6 |
7 | import "./styles.css";
8 |
9 | import * as DataStore from "@api/DataStore";
10 | import { Settings } from "@api/Settings";
11 | import { ErrorCard } from "@components/ErrorCard";
12 | import { OpenExternalIcon } from "@components/Icons";
13 | import { SettingsTab, wrapTab } from "@components/VencordSettings/shared";
14 | import { Logger } from "@utils/Logger";
15 | import { Margins } from "@utils/margins";
16 | import { classes } from "@utils/misc";
17 | import { findByPropsLazy } from "@webpack";
18 | import { Button, Forms, React, SearchableSelect, TabBar, TextInput, useEffect, useState } from "@webpack/common";
19 |
20 | import { SearchStatus, TabItem, Theme, ThemeLikeProps } from "../types";
21 | import { ThemeCard } from "./ThemeCard";
22 |
23 | const InputStyles = findByPropsLazy("inputDefault", "inputWrapper", "error");
24 |
25 | export const apiUrl = "https://discord-themes.com/api";
26 | export const logger = new Logger("ThemeLibrary", "#e5c890");
27 |
28 | export async function fetchAllThemes(): Promise {
29 | const response = await themeRequest("/themes");
30 | const data = await response.json();
31 | const themes: Theme[] = Object.values(data);
32 | themes.forEach(theme => {
33 | if (!theme.source) {
34 | theme.source = `${apiUrl}/${theme.id}`;
35 | }
36 | });
37 | return themes.sort((a, b) => new Date(b.release_date).getTime() - new Date(a.release_date).getTime());
38 | }
39 |
40 | export async function themeRequest(path: string, options: RequestInit = {}) {
41 | return fetch(apiUrl + path, {
42 | ...options,
43 | headers: {
44 | ...options.headers,
45 | }
46 | });
47 | }
48 |
49 | const SearchTags = {
50 | [SearchStatus.THEME]: "THEME",
51 | [SearchStatus.SNIPPET]: "SNIPPET",
52 | [SearchStatus.LIKED]: "LIKED",
53 | [SearchStatus.DARK]: "DARK",
54 | [SearchStatus.LIGHT]: "LIGHT",
55 | };
56 |
57 | function ThemeTab() {
58 | const [themes, setThemes] = useState([]);
59 | const [filteredThemes, setFilteredThemes] = useState([]);
60 | const [themeLinks, setThemeLinks] = useState(Vencord.Settings.themeLinks);
61 | const [likedThemes, setLikedThemes] = useState();
62 | const [searchValue, setSearchValue] = useState({ value: "", status: SearchStatus.ALL });
63 | const [hideWarningCard, setHideWarningCard] = useState(Settings.plugins.ThemeLibrary.hideWarningCard);
64 | const [loading, setLoading] = useState(true);
65 |
66 | const onSearch = (query: string) => setSearchValue(prev => ({ ...prev, value: query }));
67 | const onStatusChange = (status: SearchStatus) => setSearchValue(prev => ({ ...prev, status }));
68 |
69 | const themeFilter = (theme: Theme) => {
70 | const enabled = themeLinks.includes(`${apiUrl}/${theme.name}`);
71 | console.log(theme.name, theme.tags);
72 |
73 | const tags = new Set(theme.tags.map(tag => tag?.toLowerCase()));
74 |
75 | if (!enabled && searchValue.status === SearchStatus.ENABLED) return false;
76 |
77 | const anyTags = SearchTags[searchValue.status];
78 | if (anyTags && !tags.has(anyTags?.toLowerCase())) return false;
79 |
80 | if ((enabled && searchValue.status === SearchStatus.DISABLED) || (!enabled && searchValue.status === SearchStatus.ENABLED)) return false;
81 |
82 | if (!searchValue.value.length) return true;
83 |
84 | const v = searchValue.value?.toLowerCase();
85 | return (
86 | theme.name?.toLowerCase().includes(v) ||
87 | theme.description?.toLowerCase().includes(v) ||
88 | (Array.isArray(theme.author) ? theme.author.some(author => author.discord_name?.toLowerCase()?.includes(v)) : theme.author.discord_name?.toLowerCase()?.includes(v)) ||
89 | tags.has(v)
90 | );
91 | };
92 |
93 | const fetchLikes = async () => {
94 | try {
95 | const token = await DataStore.get("ThemeLibrary_uniqueToken");
96 | const response = await themeRequest("/likes/get", {
97 | headers: {
98 | "Authorization": `Bearer ${token}`,
99 | },
100 | });
101 | const data = await response.json();
102 | return data;
103 | } catch (err) {
104 | logger.error(err);
105 | }
106 | };
107 |
108 | useEffect(() => {
109 | const fetchData = async () => {
110 | try {
111 | const [themes, likes] = await Promise.all([fetchAllThemes(), fetchLikes()]);
112 | setThemes(themes);
113 | setLikedThemes(likes);
114 | setFilteredThemes(themes);
115 | } catch (err) {
116 | logger.error(err);
117 | } finally {
118 | setLoading(false);
119 | }
120 | };
121 | fetchData();
122 | }, []);
123 |
124 | useEffect(() => {
125 | setThemeLinks(Vencord.Settings.themeLinks);
126 | }, []);
127 |
128 | useEffect(() => {
129 | // likes only update after 12_000 due to cache
130 | if (searchValue.status === SearchStatus.LIKED) {
131 | const likedThemes = themes.sort((a, b) => b.likes - a.likes);
132 | // replacement of themeFilter which wont work with SearchStatus.LIKED
133 | const filteredLikedThemes = likedThemes.filter(x => x.name.includes(searchValue.value));
134 | setFilteredThemes(filteredLikedThemes);
135 | } else {
136 | const sortedThemes = themes.sort((a, b) => new Date(b.release_date).getTime() - new Date(a.release_date).getTime());
137 | const filteredThemes = sortedThemes.filter(themeFilter);
138 | setFilteredThemes(filteredThemes);
139 | }
140 | }, [searchValue, themes]);
141 |
142 | return (
143 |
144 | <>
145 | {loading ? (
146 |
156 |
Getting the latest themes...
157 |
This won't take long!
161 |
162 |
163 | ) : (
164 | <>
165 | {hideWarningCard ? null : (
166 |
167 | Want your theme removed?
168 |
169 | If you want your theme(s) permanently removed, please open an issue on GitHub
170 |
171 | {
173 | Settings.plugins.ThemeLibrary.hideWarningCard = true;
174 | setHideWarningCard(true);
175 | }}
176 | size={Button.Sizes.SMALL}
177 | color={Button.Colors.RED}
178 | look={Button.Looks.FILLED}
179 | className={classes(Margins.top16, "vce-warning-button")}
180 | >Hide
181 |
182 | )}
183 |
184 |
189 | {searchValue.status === SearchStatus.LIKED ? "Most Liked" : "Newest Additions"}
190 |
191 |
192 | {themes.slice(0, 2).map((theme: Theme) => (
193 |
201 | ))}
202 |
203 |
207 | Themes
208 |
209 |
210 |
211 |
212 | onStatusChange(v as SearchStatus)}
227 | closeOnSelect={true}
228 | className={InputStyles.inputDefault}
229 | />
230 |
231 |
232 |
233 | {filteredThemes.length ? filteredThemes.map((theme: Theme) => (
234 |
241 | )) :
248 |
No theme found.
252 |
Try narrowing your search down.
256 |
257 | }
258 |
259 | >)}
260 | >
261 |
262 | );
263 | }
264 |
265 | // rework this!
266 | function SubmitThemes() {
267 | return (
268 |
278 |
This tab was replaced in favour of the new website:
279 |
discord-themes.com
280 |
Thank you for your understanding!
284 |
285 | );
286 | }
287 |
288 |
289 | function ThemeLibrary() {
290 | const [currentTab, setCurrentTab] = useState(TabItem.THEMES);
291 |
292 | return (
293 |
294 |
301 |
305 | Themes
306 |
307 |
311 | Submit Theme
312 |
313 |
314 |
315 | {currentTab === TabItem.THEMES ? : }
316 |
317 | );
318 | }
319 |
320 | export default wrapTab(ThemeLibrary, "Theme Library");
321 |
--------------------------------------------------------------------------------
/components/styles.css:
--------------------------------------------------------------------------------
1 | [data-tab-id="ThemeLibrary"]::before {
2 | /* stylelint-disable-next-line property-no-vendor-prefix */
3 | -webkit-mask: var(--si-widget) center/contain no-repeat !important;
4 | mask: var(--si-widget) center/contain no-repeat !important;
5 | }
6 |
7 | .vce-theme-info {
8 | padding: 0.5rem;
9 | margin-bottom: 10px;
10 | display: flex;
11 | align-items: center;
12 | justify-content: flex-start;
13 | flex-direction: row;
14 | overflow: hidden;
15 | transition: all 0.3s ease;
16 | }
17 |
18 | .vce-theme-info-preview {
19 | max-width: 100%;
20 | max-height: 100%;
21 | border-radius: 5px;
22 | margin: 0.5rem;
23 | }
24 |
25 | .vce-theme-info-preview img {
26 | width: 1080px !important;
27 | height: 1920px !important;
28 | object-fit: cover;
29 | }
30 |
31 | .vce-theme-text {
32 | padding: 0.5rem;
33 | }
34 |
35 | .vce-theme-info-tag {
36 | background: var(--background-secondary);
37 | color: var(--text-primary);
38 | border: 2px solid var(--background-tertiary);
39 | padding: 0.25rem 0.5rem;
40 | display: inline-block;
41 | border-radius: 5px;
42 | margin-right: 0.5rem;
43 | margin-bottom: 0.5rem;
44 | }
45 |
46 | .vce-text-input {
47 | display: inline-block !important;
48 | color: var(--text-normal) !important;
49 | font-size: 16px !important;
50 | padding: 0.5em;
51 | border: 2px solid var(--background-tertiary);
52 | line-height: 1.2;
53 | max-height: unset;
54 | }
55 |
56 | .vce-likes-icon {
57 | overflow: visible;
58 | margin-right: 0.5rem;
59 | transition: fill 0.3s ease;
60 | }
61 |
62 | .vce-button-grid {
63 | display: grid;
64 | grid-template-columns: 1fr;
65 | gap: 10px;
66 | }
67 |
68 | .vce-warning-button {
69 | display: flex;
70 | width: 100%;
71 | }
72 |
73 | .vce-search-grid {
74 | display: grid;
75 | height: 40px;
76 | gap: 10px;
77 | grid-template-columns: 1fr 200px;
78 | }
79 |
80 | .vce-button {
81 | white-space: normal;
82 | display: inline-flex;
83 | align-items: center;
84 | justify-content: center;
85 | }
86 |
87 | .vce-overwrite-modal {
88 | border: 1px solid var(--background-modifier-accent);
89 | border-radius: 8px;
90 | padding: 0.5em;
91 | }
92 |
93 | .vce-image-paste {
94 | border-radius: 8px;
95 | border: 3px dashed var(--background-modifier-accent);
96 | background-color: var(--background-secondary);
97 | padding: 20px;
98 | text-align: center;
99 | position: relative;
100 | cursor: pointer;
101 | margin-top: 20px;
102 | transition: border 0.3s ease;
103 | }
104 |
105 | .vce-image-paste:hover {
106 | border: 3px dashed var(--brand-500);
107 | transition: border 0.3s ease;
108 | }
109 |
110 | .vce-styled-list {
111 | list-style-type: none;
112 | padding: 0;
113 | margin: 0;
114 | }
115 |
116 | .vce-styled-list li {
117 | padding: 10px 0;
118 | border-bottom: thin solid var(--background-modifier-accent);
119 | }
120 |
121 | .vce-styled-list li:last-child {
122 | border-bottom: none;
123 | }
124 |
125 | .vce-divider-border {
126 | border-bottom: thin solid var(--background-modifier-accent);
127 | padding: 10px 0;
128 | width: 100%;
129 | }
130 |
--------------------------------------------------------------------------------
/index.tsx:
--------------------------------------------------------------------------------
1 | /*
2 | * Vencord, a Discord client mod
3 | * Copyright (c) 2024 Vendicated and contributors
4 | * SPDX-License-Identifier: GPL-3.0-or-later
5 | */
6 |
7 | import definePlugin from "@utils/types";
8 | import { SettingsRouter } from "@webpack/common";
9 |
10 | import { settings } from "./utils/settings";
11 |
12 | export default definePlugin({
13 | name: "ThemeLibrary",
14 | description: "A library of themes for Vencord.",
15 | authors: [
16 | {
17 | name: "Fafa",
18 | id: 428188716641812481n,
19 | },
20 | ],
21 | settings,
22 | toolboxActions: {
23 | "Open Theme Library": () => {
24 | SettingsRouter.open("ThemeLibrary");
25 | },
26 | },
27 |
28 | start() {
29 | const customSettingsSections = (
30 | Vencord.Plugins.plugins.Settings as any as {
31 | customSections: ((ID: Record) => any)[];
32 | }
33 | ).customSections;
34 |
35 | const ThemeSection = () => ({
36 | section: "ThemeLibrary",
37 | label: "Theme Library",
38 | searchableTitles: ["Theme Library"],
39 | element: require("./components/ThemeTab").default,
40 | id: "ThemeSection",
41 | });
42 |
43 | customSettingsSections.push(ThemeSection);
44 | },
45 |
46 | stop() {
47 | const customSettingsSections = (
48 | Vencord.Plugins.plugins.Settings as any as {
49 | customSections: ((ID: Record) => any)[];
50 | }
51 | ).customSections;
52 |
53 | const i = customSettingsSections.findIndex(
54 | section => section({}).id === "ThemeSection"
55 | );
56 |
57 | if (i !== -1) customSettingsSections.splice(i, 1);
58 | },
59 | });
60 |
--------------------------------------------------------------------------------
/native.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Vencord, a Discord client mod
3 | * Copyright (c) 2024 Vendicated and contributors
4 | * SPDX-License-Identifier: GPL-3.0-or-later
5 | */
6 |
7 | import { CspPolicies, MediaScriptsAndCssSrc } from "@main/csp";
8 | import { IpcMainInvokeEvent } from "electron";
9 | import { existsSync, type PathLike, writeFileSync } from "fs";
10 | import { join } from "path";
11 |
12 | import type { Theme } from "./types";
13 |
14 | export async function themeExists(_: IpcMainInvokeEvent, dir: PathLike, theme: Theme) {
15 | return existsSync(join(dir.toString(), `${theme.name}.theme.css`));
16 | }
17 |
18 | export function getThemesDir(_: IpcMainInvokeEvent, dir: PathLike, theme: Theme) {
19 | return join(dir.toString(), `${theme.name}.theme.css`);
20 | }
21 |
22 | CspPolicies["discord-themes.com"] = MediaScriptsAndCssSrc;
23 | CspPolicies["cdn.discord-themes.com"] = MediaScriptsAndCssSrc;
24 |
25 | export async function downloadTheme(_: IpcMainInvokeEvent, dir: PathLike, theme: Theme) {
26 | if (!theme.content || !theme.name) return;
27 | const path = join(dir.toString(), `${theme.name}.theme.css`);
28 | const download = await fetch(`https://discord-themes.com/api/download/${theme.id}`);
29 | const content = await download.text();
30 | writeFileSync(path, content);
31 | }
32 |
--------------------------------------------------------------------------------
/types.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Vencord, a Discord client mod
3 | * Copyright (c) 2024 Vendicated and contributors
4 | * SPDX-License-Identifier: GPL-3.0-or-later
5 | */
6 |
7 | import { ModalProps } from "@utils/modal";
8 | import { User } from "discord-types/general";
9 |
10 | type Author = {
11 | github_name?: string;
12 | discord_name: string;
13 | discord_snowflake: string;
14 | };
15 |
16 | export interface Theme {
17 | id: string;
18 | name: string;
19 | content: string;
20 | type: string | "theme" | "snippet";
21 | description: string;
22 | version: string;
23 | author: Author | Author[];
24 | likes: number;
25 | tags: string[];
26 | thumbnail_url: string;
27 | release_date: Date;
28 | last_updated?: Date;
29 | guild?: {
30 | name: string;
31 | snowflake: string;
32 | invite_link: string;
33 | };
34 | source?: string;
35 | requiresThemeAttributes?: boolean;
36 | }
37 |
38 | export interface ThemeInfoModalProps extends ModalProps {
39 | author: User | User[];
40 | theme: Theme;
41 | }
42 |
43 | export const enum TabItem {
44 | THEMES,
45 | SUBMIT_THEMES,
46 | }
47 |
48 | export interface LikesComponentProps {
49 | theme: Theme;
50 | userId: User["id"];
51 | }
52 |
53 | export const enum SearchStatus {
54 | ALL,
55 | ENABLED,
56 | DISABLED,
57 | THEME,
58 | SNIPPET,
59 | DARK,
60 | LIGHT,
61 | LIKED,
62 | }
63 |
64 | export type ThemeLikeProps = {
65 | status: number;
66 | likes: [{
67 | themeId: number;
68 | likes: number;
69 | hasLiked?: boolean;
70 | }];
71 | };
72 |
73 | export interface Contributor {
74 | username: User["username"];
75 | github_username: string;
76 | id: User["id"];
77 | avatar: string;
78 | }
79 |
--------------------------------------------------------------------------------
/utils/Icons.tsx:
--------------------------------------------------------------------------------
1 | /*
2 | * Vencord, a Discord client mod
3 | * Copyright (c) 2024 Vendicated and contributors
4 | * SPDX-License-Identifier: GPL-3.0-or-later
5 | */
6 |
7 | export const LikeIcon = (isLiked: boolean = false) => (
8 |
16 |
17 |
18 | );
19 |
20 | export const DownloadIcon = (props?: any) => (
21 |
29 |
30 |
31 |
32 | );
33 |
34 | export const ClockIcon = (props?: any) => {
35 | return (
36 |
37 |
45 |
46 |
47 | );
48 | };
49 |
50 | export const WarningIcon = (props?: any) => {
51 | return (
52 |
60 |
61 |
62 |
63 | );
64 | };
65 |
--------------------------------------------------------------------------------
/utils/auth.tsx:
--------------------------------------------------------------------------------
1 | /*
2 | * Vencord, a Discord client mod
3 | * Copyright (c) 2024 Vendicated and contributors
4 | * SPDX-License-Identifier: GPL-3.0-or-later
5 | */
6 |
7 | import * as DataStore from "@api/DataStore";
8 | import { showNotification } from "@api/Notifications";
9 | import { openModal } from "@utils/modal";
10 | import { OAuth2AuthorizeModal, Toasts, UserStore } from "@webpack/common";
11 |
12 | import { logger, themeRequest } from "../components/ThemeTab";
13 |
14 | export async function authorizeUser(triggerModal: boolean = true) {
15 | const isAuthorized = await getAuthorization();
16 |
17 | if (isAuthorized === false) {
18 | if (!triggerModal) return false;
19 | openModal((props: any) => {
28 | if (!location) return logger.error("No redirect location returned");
29 |
30 | try {
31 | const response = await fetch(location, {
32 | headers: { Accept: "application/json" }
33 | });
34 |
35 | const { token } = await response.json();
36 |
37 | if (token) {
38 | logger.debug("Authorized via OAuth2, got token");
39 | await DataStore.set("ThemeLibrary_uniqueToken", token);
40 | showNotification({
41 | title: "ThemeLibrary",
42 | body: "Successfully authorized with ThemeLibrary!"
43 | });
44 | } else {
45 | logger.debug("Tried to authorize via OAuth2, but no token returned");
46 | showNotification({
47 | title: "ThemeLibrary",
48 | body: "Failed to authorize, check console"
49 | });
50 | }
51 | } catch (e: any) {
52 | logger.error("Failed to authorize", e);
53 | showNotification({
54 | title: "ThemeLibrary",
55 | body: "Failed to authorize, check console"
56 | });
57 | }
58 | }
59 | }
60 | />);
61 | } else {
62 | return isAuthorized;
63 | }
64 | }
65 |
66 | export async function deauthorizeUser() {
67 | const uniqueToken = await DataStore.get>("ThemeLibrary_uniqueToken");
68 |
69 | if (!uniqueToken) return Toasts.show({
70 | message: "No uniqueToken present, try authorizing first!",
71 | id: Toasts.genId(),
72 | type: Toasts.Type.FAILURE,
73 | options: {
74 | duration: 2e3,
75 | position: Toasts.Position.BOTTOM
76 | }
77 | });
78 |
79 | const res = await themeRequest("/user/revoke", {
80 | method: "DELETE",
81 | headers: {
82 | "Content-Type": "application/json",
83 | "Authorization": `Bearer ${uniqueToken}`
84 | },
85 | body: JSON.stringify({ userId: UserStore.getCurrentUser().id })
86 | });
87 |
88 | if (res.ok) {
89 | await DataStore.del("ThemeLibrary_uniqueToken");
90 | showNotification({
91 | title: "ThemeLibrary",
92 | body: "Successfully deauthorized from ThemeLibrary!"
93 | });
94 | } else {
95 | // try to delete anyway
96 | try {
97 | await DataStore.del("ThemeLibrary_uniqueToken");
98 | } catch (e) {
99 | logger.error("Failed to delete token", e);
100 | showNotification({
101 | title: "ThemeLibrary",
102 | body: "Failed to deauthorize, check console"
103 | });
104 | }
105 | }
106 | }
107 |
108 | export async function getAuthorization() {
109 | const uniqueToken = await DataStore.get>("ThemeLibrary_uniqueToken");
110 |
111 | if (!uniqueToken) {
112 | return false;
113 | } else {
114 | // check if valid
115 | const res = await themeRequest("/user/findUserByToken", {
116 | headers: {
117 | "Content-Type": "application/json",
118 | "Authorization": `Bearer ${uniqueToken}`
119 | },
120 | });
121 |
122 | if (res.status === 400 || res.status === 500) {
123 | return false;
124 | } else {
125 | logger.debug("User is already authorized, skipping");
126 | return uniqueToken;
127 | }
128 | }
129 |
130 | }
131 |
132 | export async function isAuthorized(triggerModal: boolean = true) {
133 | const isAuthorized = await getAuthorization();
134 | const token = await DataStore.get("ThemeLibrary_uniqueToken");
135 |
136 | if (isAuthorized === false || !token) {
137 | await authorizeUser(triggerModal);
138 | } else {
139 | return true;
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/utils/settings.tsx:
--------------------------------------------------------------------------------
1 | /*
2 | * Vencord, a Discord client mod
3 | * Copyright (c) 2024 Vendicated and contributors
4 | * SPDX-License-Identifier: GPL-3.0-or-later
5 | */
6 |
7 | import * as DataStore from "@api/DataStore";
8 | import { definePluginSettings } from "@api/Settings";
9 | import { classNameFactory } from "@api/Styles";
10 | import { OpenExternalIcon } from "@components/Icons";
11 | import { OptionType } from "@utils/types";
12 | import { Button, Forms, Toasts } from "@webpack/common";
13 | import { copyToClipboard } from "@utils/clipboard";
14 |
15 | import { authorizeUser, deauthorizeUser } from "./auth";
16 |
17 | const cl = classNameFactory("vce-");
18 |
19 | export const settings = definePluginSettings({
20 | hideWarningCard: {
21 | type: OptionType.BOOLEAN,
22 | default: false,
23 | description: "Hide the warning card displayed at the top of the theme library tab",
24 | restartNeeded: false,
25 | },
26 | buttons: {
27 | type: OptionType.COMPONENT,
28 | description: "ThemeLibrary Buttons",
29 | component: () => {
30 | const handleClick = async () => {
31 | const token = await DataStore.get("ThemeLibrary_uniqueToken");
32 |
33 | if (!token) return Toasts.show({
34 | message: "No token to copy, try authorizing first!",
35 | id: Toasts.genId(),
36 | type: Toasts.Type.FAILURE,
37 | options: {
38 | duration: 2.5e3,
39 | position: Toasts.Position.BOTTOM
40 | }
41 | });
42 |
43 | copyToClipboard(token);
44 |
45 | Toasts.show({
46 | message: "Copied to Clipboard!",
47 | id: Toasts.genId(),
48 | type: Toasts.Type.SUCCESS,
49 | options: {
50 | duration: 2.5e3,
51 | position: Toasts.Position.BOTTOM
52 | }
53 | });
54 | };
55 |
56 | return (
57 |
58 | ThemeLibrary Auth
59 |
60 | authorizeUser()}>
61 | Authorize with ThemeLibrary
62 |
63 |
64 | Copy ThemeLibrary Token
65 |
66 | deauthorizeUser()}>
67 | Deauthorize ThemeLibrary
68 |
69 |
70 | Theme Removal
71 | All Theme Authors are given credit in the theme info, no source has been modified, if you wish your theme to be removed anyway, open an Issue by clicking below.
72 |
73 | VencordNative.native.openExternal("https://github.com/Faf4a/plugins/issues/new?labels=removal&projects=&template=request_removal.yml&title=Theme+Removal")}>
74 | Request Theme Removal
75 |
76 |
77 |
78 | );
79 | }
80 | }
81 | });
82 |
--------------------------------------------------------------------------------