feat: add language chart to about page (#14)

Replaces this section on the `/about` page:
![image](/attachments/67ac2f42-3784-4c69-9240-0a7961afb47d)
with:
![image](/attachments/fa073c9c-a016-4281-a3fb-30b7be95881f)

I will replace and fix the colors of the buttons after #15 is merged.

Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/14
This commit is contained in:
Victor Westerlund 2025-01-28 14:45:52 +00:00
parent 3b51458dd4
commit e25b1b6689
13 changed files with 528 additions and 45 deletions

View file

@ -6,3 +6,14 @@ pass = ""
[databases] [databases]
vlw = "" vlw = ""
battlestation = "" battlestation = ""
; Forgejo instance config
[forgejo]
base_url = ""
; Forgejo language chart endpoints config
[about_languages]
; CSV of Forgejo profiles to include public source repositories from
scan_profiles = ""
; Path to a JSON file to store cached language endpoint responses
cache_file = ""

20
api/composer.lock generated
View file

@ -8,17 +8,11 @@
"packages": [ "packages": [
{ {
"name": "reflect/plugin-rules", "name": "reflect/plugin-rules",
"version": "1.5.0", "version": "1.5.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/VictorWesterlund/reflect-rules-plugin.git", "url": "https://codeberg.org/reflect/reflect-rules-plugin",
"reference": "9c837fd1944133edfed70a63ce8b32bf67f0d94b" "reference": "df150f0d860dbc2311e5e2fcb2fac36ee52db56b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/VictorWesterlund/reflect-rules-plugin/zipball/9c837fd1944133edfed70a63ce8b32bf67f0d94b",
"reference": "9c837fd1944133edfed70a63ce8b32bf67f0d94b",
"shasum": ""
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@ -37,11 +31,7 @@
} }
], ],
"description": "Add request search paramter and request body constraints to an API built with Reflect", "description": "Add request search paramter and request body constraints to an API built with Reflect",
"support": { "time": "2024-11-20T10:39:33+00:00"
"issues": "https://github.com/VictorWesterlund/reflect-rules-plugin/issues",
"source": "https://github.com/VictorWesterlund/reflect-rules-plugin/tree/1.5.0"
},
"time": "2024-01-17T11:07:44+00:00"
}, },
{ {
"name": "victorwesterlund/xenum", "name": "victorwesterlund/xenum",
@ -121,5 +111,5 @@
"prefer-lowest": false, "prefer-lowest": false,
"platform": [], "platform": [],
"platform-dev": [], "platform-dev": [],
"plugin-api-version": "2.0.0" "plugin-api-version": "2.3.0"
} }

View file

@ -0,0 +1,22 @@
<?php
use Reflect\Call;
use Reflect\Path;
use Reflect\Response;
const MSG_OK = "Cache file deleted";
const MSG_FAIL = "Cache file does not exist or can't be deleted";
class DELETE_AboutLanguages {
public function __construct() {}
// Delete languages cache file if it exists
public function main(): Response {
// Bail out if cache is not used
if (empty($_ENV["forgejo_languages"]["cache_file"])) {
return new Response(MSG_OK);
}
return unlink($_ENV["forgejo_languages"]["cache_file"]) ? new Response(MSG_OK) : new Response(MSG_FAIL, 404);
}
}

View file

@ -0,0 +1,51 @@
<?php
use Reflect\Call;
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Endpoints;
require_once Path::root("src/Endpoints.php");
const PARM_FORCE_RECACHE = "force_recache";
class GET_AboutLanguages {
protected Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(PARM_FORCE_RECACHE))
->type(Type::BOOLEAN)
->default(false)
]);
$this->ruleset->validate_or_exit();
}
private static function cache_exists(): bool {
return file_exists($_ENV["forgejo_languages"]["cache_file"]);
}
private static function load_cache(): array {
return json_decode(file_get_contents($_ENV["forgejo_languages"]["cache_file"]), true);
}
public function main(): Response {
// Delete cache file if force flag is set
if ($_GET[PARM_FORCE_RECACHE]) {
(new Call(Endpoints::ABOUT_LANGUAGES->value))->delete();
}
return self::cache_exists()
// Return languages from cache
? new Response(self::load_cache())
// Fetch and return languages (and generate cache file if enabled)
: new Response((new Call(Endpoints::ABOUT_LANGUAGES->value))->post());
}
}

View file

@ -0,0 +1,97 @@
<?php
use Reflect\Call;
use Reflect\Path;
use Reflect\Response;
const ERRNO_CACHE_FAILED = 0;
const FORGEJO_ENDPOINT_USER = "/api/v1/users/%s";
const FORGEJO_ENDPOINT_SEARCH = "/api/v1/repos/search?uid=%s";
class POST_AboutLanguages {
private array $errors = [];
// Tally of all languages used in all configured repositories
private array $languages = [];
public function __construct() {}
// Fetch JSON from URL
private static function fetch_json(string $url): array {
return json_decode(file_get_contents($url), true);
}
// Fetch JSON from a Forgejo endpoint
private static function fetch_endpoint(string $endpoint): array {
$url = $_ENV["forgejo"]["base_url"] . $endpoint;
return self::fetch_json($url);
}
// Write $this->languages to a JSON file
private function cache_languages(): int|bool {
$cache_filename = $_ENV["forgejo_languages"]["cache_file"];
// Bail out if cache file is not configured
if (empty($cache_filename)) {
return true;
}
return file_put_contents($cache_filename, json_encode($this->languages));
}
// Fetch and add languages to total from a fully-qualified Forgejo URL
private function add_repository_languages(string $url): void {
foreach(self::fetch_json($url) as $language => $bytes) {
// Create key for language if it doesn't exist
if (!array_key_exists($language, $this->languages)) {
$this->languages[$language] = 0;
}
// Add bytes to language in total
$this->languages[$language] += $bytes;
}
}
// Tally languages from public repositories for user id
private function add_public_repositores(int $uid): bool {
$resp = self::fetch_endpoint(sprintf(FORGEJO_ENDPOINT_SEARCH, $uid));
// Bail out if request failed or if response indicated a problem
if (!$resp or $resp["ok"] === false) {
return false;
}
// Add langauges for each public repository
foreach ($resp["data"] as $repo) {
$this->add_repository_languages($repo["languages_url"]);
}
return true;
}
// Add languages from all public repositories for profiles in config
private function add_repositories_from_config_profiles(): void {
foreach(explode(",", $_ENV["forgejo_languages"]["scan_profiles"]) as $profile) {
// Resolve user data from username
$user = self::fetch_endpoint(sprintf(FORGEJO_ENDPOINT_USER, $profile));
if (!$this->add_public_repositores($user["id"])) {
$this->errors[] = $profile;
}
}
}
public function main(): Response {
$this->add_repositories_from_config_profiles();
// Sort langauges bytes tally by largest in descending order
arsort($this->languages);
// Save languages to cache
if (!$this->cache_languages()) {
$this->errors[] = ERRNO_CACHE_FAILED;
}
return new Response($this->languages);
}
}

View file

@ -7,6 +7,8 @@
// Enum of all available VLW endpoints grouped by category // Enum of all available VLW endpoints grouped by category
enum Endpoints: string { enum Endpoints: string {
case ABOUT_LANGUAGES = "/about/languages";
case SEARCH = "/search"; case SEARCH = "/search";
case MESSAGES = "/messages"; case MESSAGES = "/messages";

View file

@ -1,12 +1,110 @@
<?php
use Vegvisir\Path;
use Reflect\Response;
use VLW\Client\API;
use VLW\API\Endpoints;
require_once VV::root("src/client/API.php");
require_once VV::root("api/src/Endpoints.php");
const BYTE_UNITS = ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const FORGEJO_HREF = "https://git.vlw.se/explore/repos?q=&sort=recentupdate&language=";
$langs = new class extends API {
public readonly int $total_bytes;
private readonly Response $resp;
private readonly array $languages;
public function __construct() {
parent::__construct();
// Fetch languages from endpoint
$this->resp = $this->call(Endpoints::ABOUT_LANGUAGES->value)->get();
// We got a response from endpoint
if ($this->resp->ok) {
$this->languages = $this->resp->json();
$this->total_bytes = array_sum($this->languages);
}
}
// Return all languages as (string) language => (int) language_bytes
public function all(): array {
return $this->languages;
}
// Return percent of total for all languages
public function get_percent(string $lang, int $mode = PHP_ROUND_HALF_UP): int {
return round(($this->languages[$lang] / $this->total_bytes) * 100, 0, $mode);
}
// Return language bytes as percent of whole
public function get_percent_str(string $lang): string {
$percent = $this->get_percent($lang, PHP_ROUND_HALF_DOWN);
return ($percent > 1 ? $percent : "<1") . "%";
}
// Return languages bytes as a multiple-byte decimal unit
public function get_bytes_str(string $lang): string {
$bytes = $this->languages[$lang];
// Calculate factor for unit
$factor = floor((strlen($bytes) - 1) / 3);
// Divide by radix 10
$format = $bytes / pow(1000, $factor);
return round($format) . " " . BYTE_UNITS[$factor];
}
};
?>
<style><?= VV::css("public/assets/css/pages/about") ?></style> <style><?= VV::css("public/assets/css/pages/about") ?></style>
<section class="intro"> <section class="intro">
<h2 aria-hidden="true">Hi, I'm</h2> <h2 aria-hidden="true">Hi, I"m</h2>
<h1>Victor Westerlund</h1> <h1>Victor Westerlund</h1>
</section> </section>
<hr aria-hidden="true"> <hr aria-hidden="true">
<section class="about"> <section class="about">
<p>I&ZeroWidthSpace;'m a full-stack web developer from Sweden.</p> <p>I&ZeroWidthSpace;'m a full-stack web developer from Sweden.</p>
<p>The &lt;programming/markup/command/query/whatever&gt;-languages I currently use the most are (in a mostly accurate decending order): PHP, JavaScript, CSS, MySQL, TypeScript, Python, SQLite, Bash, and HTML.</p> <p>I used to list the &lt;programming/markup/command/whatever&gt;-languages here that I use the most and order them by guesstimating how much I use each one. But then I thought it would be better to just show you instead using this chart that <a href="https://git.vlw.se/config/vlw.se">automatically pulls the total bytes</a> for each language from my public mirrors and sources on <a href="https://git.vlw.se/vlw">Forgejo</a>.</p>
</section>
<section class="languages">
<stacked-bar-chart>
<?php foreach ($langs->all() as $lang => $bytes): ?>
<a href="<?= FORGEJO_HREF . $lang ?>" target="_blank"><chart-segment style="--size:<?= $langs->get_percent($lang) ?>%;" data-lang="<?= $lang ?>" data-bytes="<?= $bytes ?>">
<span data-hover><strong><?= $langs->get_percent_str($lang) ?> <?= $lang ?></strong><br>(<?= $bytes ?> bytes)</span>
<!--<p><span><?= $lang ?></span></p>-->
</chart-segment></a>
<?php endforeach; ?>
</stacked-bar-chart>
<languages-list>
<?php foreach ($langs->all() as $lang => $bytes): ?>
<a href="<?= FORGEJO_HREF . $lang ?>"><language-item data-lang="<?= $lang ?>">
<p><?= $langs->get_percent_str($lang) ?></p>
<p class="lang"><?= $lang ?></p>
<p><?= $langs->get_bytes_str($lang) ?></p>
<?= VV::embed("public/assets/media/icons/chevron.svg") ?>
</language-item></a>
<?php endforeach; ?>
</languages-list>
<stacked-bar-chart>
<?php foreach ($langs->all() as $lang => $bytes): ?>
<a href="<?= FORGEJO_HREF . $lang ?>" target="_blank"><chart-segment style="--size:<?= $langs->get_percent($lang) ?>%;" data-lang="<?= $lang ?>" data-bytes="<?= $bytes ?>">
<span data-hover><strong><?= $langs->get_percent_str($lang) ?> <?= $lang ?></strong><br>(<?= $bytes ?> bytes)</span>
<!--<p><span><?= $lang ?></span></p>-->
</chart-segment></a>
<?php endforeach; ?>
</stacked-bar-chart>
</section> </section>
<section class="about"> <section class="about">
<h2>This website</h2> <h2>This website</h2>

View file

@ -3,6 +3,15 @@
:root { :root {
--primer-color-accent: 148, 255, 21; --primer-color-accent: 148, 255, 21;
--color-accent: rgb(var(--primer-color-accent)); --color-accent: rgb(var(--primer-color-accent));
--color-go: rgb(0, 173, 216);
--color-php: rgb(79, 93, 149);
--color-css: rgb(86, 61, 124);
--color-html: rgb(227, 76, 38);
--color-shell: rgb(137, 224, 81);
--color-python: rgb(53, 114, 165);
--color-typescript: rgb(49, 120, 198);
--color-javascript: rgb(241, 224, 90);
} }
vv-shell { vv-shell {
@ -45,6 +54,147 @@ section.about p i:not(:hover) {
opacity: .3; opacity: .3;
} }
/* ## Languages */
section.languages {
margin: calc(var(--padding) / 1.5) 0;
}
section.languages stacked-bar-chart {
gap: 3px;
width: 100%;
display: flex;
border-radius: 100px;
height: var(--padding);
background-color: rgba(255, 255, 255, 0);
}
section.languages stacked-bar-chart:last-of-type {
flex-direction: row-reverse;
}
section.languages stacked-bar-chart:hover chart-segment {
opacity: .5;
}
section.languages stacked-bar-chart chart-segment {
--border-corner-radius: 100px;
transition: 150ms opacity;
width: var(--size, 0%);
min-width: 3%;
height: 100%;
color: white;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, .1);
border-radius: 2px;
}
section.languages stacked-bar-chart a:nth-child(odd) chart-segment {
background-color: rgba(255, 255, 255, .3);
}
/* ### Round corners */
section.languages stacked-bar-chart a:first-child chart-segment {
border-top-right-radius: var(--padding);
border-bottom-right-radius: var(--padding);
border-top-left-radius: var(--border-corner-radius);
border-bottom-left-radius: var(--border-corner-radius);
}
section.languages stacked-bar-chart a:last-child chart-segment {
border-top-left-radius: var(--padding);
border-bottom-left-radius: var(--padding);
border-top-right-radius: var(--border-corner-radius);
border-bottom-right-radius: var(--border-corner-radius);
}
section.languages stacked-bar-chart:last-of-type a:first-child chart-segment {
border-top-left-radius: var(--padding);
border-bottom-left-radius: var(--padding);
border-top-right-radius: var(--border-corner-radius);
border-bottom-right-radius: var(--border-corner-radius);
}
section.languages stacked-bar-chart:last-of-type a:last-child chart-segment {
border-top-right-radius: var(--padding);
border-bottom-right-radius: var(--padding);
border-top-left-radius: var(--border-corner-radius);
border-bottom-left-radius: var(--border-corner-radius);
}
/* ### Texts */
section.languages stacked-bar-chart chart-segment p {
text-align: center;
color: inherit;
overflow: hidden;
white-space: nowrap;
pointer-events: none;
text-overflow: ellipsis;
padding: 0 3px;
}
section.languages stacked-bar-chart chart-segment[style="--size:0%;"] p span {
display: none;
}
section.languages stacked-bar-chart chart-segment[style="--size:0%;"] p::before {
content: "<1%";
opacity: .5;
}
section.languages stacked-bar-chart chart-segment [data-hover] {
display: none;
}
/* ### Colors */
section.languages stacked-bar-chart a chart-segment[data-lang="Go"] { background-color: var(--color-go); }
section.languages stacked-bar-chart a chart-segment[data-lang="PHP"] { background-color: var(--color-php); }
section.languages stacked-bar-chart a chart-segment[data-lang="CSS"] { background-color: var(--color-css); }
section.languages stacked-bar-chart a chart-segment[data-lang="HTML"] { background-color: var(--color-html); }
section.languages stacked-bar-chart a chart-segment[data-lang="Python"] { background-color: var(--color-python); }
section.languages stacked-bar-chart a chart-segment[data-lang="TypeScript"] { background-color: var(--color-typescript); }
section.languages stacked-bar-chart a chart-segment[data-lang="Shell"] { background-color: var(--color-shell); color: black; }
section.languages stacked-bar-chart a chart-segment[data-lang="JavaScript"] { background-color: var(--color-javascript); color: black; }
/* ### Legend */
section.languages languages-list {
gap: calc(var(--padding) / 2);
display: grid;
grid-template-columns: repeat(3, 1fr);
margin: var(--padding) 0;
}
section.languages languages-list language-item {
gap: 10px;
display: flex;
border-radius: 8px;
align-items: center;
fill: var(--color-php);
padding: calc(var(--padding) / 1.5);
border: solid 1px rgba(255, 255, 255, .1);
background: linear-gradient(139deg, rgba(0, 0, 0, 0) 0%, rgba(79, 93, 144, .2) 100%);
}
section.languages languages-list language-item p.lang {
font-size: 1.3em;
font-weight: 900;
color: var(--color-php);
}
section.languages languages-list language-item svg {
width: 2em;
margin-left: auto;
transform: rotate(-90deg);
}
/* # Interests */ /* # Interests */
div.interests { div.interests {
@ -86,3 +236,46 @@ div.interests p {
filter: hue-rotate(360deg); filter: hue-rotate(360deg);
} }
} }
/* Feature queries */
@media (hover: hover) {
section.languages stacked-bar-chart chart-segment:hover {
opacity: 1;
}
section.languages stacked-bar-chart chart-segment [data-hover] {
display: none;
position: absolute;
top: 0;
left: 0;
text-align: center;
transform: translate(0, 0);
background-color: inherit;
padding: 5px 10px;
white-space: nowrap;
pointer-events: none;
border-radius: 6px;
z-index: 2000;
-webkit-backdrop-filter: brightness(.2) blur(20px);
backdrop-filter: brightness(.2) blur(20px);
}
section.languages stacked-bar-chart chart-segment [data-hover].hovering {
display: initial;
}
}
/* Size queries */
@media (max-width: 900px) {
section.languages languages-list {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 650px) {
section.languages languages-list {
grid-template-columns: 1fr;
}
}

View file

@ -71,7 +71,7 @@ section.social social:hover {
fill: var(--color-accent); fill: var(--color-accent);
} }
section.social social.hovering p { section.social social p.hovering {
display: initial; display: initial;
} }

View file

@ -0,0 +1,32 @@
import { Elevent } from "/assets/js/modules/npm/Elevent.mjs";
export const TARGET_SELECTOR = "[data-hover]";
export class Hoverpop {
/**
* Bind hover targets on provided Elevent-compatible element(s)
* @param {HTMLElement|HTMLElements|string} elements
*/
constructor(elements) {
// Bind hover targets on element(s)
new Elevent("mouseenter", elements, (event) => {
const element = event.target.querySelector(TARGET_SELECTOR);
// Bail out if target element contains no hover target element
if (!element) {
return;
}
element.classList.add("hovering");
event.target.addEventListener("mousemove", (event) => {
const x = event.layerX - (element.clientWidth / 2);
const y = event.layerY + element.clientHeight;
element.style.setProperty("transform", `translate(${x}px, ${y}px)`);
});
});
// Bind hover leave targets on element(s)
new Elevent("mouseleave", elements, () => elements.forEach(element => element.querySelector(TARGET_SELECTOR)?.classList.remove("hovering")));
}
}

View file

@ -1,3 +1,5 @@
import { Hoverpop } from "/assets/js/modules/Hoverpop.mjs";
const randomIntFromInterval = (min, max) => { const randomIntFromInterval = (min, max) => {
return Math.floor(Math.random() * (max - min + 1) + min); return Math.floor(Math.random() * (max - min + 1) + min);
} }
@ -65,3 +67,6 @@ const implodeInterests = () => {
interestsElement.addEventListener(canHover ? "mouseleave" : "touchend", () => implodeInterests()); interestsElement.addEventListener(canHover ? "mouseleave" : "touchend", () => implodeInterests());
} }
// Languages stacking bar chart hoverpop
new Hoverpop(document.querySelectorAll("stacked-bar-chart chart-segment"));

View file

@ -1,3 +1,5 @@
import { Hoverpop } from "/assets/js/modules/Hoverpop.mjs";
class ContactForm { class ContactForm {
static STORAGE_KEY = "contact_form_message"; static STORAGE_KEY = "contact_form_message";
@ -60,27 +62,7 @@ class ContactForm {
form ? (new ContactForm(form)) : ContactForm.removeSavedMessage(); form ? (new ContactForm(form)) : ContactForm.removeSavedMessage();
} }
// Social links hover // Social links hoverpop
{ {
const socialElementHover = (target) => { new Hoverpop(document.querySelectorAll("social"));
const element = target.querySelector("p");
target.classList.add("hovering");
target.addEventListener("mousemove", (event) => {
const x = event.layerX - (element.clientWidth / 2);
const y = event.layerY + element.clientHeight;
element.style.setProperty("transform", `translate(${x}px, ${y}px)`);
});
};
const elements = [...document.querySelectorAll("social")];
elements.forEach(element => {
element.addEventListener("mouseenter", () => socialElementHover(element));
element.addEventListener("mouseleave", () => {
elements.forEach(element => element.classList.remove("hovering"));
});
});
} }

View file

@ -63,15 +63,15 @@
<section class="social"> <section class="social">
<a href="mailto:victor@vlw.se"><social> <a href="mailto:victor@vlw.se"><social>
<?= VV::embed("public/assets/media/icons/email.svg") ?> <?= VV::embed("public/assets/media/icons/email.svg") ?>
<p>e-mail</p> <p data-hover>e-mail</p>
</social></a> </social></a>
<a href="matrix:@vlw:vlw.se"><social> <a href="matrix:@vlw:vlw.se"><social>
<?= VV::embed("public/assets/media/icons/matrix.svg") ?> <?= VV::embed("public/assets/media/icons/matrix.svg") ?>
<p>matrix</p> <p data-hover>matrix</p>
</social></a> </social></a>
<a href="https://web.libera.chat/#vlw.se"><social> <a href="https://web.libera.chat/#vlw.se"><social>
<?= VV::embed("public/assets/media/icons/libera.svg") ?> <?= VV::embed("public/assets/media/icons/libera.svg") ?>
<p>libera.chat</p> <p data-hover>libera.chat</p>
</social></a> </social></a>
</section> </section>
<?= VV::embed("public/assets/media/line.svg") ?> <?= VV::embed("public/assets/media/line.svg") ?>
@ -133,4 +133,4 @@
</form> </form>
</section> </section>
<script><?= VV::js("public/assets/js/pages/contact") ?></script> <script type="module"><?= VV::js("public/assets/js/pages/contact") ?></script>