Refactor source paths
This commit is contained in:
5
source/app/web/index.mjs
Normal file
5
source/app/web/index.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
//Imports
|
||||
import app from "./instance.mjs"
|
||||
|
||||
//Start app
|
||||
await app({mock:process.env.USE_MOCKED_DATA})
|
||||
189
source/app/web/instance.mjs
Normal file
189
source/app/web/instance.mjs
Normal file
@@ -0,0 +1,189 @@
|
||||
//Imports
|
||||
import octokit from "@octokit/graphql"
|
||||
import OctokitRest from "@octokit/rest"
|
||||
import express from "express"
|
||||
import ratelimit from "express-rate-limit"
|
||||
import compression from "compression"
|
||||
import cache from "memory-cache"
|
||||
import util from "util"
|
||||
import setup from "../setup.mjs"
|
||||
import mocks from "../mocks.mjs"
|
||||
import metrics from "../metrics.mjs"
|
||||
|
||||
/** App */
|
||||
export default async function ({mock = false} = {}) {
|
||||
|
||||
//Load configuration settings
|
||||
const {conf, Plugins, Templates} = await setup()
|
||||
const {token, maxusers = 0, restricted = [], debug = false, cached = 30*60*1000, port = 3000, ratelimiter = null, plugins = null} = conf.settings
|
||||
|
||||
//Apply configuration mocking if needed
|
||||
if (mock) {
|
||||
console.debug(`metrics/app > using mocked settings`)
|
||||
const {settings} = conf
|
||||
//Mock token if it's undefined
|
||||
if (!settings.token)
|
||||
settings.token = (console.debug(`metrics/app > using mocked token`), "MOCKED_TOKEN")
|
||||
//Mock plugins state and tokens if they're undefined
|
||||
for (const plugin of Object.keys(Plugins)) {
|
||||
if (!settings.plugins[plugin])
|
||||
settings.plugins[plugin] = {}
|
||||
settings.plugins[plugin].enabled = settings.plugins[plugin].enabled ?? (console.debug(`metrics/app > using mocked token enable state for ${plugin}`), true)
|
||||
if (["tweets", "pagespeed"].includes(plugin))
|
||||
settings.plugins[plugin].token = settings.plugins[plugin].token ?? (console.debug(`metrics/app > using mocked token for ${plugin}`), "MOCKED_TOKEN")
|
||||
if (["music"].includes(plugin))
|
||||
settings.plugins[plugin].token = settings.plugins[plugin].token ?? (console.debug(`metrics/app > using mocked token for ${plugin}`), "MOCKED_CLIENT_ID, MOCKED_CLIENT_SECRET, MOCKED_REFRESH_TOKEN")
|
||||
}
|
||||
console.debug(util.inspect(settings, {depth:Infinity, maxStringLength:256}))
|
||||
}
|
||||
|
||||
//Load octokits
|
||||
const api = {graphql:octokit.graphql.defaults({headers:{authorization: `token ${token}`}}), rest:new OctokitRest.Octokit({auth:token})}
|
||||
//Apply mocking if needed
|
||||
if (mock)
|
||||
Object.assign(api, await mocks(api))
|
||||
const {graphql, rest} = api
|
||||
|
||||
//Setup server
|
||||
const app = express()
|
||||
app.use(compression())
|
||||
const middlewares = []
|
||||
//Rate limiter middleware
|
||||
if (ratelimiter) {
|
||||
app.set("trust proxy", 1)
|
||||
middlewares.push(ratelimit({
|
||||
skip(req, res) { return !!cache.get(req.params.login) },
|
||||
message:"Too many requests",
|
||||
...ratelimiter
|
||||
}))
|
||||
}
|
||||
//Cache headers middleware
|
||||
middlewares.push((req, res, next) => {
|
||||
if (!["/placeholder"].includes(req.path))
|
||||
res.header("Cache-Control", cached ? `public, max-age=${cached}` : "no-store, no-cache")
|
||||
next()
|
||||
})
|
||||
|
||||
//Base routes
|
||||
const limiter = ratelimit({max:debug ? Number.MAX_SAFE_INTEGER : 60, windowMs:60*1000})
|
||||
const templates = [...new Set([conf.settings.templates.default, ...(conf.settings.templates.enabled.length ? Object.keys(Templates).filter(key => conf.settings.templates.enabled.includes(key)) : Object.keys(Templates))])]
|
||||
const enabled = Object.entries(plugins).filter(([key, plugin]) => plugin.enabled).map(([key]) => key)
|
||||
const actions = {flush:new Map()}
|
||||
app.get("/", limiter, (req, res) => res.sendFile(`${conf.statics}/index.html`))
|
||||
app.get("/index.html", limiter, (req, res) => res.sendFile(`${conf.statics}/index.html`))
|
||||
app.get("/favicon.ico", limiter, (req, res) => res.sendStatus(204))
|
||||
app.get("/.version", limiter, (req, res) => res.status(200).send(conf.package.version))
|
||||
app.get("/.requests", limiter, async (req, res) => res.status(200).json((await rest.rateLimit.get()).data.rate))
|
||||
app.get("/.templates", limiter, (req, res) => res.status(200).json(templates))
|
||||
app.get("/.plugins", limiter, (req, res) => res.status(200).json(enabled))
|
||||
app.get("/.plugins.base", limiter, (req, res) => res.status(200).json(conf.settings.plugins.base.parts))
|
||||
app.get("/.css/style.css", limiter, (req, res) => res.sendFile(`${conf.statics}/style.css`))
|
||||
app.get("/.css/style.prism.css", limiter, (req, res) => res.sendFile(`${conf.node_modules}/prismjs/themes/prism-tomorrow.css`))
|
||||
app.get("/.js/app.js", limiter, (req, res) => res.sendFile(`${conf.statics}/app.js`))
|
||||
app.get("/.js/ejs.min.js", limiter, (req, res) => res.sendFile(`${conf.node_modules}/ejs/ejs.min.js`))
|
||||
app.get("/.js/axios.min.js", limiter, (req, res) => res.sendFile(`${conf.node_modules}/axios/dist/axios.min.js`))
|
||||
app.get("/.js/axios.min.map", limiter, (req, res) => res.sendFile(`${conf.node_modules}/axios/dist/axios.min.map`))
|
||||
app.get("/.js/vue.min.js", limiter, (req, res) => res.sendFile(`${conf.node_modules}/vue/dist/vue.min.js`))
|
||||
app.get("/.js/vue.prism.min.js", limiter, (req, res) => res.sendFile(`${conf.node_modules}/vue-prism-component/dist/vue-prism-component.min.js`))
|
||||
app.get("/.js/vue-prism-component.min.js.map", limiter, (req, res) => res.sendFile(`${conf.node_modules}/vue-prism-component/dist/vue-prism-component.min.js.map`))
|
||||
app.get("/.js/prism.min.js", limiter, (req, res) => res.sendFile(`${conf.node_modules}/prismjs/prism.js`))
|
||||
app.get("/.js/prism.yaml.min.js", limiter, (req, res) => res.sendFile(`${conf.node_modules}/prismjs/components/prism-yaml.min.js`))
|
||||
app.get("/.js/prism.markdown.min.js", limiter, (req, res) => res.sendFile(`${conf.node_modules}/prismjs/components/prism-markdown.min.js`))
|
||||
app.get("/.uncache", limiter, async (req, res) => {
|
||||
const {token, user} = req.query
|
||||
if (token) {
|
||||
if (actions.flush.has(token)) {
|
||||
console.debug(`metrics/app/${actions.flush.get(token)} > flushed cache`)
|
||||
cache.del(actions.flush.get(token))
|
||||
return res.sendStatus(200)
|
||||
}
|
||||
else
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
else {
|
||||
const token = `${Math.random().toString(16).replace("0.", "")}${Math.random().toString(16).replace("0.", "")}`
|
||||
actions.flush.set(token, user)
|
||||
return res.json({token})
|
||||
}
|
||||
})
|
||||
|
||||
//Metrics
|
||||
app.get("/:login", ...middlewares, async (req, res) => {
|
||||
|
||||
//Request params
|
||||
const {login} = req.params
|
||||
if ((restricted.length)&&(!restricted.includes(login))) {
|
||||
console.debug(`metrics/app/${login} > 403 (not in whitelisted users)`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
//Read cached data if possible
|
||||
if ((!debug)&&(cached)&&(cache.get(login))) {
|
||||
res.header("Content-Type", "image/svg+xml")
|
||||
res.send(cache.get(login))
|
||||
return
|
||||
}
|
||||
//Maximum simultaneous users
|
||||
if ((maxusers)&&(cache.size()+1 > maxusers)) {
|
||||
console.debug(`metrics/app/${login} > 503 (maximum users reached)`)
|
||||
return res.sendStatus(503)
|
||||
}
|
||||
|
||||
//Compute rendering
|
||||
try {
|
||||
//Render
|
||||
console.debug(`metrics/app/${login} > ${util.inspect(req.query, {depth:Infinity, maxStringLength:256})}`)
|
||||
const q = parse(req.query)
|
||||
const rendered = await metrics({login, q}, {graphql, rest, plugins, conf, die:q["plugins.errors.fatal"] ?? false, verify:q["verify"] ?? false}, {Plugins, Templates})
|
||||
//Cache
|
||||
if ((!debug)&&(cached)&&(login !== "placeholder"))
|
||||
cache.put(login, rendered, cached)
|
||||
//Send response
|
||||
res.header("Content-Type", "image/svg+xml")
|
||||
res.send(rendered)
|
||||
}
|
||||
//Internal error
|
||||
catch (error) {
|
||||
//Not found user
|
||||
if ((error instanceof Error)&&(/^user not found$/.test(error.message))) {
|
||||
console.debug(`metrics/app/${login} > 404 (user not found)`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
//Invalid template
|
||||
if ((error instanceof Error)&&(/^unsupported template$/.test(error.message))) {
|
||||
console.debug(`metrics/app/${login} > 400 (bad request)`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
//General error
|
||||
console.error(error)
|
||||
res.sendStatus(500)
|
||||
}
|
||||
})
|
||||
|
||||
//Listen
|
||||
app.listen(port, () => console.log([
|
||||
`Listening on port │ ${port}`,
|
||||
`Debug mode │ ${debug}`,
|
||||
`Restricted to users │ ${restricted.size ? [...restricted].join(", ") : "(unrestricted)"}`,
|
||||
`Cached time │ ${cached} seconds`,
|
||||
`Rate limiter │ ${ratelimiter ? util.inspect(ratelimiter, {depth:Infinity, maxStringLength:256}) : "(enabled)"}`,
|
||||
`Max simultaneous users │ ${maxusers ? `${maxusers} users` : "(unrestricted)"}`,
|
||||
`Plugins enabled │ ${enabled.join(", ")}`,
|
||||
`Server ready !`
|
||||
].join("\n")))
|
||||
}
|
||||
|
||||
/** Query parser */
|
||||
function parse(query) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
//Parse number
|
||||
if (/^\d+$/.test(value))
|
||||
query[key] = Number(value)
|
||||
//Parse boolean
|
||||
if (/^(?:true|false)$/.test(value))
|
||||
query[key] = value === "true"
|
||||
//Parse null
|
||||
if (/^null$/.test(value))
|
||||
query[key] = null
|
||||
}
|
||||
return query
|
||||
}
|
||||
197
source/app/web/statics/app.js
Normal file
197
source/app/web/statics/app.js
Normal file
@@ -0,0 +1,197 @@
|
||||
;(async function() {
|
||||
//Init
|
||||
const url = new URLSearchParams(window.location.search)
|
||||
const {data:templates} = await axios.get("/.templates")
|
||||
const {data:plugins} = await axios.get("/.plugins")
|
||||
const {data:base} = await axios.get("/.plugins.base")
|
||||
const {data:version} = await axios.get("/.version")
|
||||
//App
|
||||
return new Vue({
|
||||
//Initialization
|
||||
el:"main",
|
||||
async mounted() {
|
||||
//Load instance
|
||||
await this.load()
|
||||
//Interpolate config from browser
|
||||
try {
|
||||
this.config.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
} catch (error) {}
|
||||
},
|
||||
components:{Prism:PrismComponent},
|
||||
//Data initialization
|
||||
data:{
|
||||
version,
|
||||
user:url.get("user") || "",
|
||||
palette:url.get("palette") || (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") || "light",
|
||||
requests:{limit:0, used:0, remaining:0, reset:0},
|
||||
config:{
|
||||
timezone:"",
|
||||
},
|
||||
plugins:{
|
||||
base,
|
||||
list:plugins,
|
||||
enabled:{base:Object.fromEntries(base.map(key => [key, true]))},
|
||||
descriptions:{
|
||||
pagespeed:"Website performances",
|
||||
languages:"Most used languages",
|
||||
followup:"Issues and pull requests",
|
||||
traffic:"Pages views",
|
||||
lines:"Lines of code changed",
|
||||
habits:"Coding habits",
|
||||
music:"Music plugin",
|
||||
posts:"Recent posts",
|
||||
isocalendar:"Isometric commit calendar",
|
||||
gists:"Gists metrics",
|
||||
topics:"Starred topics",
|
||||
projects:"Projects",
|
||||
tweets:"Latest tweets",
|
||||
"base.header":"Header",
|
||||
"base.activity":"Account activity",
|
||||
"base.community":"Community stats",
|
||||
"base.repositories":"Repositories metrics",
|
||||
"base.metadata":"Metadata",
|
||||
},
|
||||
options:{
|
||||
"languages.ignored":"",
|
||||
"languages.skipped":"",
|
||||
"pagespeed.detailed":false,
|
||||
"pagespeed.screenshot":false,
|
||||
"habits.from":200,
|
||||
"habits.days":14,
|
||||
"habits.facts":true,
|
||||
"habits.charts":false,
|
||||
"music.playlist":"",
|
||||
"music.limit":4,
|
||||
"posts.limit":4,
|
||||
"posts.source":"dev.to",
|
||||
"isocalendar.duration":"half-year",
|
||||
"projects.limit":4,
|
||||
"projects.repositories":"",
|
||||
"topics.mode":"starred",
|
||||
"topics.sort":"stars",
|
||||
"topics.limit":12,
|
||||
"tweets.limit":2,
|
||||
},
|
||||
},
|
||||
templates:{
|
||||
list:templates,
|
||||
selected:url.get("template") || templates[0],
|
||||
loaded:{},
|
||||
placeholder:"",
|
||||
descriptions:{
|
||||
classic:"Classic template",
|
||||
terminal:"Terminal template",
|
||||
repository:"(hidden)",
|
||||
},
|
||||
},
|
||||
generated:{
|
||||
pending:false,
|
||||
content:"",
|
||||
error:false,
|
||||
},
|
||||
},
|
||||
//Computed data
|
||||
computed:{
|
||||
//User's repository
|
||||
repo() {
|
||||
return `https://github.com/${this.user}/${this.user}`
|
||||
},
|
||||
//Endpoint to use for computed metrics
|
||||
url() {
|
||||
//Plugins enabled
|
||||
const plugins = Object.entries(this.plugins.enabled)
|
||||
.flatMap(([key, value]) => key === "base" ? Object.entries(value).map(([key, value]) => [`base.${key}`, value]) : [[key, value]])
|
||||
.filter(([key, value]) => /^base[.]\w+$/.test(key) ? !value : value)
|
||||
.map(([key, value]) => `${key}=${+value}`)
|
||||
//Plugins options
|
||||
const options = Object.entries(this.plugins.options)
|
||||
.filter(([key, value]) => `${value}`.length)
|
||||
.filter(([key, value]) => this.plugins.enabled[key.split(".")[0]])
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
||||
//Config
|
||||
const config = Object.entries(this.config).filter(([key, value]) => value).map(([key, value]) => `config.${key}=${encodeURIComponent(value)}`)
|
||||
//Template
|
||||
const template = (this.templates.selected !== templates[0]) ? [`template=${this.templates.selected}`] : []
|
||||
//Generated url
|
||||
const params = [...template, ...plugins, ...options, ...config].join("&")
|
||||
return `${window.location.protocol}//${window.location.host}/${this.user}${params.length ? `?${params}` : ""}`
|
||||
},
|
||||
//Embedded generated code
|
||||
embed() {
|
||||
return ``
|
||||
},
|
||||
//GitHub action auto-generated code
|
||||
action() {
|
||||
return [
|
||||
`# Visit https://github.com/lowlighter/metrics/blob/master/action.yml for full reference`,
|
||||
`name: GitHub metrics`,
|
||||
`on:`,
|
||||
` # Schedule updates`,
|
||||
` schedule: [{cron: "0 * * * *"}]`,
|
||||
` push: {branches: "master"}`,
|
||||
`jobs:`,
|
||||
` github-metrics:`,
|
||||
` runs-on: ubuntu-latest`,
|
||||
` steps:`,
|
||||
` - uses: lowlighter/metrics@latest`,
|
||||
` with:`,
|
||||
` # You'll need to setup a personal token in your secrets.`,
|
||||
` token: ${"$"}{{ secrets.METRICS_TOKEN }}`,
|
||||
` # GITHUB_TOKEN is a special auto-generated token used for commits`,
|
||||
` committer_token: ${"$"}{{ secrets.GITHUB_TOKEN }}`,
|
||||
``,
|
||||
` # Options`,
|
||||
` user: ${this.user }`,
|
||||
` template: ${this.templates.selected}`,
|
||||
` base: ${Object.entries(this.plugins.enabled.base).filter(([key, value]) => value).map(([key]) => key).join(", ")||'""'}`,
|
||||
...[
|
||||
...Object.entries(this.plugins.enabled).filter(([key, value]) => (key !== "base")&&(value)).map(([key]) => ` plugin_${key}: yes`),
|
||||
...Object.entries(this.plugins.options).filter(([key, value]) => value).filter(([key, value]) => this.plugins.enabled[key.split(".")[0]]).map(([key, value]) => ` plugin_${key.replace(/[.]/, "_")}: ${typeof value === "boolean" ? {true:"yes", false:"no"}[value] : value}`),
|
||||
...Object.entries(this.config).filter(([key, value]) => value).map(([key, value]) => ` config_${key.replace(/[.]/, "_")}: ${typeof value === "boolean" ? {true:"yes", false:"no"}[value] : value}`),
|
||||
].sort(),
|
||||
].join("\n")
|
||||
}
|
||||
},
|
||||
//Methods
|
||||
methods:{
|
||||
//Load and render image
|
||||
async load() {
|
||||
//Render placeholder
|
||||
const url = this.url.replace(new RegExp(`${this.user}(\\?|$)`), "placeholder$1")
|
||||
this.templates.placeholder = this.serialize((await axios.get(url)).data)
|
||||
this.generated.content = ""
|
||||
//Start GitHub rate limiter tracker
|
||||
this.ghlimit()
|
||||
},
|
||||
//Generate metrics and flush cache
|
||||
async generate() {
|
||||
//Avoid requests spamming
|
||||
if (this.generated.pending)
|
||||
return
|
||||
this.generated.pending = true
|
||||
//Compute metrics
|
||||
try {
|
||||
await axios.get(`/.uncache?&token=${(await axios.get(`/.uncache?user=${this.user}`)).data.token}`)
|
||||
this.generated.content = this.serialize((await axios.get(this.url)).data)
|
||||
} catch {
|
||||
this.generated.error = true
|
||||
}
|
||||
finally {
|
||||
this.generated.pending = false
|
||||
}
|
||||
this.ghlimit({once:true})
|
||||
},
|
||||
//Serialize svg
|
||||
serialize(svg) {
|
||||
return `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svg)))}`
|
||||
},
|
||||
//Update reate limit requests
|
||||
async ghlimit({once = false} = {}) {
|
||||
const {data:requests} = await axios.get("/.requests")
|
||||
this.requests = requests
|
||||
if (!once)
|
||||
setTimeout(() => this.ghlimit(), 30*1000)
|
||||
}
|
||||
},
|
||||
})
|
||||
})()
|
||||
246
source/app/web/statics/index.html
Normal file
246
source/app/web/statics/index.html
Normal file
@@ -0,0 +1,246 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>📊 GitHub metrics</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="A SVG image generator which includes activity, community and repositories metrics about your GitHub account that you can includes on your profile">
|
||||
<meta name="author" content="lowlighter">
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="/.css/style.css">
|
||||
<link rel="stylesheet" href="/.css/style.prism.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Vue app -->
|
||||
<main :class="[palette]">
|
||||
<!-- Title -->
|
||||
<template>
|
||||
<h1><a href="https://github.com/lowlighter/metrics">Metrics v{{ version }}</a></h1>
|
||||
</template>
|
||||
<!-- Content -->
|
||||
<template>
|
||||
<section class="generator">
|
||||
<!-- Steps panel -->
|
||||
<section class="steps">
|
||||
<div class="step">
|
||||
<h2>1. Enter your GitHub username</h2>
|
||||
<input type="text" name="user" v-model="user" maxlength="39" placeholder="GitHub username" :disabled="generated.pending">
|
||||
</div>
|
||||
<div class="step">
|
||||
<h2>2. Select a template</h2>
|
||||
<div class="templates">
|
||||
<label v-for="template in templates.list" :key="template" v-show="templates.descriptions[template] !== '(hidden)'">
|
||||
<input type="radio" v-model="templates.selected" :value="template" @change="load" :disabled="generated.pending">
|
||||
{{ templates.descriptions[template] || template }}
|
||||
</label>
|
||||
</div>
|
||||
<template v-if="plugins.base.length">
|
||||
<h3>2.1 Configure base content</h3>
|
||||
<div class="plugins">
|
||||
<label v-for="part in plugins.base" :key="part">
|
||||
<input type="checkbox" v-model="plugins.enabled.base[part]" @change="load" :disabled="generated.pending">
|
||||
{{ plugins.descriptions[`base.${part}`] || `base.${part}` }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="plugins.list.length">
|
||||
<h3>2.2 Enable additional plugins</h3>
|
||||
<div class="plugins">
|
||||
<label v-for="plugin in plugins.list" :key="plugin">
|
||||
<input type="checkbox" v-model="plugins.enabled[plugin]" @change="load" :disabled="generated.pending">
|
||||
{{ plugins.descriptions[plugin] || plugin }}
|
||||
</label>
|
||||
</div>
|
||||
<i>*Additional plugins may be available when used as GitHub Action</i>
|
||||
<template v-if="(plugins.enabled.tweets)||(plugins.enabled.music)||(plugins.enabled.pagespeed)||(plugins.enabled.languages)||(plugins.enabled.habits)||(plugins.enabled.posts)||(plugins.enabled.isocalendar)||(plugins.enabled.projects)||(plugins.enabled.topics)">
|
||||
<h3>2.3 Configure additional plugins</h3>
|
||||
<div class="options">
|
||||
<div class="options-group" v-if="plugins.enabled.tweets">
|
||||
<h4>{{ plugins.descriptions.tweets }}</h4>
|
||||
<label>
|
||||
Number of tweets to display
|
||||
<input type="number" v-model="plugins.options['tweets.limit']" min="1" max="10" @change="load">
|
||||
</label>
|
||||
</div>
|
||||
<div class="options-group" v-if="plugins.enabled.music">
|
||||
<h4>{{ plugins.descriptions.music }}</h4>
|
||||
<label>
|
||||
Playlist embed link
|
||||
<input type="text" v-model="plugins.options['music.playlist']" placeholder="https://embed.music.apple.com/en/playlist/">
|
||||
</label>
|
||||
<label>
|
||||
Number of tracks to display
|
||||
<input type="number" v-model="plugins.options['music.limit']" min="1" @change="load">
|
||||
</label>
|
||||
</div>
|
||||
<div class="options-group" v-if="plugins.enabled.pagespeed">
|
||||
<h4>{{ plugins.descriptions.pagespeed }}</h4>
|
||||
<label>
|
||||
Detailed PageSpeed report
|
||||
<input type="checkbox" v-model="plugins.options['pagespeed.detailed']" @change="load">
|
||||
</label>
|
||||
<label>
|
||||
Include a website screenshot
|
||||
<input type="checkbox" v-model="plugins.options['pagespeed.screenshot']" @change="load">
|
||||
</label>
|
||||
</div>
|
||||
<div class="options-group" v-if="plugins.enabled.languages">
|
||||
<h4>{{ plugins.descriptions.languages }}</h4>
|
||||
<label>
|
||||
Ignored languages (comma separated)
|
||||
<input type="text" v-model="plugins.options['languages.ignored']" @change="load">
|
||||
</label>
|
||||
<label>
|
||||
Skipped repositories (comma separated)
|
||||
<input type="text" v-model="plugins.options['languages.skipped']" @change="load">
|
||||
</label>
|
||||
</div>
|
||||
<div class="options-group" v-if="plugins.enabled.habits">
|
||||
<h4>{{ plugins.descriptions.habits }}</h4>
|
||||
<label>
|
||||
Number of events for habits
|
||||
<input type="number" v-model="plugins.options['habits.from']" min="1" max="1000">
|
||||
</label>
|
||||
<label>
|
||||
Number of days for habits
|
||||
<input type="number" v-model="plugins.options['habits.days']" min="1" max="30">
|
||||
</label>
|
||||
<label>
|
||||
Display tidbits
|
||||
<input type="checkbox" v-model="plugins.options['habits.facts']" @change="load">
|
||||
</label>
|
||||
<label>
|
||||
Display activity charts
|
||||
<input type="checkbox" v-model="plugins.options['habits.charts']" @change="load">
|
||||
</label>
|
||||
</div>
|
||||
<div class="options-group" v-if="plugins.enabled.posts">
|
||||
<h4>{{ plugins.descriptions.posts }}</h4>
|
||||
<label>
|
||||
Posts source
|
||||
<select v-model="plugins.options['posts.source']" disabled>
|
||||
<option value="dev.to">dev.to</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Number of posts to display
|
||||
<input type="number" v-model="plugins.options['posts.limit']" min="1" @change="load">
|
||||
</label>
|
||||
</div>
|
||||
<div class="options-group" v-if="plugins.enabled.isocalendar">
|
||||
<h4>{{ plugins.descriptions.isocalendar }}</h4>
|
||||
<label>
|
||||
Isocalendar duration
|
||||
<select v-model="plugins.options['isocalendar.duration']">
|
||||
<option value="half-year">Half year</option>
|
||||
<option value="full-year">Full year</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="options-group" v-if="plugins.enabled.topics">
|
||||
<h4>{{ plugins.descriptions.topics }}</h4>
|
||||
<label>
|
||||
Topics display mode
|
||||
<select v-model="plugins.options['topics.mode']" @change="load">
|
||||
<option value="starred">Starred topics</option>
|
||||
<option value="mastered">Known and mastered technologies</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Topics sorting
|
||||
<select v-model="plugins.options['topics.sort']">
|
||||
<option value="starred">Recently starred by you</option>
|
||||
<option value="stars">Most stars</option>
|
||||
<option value="activity">Recent actity</option>
|
||||
<option value="random">Random</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Number of topics to display
|
||||
<input type="number" v-model="plugins.options['topics.limit']" @change="load">
|
||||
</label>
|
||||
</div>
|
||||
<div class="options-group" v-if="plugins.enabled.projects">
|
||||
<h4>{{ plugins.descriptions.projects }}</h4>
|
||||
<label>
|
||||
Number of projects to display
|
||||
<input type="number" v-model="plugins.options['projects.limit']" min="1" max="100" @change="load">
|
||||
</label>
|
||||
<label>
|
||||
Repositories projects to display (comma separated)
|
||||
<input type="text" v-model="plugins.options['projects.repositories']" @change="load">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<div class="step">
|
||||
<h2>3. Generate your metrics</h2>
|
||||
<template v-if="!user">
|
||||
Set your username to generate your metrics 🦑
|
||||
</template>
|
||||
<div class="preview-inliner">
|
||||
<template v-if="generated.content">
|
||||
<img class="metrics preview-inline" :src="generated.content" alt="metrics">
|
||||
</template>
|
||||
<template v-else>
|
||||
<img class="metrics preview-inline" :src="templates.placeholder" alt="metrics">
|
||||
</template>
|
||||
<div class="error" v-if="generated.error">An error occurred. Please try again later.</div>
|
||||
</div>
|
||||
<template v-if="user">
|
||||
<button @click="generate" :disabled="generated.pending">{{ generated.pending ? "Working on it :)" : "Generate your metrics !" }}</button>
|
||||
</template>
|
||||
<div class="palette">
|
||||
Generated metrics use transparency and colors which can be read on both light and dark modes, so everyone can see your stats whatever their preferred color scheme !
|
||||
<div class="palettes">
|
||||
<label>
|
||||
<input type="radio" v-model="palette" value="light"> ☀️ Light mode
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" v-model="palette" value="dark"> 🌙 Night mode
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<h2>4. Embed these metrics on your GitHub profile</h2>
|
||||
For even more features, be sure to checkout <a href="https://github.com/lowlighter/metrics">lowlighter/metrics</a> !
|
||||
<template v-if="user">
|
||||
<h3>4.1 Using <a href="#">{{ window.location.host }}</a></h3>
|
||||
Add the markdown below in your <i>README.md</i> at <a :href="repo">{{ user }}/{{ user }}</a>
|
||||
<div class="code"><Prism language="markdown" :code="embed"></Prism></div>
|
||||
<h3>4. Using <a href="https://github.com/marketplace/actions/github-metrics-as-svg-image">GitHub action</a></h3>
|
||||
Create a new workflow with the following content at <a :href="repo">{{ user }}/{{ user }}</a>
|
||||
<div class="code"><Prism language="yaml" :code="action"></Prism></div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Metrics preview -->
|
||||
<section class="preview">
|
||||
<template v-if="generated.content">
|
||||
<img class="metrics" :src="generated.content" alt="metrics">
|
||||
</template>
|
||||
<template v-else>
|
||||
<img class="metrics" :src="templates.placeholder" alt="metrics">
|
||||
</template>
|
||||
<div class="error" v-if="generated.error">An error occurred. Please try again later.</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
<!-- GitHub requests tracker -->
|
||||
<template>
|
||||
<div class="gh-requests">{{ requests.remaining }} GitHub request{{ requests.remaining > 1 ? "s" : "" }} remaining</div>
|
||||
</template>
|
||||
</main>
|
||||
<!-- Scripts -->
|
||||
<script src="/.js/axios.min.js"></script>
|
||||
<script src="/.js/prism.min.js"></script>
|
||||
<script src="/.js/prism.markdown.min.js"></script>
|
||||
<script src="/.js/prism.yaml.min.js"></script>
|
||||
<script src="/.js/ejs.min.js"></script>
|
||||
<script src="/.js/vue.min.js"></script>
|
||||
<script src="/.js/vue.prism.min.js"></script>
|
||||
<script src="/.js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
213
source/app/web/statics/style.css
Normal file
213
source/app/web/statics/style.css
Normal file
@@ -0,0 +1,213 @@
|
||||
/* General */
|
||||
body {
|
||||
font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
main {
|
||||
background-color: #FFFFFF;
|
||||
color: #1B1F23;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 1rem 1.5rem;
|
||||
overflow-x: hidden;
|
||||
transition: background-color .3s;
|
||||
}
|
||||
/* Headlines */
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
h2 {
|
||||
margin: 1.5rem 0 1rem;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
h3 {
|
||||
margin: .5rem 0 .25rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
/* Links */
|
||||
a, a:hover, a:visited {
|
||||
color: #0366D6;
|
||||
text-decoration: none;
|
||||
font-style: normal;
|
||||
outline: none;
|
||||
}
|
||||
a:hover {
|
||||
color: #79B8FF;
|
||||
transition: color .4s;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Inputs */
|
||||
input, button, select {
|
||||
border-radius: .5rem;
|
||||
padding: .25rem .5rem;
|
||||
outline: none;
|
||||
border: 1px solid #E1E4E8;
|
||||
background-color: #FAFBFC;
|
||||
color: #1B1F23;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
input[name=user] {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
input[type=text], select, button {
|
||||
min-width: 50%;
|
||||
}
|
||||
option {
|
||||
text-align: center;
|
||||
}
|
||||
label, button {
|
||||
margin: 1rem;
|
||||
}
|
||||
label {
|
||||
padding-right: .25rem;
|
||||
padding-bottom: .125rem;
|
||||
}
|
||||
input[disabled], button[disabled], select[disabled] {
|
||||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
label:hover {
|
||||
border-radius: .25rem;
|
||||
background-color: #79B8FF50;
|
||||
transition: background-color .4s;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Generator */
|
||||
.generator {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.generator .step {
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
}
|
||||
.generator .steps {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.generator .preview {
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.generator .preview .metrics {
|
||||
width: 480px;
|
||||
}
|
||||
.generator .preview-inliner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.generator .preview-inliner .metrics {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
@media only screen and (min-width: 1180px) {
|
||||
.generator .preview-inliner {
|
||||
display: none;
|
||||
}
|
||||
.generator .preview {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
/* Plugins */
|
||||
.plugins, .palettes {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.plugins label, .palettes label {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.options-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.options-group label {
|
||||
margin: 0;
|
||||
}
|
||||
.options-group h4 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* Code snippets */
|
||||
.code {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 0 .5rem;
|
||||
}
|
||||
.code pre {
|
||||
width: 100%;
|
||||
border-radius: .5rem;
|
||||
}
|
||||
.code .language-markdown {
|
||||
word-break: break-all !important;
|
||||
white-space: pre-wrap !important;
|
||||
}
|
||||
details {
|
||||
width: 100%;
|
||||
}
|
||||
details summary {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
/* Color palette */
|
||||
.palette {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
main.dark {
|
||||
background-color: #181A1B;
|
||||
color: #D4D1C5;
|
||||
}
|
||||
.dark a, .dark a:visited {
|
||||
color: #4CACEE;
|
||||
}
|
||||
.dark input, .dark button {
|
||||
color: #D4D1C5;
|
||||
background-color: #1A1C1E;
|
||||
border-color: #373C3E;
|
||||
}
|
||||
.dark .code {
|
||||
background-color: #1A1C1E;
|
||||
}
|
||||
/* Error */
|
||||
.error {
|
||||
color: #721c24;
|
||||
background-color: #f8d7da;
|
||||
padding: .75rem 1.25rem;
|
||||
border: 1px solid #f5c6cb;
|
||||
border-radius: .25rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
/* Github requests */
|
||||
.gh-requests {
|
||||
position: fixed;
|
||||
right: .25rem;
|
||||
bottom: .25rem;
|
||||
font-size: .8rem;
|
||||
}
|
||||
Reference in New Issue
Block a user