Skip to content

Commit bdaaa2c

Browse files
feat: package likes (#712)
Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent d8a30b4 commit bdaaa2c

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
@@ -12,6 +12,9 @@ import { areUrlsEquivalent } from '#shared/utils/url'
1212
import { isEditableElement } from '~/utils/input'
1313
import { formatBytes } from '~/utils/formatters'
1414
import { NuxtLink } from '#components'
15+
import { useModal } from '~/composables/useModal'
16+
import { useAtproto } from '~/composables/atproto/useAtproto'
17+
import { togglePackageLike } from '~/utils/atproto/likes'
1518
1619
definePageMeta({
1720
name: 'package',
@@ -356,6 +359,54 @@ const canonicalUrl = computed(() => {
356359
return requestedVersion.value ? `${base}/v/${requestedVersion.value}` : base
357360
})
358361
362+
//atproto
363+
// TODO: Maybe set this where it's not loaded here every load?
364+
const { user } = useAtproto()
365+
366+
const authModal = useModal('auth-modal')
367+
368+
const { data: likesData } = useFetch(() => `/api/social/likes/${packageName.value}`, {
369+
default: () => ({ totalLikes: 0, userHasLiked: false }),
370+
server: false,
371+
})
372+
373+
const isLikeActionPending = ref(false)
374+
375+
const likeAction = async () => {
376+
if (user.value?.handle == null) {
377+
authModal.open()
378+
return
379+
}
380+
381+
if (isLikeActionPending.value) return
382+
383+
const currentlyLiked = likesData.value?.userHasLiked ?? false
384+
const currentLikes = likesData.value?.totalLikes ?? 0
385+
386+
// Optimistic update
387+
likesData.value = {
388+
totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1,
389+
userHasLiked: !currentlyLiked,
390+
}
391+
392+
isLikeActionPending.value = true
393+
394+
const result = await togglePackageLike(packageName.value, currentlyLiked, user.value?.handle)
395+
396+
isLikeActionPending.value = false
397+
398+
if (result.success) {
399+
// Update with server response
400+
likesData.value = result.data
401+
} else {
402+
// Revert on error
403+
likesData.value = {
404+
totalLikes: currentLikes,
405+
userHasLiked: currentlyLiked,
406+
}
407+
}
408+
}
409+
359410
useHead({
360411
link: [{ rel: 'canonical', href: canonicalUrl }],
361412
})
@@ -497,10 +548,31 @@ defineOgImageComponent('Package', {
497548
:is-binary="isBinaryOnly"
498549
class="self-baseline ms-1 sm:ms-2"
499550
/>
551+
552+
<!-- Package likes -->
553+
<button
554+
@click="likeAction"
555+
type="button"
556+
class="inline-flex items-center gap-1.5 font-mono text-sm text-fg hover:text-fg-muted transition-colors duration-200"
557+
:title="$t('package.links.like')"
558+
>
559+
<span
560+
:class="
561+
likesData?.userHasLiked
562+
? 'i-lucide-heart-minus text-red-500'
563+
: 'i-lucide-heart-plus'
564+
"
565+
class="w-4 h-4"
566+
aria-hidden="true"
567+
/>
568+
<span>{{ formatCompactNumber(likesData?.totalLikes ?? 0, { decimals: 1 }) }}</span>
569+
</button>
570+
500571
<template #fallback>
501572
<div class="flex items-center gap-1.5 self-baseline ms-1 sm:ms-2">
502573
<SkeletonBlock class="w-8 h-5 rounded" />
503574
<SkeletonBlock class="w-12 h-5 rounded" />
575+
<SkeletonBlock class="w-5 h-5 rounded" />
504576
</div>
505577
</template>
506578
</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)