Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
bcd504f
refactor(app): use getter keys for navigation and content heads data
atinux Oct 10, 2026
b5e516b
feat(nuxters): move nuxters.nuxt.com to nuxt.com/nuxters
atinux Oct 10, 2026
1b02817
feat(nuxters): put the profile next to its stats, one GitHub icon
atinux Oct 10, 2026
e1a63aa
feat(nuxters): color the profile stats like the README card
atinux Oct 10, 2026
0ee587a
feat(nuxters): profile periods, Nuxter search and header navigation
atinux Oct 10, 2026
81ab70b
fix(nuxters): address review feedback
atinux Oct 12, 2026
d266460
up
atinux Oct 12, 2026
51df33f
chore: add admin sync
atinux Oct 12, 2026
a1c985f
feat(nuxters): share the period in titles, descriptions and cards
atinux Oct 12, 2026
4403182
more improvements
atinux Oct 13, 2026
2ca9124
Merge branch 'main' into feat/nuxters
atinux Oct 13, 2026
820c1e3
chore: refacto to use NuxtersPeriodTab component
atinux Oct 13, 2026
0c26c2d
Update [username].vue
atinux Oct 13, 2026
f68ba48
feat(nuxters): show the GitHub profile links of Nuxters
atinux Oct 13, 2026
0c82229
feat(nuxters): show GitHub with the other profile links
atinux Oct 13, 2026
4cf3bfb
feat(nuxters): share actions for the profile owner and in the Nuxter …
atinux Oct 13, 2026
9a3a04a
Merge remote-tracking branch 'origin/main' into feat/nuxters
atinux Oct 15, 2026
4de9dd4
feat(nuxters): maintained and contributed modules on profiles
atinux Oct 15, 2026
c71e0a2
feat(nuxi): Nuxter card in the chat, modules in get-nuxter
atinux Oct 15, 2026
c978a0e
feat(blog): GitHub usernames for authors, blog posts on Nuxter profiles
atinux Oct 15, 2026
2b16bf0
feat(modules): link maintainers and contributors to their Nuxter profile
atinux Oct 15, 2026
fdf877e
feat(nuxters): period tabs with UTabs, no scroll on period change
benjamincanac Oct 15, 2026
a2dbd61
fix(nuxters): keep #leaderboard in view once the hero card loads
benjamincanac Oct 15, 2026
80a9210
feat(nuxters): tinted stat cards, compact blog posts and show all on …
benjamincanac Oct 15, 2026
1074082
feat(nuxi): Nuxter card links as a whole, stats without labels
benjamincanac Oct 15, 2026
cf5706a
fix(nuxters): sharp leaderboard avatars
atinux Oct 15, 2026
e66e17e
fix(nuxters): no layout shift from the owner share block on profiles
benjamincanac Oct 15, 2026
e5663c9
feat(nuxters): 4 column grids with aligned gaps on profiles
benjamincanac Oct 15, 2026
fa8903b
feat(nuxters): people-first hero
atinux Oct 15, 2026
ba5d1af
fix(nuxters): harden module maintainers, profile links and profiles i…
atinux Oct 15, 2026
20c18ac
fix(nuxters): no layout shift in the hero, period picker for the READ…
atinux Oct 15, 2026
13e9a0f
chore: remove extra hash
atinux Oct 15, 2026
f46ae36
fix(nuxters): read the Discord env vars of the Vercel project
atinux Oct 15, 2026
432ce99
feat(nuxters): centered sign-in card, hero thanks the contributors
atinux Oct 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
28 changes: 28 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,34 @@ NUXI_VERCEL_TEAM_ID=
# Personal access token to avoid GitHub API rate limits
NUXT_GITHUB_TOKEN=

# ----------------------------------------------------------------------------
# Nuxters (optional — /nuxters and /nuxters/<username>)
# ----------------------------------------------------------------------------
# Data: run `pnpm nuxters:sync` while `pnpm dev` runs to import the latest
# period files from github.com/nuxt/nuxters.
# In production, the push webhook of nuxt/nuxters (POST /api/webhooks/nuxters)
# starts a Vercel Workflow that imports them through /api/internal/nuxters/*
# (needs INTERNAL_API_SECRET, see the Nuxi section). Admins can also start it:
# POST /api/admin/nuxters/sync.
# Secret of that webhook, also set in nuxt/nuxters → Settings → Webhooks.
# Generate with: openssl rand -hex 32
NUXT_NUXTERS_WEBHOOK_SECRET=
#
# Discord role unlock. The Discord step stays hidden unless all four are set.
# OAuth redirect URL: http://localhost:3000/api/auth/discord
# Each also reads the name of the Vercel project (server/utils/nuxters-discord.ts): NUXT_DISCORD_CLIENT_ID,
# NUXT_DISCORD_CLIENT_SECRET, NUXT_DISCORD_GUILD_ID, NUXT_DISCORD_BOT_TOKEN, NUXT_DISCORD_NUXTER_ROLE_ID,
# NUXT_DISCORD_MODULE_MAINTAINER_ROLE_ID and NUXT_DISCORD_HACKATHON_ROLE_IDS_<HACKATHON ID>.
NUXT_OAUTH_DISCORD_CLIENT_ID=
NUXT_OAUTH_DISCORD_CLIENT_SECRET=
NUXT_NUXTERS_DISCORD_GUILD_ID=
# Bot with the Manage Roles permission, in the guild above
NUXT_NUXTERS_DISCORD_BOT_TOKEN=
# Role ids (each role is skipped when empty)
NUXT_NUXTERS_DISCORD_ROLES_NUXTER=
NUXT_NUXTERS_DISCORD_ROLES_MODULE_AUTHOR=
NUXT_NUXTERS_DISCORD_ROLES_NUXTATHON1=

# ----------------------------------------------------------------------------
# MCP evals (optional — for running `pnpm eval`)
# ----------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ test-results/
.env*.local
.env*
!.env.example
/.swc

# Amp orb portal manifests
.amp/portals/*
6 changes: 4 additions & 2 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,14 @@ watch(() => colorMode.preference, (newMode, oldMode) => {

const pull = usePullPreview()

const { data: navigation } = await useFetch<NavigationItem[]>(computed(() =>
pull.value ? pullNavigationPath(docsVersion.value, pull.value) : navigationPath(docsVersion.value)))
const { data: navigation } = await useFetch<NavigationItem[]>(() =>
pull.value ? pullNavigationPath(docsVersion.value, pull.value) : navigationPath(docsVersion.value), { key: 'navigation' })

const searchKeys = computed(() => searchInstanceKeys(docsVersion.value))
// Client-only: an SSR value gets baked into the page's ISR entry and would pin search to a stale commit.
const { data: searchShas } = useAsyncData(
// A `computed()` key, not a getter: `experimental.extractAsyncDataHandlers` takes the first arrow
// function as the handler, so a getter key is extracted instead (NUXT_E3008 in production).
computed(() => `content-heads:${searchKeys.value.join(',')}`),
() => $fetch<ContentShas>('/api/content/heads', {
query: { keys: searchKeys.value.join(',') }
Expand Down
96 changes: 96 additions & 0 deletions app/components/OgImage/Nuxter.takumi.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<script lang="ts" setup>
import type { NuxterProfile } from '#shared/types'
import { isNuxtersPeriod, nuxterForPeriod, nuxtersPeriodPhrase } from '#shared/utils/nuxters'

// Only the username and the period are in the image URL: the stats are read at render time,
// so a README card stays the same URL and shows fresh numbers.
const props = defineProps<{
username: string
/** `all` (default), `30d`, `12m` or a year: the stats of a shared `?period=` link. */
period?: string
}>()

const profile = await $fetch<NuxterProfile>(`/api/nuxters/${props.username}`)
// Not named `period`: a local variable with a prop's name hides the prop from nuxt-og-image,
// which then drops it from the image URL (it only keeps the declared props).
// The image URL decodes `period_2024` as the number 2024: compare it as a string.
const raw = props.period == null ? undefined : String(props.period)
const requested = isNuxtersPeriod(raw) ? raw : 'all'
// A period without contributions shows all time, like the profile page.
const nuxter = nuxterForPeriod(profile, requested) ?? profile
const shown = nuxter === profile ? 'all' : requested
const since = profile.firstContributionAt ? new Date(profile.firstContributionAt).getUTCFullYear() : null

const { format } = new Intl.NumberFormat('en-US')

const label = (value: number, one: string, many: string) => value === 1 ? one : many

// Two explicit rows: takumi does not wrap flex items.
const rows = [
[
{ label: label(nuxter.mergedPullRequests.all, 'Merged PR', 'Merged PRs'), value: nuxter.mergedPullRequests.all, color: '#00DC82' },
{ label: label(nuxter.issues, 'Issue', 'Issues'), value: nuxter.issues, color: '#38BDF8' }
],
[
{ label: label(nuxter.comments, 'Comment', 'Comments'), value: nuxter.comments, color: '#A78BFA' },
{ label: label(nuxter.reactions, 'Reaction', 'Reactions'), value: nuxter.reactions, color: '#FACC15' }
]
]
</script>

<template>
<div class="size-full flex flex-row bg-neutral-950 text-white p-16 gap-16">
<div class="flex flex-col w-[380px] items-center justify-between">
<div class="flex flex-col items-center">
<img
:src="`https://avatars.githubusercontent.com/u/${nuxter.githubId}?s=256`"
class="size-[176px] rounded-full"
:style="{ border: '4px solid #262626' }"
width="176"
height="176"
>
<p class="text-5xl font-semibold mt-6">
{{ nuxter.username }}
</p>
<p class="text-3xl text-neutral-400 mt-4">
{{ shown === 'all' ? `Nuxter #${format(nuxter.rank)}` : `#${format(nuxter.rank)} ${nuxtersPeriodPhrase(shown)}` }}
</p>
<p class="text-4xl font-semibold text-green-400 mt-4">
{{ format(nuxter.score) }} {{ nuxter.score === 1 ? 'pt' : 'pts' }}
</p>
<p v-if="since" class="text-2xl text-neutral-500 mt-3">
Nuxter since {{ since }}
</p>
</div>

<svg
viewBox="0 0 256 168"
xmlns="http://www.w3.org/2000/svg"
width="64"
height="42"
class="w-[64px] h-[42px] shrink-0"
preserveAspectRatio="xMidYMid meet"
>
<path fill="#00DC82" d="M143.618 167.029h95.166c3.023 0 5.992-.771 8.61-2.237a16.963 16.963 0 0 0 6.302-6.115 16.324 16.324 0 0 0 2.304-8.352c0-2.932-.799-5.811-2.312-8.35L189.778 34.6a16.966 16.966 0 0 0-6.301-6.113 17.626 17.626 0 0 0-8.608-2.238c-3.023 0-5.991.772-8.609 2.238a16.964 16.964 0 0 0-6.3 6.113l-16.342 27.473-31.95-53.724a16.973 16.973 0 0 0-6.304-6.112A17.638 17.638 0 0 0 96.754 0c-3.022 0-5.992.772-8.61 2.237a16.973 16.973 0 0 0-6.303 6.112L2.31 141.975A16.302 16.302 0 0 0 0 150.325c0 2.932.793 5.813 2.304 8.352a16.964 16.964 0 0 0 6.302 6.115 17.628 17.628 0 0 0 8.61 2.237h59.737c23.669 0 41.123-10.084 53.134-29.758l29.159-48.983 15.618-26.215 46.874 78.742h-62.492l-15.628 26.214Zm-67.64-26.24-41.688-.01L96.782 35.796l31.181 52.492-20.877 35.084c-7.976 12.765-17.037 17.416-31.107 17.416Z" />
</svg>
</div>

<div class="flex flex-col flex-1 gap-8 justify-center">
<div v-for="(row, index) in rows" :key="index" class="flex flex-row gap-8">
<div
v-for="stat in row"
:key="stat.label"
class="flex flex-col justify-end flex-1 h-[220px] rounded-3xl bg-neutral-900 p-8"
:style="{ border: `2px solid ${stat.color}` }"
>
<p class="text-6xl font-semibold">
{{ format(stat.value) }}
</p>
<p class="text-3xl mt-2" :style="{ color: stat.color }">
{{ stat.label }}
</p>
</div>
</div>
</div>
</div>
</template>
4 changes: 4 additions & 0 deletions app/components/header/HeaderUserMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ const items = computed<DropdownMenuItem[][]>(() => {
label: 'Chats with Nuxi',
icon: 'i-lucide-message-circle',
to: '/dashboard/chat'
}, {
label: 'My Nuxter profile',
icon: 'i-lucide-award',
to: '/nuxters'
}]
if (user.value?.role === 'admin') {
accountItems.push({
Expand Down
9 changes: 4 additions & 5 deletions app/components/home/HomeSectionContributors.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function onIntersectionObserver([{ isIntersecting }]) {
// Fetch on client-side
onMounted(async () => {
if (contributors.value.length) return
_contributors = await $fetch('/api/contributors').then(data => data.slice(0, total * 10).map(c => c.username))
_contributors = await $fetch('/api/nuxters', { query: { limit: total * 10 } }).then(data => data.items.map(c => c.username))
await loadImages(_contributors.slice(0, total))
if (!contributors.value.length && intersecting.value) {
contributors.value = _contributors
Expand Down Expand Up @@ -75,11 +75,10 @@ async function nextContributors() {
name="avatar"
mode="out-in"
>
<a
<NuxtLink
v-if="username"
:key="username"
:href="`https://nuxters.nuxt.com/${username}`"
target="_blank"
:to="`/nuxters/${username}`"
class="absolute inset-0 flex transition-all"
:style="{
'transition-delay': `${(index % 8 + Math.floor(index / 8)) * 20}ms`
Expand All @@ -99,7 +98,7 @@ async function nextContributors() {
class="rounded-xl w-full h-full transition lg:hover:scale-125 bg-muted"
/>
</UTooltip>
</a>
</NuxtLink>
</Transition>
</div>
</div>
Expand Down
80 changes: 80 additions & 0 deletions app/components/nuxters/NuxtersLeaderboard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<script setup lang="ts">
import type { NuxtersPage, NuxterSummary } from '#shared/types'

const props = defineProps<{
initial: NuxtersPage
}>()

const PAGE_SIZE = 100

const { format } = new Intl.NumberFormat('en-US')
const points = (score: number) => `${format(score)} ${score === 1 ? 'pt' : 'pts'}`

const more = ref<NuxterSummary[]>([])
const loading = ref(false)
const items = computed(() => [...props.initial.items, ...more.value])
const total = computed(() => props.initial.total)

// The profile opens on the same period (all time is its default).
const profileQuery = computed(() => props.initial.period === 'all' ? {} : { period: props.initial.period })

// Another period: start again from its first page.
watch(() => props.initial, () => {
more.value = []
})

async function showMore() {
// The period can change while the page loads: its items must not join the new list.
const initial = props.initial
loading.value = true
try {
const page = await $fetch<NuxtersPage>('/api/nuxters', { query: { period: initial.period, offset: items.value.length, limit: PAGE_SIZE } })
if (initial === props.initial) more.value.push(...page.items)
} finally {
loading.value = false
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
</script>

<template>
<div>
<ul class="grid grid-cols-5 sm:grid-cols-8 md:grid-cols-10 gap-3 sm:gap-4">
<li v-for="nuxter in items" :key="nuxter.githubId" class="relative">
<UTooltip :text="`${nuxter.username} · ${points(nuxter.score)}`">
<NuxtLink
:to="{ path: `/nuxters/${nuxter.username}`, query: profileQuery }"
:aria-label="`${nuxter.username}, #${format(nuxter.rank)}, ${points(nuxter.score)}`"
class="block aspect-square rounded-lg overflow-hidden bg-muted ring ring-default hover:ring-primary transition"
>
<!-- Cells are 123px at most (10 columns of the full container): 124px, and 248px on 2x screens -->
<NuxtImg
:src="`/gh_avatar/${nuxter.username}`"
provider="ipx"
densities="x1 x2"
width="124"
height="124"
format="auto"
loading="lazy"
:alt="nuxter.username"
class="size-full"
/>
</NuxtLink>
</UTooltip>
<span class="absolute -bottom-1.5 right-1 px-1 rounded bg-default ring ring-default text-xs font-medium tabular-nums">
{{ nuxter.rank }}
</span>
</li>
</ul>

<div v-if="items.length < total" class="flex justify-center mt-10">
<UButton
label="Show more"
icon="i-lucide-plus"
color="neutral"
variant="outline"
:loading="loading"
@click="showMore"
/>
</div>
</div>
</template>
Loading
Loading