Skip to content

Commit e8f0a33

Browse files
authored
Merge branch 'main' into fix-compare-deps
2 parents c330cd0 + bdaaa2c commit e8f0a33

File tree

22 files changed

+687
-27
lines changed

22 files changed

+687
-27
lines changed

app/components/Header/AccountMenu.client.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script setup lang="ts">
2+
import { useAtproto } from '~/composables/atproto/useAtproto'
23
import { useModal } from '~/composables/useModal'
34
45
const {

app/components/Header/AuthModal.client.vue

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,22 @@
11
<script setup lang="ts">
2+
import { useAtproto } from '~/composables/atproto/useAtproto'
3+
import { authRedirect } from '~/utils/atproto/helpers'
4+
25
const handleInput = shallowRef('')
36
47
const { user, logout } = useAtproto()
58
69
async function handleBlueskySignIn() {
7-
await navigateTo(
8-
{
9-
path: '/api/auth/atproto',
10-
query: { handle: 'https://bsky.social' },
11-
},
12-
{ external: true },
13-
)
10+
await authRedirect('https://bsky.social')
1411
}
1512
1613
async function handleCreateAccount() {
17-
await navigateTo(
18-
{
19-
path: '/api/auth/atproto',
20-
query: { handle: 'https://npmx.social', create: 'true' },
21-
},
22-
{ external: true },
23-
)
14+
await authRedirect('https://npmx.social', true)
2415
}
2516
2617
async function handleLogin() {
2718
if (handleInput.value) {
28-
await navigateTo(
29-
{
30-
path: '/api/auth/atproto',
31-
query: { handle: handleInput.value },
32-
},
33-
{ external: true },
34-
)
19+
await authRedirect(handleInput.value)
3520
}
3621
}
3722
</script>

app/components/Header/MobileMenu.client.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script setup lang="ts">
22
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
3+
import { useAtproto } from '~/composables/atproto/useAtproto'
34
45
const isOpen = defineModel<boolean>('open', { default: false })
56
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export function useAtproto() {
1+
export const useAtproto = createSharedComposable(function useAtproto() {
22
const {
33
data: user,
44
pending,
@@ -17,4 +17,4 @@ export function useAtproto() {
1717
}
1818

1919
return { user, pending, logout }
20-
}
20+
})

app/pages/package/[...package].vue

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import { isEditableElement } from '~/utils/input'
1313
import { formatBytes } from '~/utils/formatters'
1414
import { getDependencyCount } from '~/utils/npm/dependency-count'
1515
import { NuxtLink } from '#components'
16+
import { useModal } from '~/composables/useModal'
17+
import { useAtproto } from '~/composables/atproto/useAtproto'
18+
import { togglePackageLike } from '~/utils/atproto/likes'
1619
1720
definePageMeta({
1821
name: 'package',
@@ -352,6 +355,54 @@ const canonicalUrl = computed(() => {
352355
return requestedVersion.value ? `${base}/v/${requestedVersion.value}` : base
353356
})
354357
358+
//atproto
359+
// TODO: Maybe set this where it's not loaded here every load?
360+
const { user } = useAtproto()
361+
362+
const authModal = useModal('auth-modal')
363+
364+
const { data: likesData } = useFetch(() => `/api/social/likes/${packageName.value}`, {
365+
default: () => ({ totalLikes: 0, userHasLiked: false }),
366+
server: false,
367+
})
368+
369+
const isLikeActionPending = ref(false)
370+
371+
const likeAction = async () => {
372+
if (user.value?.handle == null) {
373+
authModal.open()
374+
return
375+
}
376+
377+
if (isLikeActionPending.value) return
378+
379+
const currentlyLiked = likesData.value?.userHasLiked ?? false
380+
const currentLikes = likesData.value?.totalLikes ?? 0
381+
382+
// Optimistic update
383+
likesData.value = {
384+
totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1,
385+
userHasLiked: !currentlyLiked,
386+
}
387+
388+
isLikeActionPending.value = true
389+
390+
const result = await togglePackageLike(packageName.value, currentlyLiked, user.value?.handle)
391+
392+
isLikeActionPending.value = false
393+
394+
if (result.success) {
395+
// Update with server response
396+
likesData.value = result.data
397+
} else {
398+
// Revert on error
399+
likesData.value = {
400+
totalLikes: currentLikes,
401+
userHasLiked: currentlyLiked,
402+
}
403+
}
404+
}
405+
355406
useHead({
356407
link: [{ rel: 'canonical', href: canonicalUrl }],
357408
})
@@ -493,10 +544,31 @@ defineOgImageComponent('Package', {
493544
:is-binary="isBinaryOnly"
494545
class="self-baseline ms-1 sm:ms-2"
495546
/>
547+
548+
<!-- Package likes -->
549+
<button
550+
@click="likeAction"
551+
type="button"
552+
class="inline-flex items-center gap-1.5 font-mono text-sm text-fg hover:text-fg-muted transition-colors duration-200"
553+
:title="$t('package.links.like')"
554+
>
555+
<span
556+
:class="
557+
likesData?.userHasLiked
558+
? 'i-lucide-heart-minus text-red-500'
559+
: 'i-lucide-heart-plus'
560+
"
561+
class="w-4 h-4"
562+
aria-hidden="true"
563+
/>
564+
<span>{{ formatCompactNumber(likesData?.totalLikes ?? 0, { decimals: 1 }) }}</span>
565+
</button>
566+
496567
<template #fallback>
497568
<div class="flex items-center gap-1.5 self-baseline ms-1 sm:ms-2">
498569
<SkeletonBlock class="w-8 h-5 rounded" />
499570
<SkeletonBlock class="w-12 h-5 rounded" />
571+
<SkeletonBlock class="w-5 h-5 rounded" />
500572
</div>
501573
</template>
502574
</ClientOnly>

app/utils/atproto/helpers.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { FetchError } from 'ofetch'
2+
import type { LocationQueryRaw } from 'vue-router'
3+
4+
/**
5+
* Redirect user to ATProto authentication
6+
*/
7+
export async function authRedirect(identifier: string, create: boolean = false) {
8+
let query: LocationQueryRaw = { handle: identifier }
9+
if (create) {
10+
query = { ...query, create: 'true' }
11+
}
12+
await navigateTo(
13+
{
14+
path: '/api/auth/atproto',
15+
query,
16+
},
17+
{ external: true },
18+
)
19+
}
20+
21+
export async function handleAuthError(
22+
fetchError: FetchError,
23+
userHandle?: string | null,
24+
): Promise<never> {
25+
const errorMessage = fetchError?.data?.message
26+
if (errorMessage === ERROR_NEED_REAUTH && userHandle) {
27+
await authRedirect(userHandle)
28+
}
29+
throw fetchError
30+
}

app/utils/atproto/likes.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { FetchError } from 'ofetch'
2+
import { handleAuthError } from '~/utils/atproto/helpers'
3+
import type { PackageLikes } from '#shared/types/social'
4+
5+
export type LikeResult = { success: true; data: PackageLikes } | { success: false; error: Error }
6+
7+
/**
8+
* Like a package via the API
9+
*/
10+
export async function likePackage(
11+
packageName: string,
12+
userHandle?: string | null,
13+
): Promise<LikeResult> {
14+
try {
15+
const result = await $fetch<PackageLikes>('/api/social/like', {
16+
method: 'POST',
17+
body: { packageName },
18+
})
19+
return { success: true, data: result }
20+
} catch (e) {
21+
if (e instanceof FetchError) {
22+
await handleAuthError(e, userHandle)
23+
}
24+
return { success: false, error: e as Error }
25+
}
26+
}
27+
28+
/**
29+
* Unlike a package via the API
30+
*/
31+
export async function unlikePackage(
32+
packageName: string,
33+
userHandle?: string | null,
34+
): Promise<LikeResult> {
35+
try {
36+
const result = await $fetch<PackageLikes>('/api/social/like', {
37+
method: 'DELETE',
38+
body: { packageName },
39+
})
40+
return { success: true, data: result }
41+
} catch (e) {
42+
if (e instanceof FetchError) {
43+
await handleAuthError(e, userHandle)
44+
}
45+
return { success: false, error: e as Error }
46+
}
47+
}
48+
49+
/**
50+
* Toggle like status for a package
51+
*/
52+
export async function togglePackageLike(
53+
packageName: string,
54+
currentlyLiked: boolean,
55+
userHandle?: string | null,
56+
): Promise<LikeResult> {
57+
return currentlyLiked
58+
? unlikePackage(packageName, userHandle)
59+
: likePackage(packageName, userHandle)
60+
}

modules/cache.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import process from 'node:process'
12
import { defineNuxtModule } from 'nuxt/kit'
23
import { provider } from 'std-env'
34

@@ -27,6 +28,11 @@ export default defineNuxtModule({
2728
...nitroConfig.storage[FETCH_CACHE_STORAGE_BASE],
2829
driver: 'vercel-runtime-cache',
2930
}
31+
32+
const env = process.env.VERCEL_ENV
33+
nitroConfig.storage.atproto = {
34+
driver: env === 'production' ? 'vercel-kv' : 'vercel-runtime-cache',
35+
}
3036
})
3137
},
3238
})

nuxt.config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export default defineNuxtConfig({
9292
// never cache
9393
'/search': { isr: false, cache: false },
9494
'/api/auth/**': { isr: false, cache: false },
95+
'/api/social/**': { isr: false, cache: false },
9596
// infinite cache (versioned - doesn't change)
9697
'/package-code/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
9798
'/package-docs/:pkg/v/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
@@ -151,6 +152,10 @@ export default defineNuxtConfig({
151152
driver: 'fsLite',
152153
base: './.cache/fetch',
153154
},
155+
'atproto': {
156+
driver: 'fsLite',
157+
base: './.cache/atproto',
158+
},
154159
},
155160
typescript: {
156161
tsConfig: {
@@ -253,6 +258,11 @@ export default defineNuxtConfig({
253258
'virtua/vue',
254259
'semver',
255260
'validate-npm-package-name',
261+
'@atproto/lex',
262+
'@atproto/lex-data',
263+
'@atproto/lex-json',
264+
'@atproto/lex-schema',
265+
'@atproto/lex-client',
256266
],
257267
},
258268
},

server/api/auth/atproto.get.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Agent } from '@atproto/api'
22
import { NodeOAuthClient } from '@atproto/oauth-client-node'
33
import { createError, getQuery, sendRedirect } from 'h3'
4+
import { getOAuthLock } from '#server/utils/atproto/lock'
45
import { useOAuthStorage } from '#server/utils/atproto/storage'
56
import { SLINGSHOT_HOST } from '#shared/utils/constants'
67
import { useServerSession } from '#server/utils/server-session'
@@ -11,7 +12,7 @@ export default defineEventHandler(async event => {
1112
if (!config.sessionPassword) {
1213
throw createError({
1314
status: 500,
14-
message: 'NUXT_SESSION_PASSWORD not set',
15+
message: UNSET_NUXT_SESSION_PASSWORD,
1516
})
1617
}
1718

@@ -24,6 +25,7 @@ export default defineEventHandler(async event => {
2425
stateStore,
2526
sessionStore,
2627
clientMetadata,
28+
requestLock: getOAuthLock(),
2729
})
2830

2931
if (!query.code) {

0 commit comments

Comments
 (0)