Equicord/src/equicordplugins/themeLibrary/components/ThemeTab.tsx

600 lines
31 KiB
TypeScript
Raw Normal View History

2024-05-04 00:50:59 -04:00
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
2024-05-12 17:43:11 -04:00
/* eslint-disable indent */
2024-05-04 00:50:59 -04:00
import "./styles.css";
import { generateId } from "@api/Commands";
2024-07-07 00:38:05 -04:00
import * as DataStore from "@api/DataStore";
2024-05-12 17:43:11 -04:00
import { Settings } from "@api/Settings";
2024-05-04 00:50:59 -04:00
import { classNameFactory } from "@api/Styles";
import { CodeBlock } from "@components/CodeBlock";
2024-06-01 14:32:22 -04:00
import { ErrorCard } from "@components/ErrorCard";
2024-05-12 17:43:11 -04:00
import { OpenExternalIcon } from "@components/Icons";
2024-05-04 00:50:59 -04:00
import { SettingsTab, wrapTab } from "@components/VencordSettings/shared";
import { proxyLazy } from "@utils/lazy";
import { Logger } from "@utils/Logger";
2024-05-04 00:50:59 -04:00
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { openModal } from "@utils/modal";
import { findByPropsLazy, findLazy } from "@webpack";
2024-07-07 00:38:05 -04:00
import { Button, Card, FluxDispatcher, Forms, React, SearchableSelect, TabBar, TextArea, TextInput, Toasts, useEffect, UserStore, UserUtils, useState } from "@webpack/common";
2024-05-04 00:50:59 -04:00
import { User } from "discord-types/general";
import { Constructor } from "type-fest";
2024-07-07 00:38:05 -04:00
import { isAuthorized } from "../auth";
import { SearchStatus, TabItem, Theme, ThemeLikeProps } from "../types";
import { LikesComponent } from "./LikesComponent";
2024-05-04 00:50:59 -04:00
import { ThemeInfoModal } from "./ThemeInfoModal";
const cl = classNameFactory("vc-plugins-");
2024-07-07 00:38:05 -04:00
const InputStyles = findByPropsLazy("inputDefault", "inputWrapper", "error");
2024-05-04 00:50:59 -04:00
const UserRecord: Constructor<Partial<User>> = proxyLazy(() => UserStore.getCurrentUser().constructor) as any;
const TextAreaProps = findLazy(m => typeof m.textarea === "string");
const API_URL = "https://themes-delta.vercel.app/api";
const logger = new Logger("ThemeLibrary", "#e5c890");
2024-07-07 00:38:05 -04:00
export async function fetchAllThemes(): Promise<Theme[]> {
const response = await themeRequest("/themes");
2024-05-12 17:43:11 -04:00
const data = await response.json();
const themes: Theme[] = Object.values(data);
themes.forEach(theme => {
if (!theme.source) {
theme.source = `${API_URL}/${theme.name}`;
}
});
return themes.sort((a, b) => new Date(b.release_date).getTime() - new Date(a.release_date).getTime());
}
export async function themeRequest(path: string, options: RequestInit = {}) {
2024-05-04 00:50:59 -04:00
return fetch(API_URL + path, {
...options,
headers: {
...options.headers,
}
});
}
function makeDummyUser(user: { username: string; id?: string; avatar?: string; }) {
const newUser = new UserRecord({
username: user.username,
id: user.id ?? generateId(),
avatar: user.avatar,
bot: true,
});
FluxDispatcher.dispatch({
type: "USER_UPDATE",
user: newUser,
});
return newUser;
}
const themeTemplate = `/**
* @name [Theme name]
* @author [Your name]
* @description [Your Theme Description]
* @version [Your Theme Version]
* @donate [Optionally, your Donation Link]
* @tags [Optionally, tags that apply to your theme]
* @invite [Optionally, your Support Server Invite]
* @source [Optionally, your source code link]
*/
/* Your CSS goes here */
`;
function ThemeTab() {
const [themes, setThemes] = useState<Theme[]>([]);
const [filteredThemes, setFilteredThemes] = useState<Theme[]>([]);
const [themeLinks, setThemeLinks] = useState(Vencord.Settings.themeLinks);
const [likedThemes, setLikedThemes] = useState<ThemeLikeProps>();
2024-05-04 00:50:59 -04:00
const [searchValue, setSearchValue] = useState({ value: "", status: SearchStatus.ALL });
const [hideWarningCard, setHideWarningCard] = useState(Settings.plugins.ThemeLibrary.hideWarningCard);
2024-05-04 00:50:59 -04:00
const [loading, setLoading] = useState(true);
const getUser = (id: string, username: string) => UserUtils.getUser(id) ?? makeDummyUser({ username, id });
const onSearch = (query: string) => setSearchValue(prev => ({ ...prev, value: query }));
const onStatusChange = (status: SearchStatus) => setSearchValue(prev => ({ ...prev, status }));
const themeFilter = (theme: Theme) => {
2024-07-07 00:38:05 -04:00
const enabled = themeLinks.includes(`${API_URL}/${theme.name}`);
2024-05-04 00:50:59 -04:00
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;
if (!theme.tags.includes("dark") && searchValue.status === SearchStatus.DARK) return false;
if (!theme.tags.includes("light") && searchValue.status === SearchStatus.LIGHT) return false;
if (!enabled && searchValue.status === SearchStatus.ENABLED) return false;
if (!searchValue.value.length) return true;
const v = searchValue.value.toLowerCase();
return (
theme.name.toLowerCase().includes(v) ||
theme.description.toLowerCase().includes(v) ||
theme.author.discord_name.toLowerCase().includes(v) ||
theme.tags?.some(t => t.toLowerCase().includes(v))
);
};
const fetchLikes = async () => {
try {
const response = await themeRequest("/likes/get");
const data = await response.json();
return data;
} catch (err) {
logger.error(err);
}
};
2024-05-04 00:50:59 -04:00
useEffect(() => {
const fetchThemes = async () => {
try {
2024-07-07 00:38:05 -04:00
const themes = await fetchAllThemes();
// fetch likes
setThemes(themes);
const likes = await fetchLikes();
setLikedThemes(likes);
setFilteredThemes(themes);
} catch (err) {
logger.error(err);
} finally {
setLoading(false);
}
};
fetchThemes();
2024-05-04 00:50:59 -04:00
}, []);
useEffect(() => {
setThemeLinks(Vencord.Settings.themeLinks);
}, [Vencord.Settings.themeLinks]);
useEffect(() => {
const filteredThemes = themes.filter(themeFilter);
setFilteredThemes(filteredThemes);
}, [searchValue]);
return (
<div>
<>
{loading ? (
<div
className={Margins.top20}
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
fontSize: "1.5em",
color: "var(--text-muted)"
}}>Loading themes...</div>
2024-06-01 14:32:22 -04:00
) : (
<>
{hideWarningCard ? null : (
2024-07-07 00:38:05 -04:00
<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>
</Forms.FormText>
<Button
onClick={() => {
Settings.plugins.ThemeLibrary.hideWarningCard = true;
setHideWarningCard(true);
}}
size={Button.Sizes.SMALL}
color={Button.Colors.RED}
look={Button.Looks.FILLED}
2024-07-07 00:38:05 -04:00
className={classes(Margins.top16, "vce-warning-button")}
>Hide</Button>
</ErrorCard >
)}
2024-06-01 14:32:22 -04:00
<div className={`${Margins.bottom8} ${Margins.top16}`}>
<Forms.FormTitle tag="h2"
style={{
2024-05-04 00:50:59 -04:00
overflowWrap: "break-word",
marginTop: 8,
2024-06-01 14:32:22 -04:00
}}>
Newest Additions
</Forms.FormTitle>
{themes.slice(0, 2).map((theme: Theme) => (
<Card style={{
padding: ".5rem",
marginBottom: ".5em",
marginTop: ".5em",
display: "flex",
flexDirection: "column",
backgroundColor: "var(--background-secondary-alt)"
}} key={theme.id}>
<Forms.FormTitle tag="h2" style={{
overflowWrap: "break-word",
marginTop: 8,
}}
className="vce-theme-text">
{theme.name}
</Forms.FormTitle>
<Forms.FormText className="vce-theme-text">
{theme.description}
</Forms.FormText>
<div className="vce-theme-info">
<div style={{
justifyContent: "flex-start",
flexDirection: "column"
}}>
{theme.tags && (
<Forms.FormText>
{theme.tags.map(tag => (
<span className="vce-theme-info-tag">
{tag}
</span>
))}
</Forms.FormText>
)}
<div style={{ marginTop: "8px", display: "flex", flexDirection: "row" }}>
2024-07-07 00:38:05 -04:00
{themeLinks.includes(`${API_URL}/${theme.name}`) ? (
2024-06-01 14:32:22 -04:00
<Button
onClick={() => {
2024-07-07 00:38:05 -04:00
const onlineThemeLinks = themeLinks.filter(x => x !== `${API_URL}/${theme.name}`);
2024-06-01 14:32:22 -04:00
setThemeLinks(onlineThemeLinks);
Vencord.Settings.themeLinks = onlineThemeLinks;
}}
size={Button.Sizes.MEDIUM}
color={Button.Colors.RED}
look={Button.Looks.FILLED}
className={Margins.right8}
>
Remove Theme
</Button>
) : (
<Button
onClick={() => {
2024-07-07 00:38:05 -04:00
const onlineThemeLinks = [...themeLinks, `${API_URL}/${theme.name}`];
2024-06-01 14:32:22 -04:00
setThemeLinks(onlineThemeLinks);
Vencord.Settings.themeLinks = onlineThemeLinks;
}}
size={Button.Sizes.MEDIUM}
color={Button.Colors.GREEN}
look={Button.Looks.FILLED}
className={Margins.right8}
>
Add Theme
</Button>
)}
2024-05-04 00:50:59 -04:00
<Button
2024-06-01 14:32:22 -04:00
onClick={async () => {
const author = await getUser(theme.author.discord_snowflake, theme.author.discord_name);
openModal(props => <ThemeInfoModal {...props} author={author} theme={theme} />);
2024-05-04 00:50:59 -04:00
}}
size={Button.Sizes.MEDIUM}
2024-06-01 14:32:22 -04:00
color={Button.Colors.BRAND}
2024-05-04 00:50:59 -04:00
look={Button.Looks.FILLED}
>
2024-06-01 14:32:22 -04:00
Theme Info
2024-05-04 00:50:59 -04:00
</Button>
<Button
onClick={() => {
2024-07-07 00:38:05 -04:00
const content = window.atob(theme.content);
2024-06-01 14:32:22 -04:00
const metadata = content.match(/\/\*\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*+\//g)?.[0] || "";
const source = metadata.match(/@source\s+(.+)/)?.[1] || "";
if (source) {
VencordNative.native.openExternal(source);
} else {
2024-07-07 00:38:05 -04:00
VencordNative.native.openExternal(`${API_URL}/${theme.name}`);
2024-06-01 14:32:22 -04:00
}
2024-05-04 00:50:59 -04:00
}}
size={Button.Sizes.MEDIUM}
2024-06-01 14:32:22 -04:00
color={Button.Colors.LINK}
look={Button.Looks.LINK}
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
2024-05-04 00:50:59 -04:00
>
2024-06-01 14:32:22 -04:00
View Source <OpenExternalIcon height={16} width={16} />
2024-05-04 00:50:59 -04:00
</Button>
2024-06-01 14:32:22 -04:00
</div>
2024-05-04 00:50:59 -04:00
</div>
</div>
2024-06-01 14:32:22 -04:00
</Card>
))}
2024-05-04 00:50:59 -04:00
</div>
2024-06-01 14:32:22 -04:00
<Forms.FormTitle tag="h2" style={{
overflowWrap: "break-word",
marginTop: 20,
}}>
Themes
</Forms.FormTitle>
2024-07-07 00:38:05 -04:00
<div className={classes(Margins.bottom20, cl("filter-controls"))}>
2024-06-01 14:32:22 -04:00
<TextInput value={searchValue.value} placeholder="Search for a theme..." onChange={onSearch} />
<div className={InputStyles.inputWrapper}>
2024-07-07 00:38:05 -04:00
<SearchableSelect
2024-06-01 14:32:22 -04:00
options={[
{ label: "Show All", value: SearchStatus.ALL, default: true },
{ label: "Show Themes", value: SearchStatus.THEME },
{ label: "Show Snippets", value: SearchStatus.SNIPPET },
// TODO: filter for most liked themes
// { label: "Show Most Liked", value: SearchStatus.LIKED },
2024-06-01 14:32:22 -04:00
{ label: "Show Dark", value: SearchStatus.DARK },
{ label: "Show Light", value: SearchStatus.LIGHT },
{ label: "Show Enabled", value: SearchStatus.ENABLED },
{ label: "Show Disabled", value: SearchStatus.DISABLED },
2024-06-01 14:32:22 -04:00
]}
2024-07-07 00:38:05 -04:00
// @ts-ignore
value={searchValue.status}
clearable={false}
onChange={v => onStatusChange(v as SearchStatus)}
2024-06-01 14:32:22 -04:00
closeOnSelect={true}
2024-07-07 00:38:05 -04:00
className={InputStyles.inputDefault}
2024-05-04 00:50:59 -04:00
/>
2024-06-01 14:32:22 -04:00
</div>
</div>
<div>
{filteredThemes.map((theme: Theme) => (
<Card style={{
padding: ".5rem",
marginBottom: ".5em",
marginTop: ".5em",
display: "flex",
flexDirection: "column",
backgroundColor: "var(--background-secondary-alt)"
}} key={theme.id}>
<Forms.FormTitle tag="h2" style={{
overflowWrap: "break-word",
marginTop: 8,
}}
className="vce-theme-text">
{theme.name}
</Forms.FormTitle>
<Forms.FormText className="vce-theme-text">
{theme.description}
</Forms.FormText>
<img
role="presentation"
src={theme.thumbnail_url}
loading="lazy"
alt={theme.name}
className="vce-theme-info-preview"
/>
<div className="vce-theme-info">
<div style={{
justifyContent: "flex-start",
flexDirection: "column"
}}>
{theme.tags && (
<Forms.FormText>
{theme.tags.map(tag => (
<span className="vce-theme-info-tag">
{tag}
</span>
))}
</Forms.FormText>
)}
<div style={{ marginTop: "8px", display: "flex", flexDirection: "row" }}>
2024-07-07 00:38:05 -04:00
{themeLinks.includes(`${API_URL}/${theme.name}`) ? (
2024-06-01 14:32:22 -04:00
<Button
onClick={() => {
2024-07-07 00:38:05 -04:00
const onlineThemeLinks = themeLinks.filter(x => x !== `${API_URL}/${theme.name}`);
2024-06-01 14:32:22 -04:00
setThemeLinks(onlineThemeLinks);
Vencord.Settings.themeLinks = onlineThemeLinks;
}}
size={Button.Sizes.MEDIUM}
color={Button.Colors.RED}
look={Button.Looks.FILLED}
className={Margins.right8}
>
Remove Theme
</Button>
) : (
<Button
onClick={() => {
2024-07-07 00:38:05 -04:00
const onlineThemeLinks = [...themeLinks, `${API_URL}/${theme.name}`];
2024-06-01 14:32:22 -04:00
setThemeLinks(onlineThemeLinks);
Vencord.Settings.themeLinks = onlineThemeLinks;
}}
size={Button.Sizes.MEDIUM}
color={Button.Colors.GREEN}
look={Button.Looks.FILLED}
className={Margins.right8}
>
Add Theme
</Button>
)}
2024-05-04 00:50:59 -04:00
<Button
2024-06-01 14:32:22 -04:00
onClick={async () => {
const author = await getUser(theme.author.discord_snowflake, theme.author.discord_name);
openModal(props => <ThemeInfoModal {...props} author={author} theme={theme} />);
2024-05-04 00:50:59 -04:00
}}
size={Button.Sizes.MEDIUM}
2024-06-01 14:32:22 -04:00
color={Button.Colors.BRAND}
2024-05-04 00:50:59 -04:00
look={Button.Looks.FILLED}
>
2024-06-01 14:32:22 -04:00
Theme Info
2024-05-04 00:50:59 -04:00
</Button>
<LikesComponent themeId={theme.id} likedThemes={likedThemes} />
2024-05-04 00:50:59 -04:00
<Button
onClick={() => {
2024-07-07 00:38:05 -04:00
const content = window.atob(theme.content);
2024-06-01 14:32:22 -04:00
const metadata = content.match(/\/\*\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*+\//g)?.[0] || "";
const source = metadata.match(/@source\s+(.+)/)?.[1] || "";
if (source) {
VencordNative.native.openExternal(source);
} else {
2024-07-07 00:38:05 -04:00
VencordNative.native.openExternal(`${API_URL}/${theme.name}`);
2024-06-01 14:32:22 -04:00
}
2024-05-04 00:50:59 -04:00
}}
size={Button.Sizes.MEDIUM}
2024-06-01 14:32:22 -04:00
color={Button.Colors.LINK}
look={Button.Looks.LINK}
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
2024-05-04 00:50:59 -04:00
>
2024-06-01 14:32:22 -04:00
View Source <OpenExternalIcon height={16} width={16} />
2024-05-04 00:50:59 -04:00
</Button>
2024-06-01 14:32:22 -04:00
</div>
2024-05-04 00:50:59 -04:00
</div>
</div>
2024-06-01 14:32:22 -04:00
</Card>
))}
</div>
</>)}
2024-05-04 00:50:59 -04:00
</>
</div >
);
}
function SubmitThemes() {
const [themeContent, setContent] = useState("");
const handleChange = (v: string) => setContent(v);
return (
<div className={`${Margins.bottom8} ${Margins.top16}`}>
<Forms.FormTitle tag="h2" style={{
overflowWrap: "break-word",
marginTop: 8,
}}>
Theme Guidelines
</Forms.FormTitle>
<Forms.FormText>
Follow the formatting for your CSS to get credit for your theme. You can find the template below.
</Forms.FormText>
<Forms.FormText>
(your theme will be reviewed and can take up to 24 hours to be approved)
</Forms.FormText>
<Forms.FormText className={`${Margins.bottom16} ${Margins.top8}`}>
<CodeBlock lang="css" content={themeTemplate} />
</Forms.FormText>
<Forms.FormTitle tag="h2" style={{
overflowWrap: "break-word",
marginTop: 8,
}}>
Submit Themes
</Forms.FormTitle>
2024-07-07 00:38:05 -04:00
<Forms.FormText className={Margins.bottom16}>
If you plan on updating your theme / snippet frequently, consider using an <code>@import</code> instead!
</Forms.FormText>
2024-05-04 00:50:59 -04:00
<Forms.FormText>
<TextArea
content={themeTemplate}
onChange={handleChange}
className={classes(TextAreaProps.textarea, "vce-text-input")}
placeholder="Theme CSS goes here..."
spellCheck={false}
rows={35}
/>
<div style={{ display: "flex", alignItems: "center" }}>
<Button
2024-07-07 00:38:05 -04:00
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");
2024-05-04 00:50:59 -04:00
themeRequest("/submit/theme", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
2024-07-07 00:38:05 -04:00
token,
content: window.btoa(themeContent),
2024-05-04 00:50:59 -04:00
}),
2024-07-07 00:38:05 -04:00
}).then(async response => {
2024-05-04 00:50:59 -04:00
if (!response.ok) {
2024-07-07 00:38:05 -04:00
const res = await response.json();
2024-05-04 00:50:59 -04:00
Toasts.show({
2024-07-07 00:38:05 -04:00
message: `Failed to submit theme, ${res.message}`,
2024-05-04 00:50:59 -04:00
id: Toasts.genId(),
type: Toasts.Type.FAILURE,
options: {
duration: 5e3,
position: Toasts.Position.BOTTOM
}
});
} else {
Toasts.show({
message: "Submitted your theme! Review can take up to 24 hours.",
type: Toasts.Type.SUCCESS,
id: Toasts.genId(),
options: {
duration: 5e3,
position: Toasts.Position.BOTTOM
}
});
}
2024-07-07 00:38:05 -04:00
}).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
}
});
2024-05-04 00:50:59 -04:00
});
}}
size={Button.Sizes.MEDIUM}
color={Button.Colors.GREEN}
look={Button.Looks.FILLED}
className={Margins.top16}
>
Submit
</Button>
<p style={{
color: "var(--text-muted)",
fontSize: "12px",
marginTop: "8px",
marginLeft: "8px",
}}>
2024-07-07 00:38:05 -04:00
Abusing this feature will result in you being blocked from further submissions.
2024-05-04 00:50:59 -04:00
</p>
</div>
</Forms.FormText>
</div>
);
}
function ThemeLibrary() {
const [currentTab, setCurrentTab] = useState(TabItem.THEMES);
return (
<SettingsTab title="Theme Library">
<TabBar
type="top"
look="brand"
className="vc-settings-tab-bar"
selectedItem={currentTab}
onItemSelect={setCurrentTab}
>
<TabBar.Item
className="vc-settings-tab-bar-item"
id={TabItem.THEMES}
>
Themes
</TabBar.Item>
<TabBar.Item
className="vc-settings-tab-bar-item"
id={TabItem.SUBMIT_THEMES}
>
Submit Theme
</TabBar.Item>
</TabBar>
{currentTab === TabItem.THEMES ? <ThemeTab /> : <SubmitThemes />}
</SettingsTab>
);
}
2024-06-27 21:46:46 -04:00
export default wrapTab(ThemeLibrary, "Theme Library");