From 39f82af5ed1829590aac707faecf054bbf895902 Mon Sep 17 00:00:00 2001 From: macaowski <130361669+macaowskii@users.noreply.github.com> Date: Wed, 6 May 2026 23:28:42 +0200 Subject: [PATCH] fix(userpfp): fixed imports, add avatar preview and file upload (#1030) * fix(userpfp): fixed imports, add avatar preview and file upload * feat: add sketchmyname to authors and refactor userPFP * Update AvatarModal.tsx --------- Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com> --- src/equicordplugins/userpfp/AvatarModal.tsx | 111 +++++++++++++++++--- src/equicordplugins/userpfp/index.tsx | 23 ++-- src/equicordplugins/userpfp/styles.css | 60 +++++++++++ src/utils/constants.ts | 4 + 4 files changed, 178 insertions(+), 20 deletions(-) diff --git a/src/equicordplugins/userpfp/AvatarModal.tsx b/src/equicordplugins/userpfp/AvatarModal.tsx index 35708dc80..bd730895f 100644 --- a/src/equicordplugins/userpfp/AvatarModal.tsx +++ b/src/equicordplugins/userpfp/AvatarModal.tsx @@ -5,32 +5,68 @@ */ import { set } from "@api/DataStore"; -import { classNameFactory } from "@api/Styles"; import { Button } from "@components/Button"; import { Flex } from "@components/Flex"; import { Heading } from "@components/Heading"; -import { Margins } from "@utils/margins"; +import { Margins } from "@components/margins"; +import { classNameFactory } from "@utils/css"; import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot } from "@utils/modal"; -import { TextInput, useState } from "@webpack/common"; +import { IconUtils, React, TextInput, Toasts, UserStore, useState } from "@webpack/common"; import { data, KEY_DATASTORE } from "."; -const cl = classNameFactory("vc-custom-avatars-"); -export function SetAvatarModal({ userId, modalProps }: { userId: string, modalProps: ModalProps; }) { +const cl = classNameFactory("vc-userpfp-"); + +function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +export function SetAvatarModal({ userId, modalProps }: { userId: string; modalProps: ModalProps; }) { const { avatars } = data; - const initialAvatarUrl = avatars[userId] || ""; - const [url, setUrl] = useState(initialAvatarUrl); + const user = UserStore.getUser(userId); + const originalAvatar = IconUtils.getUserAvatarURL(user, true, 128) || ""; + + const [url, setUrl] = useState(avatars[userId] || ""); + const [preview, setPreview] = useState(avatars[userId] || ""); + const [isDragging, setIsDragging] = useState(false); + const fileInputRef = React.useRef(null); function handleKey(e: KeyboardEvent) { if (e.key === "Enter") saveUserAvatar(); } + function handleUrlChange(val: string) { + setUrl(val); + setPreview(val.trim()); + } + + async function handleFile(file: File) { + if (!file.type.startsWith("image/")) return; + + if (file.type === "image/gif" || file.type === "image/webp") { + Toasts.show({ + message: "GIFs/WebP must be added via URL. Upload your GIF/WebP to a image hosting service and paste the link.", + type: Toasts.Type.FAILURE, + id: Toasts.genId(), + }); + return; + } + + const dataUrl = await fileToDataUrl(file); + setUrl(dataUrl); + setPreview(dataUrl); + } + async function saveUserAvatar() { if (!url.trim()) { await deleteUserAvatar(); return; } - avatars[userId] = url.trim(); await set(KEY_DATASTORE, avatars); modalProps.onClose(); @@ -48,20 +84,71 @@ export function SetAvatarModal({ userId, modalProps }: { userId: string, modalPr Custom Avatar + -
+ + {/* Preview */} +
+
+ Original + original +
+ → +
+ Local + local +
+
+ + {/* URL input */} +
Enter PNG/GIF URL
+ + {/* Drag & drop */} +
{ e.preventDefault(); setIsDragging(true); }} + onDragLeave={() => setIsDragging(false)} + onDrop={e => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files?.[0]; + if (file) handleFile(file); + }} + onClick={() => fileInputRef.current?.click()} + > + {isDragging ? "Drop here!" : "⬆ Drag an image or click to upload (for GIFs or WebP use a URL instead)"} + { + const file = e.currentTarget.files?.[0]; + if (file) handleFile(file); + e.currentTarget.value = ""; + }} + /> +
+ + - {avatars[userId] && ()} + {avatars[userId] && ( + + )} diff --git a/src/equicordplugins/userpfp/index.tsx b/src/equicordplugins/userpfp/index.tsx index d884dd59d..0bc2c5782 100644 --- a/src/equicordplugins/userpfp/index.tsx +++ b/src/equicordplugins/userpfp/index.tsx @@ -66,7 +66,7 @@ export default definePlugin({ name: "UserPFP", description: "Allows you to use an animated avatar without Nitro", tags: ["Appearance", "Customisation", "Servers"], - authors: [EquicordDevs.nexpid, Devs.thororen, EquicordDevs.soapphia], + authors: [EquicordDevs.nexpid, Devs.thororen, EquicordDevs.soapphia, EquicordDevs.sketchmyname], settings, data, settingsAboutComponent: () => ( @@ -130,13 +130,20 @@ export default definePlugin({ if (settings.store.preferNitro && user.avatar?.startsWith("a_")) return original(user, animated, size); if (!data.avatars[user.id]) return original(user, animated, size); - const res = new URL(data.avatars[user.id]); - res.searchParams.set("animated", animated ? "true" : "false"); - if (res && !animated) { - res.pathname = res.pathname.replaceAll(/\.gifv?/g, ".png"); - } + const avatarUrl = data.avatars[user.id]; - return res.toString(); + if (avatarUrl.startsWith("data:")) return avatarUrl; + + try { + const res = new URL(avatarUrl); + res.searchParams.set("animated", animated ? "true" : "false"); + if (!animated) { + res.pathname = res.pathname.replaceAll(/\.gifv?/g, ".png"); + } + return res.toString(); + } catch { + return original(user, animated, size); + } }, getAvatarServerHook: (original: any) => (config: any) => { const { userId, avatar, size, canAnimate } = config; @@ -170,4 +177,4 @@ export default definePlugin({ .then(remote => remote?.avatars && Object.assign(data.avatars, remote.avatars)) .catch(() => null); } -}); +}); \ No newline at end of file diff --git a/src/equicordplugins/userpfp/styles.css b/src/equicordplugins/userpfp/styles.css index 80d54852c..7a55af88b 100644 --- a/src/equicordplugins/userpfp/styles.css +++ b/src/equicordplugins/userpfp/styles.css @@ -9,4 +9,64 @@ .vc-userpfp-settings-heart { margin-left: 6px; +} + +.vc-userpfp-preview-row { + display: flex; + align-items: center; + justify-content: center; + flex-direction: row; + gap: 24px; + margin: 12px 0; +} + +.vc-userpfp-preview-box { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.vc-userpfp-preview-label { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.vc-userpfp-avatar { + width: 80px; + height: 80px; + border-radius: 50%; + object-fit: cover; + border: 3px solid var(--background-modifier-accent); +} + +.vc-userpfp-avatar-active { + border: 3px solid var(--brand-experiment); + box-shadow: 0 0 12px var(--brand-experiment-30a); +} + +.vc-userpfp-arrow { + font-size: 24px; + color: var(--text-muted); + margin-top: 20px; +} + +.vc-userpfp-dropzone { + border: 2px dashed var(--background-modifier-accent); + border-radius: 10px; + padding: 20px; + text-align: center; + cursor: pointer; + transition: background-color 0.15s, border-color 0.15s; + user-select: none; + color: var(--text-muted); + font-size: 14px; + margin-top: 8px; +} + +.vc-userpfp-dropzone-active { + border-color: var(--brand-experiment); + background: var(--brand-experiment-10a); } \ No newline at end of file diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 9df720575..1dd4fccb5 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1317,6 +1317,10 @@ export const EquicordDevs = Object.freeze({ name: "Moowi", id: 246128594756173824n }, + sketchmyname: { + name: "sketchmyname", + id: 1412164910443663491n + }, } satisfies Record); // iife so #__PURE__ works correctly