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];
+ }
+ };
+
+?>
- Hi, I'm
+ Hi, I"m
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.
+
+
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 @@
= VV::embed("public/assets/media/line.svg") ?>
@@ -133,4 +133,4 @@
-
\ No newline at end of file
+
\ No newline at end of file