Skip to content

Commit 671d13a

Browse files
zeucapuafatfingers23danielroeKai-rosautofix-ci[bot]
authored
feat: profile page (#1113)
Co-authored-by: Bailey Townsend <baileytownsend2323@gmail.com> Co-authored-by: Daniel Roe <daniel@roe.dev> Co-authored-by: Brandon Hurrington <brandon.o.hurrington@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 85ac3d7 commit 671d13a

29 files changed

Lines changed: 1236 additions & 18 deletions

File tree

app/components/Header/AuthModal.client.vue

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { useAtproto } from '~/composables/atproto/useAtproto'
33
import { authRedirect } from '~/utils/atproto/helpers'
44
import { isAtIdentifierString } from '@atproto/lex'
55
6+
const authModal = useModal('auth-modal')
7+
68
const handleInput = shallowRef('')
79
const errorMessage = shallowRef('')
810
const route = useRoute()
@@ -72,9 +74,22 @@ watch(user, async newUser => {
7274
</p>
7375
</div>
7476
</div>
75-
<ButtonBase class="w-full" @click="logout">
76-
{{ $t('auth.modal.disconnect') }}
77-
</ButtonBase>
77+
78+
<div class="flex flex-col space-y-4">
79+
<LinkBase
80+
variant="button-secondary"
81+
:to="{ name: 'profile-handle', params: { handle: user.handle } }"
82+
prefetch-on="interaction"
83+
class="w-full"
84+
@click="authModal.close()"
85+
>
86+
{{ $t('auth.modal.profile') }}
87+
</LinkBase>
88+
89+
<ButtonBase class="w-full" @click="logout">
90+
{{ $t('auth.modal.disconnect') }}
91+
</ButtonBase>
92+
</div>
7893
</div>
7994

8095
<!-- Disconnected state -->
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<script setup lang="ts">
2+
import { useAtproto } from '~/composables/atproto/useAtproto'
3+
import { togglePackageLike } from '~/utils/atproto/likes'
4+
const props = defineProps<{
5+
packageUrl: string
6+
}>()
7+
8+
const compactNumberFormatter = useCompactNumberFormatter()
9+
10+
function extractPackageFromRef(ref: string) {
11+
return /https:\/\/npmx.dev\/package\/(?<pkg>.*)/.exec(ref)?.groups?.pkg ?? ref
12+
}
13+
14+
const name = computed(() => extractPackageFromRef(props.packageUrl))
15+
16+
const { user } = useAtproto()
17+
18+
const authModal = useModal('auth-modal')
19+
20+
const { data: likesData } = useFetch(() => `/api/social/likes/${name.value}`, {
21+
default: () => ({ totalLikes: 0, userHasLiked: false }),
22+
server: false,
23+
})
24+
25+
const isLikeActionPending = ref(false)
26+
27+
const likeAction = async () => {
28+
if (user.value?.handle == null) {
29+
authModal.open()
30+
return
31+
}
32+
33+
if (isLikeActionPending.value) return
34+
35+
const currentlyLiked = likesData.value?.userHasLiked ?? false
36+
const currentLikes = likesData.value?.totalLikes ?? 0
37+
38+
// Optimistic update
39+
likesData.value = {
40+
totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1,
41+
userHasLiked: !currentlyLiked,
42+
}
43+
44+
isLikeActionPending.value = true
45+
46+
try {
47+
const result = await togglePackageLike(name.value, currentlyLiked, user.value?.handle)
48+
49+
isLikeActionPending.value = false
50+
51+
if (result.success) {
52+
// Update with server response
53+
likesData.value = result.data
54+
} else {
55+
// Revert on error
56+
likesData.value = {
57+
totalLikes: currentLikes,
58+
userHasLiked: currentlyLiked,
59+
}
60+
}
61+
} catch (e) {
62+
// Revert on error
63+
likesData.value = {
64+
totalLikes: currentLikes,
65+
userHasLiked: currentlyLiked,
66+
}
67+
isLikeActionPending.value = false
68+
}
69+
}
70+
</script>
71+
72+
<template>
73+
<NuxtLink :to="packageRoute(name)">
74+
<BaseCard class="font-mono flex justify-between">
75+
{{ name }}
76+
<div class="flex items-center gap-4 justify-between">
77+
<ClientOnly>
78+
<TooltipApp
79+
:text="likesData?.userHasLiked ? $t('package.likes.unlike') : $t('package.likes.like')"
80+
position="bottom"
81+
>
82+
<button
83+
@click.prevent="likeAction"
84+
type="button"
85+
:title="
86+
likesData?.userHasLiked ? $t('package.likes.unlike') : $t('package.likes.like')
87+
"
88+
class="inline-flex items-center gap-1.5 font-mono text-sm text-fg hover:text-fg-muted transition-colors duration-200"
89+
:aria-label="
90+
likesData?.userHasLiked ? $t('package.likes.unlike') : $t('package.likes.like')
91+
"
92+
>
93+
<span
94+
:class="
95+
likesData?.userHasLiked
96+
? 'i-lucide-heart-minus text-red-500'
97+
: 'i-lucide-heart-plus'
98+
"
99+
class="w-4 h-4"
100+
aria-hidden="true"
101+
/>
102+
<span>{{ compactNumberFormatter.format(likesData?.totalLikes ?? 0) }}</span>
103+
</button>
104+
</TooltipApp>
105+
</ClientOnly>
106+
<p class="transition-transform duration-150 group-hover:rotate-45 pb-1">↗</p>
107+
</div>
108+
</BaseCard>
109+
</NuxtLink>
110+
</template>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export type LikesResult = {
2+
records: {
3+
value: {
4+
subjectRef: string
5+
}
6+
}[]
7+
}
8+
9+
export function useProfileLikes(handle: MaybeRefOrGetter<string>) {
10+
const asyncData = useLazyFetch<LikesResult>(() => `/api/social/profile/${toValue(handle)}/likes`)
11+
12+
return asyncData
13+
}
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
<script setup lang="ts">
2+
import { updateProfile as updateProfileUtil } from '~/utils/atproto/profile'
3+
4+
const route = useRoute('profile-handle')
5+
const handle = computed(() => route.params.handle)
6+
7+
const { data: profile, error: profileError } = await useFetch<NPMXProfile>(
8+
() => `/api/social/profile/${handle.value}`,
9+
{
10+
default: () => ({
11+
displayName: handle.value,
12+
description: '',
13+
website: '',
14+
recordExists: false,
15+
}),
16+
},
17+
)
18+
if (!profile.value || profileError.value?.statusCode === 404) {
19+
throw createError({
20+
statusCode: 404,
21+
statusMessage: $t('profile.not_found'),
22+
message: $t('profile.not_found_message', { handle: handle.value }),
23+
})
24+
}
25+
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.value) {
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 || undefined,
55+
website: websiteInput.value || undefined,
56+
recordExists: true,
57+
}
58+
59+
try {
60+
const result = await updateProfileUtil(handle.value, {
61+
displayName: displayNameInput.value,
62+
description: descriptionInput.value || undefined,
63+
website: websiteInput.value || undefined,
64+
})
65+
66+
if (result.success) {
67+
isEditing.value = false
68+
} else {
69+
profile.value = currentProfile
70+
}
71+
72+
isUpdateProfileActionPending.value = false
73+
} catch (e) {
74+
profile.value = currentProfile
75+
isUpdateProfileActionPending.value = false
76+
}
77+
}
78+
79+
const { data: likes, status } = useProfileLikes(handle)
80+
81+
const showInviteSection = computed(() => {
82+
return (
83+
profile.value.recordExists === false &&
84+
status.value === 'success' &&
85+
!likes.value?.records?.length &&
86+
user.value?.handle !== handle.value
87+
)
88+
})
89+
90+
const inviteUrl = computed(() => {
91+
const text = $t('profile.invite.compose_text', { handle: handle.value })
92+
return `https://bsky.app/intent/compose?text=${encodeURIComponent(text)}`
93+
})
94+
95+
useSeoMeta({
96+
title: () => $t('profile.seo_title', { handle: handle.value }),
97+
description: () => $t('profile.seo_description', { handle: handle.value }),
98+
})
99+
100+
/**
101+
defineOgImageComponent('Default', {
102+
title: () => `~${username.value}`,
103+
description: () => (results.value ? `${results.value.total} packages` : 'npm user profile'),
104+
primaryColor: '#60a5fa',
105+
})
106+
**/
107+
</script>
108+
109+
<template>
110+
<main class="container flex-1 flex flex-col py-8 sm:py-12 w-full">
111+
<!-- Header -->
112+
<header class="mb-8 pb-8 border-b border-border">
113+
<!-- Editing Profile -->
114+
<div v-if="isEditing" class="flex flex-col flex-wrap gap-4">
115+
<label for="displayName" class="text-sm flex flex-col gap-2">
116+
{{ $t('profile.display_name') }}
117+
<input
118+
required
119+
name="displayName"
120+
type="text"
121+
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)"
122+
v-model="displayNameInput"
123+
/>
124+
</label>
125+
<label for="description" class="text-sm flex flex-col gap-2">
126+
{{ $t('profile.description') }}
127+
<input
128+
name="description"
129+
type="text"
130+
:placeholder="$t('profile.no_description')"
131+
v-model="descriptionInput"
132+
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)"
133+
/>
134+
</label>
135+
<label for="website" class="text-sm flex flex-col gap-2">
136+
{{ $t('profile.website') }}
137+
<input
138+
name="website"
139+
type="url"
140+
:placeholder="$t('profile.website_placeholder')"
141+
v-model="websiteInput"
142+
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)"
143+
/>
144+
</label>
145+
<div class="flex gap-4 items-center font-mono text-sm">
146+
<h2>@{{ handle }}</h2>
147+
<ButtonBase @click="isEditing = false">
148+
{{ $t('common.cancel') }}
149+
</ButtonBase>
150+
<ButtonBase
151+
@click="updateProfile"
152+
variant="primary"
153+
:disabled="isUpdateProfileActionPending"
154+
>
155+
{{ $t('common.save') }}
156+
</ButtonBase>
157+
</div>
158+
</div>
159+
160+
<!-- Display Profile -->
161+
<div v-else class="flex flex-col flex-wrap gap-4">
162+
<h1 v-if="profile.displayName" class="font-mono text-2xl sm:text-3xl font-medium">
163+
{{ profile.displayName }}
164+
</h1>
165+
<p v-if="profile.description">{{ profile.description }}</p>
166+
<div class="flex gap-4 items-center font-mono text-sm">
167+
<h2>@{{ handle }}</h2>
168+
<LinkBase v-if="profile.website" :to="profile.website" classicon="i-lucide:link">
169+
{{ profile.website }}
170+
</LinkBase>
171+
<ButtonBase
172+
@click="isEditing = true"
173+
:class="user?.handle === handle ? '' : 'invisible'"
174+
class="hidden sm:inline-flex"
175+
>
176+
{{ $t('common.edit') }}
177+
</ButtonBase>
178+
</div>
179+
</div>
180+
</header>
181+
182+
<section class="flex flex-col gap-8">
183+
<h2
184+
class="font-mono text-2xl sm:text-3xl font-medium min-w-0 break-words"
185+
:title="$t('profile.likes')"
186+
dir="ltr"
187+
>
188+
{{ $t('profile.likes') }}
189+
<span v-if="likes">({{ likes.records?.length ?? 0 }})</span>
190+
</h2>
191+
<div v-if="status === 'pending'" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
192+
<SkeletonBlock v-for="i in 4" :key="i" class="h-16 rounded-lg" />
193+
</div>
194+
<div v-else-if="status === 'error'">
195+
<p>{{ $t('common.error') }}</p>
196+
</div>
197+
<div v-else-if="likes?.records" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
198+
<PackageLikeCard v-for="like in likes.records" :packageUrl="like.value.subjectRef" />
199+
</div>
200+
201+
<!-- Invite section: shown when user does not have npmx profile or any like lexicons -->
202+
<div
203+
v-if="showInviteSection"
204+
class="flex flex-col items-start gap-4 p-6 bg-bg-subtle border border-border rounded-lg"
205+
>
206+
<p class="text-fg-muted">
207+
{{ $t('profile.invite.message') }}
208+
</p>
209+
<LinkBase variant="button-secondary" classicon="i-simple-icons:bluesky" :to="inviteUrl">
210+
{{ $t('profile.invite.share_button') }}
211+
</LinkBase>
212+
</div>
213+
</section>
214+
</main>
215+
</template>

0 commit comments

Comments
 (0)