Skip to content
Merged
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
24 changes: 24 additions & 0 deletions app/pages/@[login]/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ const { data: profile } = await useFetch<OrgProfile>(
{ key: () => `community-profile-${login.value}` },
)

// 404 when the list is private and the viewer is not its owner, so the link
// stays hidden. That 404 is the expected answer; the page needs no error UI.
const { data: likedList } = await useFetch<{ access: 'public' | 'owner' }>(
() => `/api/likes/by-user/${login.value}/visibility`,
{ key: () => `liked-visibility-${login.value}` },
)

const displayName = computed(() => profile.value?.displayName || `@${login.value}`)
const profileDescription = computed(() => profile.value?.description ?? '')
const avatar = computed(() => profile.value?.avatar || `https://github.com/${login.value}.png?size=192`)
Expand Down Expand Up @@ -253,6 +260,16 @@ defineOgImage('Curator.takumi', {
:loading="scanning"
@click="scanRepos"
/>
<UButton
v-if="likedList?.access"
:to="`/@${login}/liked`"
label="Liked"
icon="i-lucide-heart"
size="sm"
color="neutral"
variant="outline"
class="min-h-11"
/>
<UButton
:to="githubUrl"
target="_blank"
Expand Down Expand Up @@ -281,6 +298,13 @@ defineOgImage('Curator.takumi', {
</div>
</div>

<p
v-if="likedList?.access === 'owner'"
class="mt-4 flex items-center gap-2 text-xs text-muted md:justify-end"
>
<UIcon name="i-lucide-lock" class="size-3.5 shrink-0" aria-hidden="true" />
Only you can see your liked Skills.
</p>
<p
v-if="scanResult"
class="mt-4 text-sm text-muted"
Expand Down
13 changes: 13 additions & 0 deletions app/pages/@[login]/liked.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ interface LikedSkill {
}

interface LikedResponse {
/** `owner` means the list is private and the viewer owns it. */
access: 'public' | 'owner'
author: { login: string, name: string | null, avatar: string | null }
items: LikedSkill[]
}
Expand Down Expand Up @@ -83,6 +85,17 @@ useSeoMeta({
skills whose changes reach their digest.
</p>

<p
v-if="data?.access === 'owner'"
class="mt-4 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted"
>
<UIcon name="i-lucide-lock" class="size-4 shrink-0" aria-hidden="true" />
Only you can see this.
<NuxtLink to="/me" class="underline hover:text-default">
Change in your settings
</NuxtLink>
</p>

<dl class="mt-6 flex flex-wrap gap-x-6 gap-y-3 border-y border-default py-3">
<div class="flex items-baseline gap-2">
<dt class="data-label">
Expand Down
134 changes: 134 additions & 0 deletions layers/identity/app/components/OwnedSkillsPrompt.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<script setup lang="ts">
import type { OwnedRepoScanNotice } from '../utils/owned-repo-scan'
import { identityAccountQueries } from '../queries/account'
import { ownedRepoScanFailureNotice, ownedRepoScanNotice } from '../utils/owned-repo-scan'

/**
* Tells the signed-in account that its public repositories are indexed, and
* offers the switch that stops it.
*
* Sign-in already ran the scan, because SKILL.md files in a public repository
* are already public and indexing them is how the registry grows. So this is a
* notice with a way out, not a question.
*/
const { login } = defineProps<{ login: string }>()

type PromptState
= | { _tag: 'indexing' }
| { _tag: 'scanning' }
| { _tag: 'finished', notice: OwnedRepoScanNotice }
| { _tag: 'turned_off' }

const state = ref<PromptState>({ _tag: 'indexing' })
const rpc = useNuxtRpc()

const scanMutation = useNuxtMutation({
mutation: () => rpc.execute(identityAccountQueries.scanOwnedRepos()),
})

const privacyMutation = useNuxtMutation({
mutation: () => rpc.execute(identityAccountQueries.savePrivacy(), { repo_indexing: false }),
})

async function checkNow() {
state.value = { _tag: 'scanning' }
const outcome = await scanMutation.mutateSafe()
state.value = {
_tag: 'finished',
notice: outcome._tag === 'ok' ? ownedRepoScanNotice(outcome.data) : ownedRepoScanFailureNotice(outcome.error),
}
}

async function turnOff() {
const outcome = await privacyMutation.mutateSafe()
if (outcome._tag === 'ok')
state.value = { _tag: 'turned_off' }
}

const route = useRoute()
const { loginUrl } = useAuth()
const signInPath = computed(() => loginUrl({ returnTo: route.fullPath }))
</script>

<template>
<section
class="rounded-lg border border-default p-4"
aria-labelledby="owned-skills-prompt-heading"
>
<h2 id="owned-skills-prompt-heading" class="text-base font-semibold text-pretty">
Your public repositories are in skilld.dev
</h2>
<p class="mt-2 text-sm leading-relaxed text-muted text-pretty">
skilld checks your public GitHub repositories for SKILL.md files. Each Skill it finds gets a public page. If you would rather it did not, turn it off here.
</p>

<p v-if="state._tag === 'turned_off'" class="mt-4 flex items-start gap-2 text-sm text-muted" role="status" aria-live="polite">
<UIcon name="i-lucide-circle-check" class="mt-0.5 size-4 shrink-0" aria-hidden="true" />
Indexing is off. Skills already added stay until you remove the SKILL.md file, or ask us to.
</p>

<template v-else>
<div v-if="state._tag === 'finished'" class="mt-4" role="status" aria-live="polite">
<p
class="flex items-start gap-2 text-sm"
:class="state.notice._tag === 'added' ? 'text-default' : 'text-muted'"
>
<UIcon
:name="state.notice._tag === 'added' ? 'i-lucide-check' : state.notice._tag === 'none_found' ? 'i-lucide-info' : 'i-lucide-circle-alert'"
class="mt-0.5 size-4 shrink-0"
:class="state.notice._tag === 'added' ? 'text-primary' : ''"
aria-hidden="true"
/>
{{ state.notice.message }}
</p>
</div>

<div class="mt-4 flex flex-wrap items-center gap-2">
<UButton
v-if="state._tag === 'finished' && state.notice._tag === 'added'"
:to="`/@${login}`"
label="View your Skills"
icon="i-lucide-user-round"
size="sm"
color="neutral"
variant="outline"
class="min-h-11"
/>
<UButton
v-else-if="state._tag === 'finished' && state.notice._tag === 'sign_in'"
:to="signInPath"
external
label="Sign in with GitHub"
icon="i-lucide-github"
size="sm"
color="neutral"
variant="outline"
class="min-h-11"
/>
<UButton
v-else
:label="state._tag === 'finished' ? 'Try again' : 'Check now'"
:icon="state._tag === 'finished' ? 'i-lucide-refresh-cw' : 'i-lucide-scan-search'"
size="sm"
color="neutral"
variant="outline"
class="min-h-11"
:loading="state._tag === 'scanning'"
@click="checkNow"
/>
<UButton
label="Turn this off"
size="sm"
color="neutral"
variant="ghost"
class="min-h-11"
:loading="privacyMutation.pending.value"
@click="turnOff"
/>
<span v-if="state._tag === 'scanning'" class="text-xs text-muted" role="status" aria-live="polite">
Checking your public repositories. This can take a minute.
</span>
</div>
</template>
</section>
</template>
54 changes: 54 additions & 0 deletions layers/identity/app/pages/me/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type {
IdentityEmailPatchBody,
IdentityMutationResponse,
IdentityPrivacyPatchBody,
IdentitySubscriptionRef,
} from '../../../shared/contracts/account'
import type { StarsSyncResponse } from '../../utils/sync-starred-repos'
Expand Down Expand Up @@ -59,6 +60,21 @@ const saveEmailMutation = useNuxtMutation<IdentityEmailPatchBody, IdentityMutati
onError: actionFailed('save your email settings'),
})

const savePrivacyMutation = useNuxtMutation<IdentityPrivacyPatchBody, IdentityMutationResponse>({
mutation: async (body) => {
const result = await rpc.execute(identityAccountQueries.savePrivacy(), body)
await refreshAccount()
return result
},
onError: actionFailed('save your privacy settings'),
})
async function setLikesPublic(likesPublic: boolean) {
await savePrivacyMutation.mutateSafe({ likes_public: likesPublic })
}
async function setRepoIndexing(repoIndexing: boolean) {
await savePrivacyMutation.mutateSafe({ repo_indexing: repoIndexing })
}

const removeLikeMutation = useNuxtMutation<LikedSkill, { ok: true }>({
mutation: async (skill) => {
const path = `/api/me/likes/${skill.owner}/${skill.repo}/${skill.name}`
Expand Down Expand Up @@ -392,6 +408,44 @@ function fmtDate(ts: number | null | undefined): string {
</div>
</section>

<section aria-labelledby="privacy-heading">
<h2 id="privacy-heading" class="text-lg font-semibold">
Privacy
</h2>
<div class="mt-5 border-y border-default py-4">
<USwitch
:model-value="!!me?.likes_public"
:loading="savePrivacyMutation.pending.value"
:disabled="!me || savePrivacyMutation.pending.value"
label="Show liked Skills on your profile"
:description="me?.likes_public
? 'Anyone can see your liked Skills.'
: 'Only you can see your liked Skills.'"
@update:model-value="setLikesPublic"
/>
<UButton
:to="`/@${me?.login}/liked`"
class="mt-3 min-h-11"
color="neutral"
variant="ghost"
icon="i-lucide-heart"
label="View liked Skills page"
/>
</div>
<div class="border-b border-default py-4">
<USwitch
:model-value="!!me?.repo_indexing"
:loading="savePrivacyMutation.pending.value"
:disabled="!me || savePrivacyMutation.pending.value"
label="Add Skills from your public repositories"
:description="me?.repo_indexing
? 'skilld checks your public repositories for SKILL.md files and gives each Skill a public page.'
: 'skilld does not check your repositories. Skills already added stay.'"
@update:model-value="setRepoIndexing"
/>
</div>
</section>

<section>
<h2 class="text-lg font-semibold">
Repository coverage
Expand Down
7 changes: 7 additions & 0 deletions layers/identity/app/pages/onboarding/discover.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { syncStarredRepos } from '../../utils/sync-starred-repos'

definePageMeta({ middleware: ['auth'] })

const { user } = useUserSession()

interface Skill {
name: string
displayName: string
Expand Down Expand Up @@ -111,6 +113,11 @@ useSeoMeta({ title: 'Discover skills', robots: 'noindex' })
<p class="mt-2 text-sm text-muted">
We check starred GitHub repos with "skill" in the name against the registry. Choose the ones to watch. You'll only get a digest when one changes.
</p>
<OwnedSkillsPrompt
v-if="user?.login"
:login="user.login"
class="mt-6"
/>

<div class="mt-4 flex flex-wrap items-center gap-3">
<UButton
Expand Down
14 changes: 14 additions & 0 deletions layers/identity/app/queries/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
identityEmailPatchBodySchema,
identityMeSchema,
identityMutationResponseSchema,
identityOwnedRepoScanResponseSchema,
identityPrivacyPatchBodySchema,
identitySubscriptionsSchema,
} from '../../shared/contracts/account'

Expand All @@ -31,6 +33,18 @@ export const identityAccountQueries = defineNuxtQueryGroup('identity:account', {
path: '/api/me/email',
response: identityMutationResponseSchema,
}),
savePrivacy: () => defineNuxtRpcMutation({
body: identityPrivacyPatchBodySchema,
method: 'PATCH',
path: '/api/me/privacy',
response: identityMutationResponseSchema,
}),
scanOwnedRepos: () => defineNuxtRpcMutation({
body: null,
method: 'POST',
path: '/api/me/repos/scan',
response: identityOwnedRepoScanResponseSchema,
}),
finishOnboarding: () => defineNuxtRpcMutation({
body: null,
method: 'POST',
Expand Down
64 changes: 64 additions & 0 deletions layers/identity/app/utils/owned-repo-scan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { IdentityOwnedRepoScanResponse } from '../../shared/contracts/account'

export type OwnedRepoScanNotice
= | { _tag: 'added', message: string }
| { _tag: 'none_found', message: string }
| { _tag: 'retry', message: string }
| { _tag: 'sign_in', message: string }

function repositories(count: number): string {
return `${count} ${count === 1 ? 'repository' : 'repositories'}`
}

function addedLine(count: number): string {
return count > 0 ? `Added Skills from ${repositories(count)}. ` : ''
}

/**
* Turn one scan result into the line the onboarding prompt shows.
*
* A `partial` scan still reports what it added, then asks for a retry so the
* rest of the results can arrive.
*/
export function ownedRepoScanNotice(result: IdentityOwnedRepoScanResponse): OwnedRepoScanNotice {
switch (result._tag) {
case 'auth_failure':
return { _tag: 'sign_in', message: 'GitHub did not accept your sign-in. Sign in with GitHub again.' }
case 'rate_limited':
return { _tag: 'retry', message: 'GitHub limited the check. Try again in a few minutes.' }
case 'provider_failure':
return { _tag: 'retry', message: 'GitHub did not answer. Try again later.' }
case 'partial':
return {
_tag: 'retry',
message: `${addedLine(result.reposSynced)}GitHub did not return every result. Try again later.`,
}
case 'complete':
if (result.reposFound === 0)
return { _tag: 'none_found', message: 'No SKILL.md files found in your public repositories.' }
if (result.reposFailed > 0) {
return {
_tag: 'retry',
message: `${addedLine(result.reposSynced)}${repositories(result.reposFailed)} failed. Try again later.`,
}
}
if (result.reposSynced === 0)
return { _tag: 'none_found', message: `Checked ${repositories(result.reposFound)}. No new Skills to add.` }
return { _tag: 'added', message: `Added Skills from ${repositories(result.reposSynced)}.` }
}
}

/**
* Turn a failed scan request into the line the prompt shows.
*
* A 401 means skilld has no usable GitHub token for the account, so the only
* fix is a new GitHub sign-in.
*/
export function ownedRepoScanFailureNotice(error: unknown): OwnedRepoScanNotice {
const status = typeof error === 'object' && error !== null
? (error as { statusCode?: unknown, status?: unknown }).statusCode ?? (error as { status?: unknown }).status
: undefined
if (status === 401)
return { _tag: 'sign_in', message: 'Your GitHub sign-in expired. Sign in with GitHub again.' }
return { _tag: 'retry', message: 'Could not check your repositories. Try again.' }
}
Loading
Loading