Add ProfileSets (#715)
* Add ProfileSets dragify before gta6??? * Update confirmModal.tsx * Update presetManager.tsx * fix lint (: * messy --------- Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
parent
ebfd36c1d6
commit
6e2bb1ea90
16 changed files with 1258 additions and 8 deletions
49
packages/discord-types/src/common/User.d.ts
vendored
49
packages/discord-types/src/common/User.d.ts
vendored
|
|
@ -83,3 +83,52 @@ export interface UserJSON {
|
||||||
username: string;
|
username: string;
|
||||||
globalName: string | undefined;
|
globalName: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProfileEffect {
|
||||||
|
skuId: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
accessibilityLabel?: string;
|
||||||
|
reducedMotionSrc?: string;
|
||||||
|
thumbnailPreviewSrc?: string;
|
||||||
|
effects?: any[];
|
||||||
|
animationType?: number;
|
||||||
|
staticFrameSrc?: string;
|
||||||
|
type?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Nameplate {
|
||||||
|
skuId: string;
|
||||||
|
asset: string;
|
||||||
|
label?: string;
|
||||||
|
palette?: string;
|
||||||
|
type?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfilePreset {
|
||||||
|
name: string;
|
||||||
|
timestamp: number;
|
||||||
|
avatarDataUrl?: string | null;
|
||||||
|
bannerDataUrl?: string | null;
|
||||||
|
bio?: string | null;
|
||||||
|
accentColor?: number | null;
|
||||||
|
themeColors?: number[] | null;
|
||||||
|
globalName?: string | null;
|
||||||
|
pronouns?: string | null;
|
||||||
|
avatarDecoration?: {
|
||||||
|
asset: string;
|
||||||
|
skuId: string;
|
||||||
|
} | null;
|
||||||
|
profileEffect?: ProfileEffect | null;
|
||||||
|
nameplate?: Nameplate | null;
|
||||||
|
primaryGuildId?: string | null;
|
||||||
|
customStatus?: CustomStatus | null;
|
||||||
|
displayNameStyles?: DisplayNameStyles | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomStatus {
|
||||||
|
text?: string;
|
||||||
|
emojiId?: string;
|
||||||
|
emojiName?: string;
|
||||||
|
expiresAtMs?: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { FluxStore, Guild, User, Application, ApplicationInstallParams } from "..";
|
import { FluxStore, Guild, User, Application, ApplicationInstallParams, ProfileEffect } from "..";
|
||||||
import { ApplicationFlags, ApplicationIntegrationType } from "../../enums";
|
import { ApplicationFlags, ApplicationIntegrationType } from "../../enums";
|
||||||
|
|
||||||
export interface MutualFriend {
|
export interface MutualFriend {
|
||||||
|
|
@ -70,9 +70,11 @@ export interface UserProfileBase extends Pick<User, "banner"> {
|
||||||
*/
|
*/
|
||||||
badges: ProfileBadge[];
|
badges: ProfileBadge[];
|
||||||
bio: string | undefined;
|
bio: string | undefined;
|
||||||
|
collectibles: ProfileEffect[];
|
||||||
popoutAnimationParticleType: string | null;
|
popoutAnimationParticleType: string | null;
|
||||||
profileEffectExpiresAt: number | Date | undefined;
|
profileEffectExpiresAt: number | Date | undefined;
|
||||||
profileEffectId: undefined | string;
|
profileEffectId: undefined | string;
|
||||||
|
profileEffect: ProfileEffect;
|
||||||
/**
|
/**
|
||||||
* often an empty string when not set
|
* often an empty string when not set
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import usrbg from "@plugins/usrbg";
|
||||||
import { Devs } from "@utils/constants";
|
import { Devs } from "@utils/constants";
|
||||||
import definePlugin, { OptionType } from "@utils/types";
|
import definePlugin, { OptionType } from "@utils/types";
|
||||||
import { User } from "@vencord/discord-types";
|
import { User } from "@vencord/discord-types";
|
||||||
import { findStoreLazy } from "@webpack";
|
import { UserProfileStore } from "@webpack/common";
|
||||||
|
|
||||||
import style from "./style.css?managed";
|
import style from "./style.css?managed";
|
||||||
|
|
||||||
|
|
@ -41,8 +41,6 @@ const settings = definePluginSettings({
|
||||||
|
|
||||||
const DATASTORE_KEY = "bannersEverywhere";
|
const DATASTORE_KEY = "bannersEverywhere";
|
||||||
|
|
||||||
const UserProfileStore = findStoreLazy("UserProfileStore");
|
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
name: "BannersEverywhere",
|
name: "BannersEverywhere",
|
||||||
description: "Displays banners in the member list ",
|
description: "Displays banners in the member list ",
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import "../styles.css";
|
|
||||||
|
|
||||||
import ErrorBoundary from "@components/ErrorBoundary";
|
import ErrorBoundary from "@components/ErrorBoundary";
|
||||||
import { Heading } from "@components/Heading";
|
import { Heading } from "@components/Heading";
|
||||||
import { SettingsTab, wrapTab } from "@components/settings";
|
import { SettingsTab, wrapTab } from "@components/settings";
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
import SettingsPlugin from "@plugins/_core/settings";
|
import SettingsPlugin from "@plugins/_core/settings";
|
||||||
import { EquicordDevs } from "@utils/constants";
|
import { EquicordDevs } from "@utils/constants";
|
||||||
import definePlugin, { StartAt } from "@utils/types";
|
import definePlugin, { StartAt } from "@utils/types";
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import "../styles.css";
|
|
||||||
|
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import { Heading } from "@components/Heading";
|
import { Heading } from "@components/Heading";
|
||||||
import { SettingsTab, wrapTab } from "@components/settings";
|
import { SettingsTab, wrapTab } from "@components/settings";
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
import { MagnifyingGlassIcon } from "@components/Icons";
|
import { MagnifyingGlassIcon } from "@components/Icons";
|
||||||
import SettingsPlugin, { settingsSectionMap } from "@plugins/_core/settings";
|
import SettingsPlugin, { settingsSectionMap } from "@plugins/_core/settings";
|
||||||
import { EquicordDevs } from "@utils/constants";
|
import { EquicordDevs } from "@utils/constants";
|
||||||
|
|
|
||||||
50
src/equicordplugins/profileSets/components/confirmModal.tsx
Normal file
50
src/equicordplugins/profileSets/components/confirmModal.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2026 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Heading } from "@components/Heading";
|
||||||
|
import { Button } from "@components/index";
|
||||||
|
import { Paragraph } from "@components/Paragraph";
|
||||||
|
import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot } from "@utils/modal";
|
||||||
|
interface ConfirmModalProps extends ModalProps {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmText: string;
|
||||||
|
cancelText: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmModal({ title, message, confirmText, cancelText, onConfirm, onCancel, onClose, transitionState }: ConfirmModalProps) {
|
||||||
|
return (
|
||||||
|
<ModalRoot transitionState={transitionState}>
|
||||||
|
<ModalHeader>
|
||||||
|
<Heading tag="h2">{title}</Heading>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalContent>
|
||||||
|
<Paragraph>{message}</Paragraph>
|
||||||
|
</ModalContent>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
onConfirm();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
onCancel();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalRoot>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
src/equicordplugins/profileSets/components/presetList.tsx
Normal file
205
src/equicordplugins/profileSets/components/presetList.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2026 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { classes } from "@utils/misc";
|
||||||
|
import { ProfilePreset } from "@vencord/discord-types";
|
||||||
|
import { ContextMenuApi, Menu, React, showToast, TextInput, Toasts } from "@webpack/common";
|
||||||
|
|
||||||
|
import { cl } from "..";
|
||||||
|
import { deletePreset, movePreset, renamePreset, updatePresetField } from "../utils/actions";
|
||||||
|
import { getCurrentProfile } from "../utils/profile";
|
||||||
|
|
||||||
|
interface PresetListProps {
|
||||||
|
presets: ProfilePreset[];
|
||||||
|
allPresets: ProfilePreset[];
|
||||||
|
avatarSize: number;
|
||||||
|
selectedPreset: number;
|
||||||
|
onLoad: (index: number) => void;
|
||||||
|
onUpdate: () => void;
|
||||||
|
guildId?: string;
|
||||||
|
currentPage: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PresetList({ presets, allPresets, avatarSize, selectedPreset, onLoad, onUpdate, guildId, currentPage, onPageChange }: PresetListProps) {
|
||||||
|
const [renaming, setRenaming] = React.useState<number>(-1);
|
||||||
|
const [renameText, setRenameText] = React.useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cl("list-container")}>
|
||||||
|
{presets.map(preset => {
|
||||||
|
const actualIndex = allPresets.indexOf(preset);
|
||||||
|
const isRenaming = renaming === actualIndex;
|
||||||
|
const date = new Date(preset.timestamp);
|
||||||
|
const formattedDate = date.toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric"
|
||||||
|
});
|
||||||
|
const formattedTime = date.toLocaleTimeString(undefined, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={actualIndex}
|
||||||
|
tabIndex={isRenaming ? -1 : 0}
|
||||||
|
role="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!isRenaming) {
|
||||||
|
onLoad(actualIndex);
|
||||||
|
showToast(`Profile "${preset.name}" loaded successfully`, Toasts.Type.SUCCESS);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (!isRenaming && (e.key === "Enter" || e.key === " ")) {
|
||||||
|
e.preventDefault();
|
||||||
|
onLoad(actualIndex);
|
||||||
|
showToast(`Profile "${preset.name}" loaded successfully`, Toasts.Type.SUCCESS);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={classes(cl("row"), !isRenaming && selectedPreset === actualIndex ? "selected" : "")}
|
||||||
|
>
|
||||||
|
<div className={cl("avatar-url")}>
|
||||||
|
{preset.avatarDataUrl && (
|
||||||
|
<img
|
||||||
|
src={preset.avatarDataUrl}
|
||||||
|
alt=""
|
||||||
|
className={cl("avatar")}
|
||||||
|
style={{ width: `${avatarSize}px`, height: `${avatarSize}px` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className={cl("rename")}>
|
||||||
|
{isRenaming ? (
|
||||||
|
<TextInput
|
||||||
|
value={renameText}
|
||||||
|
onChange={setRenameText}
|
||||||
|
onBlur={() => {
|
||||||
|
if (renameText.trim()) {
|
||||||
|
renamePreset(actualIndex, renameText);
|
||||||
|
onUpdate();
|
||||||
|
}
|
||||||
|
setRenaming(-1);
|
||||||
|
}}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
if (renameText.trim()) {
|
||||||
|
renamePreset(actualIndex, renameText);
|
||||||
|
onUpdate();
|
||||||
|
}
|
||||||
|
setRenaming(-1);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setRenaming(-1);
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={cl("name")}>
|
||||||
|
{preset.name}
|
||||||
|
</div>
|
||||||
|
<div className={cl("timestamp")}>
|
||||||
|
{formattedDate} at {formattedTime}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={cl("updated")}>
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
className={cl("menu-icon")}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const target = e.currentTarget;
|
||||||
|
ContextMenuApi.openContextMenu(e, () => (
|
||||||
|
<Menu.Menu navId="preset-options" onClose={ContextMenuApi.closeContextMenu}>
|
||||||
|
<Menu.MenuItem
|
||||||
|
id="rename"
|
||||||
|
label="Rename"
|
||||||
|
action={() => {
|
||||||
|
setRenaming(actualIndex);
|
||||||
|
setRenameText(preset.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Menu.MenuItem
|
||||||
|
id="update"
|
||||||
|
label="Update"
|
||||||
|
action={async () => {
|
||||||
|
const profile = await getCurrentProfile(guildId);
|
||||||
|
Object.entries(profile).forEach(([key, value]) => {
|
||||||
|
if (value !== null && value !== undefined) {
|
||||||
|
updatePresetField(actualIndex, key as any, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
showToast("Profile updated successfully", Toasts.Type.SUCCESS);
|
||||||
|
onUpdate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Menu.MenuSeparator />
|
||||||
|
{actualIndex > 0 && (
|
||||||
|
<Menu.MenuItem
|
||||||
|
id="move-up"
|
||||||
|
label="Move Up"
|
||||||
|
action={() => {
|
||||||
|
movePreset(actualIndex, actualIndex - 1);
|
||||||
|
onUpdate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{actualIndex < allPresets.length - 1 && (
|
||||||
|
<Menu.MenuItem
|
||||||
|
id="move-down"
|
||||||
|
label="Move Down"
|
||||||
|
action={() => {
|
||||||
|
movePreset(actualIndex, actualIndex + 1);
|
||||||
|
onUpdate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentPage > 1 && (
|
||||||
|
<Menu.MenuItem
|
||||||
|
id="move-to-page-1"
|
||||||
|
label="Move to Page 1"
|
||||||
|
action={() => {
|
||||||
|
movePreset(actualIndex, 0);
|
||||||
|
onPageChange(1);
|
||||||
|
onUpdate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Menu.MenuSeparator />
|
||||||
|
<Menu.MenuItem
|
||||||
|
id="delete"
|
||||||
|
label="Delete"
|
||||||
|
color="danger"
|
||||||
|
action={async () => {
|
||||||
|
await deletePreset(actualIndex);
|
||||||
|
onUpdate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Menu.Menu>
|
||||||
|
));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M10 3a1.5 1.5 0 110 3 1.5 1.5 0 010-3zm0 5a1.5 1.5 0 110 3 1.5 1.5 0 010-3zm0 5a1.5 1.5 0 110 3 1.5 1.5 0 010-3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
src/equicordplugins/profileSets/components/presetManager.tsx
Normal file
198
src/equicordplugins/profileSets/components/presetManager.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2026 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Heading } from "@components/Heading";
|
||||||
|
import { Button } from "@components/index";
|
||||||
|
import { openModal } from "@utils/modal";
|
||||||
|
import { React, TextInput } from "@webpack/common";
|
||||||
|
|
||||||
|
import { cl, settings } from "../index";
|
||||||
|
import { exportPresets, importPresets, savePreset } from "../utils/actions";
|
||||||
|
import { loadPresetAsPending } from "../utils/profile";
|
||||||
|
import { presets, setCurrentPresetIndex } from "../utils/storage";
|
||||||
|
import { ConfirmModal } from "./confirmModal";
|
||||||
|
import { PresetList } from "./presetList";
|
||||||
|
|
||||||
|
const PRESETS_PER_PAGE = 5;
|
||||||
|
|
||||||
|
export function PresetManager({ guildId }: { guildId?: string; }) {
|
||||||
|
const [presetName, setPresetName] = React.useState("");
|
||||||
|
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
|
||||||
|
const [isSaving, setIsSaving] = React.useState(false);
|
||||||
|
const [currentPage, setCurrentPage] = React.useState(1);
|
||||||
|
const [pageInput, setPageInput] = React.useState("1");
|
||||||
|
const [selectedPreset, setSelectedPreset] = React.useState<number>(-1);
|
||||||
|
const [searchMode, setSearchMode] = React.useState(false);
|
||||||
|
|
||||||
|
const filteredPresets = searchMode
|
||||||
|
? presets.filter(p => p.name.toLowerCase().includes(presetName.toLowerCase()))
|
||||||
|
: presets;
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredPresets.length / PRESETS_PER_PAGE);
|
||||||
|
const startIndex = (currentPage - 1) * PRESETS_PER_PAGE;
|
||||||
|
const currentPresets = filteredPresets.slice(startIndex, startIndex + PRESETS_PER_PAGE);
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
if (newPage >= 1 && newPage <= totalPages) {
|
||||||
|
setCurrentPage(newPage);
|
||||||
|
setPageInput(String(newPage));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePreset = async () => {
|
||||||
|
if (!presetName.trim()) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
await savePreset(presetName.trim(), guildId);
|
||||||
|
setPresetName("");
|
||||||
|
setIsSaving(false);
|
||||||
|
const newTotalPages = Math.ceil(presets.length / PRESETS_PER_PAGE);
|
||||||
|
handlePageChange(newTotalPages);
|
||||||
|
forceUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadPreset = (index: number) => {
|
||||||
|
setSelectedPreset(index);
|
||||||
|
setCurrentPresetIndex(index);
|
||||||
|
loadPresetAsPending(presets[index], guildId);
|
||||||
|
forceUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const showOverridePrompt = (): Promise<boolean> => {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
openModal(props => (
|
||||||
|
<ConfirmModal
|
||||||
|
{...props}
|
||||||
|
title="Override Existing Presets?"
|
||||||
|
message="Some imported profiles match existing ones. Do you want to override all profiles or combine them?"
|
||||||
|
confirmText="Override"
|
||||||
|
cancelText="Combine"
|
||||||
|
onConfirm={() => resolve(true)}
|
||||||
|
onCancel={() => resolve(false)}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const avatarSize = settings.store.avatarSize || 40;
|
||||||
|
const hasPresets = presets.length > 0;
|
||||||
|
const shouldShowPagination = filteredPresets.length > PRESETS_PER_PAGE;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cl("section")} >
|
||||||
|
<Heading tag="h3" className={cl("heading")}>
|
||||||
|
Saved Profiles
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<div className={cl("text")}>
|
||||||
|
<TextInput
|
||||||
|
placeholder={searchMode ? "Search profiles..." : "Profile Name"}
|
||||||
|
value={presetName}
|
||||||
|
onChange={setPresetName}
|
||||||
|
className={cl("text-input")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cl("search")}>
|
||||||
|
{!searchMode && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={isSaving || !presetName.trim()}
|
||||||
|
onClick={handleSavePreset}
|
||||||
|
className={cl("search-button")}
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving..." : "Save Profile"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasPresets && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={searchMode ? "primary" : "secondary"}
|
||||||
|
onClick={() => {
|
||||||
|
setSearchMode(!searchMode);
|
||||||
|
handlePageChange(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{searchMode ? "Cancel Search" : "Search"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasPresets && (
|
||||||
|
<>
|
||||||
|
<PresetList
|
||||||
|
presets={currentPresets}
|
||||||
|
allPresets={presets}
|
||||||
|
avatarSize={avatarSize}
|
||||||
|
selectedPreset={selectedPreset}
|
||||||
|
onLoad={handleLoadPreset}
|
||||||
|
onUpdate={forceUpdate}
|
||||||
|
guildId={guildId}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{shouldShowPagination && (
|
||||||
|
<div className={cl("pagination")}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</Button>
|
||||||
|
<div className={cl("page")}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pageInput}
|
||||||
|
onChange={e => {
|
||||||
|
const { value } = e.target;
|
||||||
|
setPageInput(value);
|
||||||
|
const num = parseInt(value);
|
||||||
|
if (!isNaN(num) && num >= 1 && num <= totalPages) {
|
||||||
|
setCurrentPage(num);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cl("page-input")}
|
||||||
|
/>
|
||||||
|
<span className={cl("page-of")}>
|
||||||
|
/ {totalPages}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={cl("import")}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => importPresets(forceUpdate, showOverridePrompt)}
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={exportPresets}
|
||||||
|
>
|
||||||
|
Export All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className={cl("block")} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/equicordplugins/profileSets/index.tsx
Normal file
49
src/equicordplugins/profileSets/index.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2026 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
import { definePluginSettings } from "@api/Settings";
|
||||||
|
import { EquicordDevs } from "@utils/constants";
|
||||||
|
import { classNameFactory } from "@utils/css";
|
||||||
|
import definePlugin, { makeRange, OptionType } from "@utils/types";
|
||||||
|
import { React } from "@webpack/common";
|
||||||
|
|
||||||
|
import { PresetManager } from "./components/presetManager";
|
||||||
|
import { loadPresets } from "./utils/storage";
|
||||||
|
|
||||||
|
export const cl = classNameFactory("vc-profile-presets-");
|
||||||
|
export const settings = definePluginSettings({
|
||||||
|
avatarSize: {
|
||||||
|
type: OptionType.SLIDER,
|
||||||
|
description: "Avatar size in preset list.",
|
||||||
|
default: 40,
|
||||||
|
markers: makeRange(32, 96, 8),
|
||||||
|
stickToMarkers: true
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
name: "ProfileSets",
|
||||||
|
description: "Allows you to save and load different profile presets, via the Profile Section in Settings.",
|
||||||
|
authors: [EquicordDevs.omaw, EquicordDevs.justjxke],
|
||||||
|
settings,
|
||||||
|
patches: [
|
||||||
|
{
|
||||||
|
find: "DefaultCustomizationSections: user cannot be undefined",
|
||||||
|
replacement: {
|
||||||
|
match: /return.{0,50}children:\[(?<=\.getLegacyUsername\(\).*?)/,
|
||||||
|
replace: "$&$self.renderPresetSection(),"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
start() {
|
||||||
|
loadPresets();
|
||||||
|
},
|
||||||
|
renderPresetSection() {
|
||||||
|
return <PresetManager />;
|
||||||
|
}
|
||||||
|
});
|
||||||
164
src/equicordplugins/profileSets/styles.css
Normal file
164
src/equicordplugins/profileSets/styles.css
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
.vc-profile-presets-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-heading {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-text {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-text-input {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-search {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-search-button {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-page-of {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-import {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-block {
|
||||||
|
margin: 16px 0;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--background-modifier-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-avatar-url {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-rename {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-updated {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-list-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid var(--background-modifier-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-row:hover {
|
||||||
|
background-color: var(--background-modifier-hover) !important;
|
||||||
|
border-color: var(--background-modifier-accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-row:focus-visible {
|
||||||
|
border-color: var(--brand-experiment) !important;
|
||||||
|
box-shadow: 0 0 0 2px var(--brand-experiment-30a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-row.selected {
|
||||||
|
background-color: var(--background-modifier-selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-avatar {
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--background-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--header-primary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-timestamp {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-menu-icon {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-menu-icon:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-profile-presets-page-input {
|
||||||
|
width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 2px solid var(--background-modifier-accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
135
src/equicordplugins/profileSets/utils/actions.ts
Normal file
135
src/equicordplugins/profileSets/utils/actions.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2026 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ProfilePreset } from "@vencord/discord-types";
|
||||||
|
import { showToast, Toasts } from "@webpack/common";
|
||||||
|
|
||||||
|
import { getCurrentProfile } from "./profile";
|
||||||
|
import { addPreset, movePresetInArray, presets, removePreset, replaceAllPresets, savePresetsData, updatePreset } from "./storage";
|
||||||
|
|
||||||
|
export async function savePreset(name: string, guildId?: string) {
|
||||||
|
const profile = await getCurrentProfile(guildId);
|
||||||
|
const newPreset: ProfilePreset = {
|
||||||
|
name,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
...profile,
|
||||||
|
};
|
||||||
|
addPreset(newPreset);
|
||||||
|
await savePresetsData();
|
||||||
|
showToast(`Profile "${name}" saved successfully`, Toasts.Type.SUCCESS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePresetField(index: number, field: keyof Omit<ProfilePreset, "name" | "timestamp">, value: any) {
|
||||||
|
if (index < 0 || index >= presets.length) return;
|
||||||
|
|
||||||
|
const updatedPreset = {
|
||||||
|
...presets[index],
|
||||||
|
[field]: value,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
updatePreset(index, updatedPreset);
|
||||||
|
await savePresetsData();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePreset(index: number) {
|
||||||
|
if (index < 0 || index >= presets.length) return;
|
||||||
|
|
||||||
|
const preset = presets[index];
|
||||||
|
removePreset(index);
|
||||||
|
await savePresetsData();
|
||||||
|
showToast(`Profile "${preset.name}" deleted`, Toasts.Type.MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function movePreset(fromIndex: number, toIndex: number) {
|
||||||
|
if (fromIndex < 0 || fromIndex >= presets.length || toIndex < 0 || toIndex >= presets.length) return;
|
||||||
|
|
||||||
|
movePresetInArray(fromIndex, toIndex);
|
||||||
|
await savePresetsData();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renamePreset(index: number, newName: string) {
|
||||||
|
if (index < 0 || index >= presets.length || !newName.trim()) return;
|
||||||
|
|
||||||
|
const updatedPreset = { ...presets[index], name: newName.trim() };
|
||||||
|
updatePreset(index, updatedPreset);
|
||||||
|
await savePresetsData();
|
||||||
|
showToast(`Profile renamed to "${newName}"`, Toasts.Type.SUCCESS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportPresets() {
|
||||||
|
try {
|
||||||
|
const dataStr = JSON.stringify(presets, null, 2);
|
||||||
|
const dataBlob = new Blob([dataStr], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(dataBlob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `profile-presets-${Date.now()}.json`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
showToast("Profiles exported successfully", Toasts.Type.SUCCESS);
|
||||||
|
} catch (err) {
|
||||||
|
showToast("Failed to export profiles", Toasts.Type.FAILURE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function presetsAreEqual(preset1: ProfilePreset, preset2: ProfilePreset): boolean {
|
||||||
|
return preset1.avatarDataUrl === preset2.avatarDataUrl &&
|
||||||
|
preset1.bannerDataUrl === preset2.bannerDataUrl &&
|
||||||
|
preset1.bio === preset2.bio &&
|
||||||
|
preset1.pronouns === preset2.pronouns &&
|
||||||
|
JSON.stringify(preset1.themeColors) === JSON.stringify(preset2.themeColors) &&
|
||||||
|
JSON.stringify(preset1.avatarDecoration) === JSON.stringify(preset2.avatarDecoration) &&
|
||||||
|
JSON.stringify(preset1.profileEffect) === JSON.stringify(preset2.profileEffect) &&
|
||||||
|
JSON.stringify(preset1.nameplate) === JSON.stringify(preset2.nameplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importPresets(forceUpdate: () => void, onOverridePrompt: () => Promise<boolean>) {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = "application/json";
|
||||||
|
input.onchange = async (e: any) => {
|
||||||
|
try {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const text = await file.text();
|
||||||
|
const importedPresets = JSON.parse(text);
|
||||||
|
|
||||||
|
if (!Array.isArray(importedPresets)) {
|
||||||
|
showToast("Invalid profile file format", Toasts.Type.FAILURE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (presets.length > 0) {
|
||||||
|
const hasDuplicates = importedPresets.some(imported =>
|
||||||
|
presets.some(existing => presetsAreEqual(imported, existing))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasDuplicates) {
|
||||||
|
const shouldOverride = await onOverridePrompt();
|
||||||
|
if (shouldOverride) {
|
||||||
|
replaceAllPresets(importedPresets);
|
||||||
|
} else {
|
||||||
|
const combined = [...presets, ...importedPresets];
|
||||||
|
replaceAllPresets(combined);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const combined = [...presets, ...importedPresets];
|
||||||
|
replaceAllPresets(combined);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
replaceAllPresets(importedPresets);
|
||||||
|
}
|
||||||
|
|
||||||
|
await savePresetsData();
|
||||||
|
forceUpdate();
|
||||||
|
showToast(`Imported ${importedPresets.length} profiles successfully`, Toasts.Type.SUCCESS);
|
||||||
|
} catch (err) {
|
||||||
|
showToast("Failed to import profiles", Toasts.Type.FAILURE);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
329
src/equicordplugins/profileSets/utils/profile.ts
Normal file
329
src/equicordplugins/profileSets/utils/profile.ts
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2026 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getUserSettingLazy } from "@api/UserSettings";
|
||||||
|
import { Logger } from "@utils/Logger";
|
||||||
|
import { CustomStatus, ProfileEffect, ProfilePreset } from "@vencord/discord-types";
|
||||||
|
import { findStoreLazy } from "@webpack";
|
||||||
|
import { FluxDispatcher, UserProfileStore, UserStore } from "@webpack/common";
|
||||||
|
|
||||||
|
const logger = new Logger("ProfilePresets");
|
||||||
|
const UserProfileSettingsStore = findStoreLazy("UserProfileSettingsStore");
|
||||||
|
const CustomStatusSettings = getUserSettingLazy("status", "customStatus")!;
|
||||||
|
|
||||||
|
function dispatch(type: string, payload: any) {
|
||||||
|
FluxDispatcher.dispatch({ type, ...payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function imageUrlToBase64(url: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const blob = await response.blob();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to convert image to base64", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processImage(imageData: any, userId: string, type: "avatar" | "banner"): Promise<string | null> {
|
||||||
|
if (!imageData) return null;
|
||||||
|
|
||||||
|
if (typeof imageData === "object" && imageData.imageUri) {
|
||||||
|
return imageData.imageUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof imageData === "string") {
|
||||||
|
if (imageData.startsWith("data:")) return imageData;
|
||||||
|
|
||||||
|
const isAnimated = imageData.startsWith("a_");
|
||||||
|
const size = type === "banner" ? 1024 : 512;
|
||||||
|
const urlPath = type === "banner" ? "banners" : "avatars";
|
||||||
|
const url = `https://cdn.discordapp.com/${urlPath}/${userId}/${imageData}.${isAnimated ? "gif" : "png"}?size=${size}`;
|
||||||
|
return await imageUrlToBase64(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentProfile(guildId?: string): Promise<Omit<ProfilePreset, "name" | "timestamp">> {
|
||||||
|
const currentUser = UserStore.getCurrentUser();
|
||||||
|
const userProfile = UserProfileStore.getUserProfile(currentUser.id);
|
||||||
|
const userAny = currentUser as any;
|
||||||
|
|
||||||
|
const pendingChanges = UserProfileSettingsStore.getPendingChanges(guildId);
|
||||||
|
|
||||||
|
const customStatusSetting = CustomStatusSettings.getSetting();
|
||||||
|
const customStatus: CustomStatus | null = customStatusSetting ? {
|
||||||
|
text: customStatusSetting.text,
|
||||||
|
emojiId: customStatusSetting.emojiId,
|
||||||
|
emojiName: customStatusSetting.emojiName,
|
||||||
|
expiresAtMs: customStatusSetting.expiresAtMs
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
const avatarDecoration = (pendingChanges.pendingAvatarDecoration !== undefined
|
||||||
|
? pendingChanges.pendingAvatarDecoration
|
||||||
|
: userAny.avatarDecorationData) ? {
|
||||||
|
asset: (pendingChanges.pendingAvatarDecoration || userAny.avatarDecorationData).asset,
|
||||||
|
skuId: (pendingChanges.pendingAvatarDecoration || userAny.avatarDecorationData).skuId
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
let profileEffect: ProfileEffect | null = null;
|
||||||
|
const effectToUse = pendingChanges.pendingProfileEffect !== undefined
|
||||||
|
? pendingChanges.pendingProfileEffect
|
||||||
|
: userProfile?.profileEffect;
|
||||||
|
|
||||||
|
if (effectToUse) {
|
||||||
|
if (effectToUse.skuId && effectToUse.effects) {
|
||||||
|
profileEffect = {
|
||||||
|
skuId: effectToUse.skuId,
|
||||||
|
title: effectToUse.title,
|
||||||
|
description: effectToUse.description,
|
||||||
|
accessibilityLabel: effectToUse.accessibilityLabel,
|
||||||
|
reducedMotionSrc: effectToUse.reducedMotionSrc,
|
||||||
|
thumbnailPreviewSrc: effectToUse.thumbnailPreviewSrc,
|
||||||
|
effects: effectToUse.effects,
|
||||||
|
animationType: effectToUse.animationType,
|
||||||
|
staticFrameSrc: effectToUse.staticFrameSrc,
|
||||||
|
type: effectToUse.type || 1
|
||||||
|
};
|
||||||
|
} else if (effectToUse.skuId) {
|
||||||
|
const collectible = userProfile?.collectibles?.find((c: any) => c.skuId === effectToUse.skuId);
|
||||||
|
if (collectible) {
|
||||||
|
profileEffect = {
|
||||||
|
skuId: collectible.skuId,
|
||||||
|
title: collectible.title,
|
||||||
|
description: collectible.description,
|
||||||
|
accessibilityLabel: collectible.accessibilityLabel,
|
||||||
|
reducedMotionSrc: collectible.reducedMotionSrc,
|
||||||
|
thumbnailPreviewSrc: collectible.thumbnailPreviewSrc,
|
||||||
|
effects: collectible.effects,
|
||||||
|
animationType: collectible.animationType,
|
||||||
|
staticFrameSrc: collectible.staticFrameSrc,
|
||||||
|
type: collectible.type || 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameplateToUse = pendingChanges.pendingNameplate !== undefined
|
||||||
|
? pendingChanges.pendingNameplate
|
||||||
|
: userAny.collectibles?.nameplate;
|
||||||
|
|
||||||
|
const nameplate = nameplateToUse ? {
|
||||||
|
skuId: nameplateToUse.skuId,
|
||||||
|
asset: nameplateToUse.asset,
|
||||||
|
label: nameplateToUse.label,
|
||||||
|
palette: nameplateToUse.palette,
|
||||||
|
type: nameplateToUse.type || 2
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
const displayNameStylesToUse = pendingChanges.pendingDisplayNameStyles !== undefined
|
||||||
|
? pendingChanges.pendingDisplayNameStyles
|
||||||
|
: userAny.displayNameStyles;
|
||||||
|
|
||||||
|
const displayNameStyles = displayNameStylesToUse ? {
|
||||||
|
font_id: displayNameStylesToUse.font_id,
|
||||||
|
effect_id: displayNameStylesToUse.effect_id,
|
||||||
|
colors: Array.isArray(displayNameStylesToUse.colors) ? displayNameStylesToUse.colors : []
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
const avatarToUse = pendingChanges.pendingAvatar !== undefined
|
||||||
|
? pendingChanges.pendingAvatar
|
||||||
|
: currentUser.avatar;
|
||||||
|
|
||||||
|
const avatarDataUrl = await processImage(avatarToUse, currentUser.id, "avatar");
|
||||||
|
|
||||||
|
const bannerToUse = pendingChanges.pendingBanner !== undefined
|
||||||
|
? pendingChanges.pendingBanner
|
||||||
|
: userProfile?.banner;
|
||||||
|
|
||||||
|
const bannerDataUrl = await processImage(bannerToUse, currentUser.id, "banner");
|
||||||
|
|
||||||
|
return {
|
||||||
|
avatarDataUrl,
|
||||||
|
bannerDataUrl,
|
||||||
|
bio: pendingChanges.pendingBio !== undefined ? pendingChanges.pendingBio : userProfile?.bio || null,
|
||||||
|
accentColor: pendingChanges.pendingAccentColor !== undefined ? pendingChanges.pendingAccentColor : userProfile?.accentColor || null,
|
||||||
|
themeColors: pendingChanges.pendingThemeColors !== undefined
|
||||||
|
? pendingChanges.pendingThemeColors
|
||||||
|
: userProfile?.themeColors || null,
|
||||||
|
globalName: pendingChanges.pendingGlobalName !== undefined ? pendingChanges.pendingGlobalName : currentUser.globalName || null,
|
||||||
|
pronouns: pendingChanges.pendingPronouns !== undefined ? pendingChanges.pendingPronouns : userProfile?.pronouns || null,
|
||||||
|
avatarDecoration,
|
||||||
|
profileEffect,
|
||||||
|
nameplate,
|
||||||
|
primaryGuildId: pendingChanges.pendingPrimaryGuildId !== undefined ? pendingChanges.pendingPrimaryGuildId : userAny.primaryGuild?.identityGuildId || null,
|
||||||
|
customStatus,
|
||||||
|
displayNameStyles
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPresetAsPending(preset: ProfilePreset, guildId?: string) {
|
||||||
|
try {
|
||||||
|
if (preset.avatarDataUrl) {
|
||||||
|
dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_AVATAR", { guildId, avatar: preset.avatarDataUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset.bannerDataUrl) {
|
||||||
|
dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_BANNER", { guildId, banner: preset.bannerDataUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset.bio !== undefined) {
|
||||||
|
dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_BIO", { guildId, bio: preset.bio });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset.pronouns !== undefined) {
|
||||||
|
dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_PRONOUNS", { guildId, pronouns: preset.pronouns });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset.themeColors !== undefined) {
|
||||||
|
dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_THEME_COLORS", {
|
||||||
|
guildId,
|
||||||
|
themeColors: Array.isArray(preset.themeColors) ? preset.themeColors.map(Number) : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset.globalName !== undefined) {
|
||||||
|
dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_GLOBAL_NAME", { globalName: preset.globalName });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset.avatarDecoration !== undefined) {
|
||||||
|
dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_COLLECTIBLES_ITEM", {
|
||||||
|
guildId,
|
||||||
|
item: { type: 0, value: preset.avatarDecoration }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset.profileEffect !== undefined) {
|
||||||
|
dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_COLLECTIBLES_ITEM", {
|
||||||
|
guildId,
|
||||||
|
item: { type: 1, value: preset.profileEffect }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset.nameplate !== undefined) {
|
||||||
|
dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_COLLECTIBLES_ITEM", {
|
||||||
|
guildId,
|
||||||
|
item: { type: 2, value: preset.nameplate }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset.displayNameStyles) {
|
||||||
|
dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_DISPLAY_NAME_STYLES", {
|
||||||
|
guildId,
|
||||||
|
displayNameStyles: preset.displayNameStyles
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset.primaryGuildId !== undefined && !guildId) {
|
||||||
|
dispatch("USER_SETTINGS_SET_PENDING_PRIMARY_GUILD_ID", { primaryGuildId: preset.primaryGuildId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset.customStatus && !guildId) {
|
||||||
|
CustomStatusSettings.updateSetting({
|
||||||
|
text: preset.customStatus.text || "",
|
||||||
|
expiresAtMs: preset.customStatus.expiresAtMs || "0",
|
||||||
|
emojiId: preset.customStatus.emojiId || "0",
|
||||||
|
emojiName: preset.customStatus.emojiName || ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to load profile", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyUserProfile(userId: string) {
|
||||||
|
const user = UserStore.getUser(userId);
|
||||||
|
const userProfile = UserProfileStore.getUserProfile(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userProfile?.banner) {
|
||||||
|
const bannerUrl = `https://cdn.discordapp.com/banners/${userId}/${userProfile.banner}.${userProfile.banner.startsWith("a_") ? "gif" : "png"}?size=1024`;
|
||||||
|
const bannerDataUrl = await imageUrlToBase64(bannerUrl);
|
||||||
|
if (bannerDataUrl) {
|
||||||
|
dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_BANNER", { guildId: undefined, banner: bannerDataUrl });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userProfile?.bio) {
|
||||||
|
dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_BIO", { guildId: undefined, bio: userProfile.bio });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userProfile?.pronouns) {
|
||||||
|
dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_PRONOUNS", { guildId: undefined, pronouns: userProfile.pronouns });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userProfile?.themeColors) {
|
||||||
|
dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_THEME_COLORS", { guildId: undefined, themeColors: userProfile.themeColors });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAny = user as any;
|
||||||
|
if (userAny.avatarDecorationData) {
|
||||||
|
dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_COLLECTIBLES_ITEM", {
|
||||||
|
guildId: undefined,
|
||||||
|
item: {
|
||||||
|
type: 0,
|
||||||
|
value: {
|
||||||
|
asset: userAny.avatarDecorationData.asset,
|
||||||
|
skuId: userAny.avatarDecorationData.skuId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userAny.collectibles?.nameplate) {
|
||||||
|
const np = userAny.collectibles.nameplate;
|
||||||
|
dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_COLLECTIBLES_ITEM", {
|
||||||
|
guildId: undefined,
|
||||||
|
item: {
|
||||||
|
type: 2,
|
||||||
|
value: {
|
||||||
|
skuId: np.skuId,
|
||||||
|
asset: np.asset,
|
||||||
|
label: np.label,
|
||||||
|
palette: np.palette,
|
||||||
|
type: 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userProfile?.profileEffect?.skuId) {
|
||||||
|
const collectible = userProfile.collectibles?.find((c: any) => c.skuId === userProfile.profileEffect.skuId);
|
||||||
|
if (collectible) {
|
||||||
|
dispatch("USER_SETTINGS_ACCOUNT_SET_PENDING_COLLECTIBLES_ITEM", {
|
||||||
|
guildId: undefined,
|
||||||
|
item: {
|
||||||
|
type: 1,
|
||||||
|
value: {
|
||||||
|
skuId: collectible.skuId,
|
||||||
|
title: collectible.title,
|
||||||
|
description: collectible.description,
|
||||||
|
accessibilityLabel: collectible.accessibilityLabel,
|
||||||
|
reducedMotionSrc: collectible.reducedMotionSrc,
|
||||||
|
thumbnailPreviewSrc: collectible.thumbnailPreviewSrc,
|
||||||
|
effects: collectible.effects,
|
||||||
|
animationType: collectible.animationType,
|
||||||
|
staticFrameSrc: collectible.staticFrameSrc,
|
||||||
|
type: collectible.type || 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.username;
|
||||||
|
}
|
||||||
70
src/equicordplugins/profileSets/utils/storage.ts
Normal file
70
src/equicordplugins/profileSets/utils/storage.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2026 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DataStore } from "@api/index";
|
||||||
|
import { Logger } from "@utils/Logger";
|
||||||
|
import { ProfilePreset } from "@vencord/discord-types";
|
||||||
|
|
||||||
|
const logger = new Logger("ProfilePresets");
|
||||||
|
const PRESETS_KEY = "ProfileDataset";
|
||||||
|
|
||||||
|
export let presets: ProfilePreset[] = [];
|
||||||
|
export let currentPresetIndex = -1;
|
||||||
|
|
||||||
|
export async function loadPresets() {
|
||||||
|
try {
|
||||||
|
const stored = await DataStore.get(PRESETS_KEY);
|
||||||
|
if (stored && Array.isArray(stored)) {
|
||||||
|
presets = stored;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to load presets", err);
|
||||||
|
presets = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePresetsData() {
|
||||||
|
try {
|
||||||
|
await DataStore.set(PRESETS_KEY, presets);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to save presets", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCurrentPresetIndex(index: number) {
|
||||||
|
currentPresetIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addPreset(preset: ProfilePreset) {
|
||||||
|
presets.push(preset);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePreset(index: number, preset: ProfilePreset) {
|
||||||
|
if (index >= 0 && index < presets.length) {
|
||||||
|
presets[index] = preset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removePreset(index: number) {
|
||||||
|
if (index >= 0 && index < presets.length) {
|
||||||
|
presets.splice(index, 1);
|
||||||
|
if (currentPresetIndex === index) {
|
||||||
|
currentPresetIndex = -1;
|
||||||
|
} else if (currentPresetIndex > index) {
|
||||||
|
currentPresetIndex--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function movePresetInArray(fromIndex: number, toIndex: number) {
|
||||||
|
if (fromIndex < 0 || fromIndex >= presets.length || toIndex < 0 || toIndex >= presets.length) return;
|
||||||
|
const [preset] = presets.splice(fromIndex, 1);
|
||||||
|
presets.splice(toIndex, 0, preset);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceAllPresets(newPresets: ProfilePreset[]) {
|
||||||
|
presets = newPresets;
|
||||||
|
}
|
||||||
|
|
@ -83,6 +83,7 @@ const ToastType = {
|
||||||
BOOKMARK: "bookmark",
|
BOOKMARK: "bookmark",
|
||||||
CLOCK: "clock"
|
CLOCK: "clock"
|
||||||
};
|
};
|
||||||
|
|
||||||
const ToastPosition = {
|
const ToastPosition = {
|
||||||
TOP: 0,
|
TOP: 0,
|
||||||
BOTTOM: 1
|
BOTTOM: 1
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue