Refactor source paths

This commit is contained in:
linguist
2020-12-30 20:05:31 +01:00
parent dd0b3c5626
commit e38614a100
45 changed files with 32 additions and 36 deletions

5
source/app/web/index.mjs Normal file
View 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
View 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
}

View 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 metrics](${this.url})`
},
//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)
}
},
})
})()

View 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>

View 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;
}