Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
96 changes: 81 additions & 15 deletions app/components/Header/AccountMenu.client.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,26 @@ const {
} = useConnector()

const { user: atprotoUser } = useAtproto()
const { isConnected: isGitHubConnected, user: githubUser } = useGitHub()

const isOpen = shallowRef(false)

/** Check if connected to at least one service */
const hasAnyConnection = computed(() => isNpmConnected.value || !!atprotoUser.value)
const hasAnyConnection = computed(
() => isNpmConnected.value || !!atprotoUser.value || isGitHubConnected.value,
)

/** Check if connected to both services */
const hasBothConnections = computed(() => isNpmConnected.value && !!atprotoUser.value)
/** Count of connected services for avatar stacking */
const connectedCount = computed(() => {
let count = 0
if (isNpmConnected.value) count++
if (atprotoUser.value) count++
if (isGitHubConnected.value) count++
return count
})

/** Check if connected to more than one service */
const hasMultipleConnections = computed(() => connectedCount.value > 1)

/** Only show count of active (pending/approved/running) operations */
const operationCount = computed(() => activeOperations.value.length)
Expand All @@ -45,12 +57,21 @@ function openConnectorModal() {
}
}

const authModal = useModal('auth-modal')
const atprotoModal = useModal('atproto-modal')

function openAuthModal() {
if (authModal) {
function openAtprotoModal() {
if (atprotoModal) {
isOpen.value = false
authModal.open()
atprotoModal.open()
}
}

const githubModal = useModal('github-modal')

function openGitHubModal() {
if (githubModal) {
isOpen.value = false
githubModal.open()
}
}
</script>
Expand All @@ -68,7 +89,7 @@ function openAuthModal() {
<span
v-if="hasAnyConnection"
class="flex items-center"
:class="hasBothConnections ? '-space-x-2' : ''"
:class="hasMultipleConnections ? '-space-x-2' : ''"
>
<!-- npm avatar (first/back) -->
<img
Expand All @@ -94,15 +115,24 @@ function openAuthModal() {
width="24"
height="24"
class="w-6 h-6 rounded-full ring-2 ring-bg object-cover"
:class="hasBothConnections ? 'relative z-10' : ''"
:class="hasMultipleConnections ? 'relative z-10' : ''"
/>
<span
v-else-if="atprotoUser"
class="w-6 h-6 rounded-full bg-bg-muted ring-2 ring-bg flex items-center justify-center"
:class="hasBothConnections ? 'relative z-10' : ''"
:class="hasMultipleConnections ? 'relative z-10' : ''"
>
<span class="i-lucide:at-sign w-3 h-3 text-fg-muted" aria-hidden="true" />
</span>

<!-- GitHub avatar (overlapping) -->
<span
v-if="isGitHubConnected"
class="w-6 h-6 rounded-full bg-bg-muted ring-2 ring-bg flex items-center justify-center"
:class="hasMultipleConnections ? 'relative z-20' : ''"
>
<span class="i-simple-icons:github w-3 h-3 text-fg-muted" aria-hidden="true" />
</span>
</span>

<!-- "connect" text when not connected -->
Expand Down Expand Up @@ -189,7 +219,7 @@ function openAuthModal() {
v-if="atprotoUser"
role="menuitem"
class="w-full text-start gap-x-3 border-none"
@click="openAuthModal"
@click="openAtprotoModal"
>
<img
v-if="atprotoUser.avatar"
Expand All @@ -212,16 +242,34 @@ function openAuthModal() {
<span class="text-xs text-fg-subtle">{{ $t('account_menu.atmosphere') }}</span>
</span>
</ButtonBase>

<!-- GitHub connection -->
<ButtonBase
v-if="isGitHubConnected && githubUser"
role="menuitem"
class="w-full text-start gap-x-3 border-none"
@click="openGitHubModal"
>
<span class="w-8 h-8 rounded-full bg-bg-muted flex items-center justify-center">
<span class="i-simple-icons:github w-4 h-4 text-fg-muted" aria-hidden="true" />
</span>
<span class="flex-1 min-w-0">
<span class="font-mono text-sm text-fg truncate block">{{
githubUser.username
}}</span>
<span class="text-xs text-fg-subtle">{{ $t('account_menu.github') }}</span>
</span>
</ButtonBase>
</div>

<!-- Divider (only if we have connections AND options to connect) -->
<div
v-if="hasAnyConnection && (!isNpmConnected || !atprotoUser)"
v-if="hasAnyConnection && (!isNpmConnected || !atprotoUser || !isGitHubConnected)"
class="border-t border-border"
/>

<!-- Connect options -->
<div v-if="!isNpmConnected || !atprotoUser" class="py-1">
<div v-if="!isNpmConnected || !atprotoUser || !isGitHubConnected" class="py-1">
<ButtonBase
v-if="!isNpmConnected"
role="menuitem"
Expand Down Expand Up @@ -252,7 +300,7 @@ function openAuthModal() {
v-if="!atprotoUser"
role="menuitem"
class="w-full text-start gap-x-3 border-none"
@click="openAuthModal"
@click="openAtprotoModal"
>
<span class="w-8 h-8 rounded-full bg-bg-muted flex items-center justify-center">
<span class="i-lucide:at-sign w-4 h-4 text-fg-muted" aria-hidden="true" />
Expand All @@ -264,11 +312,29 @@ function openAuthModal() {
<span class="text-xs text-fg-subtle">{{ $t('account_menu.atmosphere_desc') }}</span>
</span>
</ButtonBase>

<ButtonBase
v-if="!isGitHubConnected"
role="menuitem"
class="w-full text-start gap-x-3 border-none"
@click="openGitHubModal"
>
<span class="w-8 h-8 rounded-full bg-bg-muted flex items-center justify-center">
<span class="i-simple-icons:github w-4 h-4 text-fg-muted" aria-hidden="true" />
</span>
<span class="flex-1 min-w-0">
<span class="font-mono text-sm text-fg block">
{{ $t('account_menu.connect_github') }}
</span>
<span class="text-xs text-fg-subtle">{{ $t('account_menu.github_desc') }}</span>
</span>
</ButtonBase>
</div>
</div>
</div>
</Transition>
</div>
<HeaderConnectorModal />
<HeaderAuthModal />
<HeaderAtprotoModal />
<HeaderGitHubModal />
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ watch(handleInput, newHandleInput => {

<template>
<!-- Modal -->
<Modal :modalTitle="$t('auth.modal.title')" class="max-w-lg" id="auth-modal">
<Modal :modalTitle="$t('auth.modal.title')" class="max-w-lg" id="atproto-modal">
<div v-if="user?.handle" class="space-y-4">
<div class="flex items-center gap-3 p-4 bg-bg-subtle border border-border rounded-lg">
<span class="w-3 h-3 rounded-full bg-green-500" aria-hidden="true" />
Expand Down
40 changes: 40 additions & 0 deletions app/components/Header/GitHubModal.client.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<script setup lang="ts">
const route = useRoute()
const { isConnected, user, login, logout } = useGitHub()

function handleConnect() {
login(route.fullPath)
}
</script>

<template>
<Modal :modalTitle="$t('auth.github.title')" class="max-w-lg" id="github-modal">
<!-- Connected state -->
<div v-if="isConnected && user" class="space-y-4">
<div class="flex items-center gap-3 p-4 bg-bg-subtle border border-border rounded-lg">
<span class="w-3 h-3 rounded-full bg-green-500" aria-hidden="true" />
<div>
<p class="font-mono text-xs text-fg-muted">
{{ $t('auth.github.connected_as', { username: user.username }) }}
</p>
</div>
</div>
<ButtonBase class="w-full" @click="logout">
{{ $t('auth.modal.disconnect') }}
</ButtonBase>
</div>

<!-- Disconnected state -->
<div v-else class="space-y-4">
<p class="text-sm text-fg-muted">{{ $t('auth.github.connect_prompt') }}</p>
<ButtonBase
variant="primary"
class="w-full"
classicon="i-simple-icons:github"
@click="handleConnect"
>
{{ $t('auth.modal.connect') }}
</ButtonBase>
</div>
</Modal>
</template>
4 changes: 2 additions & 2 deletions app/composables/atproto/useAtproto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})

Expand Down
37 changes: 37 additions & 0 deletions app/composables/github/useGitHub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
function login(redirectTo?: string) {
const query: Record<string, string> = {}
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 }
})
79 changes: 79 additions & 0 deletions app/composables/github/useGitHubStar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { RepoRef } from '#shared/utils/git-providers'

type StarStatus = {
starred: boolean
connected: boolean
}

export function useGitHubStar(repoRef: Ref<RepoRef | null>) {
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<StarStatus>(
() => `/api/github/starred?owner=${owner.value}&repo=${repo.value}`,
{
server: false,
immediate: false,
default: () => ({ starred: false, connected: false }),
watch: false,
},
)
Comment on lines +19 to +27
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

Query parameters are not URL-encoded.

owner.value and repo.value are interpolated directly into the URL string. If either value contains characters like &, =, or spaces, the URL will be malformed. Use encodeURIComponent or pass a query object.

Suggested fix using query object
- const { data: starStatus, refresh } = useFetch<StarStatus>(
-   () => `/api/github/starred?owner=${owner.value}&repo=${repo.value}`,
-   {
-     server: false,
-     immediate: false,
-     default: () => ({ starred: false, connected: false }),
-     watch: false,
-   },
- )
+ const { data: starStatus, refresh } = useFetch<StarStatus>(
+   '/api/github/starred',
+   {
+     server: false,
+     immediate: false,
+     default: () => ({ starred: false, connected: false }),
+     watch: false,
+     query: computed(() => ({ owner: owner.value, repo: repo.value })),
+   },
+ )


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,
}
}
Loading
Loading