From e25b1b66893459509d270203105b8c568f9276e3 Mon Sep 17 00:00:00 2001 From: Victor Westerlund Date: Tue, 28 Jan 2025 14:45:52 +0000 Subject: [PATCH] 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 --- api/.env.example.ini | 13 +- api/composer.lock | 20 +-- api/endpoints/about/languages/DELETE.php | 22 +++ api/endpoints/about/languages/GET.php | 51 ++++++ api/endpoints/about/languages/POST.php | 97 ++++++++++++ api/src/Endpoints.php | 2 + public/about.php | 102 +++++++++++- public/assets/css/pages/about.css | 193 +++++++++++++++++++++++ public/assets/css/pages/contact.css | 2 +- public/assets/js/modules/Hoverpop.mjs | 32 ++++ public/assets/js/pages/about.js | 5 + public/assets/js/pages/contact.js | 26 +-- public/contact.php | 8 +- 13 files changed, 528 insertions(+), 45 deletions(-) create mode 100644 api/endpoints/about/languages/DELETE.php create mode 100644 api/endpoints/about/languages/GET.php create mode 100644 api/endpoints/about/languages/POST.php create mode 100644 public/assets/js/modules/Hoverpop.mjs diff --git a/api/.env.example.ini b/api/.env.example.ini index 163a056..04c3c11 100755 --- a/api/.env.example.ini +++ b/api/.env.example.ini @@ -5,4 +5,15 @@ pass = "" [databases] vlw = "" -battlestation = "" \ No newline at end of file +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 = "" \ No newline at end of file diff --git a/api/composer.lock b/api/composer.lock index d46e22b..3887c2d 100755 --- a/api/composer.lock +++ b/api/composer.lock @@ -8,17 +8,11 @@ "packages": [ { "name": "reflect/plugin-rules", - "version": "1.5.0", + "version": "1.5.1", "source": { "type": "git", - "url": "https://github.com/VictorWesterlund/reflect-rules-plugin.git", - "reference": "9c837fd1944133edfed70a63ce8b32bf67f0d94b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/VictorWesterlund/reflect-rules-plugin/zipball/9c837fd1944133edfed70a63ce8b32bf67f0d94b", - "reference": "9c837fd1944133edfed70a63ce8b32bf67f0d94b", - "shasum": "" + "url": "https://codeberg.org/reflect/reflect-rules-plugin", + "reference": "df150f0d860dbc2311e5e2fcb2fac36ee52db56b" }, "type": "library", "autoload": { @@ -37,11 +31,7 @@ } ], "description": "Add request search paramter and request body constraints to an API built with Reflect", - "support": { - "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" + "time": "2024-11-20T10:39:33+00:00" }, { "name": "victorwesterlund/xenum", @@ -121,5 +111,5 @@ "prefer-lowest": false, "platform": [], "platform-dev": [], - "plugin-api-version": "2.0.0" + "plugin-api-version": "2.3.0" } diff --git a/api/endpoints/about/languages/DELETE.php b/api/endpoints/about/languages/DELETE.php new file mode 100644 index 0000000..c7a4dab --- /dev/null +++ b/api/endpoints/about/languages/DELETE.php @@ -0,0 +1,22 @@ +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()); + } + } \ No newline at end of file diff --git a/api/endpoints/about/languages/POST.php b/api/endpoints/about/languages/POST.php new file mode 100644 index 0000000..1b3eb0f --- /dev/null +++ b/api/endpoints/about/languages/POST.php @@ -0,0 +1,97 @@ +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); + } + } \ No newline at end of file diff --git a/api/src/Endpoints.php b/api/src/Endpoints.php index 7ce9a17..e5150e0 100644 --- a/api/src/Endpoints.php +++ b/api/src/Endpoints.php @@ -7,6 +7,8 @@ // Enum of all available VLW endpoints grouped by category enum Endpoints: string { + case ABOUT_LANGUAGES = "/about/languages"; + case SEARCH = "/search"; case MESSAGES = "/messages"; diff --git a/public/about.php b/public/about.php index 37cecd9..4791029 100644 --- a/public/about.php +++ b/public/about.php @@ -1,12 +1,110 @@ +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]; + } + }; + +?>
- +

Victor Westerlund

I​'m a full-stack web developer from Sweden.

-

The <programming/markup/command/query/whatever>-languages I currently use the most are (in a mostly accurate decending order): PHP, JavaScript, CSS, MySQL, TypeScript, Python, SQLite, Bash, and HTML.

+

I used to list the <programming/markup/command/whatever>-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 automatically pulls the total bytes for each language from my public mirrors and sources on Forgejo.

+
+
+ + + all() as $lang => $bytes): ?> + + get_percent_str($lang) ?>
( bytes)
+ +
+ + +
+ + + all() as $lang => $bytes): ?> + +

get_percent_str($lang) ?>

+

+

get_bytes_str($lang) ?>

+ +
+ + +
+ + + all() as $lang => $bytes): ?> + + get_percent_str($lang) ?>
( bytes)
+ +
+ + +
+

This website

diff --git a/public/assets/css/pages/about.css b/public/assets/css/pages/about.css index dc66092..42f855a 100644 --- a/public/assets/css/pages/about.css +++ b/public/assets/css/pages/about.css @@ -3,6 +3,15 @@ :root { --primer-color-accent: 148, 255, 21; --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 { @@ -45,6 +54,147 @@ section.about p i:not(:hover) { 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 */ div.interests { @@ -85,4 +235,47 @@ div.interests p { -webkit-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; + } } \ No newline at end of file diff --git a/public/assets/css/pages/contact.css b/public/assets/css/pages/contact.css index a1efd9a..1e8adda 100644 --- a/public/assets/css/pages/contact.css +++ b/public/assets/css/pages/contact.css @@ -71,7 +71,7 @@ section.social social:hover { fill: var(--color-accent); } -section.social social.hovering p { +section.social social p.hovering { display: initial; } diff --git a/public/assets/js/modules/Hoverpop.mjs b/public/assets/js/modules/Hoverpop.mjs new file mode 100644 index 0000000..34b085c --- /dev/null +++ b/public/assets/js/modules/Hoverpop.mjs @@ -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"))); + } +} \ No newline at end of file diff --git a/public/assets/js/pages/about.js b/public/assets/js/pages/about.js index bc933f2..0562f27 100644 --- a/public/assets/js/pages/about.js +++ b/public/assets/js/pages/about.js @@ -1,3 +1,5 @@ +import { Hoverpop } from "/assets/js/modules/Hoverpop.mjs"; + const randomIntFromInterval = (min, max) => { return Math.floor(Math.random() * (max - min + 1) + min); } @@ -65,3 +67,6 @@ const implodeInterests = () => { interestsElement.addEventListener(canHover ? "mouseleave" : "touchend", () => implodeInterests()); } + +// Languages stacking bar chart hoverpop +new Hoverpop(document.querySelectorAll("stacked-bar-chart chart-segment")); \ No newline at end of file diff --git a/public/assets/js/pages/contact.js b/public/assets/js/pages/contact.js index e42bdab..45f8a85 100644 --- a/public/assets/js/pages/contact.js +++ b/public/assets/js/pages/contact.js @@ -1,3 +1,5 @@ +import { Hoverpop } from "/assets/js/modules/Hoverpop.mjs"; + class ContactForm { static STORAGE_KEY = "contact_form_message"; @@ -60,27 +62,7 @@ class ContactForm { form ? (new ContactForm(form)) : ContactForm.removeSavedMessage(); } -// Social links hover +// Social links hoverpop { - const socialElementHover = (target) => { - 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")); - }); - }); + new Hoverpop(document.querySelectorAll("social")); } \ No newline at end of file diff --git a/public/contact.php b/public/contact.php index 51c2e6d..2223317 100644 --- a/public/contact.php +++ b/public/contact.php @@ -63,15 +63,15 @@ @@ -133,4 +133,4 @@
- \ No newline at end of file + \ No newline at end of file