Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
3bf1cde
add profile lexicon + scope, add profile button to AuthModal, add pro…
zeucapua Feb 6, 2026
09c99c1
Like endpoint
fatfingers23 Feb 7, 2026
ecdc8cf
Merge pull request #4 from fatfingers23/feat/profile-page
zeucapua Feb 7, 2026
9591998
init useProfileLikes composable, add likes grid to profile page
zeucapua Feb 10, 2026
c467c3e
create and implement BasicCard for likes grid
zeucapua Feb 10, 2026
dfc4d7d
rename BasicCard to LikeCard, copy paste like button to card
zeucapua Feb 12, 2026
58a3b0c
implement like update
zeucapua Feb 12, 2026
0215014
add website to profile
zeucapua Feb 12, 2026
30741b8
working update profile endpoint, util, and cache update
zeucapua Feb 26, 2026
6e5be0e
style edit buttons
zeucapua Feb 26, 2026
45d35ef
Merge branch 'main' into feat/profile-page
zeucapua Feb 26, 2026
c012a45
fix merge, change likes cols to 2
zeucapua Feb 27, 2026
b81eda7
add TODOs, use LinkBase for profile website
zeucapua Feb 27, 2026
f732b14
fix: use resolved handle from profile in OAuth callback
danielroe Feb 27, 2026
73ce1df
fix: remove duplicate and unused imports in oauth.ts
danielroe Feb 27, 2026
78db755
fix(ui): check isEditing.value instead of ref object in watchEffect
danielroe Feb 27, 2026
d72a756
fix(ui): pass handle.value instead of ComputedRef to updateProfile
danielroe Feb 27, 2026
2e1bbd7
fix(ui): handle non-matching URLs in extractPackageFromRef
danielroe Feb 27, 2026
6e41ecc
fix: use DID consistently for profile cache keys
danielroe Feb 27, 2026
7f0c987
fix: validate identifier param in likes endpoint
danielroe Feb 27, 2026
6c49480
fix(ui): remove undefined prefetch reference in AuthModal
danielroe Feb 27, 2026
fe73fea
fix(ui): remove unused imports, duplicate type, and fix title binding
danielroe Feb 27, 2026
75db4ae
fix(i18n): extract hardcoded strings to locale file
danielroe Feb 27, 2026
8dfc2d9
Merge remote-tracking branch 'origin/main' into feat/profile-page
danielroe Feb 27, 2026
5f3874c
fix(a11y): remove nested button inside NuxtLink in AuthModal
danielroe Feb 27, 2026
b5bf1f2
fix(ui): remove invalid second argument from format() call
danielroe Feb 27, 2026
a3b6cc0
fix(ui): fix useRoute argument, useFetch types, and null safety
danielroe Feb 27, 2026
1bb55b8
fix: replace unlisted @atproto/syntax import and fix typo in identity.ts
danielroe Feb 27, 2026
e2e027d
fix: add response.ok check in slingshotMiniDoc fetch
danielroe Feb 27, 2026
7bde6b7
fix: add validation constraints to ProfileEditBodySchema
danielroe Feb 27, 2026
b2ecd2c
fix: add fetch timeout and encode URI in getNpmxProfile
danielroe Feb 27, 2026
0804679
fix(a11y): add LikeCard a11y test and exclude lexicons from knip
danielroe Feb 27, 2026
2cbf1d9
fix(ui): fix likesData type errors and only close editor on success
danielroe Feb 27, 2026
c4ab87d
fix(ui): add website label/placeholder, fix empty string validation, …
danielroe Feb 27, 2026
cdb891b
fix(ui): handle 404 for non-existing profiles
danielroe Feb 27, 2026
91b24a9
fix: use linkbase/buttonbase
danielroe Feb 27, 2026
e5d1638
fix(ui): SSR likes data and add skeleton placeholders to prevent layo…
danielroe Feb 27, 2026
8f19abe
fix: ssr likes
danielroe Feb 27, 2026
0bf4443
fix(ui): reserve space for edit button to prevent layout shift
danielroe Feb 27, 2026
14556af
fix(ui): remove duplicate group class from LikeCard
danielroe Feb 27, 2026
8bf70eb
Merge remote-tracking branch 'origin/main' into feat/profile-page
danielroe Feb 27, 2026
981b70b
moved some fetches to use the xrpc client
fatfingers23 Feb 27, 2026
81402ee
should be the best to tell if an identity is there or not
fatfingers23 Feb 27, 2026
a834d7a
tell the homies about npmx
fatfingers23 Feb 27, 2026
a114bf0
Update i18n/locales/en.json
zeucapua Feb 27, 2026
5676cf6
Update lunaria/files/en-US.json
zeucapua Feb 27, 2026
e5b9bbd
Merge pull request #5 from fatfingers23/feat/profile-page
zeucapua Feb 27, 2026
c89de62
[autofix.ci] apply automated fixes
autofix-ci[bot] Feb 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions app/components/Header/AuthModal.client.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { useAtproto } from '~/composables/atproto/useAtproto'
import { authRedirect } from '~/utils/atproto/helpers'
import { isAtIdentifierString } from '@atproto/lex'

const authModal = useModal('auth-modal')

const handleInput = shallowRef('')
const errorMessage = shallowRef('')
const route = useRoute()
Expand Down Expand Up @@ -72,9 +74,22 @@ watch(user, async newUser => {
</p>
</div>
</div>
<ButtonBase class="w-full" @click="logout">
{{ $t('auth.modal.disconnect') }}
</ButtonBase>

<div class="flex flex-col space-y-4">
<LinkBase
variant="button-secondary"
:to="{ name: 'profile-handle', params: { handle: user.handle } }"
prefetch-on="interaction"
class="w-full"
@click="authModal.close()"
>
{{ $t('auth.modal.profile') }}
</LinkBase>

<ButtonBase class="w-full" @click="logout">
{{ $t('auth.modal.disconnect') }}
</ButtonBase>
</div>
</div>

<!-- Disconnected state -->
Expand Down
110 changes: 110 additions & 0 deletions app/components/Package/LikeCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { useAtproto } from '~/composables/atproto/useAtproto'
import { togglePackageLike } from '~/utils/atproto/likes'
const props = defineProps<{
packageUrl: string
}>()

const compactNumberFormatter = useCompactNumberFormatter()

function extractPackageFromRef(ref: string) {
return /https:\/\/npmx.dev\/package\/(?<pkg>.*)/.exec(ref)?.groups?.pkg ?? ref
}

const name = computed(() => extractPackageFromRef(props.packageUrl))

const { user } = useAtproto()

const authModal = useModal('auth-modal')

const { data: likesData } = useFetch(() => `/api/social/likes/${name.value}`, {
default: () => ({ totalLikes: 0, userHasLiked: false }),
server: false,
})

const isLikeActionPending = ref(false)

const likeAction = async () => {
if (user.value?.handle == null) {
authModal.open()
return
}

if (isLikeActionPending.value) return

const currentlyLiked = likesData.value?.userHasLiked ?? false
const currentLikes = likesData.value?.totalLikes ?? 0

// Optimistic update
likesData.value = {
totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1,
userHasLiked: !currentlyLiked,
}

isLikeActionPending.value = true

try {
const result = await togglePackageLike(name.value, currentlyLiked, user.value?.handle)

isLikeActionPending.value = false

if (result.success) {
// Update with server response
likesData.value = result.data
} else {
// Revert on error
likesData.value = {
totalLikes: currentLikes,
userHasLiked: currentlyLiked,
}
}
} catch (e) {
// Revert on error
likesData.value = {
totalLikes: currentLikes,
userHasLiked: currentlyLiked,
}
isLikeActionPending.value = false
}
}
</script>

<template>
<NuxtLink :to="packageRoute(name)">
<BaseCard class="font-mono flex justify-between">
{{ name }}
<div class="flex items-center gap-4 justify-between">
<ClientOnly>
<TooltipApp
:text="likesData?.userHasLiked ? $t('package.likes.unlike') : $t('package.likes.like')"
position="bottom"
>
<button
@click.prevent="likeAction"
type="button"
:title="
likesData?.userHasLiked ? $t('package.likes.unlike') : $t('package.likes.like')
"
class="inline-flex items-center gap-1.5 font-mono text-sm text-fg hover:text-fg-muted transition-colors duration-200"
:aria-label="
likesData?.userHasLiked ? $t('package.likes.unlike') : $t('package.likes.like')
"
>
<span
:class="
likesData?.userHasLiked
? 'i-lucide-heart-minus text-red-500'
: 'i-lucide-heart-plus'
"
class="w-4 h-4"
aria-hidden="true"
/>
<span>{{ compactNumberFormatter.format(likesData?.totalLikes ?? 0) }}</span>
</button>
</TooltipApp>
Comment on lines 73 to 104
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid nesting a <button> inside NuxtLink.

This creates invalid interactive nesting and can break keyboard/screen-reader behaviour. Split the clickable card link and like button into sibling interactive elements.

🧰 Tools
🪛 GitHub Check: 💪 Type check

[failure] 103-103:
Expected 1 arguments, but got 2.

</ClientOnly>
<p class="transition-transform duration-150 group-hover:rotate-45 pb-1">↗</p>
</div>
</BaseCard>
</NuxtLink>
</template>
13 changes: 13 additions & 0 deletions app/composables/atproto/useProfileLikes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export type LikesResult = {
records: {
value: {
subjectRef: string
}
}[]
}

export function useProfileLikes(handle: MaybeRefOrGetter<string>) {
const asyncData = useLazyFetch<LikesResult>(() => `/api/social/profile/${toValue(handle)}/likes`)

return asyncData
}
215 changes: 215 additions & 0 deletions app/pages/profile/[handle]/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
<script setup lang="ts">
import { updateProfile as updateProfileUtil } from '~/utils/atproto/profile'

const route = useRoute('profile-handle')
const handle = computed(() => route.params.handle)

const { data: profile, error: profileError } = await useFetch<NPMXProfile>(
() => `/api/social/profile/${handle.value}`,
{
default: () => ({
displayName: handle.value,
description: '',
website: '',
recordExists: false,
}),
},
)
if (!profile.value || profileError.value?.statusCode === 404) {
throw createError({
statusCode: 404,
statusMessage: $t('profile.not_found'),
message: $t('profile.not_found_message', { handle: handle.value }),
})
}

const { user } = useAtproto()
const isEditing = ref(false)
const displayNameInput = ref()
const descriptionInput = ref()
const websiteInput = ref()
const isUpdateProfileActionPending = ref(false)

watchEffect(() => {
if (isEditing.value) {
if (profile) {
displayNameInput.value = profile.value.displayName
descriptionInput.value = profile.value.description
websiteInput.value = profile.value.website
}
}
})
Comment on lines +33 to +41
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard should check profile.value, not profile.

profile is a Ref object which is always truthy. The condition should check profile.value to properly guard against null/undefined profile data before accessing its properties.

🐛 Proposed fix
 watchEffect(() => {
   if (isEditing.value) {
-    if (profile) {
+    if (profile.value) {
       displayNameInput.value = profile.value.displayName
       descriptionInput.value = profile.value.description
       websiteInput.value = profile.value.website
     }
   }
 })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
watchEffect(() => {
if (isEditing.value) {
if (profile) {
displayNameInput.value = profile.value.displayName
descriptionInput.value = profile.value.description
websiteInput.value = profile.value.website
}
}
})
watchEffect(() => {
if (isEditing.value) {
if (profile.value) {
displayNameInput.value = profile.value.displayName
descriptionInput.value = profile.value.description
websiteInput.value = profile.value.website
}
}
})


async function updateProfile() {
if (!user.value?.handle || !displayNameInput.value) {
return
}

isUpdateProfileActionPending.value = true
const currentProfile = profile.value

// optimistic update
profile.value = {
displayName: displayNameInput.value,
description: descriptionInput.value || undefined,
website: websiteInput.value || undefined,
recordExists: true,
}

try {
const result = await updateProfileUtil(handle.value, {
displayName: displayNameInput.value,
description: descriptionInput.value || undefined,
website: websiteInput.value || undefined,
})

if (result.success) {
isEditing.value = false
} else {
profile.value = currentProfile
}

isUpdateProfileActionPending.value = false
} catch (e) {
profile.value = currentProfile
isUpdateProfileActionPending.value = false
}
}

const { data: likes, status } = useProfileLikes(handle)

const showInviteSection = computed(() => {
return (
profile.value.recordExists === false &&
status.value === 'success' &&
!likes.value?.records?.length &&
user.value?.handle !== handle.value
)
})

const inviteUrl = computed(() => {
const text = $t('profile.invite.compose_text', { handle: handle.value })
return `https://bsky.app/intent/compose?text=${encodeURIComponent(text)}`
})

useSeoMeta({
title: () => $t('profile.seo_title', { handle: handle.value }),
description: () => $t('profile.seo_description', { handle: handle.value }),
})

/**
defineOgImageComponent('Default', {
title: () => `~${username.value}`,
description: () => (results.value ? `${results.value.total} packages` : 'npm user profile'),
primaryColor: '#60a5fa',
})
**/
</script>

<template>
<main class="container flex-1 flex flex-col py-8 sm:py-12 w-full">
<!-- Header -->
<header class="mb-8 pb-8 border-b border-border">
<!-- Editing Profile -->
<div v-if="isEditing" class="flex flex-col flex-wrap gap-4">
<label for="displayName" class="text-sm flex flex-col gap-2">
{{ $t('profile.display_name') }}
<input
required
name="displayName"
type="text"
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)"
v-model="displayNameInput"
/>
</label>
<label for="description" class="text-sm flex flex-col gap-2">
{{ $t('profile.description') }}
<input
name="description"
type="text"
:placeholder="$t('profile.no_description')"
v-model="descriptionInput"
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)"
/>
</label>
<label for="website" class="text-sm flex flex-col gap-2">
{{ $t('profile.website') }}
<input
name="website"
type="url"
:placeholder="$t('profile.website_placeholder')"
v-model="websiteInput"
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)"
/>
</label>
<div class="flex gap-4 items-center font-mono text-sm">
<h2>@{{ handle }}</h2>
<ButtonBase @click="isEditing = false">
{{ $t('common.cancel') }}
</ButtonBase>
<ButtonBase
@click="updateProfile"
variant="primary"
:disabled="isUpdateProfileActionPending"
>
{{ $t('common.save') }}
</ButtonBase>
</div>
</div>

<!-- Display Profile -->
<div v-else class="flex flex-col flex-wrap gap-4">
<h1 v-if="profile.displayName" class="font-mono text-2xl sm:text-3xl font-medium">
{{ profile.displayName }}
</h1>
<p v-if="profile.description">{{ profile.description }}</p>
<div class="flex gap-4 items-center font-mono text-sm">
<h2>@{{ handle }}</h2>
<LinkBase v-if="profile.website" :to="profile.website" classicon="i-lucide:link">
{{ profile.website }}
</LinkBase>
<ButtonBase
@click="isEditing = true"
:class="user?.handle === handle ? '' : 'invisible'"
class="hidden sm:inline-flex"
>
{{ $t('common.edit') }}
</ButtonBase>
</div>
</div>
</header>

<section class="flex flex-col gap-8">
<h2
class="font-mono text-2xl sm:text-3xl font-medium min-w-0 break-words"
:title="$t('profile.likes')"
dir="ltr"
>
{{ $t('profile.likes') }}
<span v-if="likes">({{ likes.records?.length ?? 0 }})</span>
</h2>
<div v-if="status === 'pending'" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<SkeletonBlock v-for="i in 4" :key="i" class="h-16 rounded-lg" />
</div>
<div v-else-if="status === 'error'">
<p>{{ $t('common.error') }}</p>
</div>
<div v-else-if="likes?.records" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<PackageLikeCard v-for="like in likes.records" :packageUrl="like.value.subjectRef" />
</div>

<!-- Invite section: shown when user does not have npmx profile or any like lexicons -->
<div
v-if="showInviteSection"
class="flex flex-col items-start gap-4 p-6 bg-bg-subtle border border-border rounded-lg"
>
<p class="text-fg-muted">
{{ $t('profile.invite.message') }}
</p>
<LinkBase variant="button-secondary" classicon="i-simple-icons:bluesky" :to="inviteUrl">
{{ $t('profile.invite.share_button') }}
</LinkBase>
</div>
</section>
</main>
</template>
Loading
Loading