mirror of
https://github.com/lucide-icons/lucide.git
synced 2026-11-13 13:48:26 +00:00
feat(docs): add navbar sponsor button, more sponsor stats and tier links
- Replace the Sponsor nav text link with a dedicated heart button in the navbar, shown icon-only at tablet widths where the menu is crowded - Show weekly npm downloads across all Lucide packages and the all-time Open Collective sponsor count in the sponsor page hero - Add tierLinks.ts for direct Open Collective and GitHub Sponsors tier checkout links, falling back to the general pages while empty Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -118,7 +118,6 @@ export default defineConfig({
|
||||
},
|
||||
{ text: 'Packages', link: '/packages' },
|
||||
{ text: 'Showcase', link: '/showcase' },
|
||||
{ text: 'Sponsor', link: '/sponsor' },
|
||||
{ text: 'Merch', link: 'https://merch.lucide.dev/' },
|
||||
],
|
||||
sidebar,
|
||||
|
||||
@@ -5,14 +5,7 @@ import Icon from '@lucide/vue/src/Icon';
|
||||
import { check } from '../../../data/iconNodes';
|
||||
import SponsorSection from './SponsorSection.vue';
|
||||
import SponsorSectionHeader from './SponsorSectionHeader.vue';
|
||||
import {
|
||||
GITHUB_SPONSORS_URL,
|
||||
OPEN_COLLECTIVE_URL,
|
||||
TIERS,
|
||||
formatEuro,
|
||||
getPreviousTier,
|
||||
getTierForAmount,
|
||||
} from './tiers';
|
||||
import { TIERS, formatEuro, getPreviousTier, getTierForAmount, getTierLinks } from './tiers';
|
||||
|
||||
type Frequency = 'monthly' | 'once';
|
||||
|
||||
@@ -36,6 +29,9 @@ const total = computed(() => {
|
||||
|
||||
const tier = computed(() => (isMonthly.value ? getTierForAmount(total.value) : null));
|
||||
|
||||
// Only a monthly preset matches a tier's checkout, other amounts go to the general pages.
|
||||
const links = computed(() => getTierLinks(customAmount.value === '' ? tier.value?.id : undefined));
|
||||
|
||||
const amountText = computed(() => formatEuro(total.value));
|
||||
|
||||
const summary = computed(() => {
|
||||
@@ -159,11 +155,11 @@ function pickAmount(value: number) {
|
||||
</div>
|
||||
<div class="summary-actions">
|
||||
<VPButton
|
||||
:href="OPEN_COLLECTIVE_URL"
|
||||
:href="links.openCollective"
|
||||
text="Open Collective"
|
||||
/>
|
||||
<VPButton
|
||||
:href="GITHUB_SPONSORS_URL"
|
||||
:href="links.githubSponsors"
|
||||
text="GitHub Sponsors"
|
||||
theme="alt"
|
||||
/>
|
||||
|
||||
@@ -2,18 +2,32 @@
|
||||
import VPButton from 'vitepress/dist/client/theme-default/components/VPButton.vue';
|
||||
import SponsorSection from './SponsorSection.vue';
|
||||
import { data } from './SponsorStats.data';
|
||||
import { sponsors } from './sponsors';
|
||||
import { allTimeSponsorCount } from './sponsors';
|
||||
|
||||
/** Rounds down to a short "120M+" / "25k+" figure. */
|
||||
function formatCompact(value: number) {
|
||||
if (value >= 1_000_000) return `${Math.floor(value / 1_000_000)}M+`;
|
||||
if (value >= 1_000) return `${Math.floor(value / 1_000)}k+`;
|
||||
return value.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
const stats = [
|
||||
...(data.stars != null
|
||||
? [{ value: `${Math.floor(data.stars / 1000)}k+`, label: 'GitHub stars' }]
|
||||
: []),
|
||||
{ value: data.weeklyDownloads, label: 'Weekly downloads', format: formatCompact },
|
||||
{ value: data.stars, label: 'GitHub stars', format: formatCompact },
|
||||
{
|
||||
value: `${(Math.floor(data.iconsCount / 100) * 100).toLocaleString('en-US')}+`,
|
||||
value: data.iconsCount,
|
||||
label: 'Icons',
|
||||
format: (value: number) => `${(Math.floor(value / 100) * 100).toLocaleString('en-US')}+`,
|
||||
},
|
||||
{ value: sponsors.length.toLocaleString('en-US'), label: 'Sponsors' },
|
||||
];
|
||||
{
|
||||
value: allTimeSponsorCount,
|
||||
label: 'All-time sponsors',
|
||||
format: (value: number) => value.toLocaleString('en-US'),
|
||||
},
|
||||
]
|
||||
// Stats the build couldn't fetch are left out.
|
||||
.filter((stat): stat is typeof stat & { value: number } => stat.value != null)
|
||||
.map(({ value, label, format }) => ({ value: format(value), label }));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -111,9 +125,8 @@ const stats = [
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 24px 16px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import iconNodes from '../../../data/iconNodes';
|
||||
import fetchPackages from '../../../lib/fetchPackages';
|
||||
|
||||
const NPM_DOWNLOADS_API = 'https://api.npmjs.org/downloads/point/last-week';
|
||||
|
||||
/**
|
||||
* Packages that are no longer in the repo but are still published and installed.
|
||||
*/
|
||||
const LEGACY_PACKAGES = ['lucide-vue-next', 'lucide-vue', 'lucide-svelte', 'lucide-angular'];
|
||||
|
||||
async function fetchStars() {
|
||||
try {
|
||||
@@ -13,10 +21,56 @@ async function fetchStars() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDownloads(url: string) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`npm downloads API responded with ${response.status} for ${url}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum of last week's npm downloads of every published Lucide package, or `null` when any
|
||||
* request fails, so a partial total never shows up as the real number.
|
||||
*/
|
||||
async function fetchWeeklyDownloads() {
|
||||
try {
|
||||
const packageNames = (await fetchPackages())
|
||||
.filter((packageJson) => packageJson?.name && !packageJson.private)
|
||||
.map(({ name }) => name)
|
||||
.concat(LEGACY_PACKAGES);
|
||||
|
||||
// The bulk endpoint only accepts unscoped packages, scoped ones are fetched one by one.
|
||||
const unscoped = packageNames.filter((name) => !name.startsWith('@'));
|
||||
const scoped = packageNames.filter((name) => name.startsWith('@'));
|
||||
|
||||
const [bulk, ...single] = await Promise.all([
|
||||
fetchDownloads(`${NPM_DOWNLOADS_API}/${unscoped.join(',')}`) as Promise<
|
||||
Record<string, { downloads: number } | null>
|
||||
>,
|
||||
...scoped.map(
|
||||
(name) => fetchDownloads(`${NPM_DOWNLOADS_API}/${name}`) as Promise<{ downloads: number }>,
|
||||
),
|
||||
]);
|
||||
|
||||
return [...Object.values(bulk), ...single].reduce(
|
||||
(total, result) => total + (result?.downloads ?? 0),
|
||||
0,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(`Could not fetch npm downloads. ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const loader = {
|
||||
async load() {
|
||||
const [stars, weeklyDownloads] = await Promise.all([fetchStars(), fetchWeeklyDownloads()]);
|
||||
|
||||
return {
|
||||
stars: await fetchStars(),
|
||||
stars,
|
||||
weeklyDownloads,
|
||||
iconsCount: Object.keys(iconNodes).length,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -3,19 +3,14 @@ import { computed } from 'vue';
|
||||
import VPButton from 'vitepress/dist/client/theme-default/components/VPButton.vue';
|
||||
import Icon from '@lucide/vue/src/Icon';
|
||||
import { check } from '../../../data/iconNodes';
|
||||
import {
|
||||
GITHUB_SPONSORS_URL,
|
||||
OPEN_COLLECTIVE_URL,
|
||||
formatEuro,
|
||||
getPreviousTier,
|
||||
type Tier,
|
||||
} from './tiers';
|
||||
import { formatEuro, getPreviousTier, getTierLinks, type Tier } from './tiers';
|
||||
|
||||
const props = defineProps<{
|
||||
tier: Tier;
|
||||
}>();
|
||||
|
||||
const previousTier = computed(() => getPreviousTier(props.tier.id));
|
||||
const links = computed(() => getTierLinks(props.tier.id));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -59,13 +54,13 @@ const previousTier = computed(() => getPreviousTier(props.tier.id));
|
||||
|
||||
<div class="actions">
|
||||
<VPButton
|
||||
:href="OPEN_COLLECTIVE_URL"
|
||||
:href="links.openCollective"
|
||||
text="Open Collective"
|
||||
:theme="tier.featured ? 'brand' : 'alt'"
|
||||
class="primary-action"
|
||||
/>
|
||||
<a
|
||||
:href="GITHUB_SPONSORS_URL"
|
||||
:href="links.githubSponsors"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="secondary-action"
|
||||
|
||||
@@ -12,6 +12,9 @@ export interface Sponsor {
|
||||
|
||||
export const sponsors: Sponsor[] = sponsorsData.sponsors;
|
||||
|
||||
/** Everyone who ever contributed on Open Collective, `null` when the build couldn't fetch it. */
|
||||
export const allTimeSponsorCount: number | null = sponsorsData.allTimeCount;
|
||||
|
||||
export const getSponsorsByTier = (tierId: TierId) =>
|
||||
sponsors.filter(({ monthlyAmount }) => getTierForAmount(monthlyAmount)?.id === tierId);
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { TierId } from './tiers';
|
||||
|
||||
/**
|
||||
* Direct checkout links for each sponsor tier. Leave a link empty to fall back to the
|
||||
* general Open Collective or GitHub Sponsors page.
|
||||
*
|
||||
* - Open Collective: open the tier on opencollective.com/lucide-icons and copy its
|
||||
* "Contribute" link, e.g. https://opencollective.com/lucide-icons/contribute/backer-12345/checkout
|
||||
* - GitHub Sponsors: open the tier on github.com/sponsors/lucide-icons and copy its link,
|
||||
* e.g. https://github.com/sponsors/lucide-icons/sponsorships?tier_id=12345
|
||||
*/
|
||||
export const TIER_LINKS: Record<TierId, { openCollective: string; githubSponsors: string }> = {
|
||||
supporter: {
|
||||
openCollective: '',
|
||||
githubSponsors: '',
|
||||
},
|
||||
backer: {
|
||||
openCollective: '',
|
||||
githubSponsors: '',
|
||||
},
|
||||
bronze: {
|
||||
openCollective: '',
|
||||
githubSponsors: '',
|
||||
},
|
||||
gold: {
|
||||
openCollective: '',
|
||||
githubSponsors: '',
|
||||
},
|
||||
platinum: {
|
||||
openCollective: '',
|
||||
githubSponsors: '',
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import { TIER_LINKS } from './tierLinks';
|
||||
|
||||
export const OPEN_COLLECTIVE_URL = 'https://opencollective.com/lucide-icons';
|
||||
export const GITHUB_SPONSORS_URL = 'https://github.com/sponsors/lucide-icons';
|
||||
export const PARTNER_EMAIL = 'info@lucide.dev';
|
||||
@@ -89,4 +91,14 @@ export function getTierForAmount(monthly: number) {
|
||||
return [...TIERS].reverse().find((tier) => monthly >= tier.monthly) ?? null;
|
||||
}
|
||||
|
||||
/** Checkout links for a tier, or the general sponsor pages without a tier. */
|
||||
export function getTierLinks(id?: TierId) {
|
||||
const links = id ? TIER_LINKS[id] : undefined;
|
||||
|
||||
return {
|
||||
openCollective: links?.openCollective || OPEN_COLLECTIVE_URL,
|
||||
githubSponsors: links?.githubSponsors || GITHUB_SPONSORS_URL,
|
||||
};
|
||||
}
|
||||
|
||||
export const formatEuro = (amount: number) => `€${amount.toLocaleString('en-US')}`;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ICON_STYLE_CONTEXT, iconStyleContext } from './composables/useIconStyle
|
||||
import { CATEGORY_VIEW_CONTEXT, categoryViewContext } from './composables/useCategoryView';
|
||||
import { EXTERNAL_LIBS_CONTEXT, externalLibContext } from './composables/useExternalLibs';
|
||||
import SideBarNavBefore from './layouts/SideBarNavBefore.vue';
|
||||
import NavBarSponsorButton from './layouts/NavBarSponsorButton.vue';
|
||||
import SnackPlayer from './components/editors/SnackPlayer.vue';
|
||||
import { setupTracking } from './utils/openpanel';
|
||||
|
||||
@@ -23,6 +24,7 @@ const theme: Partial<Theme> = {
|
||||
'sidebar-nav-after': () => h(IconsSidebarNavAfter),
|
||||
'home-hero-image': () => h(HomeHeroIconsCard),
|
||||
'home-hero-actions-after': () => h(HomeHeroAfter),
|
||||
'nav-bar-content-after': () => h(NavBarSponsorButton),
|
||||
// Keep this here for now, we might want to add more things to the top bar in the future
|
||||
// 'layout-top': () => h(LayoutTop),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useData } from 'vitepress';
|
||||
import Icon from '@lucide/vue/src/Icon';
|
||||
import { heart } from '../../data/iconNodes';
|
||||
|
||||
const { page } = useData();
|
||||
|
||||
const isActive = computed(() => page.value.relativePath === 'sponsor.md');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
href="/sponsor"
|
||||
class="sponsor-button"
|
||||
:class="{ active: isActive }"
|
||||
:aria-current="isActive ? 'page' : undefined"
|
||||
>
|
||||
<Icon
|
||||
:iconNode="heart"
|
||||
name="heart"
|
||||
:size="16"
|
||||
class="heart"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="label">Sponsor</span>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sponsor-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 0.25s,
|
||||
color 0.25s;
|
||||
}
|
||||
|
||||
.heart {
|
||||
color: var(--vp-c-brand-3);
|
||||
transition:
|
||||
fill 0.25s,
|
||||
transform 0.25s;
|
||||
}
|
||||
|
||||
.sponsor-button:hover,
|
||||
.sponsor-button.active {
|
||||
border-color: var(--vp-c-brand-3);
|
||||
}
|
||||
|
||||
.sponsor-button:hover .heart,
|
||||
.sponsor-button.active .heart {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.sponsor-button:hover .heart {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.sponsor-button {
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* The nav menu leaves no room for the label between tablet and desktop widths. */
|
||||
@media (min-width: 768px) and (max-width: 959px) {
|
||||
.sponsor-button {
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
padding: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -33,6 +33,7 @@ interface Order {
|
||||
interface CollectiveResponse {
|
||||
data?: {
|
||||
account: {
|
||||
stats: { contributorsCount: number | null } | null;
|
||||
orders: { nodes: Order[] };
|
||||
} | null;
|
||||
};
|
||||
@@ -54,6 +55,9 @@ export interface Sponsor {
|
||||
const query = /* GraphQL */ `
|
||||
query Sponsors($slug: String!) {
|
||||
account(slug: $slug) {
|
||||
stats {
|
||||
contributorsCount
|
||||
}
|
||||
orders(filter: INCOMING, status: [ACTIVE], limit: 1000) {
|
||||
nodes {
|
||||
amount {
|
||||
@@ -72,7 +76,7 @@ const query = /* GraphQL */ `
|
||||
}
|
||||
`;
|
||||
|
||||
async function fetchOrders() {
|
||||
async function fetchCollective() {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (process.env.OPENCOLLECTIVE_TOKEN) {
|
||||
headers['Personal-Token'] = process.env.OPENCOLLECTIVE_TOKEN;
|
||||
@@ -98,7 +102,7 @@ async function fetchOrders() {
|
||||
throw new Error(`Could not find Open Collective account "${COLLECTIVE_SLUG}"`);
|
||||
}
|
||||
|
||||
return data.account.orders.nodes;
|
||||
return data.account;
|
||||
}
|
||||
|
||||
function getLogo({ slug, imageUrl }: Account) {
|
||||
@@ -149,13 +153,17 @@ function toSponsors(orders: Order[]) {
|
||||
}
|
||||
|
||||
let sponsors: Sponsor[] = [];
|
||||
/** Everyone who ever contributed financially, including past and one-time contributors. */
|
||||
let allTimeCount: number | null = null;
|
||||
|
||||
try {
|
||||
sponsors = toSponsors(await fetchOrders());
|
||||
const collective = await fetchCollective();
|
||||
sponsors = toSponsors(collective.orders.nodes);
|
||||
allTimeCount = collective.stats?.contributorsCount ?? null;
|
||||
} catch (error) {
|
||||
console.warn(`Could not fetch sponsors, writing an empty list. ${error}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(dataDirectory, { recursive: true });
|
||||
fs.writeFileSync(outputPath, JSON.stringify({ sponsors }, null, 2), 'utf-8');
|
||||
fs.writeFileSync(outputPath, JSON.stringify({ allTimeCount, sponsors }, null, 2), 'utf-8');
|
||||
console.log(`Wrote ${sponsors.length} sponsors to ${outputPath}`);
|
||||
|
||||
Reference in New Issue
Block a user