Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c55de84
feat: install experimental account lexicons
alexdln May 7, 2026
81317b5
feat: configure basic account logic
alexdln May 7, 2026
5e154de
feat: update lexicons
alexdln May 8, 2026
9d7b159
feat: update lexicons
alexdln May 8, 2026
b7374b9
chore: remove package-related experiments
alexdln May 9, 2026
584d4a4
chore: install atproto/syntax
alexdln May 9, 2026
26144ad
feat: populate account records
alexdln May 9, 2026
4228666
Merge branch 'main' into feat/accounts
alexdln May 9, 2026
5f4ed86
feat: create tabs component and use on profile page
alexdln May 9, 2026
cb99129
feat: add account sponsors page
alexdln May 9, 2026
eff4c9b
feat: reuse profile card in profile header
alexdln May 9, 2026
6202048
chore: add sponsors translations
alexdln May 9, 2026
71410fd
feat: add account-related pages
alexdln May 9, 2026
ac25a44
feat: add role pages
alexdln May 9, 2026
516f2d1
feat: always return handle for accounts
alexdln May 9, 2026
80f9b84
feat: add warning about related accounts
alexdln May 9, 2026
6ed3bc7
chore: minor accounts ui improvements
alexdln May 10, 2026
5581033
test: add tests for new profile components
alexdln May 10, 2026
f0d5a99
test: fix route path in test
alexdln May 10, 2026
09aef42
chore: use lazy load for accounts data
alexdln May 10, 2026
3b25339
chore: remove unused i18n keys
alexdln May 10, 2026
2449543
chore: resolve conflicts
alexdln May 14, 2026
ce10f9e
Merge branch 'main' into feat/accounts
alexdln May 14, 2026
8758e00
feat: add create methods to account utils
alexdln May 15, 2026
dea8a18
feat: show known accounts in profile
alexdln May 15, 2026
4325329
feat: add logic to add account
alexdln May 15, 2026
86232bd
feat: add logic to add account to ecosystem
alexdln May 15, 2026
73af8ac
feat: add logic to add accounts to relations
alexdln May 15, 2026
f810911
chore: pass translations via props to account dialog
alexdln May 15, 2026
5bb052e
feat: add logic for roles creation
alexdln May 15, 2026
1c407b5
feat: add delay for microcosm updates
alexdln May 15, 2026
409c8dc
chore: generate translation schema
alexdln May 15, 2026
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
134 changes: 74 additions & 60 deletions app/components/Package/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,75 @@ const timelineLink = computed((): RouteLocationRaw | null => {
return packageTimelineRoute(props.pkg.name, props.resolvedVersion)
})

const navLinks = computed(() => {
const links: {
key: string
label: string
to: RouteLocationRaw
ariaKeyshortcuts?: string
title?: string
class?: string
}[] = []

if (mainLink.value) {
links.push({
key: 'main',
label: $t('package.links.main'),
to: mainLink.value,
ariaKeyshortcuts: 'm',
class: 'lowercase',
})
}

if (docsLink.value) {
links.push({
key: 'docs',
label: $t('package.links.docs'),
to: docsLink.value,
ariaKeyshortcuts: 'd',
})
}

if (codeLink.value) {
links.push({
key: 'code',
label: $t('package.links.code'),
to: codeLink.value,
ariaKeyshortcuts: '.',
})
}

if (diffLink.value) {
links.push({
key: 'diff',
label: $t('compare.compare_versions'),
to: diffLink.value,
ariaKeyshortcuts: 'f',
title: $t('compare.compare_versions_title'),
})
}

if (changelogLink.value) {
links.push({
key: 'changelog',
label: $t('package.links.changelog'),
to: changelogLink.value,
ariaKeyshortcuts: '-',
})
}

if (timelineLink.value) {
links.push({
key: 'timeline',
label: $t('package.links.timeline'),
to: timelineLink.value,
ariaKeyshortcuts: 't',
})
}

return links
})

useShortcuts({
'.': () => codeLink.value,
'm': () => mainLink.value,
Expand Down Expand Up @@ -308,69 +377,14 @@ useShortcuts({
</div>
</div>
<!-- Docs + Code — inline on desktop, floating bottom bar on mobile -->
<nav
v-if="resolvedVersion"
<TabLinks
v-if="resolvedVersion && navLinks.length > 0"
:aria-label="$t('package.navigation')"
class="flex gap-4 me-auto -mb-px max-w-full overflow-x-auto"
:links="navLinks"
:active-key="page"
:style="navExtraOffsetStyle"
:class="$style.packageNav"
>
<LinkBase
v-if="mainLink"
:to="mainLink"
aria-keyshortcuts="m"
class="decoration-none border-b-2 p-1 hover:border-accent/50 lowercase focus-visible:[outline-offset:-2px]!"
:class="page === 'main' ? 'border-accent text-accent!' : 'border-transparent'"
>
{{ $t('package.links.main') }}
</LinkBase>
<LinkBase
v-if="docsLink"
:to="docsLink"
aria-keyshortcuts="d"
class="decoration-none border-b-2 p-1 hover:border-accent/50 focus-visible:[outline-offset:-2px]!"
:class="page === 'docs' ? 'border-accent text-accent!' : 'border-transparent'"
>
{{ $t('package.links.docs') }}
</LinkBase>
<LinkBase
v-if="codeLink"
:to="codeLink"
aria-keyshortcuts="."
class="decoration-none border-b-2 p-1 hover:border-accent/50 focus-visible:[outline-offset:-2px]!"
:class="page === 'code' ? 'border-accent text-accent!' : 'border-transparent'"
>
{{ $t('package.links.code') }}
</LinkBase>
<LinkBase
v-if="diffLink"
:to="diffLink"
:title="$t('compare.compare_versions_title')"
aria-keyshortcuts="f"
class="decoration-none border-b-2 p-1 hover:border-accent/50 focus-visible:[outline-offset:-2px]!"
:class="page === 'diff' ? 'border-accent text-accent!' : 'border-transparent'"
>
{{ $t('compare.compare_versions') }}
</LinkBase>
<LinkBase
v-if="changelogLink"
:to="changelogLink"
aria-keyshortcuts="-"
class="decoration-none border-b-2 p-1 hover:border-accent/50 focus-visible:[outline-offset:-2px]!"
:class="page === 'changelog' ? 'border-accent text-accent!' : 'border-transparent'"
>
{{ $t('package.links.changelog') }}
</LinkBase>
<LinkBase
v-if="timelineLink"
:to="timelineLink"
aria-keyshortcuts="t"
class="decoration-none border-b-2 p-1 hover:border-accent/50 focus-visible:[outline-offset:-2px]!"
:class="page === 'timeline' ? 'border-accent text-accent!' : 'border-transparent'"
>
{{ $t('package.links.timeline') }}
</LinkBase>
</nav>
/>
</div>
</div>
</template>
Expand Down
61 changes: 61 additions & 0 deletions app/components/Profile/AccountsList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<script setup lang="ts">
export type AccountEntry = {
account?: {
uri?: string
handle?: string
actor?: {
handle?: string
name?: string
displayName?: string
description?: string
}
}
}

defineProps<{
entries: AccountEntry[] | null
status: string
error?: unknown
emptyLabel: string
}>()
</script>

<template>
<div v-if="status === 'pending'" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<SkeletonBlock v-for="i in 4" :key="i" class="h-20 rounded-lg" />
</div>
<div v-else-if="status === 'error' || error">
<p>{{ $t('common.error') }}</p>
</div>
<template v-else-if="entries?.length">
<p class="text-sm p-4 bg-badge-orange/10 border border-badge-orange/40 rounded-lg">
{{ $t('profile.account.warning') }}
</p>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
<div
v-for="(entry, index) in entries"
:key="entry.account?.uri || `entry-${index}`"
class="p-4 bg-bg-subtle border border-border rounded-lg decoration-none"
>
<p class="font-mono font-medium truncate">
{{
entry.account?.actor?.displayName ||
entry.account?.actor?.name ||
entry.account?.actor?.handle ||
entry.account?.handle ||
$t('profile.related_accounts.unknown')
}}
</p>
<p v-if="entry.account?.handle" class="text-fg-muted text-sm truncate">
@{{ entry.account?.handle }}
</p>
<p v-if="entry.account?.actor?.description" class="text-sm mt-1 line-clamp-2">
{{ entry.account?.actor?.description }}
</p>
</div>
</div>
</template>
<div v-else class="p-4 bg-bg-subtle border border-border rounded-lg text-fg-muted">
{{ emptyLabel }}
</div>
</template>
148 changes: 148 additions & 0 deletions app/components/Profile/AddKnownAccountDialog.client.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<script setup lang="ts">
import type { FetchError } from 'ofetch'
import { handleAuthError } from '~/utils/atproto/helpers'

const props = defineProps<{
identity: string
}>()

const emit = defineEmits<{
added: []
}>()

const { user } = useAtproto()
const modal = useModal('add-known-account-modal')

const handleInput = ref('')
const accountType = ref<'project' | 'user' | 'organisation' | 'collective'>('user')
const isSubmitting = ref(false)
const formError = ref<string | null>(null)

const accountTypeItems = computed(() => [
{ label: $t('profile.known_accounts.add_dialog.type_user'), value: 'user' as const },
{ label: $t('profile.known_accounts.add_dialog.type_project'), value: 'project' as const },
{
label: $t('profile.known_accounts.add_dialog.type_organisation'),
value: 'organisation' as const,
},
{ label: $t('profile.known_accounts.add_dialog.type_collective'), value: 'collective' as const },
])

function resetForm() {
handleInput.value = ''
accountType.value = 'user'
formError.value = null
isSubmitting.value = false
}

function open() {
resetForm()
modal.open()
}

function close() {
modal.close()
}

function resolveFormError(statusCode?: number, message?: string): string {
if (statusCode === 409 || message === 'Account already exists') {
return $t('profile.known_accounts.add_dialog.duplicate')
}
if (statusCode === 404 || message === 'Actor profile not found') {
return $t('profile.known_accounts.add_dialog.not_found')
}
if (statusCode === 400 && message === 'Cannot add your own account') {
return $t('profile.known_accounts.add_dialog.self')
}
return $t('common.error')
}

async function handleSubmit() {
const handle = handleInput.value.replace(/^@/, '').trim()
if (!handle || isSubmitting.value) return

isSubmitting.value = true
formError.value = null

try {
await $fetch(`/api/social/profile/${props.identity}/accounts`, {
method: 'POST',
body: {
handle,
type: accountType.value,
},
})

emit('added')
close()
} catch (error) {
const fetchError = error as FetchError<{ message?: string }>
formError.value = resolveFormError(fetchError.statusCode, fetchError.data?.message)

try {
await handleAuthError(fetchError, user.value?.handle)
} catch {
// Error message already set for non-auth failures.
}
} finally {
isSubmitting.value = false
}
}

defineExpose({
open,
close,
})
</script>

<template>
<Modal
id="add-known-account-modal"
:modal-title="$t('profile.known_accounts.add_dialog.title')"
:modal-subtitle="$t('profile.known_accounts.add_dialog.description')"
class="max-w-md"
>
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label
for="known-account-handle"
class="block text-xs text-fg-subtle uppercase tracking-wider mb-1.5"
>
{{ $t('profile.known_accounts.add_dialog.handle_label') }}
</label>
<InputBase
id="known-account-handle"
v-model="handleInput"
type="text"
name="known-account-handle"
:placeholder="$t('profile.known_accounts.add_dialog.handle_placeholder')"
autocomplete="username"
required
class="w-full min-w-25"
/>
</div>

<SelectField
id="known-account-type"
v-model="accountType"
name="known-account-type"
:label="$t('profile.known_accounts.add_dialog.type_label')"
block
:items="accountTypeItems"
/>

<p v-if="formError" class="text-sm text-badge-orange" role="alert">
{{ formError }}
</p>

<div class="flex gap-3 justify-end">
<ButtonBase type="button" @click="close">
{{ $t('common.cancel') }}
</ButtonBase>
<ButtonBase variant="primary" type="submit" :disabled="!handleInput.trim() || isSubmitting">
{{ $t('profile.known_accounts.add_dialog.submit') }}
</ButtonBase>
</div>
</form>
</Modal>
</template>
Loading
Loading