diff --git a/components/Confetti.vue b/components/Confetti.vue new file mode 100644 index 0000000..ac55674 --- /dev/null +++ b/components/Confetti.vue @@ -0,0 +1,30 @@ + + + diff --git a/components/InsightCard.vue b/components/InsightCard.vue new file mode 100644 index 0000000..1e83249 --- /dev/null +++ b/components/InsightCard.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/components/Layout.vue b/components/Layout.vue index db01687..cc87891 100644 --- a/components/Layout.vue +++ b/components/Layout.vue @@ -29,7 +29,7 @@ onUnmounted(() => { >

- githundred + githundred

{

Last updated {{ lastUpdated }} ago

+
+ list + insights +
diff --git a/composables/useFlippedCards.ts b/composables/useFlippedCards.ts new file mode 100644 index 0000000..33f8188 --- /dev/null +++ b/composables/useFlippedCards.ts @@ -0,0 +1,9 @@ +export const useFlippedCards = defineStore('flippedCards', () => { + const value = ref([]); + + onUnmounted(() => { + value.value = []; + }); + + return { value }; +}); diff --git a/package.json b/package.json index 9d8e084..4481349 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@heroicons/vue": "^2.1.3", "@pinia/nuxt": "^0.5.1", + "@tsparticles/confetti": "^3.3.0", "node-emoji": "^2.1.3", "nuxt": "^3.11.2", "vue": "^3.4.21", diff --git a/pages/index.vue b/pages/index.vue index 88e88d8..cc6fbc6 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -116,14 +116,14 @@ onUnmounted(() => {

{{ repo.ownerName }}/ - {{ repo.name }} + {{ repo.name }}

- {{ repo.starsNumber.toLocaleString() }} + {{ repo.stargazerCount.toLocaleString() }}
diff --git a/pages/insights.vue b/pages/insights.vue new file mode 100644 index 0000000..5cb5187 --- /dev/null +++ b/pages/insights.vue @@ -0,0 +1,73 @@ + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38c2f50..dcb57af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@pinia/nuxt': specifier: ^0.5.1 version: 0.5.1(rollup@4.17.2)(typescript@5.4.5)(vue@3.4.26(typescript@5.4.5)) + '@tsparticles/confetti': + specifier: ^3.3.0 + version: 3.3.0 node-emoji: specifier: ^2.1.3 version: 2.1.3 @@ -1029,6 +1032,75 @@ packages: resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} + '@tsparticles/basic@3.3.0': + resolution: {integrity: sha512-YB6+pFnkby6hnHhDqH2Q7+Y6Zcm7RAgZLQ8gkecHQxywD0RFItpYQfxpIf82mOTZ39NoeVdH6AF3mydgxVNAMQ==} + + '@tsparticles/confetti@3.3.0': + resolution: {integrity: sha512-LgV4h9h6X8kHSeeTWmAOt3dxq9/yhDe4guybYUPqus5WvMVqDGnx9WIdKzJhdFywPaqfR0OYDdHBsHAxdHK8bQ==} + + '@tsparticles/engine@3.3.0': + resolution: {integrity: sha512-Sr24epYquTelGrUbMaknXTscib8IMQJrbmShJnEemU+wpZNIPtAh09sQgGtq1pUxrGQRUSQIgaybYuXcNgk8rg==} + + '@tsparticles/move-base@3.3.0': + resolution: {integrity: sha512-yxfynO0CHWYlhyXy53/HzsN8zyD+v1RX0wT9X1Wry5lgnxhJoFTAP/Pk+srgyLOdaD0WwoRjB3yA/0f/haBWkg==} + + '@tsparticles/plugin-emitters@3.3.0': + resolution: {integrity: sha512-Ze2f47YBxjgfUHRKy0YdR+j6kGq7yrrdnbR5Ttq5d6vSjC13tFL+T14YM0tqZBr4QaZZB35UpKr3r2C4WVdDug==} + + '@tsparticles/plugin-motion@3.3.0': + resolution: {integrity: sha512-N3Q+FGXObu6n1ErhfnIHgltrR2zvSsXMPLjdkgqyg6l2i1oD3ybw57siGM/th0k7m7hvJ96HmDaJrY3sUuqX6Q==} + + '@tsparticles/shape-cards@3.3.0': + resolution: {integrity: sha512-WPiGYZJFfVaFjnzbTm5gg0HIVgy4qbwSKTjAaeVxf3J0oMiu8nFV7Ya9My2ondvDWrUeTpRem2Zzbb81yYnyZg==} + + '@tsparticles/shape-circle@3.3.0': + resolution: {integrity: sha512-m/T3SbZf8Zrn0m4Rd+8KTCMy54cofkaXa1Z7oikQYr/gPigT2C7Bo4vwQpiP8HKU+Xh5CEHFyc0s6ogfOaA2fA==} + + '@tsparticles/shape-emoji@3.3.0': + resolution: {integrity: sha512-F9tl3jUTMCRzbwhpKk3t1z2d+7vbyeAoHAEeG4UdUVorO0ovaqzj16KcpfSu2wyGkPSzUKIyHX8doB9MR8DfGw==} + + '@tsparticles/shape-heart@3.3.0': + resolution: {integrity: sha512-R47xiFP7xxbBKjYSQFcfoBxLkVZLNNhzzDFqKMz3feAUXQxLxoV8bPNVH6Sx2Xn4coxqK72lhi1tNGq3buWiTw==} + + '@tsparticles/shape-image@3.3.0': + resolution: {integrity: sha512-w5PHiDZjILIUEDIn10bFasY1qnSY9lwV0ekoTLGDepiS/EmyNJb0+D7gWOy/mhFlpK1637Ngbz1Axw3Zfl8ObA==} + + '@tsparticles/shape-polygon@3.3.0': + resolution: {integrity: sha512-JAHKIO8Pmzft0METZn6BqTHpn9PxWXxhAlPMG0XOEuziHpvMxmTXYbJNDFlmWu9RFpAjfIeGg8zUcS1xNuGWUQ==} + + '@tsparticles/shape-square@3.3.0': + resolution: {integrity: sha512-72tLkzQ5QkkhgIFy+qqdA+vmBk8VE4PuJcLJ12FVH8e3uPJDO2WiLJmnCg9MHyp26uU5CEuIalrQpZ0TX1A5PQ==} + + '@tsparticles/shape-star@3.3.0': + resolution: {integrity: sha512-RX9RLuJ9oWQbOVvVyiOBdPK8dc+RLc7DaqEOxGhMTPQeGvryjdkBU/FmiG4a7KaVyZeCI2diDW4oWEkulzZIIg==} + + '@tsparticles/updater-color@3.3.0': + resolution: {integrity: sha512-4ZggZr4jTXXOCLXqMXkH/jfhylf9wLt2G7D/F9ZZPihh8l8F2x0YM/JvzRePynhIFXfZaMD1PRfdXNTr6HnFFQ==} + + '@tsparticles/updater-life@3.3.0': + resolution: {integrity: sha512-6DDx4FfezLmXxetVx0kFZdWKWLIPa/ujFxHto0fFIVFtjLaffJPEONoOIj6/TmAlcJ+XF2jY2Md4z6vyS6bMlA==} + + '@tsparticles/updater-opacity@3.3.0': + resolution: {integrity: sha512-aQZJheqvoD69YYPiSlcRuWU7yEPs9dSmeOALP+fcaQwUQbVvr+wNJSUVkVNzeawtv4tPwou4QnytoWaBmZoqog==} + + '@tsparticles/updater-out-modes@3.3.0': + resolution: {integrity: sha512-G+UDZO6pmBUdSeT1Y7SRXvZz2EUw5RBCF8AOQMyLntehJQgLPc+PbMCFenfjpRQCCiWQ8RSumZ040iv5CLRUug==} + + '@tsparticles/updater-roll@3.3.0': + resolution: {integrity: sha512-z4UNZnC1/ZtXRZqMMITjqTxjs+qhggoL2W6c6AjvQ9TUyz+Ixg8tIrcPcwUu69496o9HJf6mJ4C3G9Pb9CMm7Q==} + + '@tsparticles/updater-rotate@3.3.0': + resolution: {integrity: sha512-4i3+0rbvzmaNGunlK443kURoEVFeAvCY5VGNX73y8S5g94RFejtGYBUUsC/LAcCxgfxa3HgYwNTT6ypslnoNuw==} + + '@tsparticles/updater-size@3.3.0': + resolution: {integrity: sha512-8s0dSh8bV4CN13oM86x1MPkI1T6KwuKPIiSdTcO1qKTcN1WBYzuuIPOU7Q3+fbQhSZ6F+da3zdG9unqU5sPYUA==} + + '@tsparticles/updater-tilt@3.3.0': + resolution: {integrity: sha512-ALcz+lsdaZn3pZkBRQssF55DVVQf0nX7rXCn6dhgIcwvpTSU1Fm5wYP2Q7cr6UDOu1lOAk5BHEQaG2EgHTanMQ==} + + '@tsparticles/updater-wobble@3.3.0': + resolution: {integrity: sha512-S9TpaGCWUnfFsk/ZVQVW+KrBG5mkTbbrj6lUx8OuhJmAft6v6zKVByhrMN8LIxYwr8S6mGmguOfFt/ZG2//xkw==} + '@tufjs/canonical-json@2.0.0': resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==} engines: {node: ^16.14.0 || >=18.0.0} @@ -5661,6 +5733,117 @@ snapshots: '@trysound/sax@0.2.0': {} + '@tsparticles/basic@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + '@tsparticles/move-base': 3.3.0 + '@tsparticles/shape-circle': 3.3.0 + '@tsparticles/updater-color': 3.3.0 + '@tsparticles/updater-opacity': 3.3.0 + '@tsparticles/updater-out-modes': 3.3.0 + '@tsparticles/updater-size': 3.3.0 + + '@tsparticles/confetti@3.3.0': + dependencies: + '@tsparticles/basic': 3.3.0 + '@tsparticles/engine': 3.3.0 + '@tsparticles/plugin-emitters': 3.3.0 + '@tsparticles/plugin-motion': 3.3.0 + '@tsparticles/shape-cards': 3.3.0 + '@tsparticles/shape-emoji': 3.3.0 + '@tsparticles/shape-heart': 3.3.0 + '@tsparticles/shape-image': 3.3.0 + '@tsparticles/shape-polygon': 3.3.0 + '@tsparticles/shape-square': 3.3.0 + '@tsparticles/shape-star': 3.3.0 + '@tsparticles/updater-life': 3.3.0 + '@tsparticles/updater-roll': 3.3.0 + '@tsparticles/updater-rotate': 3.3.0 + '@tsparticles/updater-tilt': 3.3.0 + '@tsparticles/updater-wobble': 3.3.0 + + '@tsparticles/engine@3.3.0': {} + + '@tsparticles/move-base@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + + '@tsparticles/plugin-emitters@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + + '@tsparticles/plugin-motion@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + + '@tsparticles/shape-cards@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + + '@tsparticles/shape-circle@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + + '@tsparticles/shape-emoji@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + + '@tsparticles/shape-heart@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + + '@tsparticles/shape-image@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + + '@tsparticles/shape-polygon@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + + '@tsparticles/shape-square@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + + '@tsparticles/shape-star@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + + '@tsparticles/updater-color@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + + '@tsparticles/updater-life@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + + '@tsparticles/updater-opacity@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + + '@tsparticles/updater-out-modes@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + + '@tsparticles/updater-roll@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + + '@tsparticles/updater-rotate@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + + '@tsparticles/updater-size@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + + '@tsparticles/updater-tilt@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + + '@tsparticles/updater-wobble@3.3.0': + dependencies: + '@tsparticles/engine': 3.3.0 + '@tufjs/canonical-json@2.0.0': {} '@tufjs/models@2.0.0': diff --git a/server/api/insights.ts b/server/api/insights.ts new file mode 100644 index 0000000..e342ea0 --- /dev/null +++ b/server/api/insights.ts @@ -0,0 +1,67 @@ +import type { Repository } from './repositories'; + +type NumericKeys = { + [K in keyof T]: T[K] extends number ? K : never; +}[keyof T]; + +type RepositoryNumbers = keyof Pick, undefined>>; + +export default defineEventHandler(async (event) => { + const { githubToken } = useRuntimeConfig(event); + const repositories = await $fetch('/api/repositories'); + + function findMost(key: RepositoryNumbers, invert = false): { name: string; value: number } { + const [repo] = repositories.sort((a, b) => (invert ? a[key] - b[key] : b[key] - a[key])); + return { name: `${repo.ownerName}/${repo.name}`, value: repo[key] }; + } + + async function getCountFromRestApi(type: 'commits' | 'contributors', repo: Repository) { + const searchParams = new URLSearchParams({ per_page: '1', page: '1' }); + if (type === 'contributors') { + searchParams.set('anon', '1'); + } + const response = await fetch( + `https://api.github.com/repos/${repo.ownerName}/${repo.name}/${type}?${searchParams.toString()}`, + { + headers: { + Accept: 'application/vnd.github.v4.idl', + Authorization: `Bearer ${githubToken}`, + }, + }, + ); + const link = response.headers.get('Link'); + const value = parseInt(link?.match(/page=(\d+)(?:&anon=1)?>; rel="last"/)?.[1] ?? '0'); + return { name: `${repo.ownerName}/${repo.name}`, value }; + } + + async function findMostFromRestApi(type: 'commits' | 'contributors') { + const reposWithCount = await Promise.all( + repositories.map((repo) => getCountFromRestApi(type, repo)), + ); + return reposWithCount.sort((a, b) => b.value - a.value)[0]; + } + + const mostForked = findMost('forkCount'); + const mostRecent = findMost('age', true); + const oldest = findMost('age'); + + const [mostCommits, mostContributors] = await Promise.all([ + findMostFromRestApi('commits'), + findMostFromRestApi('contributors'), + ]); + + const [mostUsedLanguage] = repositories + .reduce<{ name: string; value: number }[]>((previous, { language }) => { + if (!language) return previous; + const existing = previous.find((l) => l.name === language.name); + if (existing) { + existing.value++; + } else { + previous.push({ name: language.name, value: 1 }); + } + return previous; + }, []) + .sort((a, b) => b.value - a.value); + + return { mostForked, mostRecent, oldest, mostCommits, mostContributors, mostUsedLanguage }; +}); diff --git a/server/api/repositories.ts b/server/api/repositories.ts index 6356738..4b73af0 100644 --- a/server/api/repositories.ts +++ b/server/api/repositories.ts @@ -1,15 +1,16 @@ import { emojify } from 'node-emoji'; -type Repository = { +export type Repository = { rank: number; name: string; ownerName: string; image: string; description: string; - starsNumber: number; + stargazerCount: number; url: string; age: number; language?: { name: string; color: string }; + forkCount: number; }; export default defineEventHandler(async (event) => { @@ -32,9 +33,7 @@ export default defineEventHandler(async (event) => { description createdAt url - stargazers { - totalCount - } + stargazerCount owner { login avatarUrl @@ -45,6 +44,7 @@ export default defineEventHandler(async (event) => { color } } + forkCount } } } @@ -61,10 +61,11 @@ export default defineEventHandler(async (event) => { ownerName: node.owner.login, image: node.owner.avatarUrl, description: emojify(node.description), - starsNumber: node.stargazers.totalCount, + stargazerCount: node.stargazerCount, url: node.url, age: Date.now() - new Date(node.createdAt).getTime(), language: node.languages.nodes[0], + forkCount: node.forkCount, })); return result; });