diff --git a/__tests__/components/user/user-page-header/name/UserPageHeaderName.test.tsx b/__tests__/components/user/user-page-header/name/UserPageHeaderName.test.tsx index 6b7eb190dc..09444c5d76 100644 --- a/__tests__/components/user/user-page-header/name/UserPageHeaderName.test.tsx +++ b/__tests__/components/user/user-page-header/name/UserPageHeaderName.test.tsx @@ -40,7 +40,13 @@ const baseProfile: ApiIdentity = { function renderComponent(profile: Partial, mainAddress = '0xabc') { const combined = { ...baseProfile, ...profile } as ApiIdentity; return render( - + ); } diff --git a/components/user/layout/UserPageLayout.tsx b/components/user/layout/UserPageLayout.tsx index 1a9b7eee74..49e0bdf7ee 100644 --- a/components/user/layout/UserPageLayout.tsx +++ b/components/user/layout/UserPageLayout.tsx @@ -29,7 +29,7 @@ export default function UserPageLayout({ handleOrWallet={normalizedHandleOrWallet} fallbackMainAddress={mainAddress} /> -
+
{children}
diff --git a/components/user/settings/UserSettingsBannerImageInput.tsx b/components/user/settings/UserSettingsBannerImageInput.tsx new file mode 100644 index 0000000000..fbee501734 --- /dev/null +++ b/components/user/settings/UserSettingsBannerImageInput.tsx @@ -0,0 +1,97 @@ +"use client"; + +import Image from "next/image"; +import { ACCEPTED_FORMATS_DISPLAY } from "./imageValidation"; +import { useImageUpload } from "./useImageUpload"; + +export default function UserSettingsBannerImageInput({ + imageToShow, + setFile, +}: { + readonly imageToShow: string | null; + readonly setFile: (file: File) => void; +}) { + const { error, shake, dragging, onFileChange, dragHandlers } = useImageUpload( + { + maxSizeBytes: 2097152, + maxSizeLabel: "2MB", + setFile, + } + ); + + return ( +
+ +
+ ); +} diff --git a/components/user/settings/UserSettingsImgSelectFile.tsx b/components/user/settings/UserSettingsImgSelectFile.tsx index 4f0e2c5373..04767ad586 100644 --- a/components/user/settings/UserSettingsImgSelectFile.tsx +++ b/components/user/settings/UserSettingsImgSelectFile.tsx @@ -1,21 +1,8 @@ "use client"; -import { useContext, useRef, useState, useEffect } from "react"; -import { AuthContext } from "@/components/auth/Auth"; - -const ACCEPTED_FORMATS = [ - "image/jpeg", - "image/jpg", - "image/png", - "image/gif", - "image/webp", -]; - -const ACCEPTED_FORMATS_DISPLAY = ACCEPTED_FORMATS.map( - (format) => `.${format.replace("image/", "")}` -).join(", "); - -const FILE_SIZE_LIMIT = 2097152; +import Image from "next/image"; +import { useImageUpload } from "./useImageUpload"; +import { ACCEPTED_FORMATS_DISPLAY } from "./imageValidation"; export default function UserSettingsImgSelectFile({ imageToShow, @@ -24,63 +11,17 @@ export default function UserSettingsImgSelectFile({ readonly imageToShow: string | null; readonly setFile: (file: File) => void; }) { - const { setToast } = useContext(AuthContext); - const inputRef = useRef(null); - const [error, setError] = useState(null); - const [shake, setShake] = useState(false); - const onFileChange = (file: File) => { - setError(null); - if (ACCEPTED_FORMATS.indexOf(file.type) === -1) { - setError(null); - setToast({ - type: "error", - message: "Invalid file type", - }); - } else if (file.size > FILE_SIZE_LIMIT) { - setError("File size must be less than 2MB"); - setShake(true); - } else { - setError(null); - setFile(file); + const { error, shake, dragging, onFileChange, dragHandlers } = useImageUpload( + { + maxSizeBytes: 2097152, + maxSizeLabel: "2MB", + setFile, } - }; - - const handleDrop = (e: any) => { - e.preventDefault(); - e.stopPropagation(); - if (e?.dataTransfer?.files?.length) { - onFileChange(e.dataTransfer.files[0]); - } - }; - - const [dragging, setDragging] = useState(false); - - const handleDrag = (e: any) => { - e.preventDefault(); - e.stopPropagation(); - if (e.type === "dragenter" || e.type === "dragover") { - setDragging(true); - } else if (e.type === "dragleave") { - setDragging(false); - } else if (e.type === "drop") { - setDragging(false); - } - }; - - useEffect(() => { - if (shake) { - const timeout = setTimeout(() => setShake(false), 300); - return () => clearTimeout(timeout); - } - return; - }, [shake]); + ); return (
diff --git a/components/user/settings/imageValidation.ts b/components/user/settings/imageValidation.ts new file mode 100644 index 0000000000..6df4d56ff5 --- /dev/null +++ b/components/user/settings/imageValidation.ts @@ -0,0 +1,27 @@ +import { getFileExtension } from "@/components/waves/memes/file-upload/utils/formatHelpers"; + +const ACCEPTED_FORMATS = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", +]; + +export const ACCEPTED_FORMATS_DISPLAY = ACCEPTED_FORMATS.map( + (format) => `.${format.replace("image/", "")}` +).join(", "); + +const ALLOWED_EXTENSIONS = new Set(["JPG", "JPEG", "PNG", "GIF", "WEBP"]); + +export const isValidImageType = (file: File): boolean => { + // Primary check: MIME type + if (ACCEPTED_FORMATS.includes(file.type)) { + return true; + } + // Fallback: extension check when MIME is empty (some OS/browser combos) + if (file.type === "") { + return ALLOWED_EXTENSIONS.has(getFileExtension(file)); + } + return false; +}; diff --git a/components/user/settings/useImageUpload.ts b/components/user/settings/useImageUpload.ts new file mode 100644 index 0000000000..7afaf9af8a --- /dev/null +++ b/components/user/settings/useImageUpload.ts @@ -0,0 +1,95 @@ +"use client"; + +import { useCallback, useContext, useRef, useState } from "react"; +import { AuthContext } from "@/components/auth/Auth"; +import { isValidImageType } from "./imageValidation"; + +interface UseImageUploadOptions { + maxSizeBytes: number; + maxSizeLabel: string; + setFile: (file: File) => void; +} + +export function useImageUpload({ + maxSizeBytes, + maxSizeLabel, + setFile, +}: UseImageUploadOptions) { + const { setToast } = useContext(AuthContext); + const dragDepth = useRef(0); + const [error, setError] = useState(null); + const [shake, setShake] = useState(false); + const [dragging, setDragging] = useState(false); + + const onFileChange = useCallback( + (file: File) => { + setError(null); + if (!isValidImageType(file)) { + setToast({ + type: "error", + message: "Invalid file type", + }); + return; + } + + if (file.size > maxSizeBytes) { + setError(`File size must be less than ${maxSizeLabel}`); + setShake(true); + setTimeout(() => setShake(false), 300); + return; + } + + setFile(file); + }, + [setFile, setToast, maxSizeBytes, maxSizeLabel] + ); + + const onDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragDepth.current += 1; + setDragging(true); + }, []); + + const onDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragDepth.current -= 1; + if (dragDepth.current <= 0) { + dragDepth.current = 0; + setDragging(false); + } + }, []); + + const onDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const onDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragDepth.current = 0; + setDragging(false); + const file = e.dataTransfer.files[0]; + if (file) { + onFileChange(file); + } + }, + [onFileChange] + ); + + return { + error, + shake, + dragging, + onFileChange, + dragHandlers: { + onDrop, + onDragEnter, + onDragLeave, + onDragOver, + }, + }; +} diff --git a/components/user/user-page-header/UserPageHeaderClient.tsx b/components/user/user-page-header/UserPageHeaderClient.tsx index 5cda0f356c..fd8aeabba1 100644 --- a/components/user/user-page-header/UserPageHeaderClient.tsx +++ b/components/user/user-page-header/UserPageHeaderClient.tsx @@ -13,10 +13,10 @@ import { amIUser } from "@/helpers/Helpers"; import { navigateToDirectMessage } from "@/helpers/navigation.helpers"; import { STATEMENT_GROUP, STATEMENT_TYPE } from "@/helpers/Types"; import { createDirectMessageWave } from "@/helpers/waves/waves.helpers"; +import { getBannerColorValue } from "@/helpers/profile-banner.helpers"; import useDeviceInfo from "@/hooks/useDeviceInfo"; import { useIdentity } from "@/hooks/useIdentity"; import { commonApiFetch } from "@/services/api/common-api"; -import UserLevel from "../utils/level/UserLevel"; import UserFollowBtn from "../utils/UserFollowBtn"; import UserPageHeaderAbout from "./about/UserPageHeaderAbout"; import UserPageHeaderBanner from "./banner/UserPageHeaderBanner"; @@ -24,7 +24,6 @@ import UserPageHeaderName from "./name/UserPageHeaderName"; import UserPageHeaderPfp from "./pfp/UserPageHeaderPfp"; import UserPageHeaderPfpWrapper from "./pfp/UserPageHeaderPfpWrapper"; import UserPageHeaderStats from "./stats/UserPageHeaderStats"; -import UserPageHeaderProfileEnabledAt from "./UserPageHeaderProfileEnabledAt"; type Props = { readonly profile: ApiIdentity; @@ -51,7 +50,7 @@ export default function UserPageHeaderClient({ const router = useRouter(); const { isApp } = useDeviceInfo(); const routeHandleOrWallet = - params?.["user"]?.toString().toLowerCase() ?? null; + params["user"]?.toString().toLowerCase() ?? null; const normalizedHandleOrWallet = routeHandleOrWallet ?? handleOrWallet.toLowerCase(); @@ -69,13 +68,16 @@ export default function UserPageHeaderClient({ [hydratedProfile, initialProfile] ); + const banner1Color = getBannerColorValue(profile.banner1) ?? defaultBanner1; + const banner2Color = getBannerColorValue(profile.banner2) ?? defaultBanner2; + const mainAddress = useMemo(() => { - const primaryWallet = profile?.primary_wallet; + const primaryWallet = profile.primary_wallet; if (primaryWallet) { return primaryWallet.toLowerCase(); } return fallbackMainAddress.toLowerCase(); - }, [profile?.primary_wallet, fallbackMainAddress]); + }, [profile.primary_wallet, fallbackMainAddress]); const [directMessageLoading, setDirectMessageLoading] = useState(false); @@ -121,7 +123,7 @@ export default function UserPageHeaderClient({ ); const showAbout = useMemo( - () => !!(aboutStatement || canEdit), + () => aboutStatement !== null || canEdit, [aboutStatement, canEdit] ); @@ -156,27 +158,52 @@ export default function UserPageHeaderClient({ return (
-
- -
-
-
-
+
+
+ +
+ +
+
+
+
-
- {!isMyProfile && profile.handle && connectedProfile?.handle && ( + +
+ +
+ +
+
+ +
+ {!isMyProfile && profile.handle && connectedProfile?.handle ? ( - )} + ) : null}
- + {showAbout ? ( +
+ +
+ ) : null} -
- -
- {showAbout && ( - + - )} - - +
diff --git a/components/user/user-page-header/UserPageHeaderProfileEnabledAt.tsx b/components/user/user-page-header/UserPageHeaderProfileEnabledAt.tsx deleted file mode 100644 index a97bba2e0b..0000000000 --- a/components/user/user-page-header/UserPageHeaderProfileEnabledAt.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { formatTimestampToMonthYear } from "@/helpers/Helpers"; - -export default function UserPageHeaderProfileEnabledAt({ - profileEnabledAt, -}: { - readonly profileEnabledAt: string | null; -}) { - if (!profileEnabledAt) { - return null; - } - - return ( -
-

- Profile Enabled:{" "} - {formatTimestampToMonthYear( - new Date(profileEnabledAt).getTime() - )} -

-
- ); -} diff --git a/components/user/user-page-header/about/UserPageHeaderAbout.tsx b/components/user/user-page-header/about/UserPageHeaderAbout.tsx index 529a55b0e8..74f9c6ecf0 100644 --- a/components/user/user-page-header/about/UserPageHeaderAbout.tsx +++ b/components/user/user-page-header/about/UserPageHeaderAbout.tsx @@ -46,30 +46,44 @@ export default function UserPageHeaderAbout({ return (
{view === AboutStatementView.STATEMENT && ( -
- + ) : ( + + )} {canEdit && ( -
+
+ )} - +
)} {view === AboutStatementView.EDIT && ( -
+