Update ThemeLibrary

This commit is contained in:
thororen1234 2024-07-07 00:38:05 -04:00
parent ab7547e698
commit bc0543c8dd
8 changed files with 316 additions and 89 deletions

View file

@ -0,0 +1,141 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import * as DataStore from "@api/DataStore";
import { showNotification } from "@api/Notifications";
import { openModal } from "@utils/modal";
import { OAuth2AuthorizeModal, Toasts, UserStore } from "@webpack/common";
import { logger } from "./components/LikesComponent";
export async function authorizeUser(triggerModal: boolean = true) {
const isAuthorized = await getAuthorization();
if (isAuthorized === false) {
if (!triggerModal) return false;
openModal((props: any) => <OAuth2AuthorizeModal
{...props}
scopes={["identify"]}
responseType="code"
redirectUri="https://themes-delta.vercel.app/api/user/auth"
permissions={0n}
clientId="1257819493422465235"
cancelCompletesFlow={false}
callback={async ({ location }: any) => {
if (!location) return logger.error("No redirect location returned");
try {
const response = await fetch(location, {
headers: { Accept: "application/json" }
});
const { token } = await response.json();
if (token) {
logger.debug("Authorized via OAuth2, got token");
await DataStore.set("ThemeLibrary_uniqueToken", token);
showNotification({
title: "ThemeLibrary",
body: "Successfully authorized with ThemeLibrary!"
});
} else {
logger.debug("Tried to authorize via OAuth2, but no token returned");
showNotification({
title: "ThemeLibrary",
body: "Failed to authorize, check console"
});
}
} catch (e: any) {
logger.error("Failed to authorize", e);
showNotification({
title: "ThemeLibrary",
body: "Failed to authorize, check console"
});
}
}
}
/>);
} else {
return isAuthorized;
}
}
export async function deauthorizeUser() {
const uniqueToken = await DataStore.get<Record<string, string>>("ThemeLibrary_uniqueToken");
if (!uniqueToken) return Toasts.show({
message: "No uniqueToken present, try authorizing first!",
id: Toasts.genId(),
type: Toasts.Type.FAILURE,
options: {
duration: 2e3,
position: Toasts.Position.BOTTOM
}
});
const res = await fetch("https://themes-delta.vercel.app/api/user/revoke", {
method: "DELETE",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ token: uniqueToken, userId: UserStore.getCurrentUser().id })
});
if (res.ok) {
await DataStore.del("ThemeLibrary_uniqueToken");
showNotification({
title: "ThemeLibrary",
body: "Successfully deauthorized from ThemeLibrary!"
});
} else {
// try to delete anyway
try {
await DataStore.del("ThemeLibrary_uniqueToken");
} catch (e) {
logger.error("Failed to delete token", e);
showNotification({
title: "ThemeLibrary",
body: "Failed to deauthorize, check console"
});
}
}
}
export async function getAuthorization() {
const uniqueToken = await DataStore.get<Record<string, string>>("ThemeLibrary_uniqueToken");
if (!uniqueToken) {
return false;
} else {
// check if valid
const res = await fetch("https://themes-delta.vercel.app/api/user/findUserByToken", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ token: uniqueToken })
});
if (res.status === 400 || res.status === 500) {
return false;
} else {
logger.debug("User is already authorized, skipping");
return uniqueToken;
}
}
}
export async function isAuthorized(triggerModal: boolean = true) {
const isAuthorized = await getAuthorization();
const token = await DataStore.get("ThemeLibrary_uniqueToken");
if (isAuthorized === false || !token) {
await authorizeUser(triggerModal);
} else {
return true;
}
}

View file

@ -4,32 +4,24 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import * as DataStore from "@api/DataStore";
import { Logger } from "@utils/Logger";
import { Button, useEffect, UserStore, useState } from "@webpack/common";
import { Button, useEffect, useRef, UserStore, useState } from "@webpack/common";
import type { User } from "discord-types/general";
import { isAuthorized } from "../auth";
import type { Theme, ThemeLikeProps } from "../types";
import { themeRequest } from "./ThemeTab";
const logger = new Logger("ThemeLibrary", "#e5c890");
const fetchLikes = async () => {
try {
const response = await themeRequest("/likes/get");
const data = await response.json();
return data;
} catch (err) {
logger.error(err);
}
};
export const logger = new Logger("ThemeLibrary", "#e5c890");
export const LikesComponent = ({ themeId, likedThemes: initialLikedThemes }: { themeId: Theme["id"], likedThemes: ThemeLikeProps | undefined; }) => {
const [likesCount, setLikesCount] = useState(0);
const [likedThemes, setLikedThemes] = useState(initialLikedThemes);
const debounce = useRef(false);
useEffect(() => {
const likes = getThemeLikes(themeId);
logger.debug("likes", likes, "for:", themeId);
setLikesCount(likes);
}, [likedThemes, themeId]);
@ -45,10 +37,17 @@ export const LikesComponent = ({ themeId, likedThemes: initialLikedThemes }: { t
);
const handleLikeClick = async (themeId: Theme["id"]) => {
if (!isAuthorized()) return;
const theme = likedThemes?.likes.find(like => like.themeId === themeId as unknown as Number);
const currentUser: User = UserStore.getCurrentUser();
const hasLiked: boolean = theme?.userIds.includes(currentUser.id) ?? false;
const endpoint = hasLiked ? "/likes/remove" : "/likes/add";
const token = await DataStore.get("ThemeLibrary_uniqueToken");
// doing this so the delay is not visible to the user
if (debounce.current) return;
setLikesCount(likesCount + (hasLiked ? -1 : 1));
debounce.current = true;
try {
const response = await themeRequest(endpoint, {
@ -56,14 +55,13 @@ export const LikesComponent = ({ themeId, likedThemes: initialLikedThemes }: { t
headers: {
"Content-Type": "application/json",
},
cache: "no-store",
body: JSON.stringify({
userId: currentUser.id,
token,
themeId: themeId,
}),
});
if (!response.ok) return logger.error("Couldnt update likes, res:", response.statusText);
if (!response.ok) return logger.error("Couldnt update likes, response not ok");
const fetchLikes = async () => {
try {
@ -76,11 +74,10 @@ export const LikesComponent = ({ themeId, likedThemes: initialLikedThemes }: { t
};
fetchLikes();
// doing it locally isnt the best way probably, but it does the same
setLikesCount(likesCount + (hasLiked ? -1 : 1));
} catch (err) {
logger.error(err);
}
debounce.current = false;
};
const hasLiked = likedThemes?.likes.some(like => like.themeId === themeId as unknown as Number && like.userIds.includes(UserStore.getCurrentUser().id)) ?? false;

View file

@ -18,7 +18,7 @@ const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaul
export const ThemeInfoModal: React.FC<ThemeInfoModalProps> = ({ author, theme, ...props }) => {
const content = atob(theme.content);
const content = window.atob(theme.content);
const metadata = content.match(/\/\*\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*+\//g)?.[0] || "";
const donate = metadata.match(/@donate\s+(.+)/)?.[1] || "";
const version = metadata.match(/@version\s+(.+)/)?.[1] || "";

View file

@ -8,6 +8,7 @@
import "./styles.css";
import { generateId } from "@api/Commands";
import * as DataStore from "@api/DataStore";
import { Settings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { CodeBlock } from "@components/CodeBlock";
@ -20,16 +21,17 @@ import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { openModal } from "@utils/modal";
import { findByPropsLazy, findLazy } from "@webpack";
import { Button, Card, FluxDispatcher, Forms, React, Select, showToast, TabBar, TextArea, TextInput, Toasts, useEffect, UserStore, UserUtils, useState } from "@webpack/common";
import { Button, Card, FluxDispatcher, Forms, React, SearchableSelect, TabBar, TextArea, TextInput, Toasts, useEffect, UserStore, UserUtils, useState } from "@webpack/common";
import { User } from "discord-types/general";
import { Constructor } from "type-fest";
import { isAuthorized } from "../auth";
import { SearchStatus, TabItem, Theme, ThemeLikeProps } from "../types";
import { LikesComponent } from "./LikesComponent";
import { ThemeInfoModal } from "./ThemeInfoModal";
const cl = classNameFactory("vc-plugins-");
const InputStyles = findByPropsLazy("inputDefault", "inputWrapper");
const InputStyles = findByPropsLazy("inputDefault", "inputWrapper", "error");
const UserRecord: Constructor<Partial<User>> = proxyLazy(() => UserStore.getCurrentUser().constructor) as any;
const TextAreaProps = findLazy(m => typeof m.textarea === "string");
@ -37,33 +39,18 @@ const API_URL = "https://themes-delta.vercel.app/api";
const logger = new Logger("ThemeLibrary", "#e5c890");
async function fetchThemes(url: string): Promise<Theme[]> {
const response = await fetch(url);
export async function fetchAllThemes(): Promise<Theme[]> {
const response = await themeRequest("/themes");
const data = await response.json();
const themes: Theme[] = Object.values(data);
themes.forEach(theme => {
if (!theme.source) {
theme.source = `${API_URL}/${theme.name}`;
} else {
theme.source = theme.source.replace("?raw=true", "") + "?raw=true";
}
});
return themes.sort((a, b) => new Date(b.release_date).getTime() - new Date(a.release_date).getTime());
}
function API_TYPE(theme: Theme | Object, returnAll?: boolean) {
if (!theme) return;
const settings = Settings.plugins.ThemeLibrary.domain ?? false;
if (returnAll) {
const url = settings ? "https://raw.githubusercontent.com/Faf4a/plugins/main/assets/meta.json" : `${API_URL}/themes`;
return fetchThemes(url);
} else {
// @ts-ignore
return settings ? theme.source : `${API_URL}/${theme.name}`;
}
}
export async function themeRequest(path: string, options: RequestInit = {}) {
return fetch(API_URL + path, {
...options,
@ -115,7 +102,7 @@ function ThemeTab() {
const onStatusChange = (status: SearchStatus) => setSearchValue(prev => ({ ...prev, status }));
const themeFilter = (theme: Theme) => {
const enabled = themeLinks.includes(API_TYPE(theme));
const enabled = themeLinks.includes(`${API_URL}/${theme.name}`);
if (enabled && searchValue.status === SearchStatus.DISABLED) return false;
if (!theme.tags.includes("theme") && searchValue.status === SearchStatus.THEME) return false;
if (!theme.tags.includes("snippet") && searchValue.status === SearchStatus.SNIPPET) return false;
@ -146,7 +133,7 @@ function ThemeTab() {
useEffect(() => {
const fetchThemes = async () => {
try {
const themes = await API_TYPE({}, true);
const themes = await fetchAllThemes();
// fetch likes
setThemes(themes);
const likes = await fetchLikes();
@ -187,7 +174,7 @@ function ThemeTab() {
) : (
<>
{hideWarningCard ? null : (
<ErrorCard id="vc-themetab-warning">
<ErrorCard>
<Forms.FormTitle tag="h4">Want your theme removed?</Forms.FormTitle>
<Forms.FormText className={Margins.top8}>
If you want your theme(s) permanently removed, please open an issue on <a href="https://github.com/Faf4a/plugins/issues/new?labels=removal&projects=&template=request_removal.yml&title=Theme+Removal">GitHub <OpenExternalIcon height={16} width={16} /></a>
@ -200,7 +187,7 @@ function ThemeTab() {
size={Button.Sizes.SMALL}
color={Button.Colors.RED}
look={Button.Looks.FILLED}
className={Margins.top8}
className={classes(Margins.top16, "vce-warning-button")}
>Hide</Button>
</ErrorCard >
)}
@ -247,10 +234,10 @@ function ThemeTab() {
</Forms.FormText>
)}
<div style={{ marginTop: "8px", display: "flex", flexDirection: "row" }}>
{themeLinks.includes(API_TYPE(theme)) ? (
{themeLinks.includes(`${API_URL}/${theme.name}`) ? (
<Button
onClick={() => {
const onlineThemeLinks = themeLinks.filter(x => x !== API_TYPE(theme));
const onlineThemeLinks = themeLinks.filter(x => x !== `${API_URL}/${theme.name}`);
setThemeLinks(onlineThemeLinks);
Vencord.Settings.themeLinks = onlineThemeLinks;
}}
@ -264,7 +251,7 @@ function ThemeTab() {
) : (
<Button
onClick={() => {
const onlineThemeLinks = [...themeLinks, API_TYPE(theme)];
const onlineThemeLinks = [...themeLinks, `${API_URL}/${theme.name}`];
setThemeLinks(onlineThemeLinks);
Vencord.Settings.themeLinks = onlineThemeLinks;
}}
@ -289,14 +276,14 @@ function ThemeTab() {
</Button>
<Button
onClick={() => {
const content = atob(theme.content);
const content = window.atob(theme.content);
const metadata = content.match(/\/\*\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*+\//g)?.[0] || "";
const source = metadata.match(/@source\s+(.+)/)?.[1] || "";
if (source) {
VencordNative.native.openExternal(source);
} else {
VencordNative.native.openExternal(API_TYPE(theme).replace("?raw=true", ""));
VencordNative.native.openExternal(`${API_URL}/${theme.name}`);
}
}}
size={Button.Sizes.MEDIUM}
@ -318,10 +305,10 @@ function ThemeTab() {
}}>
Themes
</Forms.FormTitle>
<div className={cl("filter-controls")}>
<div className={classes(Margins.bottom20, cl("filter-controls"))}>
<TextInput value={searchValue.value} placeholder="Search for a theme..." onChange={onSearch} />
<div className={InputStyles.inputWrapper}>
<Select
<SearchableSelect
options={[
{ label: "Show All", value: SearchStatus.ALL, default: true },
{ label: "Show Themes", value: SearchStatus.THEME },
@ -333,10 +320,12 @@ function ThemeTab() {
{ label: "Show Enabled", value: SearchStatus.ENABLED },
{ label: "Show Disabled", value: SearchStatus.DISABLED },
]}
serialize={String}
select={onStatusChange}
isSelected={v => v === searchValue.status}
// @ts-ignore
value={searchValue.status}
clearable={false}
onChange={v => onStatusChange(v as SearchStatus)}
closeOnSelect={true}
className={InputStyles.inputDefault}
/>
</div>
</div>
@ -382,10 +371,10 @@ function ThemeTab() {
</Forms.FormText>
)}
<div style={{ marginTop: "8px", display: "flex", flexDirection: "row" }}>
{themeLinks.includes(API_TYPE(theme)) ? (
{themeLinks.includes(`${API_URL}/${theme.name}`) ? (
<Button
onClick={() => {
const onlineThemeLinks = themeLinks.filter(x => x !== API_TYPE(theme));
const onlineThemeLinks = themeLinks.filter(x => x !== `${API_URL}/${theme.name}`);
setThemeLinks(onlineThemeLinks);
Vencord.Settings.themeLinks = onlineThemeLinks;
}}
@ -399,7 +388,7 @@ function ThemeTab() {
) : (
<Button
onClick={() => {
const onlineThemeLinks = [...themeLinks, API_TYPE(theme)];
const onlineThemeLinks = [...themeLinks, `${API_URL}/${theme.name}`];
setThemeLinks(onlineThemeLinks);
Vencord.Settings.themeLinks = onlineThemeLinks;
}}
@ -425,14 +414,14 @@ function ThemeTab() {
<LikesComponent themeId={theme.id} likedThemes={likedThemes} />
<Button
onClick={() => {
const content = atob(theme.content);
const content = window.atob(theme.content);
const metadata = content.match(/\/\*\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*+\//g)?.[0] || "";
const source = metadata.match(/@source\s+(.+)/)?.[1] || "";
if (source) {
VencordNative.native.openExternal(source);
} else {
VencordNative.native.openExternal(API_TYPE(theme).replace("?raw=true", ""));
VencordNative.native.openExternal(`${API_URL}/${theme.name}`);
}
}}
size={Button.Sizes.MEDIUM}
@ -455,9 +444,7 @@ function ThemeTab() {
}
function SubmitThemes() {
const currentUser = UserStore.getCurrentUser();
const [themeContent, setContent] = useState("");
const handleChange = (v: string) => setContent(v);
return (
@ -483,7 +470,7 @@ function SubmitThemes() {
}}>
Submit Themes
</Forms.FormTitle>
<Forms.FormText>
<Forms.FormText className={Margins.bottom16}>
If you plan on updating your theme / snippet frequently, consider using an <code>@import</code> instead!
</Forms.FormText>
<Forms.FormText>
@ -497,8 +484,20 @@ function SubmitThemes() {
/>
<div style={{ display: "flex", alignItems: "center" }}>
<Button
onClick={() => {
if (themeContent.length < 50) return showToast("Theme content is too short, must be above 50", Toasts.Type.FAILURE);
onClick={async () => {
if (!(await isAuthorized())) return;
if (themeContent.length < 50) return Toasts.show({
message: "Failed to submit theme, content must be at least 50 characters long.",
id: Toasts.genId(),
type: Toasts.Type.FAILURE,
options: {
duration: 5e3,
position: Toasts.Position.TOP
}
});
const token = await DataStore.get("ThemeLibrary_uniqueToken");
themeRequest("/submit/theme", {
method: "POST",
@ -506,13 +505,14 @@ function SubmitThemes() {
"Content-Type": "application/json",
},
body: JSON.stringify({
userId: `${currentUser.id}`,
content: btoa(themeContent),
token,
content: window.btoa(themeContent),
}),
}).then(response => {
}).then(async response => {
if (!response.ok) {
const res = await response.json();
Toasts.show({
message: "Failed to submit theme, try again later. Probably ratelimit, wait 2 minutes.",
message: `Failed to submit theme, ${res.message}`,
id: Toasts.genId(),
type: Toasts.Type.FAILURE,
options: {
@ -531,8 +531,17 @@ function SubmitThemes() {
}
});
}
}).catch(() => {
showToast("Failed to submit theme, try later", Toasts.Type.FAILURE);
}).catch(error => {
logger.error("Failed to submit theme", error);
Toasts.show({
message: "Failed to submit theme, check your console!",
type: Toasts.Type.FAILURE,
id: Toasts.genId(),
options: {
duration: 5e3,
position: Toasts.Position.BOTTOM
}
});
});
}}
size={Button.Sizes.MEDIUM}
@ -548,7 +557,7 @@ function SubmitThemes() {
marginTop: "8px",
marginLeft: "8px",
}}>
By submitting your theme, you agree to your Discord User ID being processed.
Abusing this feature will result in you being blocked from further submissions.
</p>
</div>
</Forms.FormText>

View file

@ -62,4 +62,15 @@
overflow: visible;
margin-right: 0.5rem;
transition: fill 0.3s ease;
}
}
.vce-button-grid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.vce-warning-button {
display: flex;
width: 100%;
}

View file

@ -4,30 +4,20 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import definePlugin from "@utils/types";
import { SettingsRouter } from "@webpack/common";
const settings = definePluginSettings({
hideWarningCard: {
type: OptionType.BOOLEAN,
default: false,
description: "Hide the warning card displayed at the top of the theme library tab",
restartNeeded: false,
},
domain: {
type: OptionType.BOOLEAN,
default: false,
description: "Use Github instead of the default domain for themes",
restartNeeded: false,
},
});
import { settings } from "./settings";
export default definePlugin({
name: "ThemeLibrary",
description: "A library of themes for Vencord.",
authors: [EquicordDevs.Fafa],
authors: [
{
name: "Fafa",
id: 428188716641812481n,
},
],
settings,
toolboxActions: {
"Open Theme Library": () => {

View file

@ -0,0 +1,80 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import * as DataStore from "@api/DataStore";
import { definePluginSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { OpenExternalIcon } from "@components/Icons";
import { OptionType } from "@utils/types";
import { Button, Clipboard, Forms, Toasts } from "@webpack/common";
import { authorizeUser, deauthorizeUser } from "./auth";
const cl = classNameFactory("vce-");
export const settings = definePluginSettings({
hideWarningCard: {
type: OptionType.BOOLEAN,
default: false,
description: "Hide the warning card displayed at the top of the theme library tab",
restartNeeded: false,
},
buttons: {
type: OptionType.COMPONENT,
description: "ThemeLibrary Buttons",
component: () => {
const handleClick = async () => {
const token = await DataStore.get("ThemeLibrary_uniqueToken");
if (!token) return Toasts.show({
message: "No token to copy, try authorizing first!",
id: Toasts.genId(),
type: Toasts.Type.FAILURE,
options: {
duration: 2.5e3,
position: Toasts.Position.BOTTOM
}
});
Clipboard.copy(token);
Toasts.show({
message: "Copied to Clipboard!",
id: Toasts.genId(),
type: Toasts.Type.SUCCESS,
options: {
duration: 2.5e3,
position: Toasts.Position.BOTTOM
}
});
};
return (
<Forms.FormSection>
<Forms.FormTitle tag="h3" style={{ marginTop: 0, marginBottom: 8 }}>ThemeLibrary Auth</Forms.FormTitle>
<div className={cl("button-grid")}>
<Button onClick={() => authorizeUser()}>
Authorize with ThemeLibrary
</Button>
<Button onClick={handleClick}>
Copy ThemeLibrary Token
</Button>
<Button color={Button.Colors.RED} onClick={() => deauthorizeUser()}>
Deauthorize ThemeLibrary
</Button>
</div>
<Forms.FormTitle tag="h3" style={{ marginTop: 8, marginBottom: 8 }}>Theme Removal</Forms.FormTitle>
<Forms.FormText style={{ marginTop: 0, marginBottom: 8 }}> 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.</Forms.FormText>
<div className={cl("button-grid")}>
<Button onClick={() => VencordNative.native.openExternal("https://github.com/Faf4a/plugins/issues/new?labels=removal&projects=&template=request_removal.yml&title=Theme+Removal")}>
Request Theme Removal <OpenExternalIcon height={16} width={16} />
</Button>
</div>
</Forms.FormSection>
);
}
}
});

View file

@ -65,7 +65,6 @@ export const enum SearchStatus {
export type ThemeLikeProps = {
status: number;
likes: [{
_id?: string;
themeId: number;
userIds: User["id"][];
}];