Skip to content

Commit c285539

Browse files
committed
Enhance wallet profile image handling and improve image upload validation
- Added profileImageIpfsUrl field to the Wallet model in the Prisma schema for storing wallet profile images. - Updated CardUI and related components to display wallet profile images, enhancing visual representation. - Implemented initialUrl prop in ImgDragAndDrop component to support pre-filled image uploads. - Added file size validation for image uploads, enforcing a 1MB limit to ensure optimal performance. - Enhanced error handling for image uploads and fetch operations, providing clearer feedback to users.
1 parent 97592cc commit c285539

File tree

10 files changed

+178
-36
lines changed

10 files changed

+178
-36
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- AlterTable
2+
ALTER TABLE "Wallet" ADD COLUMN "profileImageIpfsUrl" TEXT;
3+

prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ model Wallet {
4141
clarityApiKey String?
4242
rawImportBodies Json?
4343
migrationTargetWalletId String?
44+
profileImageIpfsUrl String?
4445
}
4546

4647
model Transaction {

src/components/common/ImgDragAndDrop.tsx

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,25 @@ interface ErrorResponse {
2020

2121
interface ImgDragAndDropProps {
2222
onImageUpload: (url: string, digest: string) => void;
23+
initialUrl?: string | null;
2324
}
2425

25-
export default function ImgDragAndDrop({ onImageUpload }: ImgDragAndDropProps) {
26+
export default function ImgDragAndDrop({ onImageUpload, initialUrl }: ImgDragAndDropProps) {
2627
const [uploading, setUploading] = useState<boolean>(false);
2728
const [error, setError] = useState<string | null>(null);
28-
const [imageUrl, setImageUrl] = useState<string | null>(null);
29-
const [filePath, setFilePath] = useState<string>("");
29+
const [imageUrl, setImageUrl] = useState<string | null>(initialUrl ?? null);
30+
const [filePath, setFilePath] = useState<string>(initialUrl ?? "");
3031
const [digest, setDigest] = useState<string>("");
3132
const lastComputedUrlRef = useRef<string>("");
3233

34+
// Update state when initialUrl changes
35+
useEffect(() => {
36+
if (initialUrl) {
37+
setImageUrl(initialUrl);
38+
setFilePath(initialUrl);
39+
}
40+
}, [initialUrl]);
41+
3342
async function computeSha256(file: File): Promise<string> {
3443
const arrayBuffer = await file.arrayBuffer();
3544
const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer);
@@ -59,6 +68,14 @@ export default function ImgDragAndDrop({ onImageUpload }: ImgDragAndDropProps) {
5968
if (!acceptedFiles.length) return;
6069
const file = acceptedFiles[0];
6170
if (!file) return;
71+
72+
// Check file size (1MB = 1,048,576 bytes)
73+
const MAX_FILE_SIZE = 1048576;
74+
if (file.size > MAX_FILE_SIZE) {
75+
setError(`File size exceeds 1MB limit. File size: ${(file.size / 1024 / 1024).toFixed(2)}MB`);
76+
return;
77+
}
78+
6279
setUploading(true);
6380
setError(null);
6481

@@ -112,6 +129,17 @@ export default function ImgDragAndDrop({ onImageUpload }: ImgDragAndDropProps) {
112129
onDrop,
113130
accept: { "image/*": [] },
114131
multiple: false,
132+
maxSize: 1048576, // 1MB in bytes
133+
onDropRejected: (fileRejections) => {
134+
const rejection = fileRejections[0];
135+
if (rejection?.errors?.[0]?.code === "file-too-large") {
136+
setError("File size exceeds 1MB limit. Please choose a smaller image.");
137+
} else if (rejection?.errors?.[0]?.code === "file-invalid-type") {
138+
setError("Invalid file type. Please upload an image file.");
139+
} else {
140+
setError("File upload rejected. Please try again.");
141+
}
142+
},
115143
});
116144

117145
// Compute image digest for manually entered URLs
@@ -121,15 +149,39 @@ export default function ImgDragAndDrop({ onImageUpload }: ImgDragAndDropProps) {
121149
try {
122150
const response = await fetch(filePath);
123151
if (!response.ok) throw new Error("Image fetch failed");
152+
153+
// Check Content-Length header if available
154+
const contentLength = response.headers.get("content-length");
155+
const MAX_FILE_SIZE = 1048576; // 1MB
156+
if (contentLength && parseInt(contentLength) > MAX_FILE_SIZE) {
157+
setError(`Image size exceeds 1MB limit. Image size: ${(parseInt(contentLength) / 1024 / 1024).toFixed(2)}MB`);
158+
setFilePath("");
159+
setImageUrl(null);
160+
return;
161+
}
162+
124163
const arrayBuffer = await response.arrayBuffer();
164+
165+
// Check actual file size
166+
if (arrayBuffer.byteLength > MAX_FILE_SIZE) {
167+
setError(`Image size exceeds 1MB limit. Image size: ${(arrayBuffer.byteLength / 1024 / 1024).toFixed(2)}MB`);
168+
setFilePath("");
169+
setImageUrl(null);
170+
return;
171+
}
172+
125173
const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer);
126174
const hashArray = Array.from(new Uint8Array(hashBuffer));
127175
const urlDigest = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
128176
setDigest(urlDigest);
177+
setError(null);
129178
lastComputedUrlRef.current = filePath;
130179
onImageUpload(filePath, urlDigest);
131180
} catch (err) {
132181
console.error("Failed to generate digest from URL:", err);
182+
setError("Failed to load image from URL. Please check the URL and try again.");
183+
setFilePath("");
184+
setImageUrl(null);
133185
}
134186
})();
135187
}
@@ -139,13 +191,12 @@ export default function ImgDragAndDrop({ onImageUpload }: ImgDragAndDropProps) {
139191
<div className="flex flex-col gap-4">
140192
<div className="flex items-center gap-4">
141193
{imageUrl && (
142-
<div className="relative h-32 w-32">
194+
<div className="relative aspect-square w-32">
143195
<Image
144196
src={imageUrl}
145197
alt="Uploaded Preview"
146-
width={128}
147-
height={128}
148-
className="rounded-md object-cover"
198+
fill
199+
className="rounded-md object-cover object-center"
149200
/>
150201
<button
151202
onClick={() => {

src/components/common/card-content.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,30 @@ export default function CardUI({
88
icon,
99
cardClassName,
1010
headerDom,
11+
profileImage,
1112
}: {
1213
children: React.ReactNode;
1314
title: string;
1415
description?: ReactNode | string | null;
1516
icon?: any;
1617
cardClassName?: string;
1718
headerDom?: ReactNode;
19+
profileImage?: ReactNode;
1820
}) {
1921
// Make title larger for wallet info card (col-span-2)
2022
const isLargeTitle = cardClassName?.includes('col-span-2');
2123

2224
return (
2325
<Card className={`w-full max-w-4xl ${cardClassName}`}>
2426
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
25-
<CardTitle className={isLargeTitle ? "text-2xl sm:text-3xl font-semibold" : "text-xl font-medium"}>{title}</CardTitle>
27+
<div className="flex items-center gap-3 flex-1 min-w-0">
28+
{profileImage && (
29+
<div className="flex-shrink-0">
30+
{profileImage}
31+
</div>
32+
)}
33+
<CardTitle className={isLargeTitle ? "text-2xl sm:text-3xl font-semibold" : "text-xl font-medium"}>{title}</CardTitle>
34+
</div>
2635
{headerDom && headerDom}
2736
{icon && (
2837
<>

src/components/pages/homepage/wallets/index.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import EmptyWalletsState from "./EmptyWalletsState";
2424
import SectionExplanation from "./SectionExplanation";
2525
import WalletCardSkeleton from "./WalletCardSkeleton";
2626
import WalletInviteCardSkeleton from "./WalletInviteCardSkeleton";
27+
import IPFSImage from "@/components/common/ipfs-image";
2728

2829

2930
export default function PageWallets() {
@@ -267,6 +268,18 @@ function CardWallet({
267268
title={`${wallet.name}${wallet.isArchived ? " (Archived)" : ""}`}
268269
description={wallet.description}
269270
cardClassName=""
271+
profileImage={
272+
wallet.profileImageIpfsUrl ? (
273+
<div className="relative aspect-square w-10 sm:w-12 rounded-lg overflow-hidden border border-border/50 shadow-sm">
274+
<IPFSImage
275+
src={wallet.profileImageIpfsUrl}
276+
alt="Wallet Profile"
277+
fill
278+
className="object-cover object-center"
279+
/>
280+
</div>
281+
) : undefined
282+
}
270283
headerDom={
271284
isSummonWallet ? (
272285
<Badge

src/components/pages/wallet/info/archive-wallet.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function ArchiveWallet({ appWallet }: { appWallet: Wallet }) {
3636
name: appWallet.name,
3737
description: appWallet.description ?? "",
3838
isArchived: isArchived,
39+
profileImageIpfsUrl: appWallet.profileImageIpfsUrl ?? null,
3940
});
4041
}
4142

src/components/pages/wallet/info/card-info.tsx

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import useAppWallet from "@/hooks/useAppWallet";
99
import { deserializeAddress } from "@meshsdk/core";
1010
import { getBalanceFromUtxos } from "@/utils/getBalance";
1111
import { useWalletsStore } from "@/lib/zustand/wallets";
12+
import ImgDragAndDrop from "@/components/common/ImgDragAndDrop";
13+
import IPFSImage from "@/components/common/ipfs-image";
14+
import Image from "next/image";
1215

1316
import {
1417
DropdownMenu,
@@ -52,6 +55,18 @@ export default function CardInfo({ appWallet }: { appWallet: Wallet }) {
5255
<CardUI
5356
title={appWallet.name}
5457
description={appWallet.description}
58+
profileImage={
59+
appWallet.profileImageIpfsUrl ? (
60+
<div className="relative aspect-square w-12 sm:w-14 rounded-lg overflow-hidden border border-border/50 shadow-sm">
61+
<IPFSImage
62+
src={appWallet.profileImageIpfsUrl}
63+
alt="Wallet Profile"
64+
fill
65+
className="object-cover object-center"
66+
/>
67+
</div>
68+
) : undefined
69+
}
5570
headerDom={
5671
<div className="flex items-center gap-2">
5772
{isLegacyWallet && (
@@ -101,6 +116,9 @@ function EditInfo({
101116
appWallet.description ?? "",
102117
);
103118
const [isArchived, setIsArchived] = useState<boolean>(appWallet.isArchived);
119+
const [profileImageIpfsUrl, setProfileImageIpfsUrl] = useState<string | null>(
120+
appWallet.profileImageIpfsUrl ?? null,
121+
);
104122
const [loading, setLoading] = useState<boolean>(false);
105123
const ctx = api.useUtils();
106124
const { toast } = useToast();
@@ -133,6 +151,7 @@ function EditInfo({
133151
name,
134152
description,
135153
isArchived,
154+
profileImageIpfsUrl: profileImageIpfsUrl || null,
136155
});
137156
}
138157
return (
@@ -158,6 +177,17 @@ function EditInfo({
158177
onChange={(e) => setDescription(e.target.value)}
159178
/>
160179
</div>
180+
<div className="grid gap-2 sm:gap-3">
181+
<Label htmlFor="profileImage">Profile Image</Label>
182+
<ImgDragAndDrop
183+
onImageUpload={(url) => setProfileImageIpfsUrl(url)}
184+
initialUrl={profileImageIpfsUrl}
185+
/>
186+
<p className="text-xs text-muted-foreground">
187+
<strong>Note:</strong> Images will be stored on public IPFS (InterPlanetary File System).
188+
Once uploaded, the image will be publicly accessible and cannot be removed from IPFS.
189+
</p>
190+
</div>
161191
<div className="grid gap-2 sm:gap-3">
162192
<Label htmlFor="type" className="text-sm">
163193
Archive Status
@@ -190,7 +220,10 @@ function EditInfo({
190220
onClick={() => editWallet()}
191221
disabled={
192222
loading ||
193-
(appWallet.name === name && appWallet.description === description && appWallet.isArchived === isArchived)
223+
(appWallet.name === name &&
224+
appWallet.description === description &&
225+
appWallet.isArchived === isArchived &&
226+
appWallet.profileImageIpfsUrl === profileImageIpfsUrl)
194227
}
195228
className="flex-1 sm:flex-initial"
196229
>
@@ -396,31 +429,40 @@ function ShowInfo({ appWallet }: { appWallet: Wallet }) {
396429
const requiredCount = getRequiredCount();
397430

398431
return (
399-
<div className="space-y-4">
400-
{/* Desktop: Grid layout for addresses and balance */}
401-
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
402-
{/* Left Column */}
403-
<div className="space-y-4">
404-
{/* Signing Threshold - Above Address */}
405-
<div className="flex items-center gap-3 p-3 bg-muted/30 rounded-lg border border-border/30">
406-
<div className="flex-1 min-w-0">
407-
<div className="text-xs font-medium text-muted-foreground mb-0.5">Signing Threshold</div>
408-
<div className="text-sm font-medium">{getSignersText()}</div>
409-
</div>
432+
<div className="space-y-6">
433+
{/* Top Section: Key Info */}
434+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 w-full">
435+
{/* Signing Threshold */}
436+
<div className="flex items-center gap-3 p-4 bg-muted/40 rounded-lg border border-border/40">
410437
<div className="flex items-center gap-1.5 flex-shrink-0">
411438
{Array.from({ length: signersCount }).map((_, index) => (
412439
<User
413440
key={index}
414-
className={`h-5 w-5 sm:h-6 sm:w-6 ${
441+
className={`h-4 w-4 sm:h-5 sm:w-5 ${
415442
index < requiredCount
416443
? "text-foreground opacity-100"
417444
: "text-muted-foreground opacity-30"
418445
}`}
419446
/>
420447
))}
421448
</div>
449+
<div className="flex-1 min-w-0">
450+
<div className="text-xs font-medium text-muted-foreground mb-0.5">Signing Threshold</div>
451+
<div className="text-sm font-semibold">{getSignersText()}</div>
452+
</div>
422453
</div>
423454

455+
{/* Balance */}
456+
<div className="flex flex-col justify-center p-4 bg-muted/40 rounded-lg border border-border/40">
457+
<div className="text-xs font-medium text-muted-foreground mb-1">Balance</div>
458+
<div className="text-2xl sm:text-3xl font-bold">{balance}</div>
459+
</div>
460+
</div>
461+
462+
{/* Addresses Section */}
463+
<div className="space-y-3 pt-2 border-t border-border/30">
464+
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">Wallet Details</div>
465+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
424466
{/* Address */}
425467
<RowLabelInfo
426468
label="Address"
@@ -468,15 +510,6 @@ function ShowInfo({ appWallet }: { appWallet: Wallet }) {
468510
/>
469511
) : null}
470512
</div>
471-
472-
{/* Right Column */}
473-
<div className="space-y-4">
474-
{/* Balance - Larger Display */}
475-
<div className="flex flex-col gap-2">
476-
<div className="text-sm font-medium text-muted-foreground">Balance</div>
477-
<div className="text-2xl sm:text-3xl font-semibold">{balance}</div>
478-
</div>
479-
</div>
480513
</div>
481514

482515
{/* Native Script - Collapsible Pro Feature */}

src/components/ui/card-content.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,27 @@ export default function CardUI({
88
icon,
99
cardClassName,
1010
headerDom,
11+
profileImage,
1112
}: {
1213
children: React.ReactNode;
1314
title: string;
1415
description?: ReactNode | string | null;
1516
icon?: any;
1617
cardClassName?: string;
1718
headerDom?: ReactNode;
19+
profileImage?: ReactNode;
1820
}) {
1921
return (
2022
<Card className={`w-full ${cardClassName || ""}`}>
2123
<CardHeader className="flex flex-row items-start sm:items-center justify-between space-y-0 pb-2 px-4 sm:px-6 pt-4 sm:pt-6 gap-2">
22-
<CardTitle className="text-lg sm:text-xl font-medium pr-2 flex-1 min-w-0">{title}</CardTitle>
24+
<div className="flex items-center gap-3 flex-1 min-w-0">
25+
{profileImage && (
26+
<div className="flex-shrink-0">
27+
{profileImage}
28+
</div>
29+
)}
30+
<CardTitle className="text-lg sm:text-xl font-medium pr-2 flex-1 min-w-0">{title}</CardTitle>
31+
</div>
2332
{headerDom && <div className="flex-shrink-0">{headerDom}</div>}
2433
{icon && (
2534
<>

src/pages/api/pinata-storage/image/put.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ export default async function handler(
5353

5454
const file: File = Array.isArray(files.file) ? files.file[0] : files.file;
5555

56+
// Validate file size (1MB = 1,048,576 bytes)
57+
const MAX_FILE_SIZE = 1048576;
58+
const fileSize = file.size;
59+
if (fileSize > MAX_FILE_SIZE) {
60+
return res.status(400).json({
61+
error: `File size exceeds 1MB limit. File size: ${(fileSize / 1024 / 1024).toFixed(2)}MB`
62+
});
63+
}
64+
5665
// Validate and retrieve form fields
5766
const rawShortHash = Array.isArray(fields.shortHash)
5867
? fields.shortHash[0]

0 commit comments

Comments
 (0)