diff --git a/app/components/Header/GitHubModal.client.vue b/app/components/Header/GitHubModal.client.vue
new file mode 100644
index 000000000..49fd1584a
--- /dev/null
+++ b/app/components/Header/GitHubModal.client.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+ {{ $t('auth.github.connected_as', { username: user.username }) }}
+
+
+
+
+ {{ $t('auth.modal.disconnect') }}
+
+
+
+
+
+
{{ $t('auth.github.connect_prompt') }}
+
+ {{ $t('auth.modal.connect') }}
+
+
+
+
diff --git a/app/composables/atproto/useAtproto.ts b/app/composables/atproto/useAtproto.ts
index 6282b27bc..122830513 100644
--- a/app/composables/atproto/useAtproto.ts
+++ b/app/composables/atproto/useAtproto.ts
@@ -3,13 +3,13 @@ export const useAtproto = createSharedComposable(function useAtproto() {
data: user,
pending,
clear,
- } = useFetch('/api/auth/session', {
+ } = useFetch('/api/auth/atproto/session', {
server: false,
immediate: !import.meta.test,
})
async function logout() {
- await $fetch('/api/auth/session', {
+ await $fetch('/api/auth/atproto/session', {
method: 'delete',
})
diff --git a/app/composables/github/useGitHub.ts b/app/composables/github/useGitHub.ts
new file mode 100644
index 000000000..dbc19a99f
--- /dev/null
+++ b/app/composables/github/useGitHub.ts
@@ -0,0 +1,37 @@
+function login(redirectTo?: string) {
+ const query: Record
= {}
+ if (redirectTo) {
+ query.returnTo = redirectTo
+ }
+ navigateTo(
+ {
+ path: '/api/auth/github',
+ query,
+ },
+ { external: true },
+ )
+}
+
+export const useGitHub = createSharedComposable(function useGitHub() {
+ const {
+ data: user,
+ pending,
+ clear,
+ refresh,
+ } = useFetch<{ username: string } | null>('/api/auth/github/session', {
+ server: false,
+ immediate: !import.meta.test,
+ })
+
+ const isConnected = computed(() => !!user.value?.username)
+
+ async function logout() {
+ await $fetch('/api/auth/github/session', {
+ method: 'delete',
+ })
+
+ clear()
+ }
+
+ return { user, isConnected, pending, logout, login, refresh }
+})
diff --git a/app/composables/github/useGitHubStar.ts b/app/composables/github/useGitHubStar.ts
new file mode 100644
index 000000000..8edb78ae0
--- /dev/null
+++ b/app/composables/github/useGitHubStar.ts
@@ -0,0 +1,79 @@
+import type { RepoRef } from '#shared/utils/git-providers'
+
+type StarStatus = {
+ starred: boolean
+ connected: boolean
+}
+
+export function useGitHubStar(repoRef: Ref) {
+ const { isConnected } = useGitHub()
+
+ const isGitHubRepo = computed(() => repoRef.value?.provider === 'github')
+ const owner = computed(() => repoRef.value?.owner ?? '')
+ const repo = computed(() => repoRef.value?.repo ?? '')
+
+ const shouldFetch = computed(
+ () => isConnected.value && isGitHubRepo.value && !!owner.value && !!repo.value,
+ )
+
+ const { data: starStatus, refresh } = useFetch(
+ () => `/api/github/starred?owner=${owner.value}&repo=${repo.value}`,
+ {
+ server: false,
+ immediate: false,
+ default: () => ({ starred: false, connected: false }),
+ watch: false,
+ },
+ )
+
+ watch(
+ shouldFetch,
+ async value => {
+ if (value) {
+ await refresh()
+ }
+ },
+ { immediate: true },
+ )
+
+ const isStarred = computed(() => starStatus.value?.starred ?? false)
+ const isStarActionPending = shallowRef(false)
+
+ async function toggleStar() {
+ if (!shouldFetch.value || isStarActionPending.value) return
+
+ const currentlyStarred = isStarred.value
+
+ // Optimistic update
+ starStatus.value = {
+ starred: !currentlyStarred,
+ connected: true,
+ }
+
+ isStarActionPending.value = true
+
+ try {
+ const result = await $fetch<{ starred: boolean }>('/api/github/star', {
+ method: currentlyStarred ? 'DELETE' : 'PUT',
+ body: { owner: owner.value, repo: repo.value },
+ })
+
+ starStatus.value = { starred: result.starred, connected: true }
+ } catch {
+ // Revert on error
+ starStatus.value = {
+ starred: currentlyStarred,
+ connected: true,
+ }
+ } finally {
+ isStarActionPending.value = false
+ }
+ }
+
+ return {
+ isStarred,
+ isStarActionPending,
+ isGitHubRepo,
+ toggleStar,
+ }
+}
diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue
index de6c114d9..14d3b1fac 100644
--- a/app/pages/package/[[org]]/[name].vue
+++ b/app/pages/package/[[org]]/[name].vue
@@ -399,6 +399,9 @@ const repositoryUrl = computed(() => {
const { meta: repoMeta, repoRef, stars, starsLink, forks, forksLink } = useRepoMeta(repositoryUrl)
+const { isConnected: isGitHubConnected } = useGitHub()
+const { isStarred, isStarActionPending, isGitHubRepo, toggleStar } = useGitHubStar(repoRef)
+
const PROVIDER_ICONS: Record = {
github: 'i-simple-icons:github',
gitlab: 'i-simple-icons:gitlab',
@@ -518,7 +521,7 @@ const canonicalUrl = computed(() => {
// TODO: Maybe set this where it's not loaded here every load?
const { user } = useAtproto()
-const authModal = useModal('auth-modal')
+const atprotoModal = useModal('atproto-modal')
const { data: likesData, status: likeStatus } = useFetch(
() => `/api/social/likes/${packageName.value}`,
@@ -535,7 +538,7 @@ const isLikeActionPending = shallowRef(false)
const likeAction = async () => {
if (user.value?.handle == null) {
- authModal.open()
+ atprotoModal.open()
return
}
@@ -818,45 +821,65 @@ const showSkeleton = shallowRef(false)
class="self-baseline"
/>
-
-
-
+
+
-
-
- {{ compactNumberFormatter.format(likesData?.totalLikes ?? 0) }}
-
-
-
+
+
+
+ {{ compactNumberFormatter.format(likesData?.totalLikes ?? 0) }}
+
+
+
+
+
+
+
+
+