diff --git a/src/equicordplugins/CharacterCounter/index.tsx b/src/equicordplugins/CharacterCounter/index.tsx index 48f0b8cc..aa3b6f41 100644 --- a/src/equicordplugins/CharacterCounter/index.tsx +++ b/src/equicordplugins/CharacterCounter/index.tsx @@ -8,6 +8,7 @@ import "./style.css"; import { definePluginSettings } from "@api/Settings"; import { EquicordDevs } from "@utils/constants"; +import { getCurrentChannel } from "@utils/discord"; import definePlugin, { OptionType } from "@utils/types"; import { waitFor } from "@webpack"; import { UserStore } from "@webpack/common"; @@ -15,6 +16,7 @@ import { UserStore } from "@webpack/common"; let ChannelTextAreaClasses; let shouldShowColorEffects: boolean; let position: boolean; +let forceLeft = false; waitFor(["buttonContainer", "channelTextArea"], m => (ChannelTextAreaClasses = m)); @@ -68,7 +70,7 @@ export default definePlugin({ charCounterDiv = document.createElement("div"); charCounterDiv.classList.add("char-counter"); - if (position) charCounterDiv.classList.add("left"); + if (position || forceLeft) charCounterDiv.classList.add("left"); charCounterDiv.innerHTML = `0/${charMax}`; } @@ -120,7 +122,10 @@ export default definePlugin({ const observeDOMChanges = () => { const observer = new MutationObserver(() => { const chatTextArea = document.querySelector(`.${ChannelTextAreaClasses?.channelTextArea}`); - if (chatTextArea) { + if (chatTextArea && !document.querySelector(".char-counter")) { + const currentChannel = getCurrentChannel(); + forceLeft = currentChannel?.rateLimitPerUser !== 0; + addCharCounter(); } }); diff --git a/src/equicordplugins/CharacterCounter/style.css b/src/equicordplugins/CharacterCounter/style.css index 080a1ece..b8a3fa8a 100644 --- a/src/equicordplugins/CharacterCounter/style.css +++ b/src/equicordplugins/CharacterCounter/style.css @@ -3,7 +3,7 @@ color: var(--text-muted); text-align: right; position: absolute; - bottom: 0; + bottom: -15px; right: 0; pointer-events: none; z-index: 1; diff --git a/src/equicordplugins/themeLibrary/components/LikesComponent.tsx b/src/equicordplugins/themeLibrary/components/LikesComponent.tsx index a906cf1a..14e3de26 100644 --- a/src/equicordplugins/themeLibrary/components/LikesComponent.tsx +++ b/src/equicordplugins/themeLibrary/components/LikesComponent.tsx @@ -5,16 +5,13 @@ */ import * as DataStore from "@api/DataStore"; -import { Logger } from "@utils/Logger"; import { Button, useEffect, useRef, UserStore, useState } from "@webpack/common"; import type { User } from "discord-types/general"; import type { Theme, ThemeLikeProps } from "../types"; import { isAuthorized } from "../utils/auth"; import { LikeIcon } from "../utils/Icons"; -import { themeRequest } from "./ThemeTab"; - -export const logger = new Logger("ThemeLibrary", "#e5c890"); +import { logger, themeRequest } from "./ThemeTab"; export const LikesComponent = ({ themeId, likedThemes: initialLikedThemes }: { themeId: Theme["id"], likedThemes: ThemeLikeProps | undefined; }) => { const [likesCount, setLikesCount] = useState(0); @@ -35,7 +32,7 @@ export const LikesComponent = ({ themeId, likedThemes: initialLikedThemes }: { t 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 hasLiked: boolean = (theme?.userIds.includes(currentUser.id) || themeId === "preview") ?? false; const endpoint = hasLiked ? "/likes/remove" : "/likes/add"; const token = await DataStore.get("ThemeLibrary_uniqueToken"); @@ -83,9 +80,10 @@ export const LikesComponent = ({ themeId, likedThemes: initialLikedThemes }: { t size={Button.Sizes.MEDIUM} color={Button.Colors.PRIMARY} look={Button.Looks.OUTLINED} + disabled={themeId === "preview"} style={{ marginLeft: "8px" }} > - {LikeIcon(hasLiked)} {likesCount} + {LikeIcon(hasLiked || themeId === "preview")} {themeId === "preview" ? 143 : likesCount} ); diff --git a/src/equicordplugins/themeLibrary/components/ThemeCard.tsx b/src/equicordplugins/themeLibrary/components/ThemeCard.tsx new file mode 100644 index 00000000..e2f85424 --- /dev/null +++ b/src/equicordplugins/themeLibrary/components/ThemeCard.tsx @@ -0,0 +1,180 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { generateId } from "@api/Commands"; +import { Settings } from "@api/Settings"; +import { OpenExternalIcon } from "@components/Icons"; +import { proxyLazy } from "@utils/lazy"; +import { Margins } from "@utils/margins"; +import { ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal"; +import { Button, Card, FluxDispatcher, Forms, Parser, React, UserStore, UserUtils } from "@webpack/common"; +import { User } from "discord-types/general"; +import { Constructor } from "type-fest"; + +import type { Theme, ThemeLikeProps } from "../types"; +import { LikesComponent } from "./LikesComponent"; +import { ThemeInfoModal } from "./ThemeInfoModal"; +import { apiUrl } from "./ThemeTab"; + +interface ThemeCardProps { + theme: Theme; + themeLinks: string[]; + likedThemes?: ThemeLikeProps; + setThemeLinks: (links: string[]) => void; + removePreview?: boolean; + removeButtons?: boolean; +} + +const UserRecord: Constructor> = proxyLazy(() => UserStore.getCurrentUser().constructor) as any; + +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; +} + +export const ThemeCard: React.FC = ({ theme, themeLinks, likedThemes, setThemeLinks, removeButtons, removePreview }) => { + + const getUser = (id: string, username: string) => UserUtils.getUser(id) ?? makeDummyUser({ username, id }); + + const handleAddRemoveTheme = () => { + const onlineThemeLinks = themeLinks.includes(`${apiUrl}/${theme.name}`) + ? themeLinks.filter(link => link !== `${apiUrl}/${theme.name}`) + : [...themeLinks, `${apiUrl}/${theme.name}`]; + + setThemeLinks(onlineThemeLinks); + Vencord.Settings.themeLinks = onlineThemeLinks; + }; + + const handleThemeAttributesCheck = () => { + const requiresThemeAttributes = theme.requiresThemeAttributes ?? false; + + if (requiresThemeAttributes && !Settings.plugins.ThemeAttributes.enabled) { + openModal(modalProps => ( + + + Hold on! + + + +

This theme requires the ThemeAttributes plugin to work properly!

+

Do you want to enable it?

+
+
+ + + + +
+ )); + } else { + handleAddRemoveTheme(); + } + }; + + const handleViewSource = () => { + 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(`${apiUrl}/${theme.name}`); + } + }; + + return ( + + + {theme.name} + + + {Parser.parse(theme.description)} + + {!removePreview && ( + {theme.name} + )} +
+
+ {theme.tags && ( + + {theme.tags.map(tag => ( + + {tag} + + ))} + + )} + {!removeButtons && ( + < div style={{ marginTop: "8px", display: "flex", flexDirection: "row" }}> + + + + +
+ )} +
+ +
+ ); +}; diff --git a/src/equicordplugins/themeLibrary/components/ThemeInfoModal.tsx b/src/equicordplugins/themeLibrary/components/ThemeInfoModal.tsx index b0a2dbdb..ada8ca91 100644 --- a/src/equicordplugins/themeLibrary/components/ThemeInfoModal.tsx +++ b/src/equicordplugins/themeLibrary/components/ThemeInfoModal.tsx @@ -16,7 +16,7 @@ import { Button, Clipboard, Forms, Parser, React, showToast, Toasts } from "@web import { Theme, ThemeInfoModalProps } from "../types"; import { ClockIcon, DownloadIcon, WarningIcon } from "../utils/Icons"; -import { logger } from "./LikesComponent"; +import { logger } from "./ThemeTab"; const Native = VencordNative.pluginHelpers.ThemeLibrary as PluginNative; const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers"); @@ -25,7 +25,7 @@ async function downloadTheme(themesDir: string, theme: Theme) { try { await Native.downloadTheme(themesDir, theme); showToast(`Downloaded ${theme.name}!`, Toasts.Type.SUCCESS); - } catch (err: any) { + } catch (err: unknown) { logger.error(err); showToast(`Failed to download ${theme.name}! (check console)`, Toasts.Type.FAILURE); } @@ -121,34 +121,36 @@ export const ThemeInfoModal: React.FC = ({ author, theme, . )} Source - - - - - ))} + + + + + ))} > View Theme Source @@ -190,6 +192,7 @@ export const ThemeInfoModal: React.FC = ({ author, theme, . color={Button.Colors.GREEN} look={Button.Looks.OUTLINED} className={classes("vce-button", Margins.right8)} + disabled={!theme.content || theme.id === "preview"} onClick={async () => { const themesDir = await VencordNative.themes.getThemesDir(); const exists = await Native.themeExists(themesDir, theme); diff --git a/src/equicordplugins/themeLibrary/components/ThemeTab.tsx b/src/equicordplugins/themeLibrary/components/ThemeTab.tsx index b1917b9f..47a5058f 100644 --- a/src/equicordplugins/themeLibrary/components/ThemeTab.tsx +++ b/src/equicordplugins/themeLibrary/components/ThemeTab.tsx @@ -6,34 +6,27 @@ import "./styles.css"; -import { generateId } from "@api/Commands"; import * as DataStore from "@api/DataStore"; import { Settings } from "@api/Settings"; -import { CodeBlock } from "@components/CodeBlock"; import { ErrorCard } from "@components/ErrorCard"; import { OpenExternalIcon } from "@components/Icons"; import { SettingsTab, wrapTab } from "@components/VencordSettings/shared"; -import { proxyLazy } from "@utils/lazy"; +import { fetchUserProfile } from "@utils/discord"; import { Logger } from "@utils/Logger"; import { Margins } from "@utils/margins"; import { classes } from "@utils/misc"; import { ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal"; import { findByPropsLazy } from "@webpack"; -import { Button, Card, FluxDispatcher, Forms, Parser, 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 { Button, Forms, React, SearchableSelect, Switch, TabBar, Text, TextArea, TextInput, Toasts, useEffect, UserProfileStore, UserStore, useState, useStateFromStores } from "@webpack/common"; -import { SearchStatus, TabItem, Theme, ThemeLikeProps } from "../types"; +import { Contributor, SearchStatus, TabItem, Theme, ThemeLikeProps } from "../types"; import { isAuthorized } from "../utils/auth"; -import { LikesComponent } from "./LikesComponent"; -import { ThemeInfoModal } from "./ThemeInfoModal"; +import { ThemeCard } from "./ThemeCard"; const InputStyles = findByPropsLazy("inputDefault", "inputWrapper", "error"); -const UserRecord: Constructor> = proxyLazy(() => UserStore.getCurrentUser().constructor) as any; -const API_URL = "https://themes-delta.vercel.app/api"; - -const logger = new Logger("ThemeLibrary", "#e5c890"); +export const apiUrl = "https://themes-delta.vercel.app/api"; +export const logger = new Logger("ThemeLibrary", "#e5c890"); export async function fetchAllThemes(): Promise { const response = await themeRequest("/themes"); @@ -41,14 +34,14 @@ export async function fetchAllThemes(): Promise { const themes: Theme[] = Object.values(data); themes.forEach(theme => { if (!theme.source) { - theme.source = `${API_URL}/${theme.name}`; + theme.source = `${apiUrl}/${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 = {}) { - return fetch(API_URL + path, { + return fetch(apiUrl + path, { ...options, headers: { ...options.headers, @@ -56,34 +49,6 @@ export async function themeRequest(path: string, options: RequestInit = {}) { }); } -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 */ -`; - const SearchTags = { [SearchStatus.THEME]: "THEME", [SearchStatus.SNIPPET]: "SNIPPET", @@ -92,7 +57,6 @@ const SearchTags = { [SearchStatus.LIGHT]: "LIGHT", }; - function ThemeTab() { const [themes, setThemes] = useState([]); const [filteredThemes, setFilteredThemes] = useState([]); @@ -102,12 +66,11 @@ function ThemeTab() { const [hideWarningCard, setHideWarningCard] = useState(Settings.plugins.ThemeLibrary.hideWarningCard); 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) => { - const enabled = themeLinks.includes(`${API_URL}/${theme.name}`); + const enabled = themeLinks.includes(`${apiUrl}/${theme.name}`); const tags = new Set(theme.tags.map(tag => tag?.toLowerCase())); if (!enabled && searchValue.status === SearchStatus.ENABLED) return false; @@ -177,15 +140,22 @@ function ThemeTab() { <> {loading ? (
+

Getting the latest themes...

+

Loading themes...

+ }}> This won't take long!

+ + ) : ( <> {hideWarningCard ? null : ( @@ -204,7 +174,7 @@ function ThemeTab() { look={Button.Looks.FILLED} className={classes(Margins.top16, "vce-warning-button")} >Hide - + )}
{themes.slice(0, 2).map((theme: Theme) => ( - - - {theme.name} - - - {Parser.parse(theme.description)} - -
-
- {theme.tags && ( - - {theme.tags.map(tag => ( - - {tag} - - ))} - - )} -
- {themeLinks.includes(`${API_URL}/${theme.name}`) ? ( - - ) : ( - - - - - )); - } else { - setThemeLinks(onlineThemeLinks); - Vencord.Settings.themeLinks = onlineThemeLinks; - } - }} - size={Button.Sizes.MEDIUM} - color={Button.Colors.GREEN} - look={Button.Looks.FILLED} - className={Margins.right8} - > - Add Theme - - )} - - -
-
-
-
+ ))}
- {filteredThemes.map((theme: Theme) => ( - ( + + )) :
- - {theme.name} - - - {Parser.parse(theme.description)} - - {theme.name} -
-
- {theme.tags && ( - - {theme.tags.map(tag => ( - - {tag} - - ))} - - )} -
- {themeLinks.includes(`${API_URL}/${theme.name}`) ? ( - - ) : ( - - - - - )); - } else { - setThemeLinks(onlineThemeLinks); - Vencord.Settings.themeLinks = onlineThemeLinks; - } - }} - size={Button.Sizes.MEDIUM} - color={Button.Colors.GREEN} - look={Button.Looks.FILLED} - className={Margins.right8} - > - Add Theme - - )} - - - -
-
-
- - ))} + justifyContent: "center", + alignItems: "center", + }}> +

No theme found.

+

Try narrowing your search down.

+
+ }
)} - + ); } +// rework this! function SubmitThemes() { - const [themeContent, setContent] = useState(""); - const handleChange = (v: string) => setContent(v); + const currentUser = UserStore.getCurrentUser(); + const currentUserProfile = useStateFromStores([UserProfileStore], () => UserProfileStore.getUserProfile(currentUser.id)); + + if (!currentUserProfile && currentUser.id) fetchUserProfile(currentUser.id); + + const [theme, setTheme] = useState({ + title: "", + description: "", + content: "", + version: "", + type: "theme", + attribution: { + include_github: false, + sourceLink: "", + donationLink: "", + contributors: [{ + username: currentUser.username, + id: currentUser.id, + avatar: currentUser.getAvatarURL(), + github_username: currentUserProfile.connectedAccounts.find(x => x.type === "github")?.name ?? null, + }], + // isAllowedToRedistribute: false, + }, + screenshotMetadata: { + data: "", + name: "", + size: 0, + } + }); + const [valid, setValid] = useState(false); + + const handleImageChange = (e: React.ChangeEvent) => { + if (!e.target.files) return; + const image = e.target.files[0]; + const reader = new FileReader(); + + reader.onloadend = () => { + const imgElement = new Image(); + imgElement.src = reader.result as string; + + imgElement.onload = () => { + const canvas = document.createElement("canvas"); + const maxWidth = 800; + const scaleSize = maxWidth / imgElement.width; + + canvas.width = maxWidth; + canvas.height = imgElement.height * scaleSize; + + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.drawImage(imgElement, 0, 0, canvas.width, canvas.height); + + const resizedBase64String = canvas.toDataURL("image/jpeg", 0.7); + + handleChange("screenshotMetadata", { + data: resizedBase64String, + name: image.name, + size: image.size, + }); + setValid(!!theme.title && theme.content.length >= 50 && !!theme.version && !!theme.description); + } + }; + }; + + reader.readAsDataURL(image); + }; + + const handleChange = (p, v) => { + setTheme(prevTheme => { + const [first, ...rest] = p.split("."); + + const updateNestedObject = (obj, keys, value) => { + const key = keys[0]; + if (keys.length === 1) { + return { ...obj, [key]: value }; + } + + return { + ...obj, + [key]: updateNestedObject(obj[key] || {}, keys.slice(1), value) + }; + }; + + return updateNestedObject(prevTheme, [first, ...rest], v); + }); + }; + + + const setContributors = contributors => { + setTheme(prevTheme => ({ + ...prevTheme, + attribution: { + ...prevTheme.attribution, + contributors: [prevTheme.attribution.contributors[0], ...contributors] + } + })); + }; + + useEffect(() => { + logger.debug(valid); + }, [theme]); return (
- Theme Guidelines + Submission Guidelines - - Follow the formatting for your CSS to get credit for your theme. You can find the template below. - - - (your theme will be reviewed and can take up to 24 hours to be approved) - - - - + +
    +
  • Do not distribute themes or snippets that aren't yours.
  • +
  • Your submission must be at least 50 characters long.
  • +
  • Do not submit low-quality themes or snippets.
  • +
+ + Fields with * are required! + +
+ +
+
+ + {theme.type.charAt(0).toUpperCase()}{theme.type.slice(1)} Name * + + { }} + onBlur={e => { + const v = e.target.value; + handleChange("title", v); + setValid(!!theme.title && !!v && theme.content.length >= 50 && !!theme.version && !!theme.description && !!theme.screenshotMetadata.data); + }} + placeholder={`My awesome ${theme.type}`} + rows={1} + /> +
+
+ + {theme.type.charAt(0).toUpperCase()}{theme.type.slice(1)} Version * + + { }} + onBlur={e => { + const v = e.target.value; + handleChange("version", v); + setValid(!!theme.title && theme.content.length >= 50 && !!v && !!theme.description && !!theme.screenshotMetadata.data); + }} + placeholder="v1.0.0" + rows={1} + /> +
+
+ - Submit Themes - - - If you plan on updating your theme / snippet frequently, consider using an @import instead! - - -