diff --git a/.env.example b/.env.example index a6f087408..2e7378182 100644 --- a/.env.example +++ b/.env.example @@ -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/) +# ---------------------------------------------------------------------------- +# 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_. +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`) # ---------------------------------------------------------------------------- diff --git a/.gitignore b/.gitignore index c8ff7dff2..697de28f7 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ test-results/ .env*.local .env* !.env.example +/.swc # Amp orb portal manifests .amp/portals/* diff --git a/app/app.vue b/app/app.vue index b3853a232..53425be49 100644 --- a/app/app.vue +++ b/app/app.vue @@ -31,12 +31,14 @@ watch(() => colorMode.preference, (newMode, oldMode) => { const pull = usePullPreview() -const { data: navigation } = await useFetch(computed(() => - pull.value ? pullNavigationPath(docsVersion.value, pull.value) : navigationPath(docsVersion.value))) +const { data: navigation } = await useFetch(() => + 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('/api/content/heads', { query: { keys: searchKeys.value.join(',') } diff --git a/app/components/OgImage/Nuxter.takumi.vue b/app/components/OgImage/Nuxter.takumi.vue new file mode 100644 index 000000000..02f43cf76 --- /dev/null +++ b/app/components/OgImage/Nuxter.takumi.vue @@ -0,0 +1,96 @@ + + + diff --git a/app/components/header/HeaderUserMenu.vue b/app/components/header/HeaderUserMenu.vue index 47542d2e7..1f97a5918 100644 --- a/app/components/header/HeaderUserMenu.vue +++ b/app/components/header/HeaderUserMenu.vue @@ -25,6 +25,10 @@ const items = computed(() => { 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({ diff --git a/app/components/home/HomeSectionContributors.vue b/app/components/home/HomeSectionContributors.vue index d08bb1a44..f06fc6623 100644 --- a/app/components/home/HomeSectionContributors.vue +++ b/app/components/home/HomeSectionContributors.vue @@ -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 @@ -75,11 +75,10 @@ async function nextContributors() { name="avatar" mode="out-in" > - - + diff --git a/app/components/nuxters/NuxtersLeaderboard.vue b/app/components/nuxters/NuxtersLeaderboard.vue new file mode 100644 index 000000000..48f2e391f --- /dev/null +++ b/app/components/nuxters/NuxtersLeaderboard.vue @@ -0,0 +1,80 @@ + + + diff --git a/app/components/nuxters/NuxtersMeCard.vue b/app/components/nuxters/NuxtersMeCard.vue new file mode 100644 index 000000000..7392e6e00 --- /dev/null +++ b/app/components/nuxters/NuxtersMeCard.vue @@ -0,0 +1,164 @@ + + + diff --git a/app/components/nuxters/NuxtersMeCardSkeleton.vue b/app/components/nuxters/NuxtersMeCardSkeleton.vue new file mode 100644 index 000000000..7e3b8fb14 --- /dev/null +++ b/app/components/nuxters/NuxtersMeCardSkeleton.vue @@ -0,0 +1,30 @@ + + + + diff --git a/app/components/nuxters/NuxtersPeriodTabs.vue b/app/components/nuxters/NuxtersPeriodTabs.vue new file mode 100644 index 000000000..2d6fc7ee4 --- /dev/null +++ b/app/components/nuxters/NuxtersPeriodTabs.vue @@ -0,0 +1,139 @@ + + + diff --git a/app/components/nuxters/NuxtersScoreBreakdown.vue b/app/components/nuxters/NuxtersScoreBreakdown.vue new file mode 100644 index 000000000..6224c13c8 --- /dev/null +++ b/app/components/nuxters/NuxtersScoreBreakdown.vue @@ -0,0 +1,80 @@ + + + diff --git a/app/components/nuxters/NuxtersSearch.vue b/app/components/nuxters/NuxtersSearch.vue new file mode 100644 index 000000000..eb99cf498 --- /dev/null +++ b/app/components/nuxters/NuxtersSearch.vue @@ -0,0 +1,83 @@ + + + diff --git a/app/components/nuxters/NuxtersShare.vue b/app/components/nuxters/NuxtersShare.vue new file mode 100644 index 000000000..15d9376bf --- /dev/null +++ b/app/components/nuxters/NuxtersShare.vue @@ -0,0 +1,86 @@ + + + diff --git a/app/components/tools/NuxterCard.vue b/app/components/tools/NuxterCard.vue new file mode 100644 index 000000000..6e387b03b --- /dev/null +++ b/app/components/tools/NuxterCard.vue @@ -0,0 +1,78 @@ + + + diff --git a/app/composables/useNavigation.ts b/app/composables/useNavigation.ts index 80d00fa88..e7b664c30 100644 --- a/app/composables/useNavigation.ts +++ b/app/composables/useNavigation.ts @@ -1,4 +1,5 @@ import type { CommandPaletteGroup } from '@nuxt/ui' +import type { NuxterSummary } from '#shared/types' import { createSharedComposable } from '@vueuse/core' function _useHeaderLinks() { @@ -63,48 +64,48 @@ function _useHeaderLinks() { to: '/modules', description: 'Supercharge your Nuxt project with modules.' }, { - label: 'Templates', - icon: 'i-lucide-app-window', - description: 'Start your next project with a Nuxt template.', - to: '/templates' + label: 'Showcase', + icon: 'i-lucide-presentation', + description: 'Discover and explore projects built with Nuxt.', + to: '/showcase' + }, { + label: 'Nuxters', + icon: 'i-lucide-award', + description: 'Discover the contributors of the Nuxt ecosystem and share your Nuxter profile.', + to: '/nuxters', + active: route.path.startsWith('/nuxters') }, { label: 'Resources', icon: 'i-lucide-library', - to: '/showcase', + to: '/templates', search: false, - active: route.path.startsWith('/video-courses') || route.path.startsWith('/showcase'), + active: ['/templates', '/video-courses', '/enterprise'].some(path => route.path.startsWith(path)), children: [{ - label: 'Showcase', - description: 'Discover and explore projects built with Nuxt.', - icon: 'i-lucide-presentation', - to: '/showcase' + label: 'Templates', + description: 'Start your next project with a Nuxt template.', + icon: 'i-lucide-app-window', + to: '/templates' }, { label: 'Video Courses', description: 'Learn Nuxt by watching video courses.', icon: 'i-lucide-graduation-cap', to: '/video-courses' }, { - label: 'Nuxt Certification', - description: 'Obtain your Certification of Competence.', - icon: 'i-lucide-medal', - to: 'https://certification.nuxt.com', - target: '_blank' - }] - }, { - label: 'Enterprise', - icon: 'i-lucide-building-2', - to: '/enterprise', - search: false, - children: [{ label: 'Agencies', - to: '/enterprise/agencies', description: 'Agencies specialized in Nuxt development.', - icon: 'i-lucide-handshake' + icon: 'i-lucide-handshake', + to: '/enterprise/agencies' }, { label: 'Sponsors', - to: '/enterprise/sponsors', description: 'Help us sustain Nuxt development.', - icon: 'i-lucide-hand-heart' + icon: 'i-lucide-hand-heart', + to: '/enterprise/sponsors' + }, { + label: 'Nuxt Certification', + description: 'Obtain your Certification of Competence.', + icon: 'i-lucide-medal', + to: 'https://certification.nuxt.com', + target: '_blank' }] }, { label: 'Updates', @@ -134,8 +135,7 @@ const footerLinks = [{ label: 'Community', children: [{ label: 'Nuxters', - to: 'https://nuxters.nuxt.com', - target: '_blank' + to: '/nuxters' }, { label: 'Team', to: '/team' @@ -256,6 +256,36 @@ const _useNavigation = () => { to: article.path }))) + // Nuxters: 27k usernames, so the server searches them (lazy, one request per pause in typing). + const nuxterResults = ref([]) + let nuxtersRequest = 0 + watchDebounced(searchTerm, async (term) => { + const query = term.trim() + const current = ++nuxtersRequest + if (query.length < 2) { + nuxterResults.value = [] + return + } + const items = await $fetch('/api/search/nuxters', { query: { q: query, limit: 5 } }).catch(() => []) + // A slower, older request must not replace newer results. + if (current === nuxtersRequest) nuxterResults.value = items + }, { debounce: 200 }) + + const { format } = new Intl.NumberFormat('en-US') + const nuxterItems = computed(() => nuxterResults.value.map(nuxter => ({ + id: `nuxter-${nuxter.githubId}`, + label: nuxter.username, + suffix: `Nuxter #${format(nuxter.rank)} · ${format(nuxter.score)} pts`, + // UAvatar renders with (IPX): pass the source path, not an IPX URL. + avatar: { + src: `/gh_avatar/${nuxter.username}`, + alt: nuxter.username, + densities: 'x1 x2', + ui: { root: 'rounded-md' } + }, + to: `/nuxters/${nuxter.username}` + }))) + const postFilter = (searchTerm: string, items: any[]) => { if (!searchTerm) { return [] @@ -278,6 +308,13 @@ const _useNavigation = () => { label: 'Blog', items: blogItems.value, postFilter + }, { + id: 'nuxters-search', + label: 'Nuxters', + items: nuxterItems.value, + // Already filtered and ranked by the server + ignoreFilter: true, + postFilter }, { id: 'ask-ai-search', label: 'AI', diff --git a/app/pages/blog/[slug].vue b/app/pages/blog/[slug].vue index a3b80f106..8d50c748f 100644 --- a/app/pages/blog/[slug].vue +++ b/app/pages/blog/[slug].vue @@ -1,5 +1,6 @@ + + + +