Skip to content

Commit 4336f8a

Browse files
feat: change user profile picture (#38)
* wip * feat: finish profile picture editing * chore: biome * build: pnpm install * fix: use @backend not test * chore: remove unused import * fix: get userId from server session for security
1 parent 72c7c2a commit 4336f8a

11 files changed

Lines changed: 166 additions & 33 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"@base-ui/react": "^1.3.0",
1717
"@better-auth/passkey": "^1.5.5",
1818
"@hookform/resolvers": "^3.9.1",
19-
"@polinetwork/backend": "^0.15.19",
19+
"@polinetwork/backend": "^0.16.0",
2020
"@radix-ui/react-dialog": "^1.1.15",
2121
"@t3-oss/env-nextjs": "^0.13.10",
2222
"@tanstack/react-table": "^8.21.2",

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/dashboard/(active)/account/page.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import { Calendar, CircleAlert, KeyIcon, UserIcon } from "lucide-react"
1+
import { Calendar, CircleAlert, KeyIcon } from "lucide-react"
22
import { headers } from "next/headers"
3-
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
43
import { auth } from "@/lib/auth"
5-
import { getInitials } from "@/lib/utils"
64
import { getServerSession } from "@/server/auth"
75
import { DeletePasskey } from "./delete-passkey"
86
import { NewPasskeyButton } from "./passkey-button"
7+
import { ProfilePic } from "./profile-pic"
98
import { SetName } from "./set-name"
109
import { Telegram } from "./telegram"
1110

@@ -25,12 +24,7 @@ export default async function Account() {
2524
<main className="container mx-auto px-4 py-8">
2625
<h2 className="text-accent-foreground mb-4 text-3xl font-bold">Account</h2>
2726
<div className="flex gap-4 mb-12">
28-
<Avatar className="h-32 w-32 rounded-lg after:rounded-lg">
29-
{user.image && <AvatarImage src={user.image} alt={`propic of ${user.name}`} />}
30-
<AvatarFallback className="rounded-lg text-3xl">
31-
{user.name ? getInitials(user.name) : <UserIcon size={48} />}
32-
</AvatarFallback>
33-
</Avatar>
27+
<ProfilePic user={user} />
3428

3529
<div className="flex flex-col gap-2">
3630
<div className="flex items-center gap-2">
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"use client"
2+
3+
import type { User } from "better-auth"
4+
import { Pencil, Upload, UserIcon, X } from "lucide-react"
5+
import { useRouter } from "next/navigation"
6+
import { forwardRef, useRef } from "react"
7+
import { toast } from "sonner"
8+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
9+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
10+
import { auth, useSession } from "@/lib/auth"
11+
import { getInitials } from "@/lib/utils"
12+
import { updateProfilePic } from "@/server/actions/users"
13+
14+
type Props = {
15+
user: User
16+
}
17+
18+
export function ProfilePic({ user }: Props) {
19+
const uploadRef = useRef<HTMLInputElement>(null)
20+
const router = useRouter()
21+
const { refetch } = useSession()
22+
23+
async function handleRemove() {
24+
const ok = confirm("Are you sure you want to reset your profile picture?")
25+
if (!ok) return
26+
27+
const r = await auth.updateUser({ image: null })
28+
if (r.error) {
29+
toast.error("There was an error")
30+
console.error(r.error)
31+
return
32+
}
33+
34+
toast.success("Profile picture removed successfully'")
35+
await refetch()
36+
router.refresh()
37+
}
38+
39+
return (
40+
<div className="flex flex-col items-center gap-2">
41+
<Avatar className="h-32 w-32 rounded-full relative group peer">
42+
<DropdownMenu>
43+
<DropdownMenuTrigger>
44+
<div className="absolute top-0 left-0 w-full h-full group-hover:opacity-100 opacity-0 bg-background/70 backdrop-blur-[1px] transition-all cursor-pointer z-10 grid place-content-center duration-100">
45+
<Pencil size={32} />
46+
</div>
47+
</DropdownMenuTrigger>
48+
<DropdownMenuContent>
49+
<DropdownMenuItem onClick={() => uploadRef.current?.click()}>
50+
<Upload /> Upload
51+
</DropdownMenuItem>
52+
<DropdownMenuItem onClick={handleRemove} variant="destructive" disabled={!user.image}>
53+
<X /> Remove
54+
</DropdownMenuItem>
55+
</DropdownMenuContent>
56+
</DropdownMenu>
57+
58+
<UploadProfilePicture ref={uploadRef} />
59+
60+
<AvatarImage src={user.image || undefined} alt={`propic of ${user.name}`} />
61+
<AvatarFallback className="rounded-full text-3xl text-foreground">
62+
{user.name ? getInitials(user.name) : <UserIcon size={48} />}
63+
</AvatarFallback>
64+
</Avatar>
65+
<p className="peer-hover:opacity-100 opacity-0 text-xs">Max 1MB</p>
66+
</div>
67+
)
68+
}
69+
70+
const UploadProfilePicture = forwardRef<HTMLInputElement>((_, ref) => {
71+
const router = useRouter()
72+
const { refetch } = useSession()
73+
74+
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
75+
const files = event.target.files
76+
if (files && files.length > 0) {
77+
const selectedFile = files[0]
78+
if (!selectedFile) return
79+
80+
if (selectedFile.size > 1024 * 1024) {
81+
toast.error("The image must be no larger than 1 MB")
82+
return
83+
}
84+
85+
if (selectedFile.type !== "image/png" && selectedFile.type !== "image/jpeg") {
86+
toast.error("The image must be jpeg or png")
87+
return
88+
}
89+
90+
const { success } = await updateProfilePic(selectedFile)
91+
if (success) toast.success("Image changed successfully!")
92+
else toast.error("There was an error, try again")
93+
await refetch()
94+
router.refresh()
95+
}
96+
}
97+
return (
98+
<input
99+
className="hidden"
100+
type="file"
101+
ref={ref}
102+
onChange={handleFileChange}
103+
multiple={false}
104+
accept="image/png,image/jpeg"
105+
maxLength={1024 * 1024}
106+
/>
107+
)
108+
})

src/app/dashboard/(active)/azure/members/page.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import { Suspense } from "react"
22
import { ErrorBoundary } from "react-error-boundary"
33
import { Spinner } from "@/components/spinner"
4-
import { wait } from "@/lib/utils"
54
import { getAzureMembers } from "@/server/actions/azure"
65
import { AssocTable } from "./table"
76

87
export default async function AssocMembers() {
98
const members = await getAzureMembers()
10-
// await wait(120_000)
119

1210
return (
1311
<div className="container p-8">

src/app/dashboard/(active)/telegram/groups/group-row.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"use client"
2-
import { Copy, Pen } from "lucide-react"
2+
import { Copy } from "lucide-react"
33
import { useRouter } from "next/navigation"
44
import { toast } from "sonner"
55
import { Badge } from "@/components/ui/badge"

src/app/dashboard/(active)/telegram/user-details/remove-role.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
2020
import { useSession } from "@/lib/auth"
2121
import { delUserRole } from "@/server/actions/users"
22-
import type { ApiOutput, TgUser, TgUserRole } from "@/server/trpc/types"
22+
import type { TgUser, TgUserRole } from "@/server/trpc/types"
2323

2424
const ARRAY_USER_ROLES = [
2525
USER_ROLE.ADMIN,

src/components/admin-header/right-nav.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"use client"
2-
import { LogOutIcon, Settings2 } from "lucide-react"
2+
import { LogOutIcon, Settings2, UserIcon } from "lucide-react"
33
import Link from "next/link"
44
import { redirect } from "next/navigation"
5-
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
5+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
66
import { Button } from "@/components/ui/button"
77
import {
88
DropdownMenu,
@@ -24,8 +24,9 @@ export function RightNav() {
2424
render={
2525
<Button variant="ghost" size="icon" className="rounded-full">
2626
<Avatar size="lg">
27-
<AvatarFallback className="text-foreground text-base">
28-
{data.user.name ? getInitials(data.user.name) : data.user.email.slice(0, 2)}
27+
<AvatarImage src={data.user.image || undefined} alt={`propic of ${data.user.name}`} />
28+
<AvatarFallback className="rounded-full text-base text-foreground">
29+
{data.user.name ? getInitials(data.user.name) : <UserIcon className="size-5" />}
2930
</AvatarFallback>
3031
</Avatar>
3132
</Button>

src/server/actions/users.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
"use server"
2-
import { requireRole } from "../auth"
2+
import { getServerSession, requireRole } from "../auth"
33
import { trpc } from "../trpc"
44
import type { ApiOutput, TgUserRole } from "../trpc/types"
55
import { getUserGrant } from "./grants"
66

7+
export async function updateProfilePic(file: File) {
8+
const session = await getServerSession()
9+
if (!session.data) return { success: false }
10+
11+
const fd = new FormData()
12+
fd.set("userId", session.data.user.id)
13+
fd.set("image", file)
14+
return await trpc.auth.updateProfilePic.mutate(fd)
15+
}
16+
717
export async function getUserInfo(userId: number) {
818
return (await trpc.tg.users.get.query({ userId })).user ?? null
919
}

src/server/trpc/index.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,28 @@
11
import "server-only"
22

33
import { type AppRouter, TRPC_PATH } from "@polinetwork/backend"
4-
import { createTRPCClient, httpBatchLink } from "@trpc/client"
4+
import { createTRPCClient, httpBatchLink, httpLink, isNonJsonSerializable, splitLink } from "@trpc/client"
55
import SuperJSON from "superjson"
66
import { env } from "@/env"
77

88
const url = env.BACKEND_URL + TRPC_PATH
99
export const trpc = createTRPCClient<AppRouter>({
1010
links: [
11-
httpBatchLink({
12-
url,
13-
transformer: SuperJSON,
11+
splitLink({
12+
condition: (op) => isNonJsonSerializable(op.input),
13+
true: httpLink({
14+
url,
15+
transformer: {
16+
// request - convert data before sending to the tRPC server
17+
serialize: (data) => data,
18+
// response - convert the tRPC response before using it in client
19+
deserialize: (data) => SuperJSON.deserialize(data), // or your other transformer
20+
},
21+
}),
22+
false: httpBatchLink({
23+
url,
24+
transformer: SuperJSON, // or your other transformer
25+
}),
1426
}),
1527
],
1628
})

0 commit comments

Comments
 (0)