Skip to content

Commit 30741b8

Browse files
committed
working update profile endpoint, util, and cache update
1 parent 0215014 commit 30741b8

File tree

6 files changed

+197
-5
lines changed

6 files changed

+197
-5
lines changed

app/pages/profile/[handle]/index.vue

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
<script setup lang="ts">
22
import { debounce } from 'perfect-debounce'
3+
import { updateProfile as updateProfileUtil } from '~/utils/atproto/profile'
34
import { normalizeSearchParam } from '#shared/utils/url'
45
5-
const route = useRoute('/profile/[handle]')
6-
const router = useRouter()
7-
86
type LikesResult = {
97
records: {
108
value: {
@@ -13,6 +11,8 @@ type LikesResult = {
1311
}[]
1412
}
1513
14+
const route = useRoute('/profile/[handle]')
15+
const router = useRouter()
1616
const handle = computed(() => route.params.handle)
1717
1818
const { data: profile }: { data?: NPMXProfile } = useFetch(
@@ -23,6 +23,57 @@ const { data: profile }: { data?: NPMXProfile } = useFetch(
2323
},
2424
)
2525
26+
const { user } = useAtproto()
27+
const isEditing = ref(false)
28+
const displayNameInput = ref()
29+
const descriptionInput = ref()
30+
const websiteInput = ref()
31+
const isUpdateProfileActionPending = ref(false)
32+
33+
watchEffect(() => {
34+
if (isEditing) {
35+
if (profile) {
36+
displayNameInput.value = profile.value.displayName
37+
descriptionInput.value = profile.value.description
38+
websiteInput.value = profile.value.website
39+
}
40+
}
41+
})
42+
43+
async function updateProfile() {
44+
if (!user.value.handle || !displayNameInput.value) {
45+
return
46+
}
47+
48+
isUpdateProfileActionPending.value = true
49+
const currentProfile = profile.value
50+
51+
// optimistic update
52+
profile.value = {
53+
displayName: displayNameInput.value,
54+
description: descriptionInput.value,
55+
website: websiteInput.value,
56+
}
57+
58+
try {
59+
const result = await updateProfileUtil(handle, {
60+
displayName: displayNameInput.value,
61+
description: descriptionInput.value,
62+
website: websiteInput.value,
63+
})
64+
65+
if (!result.success) {
66+
profile.value = currentProfile
67+
}
68+
69+
isUpdateProfileActionPending.value = false
70+
isEditing.value = false
71+
} catch (e) {
72+
profile.value = currentProfile
73+
isUpdateProfileActionPending.value = false
74+
}
75+
}
76+
2677
const { data: likesData, status } = await useProfileLikes(handle)
2778
2879
useSeoMeta({
@@ -43,7 +94,49 @@ defineOgImageComponent('Default', {
4394
<main class="container flex-1 flex flex-col py-8 sm:py-12 w-full">
4495
<!-- Header -->
4596
<header class="mb-8 pb-8 border-b border-border">
46-
<div class="flex flex-col flex-wrap gap-4">
97+
<!-- Editing Profile -->
98+
<div v-if="isEditing" class="flex flex-col flex-wrap gap-4">
99+
<button @click="isEditing = false">Cancel</button>
100+
<button @click.prevent="updateProfile" :disabled="isUpdateProfileActionPending">
101+
Save
102+
</button>
103+
<label for="displayName" class="text-sm flex flex-col gap-2">
104+
Display Name
105+
<input
106+
required
107+
name="displayName"
108+
type="text"
109+
class="w-full min-w-25 bg-bg-subtle border border-border rounded-md ps-3 pe-3 py-1.5 font-mono text-sm text-fg placeholder:text-fg-subtle transition-[border-color,outline-color] duration-300 hover:border-fg-subtle outline-2 outline-transparent focus:border-accent focus-visible:(outline-2 outline-accent/70)"
110+
v-model="displayNameInput"
111+
/>
112+
</label>
113+
<label for="description" class="text-sm flex flex-col gap-2">
114+
Description
115+
<input
116+
name="description"
117+
type="text"
118+
placeholder="No description"
119+
v-model="descriptionInput"
120+
class="w-full min-w-25 bg-bg-subtle border border-border rounded-md ps-3 pe-3 py-1.5 font-mono text-sm text-fg placeholder:text-fg-subtle transition-[border-color,outline-color] duration-300 hover:border-fg-subtle outline-2 outline-transparent focus:border-accent focus-visible:(outline-2 outline-accent/70)"
121+
/>
122+
</label>
123+
<div class="flex gap-4 items-center">
124+
<h2>@{{ handle }}</h2>
125+
<div class="link-subtle font-mono text-sm inline-flex items-center gap-1.5">
126+
<span class="i-carbon:link w-4 h-4" aria-hidden="true" />
127+
<input
128+
name="website"
129+
type=""
130+
v-model="websiteInput"
131+
class="w-full min-w-25 bg-bg-subtle border border-border rounded-md ps-3 pe-3 py-1.5 font-mono text-sm text-fg placeholder:text-fg-subtle transition-[border-color,outline-color] duration-300 hover:border-fg-subtle outline-2 outline-transparent focus:border-accent focus-visible:(outline-2 outline-accent/70)"
132+
/>
133+
</div>
134+
</div>
135+
</div>
136+
137+
<!-- Display Profile -->
138+
<div v-else class="flex flex-col flex-wrap gap-4">
139+
<button v-if="user?.handle === handle" @click="isEditing = true">Edit</button>
47140
<h1 class="font-mono text-2xl sm:text-3xl font-medium">{{ profile.displayName }}</h1>
48141
<p v-if="profile.description">{{ profile.description }}</p>
49142
<div class="flex gap-4">

app/utils/atproto/profile.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { FetchError } from 'ofetch'
2+
import { handleAuthError } from './helpers'
3+
4+
export type UpdateProfileResult = {
5+
success: boolean
6+
error?: Error
7+
}
8+
9+
/**
10+
* Update an NPMX Profile via the API
11+
*/
12+
export async function updateProfile(
13+
userHandle: string,
14+
{
15+
displayName,
16+
description,
17+
website,
18+
}: {
19+
displayName: string
20+
description?: string
21+
website?: string
22+
},
23+
): Promise<UpdateProfileResult> {
24+
try {
25+
await $fetch<string>(`/api/social/profile/${userHandle}`, {
26+
method: 'PUT',
27+
body: { displayName, description, website },
28+
})
29+
return { success: true }
30+
} catch (e) {
31+
if (e instanceof FetchError) {
32+
await handleAuthError(e, userHandle)
33+
}
34+
return { success: false, error: e as Error }
35+
}
36+
}

lexicons/dev/npmx/actor/profile.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"type": "record",
88
"record": {
99
"type": "object",
10+
"required": ["displayName"],
1011
"properties": {
1112
"avatar": {
1213
"type": "blob",
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { parse } from 'valibot'
2+
import { Client } from '@atproto/lex'
3+
import * as dev from '#shared/types/lexicons/dev'
4+
import type { NPMXProfile } from '~~/shared/types/social'
5+
import { ProfileEditBodySchema } from '~~/shared/schemas/social'
6+
7+
export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
8+
const loggedInUsersDid = oAuthSession?.did.toString()
9+
10+
if (!oAuthSession || !loggedInUsersDid) {
11+
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
12+
}
13+
14+
//Checks if the user has a scope to like packages
15+
await throwOnMissingOAuthScope(oAuthSession, LIKES_SCOPE)
16+
17+
const body = parse(ProfileEditBodySchema, await readBody(event))
18+
const client = new Client(oAuthSession)
19+
20+
const profile = dev.npmx.actor.profile.$build({
21+
displayName: body.displayName,
22+
...(body.description
23+
? {
24+
description: body.description,
25+
}
26+
: {}),
27+
...(body.website
28+
? {
29+
website: body.website as `${string}:${string}`,
30+
}
31+
: {}),
32+
})
33+
34+
const result = await client.put(dev.npmx.actor.profile, profile, { rkey: 'self' })
35+
if (!result) {
36+
throw createError({
37+
status: 500,
38+
message: 'Failed to update the profile',
39+
})
40+
}
41+
42+
const profileUtil = new ProfileUtils()
43+
await profileUtil.updateProfileCache(loggedInUsersDid, body as NPMXProfile)
44+
45+
return result.validationStatus
46+
})

server/utils/atproto/utils/profile.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export class ProfileUtils {
5555
* @param handle
5656
* @returns
5757
*/
58-
async getProfile(handle: string) {
58+
async getProfile(handle: string): Promise<NPMXProfile | undefined> {
5959
const profileKey = CACHE_PROFILE_KEY(handle)
6060
const cachedProfile = await this.cache.get<NPMXProfile>(profileKey)
6161

@@ -80,4 +80,11 @@ export class ProfileUtils {
8080

8181
return profile
8282
}
83+
84+
async updateProfileCache(handle: string, profile: NPMXProfile): Promise<NPMXProfile | undefined> {
85+
const miniDoc = await this.slingshotMiniDoc(handle)
86+
const profileKey = CACHE_PROFILE_KEY(miniDoc.handle)
87+
await this.cache.set(profileKey, profile, CACHE_MAX_AGE)
88+
return profile
89+
}
8390
}

shared/schemas/social.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,12 @@ export const PackageLikeBodySchema = v.object({
99
})
1010

1111
export type PackageLikeBody = v.InferOutput<typeof PackageLikeBodySchema>
12+
13+
// TODO: add 'avatar'
14+
export const ProfileEditBodySchema = v.object({
15+
displayName: v.string(),
16+
website: v.optional(v.string()),
17+
description: v.optional(v.string()),
18+
})
19+
20+
export type ProfileEditBody = v.InferOutput<typeof ProfileEditBodySchema>

0 commit comments

Comments
 (0)