(feat): add cache package
(feat): improve local storage security (feat): move settings to dedicated settings package
This commit is contained in:
parent
fb095db7a6
commit
790a1db788
54 changed files with 1984 additions and 947 deletions
67
packages/settings/src/pages/profile.tsx
Normal file
67
packages/settings/src/pages/profile.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import MDInput from "@tensamin/markdown/input";
|
||||
import { useMTP } from "@tensamin/mtp";
|
||||
import { mtp } from "@tensamin/shared/data";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { Avatar, AvatarFallback, AvatarImage, Button, cn, Input, useIsMobile } from "@tensamin/ui";
|
||||
import { useUser, type User } from "@tensamin/user/context";
|
||||
import { Check } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
async function prepImage(file: File, size = 300, quality = 0.8): Promise<string> {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size; canvas.height = size;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Could not get canvas context");
|
||||
const scale = Math.max(size / bitmap.width, size / bitmap.height);
|
||||
const width = bitmap.width * scale;
|
||||
const height = bitmap.height * scale;
|
||||
context.drawImage(bitmap, (size - width) / 2, (size - height) / 2, width, height);
|
||||
return canvas.toDataURL("image/webp", quality);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { get } = useUser();
|
||||
const { load } = useStorage();
|
||||
const { send } = useMTP();
|
||||
const isMobile = useIsMobile();
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [draftUser, setDraftUser] = useState<Partial<User>>({});
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [saveSucceeded, setSaveSucceeded] = useState(false);
|
||||
const avatarUploadRef = useRef<HTMLInputElement>(null);
|
||||
const draftInitializedRef = useRef(false);
|
||||
const effectiveAvatar = draftUser.Avatar === "none" ? undefined : draftUser.Avatar;
|
||||
const updateDraftUser = (updater: (previous: Partial<User>) => Partial<User>) => {
|
||||
setSaveSucceeded(false); setErrorMessage(""); setDraftUser(updater);
|
||||
};
|
||||
useEffect(() => { void (async () => setCurrentUser(await get(await load("user_id"))))(); }, [get, load]);
|
||||
useEffect(() => {
|
||||
if (!currentUser || draftInitializedRef.current) return;
|
||||
setDraftUser(currentUser); draftInitializedRef.current = true;
|
||||
}, [currentUser]);
|
||||
async function handleAvatarUpload(file: File) {
|
||||
const avatar = await prepImage(file);
|
||||
updateDraftUser((previous) => ({ ...previous, avatar }));
|
||||
if (avatarUploadRef.current) avatarUploadRef.current.value = "";
|
||||
}
|
||||
if (!currentUser) return <p>Loading...</p>;
|
||||
return <>
|
||||
<input ref={avatarUploadRef} hidden onChange={(event) => event.target.files?.[0] && handleAvatarUpload(event.target.files[0])} type="file" />
|
||||
<div className={cn("flex flex-col gap-5", isMobile ? "w-full" : "w-80")}>
|
||||
<div className="flex items-center gap-3"><Avatar className="size-14"><AvatarImage src={effectiveAvatar} /><AvatarFallback className="text-2xl">{draftUser.Display?.slice(0, 2).toUpperCase() || currentUser.Display.slice(0, 2).toUpperCase()}</AvatarFallback></Avatar><div className="flex flex-col gap-1"><p>Avatar</p><div className="flex gap-1"><Button onClick={() => avatarUploadRef.current?.click()}>Upload avatar</Button><Button onClick={() => updateDraftUser((previous) => ({ ...previous, avatar: "none" }))} variant="destructive" disabled={effectiveAvatar === undefined}>Remove</Button></div><p className="text-sm text-muted-foreground">GIFs are supported in decentralised mode or with Tensamin Premium.<br />Maximum file size is 16mb.</p></div></div>
|
||||
<Input className="w-full" onChange={(event) => updateDraftUser((previous) => ({ ...previous, display: event.target.value }))} placeholder="Display Name" value={draftUser.Display || ""} />
|
||||
<Input className="w-full" onChange={(event) => updateDraftUser((previous) => ({ ...previous, username: event.target.value }))} placeholder="Username" value={draftUser.Username || ""} />
|
||||
<MDInput styled paddingY="4px" paddingX="10px" fontSize=".875rem" placeholder="About Me" setValue={(value) => updateDraftUser((previous) => ({ ...previous, about: value }))} value={draftUser.About || ""} />
|
||||
<Button onClick={async () => {
|
||||
const { Avatar, ...draftUsersWithoutAvatar } = draftUser;
|
||||
const payload = { ...draftUsersWithoutAvatar, ...(typeof Avatar === "string" ? { avatar: Avatar.startsWith("data:") ? (Avatar.split(",", 2)[1] ?? "") : Avatar } : {}) };
|
||||
const validation = mtp.ChangeUserData.request.safeParse(payload);
|
||||
if (!validation.success) { setSaveSucceeded(false); setErrorMessage(validation.error.issues[0]?.message ?? "Invalid profile data"); return; }
|
||||
try { await send("ChangeUserData", validation.data); setSaveSucceeded(true); setErrorMessage(""); }
|
||||
catch (error) { setSaveSucceeded(false); setErrorMessage("Failed to update profile: " + error); }
|
||||
}}>{saveSucceeded ? <span className="inline-flex items-center gap-1.5"><Check className="size-4" />Saved</span> : "Save"}</Button>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
Loading…
Reference in a new issue