-
-
Notifications
You must be signed in to change notification settings - Fork 449
feat: profile page #1113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: profile page #1113
Changes from 1 commit
3bf1cde
09c99c1
ecdc8cf
9591998
c467c3e
dfc4d7d
58a3b0c
0215014
30741b8
6e5be0e
45d35ef
c012a45
b81eda7
f732b14
73ce1df
78db755
d72a756
2e1bbd7
6e41ecc
7f0c987
6c49480
fe73fea
75db4ae
8dfc2d9
5f3874c
b5bf1f2
a3b6cc0
1bb55b8
e2e027d
7bde6b7
b2ecd2c
0804679
2cbf1d9
c4ab87d
cdb891b
91b24a9
e5d1638
8f19abe
0bf4443
14556af
8bf70eb
981b70b
81402ee
a834d7a
a114bf0
5676cf6
e5b9bbd
c89de62
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| <script setup lang="ts"> | ||
| import { debounce } from 'perfect-debounce' | ||
| import { normalizeSearchParam } from '#shared/utils/url' | ||
|
|
||
| const route = useRoute('/profile/[handle]') | ||
| const router = useRouter() | ||
|
|
||
| const handle = computed(() => route.params.handle) | ||
|
Check failure on line 8 in app/pages/profile/[handle]/index.vue
|
||
|
|
||
| const { data: profile }: { data?: NPMXProfile } = useFetch( | ||
|
Check failure on line 10 in app/pages/profile/[handle]/index.vue
|
||
| () => `/api/social/profile/${handle.value}`, | ||
| { | ||
| default: () => ({ profile: { displayName: handle.value } }), | ||
| server: false, | ||
| }, | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| ) | ||
|
|
||
| useSeoMeta({ | ||
| title: () => `${handle.value} - npmx`, | ||
| description: () => `npmx profile by ${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"> | ||
| <div class="flex flex-wrap items-center gap-4"> | ||
| <div> | ||
| <h1 class="font-mono text-2xl sm:text-3xl font-medium">{{ profile.displayName }}</h1> | ||
| <h2>@{{ handle }}</h2> | ||
| <p v-if="profile.description">{{ profile.description }}</p> | ||
|
Check failure on line 40 in app/pages/profile/[handle]/index.vue
|
||
| </div> | ||
| </div> | ||
| </header> | ||
|
|
||
| <!-- Empty state (no packages found for user) --> | ||
| <div class="flex-1 flex items-center justify-center"> | ||
| <div class="text-center"> | ||
| <p class="text-fg-muted font-mono"> | ||
| {{ $t('user.page.no_packages') }} <span class="text-fg">~{{ handle }}</span> | ||
| </p> | ||
| <p class="text-fg-subtle text-sm mt-2">{{ $t('user.page.no_packages_hint') }}</p> | ||
| </div> | ||
| </div> | ||
| </main> | ||
| </template> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| { | ||
| "lexicon": 1, | ||
| "id": "dev.npmx.actor.profile", | ||
| "defs": { | ||
| "main": { | ||
| "key": "literal:self", | ||
| "type": "record", | ||
| "record": { | ||
| "type": "object", | ||
| "properties": { | ||
| "avatar": { | ||
| "type": "blob", | ||
| "accept": ["image/png", "image/jpeg"], | ||
| "maxSize": 1000000, | ||
| "description": "AKA, 'profile picture'" | ||
| }, | ||
| "website": { | ||
| "type": "string", | ||
| "format": "uri" | ||
| }, | ||
| "description": { | ||
| "type": "string", | ||
| "maxLength": 2560, | ||
| "description": "Free-form profile description text.", | ||
| "maxGraphemes": 256 | ||
| }, | ||
| "displayName": { | ||
| "type": "string", | ||
| "maxLength": 640, | ||
| "maxGraphemes": 64 | ||
| } | ||
| } | ||
| }, | ||
| "description": "A declaration of an npmx account profile." | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| export default defineEventHandler(async event => { | ||
| const handle = getRouterParam(event, 'handle') | ||
| if (!handle) { | ||
| throw createError({ | ||
| status: 400, | ||
| message: 'handle not provided', | ||
| }) | ||
| } | ||
|
|
||
| const profileUtil = new ProfileUtils() | ||
| const profile = await profileUtil.getProfile(handle) | ||
| console.log('ENDPOINT', { handle, profile }) | ||
| return profile | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| import type { MiniDoc, NPMXProfile } from '~~/shared/types/social' | ||
|
|
||
| //Cache keys and helpers | ||
| const CACHE_PREFIX = 'atproto-profile:' | ||
| const CACHE_PROFILE_MINI_DOC = (handle: string) => `${CACHE_PREFIX}${handle}:minidoc` | ||
| const CACHE_PROFILE_KEY = (did: string) => `${CACHE_PREFIX}${did}:profile` | ||
|
|
||
| const CACHE_MAX_AGE = CACHE_MAX_AGE_ONE_MINUTE * 5 | ||
|
|
||
| /** | ||
| * Logic to handle and update profile queries | ||
| */ | ||
| export class ProfileUtils { | ||
| private readonly constellation: Constellation | ||
| private readonly cache: CacheAdapter | ||
|
|
||
| constructor() { | ||
| this.constellation = new Constellation( | ||
| // Passes in a fetch wrapped as cachedfetch since are already doing some heavy caching here | ||
| async <T = unknown>( | ||
| url: string, | ||
| options: Parameters<typeof $fetch>[1] = {}, | ||
| _ttl?: number, | ||
| ): Promise<CachedFetchResult<T>> => { | ||
| const data = (await $fetch<T>(url, options)) as T | ||
| return { data, isStale: false, cachedAt: null } | ||
| }, | ||
| ) | ||
| this.cache = getCacheAdapter('generic') | ||
| } | ||
|
|
||
| private async slingshotMiniDoc(handle: string) { | ||
| const miniDocKey = CACHE_PROFILE_MINI_DOC(handle) | ||
| const cachedMiniDoc = await this.cache.get<MiniDoc>(miniDocKey) | ||
|
|
||
| let miniDoc | ||
| if (cachedMiniDoc) { | ||
| miniDoc = cachedMiniDoc | ||
| } else { | ||
| const resolveUrl = `https://${SLINGSHOT_HOST}/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(handle)}` | ||
|
zeucapua marked this conversation as resolved.
Outdated
|
||
| console.log({ resolveUrl }) | ||
| const response = await fetch(resolveUrl, { | ||
| headers: { 'User-Agent': 'npmx' }, | ||
| }) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's read the file to examine lines 30-32 and 63-74
cat -n server/utils/atproto/utils/profile.ts | sed -n '20,85p'Repository: npmx-dev/npmx.dev Length of output: 2621 🏁 Script executed: # Check for existing timeout patterns in the codebase
rg -n 'AbortSignal\.timeout|signal:.*timeout' --type=ts -C 2 | head -50Repository: npmx-dev/npmx.dev Length of output: 820 🏁 Script executed: # Verify the encoding pattern used elsewhere in the codebase
rg -n 'encodeURIComponent' --type=ts | head -20Repository: npmx-dev/npmx.dev Length of output: 2663 Harden Slingshot fetches: add timeouts and distinguish missing profile from upstream failure. Lines 30–32 and 63–68 lack timeout signals, and lines 69–74 silently swallow non-OK responses without distinguishing a missing profile (404) from upstream failures. The first fetch (line 33) correctly throws on error, but the second does not—violating consistent error handling. Timeouts and encoding patterns are already established elsewhere in the codebase (e.g., 🛠️ Proposed fix const response = await fetch(resolveUrl, {
headers: { 'User-Agent': 'npmx' },
+ signal: AbortSignal.timeout(5_000),
})
if (!response.ok) {
throw createError({
status: response.status,
message: `Failed to resolve MiniDoc for ${handle}`,
})
}
@@
const profileUri = `at://${miniDoc.did}/dev.npmx.actor.profile/self`
const response = await fetch(
- `https://${SLINGSHOT_HOST}/xrpc/blue.microcosm.repo.getRecordByUri?at_uri=${profileUri}`,
+ `https://${SLINGSHOT_HOST}/xrpc/blue.microcosm.repo.getRecordByUri?at_uri=${encodeURIComponent(profileUri)}`,
{
headers: { 'User-Agent': 'npmx' },
+ signal: AbortSignal.timeout(5_000),
},
)
if (response.ok) {
const { value } = (await response.json()) as { value: NPMXProfile }
profile = value
await this.cache.set(profileKey, profile, CACHE_MAX_AGE)
+ } else if (response.status !== 404) {
+ throw createError({
+ status: response.status,
+ message: `Failed to fetch profile for DID ${miniDoc.did}`,
+ })
} |
||
| const value = (await response.json()) as MiniDoc | ||
|
|
||
| miniDoc = value | ||
| await this.cache.set(miniDocKey, value, CACHE_MAX_AGE) | ||
| } | ||
| console.log({ miniDoc }) | ||
|
|
||
| return miniDoc | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * Gets an npmx profile based on a handle | ||
| * @param handle | ||
| * @returns | ||
| */ | ||
| async getProfile(handle: string) { | ||
| const profileKey = CACHE_PROFILE_KEY(handle) | ||
| const cachedProfile = await this.cache.get<NPMXProfile>(profileKey) | ||
|
|
||
| let profile: NPMXProfile | undefined | ||
| if (cachedProfile) { | ||
| profile = cachedProfile | ||
| } else { | ||
| const miniDoc = await this.slingshotMiniDoc(handle) | ||
| const profileUri = `at://${miniDoc.did}/dev.npmx.actor.profile/self` | ||
| const response = await fetch( | ||
| `https://${SLINGSHOT_HOST}/xrpc/blue.microcosm.repo.getRecordByUri?at_uri=${profileUri}`, | ||
| { | ||
| headers: { 'User-Agent': 'npmx' }, | ||
| }, | ||
| ) | ||
| if (response.ok) { | ||
| const { value } = (await response.json()) as { value: NPMXProfile } | ||
| profile = value | ||
| await this.cache.set(profileKey, profile, CACHE_MAX_AGE) | ||
| } | ||
| } | ||
|
|
||
| return profile | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, let's read the entire file to understand the component structure wc -l app/components/Header/AuthModal.client.vueRepository: npmx-dev/npmx.dev
Length of output: 107
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 2297
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 128
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 1126
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 2672
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 128
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 88
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 4126
Remove undefined variable and fix invalid interactive element nesting.
The
prefetchvariable is not defined in the component (lines 1–63 of the script section), causing a type-check failure. Additionally, nesting a<button>inside<NuxtLink>violates HTML semantics for interactive elements. Apply styles and event handlers directly toNuxtLinkinstead:Proposed fix
📝 Committable suggestion
🧰 Tools
🪛 GitHub Check: 💪 Type check
[failure] 81-81:
Property 'prefetch' does not exist on type '{ user: Simplify<SerializeObject<{ did: string; handle: string; pds: string; avatar?: string | undefined; relogin?: boolean | undefined; }> | null> | undefined; ... 721 more ...; $npmApi: (url: string, options?: NitroFetchOptions<...> | undefined, ttl?: number | undefined) => Promise<...>; }'.