mirror of
https://codeberg.org/vlw/vlw.se.git
synced 2025-09-13 21:13:40 +02:00
Compare commits
2 commits
ff7d4f5397
...
e25b1b6689
Author | SHA1 | Date | |
---|---|---|---|
e25b1b6689 | |||
3b51458dd4 |
54 changed files with 1244 additions and 402 deletions
|
@ -5,4 +5,15 @@ pass = ""
|
|||
|
||||
[databases]
|
||||
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
20
api/composer.lock
generated
|
@ -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"
|
||||
}
|
||||
|
|
22
api/endpoints/about/languages/DELETE.php
Normal file
22
api/endpoints/about/languages/DELETE.php
Normal 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);
|
||||
}
|
||||
}
|
51
api/endpoints/about/languages/GET.php
Normal file
51
api/endpoints/about/languages/GET.php
Normal 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());
|
||||
}
|
||||
}
|
97
api/endpoints/about/languages/POST.php
Normal file
97
api/endpoints/about/languages/POST.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -39,10 +39,7 @@
|
|||
->min(1)
|
||||
->max(parent::MYSQL_TEXT_MAX_LENGTH),
|
||||
|
||||
(new Rules(WorkModel::IS_LISTABLE->value))
|
||||
->type(Type::BOOLEAN),
|
||||
|
||||
(new Rules(WorkModel::IS_READABLE->value))
|
||||
(new Rules(WorkModel::IS_LISTED->value))
|
||||
->type(Type::BOOLEAN),
|
||||
|
||||
(new Rules(WorkModel::DATE_MODIFIED->value))
|
||||
|
|
|
@ -15,6 +15,8 @@
|
|||
require_once Path::root("src/databases/VLWdb.php");
|
||||
require_once Path::root("src/databases/models/Work/Work.php");
|
||||
|
||||
const PARAM_LIMIT = "limit";
|
||||
|
||||
class GET_Work extends VLWdb {
|
||||
protected Ruleset $ruleset;
|
||||
|
||||
|
@ -35,11 +37,7 @@
|
|||
->type(Type::STRING)
|
||||
->max(parent::MYSQL_TEXT_MAX_LENGTH),
|
||||
|
||||
(new Rules(WorkModel::IS_LISTABLE->value))
|
||||
->type(Type::BOOLEAN)
|
||||
->default(true),
|
||||
|
||||
(new Rules(WorkModel::IS_READABLE->value))
|
||||
(new Rules(WorkModel::IS_LISTED->value))
|
||||
->type(Type::BOOLEAN)
|
||||
->default(true),
|
||||
|
||||
|
@ -51,15 +49,26 @@
|
|||
(new Rules(WorkModel::DATE_CREATED->value))
|
||||
->type(Type::NUMBER)
|
||||
->min(1)
|
||||
->max(parent::MYSQL_INT_MAX_LENGTH),
|
||||
|
||||
(new Rules(PARAM_LIMIT))
|
||||
->type(Type::NUMBER)
|
||||
->type(Type::NULL)
|
||||
->min(1)
|
||||
->max(parent::MYSQL_INT_MAX_LENGTH)
|
||||
->default(null)
|
||||
]);
|
||||
|
||||
parent::__construct(Databases::VLW, $this->ruleset);
|
||||
}
|
||||
|
||||
public function main(): Response {
|
||||
// Use copy of search paramters as filters
|
||||
// Use search parameters from model as filters
|
||||
$filters = $_GET;
|
||||
// Unset keys not included in database model from filter
|
||||
foreach (array_diff(array_keys($_GET), WorkModel::values()) as $k) {
|
||||
unset($filters[$k]);
|
||||
}
|
||||
|
||||
// Do a wildcard search on the title column if provided
|
||||
if (array_key_exists(WorkModel::TITLE->value, $_GET)) {
|
||||
|
@ -78,12 +87,12 @@
|
|||
$response = $this->db->for(WorkModel::TABLE)
|
||||
->where($filters)
|
||||
->order([WorkModel::DATE_CREATED->value => "DESC"])
|
||||
->limit($_GET[PARAM_LIMIT])
|
||||
->select([
|
||||
WorkModel::ID->value,
|
||||
WorkModel::TITLE->value,
|
||||
WorkModel::SUMMARY->value,
|
||||
WorkModel::IS_LISTABLE->value,
|
||||
WorkModel::IS_READABLE->value,
|
||||
WorkModel::IS_LISTED->value,
|
||||
WorkModel::DATE_YEAR->value,
|
||||
WorkModel::DATE_MONTH->value,
|
||||
WorkModel::DATE_DAY->value,
|
||||
|
|
|
@ -47,10 +47,7 @@
|
|||
->min(1)
|
||||
->max(parent::MYSQL_TEXT_MAX_LENGTH),
|
||||
|
||||
(new Rules(WorkModel::IS_LISTABLE->value))
|
||||
->type(Type::BOOLEAN),
|
||||
|
||||
(new Rules(WorkModel::IS_READABLE->value))
|
||||
(new Rules(WorkModel::IS_LISTED->value))
|
||||
->type(Type::BOOLEAN),
|
||||
|
||||
(new Rules(WorkModel::DATE_MODIFIED->value))
|
||||
|
|
|
@ -41,11 +41,7 @@
|
|||
->max(parent::MYSQL_TEXT_MAX_LENGTH)
|
||||
->default(null),
|
||||
|
||||
(new Rules(WorkModel::IS_LISTABLE->value))
|
||||
->type(Type::BOOLEAN)
|
||||
->default(false),
|
||||
|
||||
(new Rules(WorkModel::IS_READABLE->value))
|
||||
(new Rules(WorkModel::IS_LISTED->value))
|
||||
->type(Type::BOOLEAN)
|
||||
->default(false),
|
||||
|
||||
|
|
|
@ -38,12 +38,11 @@
|
|||
WorkActionsModel::REF_WORK_ID->value,
|
||||
WorkActionsModel::DISPLAY_TEXT->value,
|
||||
WorkActionsModel::HREF->value,
|
||||
WorkActionsModel::CLASS_LIST->value,
|
||||
WorkActionsModel::EXTERNAL->value
|
||||
WorkActionsModel::CLASS_LIST->value
|
||||
]);
|
||||
|
||||
return $response->num_rows > 0
|
||||
? new Response($response->fetch_all(MYSQLI_ASSOC))
|
||||
? new Response(parent::index_array_by_key($response->fetch_all(MYSQLI_ASSOC), WorkActionsModel::REF_WORK_ID->value))
|
||||
: new Response([], 404);
|
||||
}
|
||||
}
|
|
@ -50,11 +50,7 @@
|
|||
(new Rules(WorkActionsModel::CLASS_LIST->value))
|
||||
->type(Type::ARRAY)
|
||||
->min(1)
|
||||
->default([]),
|
||||
|
||||
(new Rules(WorkActionsModel::EXTERNAL->value))
|
||||
->type(Type::BOOLEAN)
|
||||
->default(false)
|
||||
->default([])
|
||||
]);
|
||||
|
||||
parent::__construct(Databases::VLW, $this->ruleset);
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
]);
|
||||
|
||||
return $response->num_rows > 0
|
||||
? new Response($response->fetch_all(MYSQLI_ASSOC))
|
||||
? new Response(parent::index_array_by_key($response->fetch_all(MYSQLI_ASSOC), WorkTagsModel::REF_WORK_ID->value))
|
||||
: new Response([], 404);
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -37,6 +37,11 @@
|
|||
);
|
||||
}
|
||||
|
||||
// Bail out if provided ReflectRules\Ruleset is invalid
|
||||
private static function eval_ruleset_or_exit(Ruleset $ruleset): ?Response {
|
||||
return !$ruleset->is_valid() ? new Response($ruleset->get_errors(), 422) : null;
|
||||
}
|
||||
|
||||
// Generate and return UUID4 string
|
||||
public static function gen_uuid4(): string {
|
||||
return sprintf("%04x%04x-%04x-%04x-%04x-%04x%04x%04x",
|
||||
|
@ -76,8 +81,21 @@
|
|||
return $filters;
|
||||
}
|
||||
|
||||
// Bail out if provided ReflectRules\Ruleset is invalid
|
||||
private static function eval_ruleset_or_exit(Ruleset $ruleset): ?Response {
|
||||
return !$ruleset->is_valid() ? new Response($ruleset->get_errors(), 422) : null;
|
||||
public function index_array_by_key(array $input, string $key): array {
|
||||
$output = [];
|
||||
|
||||
foreach ($input as $item) {
|
||||
$idx = $item[$key];
|
||||
|
||||
// Create entry for key in output array if first item
|
||||
if (!array_key_exists($idx, $output)) {
|
||||
$output[$idx] = [];
|
||||
}
|
||||
|
||||
// Append item to array of array by key
|
||||
$output[$idx][] = $item;
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
}
|
|
@ -2,18 +2,21 @@
|
|||
|
||||
namespace VLW\API\Databases\VLWdb\Models\Work;
|
||||
|
||||
use victorwesterlund\xEnum;
|
||||
|
||||
enum WorkModel: string {
|
||||
use xEnum;
|
||||
|
||||
const TABLE = "work";
|
||||
|
||||
case ID = "id";
|
||||
case TITLE = "title";
|
||||
case SUMMARY = "summary";
|
||||
case COVER_SRCSET = "cover_srcset";
|
||||
case IS_LISTABLE = "is_listable";
|
||||
case IS_READABLE = "is_readable";
|
||||
case DATE_YEAR = "date_year";
|
||||
case DATE_MONTH = "date_month";
|
||||
case DATE_DAY = "date_day";
|
||||
case DATE_MODIFIED = "date_modified";
|
||||
case DATE_CREATED = "date_created";
|
||||
case ID = "id";
|
||||
case TITLE = "title";
|
||||
case SUMMARY = "summary";
|
||||
case COVER_SRCSET = "cover_srcset";
|
||||
case IS_LISTED = "is_listed";
|
||||
case DATE_YEAR = "date_year";
|
||||
case DATE_MONTH = "date_month";
|
||||
case DATE_DAY = "date_day";
|
||||
case DATE_MODIFIED = "date_modified";
|
||||
case DATE_CREATED = "date_created";
|
||||
}
|
|
@ -6,8 +6,10 @@
|
|||
const TABLE = "work_actions";
|
||||
|
||||
case REF_WORK_ID = "ref_work_id";
|
||||
case ICON_PREFIX = "icon_prefix";
|
||||
case ICON_SUFFIX = "icon_suffix";
|
||||
case ORDER_IDX = "order_idx";
|
||||
case DISPLAY_TEXT = "display_text";
|
||||
case HREF = "href";
|
||||
case CLASS_LIST = "class_list";
|
||||
case EXTERNAL = "external";
|
||||
}
|
15
api/src/databases/models/Work/WorkNamespaces.php
Normal file
15
api/src/databases/models/Work/WorkNamespaces.php
Normal file
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
namespace VLW\API\Databases\VLWdb\Models\Work;
|
||||
|
||||
enum WorkNamespacesModel: string {
|
||||
const TABLE = "work_actions";
|
||||
|
||||
case REF_WORK_ID = "ref_work_id";
|
||||
case ICON_PREFIX = "icon_prefix";
|
||||
case ICON_SUFFIX = "icon_suffix";
|
||||
case ORDER_IDX = "order_idx";
|
||||
case DISPLAY_TEXT = "display_text";
|
||||
case HREF = "href";
|
||||
case CLASS_LIST = "class_list";
|
||||
}
|
|
@ -10,6 +10,7 @@
|
|||
case VLW;
|
||||
case RELEASE;
|
||||
case WEBSITE;
|
||||
case REPO;
|
||||
}
|
||||
|
||||
enum WorkTagsModel: string {
|
||||
|
|
102
public/about.php
102
public/about.php
|
@ -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>
|
||||
<section class="intro">
|
||||
<h2 aria-hidden="true">Hi, I'm</h2>
|
||||
<h2 aria-hidden="true">Hi, I"m</h2>
|
||||
<h1>Victor Westerlund</h1>
|
||||
</section>
|
||||
<hr aria-hidden="true">
|
||||
<section class="about">
|
||||
<p>I​'m a full-stack web developer from Sweden.</p>
|
||||
<p>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.</p>
|
||||
<p>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 <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 class="about">
|
||||
<h2>This website</h2>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -71,7 +71,7 @@ section.social social:hover {
|
|||
fill: var(--color-accent);
|
||||
}
|
||||
|
||||
section.social social.hovering p {
|
||||
section.social social p.hovering {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,9 @@
|
|||
:root {
|
||||
--primer-color-accent: 3, 255, 219;
|
||||
--color-accent: rgb(var(--primer-color-accent));
|
||||
|
||||
--color-reflect: 220, 26, 0;
|
||||
--color-vegvisir: 0, 128, 255;
|
||||
}
|
||||
|
||||
vv-shell {
|
||||
|
@ -16,179 +19,134 @@ vv-shell {
|
|||
|
||||
/* # Sections */
|
||||
|
||||
/* ## Git */
|
||||
/* ## Hero */
|
||||
|
||||
section.git {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
section.hero {
|
||||
--color-accent: rgba(255, 255, 255);
|
||||
|
||||
display: grid;
|
||||
gap: var(--padding);
|
||||
background-color: rgba(var(--primer-color-accent), .1);
|
||||
padding: calc(var(--padding) * 1.5);
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
}
|
||||
|
||||
section.hero .item {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
section.git svg {
|
||||
fill: white;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
section.git .buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
section.hero .wrapper {
|
||||
gap: var(--padding);
|
||||
}
|
||||
|
||||
/* ## Timeline */
|
||||
|
||||
section.timeline {
|
||||
--timestamp-gap: calc(var(--padding) / 2);
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
section.timeline :is(.year, .month, .day) {
|
||||
display: grid;
|
||||
grid-template-columns: calc(40px + var(--timestamp-gap)) 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
section.timeline .track {
|
||||
--opacity: .15;
|
||||
--width: 2%;
|
||||
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%, transparent calc(50% - var(--width)),
|
||||
rgba(255, 255, 255, var(--opacity)) calc(50% - var(--width)), rgba(255, 255, 255, var(--opacity)) calc(50% + var(--width)),
|
||||
transparent calc(50% + var(--width)), transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
section.timeline .track p {
|
||||
position: sticky;
|
||||
top: calc(var(--running-size) + var(--padding));
|
||||
padding: calc(var(--padding) / 2) 0;
|
||||
background-color: black;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
section.timeline :not(.year) > .track p::before {
|
||||
content: "/ ";
|
||||
color: rgba(255, 255, 255, .3);
|
||||
}
|
||||
|
||||
/* ### Item */
|
||||
|
||||
section.timeline .items .item {
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
position: relative;
|
||||
align-items: baseline;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--padding) / 2);
|
||||
padding: var(--padding);
|
||||
padding: calc(var(--padding) * 1.5);
|
||||
}
|
||||
|
||||
section.timeline .items .item + .item {
|
||||
border-top: solid 2px rgba(255, 255, 255, .2);
|
||||
section.hero .item .title {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: var(--padding);
|
||||
grid-template-columns: 40px 1fr;
|
||||
}
|
||||
|
||||
section.timeline .items .item:first-of-type {
|
||||
margin-top: var(--padding);
|
||||
border-top: solid 2px var(--color-accent);
|
||||
}
|
||||
|
||||
/* No border style for the latest item (from the top) in the list */
|
||||
section.timeline .year:first-of-type .month:first-of-type .day:first-of-type .items .item:first-of-type {
|
||||
margin-top: unset;
|
||||
border-top: unset;
|
||||
}
|
||||
|
||||
section.timeline .items .item .tags {
|
||||
display: flex;
|
||||
gap: calc(var(--padding) / 2);
|
||||
}
|
||||
|
||||
section.timeline .items .item .tags .tag {
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
color: rgba(255, 255, 255, .7);
|
||||
background-color: rgba(255, 255, 255, .15);
|
||||
section.hero .item .title svg {
|
||||
height: 3em;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
section.timeline .items .item img {
|
||||
max-width: 100%;
|
||||
height: 250px;
|
||||
section.hero .actions {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
section.timeline .items .item .actions {
|
||||
margin-top: 7px;
|
||||
/* ### Vegivisr */
|
||||
|
||||
section.hero .item.vegvisir {
|
||||
--color-accent: var(--color-vegvisir);
|
||||
|
||||
color: rgb(var(--color-vegvisir));
|
||||
background-color: rgba(var(--color-vegvisir), .1);
|
||||
}
|
||||
|
||||
/* ## Note */
|
||||
/* ### Reflect */
|
||||
|
||||
section.note {
|
||||
text-align: center;
|
||||
section.hero .item.reflect {
|
||||
--color-accent: var(--color-reflect);
|
||||
|
||||
color: rgb(var(--color-reflect));
|
||||
background-color: rgba(var(--color-reflect), .2);
|
||||
}
|
||||
|
||||
/* ## Heading */
|
||||
|
||||
section.heading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
section.heading svg {
|
||||
fill: white;
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
/* ## Featured */
|
||||
|
||||
section.featured {
|
||||
display: grid;
|
||||
gap: var(--padding);
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
}
|
||||
|
||||
section.featured featured-item {
|
||||
display: flex;
|
||||
fill: white;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
align-items: baseline;
|
||||
flex-direction: column;
|
||||
padding: var(--padding);
|
||||
background-color: rgba(255, 255, 255, .1);
|
||||
}
|
||||
|
||||
section.featured featured-item .title {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: calc(var(--padding) / 2);
|
||||
}
|
||||
|
||||
section.featured featured-item .title svg {
|
||||
height: 2em;
|
||||
fill: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ### Languages */
|
||||
|
||||
/* ### Actions */
|
||||
|
||||
section.featured featured-item .actions {
|
||||
gap: 5px;
|
||||
display: flex;
|
||||
padding-top: var(--padding);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* # Size queries */
|
||||
|
||||
@media (min-width: 460px) {
|
||||
section.git .buttons {
|
||||
flex-direction: row;
|
||||
@media (min-width: 600px) {
|
||||
section.hero {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
section.git {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 1fr 400px;
|
||||
align-items: center;
|
||||
gap: calc(var(--padding) * 1.5);
|
||||
}
|
||||
|
||||
section.git svg {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
section.git .buttons {
|
||||
justify-content: end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
section.timeline {
|
||||
padding: unset;
|
||||
}
|
||||
|
||||
section.timeline .track {
|
||||
position: relative;
|
||||
background: unset;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
section.timeline .track p {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
section.timeline :is(.years, .year, .months, .month, .days, .day) {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
section.timeline .items {
|
||||
position: relative;
|
||||
left: -140px;
|
||||
}
|
||||
|
||||
section.timeline .items .item {
|
||||
padding: calc(var(--padding) * 1.5) 0;
|
||||
width: calc(100vw - (var(--padding) * 3.5));
|
||||
}
|
||||
|
||||
section.timeline .items .item:first-of-type {
|
||||
border-top-color: rgba(var(--primer-color-accent), .2);
|
||||
}
|
||||
|
||||
section.timeline .year:first-of-type .month:first-of-type .day:first-of-type .items .item:first-of-type {
|
||||
margin-top: var(--padding);
|
||||
section.featured {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
188
public/assets/css/pages/work/timeline.css
Normal file
188
public/assets/css/pages/work/timeline.css
Normal file
|
@ -0,0 +1,188 @@
|
|||
/* # Overrides */
|
||||
|
||||
:root {
|
||||
--primer-color-accent: 3, 255, 219;
|
||||
--color-accent: rgb(var(--primer-color-accent));
|
||||
}
|
||||
|
||||
vv-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--padding);
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
overflow-x: initial;
|
||||
}
|
||||
|
||||
/* # Sections */
|
||||
|
||||
/* ## Git */
|
||||
|
||||
section.git {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--padding);
|
||||
background-color: rgba(var(--primer-color-accent), .1);
|
||||
padding: calc(var(--padding) * 1.5);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
section.git svg {
|
||||
fill: white;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
section.git .buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--padding);
|
||||
}
|
||||
|
||||
/* ## Timeline */
|
||||
|
||||
section.timeline {
|
||||
--timestamp-gap: calc(var(--padding) / 2);
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
section.timeline :is(.year, .month, .day) {
|
||||
display: grid;
|
||||
grid-template-columns: calc(40px + var(--timestamp-gap)) 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
section.timeline .track {
|
||||
--opacity: .15;
|
||||
--width: 2%;
|
||||
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%, transparent calc(50% - var(--width)),
|
||||
rgba(255, 255, 255, var(--opacity)) calc(50% - var(--width)), rgba(255, 255, 255, var(--opacity)) calc(50% + var(--width)),
|
||||
transparent calc(50% + var(--width)), transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
section.timeline .track p {
|
||||
position: sticky;
|
||||
top: calc(var(--running-size) + var(--padding));
|
||||
padding: calc(var(--padding) / 2) 0;
|
||||
background-color: black;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
section.timeline :not(.year) > .track p::before {
|
||||
content: "/ ";
|
||||
color: rgba(255, 255, 255, .3);
|
||||
}
|
||||
|
||||
/* ### Item */
|
||||
|
||||
section.timeline .items .item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--padding) / 2);
|
||||
padding: var(--padding);
|
||||
}
|
||||
|
||||
section.timeline .items .item + .item {
|
||||
border-top: solid 2px rgba(255, 255, 255, .2);
|
||||
}
|
||||
|
||||
section.timeline .items .item:first-of-type {
|
||||
margin-top: var(--padding);
|
||||
border-top: solid 2px var(--color-accent);
|
||||
}
|
||||
|
||||
/* No border style for the latest item (from the top) in the list */
|
||||
section.timeline .year:first-of-type .month:first-of-type .day:first-of-type .items .item:first-of-type {
|
||||
margin-top: unset;
|
||||
border-top: unset;
|
||||
}
|
||||
|
||||
section.timeline .items .item .tags {
|
||||
display: flex;
|
||||
gap: calc(var(--padding) / 2);
|
||||
}
|
||||
|
||||
section.timeline .items .item .tags .tag {
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
color: rgba(255, 255, 255, .7);
|
||||
background-color: rgba(255, 255, 255, .15);
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
section.timeline .items .item img {
|
||||
max-width: 100%;
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
section.timeline .items .item .actions {
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
/* # Size queries */
|
||||
|
||||
@media (min-width: 460px) {
|
||||
section.git .buttons {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
section.timeline {
|
||||
padding: unset;
|
||||
}
|
||||
|
||||
section.timeline .track {
|
||||
position: relative;
|
||||
background: unset;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
section.timeline .track p {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
section.timeline :is(.years, .year, .months, .month, .days, .day) {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
section.timeline .items {
|
||||
position: relative;
|
||||
left: -140px;
|
||||
}
|
||||
|
||||
section.timeline .items .item {
|
||||
padding: calc(var(--padding) * 1.5) 0;
|
||||
width: calc(100vw - (var(--padding) * 3.5));
|
||||
}
|
||||
|
||||
section.timeline .items .item:first-of-type {
|
||||
border-top-color: rgba(var(--primer-color-accent), .2);
|
||||
}
|
||||
|
||||
section.timeline .year:first-of-type .month:first-of-type .day:first-of-type .items .item:first-of-type {
|
||||
margin-top: var(--padding);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
section.git {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 1fr 400px;
|
||||
align-items: center;
|
||||
gap: calc(var(--padding) * 1.5);
|
||||
}
|
||||
|
||||
section.git svg {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
section.git .buttons {
|
||||
justify-content: end;
|
||||
}
|
||||
}
|
15
public/assets/css/pages/work/wip.css
Normal file
15
public/assets/css/pages/work/wip.css
Normal file
|
@ -0,0 +1,15 @@
|
|||
/* # Overrides */
|
||||
|
||||
:root {
|
||||
--primer-color-accent: 3, 255, 219;
|
||||
--color-accent: rgb(var(--primer-color-accent));
|
||||
}
|
||||
|
||||
vv-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--padding);
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
overflow-x: initial;
|
||||
}
|
|
@ -285,6 +285,7 @@ search-results {
|
|||
transform: scale(.99);
|
||||
transform-origin: 100% 0;
|
||||
overflow-y: scroll;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
search-results:not([vv-page]) {
|
||||
|
|
32
public/assets/js/modules/Hoverpop.mjs
Normal file
32
public/assets/js/modules/Hoverpop.mjs
Normal 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")));
|
||||
}
|
||||
}
|
|
@ -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"));
|
|
@ -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"));
|
||||
}
|
1
public/assets/media/icons/reflect.svg
Normal file
1
public/assets/media/icons/reflect.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg viewBox="0 0 60.965 60.965" xmlns="http://www.w3.org/2000/svg"><path style="fill:#dc1a00;fill-opacity:1;stroke-width:.529167" d="M0 0h135.467v135.467H0z" transform="matrix(.45004 0 0 .45004 0 0)"/><g style="fill:#fff;fill-opacity:1"><g fill="none" style="fill:#fff;fill-opacity:1"><path class="solid" d="M12 22 0 0h24z" style="display:inline;fill:#fff;fill-opacity:1" transform="matrix(.95357 0 0 .95357 19.04 10.01)"/><path class="stroke" d="M12 17.823 20.63 2H3.37L12 17.823M12 22 0 0h24z" style="display:inline;fill:#fff;fill-opacity:1" transform="matrix(.95357 0 0 .95357 19.04 10.01)"/></g><g opacity=".5" style="fill:#fff;fill-opacity:1"><path class="solid" d="M24 22 12 0h24z" style="display:inline;fill:#fff;fill-opacity:1" transform="matrix(-.95357 0 0 -.95357 53.368 51.968)"/><path class="stroke" d="M24 17.823 32.63 2H15.37L24 17.823M24 22 12 0h24z" style="display:inline;fill:#fff;fill-opacity:1" transform="matrix(-.95357 0 0 -.95357 53.368 51.968)"/></g></g></svg>
|
After Width: | Height: | Size: 984 B |
1
public/assets/media/icons/repo.svg
Normal file
1
public/assets/media/icons/repo.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 8 KiB |
1
public/assets/media/icons/star.svg
Normal file
1
public/assets/media/icons/star.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 5.1 KiB |
1
public/assets/media/icons/vegvisir.svg
Normal file
1
public/assets/media/icons/vegvisir.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg viewBox="0 0 60.965 60.965" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path style="fill:#0080ff;fill-opacity:1;stroke-width:.529167" d="M0 0h135.467v135.467H0z" transform="matrix(.45004 0 0 .45004 0 0)"/><g style="fill:#87ffff;fill-opacity:1"><g fill="none" style="fill:#87ffff;fill-opacity:1"><path class="solid" d="M12 22 0 0h24z" style="display:inline;fill:#87ffff;fill-opacity:1" transform="matrix(-.95357 0 0 -.95357 41.925 30.482)"/><path class="stroke" d="M12 17.823 20.63 2H3.37L12 17.823M12 22 0 0h24z" style="display:inline;fill:#87ffff;fill-opacity:1" transform="matrix(-.95357 0 0 -.95357 41.925 30.482)"/></g><g fill="none" style="fill:#87ffff;fill-opacity:1"><path class="solid" d="M12 22 0 0h24z" style="display:inline;fill:#87ffff;fill-opacity:1" transform="matrix(.95357 0 0 .95357 19.04 30.482)"/><path class="stroke" d="M12 17.823 20.63 2H3.37L12 17.823M12 22 0 0h24z" style="display:inline;fill:#87ffff;fill-opacity:1" transform="matrix(.95357 0 0 .95357 19.04 30.482)"/></g><g opacity=".5" style="fill:#87ffff;fill-opacity:1"><path class="solid" d="M24 22 12 0h24z" style="display:inline;fill:#87ffff;fill-opacity:1" transform="matrix(0 .95357 -.95357 0 29.529 7.597)"/><path class="stroke" d="M24 17.823 32.63 2H15.37L24 17.823M24 22 12 0h24z" style="display:inline;fill:#87ffff;fill-opacity:1" transform="matrix(0 .95357 -.95357 0 29.529 7.597)"/></g><g opacity=".5" style="fill:#87ffff;fill-opacity:1"><path class="solid" d="M24 22 12 0h24z" style="display:inline;fill:#87ffff;fill-opacity:1" transform="matrix(0 -.95357 .95357 0 30.482 53.368)"/><path class="stroke" d="M24 17.823 32.63 2H15.37L24 17.823M24 22 12 0h24z" style="display:inline;fill:#87ffff;fill-opacity:1" transform="matrix(0 -.95357 .95357 0 30.482 53.368)"/></g></g></svg>
|
After Width: | Height: | Size: 1.7 KiB |
1
public/assets/media/icons/vw.svg
Normal file
1
public/assets/media/icons/vw.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 5.2 KiB |
BIN
public/assets/media/vegvisir.webm
Normal file
BIN
public/assets/media/vegvisir.webm
Normal file
Binary file not shown.
|
@ -63,15 +63,15 @@
|
|||
<section class="social">
|
||||
<a href="mailto:victor@vlw.se"><social>
|
||||
<?= VV::embed("public/assets/media/icons/email.svg") ?>
|
||||
<p>e-mail</p>
|
||||
<p data-hover>e-mail</p>
|
||||
</social></a>
|
||||
<a href="matrix:@vlw:vlw.se"><social>
|
||||
<?= VV::embed("public/assets/media/icons/matrix.svg") ?>
|
||||
<p>matrix</p>
|
||||
<p data-hover>matrix</p>
|
||||
</social></a>
|
||||
<a href="https://web.libera.chat/#vlw.se"><social>
|
||||
<?= VV::embed("public/assets/media/icons/libera.svg") ?>
|
||||
<p>libera.chat</p>
|
||||
<p data-hover>libera.chat</p>
|
||||
</social></a>
|
||||
</section>
|
||||
<?= VV::embed("public/assets/media/line.svg") ?>
|
||||
|
@ -133,4 +133,4 @@
|
|||
</form>
|
||||
</section>
|
||||
|
||||
<script><?= VV::js("public/assets/js/pages/contact") ?></script>
|
||||
<script type="module"><?= VV::js("public/assets/js/pages/contact") ?></script>
|
|
@ -66,11 +66,7 @@
|
|||
<?php foreach ($actions->json() as $action): ?>
|
||||
|
||||
<?php // Bind VV Interactions if link is same origin, else open in new tab ?>
|
||||
<?php if (!$action[WorkActionsModel::EXTERNAL->value]): ?>
|
||||
<a href="<?= $action[WorkActionsModel::HREF->value] ?>"><button class="inline <?= $action[WorkActionsModel::CLASS_LIST->value] ?>"><?= $action[WorkActionsModel::DISPLAY_TEXT->value] ?></button></a>
|
||||
<?php else: ?>
|
||||
<a href="<?= $action[WorkActionsModel::HREF->value] ?>" target="_blank"><button class="inline <?= $action[WorkActionsModel::CLASS_LIST->value] ?>"><?= $action[WorkActionsModel::DISPLAY_TEXT->value] ?></button></a>
|
||||
<?php endif; ?>
|
||||
<a href="<?= $action[WorkActionsModel::HREF->value] ?>" target="_blank"><button class="inline <?= $action[WorkActionsModel::CLASS_LIST->value] ?>"><?= $action[WorkActionsModel::DISPLAY_TEXT->value] ?></button></a>
|
||||
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
|
279
public/work.php
279
public/work.php
|
@ -1,181 +1,138 @@
|
|||
<?php
|
||||
|
||||
use Vegvisir\Path;
|
||||
use Reflect\Response;
|
||||
|
||||
use VLW\Client\API;
|
||||
use VLW\API\Endpoints;
|
||||
|
||||
use VLW\API\Databases\VLWdb\Models\Work\{
|
||||
WorkModel,
|
||||
WorkTagsModel,
|
||||
WorkActionsModel
|
||||
};
|
||||
use VLW\API\Databases\VLWdb\Models\Work\WorkModel;
|
||||
|
||||
require_once VV::root("src/client/API.php");
|
||||
require_once VV::root("api/src/Endpoints.php");
|
||||
|
||||
require_once VV::root("api/src/databases/models/Work/Work.php");
|
||||
require_once VV::root("api/src/databases/models/Work/WorkTags.php");
|
||||
require_once VV::root("api/src/databases/models/Work/WorkActions.php");
|
||||
|
||||
// Connect to VLW API
|
||||
$api = new API();
|
||||
// Number of items from the timeline to display on this page
|
||||
const TIMELINE_PREVIEW_LIMIT = 5;
|
||||
|
||||
// Retreive rows from work endpoints
|
||||
$resp_work = $api->call(Endpoints::WORK->value)->get();
|
||||
$work = new class extends API {
|
||||
const ERROR_MSG = "Something went wrong";
|
||||
|
||||
// Resolve tags and actions if we got work results
|
||||
if ($resp_work->ok) {
|
||||
$work_tags = $api->call(Endpoints::WORK_TAGS->value)->get()->json();
|
||||
$work_actions = $api->call(Endpoints::WORK_ACTIONS->value)->get()->json();
|
||||
private readonly Response $resp;
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct();
|
||||
|
||||
// Get work items from endpoint
|
||||
$this->resp = $this->call(Endpoints::WORK->value)->params([
|
||||
WorkModel::IS_LISTED->value => true
|
||||
])->get();
|
||||
}
|
||||
|
||||
private function get_item(string $key): array {
|
||||
$idx = array_search($key, array_column($this->resp->json(), WorkModel::ID->value));
|
||||
return $this->resp->json()[$idx];
|
||||
}
|
||||
|
||||
public function get_summary(string $key): string {
|
||||
return $this->resp->ok ? $this->get_item($key)[WorkModel::SUMMARY->value] : self::ERROR_MSG;
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
<style><?= VV::css("public/assets/css/pages/work") ?></style>
|
||||
|
||||
<section class="git">
|
||||
<?= VV::embed("public/assets/media/icons/codeberg.svg") ?>
|
||||
<p>I have moved most of my free open-source software <a href="https://giveupgithub.com">away from GitHub</a> to <a href="https://codeberg.org/vlw">Codeberg</a>. I also have a mirror of everything and sources for some smaller projects on <a href="https://git.vlw.se">Forgejo</a>.</p>
|
||||
<div class="buttons">
|
||||
<a href="https://codeberg.org/vlw"><button class="inline solid">Codeberg</button></a>
|
||||
<a href="https://git.vlw.se"><button class="inline">Forgejo</button></a>
|
||||
<section class="hero">
|
||||
<div class="item vegvisir">
|
||||
<div class="wrapper">
|
||||
<div class="title">
|
||||
<?= VV::embed("public/assets/media/icons/vegvisir.svg") ?>
|
||||
<h1>vegvisir</h1>
|
||||
</div>
|
||||
<p><?= $work->get_summary("vlw/vegvisir") ?></p>
|
||||
<div class="actions">
|
||||
<a href="https://vegvisir.vlw.se"><button class="inline">read more</button></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item reflect">
|
||||
<div class="wrapper">
|
||||
<div class="title">
|
||||
<?= VV::embed("public/assets/media/icons/reflect.svg") ?>
|
||||
<h1>reflect</h1>
|
||||
</div>
|
||||
<p><?= $work->get_summary("vlw/reflect") ?></p>
|
||||
<div class="actions">
|
||||
<a href="https://reflect.vlw.se"><button class="inline">read more</button></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if ($resp_work->ok): ?>
|
||||
<?php
|
||||
|
||||
/*
|
||||
Order response from endpoint into a multi-dimensional array.
|
||||
For example, a single item created at 14th of February 2024 would be ordered like this
|
||||
[2024 => [[02 => [14 => [<row_data>]]]]]
|
||||
*/
|
||||
|
||||
$rows = [];
|
||||
// Create array of arrays ordered by decending year, month, day, items
|
||||
foreach ($resp_work->json() as $row) {
|
||||
// Create array for current year if it doesn't exist
|
||||
if (!array_key_exists($row[WorkModel::DATE_YEAR->value], $rows)) {
|
||||
$rows[$row[WorkModel::DATE_YEAR->value]] = [];
|
||||
}
|
||||
|
||||
// Create array for current month if it doesn't exist
|
||||
if (!array_key_exists($row[WorkModel::DATE_MONTH->value], $rows[$row[WorkModel::DATE_YEAR->value]])) {
|
||||
$rows[$row[WorkModel::DATE_YEAR->value]][$row[WorkModel::DATE_MONTH->value]] = [];
|
||||
}
|
||||
|
||||
// Create array for current day if it doesn't exist
|
||||
if (!array_key_exists($row[WorkModel::DATE_DAY->value], $rows[$row[WorkModel::DATE_YEAR->value]][$row[WorkModel::DATE_MONTH->value]])) {
|
||||
$rows[$row[WorkModel::DATE_YEAR->value]][$row[WorkModel::DATE_MONTH->value]][$row[WorkModel::DATE_DAY->value]] = [];
|
||||
}
|
||||
|
||||
// Append item to ordered array
|
||||
$rows[$row[WorkModel::DATE_YEAR->value]][$row[WorkModel::DATE_MONTH->value]][$row[WorkModel::DATE_DAY->value]][] = $row;
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
<section class="timeline">
|
||||
<?php // Get year int from key and array of months for current year ?>
|
||||
<?php foreach($rows as $year => $months): ?>
|
||||
<div class="year">
|
||||
<div class="track">
|
||||
<p><?= $year ?></p>
|
||||
</div>
|
||||
|
||||
<div class="months">
|
||||
<?php // Get month int from key and array of days for current month ?>
|
||||
<?php foreach($months as $month => $days): ?>
|
||||
<div class="month">
|
||||
<div class="track">
|
||||
<?php // Append leading zero to month ?>
|
||||
<p><?= sprintf("%02d", $month) ?></p>
|
||||
</div>
|
||||
|
||||
<div class="days">
|
||||
<?php // Get day int from key and array of items for current day ?>
|
||||
<?php foreach($days as $day => $items): ?>
|
||||
<div class="day">
|
||||
<div class="track">
|
||||
<?php // Append leading zero to day ?>
|
||||
<p><?= sprintf("%02d", $day) ?></p>
|
||||
</div>
|
||||
|
||||
<div class="items">
|
||||
<?php foreach($items as $item): ?>
|
||||
<div class="item">
|
||||
|
||||
<?php // Get array index ids from tags array where work entity id matches ref_work_id ?>
|
||||
<?php $tag_ids = array_keys(array_column($work_tags, WorkTagsModel::REF_WORK_ID->value), $item[WorkModel::ID->value]); ?>
|
||||
|
||||
<?php // List tags if available ?>
|
||||
<?php if($tag_ids): ?>
|
||||
<div class="tags">
|
||||
<?php foreach($tag_ids as $tag_id): ?>
|
||||
<?php // Get tag details from tag array by index id ?>
|
||||
<?php $tag = $work_tags[$tag_id]; ?>
|
||||
|
||||
<p class="tag <?= $tag[WorkTagsModel::NAME->value] ?>"><?= $tag[WorkTagsModel::NAME->value] ?></p>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php // Show large heading if defined ?>
|
||||
<?php if (!empty($item[WorkModel::TITLE->value])): ?>
|
||||
<h2><?= $item[WorkModel::TITLE->value] ?></h2>
|
||||
<?php endif; ?>
|
||||
|
||||
<p><?= $item[WorkModel::SUMMARY->value] ?></p>
|
||||
|
||||
<?php // Get array index ids from actions array where work entity id matches ref_work_id ?>
|
||||
<?php $action_ids = array_keys(array_column($work_actions, WorkTagsModel::REF_WORK_ID->value), $item[WorkModel::ID->value]); ?>
|
||||
|
||||
<?php // List actions if defined for item ?>
|
||||
<?php if($action_ids): ?>
|
||||
<div class="actions">
|
||||
<?php foreach($action_ids as $action_id): ?>
|
||||
<?php
|
||||
// Get tag details from tag array by index id
|
||||
$action = $work_actions[$action_id];
|
||||
|
||||
$link_attr = !$action[WorkActionsModel::EXTERNAL->value]
|
||||
// Bind VV Interactions for local links
|
||||
? "vv='work' vv-call='navigate'"
|
||||
// Open external links in a new tab
|
||||
: "target='_blank'";
|
||||
|
||||
$link_href = $action[WorkActionsModel::HREF->value] === null
|
||||
// Navigate to work details page if no href is defined
|
||||
? "/work/{$item[WorkModel::ID->value]}"
|
||||
// Href is defined so use it directly
|
||||
: $action[WorkActionsModel::HREF->value];
|
||||
?>
|
||||
|
||||
<a href="<?= $link_href ?>" <?= $link_attr ?>><button class="inline <?= $action["class_list"] ?>"><?= $action["display_text"] ?></button></a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</section>
|
||||
<section class="note">
|
||||
<p>This is not really the end of the list. I will add some of my notable older work at some point.</p>
|
||||
</section>
|
||||
<?php else: ?>
|
||||
<p>Something went wrong!</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<script><?= VV::js("assets/js/pages/work") ?></script>
|
||||
<section class="featured">
|
||||
<featured-item>
|
||||
<div class="title">
|
||||
<?= VV::embed("public/assets/media/icons/vw.svg") ?>
|
||||
</div>
|
||||
<h3>vlw.se</h3>
|
||||
<p>Can I put my own website here, is that cheating? Maybe, but I think this site counts as the most important thing I've personally created. I've only used my own libraries and frameworks to create this website, so it kind of works as a live demonstration of many of my web projects bundled together.</p>
|
||||
<div class="actions">
|
||||
<a href="https://codeberg.org/vlw/vlw.se"><button class="inline">view source</button></a>
|
||||
</div>
|
||||
</featured-item>
|
||||
<featured-item>
|
||||
<div class="title">
|
||||
<?= VV::embed("public/assets/media/icons/vw.svg") ?>
|
||||
</div>
|
||||
<h3>Silly dabbles</h3>
|
||||
<p>I create silly things for fun to challenge myself sometimes, and putting them all on the timeline is not right. So I made an appropriately-themed and named page to highlight most of my "what if I could" projects.</p>
|
||||
<div class="actions">
|
||||
<a href="/playground"><button class="inline">playground</button></a>
|
||||
</div>
|
||||
</featured-item>
|
||||
<featured-item>
|
||||
<div class="title">
|
||||
<?= VV::embed("public/assets/media/icons/repo.svg") ?>
|
||||
</div>
|
||||
<h3>vlw/php-mysql</h3>
|
||||
<p><?= $work->get_summary("vlw/php-mysql") ?></p>
|
||||
<div class="actions">
|
||||
<a href="https://codeberg.org/vlw/php-mysql"><button class="inline">view source</button></a>
|
||||
</div>
|
||||
</featured-item>
|
||||
<featured-item>
|
||||
<div class="title">
|
||||
<?= VV::embed("public/assets/media/icons/star.svg") ?>
|
||||
</div>
|
||||
<h3>Website for iCellate Medical</h3>
|
||||
<p><?= $work->get_summary("icellate/website") ?></p>
|
||||
<div class="actions">
|
||||
<a href="/work/icellate/website"><button class="inline">read more</button></a>
|
||||
</div>
|
||||
</featured-item>
|
||||
<featured-item>
|
||||
<div class="title">
|
||||
<?= VV::embed("public/assets/media/icons/star.svg") ?>
|
||||
</div>
|
||||
<h3>Modernizing GeneMate by iCellate</h3>
|
||||
<p><?= $work->get_summary("icellate/genemate") ?></p>
|
||||
<div class="actions">
|
||||
<a href="/work/icellate/genemate"><button class="inline">read more</button></a>
|
||||
</div>
|
||||
</featured-item>
|
||||
<featured-item>
|
||||
<div class="title">
|
||||
<?= VV::embed("public/assets/media/icons/star.svg") ?>
|
||||
</div>
|
||||
<h3>Custom pages for Deltaco AB</h3>
|
||||
<p><?= $work->get_summary("deltaco/asyncapp") ?></p>
|
||||
<div class="actions">
|
||||
<a href="/work/deltaco/asyncapp"><button class="inline">read more</button></a>
|
||||
</div>
|
||||
</featured-item>
|
||||
</section>
|
||||
<section class="heading">
|
||||
<h1>latest projects</h1>
|
||||
</section>
|
||||
<?= VV::include("public/work/timeline?limit=" . TIMELINE_PREVIEW_LIMIT) ?>
|
||||
<section class="heading">
|
||||
<a href="/work/timeline"><button class="inline solid">view full timeline</button></a>
|
||||
</section>
|
1
public/work/deltaco/asyncapp.php
Normal file
1
public/work/deltaco/asyncapp.php
Normal file
|
@ -0,0 +1 @@
|
|||
<?= VV::include("public/work/wip") ?>
|
1
public/work/deltaco/distit.php
Normal file
1
public/work/deltaco/distit.php
Normal file
|
@ -0,0 +1 @@
|
|||
<?= VV::include("public/work/wip") ?>
|
1
public/work/deltaco/e-charge.php
Normal file
1
public/work/deltaco/e-charge.php
Normal file
|
@ -0,0 +1 @@
|
|||
<?= VV::include("public/work/wip") ?>
|
1
public/work/deltaco/office.php
Normal file
1
public/work/deltaco/office.php
Normal file
|
@ -0,0 +1 @@
|
|||
<?= VV::include("public/work/wip") ?>
|
1
public/work/deltaco/pdf-generator.php
Normal file
1
public/work/deltaco/pdf-generator.php
Normal file
|
@ -0,0 +1 @@
|
|||
<?= VV::include("public/work/wip") ?>
|
1
public/work/deltaco/reseller-form.php
Normal file
1
public/work/deltaco/reseller-form.php
Normal file
|
@ -0,0 +1 @@
|
|||
<?= VV::include("public/work/wip") ?>
|
1
public/work/icellate/genemate.php
Normal file
1
public/work/icellate/genemate.php
Normal file
|
@ -0,0 +1 @@
|
|||
<?= VV::include("public/work/wip") ?>
|
1
public/work/icellate/website.php
Normal file
1
public/work/icellate/website.php
Normal file
|
@ -0,0 +1 @@
|
|||
<?= VV::include("public/work/wip") ?>
|
1
public/work/itg/lan.php
Normal file
1
public/work/itg/lan.php
Normal file
|
@ -0,0 +1 @@
|
|||
<?= VV::include("public/work/wip") ?>
|
1
public/work/itg/upload.php
Normal file
1
public/work/itg/upload.php
Normal file
|
@ -0,0 +1 @@
|
|||
<?= VV::include("public/work/wip") ?>
|
184
public/work/timeline.php
Normal file
184
public/work/timeline.php
Normal file
|
@ -0,0 +1,184 @@
|
|||
<?php
|
||||
|
||||
use Vegvisir\Path;
|
||||
use Reflect\Response;
|
||||
|
||||
use VLW\Client\API;
|
||||
use VLW\API\Endpoints;
|
||||
|
||||
use VLW\API\Databases\VLWdb\Models\Work\{
|
||||
WorkModel,
|
||||
WorkTagsModel,
|
||||
WorkActionsModel
|
||||
};
|
||||
|
||||
require_once VV::root("src/client/API.php");
|
||||
require_once VV::root("api/src/Endpoints.php");
|
||||
|
||||
require_once VV::root("api/src/databases/models/Work/Work.php");
|
||||
require_once VV::root("api/src/databases/models/Work/WorkTags.php");
|
||||
require_once VV::root("api/src/databases/models/Work/WorkActions.php");
|
||||
|
||||
$work = new class extends API {
|
||||
private const API_PARAM_LIMIT = "limit";
|
||||
|
||||
private readonly Response $resp;
|
||||
private readonly Response $tags;
|
||||
private readonly Response $actions;
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct();
|
||||
|
||||
$this->resp = $this->call(Endpoints::WORK->value)->params([
|
||||
WorkModel::IS_LISTED->value => true,
|
||||
self::API_PARAM_LIMIT => $_GET[self::API_PARAM_LIMIT] ?? null
|
||||
])->get();
|
||||
|
||||
// Fetch metadata for work items if we got an ok from work endpoint
|
||||
if ($this->resp->ok) {
|
||||
$this->tags = $this->call(Endpoints::WORK_TAGS->value)->get();
|
||||
$this->actions = $this->call(Endpoints::WORK_ACTIONS->value)->get();
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Order response from endpoint into a multi-dimensional array.
|
||||
For example, a single item created at 14th of February 2024 would be ordered like this
|
||||
[2024 => [[02 => [14 => [<row_data>]]]]]
|
||||
*/
|
||||
public function get_timeline(): array {
|
||||
if (!$this->resp->ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$timeline = [];
|
||||
|
||||
// Create array of arrays ordered by decending year, month, day, items
|
||||
foreach ($this->resp->json() as $row) {
|
||||
// Create array for current year if it doesn't exist
|
||||
if (!array_key_exists($row[WorkModel::DATE_YEAR->value], $timeline)) {
|
||||
$timeline[$row[WorkModel::DATE_YEAR->value]] = [];
|
||||
}
|
||||
|
||||
// Create array for current month if it doesn't exist
|
||||
if (!array_key_exists($row[WorkModel::DATE_MONTH->value], $timeline[$row[WorkModel::DATE_YEAR->value]])) {
|
||||
$timeline[$row[WorkModel::DATE_YEAR->value]][$row[WorkModel::DATE_MONTH->value]] = [];
|
||||
}
|
||||
|
||||
// Create array for current day if it doesn't exist
|
||||
if (!array_key_exists($row[WorkModel::DATE_DAY->value], $timeline[$row[WorkModel::DATE_YEAR->value]][$row[WorkModel::DATE_MONTH->value]])) {
|
||||
$timeline[$row[WorkModel::DATE_YEAR->value]][$row[WorkModel::DATE_MONTH->value]][$row[WorkModel::DATE_DAY->value]] = [];
|
||||
}
|
||||
|
||||
// Append item to ordered array
|
||||
$timeline[$row[WorkModel::DATE_YEAR->value]][$row[WorkModel::DATE_MONTH->value]][$row[WorkModel::DATE_DAY->value]][] = $row;
|
||||
}
|
||||
|
||||
return $timeline;
|
||||
}
|
||||
|
||||
public function get_tags(string $key): array {
|
||||
if (!$this->resp->ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return in_array($key, $this->tags->json()) ? $this->tags->json()[$key] : [];
|
||||
}
|
||||
|
||||
public function get_actions(string $key): array {
|
||||
if (!$this->resp->ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_key_exists($key, $this->actions->json()) ? $this->actions->json()[$key] : [];
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
<style><?= VV::css("public/assets/css/pages/work/timeline") ?></style>
|
||||
<section class="git">
|
||||
<?= VV::embed("public/assets/media/icons/codeberg.svg") ?>
|
||||
<p>This timeline has most but not all of my FOSS software. If you want to see a list of all things I've created for the free software world, check out my repos on Codeberg or Forgejo.</p>
|
||||
<div class="buttons">
|
||||
<a href="https://codeberg.org/vlw"><button class="inline solid">Codeberg</button></a>
|
||||
<a href="https://git.vlw.se"><button class="inline">Forgejo</button></a>
|
||||
</div>
|
||||
</section>
|
||||
<section class="timeline">
|
||||
|
||||
<?php // Get year int from key and array of months for current year ?>
|
||||
<?php foreach ($work->get_timeline() as $year => $months): ?>
|
||||
<div class="year">
|
||||
<div class="track">
|
||||
<p><?= $year ?></p>
|
||||
</div>
|
||||
|
||||
<div class="months">
|
||||
<?php // Get month int from key and array of days for current month ?>
|
||||
<?php foreach ($months as $month => $days): ?>
|
||||
<div class="month">
|
||||
<div class="track">
|
||||
<?php // Append leading zero to month ?>
|
||||
<p><?= sprintf("%02d", $month) ?></p>
|
||||
</div>
|
||||
|
||||
<div class="days">
|
||||
<?php // Get day int from key and array of items for current day ?>
|
||||
<?php foreach ($days as $day => $items): ?>
|
||||
<div class="day">
|
||||
<div class="track">
|
||||
<?php // Append leading zero to day ?>
|
||||
<p><?= sprintf("%02d", $day) ?></p>
|
||||
</div>
|
||||
|
||||
<div class="items">
|
||||
<?php foreach ($items as $item): ?>
|
||||
<div class="item">
|
||||
|
||||
<?php // List tags if available ?>
|
||||
<?php if ($work->get_tags($item[WorkModel::ID->value])): ?>
|
||||
<div class="tags">
|
||||
<?php foreach ($work->get_tags($item[WorkModel::ID->value]) as $tag): ?>
|
||||
<p class="tag <?= $tag[WorkTagsModel::NAME->value] ?>"><?= $tag[WorkTagsModel::NAME->value] ?></p>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php // Show large heading if defined ?>
|
||||
<?php if (!empty($item[WorkModel::TITLE->value])): ?>
|
||||
<h2><?= $item[WorkModel::TITLE->value] ?></h2>
|
||||
<?php endif; ?>
|
||||
|
||||
<p><?= $item[WorkModel::SUMMARY->value] ?></p>
|
||||
|
||||
<div class="actions">
|
||||
<?php if ($work->get_actions($item[WorkModel::ID->value])): ?>
|
||||
|
||||
<?php // Display each action button ?>
|
||||
<?php foreach ($work->get_actions($item[WorkModel::ID->value]) as $action): ?>
|
||||
<a href="<?= $action[WorkActionsModel::HREF->value] ?>"><button class="inline <?= $action[WorkActionsModel::CLASS_LIST->value] ?>"><?= $action[WorkActionsModel::DISPLAY_TEXT->value] ?></button></a>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php // Display a link to namespaced page on vlw.se if no action is defined ?>
|
||||
<?php else: ?>
|
||||
<a href="<?= $item[WorkModel::ID->value] ?>"><button class="inline">read more</button></a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
</section>
|
||||
<script><?= VV::js("assets/js/pages/work/timeline") ?></script>
|
1
public/work/vlw/camera-obscura.php
Normal file
1
public/work/vlw/camera-obscura.php
Normal file
|
@ -0,0 +1 @@
|
|||
<?= VV::include("public/work/wip") ?>
|
1
public/work/vlw/dediprison.php
Normal file
1
public/work/vlw/dediprison.php
Normal file
|
@ -0,0 +1 @@
|
|||
<?= VV::include("public/work/wip") ?>
|
1
public/work/vlw/eyeart.php
Normal file
1
public/work/vlw/eyeart.php
Normal file
|
@ -0,0 +1 @@
|
|||
<?= VV::include("public/work/wip") ?>
|
1
public/work/vlw/ion-musik.php
Normal file
1
public/work/vlw/ion-musik.php
Normal file
|
@ -0,0 +1 @@
|
|||
<?= VV::include("public/work/wip") ?>
|
8
public/work/wip.php
Normal file
8
public/work/wip.php
Normal file
|
@ -0,0 +1,8 @@
|
|||
<style><?= VV::css("public/assets/css/pages/work/wip") ?></style>
|
||||
<section class="disclaimer">
|
||||
<h1>Soon, very soon!</h1>
|
||||
<p>Bear with me as I cook up some texts about this project! Hopefully with some pictures too.</p>
|
||||
</section>
|
||||
<section class="actions">
|
||||
<a href="/work"><button class="inline">to featured work</button></a>
|
||||
</section>
|
Loading…
Add table
Reference in a new issue