diff --git a/source/app/web/instance.mjs b/source/app/web/instance.mjs index a033a6d1..6e52eff6 100644 --- a/source/app/web/instance.mjs +++ b/source/app/web/instance.mjs @@ -89,7 +89,14 @@ let requests = {limit:0, used:0, remaining:0, reset:NaN} if (!conf.settings.notoken) { requests = (await rest.rateLimit.get()).data.rate - setInterval(async() => requests = (await rest.rateLimit.get()).data.rate, 5*60*1000) + setInterval(async() => { + try { + requests = (await rest.rateLimit.get()).data.rate + } + catch { + console.debug("metrics/app > failed to update remaining requests") + } + }, 5*60*1000) } //Web app.get("/", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/index.html`)) @@ -144,6 +151,57 @@ } }) + //About routes + app.use("/about/.statics/", express.static(`${conf.paths.statics}/about`)) + app.get("/about/", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/about/index.html`)) + app.get("/about/index.html", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/about/index.html`)) + app.get("/about/:login", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/about/index.html`)) + app.get("/about/query/:login/", ...middlewares, async(req, res) => { + //Check username + const login = req.params.login?.replace(/[\n\r]/g, "") + if (!/^[-\w]+$/i.test(login)) { + console.debug(`metrics/app/${login}/insights > 400 (invalid username)`) + return res.status(400).send("Bad request: username seems invalid") + } + //Compute metrics + try { + //Read cached data if possible + if ((!debug)&&(cached)&&(cache.get(`about.${login}`))) { + console.debug(`metrics/app/${login}/insights > using cached results`) + return res.send(cache.get(`about.${login}`)) + } + //Compute metrics + console.debug(`metrics/app/${login}/insights > compute insights`) + const json = await metrics({ + login, q:{ + template:"classic", + achievements:true, "achievements.threshold":"X", + isocalendar:true, "isocalendar.duration":"full-year", + languages:true, "languages.limit":0, + activity:true, "activity.limit":100, "activity.days":0, + notable:true, + }, + }, {graphql, rest, plugins:{achievements:{enabled:true}, isocalendar:{enabled:true}, languages:{enabled:true}, activity:{enabled:true}, notable:{enabled:true}}, conf, convert:"json"}, {Plugins, Templates}) + //Cache + if ((!debug)&&(cached)) { + const maxage = Math.round(Number(req.query.cache)) + cache.put(`about.${login}`, json, maxage > 0 ? maxage : cached) + } + return res.json(json) + } + //Internal error + catch (error) { + //Not found user + if ((error instanceof Error)&&(/^user not found$/.test(error.message))) { + console.debug(`metrics/app/${login} > 404 (user/organization not found)`) + return res.status(404).send("Not found: unknown user or organization") + } + //General error + console.error(error) + return res.status(500).send("Internal Server Error: failed to process metrics correctly") + } + }) + //Metrics const pending = new Map() app.get("/:login/:repository?", ...middlewares, async(req, res) => { diff --git a/source/app/web/statics/about/index.html b/source/app/web/statics/about/index.html new file mode 100644 index 00000000..03b85d00 --- /dev/null +++ b/source/app/web/statics/about/index.html @@ -0,0 +1,316 @@ + + + + Metrics + + + + + + + + + + + +
+ +
+ + + + + + + \ No newline at end of file diff --git a/source/app/web/statics/about/script.js b/source/app/web/statics/about/script.js new file mode 100644 index 00000000..b393edcd --- /dev/null +++ b/source/app/web/statics/about/script.js @@ -0,0 +1,113 @@ +;(async function() { + //Init + const {data:version} = await axios.get("/.version") + const {data:hosted} = await axios.get("/.hosted") + //App + return new Vue({ + //Initialization + el:"main", + async mounted() { + //Palette + try { + this.palette = (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") + } catch (error) {} + //GitHub limit tracker + const {data:requests} = await axios.get("/.requests") + this.requests = requests + //Initialization + const user = location.pathname.split("/").pop() + if ((user)&&(user !== "about")) { + this.user = user + await this.search() + } + else + this.searchable = true + //Embed + this.embed = !!(new URLSearchParams(location.search).get("embed")) + }, + //Watchers + watch:{ + palette:{ + immediate:true, + handler(current, previous) { + document.querySelector("body").classList.remove(previous) + document.querySelector("body").classList.add(current) + } + } + }, + //Methods + methods:{ + format(type, value, options) { + switch (type) { + case "number": + return new Intl.NumberFormat(navigator.lang, options).format(value) + case "date": + return new Intl.DateTimeFormat(navigator.lang, options).format(new Date(value)) + } + return value + }, + async search() { + try { + this.error = null + this.metrics = null + this.pending = true + this.metrics = (await axios.get(`/about/query/${this.user}`)).data + } + catch (error) { + this.error = error + } + finally { + this.pending = false + } + } + }, + //Computed properties + computed:{ + ranked() { + return this.metrics?.rendered.plugins.achievements.list.filter(({leaderboard}) => leaderboard).sort((a, b) => a.leaderboard.type.localeCompare(b.leaderboard.type)) + }, + achievements() { + return this.metrics?.rendered.plugins.achievements.list.filter(({leaderboard}) => !leaderboard).filter(({title}) => !/(?:automater|octonaut|infographile)/i.test(title)) + }, + isocalendar() { + return this.metrics?.rendered.plugins.isocalendar.svg + .replace(/#ebedf0/gi, "var(--color-calendar-graph-day-bg)") + .replace(/#9be9a8/gi, "var(--color-calendar-graph-day-L1-bg)") + .replace(/#40c463/gi, "var(--color-calendar-graph-day-L2-bg)") + .replace(/#30a14e/gi, "var(--color-calendar-graph-day-L3-bg)") + .replace(/#216e39/gi, "var(--color-calendar-graph-day-L4-bg)") + }, + languages() { + return this.metrics?.rendered.plugins.languages.favorites + }, + activity() { + return this.metrics?.rendered.plugins.activity.events + }, + contributions() { + return this.metrics?.rendered.plugins.notable.contributions + }, + account() { + if (!this.metrics) + return null + const {login, name} = this.metrics.rendered.user + return {login, name, avatar:this.metrics.rendered.computed.avatar} + }, + url() { + return `${window.location.protocol}//${window.location.host}/about/${this.user}` + }, + }, + //Data initialization + data:{ + version, + hosted, + user:"", + embed:false, + searchable:false, + requests:{limit:0, used:0, remaining:0, reset:0}, + palette:"light", + metrics:null, + pending:false, + error:null, + } + }) +})() \ No newline at end of file diff --git a/source/app/web/statics/about/style.css b/source/app/web/statics/about/style.css new file mode 100644 index 00000000..9ce34089 --- /dev/null +++ b/source/app/web/statics/about/style.css @@ -0,0 +1,360 @@ +/* Containers */ + .container { + padding: 0 1rem; + display: flex; + flex-direction: column; + justify-content: center; + max-width: 920px; + margin: auto; + } + .center { + align-items: center; + } + +/* Search */ + .search { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + margin: 3rem 0 1rem; + } + .search h2 { + margin: 0; + padding: 0; + } + .search .about { + width: 100%; + display: flex; + flex-direction: column; + } + .search .about small { + font-size: .8rem; + color: var(--color-text-secondary); + text-align: left; + } + .search .inputs { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + } + .search .inputs > * { + margin: .25rem; + } + .search .inputs input { + flex-grow: 1; + } + .search .info { + color: var(--color-text-secondary); + margin-top: 1rem; + } + .search .info svg { + fill: currentColor; + } + +/* Contributions */ + .contributions { + display: flex; + flex-direction: row; + flex-wrap: wrap; + } + + .contribution { + display: flex; + align-items: center; + border-radius: 6px; + margin: .25rem; + padding: .25rem .5rem; + padding-left: .25rem; + color: var(--color-text-primary) !important; + border: 1px solid var(--color-border-primary); + } + + .contribution img { + height: 1.5rem; + width: 1.5rem; + margin-right: .5rem; + border-radius: 6px; + flex-shrink: 0; + box-shadow: 0 0 0 1px var(--color-avatar-border); + } + +/* Languages */ + .languages .list { + display: flex; + flex-wrap: wrap; + } + .languages .language { + width: 100%; + margin: 0 0 .5rem; + } + .languages .percent { + font-size: .8rem; + } + .languages .size { + display: flex; + font-size: .8rem; + color: var(--color-text-secondary); + } + .progress { + height: 8px; + border-radius: 6px; + outline: 1px solid transparent; + } + +/* Isocalendar */ + .isocalendar .svg { + margin-top: 2rem; + } + +/* Activity */ + .activity > ul { + padding: 0; + margin: 0; + list-style-type: none; + } + .activity > ul > li { + margin: 0 0 1rem; + } + .activity time { + font-size: .8rem; + color: var(--color-text-secondary); + } + .activity svg { + fill: var(--color-text-primary); + flex-shrink: 0; + margin: .25rem 0; + margin-right: .5rem; + } + .activity .event { + display: flex; + align-items: flex-start; + } + .activity .event ul { + font-size: .8rem; + } + +/* User */ + .user { + display: flex; + justify-content: center; + margin: 2rem 0; + } + .user img { + border-radius: 50%; + height: 4.5rem; + width: 4.5rem; + margin: 0 1rem; + } + .user .info { + display: flex; + flex-direction: column; + } + .user .name { + font-size: 2rem; + } + .user .login { + font-size: 1.25rem; + } + +/* Ranked achievements */ + .rankeds { + display: flex; + flex-direction: column; + align-items: center; + } + .ranked { + flex: 1 1 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin-bottom: 2rem; + } + .ranked .text { + min-height: 2rem; + } + .ranked .icon { + transform: scale(1.75); + } + .ranked .info { + margin-top: 1rem; + text-align: center; + } + .ranked .title { + font-size: 1.5rem; + margin-top: .5rem; + } + .user-rank { + font-size: 1.5rem; + } + .total-rank { + opacity: .75; + } + +/* Achievements */ + .achievements { + display: flex; + flex-wrap: wrap; + justify-content: center; + } + .achievement { + display: flex; + margin: .5rem; + max-width: 18rem; + width: 100%; + } + +/* Icon gauges */ + .gauge { + stroke-linecap: round; + fill: none; + color: #58A6FF; + } + .gauge-base, .gauge-arc { + stroke: currentColor; + stroke-width: 6; + } + .gauge-base { + stroke-opacity: .2; + } + .gauge-arc { + fill: none; + stroke-dashoffset: 0; + animation-delay: 250ms; + animation: animation-gauge 1s ease forwards + } + @keyframes animation-gauge { + from { + stroke-dasharray: 0 329; + } + } + +/* General and colors */ + .icon { + margin: 0 .25rem; + width: 44px; + height: 44px; + } + .text { + font-size: 12px; + } + .info { + font-size: 14px; + color: #58A6FF; + } + .x .info { + color: #666666; + } + .x .gauge { + color: #B0B0B0; + } + .b .info { + color: #9D8FFF; + } + .b .gauge { + color: #9E91FF; + } + .a .info { + color: #D79533; + } + .a .gauge { + color: #E7BD69; + } + .s .info { + color: #FF0000; + } + .s .gauge { + color: #FF0000; + } + .s .icon { + filter: sepia() saturate(100); + } + .secret .info { + color: #FF76CD; + } + .secret .gauge { + color: #FF79D1; + } + +/* Header */ + h2 { + display: flex; + align-items: center; + font-weight: normal; + } + h2 svg { + margin-right: .25rem; + fill: currentColor; + height: 1.25rem; + width: 1.25rem; + } + +/* Inputs */ + label { + cursor: pointer; + } + label:hover { + background-color: var(--color-input-contrast-bg); + border-radius: 6px; + } + + input[type=text], input[type=number], select { + background-color: var(--color-input-contrast-bg); + padding: .4rem .8rem; + color: var(--color-text-primary); + background-color: var(--color-input-bg); + border: 1px solid var(--color-input-border); + border-radius: 6px; + outline: none; + box-shadow: var(--color-shadow-inset); + } + + input[type=text]:focus, input[type=number]:focus, select:focus { + background-color: var(--color-input-bg); + border-color: var(--color-state-focus-border); + outline: none; + box-shadow: var(--color-state-focus-shadow); + } + + button { + color: var(--color-btn-primary-text); + background-color: var(--color-btn-primary-bg); + border-color: var(--color-btn-primary-border); + box-shadow: var(--color-btn-primary-shadow),var(--color-btn-primary-inset-shadow); + padding: .4rem .8rem; + border-radius: 6px; + font-weight: 500; + margin: .5rem 0; + cursor: pointer; + transition-duration: all .12s ease-out; + } + + button[disabled] { + color: var(--color-btn-primary-disabled-text); + background-color: var(--color-btn-primary-disabled-bg); + border-color: var(--color-btn-primary-disabled-border); + } + + button:focus { + outline: none; + } + +/* Media screen */ + @media only screen and (min-width: 740px) { + .rankeds { + flex-direction: row; + margin: 4rem 0 2rem; + } + .ranked { + margin-bottom: 0; + } + .languages .language { + width: 25%; + } + .search { + width: 520px; + } + } \ No newline at end of file diff --git a/source/app/web/statics/app.js b/source/app/web/statics/app.js index 90ce3086..bbeb0c37 100644 --- a/source/app/web/statics/app.js +++ b/source/app/web/statics/app.js @@ -48,6 +48,7 @@ data:{ version, user:"", + mode:"metrics", tab:"overview", palette:"light", requests:{limit:0, used:0, remaining:0, reset:0}, diff --git a/source/app/web/statics/index.html b/source/app/web/statics/index.html index 4aaef4fa..839b66f0 100644 --- a/source/app/web/statics/index.html +++ b/source/app/web/statics/index.html @@ -24,9 +24,9 @@
-
+
+ +