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>
This commit is contained in:
parent
b22fa4ae8f
commit
39f82af5ed
4 changed files with 178 additions and 20 deletions
|
|
@ -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<string> {
|
||||
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<string>(avatars[userId] || "");
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(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
|
|||
<Heading tag="h3">Custom Avatar</Heading>
|
||||
<ModalCloseButton onClick={modalProps.onClose} />
|
||||
</ModalHeader>
|
||||
|
||||
<ModalContent className={cl("modal-content")} onKeyDown={handleKey}>
|
||||
<section className={Margins.bottom16}>
|
||||
|
||||
{/* Preview */}
|
||||
<div className={cl("preview-row")}>
|
||||
<div className={cl("preview-box")}>
|
||||
<span className={cl("preview-label")}>Original</span>
|
||||
<img src={originalAvatar} className={cl("avatar")} alt="original" />
|
||||
</div>
|
||||
<span className={cl("arrow")}>→</span>
|
||||
<div className={cl("preview-box")}>
|
||||
<span className={cl("preview-label")}>Local</span>
|
||||
<img
|
||||
src={preview || originalAvatar}
|
||||
className={`${cl("avatar")} ${preview ? cl("avatar-active") : ""}`}
|
||||
alt="local"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL input */}
|
||||
<section className={Margins.bottom8}>
|
||||
<Heading tag="h3">Enter PNG/GIF URL</Heading>
|
||||
<TextInput
|
||||
placeholder="https://example.com/image.png"
|
||||
value={url}
|
||||
onChange={setUrl}
|
||||
value={url.startsWith("data:") ? "(uploaded file)" : url}
|
||||
onChange={handleUrlChange}
|
||||
autoFocus
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Drag & drop */}
|
||||
<div
|
||||
className={`${cl("dropzone")} ${isDragging ? cl("dropzone-active") : ""}`}
|
||||
onDragOver={e => { 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)"}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg"
|
||||
style={{ display: "none" }}
|
||||
onChange={e => {
|
||||
const file = e.currentTarget.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
e.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</ModalContent>
|
||||
|
||||
<ModalFooter className={cl("modal-footer")}>
|
||||
<Flex gap="8px">
|
||||
{avatars[userId] && (<Button variant="dangerPrimary" onClick={deleteUserAvatar}>Delete</Button>)}
|
||||
{avatars[userId] && (
|
||||
<Button variant="dangerPrimary" onClick={deleteUserAvatar}>Delete</Button>
|
||||
)}
|
||||
<Button onClick={saveUserAvatar}>Save</Button>
|
||||
</Flex>
|
||||
</ModalFooter>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1317,6 +1317,10 @@ export const EquicordDevs = Object.freeze({
|
|||
name: "Moowi",
|
||||
id: 246128594756173824n
|
||||
},
|
||||
sketchmyname: {
|
||||
name: "sketchmyname",
|
||||
id: 1412164910443663491n
|
||||
},
|
||||
} satisfies Record<string, Dev>);
|
||||
|
||||
// iife so #__PURE__ works correctly
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue