├── .editorconfig ├── .gitignore ├── .npmrc ├── .prettierrc ├── LICENSE ├── README.md ├── app ├── app.config.ts ├── app.vue ├── auth.d.ts ├── components │ ├── AuthButton.vue │ ├── AuthContainer.vue │ ├── AuthModal.vue │ ├── RepoButton.vue │ ├── RepoList.vue │ ├── RepoTrafficChart.vue │ ├── TheFooter.vue │ ├── TheHeader.vue │ └── TrafficData │ │ └── Sidebar.vue ├── composables │ ├── useAuth.ts │ └── useTrafficData.ts ├── error.vue ├── layouts │ ├── dashboard.vue │ └── default.vue ├── middleware │ └── auth.ts ├── pages │ ├── index.vue │ ├── login.vue │ ├── privacy-policy.vue │ └── traffic-data.vue ├── siteMetadata.ts └── types │ └── types.ts ├── eslint.config.mjs ├── nuxt.config.ts ├── package.json ├── pnpm-lock.yaml ├── public └── favicon.ico ├── renovate.json ├── server ├── routes │ └── auth │ │ └── github.get.ts └── tsconfig.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 2 6 | indent_style = space 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | .history 21 | 22 | # Local env files 23 | .env 24 | .env.* 25 | !.env.example 26 | service-account.json 27 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "printWidth": 120 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Michael Hoffmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Traffic Viewer Website 2 | 3 | A website that shows a list of traffic graphs of your own GitHub repositories. 4 | -------------------------------------------------------------------------------- /app/app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | ui: { 3 | primary: 'cyan', 4 | gray: 'slate', 5 | }, 6 | }) 7 | -------------------------------------------------------------------------------- /app/app.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 17 | -------------------------------------------------------------------------------- /app/auth.d.ts: -------------------------------------------------------------------------------- 1 | declare module '#auth-utils' { 2 | interface User { 3 | // Add your own fields 4 | } 5 | 6 | interface UserSession { 7 | user: { 8 | githubId: number 9 | githubUsername: string 10 | githubAccessToken: string 11 | } 12 | } 13 | } 14 | 15 | export {} 16 | -------------------------------------------------------------------------------- /app/components/AuthButton.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 20 | -------------------------------------------------------------------------------- /app/components/AuthContainer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 17 | -------------------------------------------------------------------------------- /app/components/AuthModal.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /app/components/RepoButton.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | -------------------------------------------------------------------------------- /app/components/RepoList.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/components/RepoTrafficChart.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 60 | -------------------------------------------------------------------------------- /app/components/TheFooter.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 20 | -------------------------------------------------------------------------------- /app/components/TheHeader.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 24 | -------------------------------------------------------------------------------- /app/components/TrafficData/Sidebar.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 74 | -------------------------------------------------------------------------------- /app/composables/useAuth.ts: -------------------------------------------------------------------------------- 1 | const showAuthModal = ref(false) 2 | 3 | export const useAuth = () => { 4 | return { 5 | showAuthModal, 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/composables/useTrafficData.ts: -------------------------------------------------------------------------------- 1 | export const useTrafficData = () => { 2 | const { session } = useUserSession() 3 | const githubUserName = computed(() => session.value?.user?.githubUsername) 4 | const githubAccessToken = computed(() => session.value?.user?.githubAccessToken) 5 | 6 | const route = useRoute() 7 | const router = useRouter() 8 | 9 | const showForkedRepos = ref(false) 10 | const showPrivateRepos = ref(false) 11 | const trafficTimeFrame = ref<'day' | 'week'>('week') 12 | const selectedRepositoryId = ref(null) 13 | const searchRepositoryName = ref('') 14 | 15 | const { data, error, status } = useAsyncData( 16 | 'traffic-data', 17 | async () => { 18 | if (!githubUserName.value) { 19 | return 20 | } 21 | 22 | if (!githubAccessToken.value) { 23 | throw new Error('No GitHub access token found. Please try to login again.') 24 | } 25 | 26 | const headers = { 27 | Accept: 'application/vnd.github+json', 28 | 'X-GitHub-Api-Version': '2022-11-28', 29 | Authorization: `Bearer ${githubAccessToken.value}`, 30 | } 31 | 32 | const REPOS_PER_PAGE = 100 33 | const NEXT_PATTERN = /(?<=<)([\S]*)(?=>; rel="Next")/i 34 | let pagesRemaining = true 35 | let repos: Array = [] 36 | 37 | // https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user 38 | let url = `https://api.github.com/user/repos?per_page=${REPOS_PER_PAGE}&page=1` 39 | 40 | while (pagesRemaining) { 41 | const reposResponse = await $fetch.raw(url, { headers }) 42 | 43 | repos = [...repos, ...(reposResponse._data as Array)] 44 | 45 | const linkHeader = reposResponse.headers.get('link') 46 | 47 | pagesRemaining = linkHeader && linkHeader.includes('rel="next"') 48 | 49 | if (pagesRemaining) { 50 | url = linkHeader.match(NEXT_PATTERN)[0] 51 | } 52 | } 53 | 54 | const mappedRepos: Array> = repos.map( 55 | (repo) => ({ 56 | name: repo.name, 57 | isFork: repo.fork, 58 | isPrivate: repo.private, 59 | id: repo.id, 60 | }), 61 | ) 62 | 63 | const results = await Promise.allSettled( 64 | mappedRepos.map(async (mappedRepo) => { 65 | // https://docs.github.com/en/rest/metrics/traffic?apiVersion=2022-11-28#get-page-views 66 | const trafficData = await $fetch( 67 | `https://api.github.com/repos/${githubUserName.value}/${mappedRepo.name}/traffic/views?per=${trafficTimeFrame.value}`, 68 | { 69 | headers, 70 | }, 71 | ) 72 | return { 73 | ...mappedRepo, 74 | trafficData, 75 | } 76 | }), 77 | ) 78 | 79 | return results 80 | .filter((result) => result.status === 'fulfilled') 81 | .map((result) => result.value as RepositoryViewModel) 82 | }, 83 | { watch: [githubUserName, githubAccessToken, trafficTimeFrame] }, 84 | ) 85 | 86 | const isLoadingTrafficData = computed(() => status.value === 'pending') 87 | 88 | const selectedRepositoryData = computed(() => { 89 | if (!filteredRepositoriesData.value) { 90 | return undefined 91 | } 92 | 93 | return filteredRepositoriesData.value.find((repo) => repo.id === selectedRepositoryId.value) 94 | }) 95 | 96 | const filteredRepositoriesData = computed(() => { 97 | if (!data.value) { 98 | return [] 99 | } 100 | 101 | return data.value.filter((repo) => { 102 | if ( 103 | searchRepositoryName.value !== '' && 104 | !repo.name.toLowerCase().includes(searchRepositoryName.value.toLowerCase()) 105 | ) { 106 | return false 107 | } 108 | 109 | if (!showForkedRepos.value && repo.isFork) { 110 | return false 111 | } 112 | 113 | if (!showPrivateRepos.value && repo.isPrivate) { 114 | return false 115 | } 116 | 117 | return true 118 | }) 119 | }) 120 | 121 | watch(data, () => { 122 | if (data.value && data.value[0] && !selectedRepositoryId.value) { 123 | selectedRepositoryId.value = data.value[0].id 124 | } 125 | }) 126 | 127 | watch( 128 | selectedRepositoryId, 129 | (newSelectedRepositoryId) => { 130 | router.push({ 131 | query: { 132 | ...route.query, 133 | selectedRepositoryId: newSelectedRepositoryId, 134 | }, 135 | }) 136 | }, 137 | { immediate: true }, 138 | ) 139 | 140 | watch( 141 | showForkedRepos, 142 | (newShowForkedRepos) => { 143 | router.push({ 144 | query: { 145 | ...route.query, 146 | showForkedRepos: newShowForkedRepos ? 'true' : undefined, 147 | }, 148 | }) 149 | }, 150 | { immediate: true }, 151 | ) 152 | 153 | watch( 154 | showPrivateRepos, 155 | (newShowPrivateRepos) => { 156 | router.push({ 157 | query: { 158 | ...route.query, 159 | showPrivateRepos: newShowPrivateRepos ? 'true' : undefined, 160 | }, 161 | }) 162 | }, 163 | { immediate: true }, 164 | ) 165 | 166 | watch( 167 | searchRepositoryName, 168 | (newSearchName) => { 169 | router.push({ 170 | query: { 171 | ...route.query, 172 | q: newSearchName || '', 173 | }, 174 | }) 175 | }, 176 | { immediate: true }, 177 | ) 178 | 179 | watch( 180 | trafficTimeFrame, 181 | (newTrafficTimeFrame) => { 182 | router.push({ 183 | query: { 184 | ...route.query, 185 | trafficTimeFrame: newTrafficTimeFrame, 186 | }, 187 | }) 188 | }, 189 | { immediate: true }, 190 | ) 191 | 192 | onBeforeMount(() => { 193 | if (route.query.selectedRepositoryId) { 194 | selectedRepositoryId.value = Number(route.query.selectedRepositoryId) 195 | } 196 | if (route.query.showForkedRepos) { 197 | showForkedRepos.value = route.query.showForkedRepos === 'true' 198 | } 199 | if (route.query.showPrivateRepos) { 200 | showPrivateRepos.value = route.query.showPrivateRepos === 'true' 201 | } 202 | if (route.query.trafficTimeFrame) { 203 | trafficTimeFrame.value = route.query.trafficTimeFrame as 'day' | 'week' 204 | } 205 | if (route.query.q) { 206 | searchRepositoryName.value = route.query.q as string 207 | } 208 | }) 209 | 210 | return { 211 | showForkedRepos, 212 | showPrivateRepos, 213 | searchRepositoryName, 214 | trafficTimeFrame, 215 | selectedRepositoryData, 216 | repositories: filteredRepositoriesData, 217 | isLoadingTrafficData, 218 | error, 219 | selectedRepositoryId, 220 | githubUserName, 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /app/error.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 24 | -------------------------------------------------------------------------------- /app/layouts/dashboard.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /app/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /app/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(async () => { 2 | const { loggedIn } = useUserSession() 3 | 4 | if (!loggedIn.value) { 5 | return navigateTo('/login') 6 | } 7 | }) 8 | -------------------------------------------------------------------------------- /app/pages/index.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 28 | -------------------------------------------------------------------------------- /app/pages/login.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 20 | ~/siteMetadata 21 | -------------------------------------------------------------------------------- /app/pages/privacy-policy.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 213 | ~/siteMetadata 214 | -------------------------------------------------------------------------------- /app/pages/traffic-data.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 92 | ~/siteMetadata 93 | -------------------------------------------------------------------------------- /app/siteMetadata.ts: -------------------------------------------------------------------------------- 1 | const siteMetadata = { 2 | projectName: 'GitHub Traffic Viewer', 3 | domain: 'github-traffic-viewer.netlify.app/', 4 | url: 'https://github-traffic-viewer.netlify.app', 5 | repoUrl: 'https://github.com/Mokkapps/github-traffic-viewer-website', 6 | description: 'Instant analytics for views of your repositories empowering you to optimize them effortlessly.', 7 | contactEmail: 'mail@mokkapps.de', 8 | } 9 | 10 | export default siteMetadata 11 | -------------------------------------------------------------------------------- /app/types/types.ts: -------------------------------------------------------------------------------- 1 | export interface TrafficData { 2 | count: number 3 | uniques: number 4 | views: Array<{ timestamp: string; count: number; uniques: number }> 5 | } 6 | 7 | export interface GithubRepositoryDTO { 8 | fork: boolean 9 | name: string 10 | private: boolean 11 | id: number 12 | } 13 | 14 | export interface RepositoryViewModel { 15 | name: string 16 | isFork: boolean 17 | isPrivate: boolean 18 | id: number 19 | trafficData: TrafficData 20 | } 21 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import withNuxt from './.nuxt/eslint.config.mjs' 2 | 3 | export default withNuxt( 4 | { 5 | ignores: ['dist', '.nuxt', 'node_modules'], 6 | }, 7 | { 8 | files: ['**/*.vue'], 9 | rules: { 10 | 'vue/multi-word-component-names': 0, 11 | 'vue/max-attributes-per-line': 'off', 12 | 'vue/no-v-html': 0, 13 | }, 14 | }, 15 | ) 16 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import siteMetadata from './app/siteMetadata' 2 | 3 | // https://nuxt.com/docs/api/configuration/nuxt-config 4 | export default defineNuxtConfig({ 5 | extends: ['@nuxt/ui-pro'], 6 | 7 | compatibilityDate: '2024-08-12', 8 | 9 | future: { 10 | compatibilityVersion: 4, 11 | }, 12 | 13 | devtools: { 14 | enabled: true, 15 | }, 16 | 17 | modules: ['@nuxt/ui', '@nuxtjs/seo', '@nuxt/eslint', 'nuxt-auth-utils', 'nuxt-umami'], 18 | 19 | app: { 20 | head: { 21 | noscript: [{ textContent: 'Javascript is required' }], 22 | }, 23 | }, 24 | 25 | devServer: { 26 | port: 4004, 27 | }, 28 | 29 | imports: { 30 | dirs: ['./types/**'], 31 | }, 32 | 33 | routeRules: { 34 | '/traffic-data': { ssr: false }, 35 | }, 36 | 37 | site: { 38 | url: 'https://github-traffic-viewer.netlify.app', 39 | name: siteMetadata.projectName, 40 | description: siteMetadata.description, 41 | defaultLocale: 'en', 42 | }, 43 | 44 | umami: { 45 | ignoreLocalhost: true, 46 | proxy: 'cloak', 47 | logErrors: true, 48 | }, 49 | }) 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-traffic-viewer-website", 3 | "private": true, 4 | "description": "A website that shows a list of traffic graphs of your own GitHub repositories.", 5 | "keywords": [ 6 | "nuxt", 7 | "github", 8 | "traffic" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/Mokkapps/github-traffic-viewer-website" 13 | }, 14 | "license": "MIT", 15 | "author": "Michael Hoffmann ", 16 | "type": "module", 17 | "scripts": { 18 | "build": "nuxt build", 19 | "dev": "nuxt dev", 20 | "generate": "nuxt generate", 21 | "postinstall": "nuxt prepare", 22 | "lint": "eslint .", 23 | "preview": "nuxt preview", 24 | "typecheck": "nuxt typecheck" 25 | }, 26 | "devDependencies": { 27 | "@iconify-json/heroicons": "^1.2.0", 28 | "@iconify-json/simple-icons": "^1.2.2", 29 | "@nuxt/eslint": "^0.6.0", 30 | "@nuxt/eslint-config": "^0.5.7", 31 | "@nuxt/ui-pro": "^1.4.4", 32 | "@nuxtjs/seo": "2.0.0-rc.21", 33 | "chart.js": "^4.4.4", 34 | "eslint": "^9.10.0", 35 | "nuxt": "^3.13.1", 36 | "nuxt-auth-utils": "^0.3.8", 37 | "nuxt-umami": "^3.0.1", 38 | "prettier": "^3.3.3", 39 | "vue": "3.5.4", 40 | "vue-chartjs": "^5.3.1", 41 | "vue-tsc": "^2.1.6" 42 | }, 43 | "dependencies": { 44 | "@aws-sdk/client-ses": "3.675.0", 45 | "@iconify-json/ri": "1.2.1", 46 | "@nuxtjs/supabase": "1.4.1", 47 | "@playwright/test": "1.48.1", 48 | "@types/node": "22.7.8", 49 | "sass": "1.80.3", 50 | "supabase": "1.207.8", 51 | "tailwindcss": "3.4.14", 52 | "twitter-api-v2": "1.18.0", 53 | "typescript": "5.6.3" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mokkapps/github-traffic-viewer-website/741cad868fb7b9d5f6b53c925fce0521ae762846/public/favicon.ico -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@nuxtjs" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /server/routes/auth/github.get.ts: -------------------------------------------------------------------------------- 1 | export default oauthGitHubEventHandler({ 2 | config: { 3 | scope: ['repo', 'metadata:read', 'administration:read'], 4 | }, 5 | async onSuccess(event, { user, tokens }) { 6 | await setUserSession(event, { 7 | user: { 8 | githubId: user.id, 9 | githubUsername: user.login, 10 | githubAccessToken: tokens.access_token, 11 | }, 12 | }) 13 | return sendRedirect(event, '/traffic-data') 14 | }, 15 | // Optional, will return a json error and 401 status code by default 16 | onError(event, error) { 17 | console.error('GitHub OAuth error:', error) 18 | return sendRedirect(event, '/') 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | --------------------------------------------------------------------------------