Skip to content

Commit 5a9a76b

Browse files
committed
[TOOL-3392] Dashboard: Add project Image field in project settings page (#6379)
1 parent e772581 commit 5a9a76b

File tree

16 files changed

+171
-19
lines changed

16 files changed

+171
-19
lines changed

apps/dashboard/src/@/components/blocks/Avatars/ProjectAvatar.stories.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Meta, StoryObj } from "@storybook/react";
22
import { useState } from "react";
33
import { BadgeContainer } from "../../../../stories/utils";
4+
import { getThirdwebClient } from "../../../constants/thirdweb.server";
45
import { Button } from "../../ui/button";
56
import { ProjectAvatar } from "./ProjectAvatar";
67

@@ -17,17 +18,19 @@ export const Desktop: Story = {
1718
args: {},
1819
};
1920

21+
const client = getThirdwebClient();
22+
2023
function Story() {
2124
return (
2225
<div className="flex flex-col gap-10 p-10">
2326
<p> All images below are set with size-6 className </p>
2427

2528
<BadgeContainer label="No Src - Skeleton">
26-
<ProjectAvatar src={undefined} className="size-6" />
29+
<ProjectAvatar src={undefined} className="size-6" client={client} />
2730
</BadgeContainer>
2831

2932
<BadgeContainer label="Invalid/Empty Src - BoxIcon Fallback">
30-
<ProjectAvatar src={""} className="size-6" />
33+
<ProjectAvatar src={""} className="size-6" client={client} />
3134
</BadgeContainer>
3235

3336
<ToggleTest />
@@ -62,13 +65,14 @@ function ToggleTest() {
6265
<p> Src+Name is: {data ? "set" : "not set"} </p>
6366

6467
<BadgeContainer label="Valid Src">
65-
<ProjectAvatar src={data?.src} className="size-6" />
68+
<ProjectAvatar src={data?.src} className="size-6" client={client} />
6669
</BadgeContainer>
6770

6871
<BadgeContainer label="invalid Src">
6972
<ProjectAvatar
7073
src={data ? "invalid-src" : undefined}
7174
className="size-6"
75+
client={client}
7276
/>
7377
</BadgeContainer>
7478
</div>

apps/dashboard/src/@/components/blocks/Avatars/ProjectAvatar.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
import { Img } from "@/components/blocks/Img";
22
import { BoxIcon } from "lucide-react";
3+
import type { ThirdwebClient } from "thirdweb";
4+
import { resolveSchemeWithErrorHandler } from "../../../lib/resolveSchemeWithErrorHandler";
35
import { cn } from "../../../lib/utils";
46

57
export function ProjectAvatar(props: {
68
src: string | undefined;
79
className: string | undefined;
10+
client: ThirdwebClient;
811
}) {
912
return (
1013
<Img
11-
src={props.src}
12-
className={cn("rounded-lg border border-border", props.className)}
14+
src={
15+
resolveSchemeWithErrorHandler({
16+
uri: props.src,
17+
client: props.client,
18+
}) || ""
19+
}
20+
className={cn("rounded-full border border-border", props.className)}
1321
alt={""}
1422
fallback={
1523
<div className="flex items-center justify-center bg-card">

apps/dashboard/src/@/components/ui/button.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,16 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
4747
({ className, variant, size, asChild = false, ...props }, ref) => {
4848
const Comp = asChild ? Slot : "button";
4949
const btnOnlyProps =
50-
Comp === "button" ? { type: "button" as const } : undefined;
50+
Comp === "button"
51+
? { type: props.type || ("button" as const) }
52+
: undefined;
53+
5154
return (
5255
<Comp
5356
className={cn(buttonVariants({ variant, size, className }))}
5457
ref={ref}
55-
{...btnOnlyProps}
5658
{...props}
59+
{...btnOnlyProps}
5760
/>
5861
);
5962
},

apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ async function getTeamsAndProjectsIfLoggedIn() {
109109
projects: (await getProjects(team.slug)).map((x) => ({
110110
id: x.id,
111111
name: x.name,
112+
image: x.image,
112113
})),
113114
})),
114115
);

apps/dashboard/src/app/(dashboard)/published-contract/components/uri-based-deploy.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export async function DeployFormForUri(props: DeployFormForUriProps) {
3636
projects: (await getProjects(team.slug)).map((x) => ({
3737
id: x.id,
3838
name: x.name,
39+
image: x.image,
3940
})),
4041
})),
4142
);

apps/dashboard/src/app/account/components/AccountHeaderUI.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
6666
focus="team-selection"
6767
createProject={props.createProject}
6868
account={props.account}
69+
client={props.client}
6970
/>
7071
)}
7172
</div>

apps/dashboard/src/app/team/[team_slug]/(team)/page.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { getWalletConnections } from "@/api/analytics";
22
import { type Project, getProjects } from "@/api/projects";
33
import { getTeamBySlug } from "@/api/team";
4+
import { getThirdwebClient } from "@/constants/thirdweb.server";
45
import { subDays } from "date-fns";
56
import { redirect } from "next/navigation";
7+
import { getAuthToken } from "../../../api/lib/getAuthToken";
8+
import { loginRedirect } from "../../../login/loginRedirect";
69
import {
710
type ProjectWithAnalytics,
811
TeamProjectsPage,
@@ -12,7 +15,14 @@ export default async function Page(props: {
1215
params: Promise<{ team_slug: string }>;
1316
}) {
1417
const params = await props.params;
15-
const team = await getTeamBySlug(params.team_slug);
18+
const [team, authToken] = await Promise.all([
19+
getTeamBySlug(params.team_slug),
20+
getAuthToken(),
21+
]);
22+
23+
if (!authToken) {
24+
loginRedirect(`/team/${params.team_slug}`);
25+
}
1626

1727
if (!team) {
1828
redirect("/team");
@@ -32,7 +42,11 @@ export default async function Page(props: {
3242
</div>
3343

3444
<div className="container flex grow flex-col pt-8 pb-20">
35-
<TeamProjectsPage projects={projectsWithTotalWallets} team={team} />
45+
<TeamProjectsPage
46+
projects={projectsWithTotalWallets}
47+
team={team}
48+
client={getThirdwebClient(authToken)}
49+
/>
3650
</div>
3751
</div>
3852
);

apps/dashboard/src/app/team/[team_slug]/(team)/~/projects/TeamProjectsPage.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { formatDate } from "date-fns";
2929
import { PlusIcon, SearchIcon } from "lucide-react";
3030
import Link from "next/link";
3131
import { useMemo, useState } from "react";
32+
import type { ThirdwebClient } from "thirdweb";
3233

3334
type SortById = "name" | "createdAt" | "monthlyActiveUsers";
3435

@@ -39,6 +40,7 @@ export type ProjectWithAnalytics = Project & {
3940
export function TeamProjectsPage(props: {
4041
projects: ProjectWithAnalytics[];
4142
team: Team;
43+
client: ThirdwebClient;
4244
}) {
4345
const { projects } = props;
4446
const [searchTerm, setSearchTerm] = useState("");
@@ -175,6 +177,7 @@ export function TeamProjectsPage(props: {
175177
<ProjectAvatar
176178
className="size-8 rounded-full"
177179
src={project.image || ""}
180+
client={props.client}
178181
/>
179182
<span className="font-medium text-sm">
180183
{project.name}

apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.stories.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ function Story(props: {
3636
return (
3737
<div className="mx-auto w-full max-w-[1100px] px-4 py-6">
3838
<ProjectGeneralSettingsPageUI
39+
updateProjectImage={async (file) => {
40+
await new Promise((resolve) => setTimeout(resolve, 1000));
41+
console.log("updateProjectImage", file);
42+
}}
3943
isOwnerAccount={props.isOwnerAccount}
4044
transferProject={async (newTeam) => {
4145
await new Promise((resolve) => setTimeout(resolve, 1000));

apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.tsx

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"use client";
2+
import { apiServerProxy } from "@/actions/proxies";
23
import type { Project } from "@/api/projects";
34
import type { Team } from "@/api/team";
45
import { GradientAvatar } from "@/components/blocks/Avatars/GradientAvatar";
@@ -31,6 +32,7 @@ import { Switch } from "@/components/ui/switch";
3132
import { Textarea } from "@/components/ui/textarea";
3233
import { ToolTipLabel } from "@/components/ui/tooltip";
3334
import { useDashboardRouter } from "@/lib/DashboardRouter";
35+
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
3436
import { cn } from "@/lib/utils";
3537
import type { RotateSecretKeyAPIReturnType } from "@3rdweb-sdk/react/hooks/useApi";
3638
import {
@@ -46,6 +48,7 @@ import {
4648
type ServiceName,
4749
getServiceByName,
4850
} from "@thirdweb-dev/service-utils";
51+
import { FileInput } from "components/shared/FileInput";
4952
import { format } from "date-fns";
5053
import { useTrack } from "hooks/analytics/useTrack";
5154
import {
@@ -60,11 +63,11 @@ import { type UseFormReturn, useForm } from "react-hook-form";
6063
import { type FieldArrayWithId, useFieldArray } from "react-hook-form";
6164
import { toast } from "sonner";
6265
import type { ThirdwebClient } from "thirdweb";
66+
import { upload } from "thirdweb/storage";
6367
import { RE_BUNDLE_ID } from "utils/regex";
6468
import { joinWithComma, toArrFromList } from "utils/string";
6569
import { validStrList } from "utils/validations";
6670
import { z } from "zod";
67-
import { apiServerProxy } from "../../../../../@/actions/proxies";
6871
import {
6972
HIDDEN_SERVICES,
7073
projectDomainsSchema,
@@ -119,6 +122,29 @@ export function ProjectGeneralSettingsPage(props: {
119122
client={props.client}
120123
teamSlug={props.teamSlug}
121124
project={props.project}
125+
updateProjectImage={async (file) => {
126+
let uri: string | undefined = undefined;
127+
128+
if (file) {
129+
// upload to IPFS
130+
uri = await upload({
131+
client: props.client,
132+
files: [file],
133+
});
134+
}
135+
136+
await updateProjectClient(
137+
{
138+
projectId: props.project.id,
139+
teamId: props.project.teamId,
140+
},
141+
{
142+
image: uri,
143+
},
144+
);
145+
146+
router.refresh();
147+
}}
122148
updateProject={async (projectValues) => {
123149
return updateProjectClient(
124150
{
@@ -184,6 +210,7 @@ export function ProjectGeneralSettingsPageUI(props: {
184210
client: ThirdwebClient;
185211
transferProject: (newTeam: Team) => Promise<void>;
186212
isOwnerAccount: boolean;
213+
updateProjectImage: (file: File | undefined) => Promise<void>;
187214
}) {
188215
const projectLayout = `/team/${props.teamSlug}/${props.project.slug}`;
189216

@@ -320,6 +347,12 @@ export function ProjectGeneralSettingsPageUI(props: {
320347
handleSubmit={handleSubmit}
321348
/>
322349

350+
<ProjectImageSetting
351+
updateProjectImage={props.updateProjectImage}
352+
avatar={project.image || null}
353+
client={props.client}
354+
/>
355+
323356
<ProjectKeyDetails
324357
project={project}
325358
rotateSecretKey={props.rotateSecretKey}
@@ -402,6 +435,66 @@ function ProjectNameSetting(props: {
402435
);
403436
}
404437

438+
function ProjectImageSetting(props: {
439+
updateProjectImage: (file: File | undefined) => Promise<void>;
440+
avatar: string | null;
441+
client: ThirdwebClient;
442+
}) {
443+
const projectAvatarUrl = resolveSchemeWithErrorHandler({
444+
client: props.client,
445+
uri: props.avatar || undefined,
446+
});
447+
448+
const [projectAvatar, setProjectAvatar] = useState<File | undefined>();
449+
450+
const updateProjectAvatarMutation = useMutation({
451+
mutationFn: async (_avatar: File | undefined) => {
452+
await props.updateProjectImage(_avatar);
453+
},
454+
});
455+
456+
function handleSave() {
457+
const promise = updateProjectAvatarMutation.mutateAsync(projectAvatar);
458+
toast.promise(promise, {
459+
success: "Project avatar updated successfully",
460+
error: "Failed to update project avatar",
461+
});
462+
}
463+
464+
return (
465+
<SettingsCard
466+
bottomText="An avatar is optional but strongly recommended."
467+
saveButton={{
468+
onClick: handleSave,
469+
disabled: false,
470+
isPending: updateProjectAvatarMutation.isPending,
471+
}}
472+
noPermissionText={undefined}
473+
errorText={undefined}
474+
>
475+
<div className="flex flex-row gap-4 md:justify-between">
476+
<div>
477+
<h3 className="font-semibold text-xl tracking-tight">
478+
Project Avatar
479+
</h3>
480+
<p className="mt-1.5 mb-4 text-foreground text-sm leading-relaxed">
481+
This is your project's avatar. <br /> Click on the avatar to upload
482+
a custom one
483+
</p>
484+
</div>
485+
<FileInput
486+
accept={{ "image/*": [] }}
487+
value={projectAvatar}
488+
setValue={setProjectAvatar}
489+
className="w-20 rounded-full lg:w-28"
490+
disableHelperText
491+
fileUrl={projectAvatarUrl}
492+
/>
493+
</div>
494+
</SettingsCard>
495+
);
496+
}
497+
405498
function AllowedDomainsSetting(props: {
406499
form: UpdateAPIForm;
407500
isUpdatingProject: boolean;

0 commit comments

Comments
 (0)