Compare commits

..

No commits in common. "master" and "1.4.0" have entirely different histories.

167 changed files with 2458 additions and 3576 deletions

View file

@ -1,15 +1,7 @@
[mariadb] [api]
host = "" base_url = "https://api.vlw.one/"
user = "" api_key = ""
pass = "" verify_peer = 0
db = ""
[config_time_available] [time]
time_zone = "Europe/Stockholm" date_time_zone = "Europe/Stockholm"
available_to_hour = 0;
reply_average_hours = 0;
available_from_hour = 0;
[service_forgejo]
url = ""
profiles = ""

16
.gitignore vendored
View file

@ -1,2 +1,18 @@
assets/media/content
# Bootstrapping #
#################
vendor vendor
.env.ini .env.ini
# OS generated files #
######################
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
Icon?
ehthumbs.db
Thumbs.db
.directory

6
.gitmodules vendored
View file

@ -1,6 +0,0 @@
[submodule "reflect"]
path = reflect
url = https://codeberg.org/reflect/reflect
[submodule "vegvisir"]
path = vegvisir
url = https://codeberg.org/vegvisir/vegvisir

View file

@ -1,27 +1,70 @@
# vlw.se # vlw.se
This is the source code behind [vlw.se](https://vlw.se) which is my personal website that I have written and designed from the ground up. The website is built on top of my own [Vegvisir web framework](https://vegvisir.vlw.se) and its optional REST API is built on top of my [Reflect API framework](https://reflect.vlw.se). This is the source code behind [vlw.se](https://vlw.se) which has been written from the ground up by me. This website is built on top of my [Vegvisir web framework](https://github.com/victorwesterlund/vegvisir) and my [Reflect API framework](https://github.com/victorwesterlund/reflect).
# Installation # Installation
Here's how you get my website up and running on your own machine. Note, I have only tested this on Linux and the install script we will run requires Bash with `coreutils` installed. If you for whatever reason want to get this website up and running for yourself this is how that is done.
## Prerequisites This website is built for PHP 8.0+ and MariaDB 14+ (for the API database).
- A web server
- A MariaDB/MySQL server
- PHP 8.4 or newer with the following extensions enabled:
- - `php8.4-mysql`
- - `php8.4-mbstring`
- The composer package manager
- Bash with `coreutils` installed (for the install script)
## 1. Clone this repo **Confimed supported framework versions:**
Clone this repository with its submodules. Preferably to a non-public directory - the frameworks will handle that. Vegvisir|Reflect
``` --|--
git clone https://codeberg.org/vlw/vlw.se --recurse-submodules --depth 1 ✅ [`2.4.4`](https://github.com/VictorWesterlund/vegvisir/releases/tag/2.4.4)|✅ [`2.7.1`](https://github.com/VictorWesterlund/reflect/releases/tag/2.7.1)
```
## 2. Run the install script ## Website (Vegvisir)
Run the `install.sh` script from the root directory of this repository. 1. **Download this repo**
```
./install.sh Git clone or download this repo to any local folder
``` ```
This script will install and configure Vegvisir, Reflect, and the website through a few propmpted steps. git clone https://github.com/VictorWesterlund/vlw.se
```
2. **Download and install Vegvisir**
Follow the installation instructions for [Vegvisir](https://github.com/victorwesterlund/vegvisir) and point the `site_path` variable to the local vlw.se folder.
3. **Install dependencies**
Install dependencies with composer.
```
composer install --optimize-autoloader
```
Et voila! You probably want to install the API-side too but the website itself should now be accessible from your configured Vegvisir host.
## API (Reflect)
The API (and database) is where most content is stored and served from on this website.
1. **Download this repo**
**You can skip this if you've already downloaded the repo from step 1 in the website installation.**
Otherwise... Git clone or download this repo to any local folder
```
git clone https://github.com/VictorWesterlund/vlw.se
```
2. **Download and install Reflect**
Follow the installation instructions for [Reflect](https://github.com/victorwesterlund/vegvisir) and point the `endpoints` variable to the `/api` subdirectory in the local vlw.se folder.
3. **Install dependencies**
Install dependencies with composer.
```
composer install --optimize-autoloader
```
4. **Create and import database**
[Create and] import the two databases associated with vlw.se data and the Reflect API configurations from `.sql` files on the Releases page.
5. **Set environment variables**
Make a copy of `/api/.env.example.ini` and change the `[vlwdb]` variables with your MariaDB credentials.
You also have to generate a [GitHub access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) if you wish to use the `releases` endpoint.
[Read more about this endpoint here](#)
6. **Set environment variables for website**
It's reasonable to assume if you've installed the website from this repo that you'd also want to use the API with it. Start my making a copy of `/.env.example.ini` (root directory) and change the `[api]` variables to point to your API hostname.

11
api/.env.example.ini Executable file
View file

@ -0,0 +1,11 @@
[vlwdb]
mariadb_host = ""
mariadb_user = ""
mariadb_pass = ""
mariadb_db = ""
[github]
api_key = ""
# Use-Agent string sent to GitHub API
# They recommend setting it to your GitHub username or app name
user_agent = ""

View file

@ -1,29 +0,0 @@
<?php
use Reflect\{Response, Path};
use Reflect\Rules\{Ruleset, Rules, Type};
use VLW\API\API;
use VLW\Helpers\UUID;
use VLW\Database\Models\Coffee\Coffee;
use VLW\Database\Tables\Coffee\Coffee as CoffeeTable;
require_once Path::root("src/API/API.php");
require_once Path::root("src/Database/Models/Coffee/Coffee.php");
require_once Path::root("src/Database/Tables/Coffee/Coffee.php");
final class DELETE_Coffee extends API {
public function __construct() {
parent::__construct(new Ruleset()->GET([
new Rules(CoffeeTable::ID->value)
->required()
->type(Type::STRING)
->min(UUID::LENGTH)
->max(UUID::LENGTH)
]));
}
public function main(): Response {
return new Response(new Coffee($_GET[CoffeeTable::ID->value])->delete());
}
}

View file

@ -1,19 +0,0 @@
<?php
use Reflect\{Response, Path};
use VLW\API\API;
use VLW\Database\Models\Coffee\Coffee;
require_once Path::root("src/API/API.php");
require_once Path::root("src/Database/Models/Coffee/Coffee.php");
final class GET_Coffee extends API {
public function __construct() {
parent::__construct();
}
public function main(): Response {
return new Response(Coffee::all());
}
}

View file

@ -1,41 +0,0 @@
<?php
use Reflect\{Response, Path};
use Reflect\Rules\{Ruleset, Rules, Type};
use VLW\API\API;
use VLW\Database\Models\Coffee\Coffee;
use VLW\Database\Tables\Coffee\Coffee as CoffeeTable;
require_once Path::root("src/API/API.php");
require_once Path::root("src/Database/Models/Coffee/Coffee.php");
require_once Path::root("src/Database/Tables/Coffee/Coffee.php");
final class POST_Coffee extends API {
public function __construct() {
parent::__construct(new Ruleset(strict: true)->POST([
new Rules(CoffeeTable::DATE_CREATED->value)
->type(Type::STRING)
->type(Type::NUMBER)
->default(null)
]));
}
public function main(): Response {
$datetime = new DateTimeImmutable();
// Parse DateTime from POST string
if ($_POST[CoffeeTable::DATE_CREATED->value]) {
try {
// Create DateTimeImmutable from Unix timestamp or datetime string
$datetime = gettype($_POST[CoffeeTable::DATE_CREATED->value]) === "integer"
? DateTimeImmutable::createFromTimestamp($_POST[CoffeeTable::DATE_CREATED->value])
: new DateTimeImmutable($_POST[CoffeeTable::DATE_CREATED->value]);
} catch (DateMalformedStringException $error) {
return new Response($error->getMessage(), 400);
}
}
return new Response(Coffee::new($datetime));
}
}

8
api/composer.json Executable file
View file

@ -0,0 +1,8 @@
{
"require": {
"reflect/plugin-rules": "^1.5",
"victorwesterlund/xenum": "dev-master",
"victorwesterlund/libmysqldriver": "dev-master"
},
"minimum-stability": "dev"
}

135
api/composer.lock generated Executable file
View file

@ -0,0 +1,135 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "13ba5cc60bab24ac8ef5b1018fe4249b",
"packages": [
{
"name": "reflect/plugin-rules",
"version": "1.5.0",
"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": ""
},
"type": "library",
"autoload": {
"psr-4": {
"ReflectRules\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Victor Westerlund",
"email": "victor.vesterlund@gmail.com"
}
],
"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"
},
{
"name": "victorwesterlund/libmysqldriver",
"version": "dev-master",
"source": {
"type": "git",
"url": "https://github.com/VictorWesterlund/php-libmysqldriver.git",
"reference": "adc2fda90a3b8308e8a9df202d5ec418a9220ff8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/VictorWesterlund/php-libmysqldriver/zipball/adc2fda90a3b8308e8a9df202d5ec418a9220ff8",
"reference": "adc2fda90a3b8308e8a9df202d5ec418a9220ff8",
"shasum": ""
},
"default-branch": true,
"type": "library",
"autoload": {
"psr-4": {
"libmysqldriver\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Victor Westerlund",
"email": "victor.vesterlund@gmail.com"
}
],
"description": "Abstraction library for common mysqli features",
"support": {
"issues": "https://github.com/VictorWesterlund/php-libmysqldriver/issues",
"source": "https://github.com/VictorWesterlund/php-libmysqldriver/tree/3.6.1"
},
"time": "2024-04-29T08:17:12+00:00"
},
{
"name": "victorwesterlund/xenum",
"version": "dev-master",
"source": {
"type": "git",
"url": "https://github.com/VictorWesterlund/php-xenum.git",
"reference": "8972f06f42abd1f382807a67e937d5564bb89699"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/VictorWesterlund/php-xenum/zipball/8972f06f42abd1f382807a67e937d5564bb89699",
"reference": "8972f06f42abd1f382807a67e937d5564bb89699",
"shasum": ""
},
"default-branch": true,
"type": "library",
"autoload": {
"psr-4": {
"victorwesterlund\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Victor Westerlund",
"email": "victor.vesterlund@gmail.com"
}
],
"description": "PHP eXtended Enums. The missing quality-of-life features from PHP 8+ Enums",
"support": {
"issues": "https://github.com/VictorWesterlund/php-xenum/issues",
"source": "https://github.com/VictorWesterlund/php-xenum/tree/1.1.1"
},
"time": "2023-11-20T10:10:39+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "dev",
"stability-flags": {
"victorwesterlund/xenum": 20,
"victorwesterlund/libmysqldriver": 20
},
"prefer-stable": false,
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
"plugin-api-version": "2.0.0"
}

48
api/endpoints/messages/POST.php Executable file
View file

@ -0,0 +1,48 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Messages\MessagesModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Messages/Messages.php");
class POST_Messages extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->POST([
(new Rules(MessagesModel::EMAIL->value))
->type(Type::STRING)
->max(255)
->default(null),
(new Rules(MessagesModel::MESSAGE->value))
->required()
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_TEXT_MAX_LENGTH)
]);
parent::__construct($this->ruleset);
}
public function main(): Response {
// Use copy of request body as entity
$entity = $_POST;
$entity[MessagesModel::ID->value] = parent::gen_uuid4();
$entity[MessagesModel::DATE_CREATED->value] = time();
return $this->db->for(MessagesModel::TABLE)->insert($entity) === true
? new Response($entity[MessagesModel::ID->value], 201)
: new Response("Failed to create message", 500);
}
}

56
api/endpoints/search/GET.php Executable file
View file

@ -0,0 +1,56 @@
<?php
use Reflect\Call;
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Endpoints;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Work\WorkModel;
require_once Path::root("src/Endpoints.php");
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Work/Work.php");
class GET_Search extends VLWdb {
const GET_QUERY = "q";
protected Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(self::GET_QUERY))
->required()
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
]);
parent::__construct($this->ruleset);
}
private function search_work(): Response {
return (new Call(Endpoints::WORK->value))->params([
WorkModel::TITLE->value => $_GET[self::GET_QUERY],
WorkModel::SUMMARY->value => $_GET[self::GET_QUERY]
])->get();
}
public function main(): Response {
$results = [
Endpoints::WORK->value => $this->search_work()->output()
];
// Calculate the total number of results from all searched endpoints
$num_results = array_sum(array_map(fn(array $result): int => count($result), array_values($results)));
// Return 404 if no search results
return new Response($results, $num_results > 0 ? 200 : 404);
}
}

64
api/endpoints/work/DELETE.php Executable file
View file

@ -0,0 +1,64 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use const VLW\API\RESP_DELETE_OK;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Work\WorkModel;
require_once Path::root("src/Endpoints.php");
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Work.php");
class DELETE_Work extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->POST([
(new Rules(WorkModel::ID->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkModel::TITLE->value))
->type(Type::STRING)
->min(3)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkModel::SUMMARY->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_TEXT_MAX_LENGTH),
(new Rules(WorkModel::IS_LISTABLE->value))
->type(Type::BOOLEAN),
(new Rules(WorkModel::IS_READABLE->value))
->type(Type::BOOLEAN),
(new Rules(WorkModel::DATE_MODIFIED->value))
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_INT_MAX_LENGHT),
(new Rules(WorkModel::DATE_CREATED->value))
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_INT_MAX_LENGHT)
]);
parent::__construct($this->ruleset);
}
public function main(): Response {
return $this->db->for(FieldsEnumsModel::TABLE)->delete($_POST) === true
? new Response(RESP_DELETE_OK)
: new Response("Failed to delete work entity", 500);
}
}

94
api/endpoints/work/GET.php Executable file
View file

@ -0,0 +1,94 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Work\WorkModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Work/Work.php");
class GET_Work extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(WorkModel::ID->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkModel::TITLE->value))
->type(Type::STRING)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkModel::SUMMARY->value))
->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))
->type(Type::BOOLEAN)
->default(true),
(new Rules(WorkModel::DATE_MODIFIED->value))
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_INT_MAX_LENGHT),
(new Rules(WorkModel::DATE_CREATED->value))
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_INT_MAX_LENGHT)
]);
parent::__construct($this->ruleset);
}
public function main(): Response {
// Use copy of search paramters as filters
$filters = $_GET;
// Do a wildcard search on the title column if provided
if (array_key_exists(WorkModel::TITLE->value, $_GET)) {
$filters[WorkModel::TITLE->value] = [
"LIKE" => "%{$_GET[WorkModel::TITLE->value]}%"
];
}
// Do a wildcard search on the summary column if provided
if (array_key_exists(WorkModel::SUMMARY->value, $_GET)) {
$filters[WorkModel::SUMMARY->value] = [
"LIKE" => "%{$_GET[WorkModel::SUMMARY->value]}%"
];
}
$response = $this->db->for(WorkModel::TABLE)
->where($filters)
->select([
WorkModel::ID->value,
WorkModel::TITLE->value,
WorkModel::SUMMARY->value,
WorkModel::IS_LISTABLE->value,
WorkModel::IS_READABLE->value,
WorkModel::DATE_YEAR->value,
WorkModel::DATE_MONTH->value,
WorkModel::DATE_DAY->value,
WorkModel::DATE_MODIFIED->value,
WorkModel::DATE_CREATED->value
]);
return $response->num_rows > 0
? new Response($response->fetch_all(MYSQLI_ASSOC))
: new Response([], 404);
}
}

115
api/endpoints/work/PATCH.php Executable file
View file

@ -0,0 +1,115 @@
<?php
use Reflect\Call;
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Endpoints;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Work\{
WorkModel,
WorkPermalinksModel
};
require_once Path::root("src/Endpoints.php");
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Work/Work.php");
require_once Path::root("src/databases/models/Work/WorkPermalinks.php");
class PATCH_Work extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(WorkModel::ID->value))
->required()
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
]);
$this->ruleset->POST([
(new Rules(WorkModel::TITLE->value))
->type(Type::STRING)
->min(3)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkModel::SUMMARY->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_TEXT_MAX_LENGTH),
(new Rules(WorkModel::IS_LISTABLE->value))
->type(Type::BOOLEAN),
(new Rules(WorkModel::IS_READABLE->value))
->type(Type::BOOLEAN),
(new Rules(WorkModel::DATE_MODIFIED->value))
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_INT_MAX_LENGHT)
->default(time()),
(new Rules(WorkModel::DATE_CREATED->value))
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_INT_MAX_LENGHT)
]);
parent::__construct();
}
// Generate a slug URL from string
private static function gen_slug(string $input): string {
return strtolower(trim(preg_replace('/[^A-Za-z0-9-]+/', '-', $input)));
}
// Compute and return modeled year, month, and day from Unix timestamp in request body
private static function gen_date_created(): array {
return [
WorkModel::DATE_YEAR->value => date("Y", $_POST[WorkModel::DATE_CREATED->value]),
WorkModel::DATE_MONTH ->value => date("n", $_POST[WorkModel::DATE_CREATED->value]),
WorkModel::DATE_DAY->value => date("j", $_POST[WorkModel::DATE_CREATED->value])
];
}
private function get_entity_by_id(string $id): Response {
return (new Call(Endpoints::WORK->value))->params([
WorkModel::ID->value => $id
])->get();
}
public function main(): Response {
// Use copy of request body as entity
$entity = $_POST;
// Generate a new slug id from title if changed
if ($_POST[WorkModel::TITLE->value]) {
$slug = $_POST[WorkModel::TITLE->value];
// Bail out if the slug generated from the new tite already exist
if ($this->get_entity_by_id($slug)) {
return new Response("An entity with this title already exist", 409);
}
// Add the new slug to update entity
$entity[WorkModel::ID] = $slug;
}
// Generate new work date fields from timestamp
if ($_POST[WorkModel::DATE_CREATED->value]) {
array_merge($entity, self::gen_date_created());
}
// Update entity by existing id
return $this->db->for(WorkModel::TABLE)->where([WorkModel::ID->value => $_GET[WorkModel::ID->value]])->update($entity) === true
? new Response($_GET[WorkModel::ID->value])
: new Response("Failed to update entity", 500);
}
}

111
api/endpoints/work/POST.php Executable file
View file

@ -0,0 +1,111 @@
<?php
use Reflect\Call;
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Endpoints;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Work\{
WorkModel,
WorkPermalinksModel
};
require_once Path::root("src/Endpoints.php");
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Work/Work.php");
require_once Path::root("src/databases/models/Work/WorkPermalinks.php");
class POST_Work extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->POST([
(new Rules(WorkModel::TITLE->value))
->type(Type::STRING)
->min(3)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
->default(null),
(new Rules(WorkModel::SUMMARY->value))
->type(Type::STRING)
->min(1)
->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))
->type(Type::BOOLEAN)
->default(false),
(new Rules(WorkModel::DATE_CREATED->value))
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_INT_MAX_LENGHT)
->default(time())
]);
parent::__construct($this->ruleset);
}
// Generate a slug URL from string
private static function gen_slug(string $input): string {
return strtolower(trim(preg_replace('/[^A-Za-z0-9-]+/', '-', $input)));
}
// Compute and return modeled year, month, and day from a Unix timestamp
private static function gen_date_created(): array {
// Use provided timestamp in request
$date_created = $_POST[WorkModel::DATE_CREATED->value];
return [
WorkModel::DATE_YEAR->value => date("Y", $date_created),
WorkModel::DATE_MONTH ->value => date("n", $date_created),
WorkModel::DATE_DAY->value => date("j", $date_created)
];
}
private function get_entity_by_id(string $id): Response {
return (new Call(Endpoints::WORK->value))->params([
WorkModel::ID->value => $id
])->get();
}
public function main(): Response {
// Use copy of request body as entity
$entity = $_POST;
// Generate URL slug from title text or UUID if undefined
$entity[WorkModel::ID->value] = $_POST[WorkModel::TITLE->value]
? self::gen_slug($_POST[WorkModel::TITLE->value])
: parent::gen_uuid4();
// Bail out here if a work entry with id had been created already
if ($this->get_entity_by_id($entity[WorkModel::ID->value])->ok) {
return new Response("An entity with id '{$slug}' already exist", 409);
}
// Generate the necessary date fields
array_merge($entity, self::gen_date_created());
// Let's try to insert the new entity
if (!$this->db->for(WorkModel::TABLE)->insert($entity)) {
return new Response("Failed to insert work entry", 500);
}
// Generate permalink for new entity
return (new Call(Endpoints::WORK_PERMALINKS->value))->post([
WorkPermalinksModel::ID => $entity[WorkModel::ID->value],
WorkPermalinksModel::REF_WORK_ID => $entity[WorkModel::ID->value],
WorkPermalinksModel::DATE_CREATED => time()
]);
}
}

View file

@ -0,0 +1,35 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use const VLW\API\RESP_DELETE_OK;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Work\WorkActionsModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/WorkActions.php");
class DELETE_WorkActions extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->POST([
(new Rules(WorkActionsModel::REF_WORK_ID->value))
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
]);
}
public function main(): Response {
return $this->db->for(WorkActionsModel::TABLE)->delete($_POST) === true
? new Response(RESP_DELETE_OK)
: new Response("Failed to delete action for work entity", 500);
}
}

View file

@ -0,0 +1,46 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Work\WorkActionsModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Work/WorkActions.php");
class GET_WorkActions extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(WorkActionsModel::REF_WORK_ID->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
]);
parent::__construct($this->ruleset);
}
public function main(): Response {
$response = $this->db->for(WorkActionsModel::TABLE)
->where($_GET)
->select([
WorkActionsModel::REF_WORK_ID->value,
WorkActionsModel::DISPLAY_TEXT->value,
WorkActionsModel::HREF->value,
WorkActionsModel::CLASS_LIST->value,
WorkActionsModel::EXTERNAL->value
]);
return $response->num_rows > 0
? new Response($response->fetch_all(MYSQLI_ASSOC))
: new Response([], 404);
}
}

View file

@ -0,0 +1,77 @@
<?php
use Reflect\Call;
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Endpoints;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Work\{
WorkModel,
WorkActionsModel
};
require_once Path::root("src/Endpoints.php");
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Work/Work.php");
require_once Path::root("src/databases/models/Work/WorkActions.php");
class POST_WorkActions extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->POST([
(new Rules(WorkActionsModel::REF_WORK_ID->value))
->required()
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkActionsModel::DISPLAY_TEXT->value))
->required()
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkActionsModel::HREF->value))
->required()
->type(Type::STRING)
->type(Type::NULL)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkActionsModel::CLASS_LIST->value))
->type(Type::ARRAY)
->min(1)
->default([]),
(new Rules(WorkActionsModel::EXTERNAL->value))
->type(Type::BOOLEAN)
->default(false)
]);
parent::__construct($this->ruleset);
}
private static function get_entity(): Response {
return (new Call(Endpoints::WORK->value))->params([
WorkModel::ID->value => $_POST[WorkActionsModel::REF_WORK_ID->value]
])->get();
}
public function main(): Response {
// Bail out if work entity could not be fetched
$entity = self::get_entity();
if (!$entity->ok) {
return $entity;
}
return $this->db->for(WorkActionsModel::TABLE)->insert($_POST) === true
? new Response($_POST[WorkActionsModel::REF_WORK_ID->value], 201)
: new Response("Failed to add action to work entity", 500);
}
}

View file

@ -0,0 +1,49 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Work\WorkPermalinksModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Work/WorkPermalinks.php");
class GET_WorkPermalinks extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(WorkPermalinksModel::ID->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkPermalinksModel::REF_WORK_ID->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
]);
parent::__construct($this->ruleset);
}
public function main(): Response {
$response = $this->db->for(WorkPermalinksModel::TABLE)
->where($_GET)
->select([
WorkPermalinksModel::ID->value,
WorkPermalinksModel::REF_WORK_ID->value,
WorkPermalinksModel::DATE_CREATED->value
]);
return $response->num_rows > 0
? new Response($response->fetch_all(MYSQLI_ASSOC))
: new Response([], 404);
}
}

View file

@ -0,0 +1,62 @@
<?php
use Reflect\Call;
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Work\WorkPermalinksModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Work/WorkPermalinks.php");
class POST_WorkPermalinks extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->POST([
(new Rules(WorkPermalinksModel::ID->value))
->required()
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkPermalinksModel::REF_WORK_ID->value))
->required()
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkPermalinksModel::DATE_CREATED->value))
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_INT_MAX_LENGHT)
->default(time())
]);
parent::__construct($this->ruleset);
}
private static function get_entity(): Response {
return (new Call(Endpoints::WORK->value))->params([
WorkModel::ID->value => $_POST[WorkTagsModel::REF_WORK_ID->value]
])->get();
}
public function main(): Response {
// Bail out if work entity could not be fetched
$entity = self::get_entity();
if (!$entity->ok) {
return $entity;
}
return $this->db->for(WorkPermalinksModel::TABLE)->insert($_POST) === true
? new Response($_POST[WorkPermalinksModel::ID->value], 201)
: new Response("Failed to add permalink to work entity", 500);
}
}

View file

@ -0,0 +1,39 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use const VLW\API\RESP_DELETE_OK;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Work\WorkTagsModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Work/WorkTags.php");
class DELETE_WorkTags extends VLWdb {
private Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(WorkTagsModel::REF_WORK_ID->value))
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkTagsModel::NAME->value))
->type(Type::ENUM, WorkTagsNameEnum::names())
]);
parent::__construct($this->ruleset);
}
public function main(): Response {
return $this->db->for(WorkTagsModel::TABLE)->delete($_POST) === true
? new Response(RESP_DELETE_OK)
: new Response("Failed to delete value from document", 500);
}
}

View file

@ -0,0 +1,48 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Work\{
WorkTagsModel,
WorkTagsNameEnum
};
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Work/WorkTags.php");
class GET_WorkTags extends VLWdb {
private Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(WorkTagsModel::REF_WORK_ID->value))
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkTagsModel::NAME->value))
->type(Type::ENUM, WorkTagsNameEnum::names())
]);
parent::__construct($this->ruleset);
}
public function main(): Response {
$response = $this->db->for(WorkTagsModel::TABLE)
->where($_GET)
->select([
WorkTagsModel::REF_WORK_ID->value,
WorkTagsModel::NAME->value
]);
return $response->num_rows > 0
? new Response($response->fetch_all(MYSQLI_ASSOC))
: new Response([], 404);
}
}

View file

@ -0,0 +1,60 @@
<?php
use Reflect\Call;
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Endpoints;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Work\{
WorkModel,
WorkTagsModel,
WorkTagsNameEnum
};
require_once Path::root("src/Endpoints.php");
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Work/Work.php");
require_once Path::root("src/databases/models/Work/WorkTags.php");
class POST_WorkTags extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->POST([
(new Rules(WorkTagsModel::REF_WORK_ID->value))
->required()
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkTagsModel::NAME->value))
->required()
->type(Type::ENUM, WorkTagsNameEnum::names())
]);
parent::__construct($this->ruleset);
}
private static function get_entity(): Response {
return (new Call(Endpoints::WORK->value))->params([
WorkModel::ID->value => $_POST[WorkTagsModel::REF_WORK_ID->value]
])->get();
}
public function main(): Response {
// Bail out if work entity could not be fetched
$entity = self::get_entity();
if (!$entity->ok) {
return $entity;
}
return $this->db->for(WorkTagsModel::TABLE)->insert($_POST) === true
? new Response($_POST[WorkTagsModel::REF_WORK_ID->value], 201)
: new Response("Failed to add tag to work entity", 500);
}
}

View file

@ -1,19 +0,0 @@
<?php
use Reflect\{Response, Path};
use VLW\API\API;
use VLW\Database\Models\Languages\Language;
require_once Path::root("src/API/API.php");
require_once Path::root("src/Database/Models/Languages/Language.php");
final class GET_Languages extends API {
public function __construct() {
parent::__construct();
}
public function main(): Response {
return new Response(Language::all());
}
}

17
api/src/Endpoints.php Normal file
View file

@ -0,0 +1,17 @@
<?php
namespace VLW\API;
// Default string to return when a DELETE request is successful
const RESP_DELETE_OK = "OK";
// Enum of all available VLW endpoints grouped by category
enum Endpoints: string {
case SEARCH = "/search";
case MESSAGES = "/messages";
case WORK = "/work";
case WORK_TAGS = "/work/tags";
case WORK_ACTIONS = "/work/actions";
}

62
api/src/databases/VLWdb.php Executable file
View file

@ -0,0 +1,62 @@
<?php
namespace VLW\API\Databases\VLWdb;
use Reflect\ENV;
use Reflect\Path;
use Reflect\Request;
use Reflect\Response;
use ReflectRules\Ruleset;
use libmysqldriver\MySQL;
class VLWdb {
const UUID_LENGTH = 36;
const MYSQL_TEXT_MAX_LENGTH = 65538;
const MYSQL_VARCHAR_MAX_LENGTH = 255;
const MYSQL_INT_MAX_LENGHT = 2147483647;
protected readonly MySQL $db;
public function __construct(Ruleset $ruleset) {
// Validate provided Ruleset before attempting to connect to the database
self::eval_ruleset_or_exit($ruleset);
// Create new MariaDB connection
$this->db = new MySQL(
$_ENV["vlwdb"]["mariadb_host"],
$_ENV["vlwdb"]["mariadb_user"],
$_ENV["vlwdb"]["mariadb_pass"],
$_ENV["vlwdb"]["mariadb_db"],
);
}
// Generate and return UUID4 string
public static function gen_uuid4(): string {
return sprintf("%04x%04x-%04x-%04x-%04x-%04x%04x%04x",
// 32 bits for "time_low"
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
// 16 bits for "time_mid"
mt_rand(0, 0xffff),
// 16 bits for "time_hi_and_version",
// four most significant bits holds version number 4
mt_rand(0, 0x0fff) | 0x4000,
// 16 bits, 8 bits for "clk_seq_hi_res",
// 8 bits for "clk_seq_low",
// two most significant bits holds zero and one for variant DCE1.1
mt_rand(0, 0x3fff) | 0x8000,
// 48 bits for "node"
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
// 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;
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Messages;
enum MessagesModel: string {
const TABLE = "messages";
case ID = "id";
case EMAIL = "email";
case MESSAGE = "message";
case IS_READ = "is_read";
case IS_SPAM = "is_spam";
case IS_SAVED = "is_saved";
case DATE_CREATED = "date_created";
}

View file

@ -0,0 +1,19 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Work;
enum WorkModel: string {
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";
}

View file

@ -0,0 +1,13 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Work;
enum WorkActionsModel: string {
const TABLE = "work_actions";
case REF_WORK_ID = "ref_work_id";
case DISPLAY_TEXT = "display_text";
case HREF = "href";
case CLASS_LIST = "class_list";
case EXTERNAL = "external";
}

View file

@ -0,0 +1,10 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Work;
enum WorkMediaModel: string {
const TABLE = "work_media";
case ANCHOR = "anchor";
case MEDIA = "media";
}

View file

@ -0,0 +1,11 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Work;
enum WorkPermalinksModel: string {
const TABLE = "work_permalinks";
case SLUG = "slug";
case ANCHOR = "anchor";
case DATE_TIMESTAMP_CREATED = "date_timestamp_created";
}

View file

@ -0,0 +1,20 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Work;
use victorwesterlund\xEnum;
enum WorkTagsNameEnum {
use xEnum;
case VLW;
case RELEASE;
case WEBSITE;
}
enum WorkTagsModel: string {
const TABLE = "work_tags";
case REF_WORK_ID = "ref_work_id";
case NAME = "name";
}

View file

@ -1,71 +0,0 @@
<?php
use \vlw\xEnum;
use Reflect\{Response, Path};
use Reflect\Rules\{Ruleset, Rules, Type};
use VLW\API\API;
use VLW\Helpers\{
Forgejo,
GenerateSearch,
GenerateTimeline
};
require_once Path::root("src/API/API.php");
require_once Path::root("src/Helpers/Forgejo.php");
require_once Path::root("src/Helpers/GenerateSearch.php");
require_once Path::root("src/Helpers/GenerateTimeline.php");
enum ServiceEnum: string {
use xEnum;
case ALL = "all";
case SEARCH = "search";
case FORGEJO = "forgejo";
case TIMELINE = "timeline";
}
final class GET_Update extends API {
private const KEY_SERVICE = "service";
public function __construct() {
parent::__construct(new Ruleset()->GET([
new Rules(self::KEY_SERVICE)
->type(Type::ENUM, ServiceEnum::values())
->default(ServiceEnum::ALL->value)
]));
}
public function main(): Response {
switch ($_GET[self::KEY_SERVICE]) {
case ServiceEnum::FORGEJO->value:
return new Response($this->update_forgejo());
case ServiceEnum::SEARCH->value:
return new Response($this->update_search());
case ServiceEnum::TIMELINE->value:
return new Response($this->update_timeline());
case ServiceEnum::ALL->value:
default:
return new Response(
$this->update_timeline() &&
$this->update_search() &&
$this->update_forgejo()
);
}
}
private function update_timeline(): bool {
return new GenerateTimeline()->generate();
}
private function update_search(): bool {
return new GenerateSearch()->generate();
}
private function update_forgejo(): bool {
return new Forgejo()->update();
}
}

View file

@ -1,28 +0,0 @@
<?php
use Reflect\{Response, Path};
use VLW\API\API;
use VLW\Database\Models\Work\{Work, Tag};
require_once Path::root("src/API/API.php");
require_once Path::root("src/Database/Models/Work/Tag.php");
require_once Path::root("src/Database/Models/Work/Work.php");
final class GET_Work extends API {
private static function entity(Work $work): object {
return (object) [
"tags" => array_map(fn(Tag $tag): string => $tag->label->name, Tag::from($work)),
"actions" => [],
"details" => $work
];
}
public function __construct() {
parent::__construct();
}
public function main(): Response {
return new Response(array_map(fn(Work $work): object => self::entity($work), Work::all()));
}
}

137
public/assets/css/shell.css → assets/css/document.css Normal file → Executable file
View file

@ -11,8 +11,7 @@
/* # Cornerstones */ /* # Cornerstones */
* { * {
margin: 0; margin: 0;
fill: inherit;
box-sizing: border-box; box-sizing: border-box;
font-family: "Roboto Mono", sans-serif; font-family: "Roboto Mono", sans-serif;
color: inherit; color: inherit;
@ -54,7 +53,7 @@ body::before {
} }
/* "enable" the corner glow effect on initial load when a page has been fully loaded */ /* "enable" the corner glow effect on initial load when a page has been fully loaded */
body[vv-top-page]::before { body[vv-page]::before {
opacity: 1; opacity: 1;
} }
@ -89,32 +88,6 @@ h3 {
font-size: 25px; font-size: 25px;
} }
/* ## Page transition */
[vv-loading] * {
transition: 200ms opacity;
}
[vv-loading="true"] * {
opacity: 0;
pointer-events: none;
}
[vv-loading="true"]::after {
content: "";
position: fixed;
top: 50%;
left: 50%;
width: 45px;
height: 49px;
background-size: contain;
image-rendering: pixelated;
transform: translate(-50%, -50%);
background-image: url("/assets/media/spinner.gif");
-webkit-filter: hue-rotate(var(--hue-accent));
filter: hue-rotate(var(--hue-accent));
}
/* ## Buttons */ /* ## Buttons */
button { button {
@ -128,44 +101,35 @@ button {
/* ### Inline */ /* ### Inline */
button.inline { button.inline {
gap: 10px; padding: calc(var(--padding) / 2) var(--padding);
display: flex; color: white;
border-radius: 7px; border: solid 2px white;
align-items: center; border-radius: 6px;
fill: var(--color-accent);
padding: calc(var(--padding) / 1.5);
background: linear-gradient(139deg, rgba(0, 0, 0, 0) 0%, rgba(var(--primer-color-accent), .1) 100%);
}
button.inline:not(.solid) {
box-shadow:
0 0 0 2px rgba(var(--primer-color-accent), .1),
10px 7px 40px 3px rgba(var(--primer-color-accent), .06)
;
}
button.inline svg {
flex: none;
height: 1em;
}
button.inline svg:last-child {
width: 1.5em;
margin-left: auto;
}
button.inline svg.chevron:last-child {
transform: rotate(-90deg);
} }
button.inline.solid { button.inline.solid {
fill: black;
color: black; color: black;
border: solid 2px rgba(var(--primer-color-accent), 1);
border-color: var(--color-accent); border-color: var(--color-accent);
background-color: var(--color-accent); background-color: var(--color-accent);
} }
a > button::after {
content: " ➜";
}
/* ### Text links */
a[target="_blank"] > button::after,
:is(h1, h2, h3, p, li) > a[target="_blank"]::after {
content: " ↑";
color: var(--color-accent);
white-space: nowrap;
}
a > button.solid:not(:hover)::after {
color: black;
}
/* ## Header */ /* ## Header */
header { header {
@ -213,10 +177,6 @@ header nav {
padding: var(--padding); padding: var(--padding);
} }
header .logo {
fill: none;
}
header .logo path.stroke { header .logo path.stroke {
fill: var(--color-accent); fill: var(--color-accent);
} }
@ -286,10 +246,6 @@ header searchbox input {
border: none; border: none;
} }
header searchbox input::placeholder {
color: rgba(0, 0, 0, .5);
}
/* #### Active */ /* #### Active */
header.searchboxActive > * { header.searchboxActive > * {
@ -300,23 +256,29 @@ header.searchboxActive searchbox {
transform: rotateX(0); transform: rotateX(0);
} }
/* ## vv-shell */ /* ## Main */
vv-shell { main {
position: relative; position: relative;
padding: calc(var(--padding) * 1.5); padding: calc(var(--padding) * 1.5);
width: 100%; width: 100%;
max-width: 1000px; max-width: 1000px;
} }
main > * {
transition: 100ms opacity;
opacity: 1;
}
main.loading > * {
opacity: 0;
}
/* ## Search results */ /* ## Search results */
search-results { search-results {
transition: 500ms opacity, 300ms transform; transition: 500ms opacity, 300ms transform;
position: fixed; position: fixed;
display: flex;
flex-direction: column;
gap: var(--padding);
top: var(--running-size); top: var(--running-size);
right: 0; right: 0;
width: 100%; width: 100%;
@ -328,7 +290,12 @@ search-results {
transform: scale(.99); transform: scale(.99);
transform-origin: 100% 0; transform-origin: 100% 0;
overflow-y: scroll; overflow-y: scroll;
z-index: 50; }
search-results:not([vv-page]) {
display: grid;
align-items: center;
justify-items: center;
} }
header.searchboxActive ~ search-results { header.searchboxActive ~ search-results {
@ -337,10 +304,6 @@ header.searchboxActive ~ search-results {
transform: scale(1); transform: scale(1);
} }
search-results section.search {
display: none;
}
/* ### "Start typing" prompt */ /* ### "Start typing" prompt */
search-results .info { search-results .info {
@ -348,11 +311,11 @@ search-results .info {
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
margin: auto; margin: auto;
gap: var(--padding); gap: 3svh;
} }
search-results .info :is(svg, img) { search-results .info :is(svg, img) {
width: 60px; width: 128px;
fill: var(--color-accent); fill: var(--color-accent);
} }
@ -368,8 +331,7 @@ search-results .info :is(svg, img) {
/* # Components */ /* # Components */
button.inline { button.inline {
transition-duration: 300ms; transition: 200ms background-color, 200ms border-color, 200ms color;
transition-property: background-color, border-color, box-shadow, color, fill;
} }
button:hover { button:hover {
@ -377,19 +339,8 @@ search-results .info :is(svg, img) {
background-color: rgba(255, 255, 255, .1); background-color: rgba(255, 255, 255, .1);
} }
button.inline:hover {
fill: var(--color-accent);
color: var(--color-accent);
}
button.inline:not(.solid):hover {
box-shadow:
0 0 0 2px rgba(var(--primer-color-accent), 1),
10px 7px 30px 3px rgba(var(--primer-color-accent), .07)
;
}
button.solid:hover { button.solid:hover {
color: var(--color-accent);
border-color: rgba(var(--primer-color-accent), .2); border-color: rgba(var(--primer-color-accent), .2);
background-color: rgba(var(--primer-color-accent), .2); background-color: rgba(var(--primer-color-accent), .2);
box-shadow: 0 -10px 20px 10px rgba(var(--primer-color-accent), .05); box-shadow: 0 -10px 20px 10px rgba(var(--primer-color-accent), .05);

16
assets/css/fonts.css Executable file
View file

@ -0,0 +1,16 @@
@font-face {
font-family: "Roboto Mono";
ascent-override: 100%;
font-weight: 400;
size-adjust: 105%;
font-stretch: 97.5% 112.5%;
src: local("Roboto Mono Regular"), url("/assets/fonts/roboto-mono-regular.woff2") format("woff2");
}
@font-face {
font-family: "Roboto Mono";
ascent-override: 100%;
size-adjust: 95%;
font-weight: 800;
src: local("Roboto Mono Bold"), url("/assets/fonts/roboto-mono-bold.woff2") format("woff2");
}

90
assets/css/pages/about.css Executable file
View file

@ -0,0 +1,90 @@
/* # Overrides */
:root {
--primer-color-accent: 148, 255, 21;
--color-accent: rgb(var(--primer-color-accent));
}
main {
display: flex;
flex-direction: column;
gap: var(--padding);
}
/* # Sections */
/* ## Divider */
main > hr {
border-color: rgba(255, 255, 255, .1);
}
/* ## About */
section.about {
display: flex;
flex-direction: column;
gap: calc(var(--padding) / 2);
}
section.about p:first-of-type:first-letter {
font-size: 1.8rem;
font-weight: bold;
margin-right: .1rem;
color: var(--color-accent);
}
section.about span.interests {
-webkit-user-select: none;
user-select: none;
color: var(--color-accent);
animation: interests-hue 5s infinite linear;
}
/* ## Version */
section.version {
color: rgba(255, 255, 255, .2);
}
/* # Interests */
div.interests {
--text-shadow-blur: 30px;
transition: 300ms opacity;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
font-weight: bold;
pointer-events: none;
font-size: clamp(16px, 15vw, 50px);
color: var(--color-accent);
overflow: hidden;
opacity: 0;
z-index: 200;
}
div.interests.active {
opacity: 1;
}
div.interests p {
transition: 500ms transform cubic-bezier(.34,0,0,.99);
position: absolute;
text-shadow:
0 0 var(--text-shadow-blur) black,
0 0 var(--text-shadow-blur) black,
0 0 var(--text-shadow-blur) black,
0 0 var(--text-shadow-blur) black,
0 0 var(--text-shadow-blur) black;
}
@keyframes interests-hue {
to {
-webkit-filter: hue-rotate(360deg);
filter: hue-rotate(360deg);
}
}

View file

@ -3,32 +3,21 @@
:root { :root {
--primer-color-accent: 255, 195, 255; --primer-color-accent: 255, 195, 255;
--color-accent: rgb(var(--primer-color-accent)); --color-accent: rgb(var(--primer-color-accent));
--hue-accent: 200deg;
} }
vv-shell { main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: var(--padding); gap: var(--padding);
} }
.fingerprint {
word-break: break-all;
}
/* # Sections */ /* # Sections */
vv-shell > svg { main > svg {
margin: var(--padding) 0; margin: var(--padding) 0;
} }
/* ## Modifiers */
section.center {
text-align: center;
}
/* ## Social */ /* ## Social */
section.social { section.social {
@ -68,22 +57,18 @@ section.social social:hover {
fill: var(--color-accent); fill: var(--color-accent);
} }
section.social social p.hovering { section.social social.hovering p {
display: initial; display: initial;
} }
/* ## PGP key */ /* ## OpenPGP key */
section.pgp { section.pgp {
max-width: 800px; max-width: 800px;
position: relative; position: relative;
display: flex;
border-radius: 12px;
flex-direction: column;
gap: var(--padding);
text-align: center; text-align: center;
background-color: rgba(var(--primer-color-accent), .15); background-color: rgba(var(--primer-color-accent), .15);
padding: var(--padding); padding: calc(var(--padding) * 1.5);
transform: rotate(-1.5deg); transform: rotate(-1.5deg);
} }
@ -96,29 +81,16 @@ section.pgp > svg {
} }
section.pgp > p { section.pgp > p {
padding: 0 var(--padding); margin-bottom: var(--padding);
padding: var(--padding);
} }
section.pgp .buttons { section.pgp .buttons {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-top: var(--padding);
gap: var(--padding); gap: var(--padding);
} }
/* ## Blockquote */
cite {
text-decoration: underline;
text-decoration-style: dotted;
text-decoration-color: rgba(var(--primer-color-accent), .5);
text-decoration-thickness: .1em;
&:hover {
text-decoration-color: var(--color-accent);
}
}
/* ## Contact form */ /* ## Contact form */
section.form :is(input, textarea) { section.form :is(input, textarea) {
@ -207,10 +179,6 @@ section.form-message.sent + section.form {
/* # Size queries */ /* # Size queries */
@media (min-width: 460px) { @media (min-width: 460px) {
section.pgp {
padding: calc(var(--padding) * 1.5);
}
section.pgp .buttons { section.pgp .buttons {
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: center;

View file

@ -6,7 +6,7 @@ header {
backdrop-filter: unset; backdrop-filter: unset;
} }
vv-shell { main {
max-width: unset; max-width: unset;
display: grid; display: grid;
justify-items: center; justify-items: center;

View file

@ -1,21 +1,21 @@
/* # Overrides */ /* # Overrides */
body[vv-top-page="/"]::before { body[vv-page="/"]::before {
opacity: 0; opacity: 0;
} }
/* # vv-shell styles */ /* # Main styles */
/* ## Picture */ /* ## Picture */
vv-shell { main {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
flex-direction: column-reverse; flex-direction: column-reverse;
} }
vv-shell img { main img {
margin: auto; margin: auto;
width: 25vh; width: 25vh;
pointer-events: none; pointer-events: none;
@ -38,7 +38,7 @@ vv-shell img {
padding: unset; padding: unset;
text-align: right; text-align: right;
font-size: clamp(20px, 8vh, 60px); font-size: clamp(20px, 8vh, 60px);
font-weight: bold; font-weight: 900;
line-height: clamp(20px, 8vh, 60px); line-height: clamp(20px, 8vh, 60px);
color: var(--color-accent); color: var(--color-accent);
} }
@ -49,11 +49,6 @@ vv-shell img {
.menu svg { .menu svg {
width: 100%; width: 100%;
animation: dash 1500ms linear infinite;
}
@keyframes dash {
to { stroke-dashoffset: 32; }
} }
/* ### Copy email button */ /* ### Copy email button */
@ -157,33 +152,25 @@ splash::after {
.menu menu li:hover { .menu menu li:hover {
opacity: 1; opacity: 1;
font-weight: 100;
text-shadow: 0 0 10px rgba(var(--primer-color-accent), .4); text-shadow: 0 0 10px rgba(var(--primer-color-accent), .4);
} }
button.email:hover { button.email:hover {
background-color: transparent; background-color: transparent;
} }
/* enable font-weight hover animation */
@media not (prefers-reduced-motion: reduce) {
.menu menu li {
transition: 200ms opacity, 200ms color, 300ms font-weight;
}
}
} }
/* # Size quries */ /* # Size quries */
@media (min-width: 900px) { @media (min-width: 900px) {
vv-shell { main {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
justify-items: center; justify-items: center;
align-items: center; align-items: center;
} }
vv-shell img { main img {
width: 35vh; width: 35vh;
} }
} }

85
assets/css/pages/search.css Executable file
View file

@ -0,0 +1,85 @@
/* # Overrides */
[vv-page="/search"]:not(body) {
display: flex;
flex-direction: column;
gap: var(--padding);
}
/* # Sections */
/* ## Search */
section.search {
width: 100%;
display: none;
flex-direction: column;
align-items: center;
gap: var(--padding);
background-color: rgba(255, 255, 255, .05);
padding: calc(var(--padding) * 1.5);
margin-bottom: calc(var(--padding) * 2);
}
main[vv-page="/search"] > section.search {
display: flex;
}
section.search form {
display: contents;
}
section.search search {
width: 100%;
}
section.search input {
width: 100%;
border: none;
color: black;
outline: none;
padding: var(--padding);
background-color: var(--color-accent);
}
section.search button[type="submit"] {
width: 100%;
max-width: 350px;
}
section.search > svg {
width: 100%;
}
/* # Search results */
section.results .result {
display: flex;
flex-direction: column;
gap: calc(var(--padding) / 2);
}
/* ## Titles */
section.title a h2 {
color: var(--color-accent);
}
section.title a h2::before {
content: "// ";
color: white;
}
/* ## Work */
section.results.work {
display: grid;
grid-template-columns: 1fr;
gap: calc(var(--padding) / 2);
}
section.results.work .result {
padding: var(--padding);
background-color: rgba(255, 255, 255, .03);
border-radius: 6px;
}

View file

@ -3,10 +3,9 @@
:root { :root {
--primer-color-accent: 3, 255, 219; --primer-color-accent: 3, 255, 219;
--color-accent: rgb(var(--primer-color-accent)); --color-accent: rgb(var(--primer-color-accent));
--hue-accent: 90deg;
} }
vv-shell { main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--padding); gap: var(--padding);
@ -28,8 +27,7 @@ section.git {
border-radius: 6px; border-radius: 6px;
} }
section.git > svg { section.git svg {
fill: white;
width: 60px; width: 60px;
} }
@ -121,10 +119,13 @@ section.timeline .items .item img {
} }
section.timeline .items .item .actions { section.timeline .items .item .actions {
display: flex;
align-items: baseline;
margin-top: 7px; margin-top: 7px;
gap: var(--padding); }
/* ## Note */
section.note {
text-align: center;
} }
/* # Size queries */ /* # Size queries */
@ -135,6 +136,23 @@ section.timeline .items .item .actions {
} }
} }
@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) { @media (max-width: 500px) {
section.timeline { section.timeline {
padding: unset; padding: unset;
@ -169,28 +187,7 @@ section.timeline .items .item .actions {
border-top-color: rgba(var(--primer-color-accent), .2); border-top-color: rgba(var(--primer-color-accent), .2);
} }
section.timeline .items .item .actions {
flex-direction: column;
}
section.timeline .year:first-of-type .month:first-of-type .day:first-of-type .items .item:first-of-type { section.timeline .year:first-of-type .month:first-of-type .day:first-of-type .items .item:first-of-type {
margin-top: var(--padding); 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;
}
}

Binary file not shown.

Binary file not shown.

51
assets/js/document.js Executable file
View file

@ -0,0 +1,51 @@
new vv.Interactions("document", {
navigateHome: () => new vv.Navigation("/").navigate(),
closeSearchbox: () => {
// Disable search button interaction while animation is running
// This is required to prevent conflicts with the :hover "peak" transformation
const searchButtonElement = document.querySelector("header button.search");
const transformDuration = parseInt(window.getComputedStyle(searchButtonElement).getPropertyValue("--transform-duration"));
searchButtonElement.style.setProperty("pointer-events", "none");
document.querySelector("header").classList.remove("searchboxActive");
// Wait for the transform animation to finish
setTimeout(() => searchButtonElement.style.removeProperty("pointer-events"), transformDuration);
},
openSearchbox: () => {
document.querySelector("header").classList.add("searchboxActive");
// Select searchbox inner input element
document.querySelector("searchbox input").focus();
}
});
// Crossfade pages on navigation
{
const mainElement = document.querySelector(vv._env.MAIN);
mainElement.addEventListener(vv.Navigation.events.LOADING, () => {
mainElement.classList.add("loading");
});
mainElement.addEventListener(vv.Navigation.events.LOADED, () => {
// Close searchbox on main page navigation
document.querySelector("header").classList.remove("searchboxActive");
// Wait 200ms for the page fade-in animation to finish
setTimeout(() => mainElement.classList.remove("loading"), 200);
});
}
// Handle search logic
{
const searchResultsElement = document.querySelector("search-results");
document.querySelector("header input[type='search']").addEventListener("input", (event) => {
// Debounce user input
clearTimeout(event.target._throttle);
event.target._throttle = setTimeout(() => {
// Navigate search-results element on user input
new vv.Navigation(`/search?q=${event.target.value}`).navigate(searchResultsElement);
}, 100);
});
}

View file

@ -1,3 +1,5 @@
new vv.Interactions("about");
const randomIntFromInterval = (min, max) => { const randomIntFromInterval = (min, max) => {
return Math.floor(Math.random() * (max - min + 1) + min); return Math.floor(Math.random() * (max - min + 1) + min);
} }
@ -56,23 +58,12 @@ const implodeInterests = () => {
// Bind mouse or touch events depending on pointer type of device // Bind mouse or touch events depending on pointer type of device
const canHover = window.matchMedia("(pointer: fine)").matches; const canHover = window.matchMedia("(pointer: fine)").matches;
// Explode interests when mouse hovers or touch hold starts interestsElement.addEventListener(canHover ? "mouseenter" : "touchstart", () => {
interestsElement.addEventListener(canHover ? "mouseenter" : "touchstart", (event) => explodeInterests(event.x, event.y)); // Get absolute position of the trigger element
// Implode interests when mouse leaves or touch hold ends const size = interestsElement.getBoundingClientRect();
explodeInterests(size.x, size.y);
});
interestsElement.addEventListener(canHover ? "mouseleave" : "touchend", () => implodeInterests()); interestsElement.addEventListener(canHover ? "mouseleave" : "touchend", () => implodeInterests());
} }
// Language bar chart hover tooltip
document.querySelectorAll("stacked-bar-chart chart-segment").forEach(element => {
const tooltipElement = element.querySelector("[data-hover]");
element.addEventListener("mouseenter", () => tooltipElement.classList.add("hovering"));
element.addEventListener("mouseleave", () => tooltipElement.classList.remove("hovering"));
element.addEventListener("mousemove", (event) => {
const x = event.layerX - (tooltipElement.clientWidth / 2);
const y = event.layerY + (tooltipElement.clientHeight - 30);
tooltipElement.style.setProperty("transform", `translate(${x}px, ${y}px)`);
});
});

View file

@ -10,6 +10,8 @@ class ContactForm {
[...document.querySelectorAll("form :is(input, textarea)")].forEach(element => { [...document.querySelectorAll("form :is(input, textarea)")].forEach(element => {
element.addEventListener("keyup", () => this.saveMessage()); element.addEventListener("keyup", () => this.saveMessage());
}); });
} }
// Get saved message as JSON from SessionStorage // Get saved message as JSON from SessionStorage
@ -34,7 +36,6 @@ class ContactForm {
return ContactForm.removeSavedMessage(); return ContactForm.removeSavedMessage();
} }
// Set value of each input field in DOM by name attribute
for (const [name, value] of Object.entries(message)) { for (const [name, value] of Object.entries(message)) {
this.form.querySelector(`[name="${name}"]`).value = value; this.form.querySelector(`[name="${name}"]`).value = value;
} }
@ -60,16 +61,27 @@ class ContactForm {
form ? (new ContactForm(form)) : ContactForm.removeSavedMessage(); form ? (new ContactForm(form)) : ContactForm.removeSavedMessage();
} }
document.querySelectorAll("social").forEach(element => { // Social links hover
const tooltipElement = element.querySelector("[data-hover]"); {
const socialElementHover = (target) => {
const element = target.querySelector("p");
element.addEventListener("mouseenter", () => tooltipElement.classList.add("hovering")); target.classList.add("hovering");
element.addEventListener("mouseleave", () => tooltipElement.classList.remove("hovering")); target.addEventListener("mousemove", (event) => {
const x = event.layerX - (element.clientWidth / 2);
const y = event.layerY + element.clientHeight;
element.addEventListener("mousemove", (event) => { element.style.setProperty("transform", `translate(${x}px, ${y}px)`);
const x = event.layerX - (tooltipElement.clientWidth / 2); });
const y = event.layerY + tooltipElement.clientHeight; };
tooltipElement.style.setProperty("transform", `translate(${x}px, ${y}px)`); const elements = [...document.querySelectorAll("social")];
elements.forEach(element => {
element.addEventListener("mouseenter", () => socialElementHover(element));
element.addEventListener("mouseleave", () => {
elements.forEach(element => element.classList.remove("hovering"));
});
}); });
}); }

View file

108
assets/js/pages/index.js Executable file
View file

@ -0,0 +1,108 @@
const EMAIL_CPY_ANIM_DUR_MSECONDS = 1000;
// Run email copied splash animation
const emailCopiedAnimation = () => {
const CONFETTI_COUNT = 40;
const CONFETTI_SCALE_PIXELS = 300;
const randomIntFromInterval = (min, max) => {
return Math.floor(Math.random() * (max - min + 1) + min)
}
// Create new splash element
const splashElement = document.createElement("splash");
splashElement.innerText = "copied!";
// Set inline display to none to hide this element on pages where the splash element has no override styles defined.
splashElement.style.display = "none";
// Array of box-shadow strings as "confetti"
const confetti = [];
// Generate random confetti
for (let i = 0; i < CONFETTI_COUNT; i++) {
// Random confetti position
const x = randomIntFromInterval(CONFETTI_SCALE_PIXELS * -1, CONFETTI_SCALE_PIXELS);
const y = randomIntFromInterval(CONFETTI_SCALE_PIXELS * -1, CONFETTI_SCALE_PIXELS);
// Random confetti RGB color
const rgb = [
randomIntFromInterval(0, 255),
randomIntFromInterval(0, 255),
randomIntFromInterval(0, 255)
];
// Interpolate random values and append to outer confetti array
confetti.push(`${x}px ${y}px 0 rgb(${rgb.join(",")})`);
}
// Set CSS variable on splash element that in turn will be used by pseudo-element
splashElement.style.setProperty("--confetti", confetti.join(","));
// Start animation by appending the created element to the document body
document.body.appendChild(splashElement);
// Run hide animation
setTimeout(() => {
splashElement.classList.add("hide");
// Selfdestruct element when hide animation finishes
setTimeout(() => splashElement.remove(), 400);
}, EMAIL_CPY_ANIM_DUR_MSECONDS + 100);
}
new vv.Interactions("index", {
// Copy email address to clipboard
copyEmail: async () => {
try {
await navigator.clipboard.writeText("victor@vlw.se");
// Run "email copied" animation!
emailCopiedAnimation();
// NOTE: I don't know, spamming the button is kinda fun
// Prevent interactions with the copy email elements while the animation is running
/*[...document.querySelectorAll("[vv-call='copyEmail']")].forEach(element => {
//element.classList.add("lock");
setTimeout(() => element.classList.remove("lock"), EMAIL_CPY_ANIM_DUR_MSECONDS);
});*/
} catch (error) {
console.error(error.message);
}
},
// Open the fullscreen menu
openMenu: () => document.querySelector("menu").classList.add("active"),
// Close the fullscreen menu
closeMenu: () => document.querySelector("menu").classList.remove("active")
});
// Change site accent color on hover of menu items
if (window.matchMedia("(hover: hover)")) {
// Update root CSS variables
const updateColor = (rgb = null, hue = 0) => {
if (!rgb) {
document.documentElement.style.removeProperty("--hue-accent");
document.documentElement.style.removeProperty("--primer-color-accent");
document.documentElement.style.removeProperty("--color-accent");
return;
}
document.documentElement.style.setProperty("--hue-accent", `${hue}deg`);
document.documentElement.style.setProperty("--primer-color-accent", `${rgb}`);
// Compiled color variable must to be updated to receive the new RGB values
document.documentElement.style.setProperty("--color-accent", "rgb(var(--primer-color-accent)");
};
[...document.querySelectorAll("menu li")].forEach(element => {
// Change site accent color to RGB and HUE rotation defined in element dataset
element.addEventListener("mouseenter", (event) => updateColor(event.target.dataset.rgb, event.target.dataset.hue));
// Reset initial accent color and hues
element.addEventListener("mouseleave", () => updateColor());
});
// Reset color on navigation
document.querySelector(vv._env.MAIN).addEventListener(vv.Navigation.events.LOADED, () => updateColor(), { once: true });
}

1
assets/js/pages/search.js Executable file
View file

@ -0,0 +1 @@
new vv.Interactions("search");

1
assets/js/pages/work.js Executable file
View file

@ -0,0 +1 @@
new vv.Interactions("work");

View file

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View file

View file

View file

View file

View file

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

1
assets/media/icons/github.svg Executable file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 98 98"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 957 B

View file

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.8 KiB

View file

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View file

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

0
public/assets/media/line.svg → assets/media/line.svg Normal file → Executable file
View file

Before

Width:  |  Height:  |  Size: 238 B

After

Width:  |  Height:  |  Size: 238 B

View file

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 238 KiB

1
assets/media/vw.svg Executable file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="22"><g fill="none"><path class="solid" d="M12 22 0 0h24Z"/><path class="stroke" d="M12 17.823 20.63 2H3.37L12 17.823M12 22 0 0h24L12 22Z"/><g opacity=".5"><path class="solid" d="M24 22 12 0h24Z"/><path class="stroke" d="M24 17.823 32.63 2H15.37L24 17.823M24 22 12 0h24L24 22Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 351 B

View file

@ -1,7 +1,7 @@
{ {
"require": { "require": {
"vlw/mysql": "dev-master", "reflect/client": "dev-master",
"vlw/xenum": "dev-master" "victorwesterlund/xenum": "dev-master"
}, },
"minimum-stability": "dev" "minimum-stability": "dev"
} }

56
composer.lock generated
View file

@ -4,49 +4,65 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "a7ce20d192550ef2d037220b593b5eb9", "content-hash": "2a8a06dc452a4eb9055d238f771dcd37",
"packages": [ "packages": [
{ {
"name": "vlw/mysql", "name": "reflect/client",
"version": "dev-master", "version": "dev-master",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://codeberg.org/vlw/php-mysql", "url": "https://github.com/VictorWesterlund/reflect-client-php.git",
"reference": "0e367f797fa9348408881ed758976f21e8c667e4" "reference": "89a8c041044c8c60cefafc4716d5d61b96c43e06"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/VictorWesterlund/reflect-client-php/zipball/89a8c041044c8c60cefafc4716d5d61b96c43e06",
"reference": "89a8c041044c8c60cefafc4716d5d61b96c43e06",
"shasum": ""
}, },
"default-branch": true, "default-branch": true,
"type": "library", "type": "library",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"vlw\\MySQL\\": "src/" "Reflect\\": "src/Reflect/"
} }
}, },
"notification-url": "https://packagist.org/downloads/", "notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"GPL-3.0-or-later" "GPL-2.0-only"
], ],
"authors": [ "authors": [
{ {
"name": "Victor Westerlund", "name": "Victor Westerlund",
"email": "victor@vlw.se" "email": "victor.vesterlund@gmail.com"
} }
], ],
"description": "Abstraction library for common MySQL/MariaDB DML operations with php-mysqli", "description": "Extendable PHP interface for communicating with Reflect API over HTTP or UNIX sockets",
"time": "2025-07-29T07:46:46+00:00" "support": {
"issues": "https://github.com/VictorWesterlund/reflect-client-php/issues",
"source": "https://github.com/VictorWesterlund/reflect-client-php/tree/3.0.6"
},
"time": "2024-04-06T14:55:04+00:00"
}, },
{ {
"name": "vlw/xenum", "name": "victorwesterlund/xenum",
"version": "dev-master", "version": "dev-master",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://codeberg.org/vlw/php-xenum", "url": "https://github.com/VictorWesterlund/php-xenum.git",
"reference": "ba3f43a9e2787bf938cfbfcb85ea87e5062df294" "reference": "8972f06f42abd1f382807a67e937d5564bb89699"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/VictorWesterlund/php-xenum/zipball/8972f06f42abd1f382807a67e937d5564bb89699",
"reference": "8972f06f42abd1f382807a67e937d5564bb89699",
"shasum": ""
}, },
"default-branch": true, "default-branch": true,
"type": "library", "type": "library",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"vlw\\": "src/" "victorwesterlund\\": "src/"
} }
}, },
"notification-url": "https://packagist.org/downloads/", "notification-url": "https://packagist.org/downloads/",
@ -56,23 +72,27 @@
"authors": [ "authors": [
{ {
"name": "Victor Westerlund", "name": "Victor Westerlund",
"email": "victor@vlw.se" "email": "victor.vesterlund@gmail.com"
} }
], ],
"description": "PHP eXtended Enums. The missing quality-of-life features from PHP 8+ Enums", "description": "PHP eXtended Enums. The missing quality-of-life features from PHP 8+ Enums",
"time": "2025-05-10T11:28:03+00:00" "support": {
"issues": "https://github.com/VictorWesterlund/php-xenum/issues",
"source": "https://github.com/VictorWesterlund/php-xenum/tree/1.1.1"
},
"time": "2023-11-20T10:10:39+00:00"
} }
], ],
"packages-dev": [], "packages-dev": [],
"aliases": [], "aliases": [],
"minimum-stability": "dev", "minimum-stability": "dev",
"stability-flags": { "stability-flags": {
"vlw/mysql": 20, "reflect/client": 20,
"vlw/xenum": 20 "victorwesterlund/xenum": 20
}, },
"prefer-stable": false, "prefer-stable": false,
"prefer-lowest": false, "prefer-lowest": false,
"platform": [], "platform": [],
"platform-dev": [], "platform-dev": [],
"plugin-api-version": "2.3.0" "plugin-api-version": "2.0.0"
} }

View file

@ -1,251 +0,0 @@
#!/bin/bash
# Define constants
DB_VLW="vlw"
DB_API="vlw_api"
# Initialize variables
cwd=""
database_app_host=""
database_app_user=""
database_seed_host=""
database_seed_user=""
database_app_password=""
database_seed_password=""
echo_err() {
echo "!! -> $1"
}
# Make sure we have all the system packages we need to proceed with the installation
check_sys_depend() {
local valid=true
local packages=("git" "composer")
for package in "${packages[@]}" ; do
if ! dpkg -l | grep -qw "ii ${package}" ; then
echo_err "Package '${package}' is not installed."
valid=false
fi
done
# Bail out if any required package is missing
if [ "$valid" = false ] ; then
exit 1
fi
}
install_vegvisir() {
echo
echo "Installing Vegvisir into '$cwd/vegvisir'"
curl -fsSL https://codeberg.org/vegvisir/install/raw/branch/master/install.sh | bash -s -- --install=n --overwrite=y --example=n --dir="$cwd"
}
install_reflect() {
echo
echo "Installing Reflect into '$cwd/reflect'"
curl -fsSL https://codeberg.org/reflect/install/raw/branch/master/install.sh | bash -s -- --install=n --overwrite=y --seed=n --dir="$cwd" --endpoints="api" --host="$database_app_host" --user="$database_app_user" --password="$database_app_password" --db="$DB_API"
}
install_vlw() {
composer install --classmap-authoritative
}
seed_databases() {
echo
echo "-- Database seeding --"
echo "We're now going to seed the databases '$DB_VLW' and '$DB_API' with default data"
echo "- Make sure that both databases exist and are empty"
echo "- Your credentials for this user '$(whoami)' might be different from your app credentials (php-mysql)"
echo
# Database seed hostname
echo "Enter the full hostname of your MySQL/MariaDB server to use in this script for seeding."
read -p "Press enter to use the same host as the app credentials [$database_app_host]: " database_seed_host </dev/tty
# Use the same database host as the app configuration
if [[ "$database_seed_host" == "" ]] ; then
database_seed_host=$database_app_host
fi
# Database seed user
echo
echo "Enter the username for your MySQL/MariaDB server to use in this script for seeding."
read -p "Press enter to use the same host as the app credentials [$database_app_user]: " database_seed_user </dev/tty
# Use the same database user as the app configuration
if [[ "$database_seed_user" == "" ]] ; then
database_seed_user=$database_app_user
fi
# Database seed password
echo
echo "Enter the password for your MySQL/MariaDB server to use in this script for seeding."
echo "Enter 'null' to disable password authentication"
read -p "Press enter to use the same password as the app credentials [<database_password>]: " database_seed_password </dev/tty
# Use the same database password as the app configuration
if [[ "$database_seed_password" == "" ]] ; then
database_seed_password=$database_app_password
fi
# Seed the main database
mysql -h "$database_seed_host" -u "$database_seed_user" --password="$database_seed_password" $DB_VLW < src/Database/Seeds/vlw.sql
if [ $? -ne 0 ]; then
echo_err "Installation aborted: MySQL error"
exit 1
fi
# Seed the API database
mysql -h "$database_seed_host" -u "$database_seed_user" --password="$database_seed_password" $DB_API < src/Database/Seeds/api.sql
if [ $? -ne 0 ]; then
echo_err "Installation aborted: MySQL error"
exit 1
fi
}
configure_vlw() {
local config_password=""
local config_available=""
local config_available_to=""
local config_available_from=""
local config_available_average=""
local config_available_timezone=""
local config_forgejo=""
local config_forgejo_url=""
local config_forgejo_profiles=""
# A configuration file already exists
if [[ -f ".env.ini" ]] ; then
echo
echo "A configuration file already exists at: ${cwd}/.env.ini"
read -p "Do you want to overwrite this file? (y/n): " overwrite </dev/tty
# Remove existing configuration file or abort
if [[ "$overwrite" == "y" || "$overwrite" == "Y" ]] ; then
echo "Removing existing configuration and proceeding with the installation in ${cwd}..."
rm .env.ini
else
echo_err "Installation aborted: Configuration file already exists"
exit 1
fi
fi
echo
echo "-- Database configuration --"
read -p "Enter the full hostname of your MySQL/MariaDB server (php-mysql): " database_app_host </dev/tty
read -p "Enter the app username for your MySQL/MariaDB server (php-mysql): " database_app_user </dev/tty
read -p "Enter the app password for your MySQL/MariaDB server (php-mysql): " config_password </dev/tty
# Coerce empty input as null keyword for later configurations
if [[ "$config_password" == "" ]] ; then
database_app_password="null"
fi
echo
read -p "(Optional) Would you like to configure time available settings? (y/n): " config_available </dev/tty
# Check the user's response
if [[ "$config_available" == "n" || "$config_available" == "N" ]]; then
config_available_to=0
config_available_from=0
config_available_average=0
config_available_timezone="Europe/Stockholm"
fi
if ! [[ -n "$config_available_timezone" ]]; then
read -p "Enter your timezone in IANA Time Zone Database Format ('Europe/Stockholm' for example): " config_available_timezone </dev/tty
fi
if ! [[ -n "$config_available_from" ]]; then
read -p "Enter time available from hour (24-hour format): " config_available_from </dev/tty
fi
if ! [[ -n "$config_available_to" ]]; then
read -p "Enter time available to hour (24-hour format): " config_available_to </dev/tty
fi
if ! [[ -n "$config_available_average" ]]; then
read -p "Enter average reply time in hours: " config_available_average </dev/tty
fi
echo
read -p "(Optional) Would you like to configure Forgejo language updates? (y/n): " config_forgejo </dev/tty
# Check the user's response
if [[ "$config_forgejo" == "n" || "$config_forgejo" == "N" ]]; then
config_forgejo_url="https://git.vlw.se"
config_forgejo_profiles="vlw,vegvisir,reflect"
fi
if ! [[ -n "$config_forgejo_url" ]]; then
read -p "Enter a hostname to a Forgejo instance ('https://git.vlw.se' for example): " config_forgejo_url </dev/tty
fi
if ! [[ -n "$config_forgejo_profiles" ]]; then
read -p "Enter a comma seperated list of Forgejo profiles to scan ('vlw,vegvisir,reflect' for example): " config_forgejo_profiles </dev/tty
fi
local config=(
"; This config file was generated automatically by ./install.sh"
"; Refer to '.env.example.ini' for more information"
"[mariadb]"
"host = '$database_app_host'"
"user = '$database_app_user'"
"pass = '$config_password'"
"db = '$DB_VLW'"
"[config_time_available]"
"time_zone = '$config_available_timezone'"
"available_to_hour = '$config_available_to'"
"reply_average_hours = '$config_available_average'"
"available_from_hour = '$config_available_from'"
"[service_forgejo]"
"url = '$config_forgejo_url'"
"profiles = '$config_forgejo_profiles'"
)
for line in "${config[@]}" ; do
echo "${line}" >> .env.ini
done
}
main() {
# Get the current working directory
cwd=$(pwd)
check_sys_depend
configure_vlw
seed_databases
install_vlw
install_vegvisir
install_reflect
echo "-- Success --"
echo "vlw.se has been installed! :)"
echo "- Point all traffic to your web server to '${cwd}/vegvisir/public/index.php'"
echo "- Point all traffic to your REST API server to '${cwd}/reflect/public/index.php'"
echo "-------------"
echo
}
# Prompt the user for confirmation
echo
echo "-- Installing vlw.se --"
echo "You are currently in: $(pwd)"
read -p "Do you want to proceed with the installation in this directory? (y/n): " choice </dev/tty
# Check the user's response
if [[ "$choice" == "y" || "$choice" == "Y" ]] ; then
echo "Proceeding with the installation in $(pwd)..."
main
else
echo "Installation aborted."
fi

45
pages/about.php Executable file
View file

@ -0,0 +1,45 @@
<style><?= VV::css("pages/about") ?></style>
<section class="intro">
<h2 aria-hidden="true">Hi, I'm</h2>
<h1>Victor Westerlund</h1>
</section>
<hr aria-hidden="true">
<section class="about">
<p>I&ZeroWidthSpace;'m a full-stack web developer from Sweden, currently working as IT-Lead at the biopharma start-up <a href="https://icellate.com">iCellate&nbsp;Medical</a> in Solna, Stockholm. I also develop and maintain <a href="https://github.com/VictorWesterlund/vegvisir">my own web framework</a> and use it to build web apps and websites - including this one.</p>
<p>The &lt;programming/markup/command&gt;-languages I currently use the most are (in a mostly accurate decending order): PHP, JavaScript, CSS, MySQL, Python, SQLite, Bash, and [pure] HTML.</p>
</section>
<section class="about">
<h2>This website</h2>
<p>This site and all of its components are 100% free and open source software. The website is designed and built by me from the ground up on top of my own <a href="https://github.com/victorwesterlund/vegvisir">web</a> and <a href="https://github.com/victorwesterlund/reflect">API</a> frameworks. There are <i>no cookies or trackers</i> here. The only information I have about you is your public IP-address and which resources on this site your browser requests. None of this data is used for any kind of analytics.</p>
<p><a href="https://github.com/victorwesterlund/vlw.se">Checkout the website source code on GitHub</a></p>
</section>
<section class="about">
<h2>Personal</h2>
<p>With a cup of coffee ready at hand, I can at times become a real armchair detective for a <span class="interests">variety of nerdy topics I find interesting</span>, and spend hours reading as much as I can about them too. I like to skii when I'm not glued in front of a computer screen.</p>
</section>
<section class="about">
<h2>Projects</h2>
<p>Here are the projects I'm working on right now:</p>
<p>* <a href="https://github.com/victorwesterlund/reflect">Reflect</a>: An API framework written in PHP - for PHP developers.</p>
<p>* <a href="https://github.com/victorwesterlund/vegvisir">Vegvisir</a>: A web framework written in PHP and JavaScript - for PHP and JavaScript developers.</p>
<p>See more on my <a href="work" vv="about" vv-call="navigate">works page</a>. And even more, including smaller projects on <a href="https://github.com/VictorWesterlund">my GitHub profile</a>.</p>
</section>
<hr>
<section>
<p>Let's work on something together or just have a chat. <a href="contact" vv="about" vv-call="navigate">Write me a line!</a></p>
</section>
<hr>
<section class="version">
<p>website version: <?= VV::include("pages/about/version") ?></p>
</section>
<div class="interests" aria-hidden="true">
<p>practical&nbsp;engineering</p>
<p>music</p>
<p>astronomy</p>
<p>electronics</p>
<p>aviation</p>
<p>marine&nbsp;technology</p>
<p>typography</p>
</div>
<script><?= VV::js("pages/about") ?></script>

19
pages/about/version.php Executable file
View file

@ -0,0 +1,19 @@
<?php
/*
A pretty naive website version fetcher that assumes the latest git tag is the
version the website is currently displaying. The intent is that any live-master
of this website should always track the master branch and pull the latest HEAD
without any exceptions.
*/
use Vegvisir\Path;
// Get tags from local git folder
$dir = scandir(Path::root(".git/refs/tags"));
// Get current version number from latest tag
$version = end($dir);
?>
<a href="https://github.com/victorwesterlund/vlw.se/releases/<?= $version ?>"><?= $version ?></a>

91
pages/contact.php Executable file
View file

@ -0,0 +1,91 @@
<?php
use Vegvisir\Path;
use VLW\Client\API;
use VLW\API\Endpoints;
use VLW\API\Databases\VLWdb\Models\Messages\MessagesModel;
require_once Path::root("src/client/API.php");
require_once Path::root("api/src/Endpoints.php");
require_once Path::root("api/src/databases/models/Messages/Messages.php");
// Connect to VLW API
$api = new API();
?>
<style><?= VV::css("pages/contact") ?></style>
<section>
<h1>Let's chat</h1>
<p>The best way to get in touch is by email, or with the form on this page. I will try to reply as quickly as possible, probably within a few hours. The time is <i><?= (new DateTime("now", new DateTimeZone($_ENV["time"]["date_time_zone"])))->format("h:i a") ?></i> in Sweden right now.</p>
</section>
<section class="social">
<a href="mailto:victor@vlw.se"><social>
<?= VV::media("icons/email.svg") ?>
<p>e-mail</p>
</social></a>
<a href="https://mastodon.social/@vlwone"><social>
<?= VV::media("icons/mastodon.svg") ?>
<p>mastodon</p>
</social></a>
<a href="https://web.libera.chat/#vlw.se"><social>
<?= VV::media("icons/libera.svg") ?>
<p>libera.chat</p>
</social></a>
</section>
<?= VV::media("line.svg") ?>
<section class="pgp">
<?= VV::media("icons/pin.svg") ?>
<h3>encrypt your message with my OpenPGP key.</h3>
<p>my key is also listed on the <a href="https://keys.openpgp.org/search?q=victor%40vlw.se" target="_blank" rel="noopener noreferer">openPGP key server</a> for victor@vlw.se so your e-mail client can automatically retreive it if supported.</p>
<div class="buttons">
<a href="https://keys.openpgp.org/vks/v1/by-fingerprint/DCE987311CB5D2A252F58951D0AD730E1057DFC6"><button class="inline solid">download ASC</button></a>
<a href="https://emailselfdefense.fsf.org/en/" target="_blank" rel="noopener noreferer"><button class="inline">more info</button></a>
</div>
</section>
<?= VV::media("line.svg") ?>
<?php // Send message on POST request ?>
<?php if ($_SERVER["REQUEST_METHOD"] === "POST"): ?>
<?php
// Send message via API
$send = $api->call(Endpoints::MESSAGES->value)->post([
MessagesModel::EMAIL->value => $_POST[MessagesModel::EMAIL->value],
MessagesModel::MESSAGE->value => $_POST[MessagesModel::MESSAGE->value]
]);
?>
<?php if ($send->ok): ?>
<section class="form-message sent">
<h3>🙏 Message sent!</h3>
</section>
<?php else: ?>
<?php // Show response body from endpoint as error if request failed ?>
<section class="form-message error">
<h3>😟 Oh no, something went wrong</h3>
<p>Response from API:</p>
<pre><?= $send->output() ?></pre>
</section>
<?php endif; ?>
<?php endif; ?>
<section class="form">
<form method="POST">
<input-group>
<label>your email (optional)</label>
<input type="email" name="<?= MessagesModel::EMAIL->value ?>" placeholder="nissehult@example.com" autocomplete="off"></input>
</input-group>
<input-group>
<label title="this field is required">your message (required)</label>
<textarea name="<?= MessagesModel::MESSAGE->value ?>" required placeholder="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed molestie dignissim mauris vel dignissim. Sed et aliquet odio, id egestas libero. Vestibulum ut dui a turpis aliquam hendrerit id et dui. Morbi eu tristique quam, sit amet dictum felis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin ac nibh a ex accumsan ullamcorper non quis eros. Nam at suscipit lacus. Nullam placerat semper sapien, vitae aliquet nisl elementum a. Duis viverra quam eros, eu vestibulum quam egestas sit amet. Duis lobortis varius malesuada. Mauris in fringilla mi. "></textarea>
</input-group>
<button class="inline solid">send</button>
</form>
</section>
<script><?= VV::js("pages/contact") ?></script>

33
public/shell.php → pages/document.php Normal file → Executable file
View file

@ -1,12 +1,8 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta property="og:title" content="Victor L. Westerlund"/>
<meta property="og:type" content="website"/>
<meta property="og:description" content="Full-stack PHP and JavaScript web developer from Sweden"/>
<meta property="og:image" content="https://vlw.se/assets/media/ogp.jpg"/>
<script> <script>
<!--//--><![CDATA[//><!-- <!--//--><![CDATA[//><!--
@ -37,38 +33,39 @@
//--><!]]> //--><!]]>
</script> </script>
<style><?= VV::css("public/assets/css/fonts") ?></style> <?php // Bootstrapping ?>
<style><?= VV::css("public/assets/css/shell") ?></style> <style><?= VV::css("fonts") ?></style>
<style><?= VV::css("document") ?></style>
<title>Victor Westerlund</title> <title>Victor Westerlund</title>
<link rel="icon" href="/assets/media/vw.svg"/>
</head> </head>
<body> <body>
<header> <header>
<nav> <nav>
<p><a href="/">victor westerlund</a></p> <p><a href="/" vv="document" vv-call="navigate">victor westerlund</a></p>
</nav> </nav>
<button class="search searchbox-open"> <button class="search" vv="document" vv-call="openSearchbox">
<?= VV::embed("public/assets/media/icons/search.svg") ?> <?= VV::media("icons/search.svg") ?>
<p>search vlw.se...</p> <p>search vlw.se...</p>
</button> </button>
<button class="logo"><?= VV::embed("public/assets/media/vw.svg") ?></button> <button class="logo" vv="document" vv-call="navigateHome"><?= VV::media("vw.svg") ?></button>
<searchbox> <searchbox>
<input type="search" autocomplete="off" placeholder="search vlw.se..."> <input type="search" autocomplete="off" placeholder="search vlw.se...">
<button class="close searchbox-close"><?= VV::embed("public/assets/media/icons/close.svg") ?></button> <button class="close" vv="document" vv-call="closeSearchbox"><?= VV::media("icons/close.svg") ?></button>
</searchbox> </searchbox>
</header> </header>
<vv-shell></vv-shell> <main></main>
<search-results> <search-results>
<div class="info empty"> <div class="info empty">
<?= VV::embed("public/assets/media/icons/search.svg") ?> <?= VV::media("icons/search.svg") ?>
<p>start typing to search</p> <p>start typing to search</p>
</div> </div>
</search-results> </search-results>
<?= VV::init() ?> <?php // Bootstrapping ?>
<script><?= VV::js("public/assets/js/shell") ?></script> <script><?= VV::init() ?></script>
<script><?= VV::js("document") ?></script>
</body> </body>
</html> </html>

6
pages/error.php Executable file
View file

@ -0,0 +1,6 @@
<style><?= VV::css("pages/error") ?></style>
<canvas></canvas>
<section class="error">
<h1 glitch-text><span>4</span><span>0</span><span>4</span></h1>
</section>
<script type="module"><?= VV::js("pages/error") ?></script>

31
pages/index.php Executable file
View file

@ -0,0 +1,31 @@
<?php
enum RGB: string {
case WORK = "3,255,219";
case ABOUT = "148,255,21";
case CONTACT = "255,195,255";
}
?>
<style><?= VV::css("pages/index") ?></style>
<div class="menu">
<?= VV::media("line.svg") ?>
<menu>
<a href="/work" vv="index" vv-call="navigate"><li data-rgb="<?= RGB::WORK->value ?>" data-hue="90">work</li></a>
<a href="/about" vv="index" vv-call="navigate"><li data-rgb="<?= RGB::ABOUT->value ?>" data-hue="390">about</li></a>
<a href="/contact" vv="index" vv-call="navigate"><li data-rgb="<?= RGB::CONTACT->value ?>" data-hue="200">contact</li></a>
</menu>
<?= VV::media("line.svg") ?>
<button class="email" vv="index" vv-call="copyEmail">
<p>victor@vlw.se</p>
<p class="cta">to copy</p>
</button>
</div>
<img src="/assets/media/gazing.jpg"/>
<!--<picture class="gazing">
<source srcset="/assets/media/gazing.avif" type="image/avif"/>
<source srcset="/assets/media/gazing.webp" type="image/webp"/>
<img src="/assets/media/gazing.jpg"/>
</picture>-->
<script><?= VV::js("pages/index") ?></script>

99
pages/search.php Executable file
View file

@ -0,0 +1,99 @@
<?php
use Vegvisir\Path;
use VLW\Client\API;
use VLW\API\Endpoints;
use VLW\API\Databases\VLWdb\Models\Work\{
WorkModel,
WorkActionsModel
};
require_once Path::root("src/client/API.php");
require_once Path::root("api/src/Endpoints.php");
require_once Path::root("api/src/databases/models/Work/Work.php");
require_once Path::root("api/src/databases/models/Work/WorkActions.php");
// Search endpoint query paramter
const SEARCH_PARAM = "q";
// Connect to VLW API
$api = new API();
// Get search results from endpoint
$response = $api->call(Endpoints::SEARCH->value)->params([SEARCH_PARAM => $_GET[SEARCH_PARAM]])->get();
?>
<style><?= VV::css("pages/search") ?></style>
<section class="search">
<form method="GET">
<search>
<input name="<? SEARCH_PARAM ?>" type="text" placeholder="search vlw.se..." value="<?= $_GET[SEARCH_PARAM] ?>"></input>
</search>
<button type="submit" class="inline solid">Search</button>
</form>
<?= VV::media("line.svg") ?>
<button class="inline">advanced search options</button>
</section>
<?php if ($response->ok): ?>
<?php // Get response body ?>
<?php $results = $response->json(); ?>
<?php // Search contains results from the work endpoint ?>
<?php if ($results[Endpoints::WORK->value]): ?>
<section class="title work">
<a href="<? Endpoints::WORK->value ?>" vv="search" vv-call="navigate"><h2>Work</h2></a>
<p><?= count($results[Endpoints::WORK->value]) ?> search result(s) from my public work</p>
</section>
<section class="results work">
<?php // List all work category search results ?>
<?php foreach ($results[Endpoints::WORK->value] as $result): ?>
<div class="result">
<h3><?= $result[WorkModel::TITLE->value] ?></h3>
<p><?= $result[WorkModel::SUMMARY->value] ?></p>
<p><?= date(API::DATE_FORMAT, $result[WorkModel::DATE_CREATED->value]) ?></p>
<?php // Get action buttons for work entity by id ?>
<?php $actions = $api->call(Endpoints::WORK_ACTIONS->value)->params([WorkActionsModel::REF_WORK_ID->value => $result[WorkModel::ID->value]])->get(); ?>
<?php // List each action button for work entity if exists ?>
<?php if ($actions->ok): ?>
<div class="actions">
<?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] ?>" vv="search" vv-call="navigate"><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; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</section>
<?php endif; ?>
<?php else: ?>
<?php if (!empty($_GET[SEARCH_PARAM])): ?>
<section class="info noresults">
<img src="/assets/media/travolta.gif" alt="">
<p>No results for search term "<?= $_GET[SEARCH_PARAM] ?>"</p>
</section>
<?php else: ?>
<section class="info empty">
<?= VV::media("icons/search.svg") ?>
<p>Start typing to search</p>
</section>
<?php endif; ?>
<?php endif; ?>
<script><?= VV::js("pages/search") ?></script>

181
pages/work.php Executable file
View file

@ -0,0 +1,181 @@
<?php
use Vegvisir\Path;
use VLW\Client\API;
use VLW\API\Endpoints;
use VLW\API\Databases\VLWdb\Models\Work\{
WorkModel,
WorkTagsModel,
WorkActionsModel
};
require_once Path::root("src/client/API.php");
require_once Path::root("api/src/Endpoints.php");
require_once Path::root("api/src/databases/models/Work/Work.php");
require_once Path::root("api/src/databases/models/Work/WorkTags.php");
require_once Path::root("api/src/databases/models/Work/WorkActions.php");
// Connect to VLW API
$api = new API();
// Retreive rows from work endpoints
$resp_work = $api->call(Endpoints::WORK->value)->get();
// 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();
}
?>
<style><?= VV::css("pages/work") ?></style>
<section class="git">
<?= VV::media("icons/github.svg") ?>
<p>Most of my free open-source software is available on GitHub and it's also mirrored on my server</p>
<div class="buttons">
<a href="https://github.com/victorwesterlund"><button class="inline solid">open GitHub</button></a>
<a href="https://git.vlw.se"><button class="inline">mirror</button></a>
</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("pages/work") ?></script>

View file

@ -1,140 +0,0 @@
<?php
use VLW\Database\Models\Coffee\Coffee;
use VLW\Database\Models\Languages\Language;
require_once VV::root("src/Database/Models/Coffee/Coffee.php");
require_once VV::root("src/Database/Models/Languages/Language.php");
const FORGEJO = "https://git.vlw.se/explore/repos?language=";
const SI_BYTE_MULTIPLE = ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
$languages = new class extends Language {
private readonly int $total_bytes;
public function __construct() {
$this->total_bytes = array_sum(array_map(fn(Language $language): int => $language->bytes, parent::all()));
}
public function percent(Language $language, int $mode = PHP_ROUND_HALF_UP): int {
return round(($language->bytes / $this->total_bytes) * 100, 0, $mode);
}
public function percent_string(Language $language): string {
return ($this->percent($language) > 1 ? $this->percent($language) : "<1") . "%";
}
public function bytes_si_string(Language $language): string {
// Calculate factor for unit
$factor = floor((strlen($language->bytes) - 1) / 3);
// Divide by radix 10
$format = $language->bytes / pow(1000, $factor);
return round($format) . " " . SI_BYTE_MULTIPLE[$factor];
}
};
$coffee = new class extends Coffee {
public readonly int $count_week;
public readonly int $count_week_average;
public function __construct() {
$this->count_week = parent::count_week();
$this->count_week_average = parent::count_week_average();
}
public function week_average_string(): string {
$diff = $this->count_week - $this->count_week_average;
return match (true) {
$diff < 0 => "less than",
$diff === 0 => "the same as",
$diff > 0 => "more than"
};
}
};
?>
<style><?= VV::css("public/assets/css/pages/about") ?></style>
<section class="intro">
<h2 aria-hidden="true">Hi, I'm</h2>
<h1>Victor Westerlund</h1>
</section>
<hr aria-hidden="true">
<section class="about">
<p>I&ZeroWidthSpace;'m a full-stack web developer from Sweden, and welcome to my little personal corner of the Internet!</p>
<p>My coding happens almost exclusivly in <a href="https://github.com/coder/code-server">code-server</a>, which is a fork of VSCode that runs entirely in the browser. I keep my development environment tucked away in a lightweight Debian VA that I can tote around to whatever host machine I happen to work on. I also keep an ephemeral Debian Live ISO ready which boots into a VM RAM disk where I can mess around without fear or breaking things or try new software.</p>
<p>I used to list the &lt;programming/markup/command/whatever&gt;-languages here that I use the most and order them by guesstimating how much I use each one. But then I thought it would be better to just show you instead using this chart that automatically pulls the total bytes for each language from my <a href="https://git.vlw.se/explore/repos">public repos on Forgejo</a>.</p>
</section>
<section class="languages">
<stacked-bar-chart>
<?php foreach ($languages::all() as $language): ?>
<a href="<?= FORGEJO . $language->name ?>" target="_blank"><chart-segment style="--size:<?= $languages->percent($language) ?>%;" data-lang="<?= $language->name ?>" data-bytes="<?= $language->bytes ?>">
<span data-hover><strong><?= $languages->percent_string($language) ?> <?= $language->name ?></strong><br>(<?= $language->bytes ?> bytes)</span>
</chart-segment></a>
<?php endforeach; ?>
</stacked-bar-chart>
<languages-list>
<?php foreach ($languages::all() as $language): ?>
<a href="<?= FORGEJO . $language->name ?>"><button data-lang="<?= $language->name ?>" class="inline">
<p><?= $languages->percent_string($language) ?></p>
<p class="lang"><?= $language->name ?></p>
<p><?= $languages->bytes_si_string($language) ?></p>
<?= VV::embed("public/assets/media/icons/chevron.svg") ?>
</button></a>
<?php endforeach; ?>
</languages-list>
<stacked-bar-chart>
<?php foreach ($languages::all() as $language): ?>
<a href="<?= FORGEJO . $language->name ?>" target="_blank"><chart-segment style="--size:<?= $languages->percent($language) ?>%;" data-lang="<?= $language->name ?>" data-bytes="<?= $language->bytes ?>">
<span data-hover><strong><?= $languages->percent_string($language) ?> <?= $language->name ?></strong><br>(<?= $language->bytes ?> bytes)</span>
</chart-segment></a>
<?php endforeach; ?>
</stacked-bar-chart>
</section>
<section class="about">
<h2>This website</h2>
<p>This site and all of its components, including texts and graphics have been created by me and are all <a href="https://codeberg.org/vlw/vlw.se">100% free and open source</a>. Feel free to use anything you see on this website in your own projects as long as it's under the same GNU GPLv3-or-later license. The website is designed by me on top of my own <a href="https://vegvisir.vlw.se">web framework</a> and <a href="https://reflect.vlw.se">API framework</a>.</p>
<p>You won't find any cookies or trackers on this site! The only information I have about you are in the standard NGINX access and error logs, which get overwritten automatically after some time.</p>
</section>
<section class="about">
<h2>Personal</h2>
<p>One thing that most people know about me is that I like coffee.. lots of coffee. In fact, I've had <?= $coffee->count_week ?> cup<?= $coffee->count_week === 1 ? "" : "s" ?> of coffee in the last 7 days! That's <?= $coffee->week_average_string() ?> my average of <?= $coffee->count_week_average ?> per week, impressive! Even though you just read that.. I don't consider myself <i>too much</i> of a coffee snob! As long as it's dark roast and warm, I'm probably happy to have it.</p>
<p>At times, I become a true, amateur, armchair detective for a <span class="interests">variety of your typical-nerdy topics that I find interesting</span> and you can bet I spend way more time reading about those things than I will ever have use for in life.</p>
<p>Another silent passion of mine that comes out every few years is building computers and fiddling with networking stuff.</p>
</section>
<hr>
<section class="about">
<h3>GitHub</h3>
<p>I have <a href="https://giveupgithub.com" target="_blank" rel="noopener noreferer">given up GitHub</a> and moved most of my free software to <a href="https://codeberg.org/vlw">Codeberg</a>. You can still find my <a href="https://github.com/VictorWesterlund">GitHub profile here</a> but I don't use it for source control of my projects anymore.</p>
</section>
<hr>
<section>
<p>Let's work on something together or just have a chat? <a href="contact">Write me a line!</a></p>
</section>
<div class="interests" aria-hidden="true">
<p>SSTV</p>
<p>music</p>
<p>aviation</p>
<p>maritime</p>
<p>politics</p>
<p>astronomy</p>
<p>typography</p>
<p>networking</p>
<p>electronics</p>
<p>simulations</p>
<p>engineering</p>
<p>photography</p>
<p>videography</p>
<p>RFC&nbsp;3339</p>
<p>digital archiving</p>
</div>
<script><?= VV::js("public/assets/js/pages/about") ?></script>

View file

@ -1,8 +0,0 @@
@font-face {
font-family: "Roboto Mono";
src:
url("/assets/fonts/roboto-mono.woff2") format("woff2 supports variations"),
url("/assets/fonts/roboto-mono.woff2") format("woff2-variations")
;
font-weight: 100 900;
}

View file

@ -1,286 +0,0 @@
/* # Overrides */
:root {
--primer-color-accent: 148, 255, 21;
--color-accent: rgb(var(--primer-color-accent));
--hue-accent: 390deg;
--primer-color-go: 0, 173, 216;
--primer-color-php: 79, 93, 149;
--primer-color-css: 86, 61, 124;
--primer-color-html: 227, 76, 38;
--primer-color-shell: 137, 224, 81;
--primer-color-python: 53, 114, 165;
--primer-color-typescript: 49, 120, 198;
--primer-color-javascript: 241, 224, 90;
--color-go: rgb(var(--primer-color-go));
--color-php: rgb(var(--primer-color-php));
--color-css: rgb(var(--primer-color-css));
--color-html: rgb(var(--primer-color-html));
--color-shell: rgb(var(--primer-color-shell));
--color-python: rgb(var(--primer-color-python));
--color-typescript: rgb(var(--primer-color-typescript));
--color-javascript: rgb(var(--primer-color-javascript));
}
vv-shell {
display: flex;
flex-direction: column;
gap: var(--padding);
}
/* # Sections */
/* ## Divider */
vv-shell > hr {
border-color: rgba(255, 255, 255, .1);
}
/* ## About */
section.about {
display: flex;
flex-direction: column;
gap: calc(var(--padding) / 2);
}
section.about p:first-of-type:first-letter {
font-size: 1.8rem;
font-weight: bold;
margin-right: .1rem;
color: var(--color-accent);
}
section.about span.interests {
-webkit-user-select: none;
user-select: none;
color: var(--color-accent);
animation: interests-hue 5s infinite linear;
}
/* ## 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 svg {
width: 2em;
margin-left: auto;
transform: rotate(-90deg);
}
section.languages button p.lang {
font-size: 1.3em;
font-weight: 900;
}
/* # Interests */
div.interests {
--text-shadow-blur: 30px;
transition: 300ms opacity;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
font-weight: bold;
pointer-events: none;
font-size: clamp(16px, 15vw, 50px);
color: var(--color-accent);
overflow: hidden;
opacity: 0;
z-index: 200;
}
div.interests.active {
opacity: 1;
}
div.interests p {
transition: 500ms transform cubic-bezier(.34,0,0,.99);
position: absolute;
text-shadow:
0 0 var(--text-shadow-blur) black,
0 0 var(--text-shadow-blur) black,
0 0 var(--text-shadow-blur) black,
0 0 var(--text-shadow-blur) black,
0 0 var(--text-shadow-blur) black;
}
@keyframes interests-hue {
to {
-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;
}
}

View file

@ -1,84 +0,0 @@
vv-shell[vv-page="/search"] {
display: flex;
flex-direction: column;
gap: calc(var(--padding) / 2);
}
/* # Search */
section.search {
display: flex;
gap: var(--padding);
border-radius: 6px;
padding: var(--padding);
background-color: rgba(255, 255, 255, .1);
}
section.search form {
display: contents;
}
section.search input {
flex: 1 1 auto;
border-radius: 6px;
padding: 0 var(--padding);
border: solid 2px rgba(255, 255, 255, .1);
background-color: rgba(255, 255, 255, .1);
}
section.search input:focus {
outline: none;
border-color: white;
}
section.search select {
padding: 5px;
border: none;
background-color: transparent;
}
section.search select :is(option, optgroup) {
color: black;
}
/* # Center */
section.center {
display: flex;
flex: 1 1 auto;
align-items: center;
flex-direction: column;
justify-content: center;
fill: var(--color-accent);
gap: calc(var(--padding) / 2);
}
section.center svg {
width: 60px;
}
/* # Result */
section.result {
display: flex;
}
section.result button {
flex: 1 1 auto;
text-align: left;
padding: var(--padding);
}
/* # Stats */
section.stats {
min-height: calc(var(--padding) * 2);
display: flex;
align-items: center;
gap: calc(var(--padding) / 2);
justify-content: space-between;
}
vv-shell[vv-page="/search"] section.stats button {
display: none;
}

View file

@ -1,16 +0,0 @@
/* # Overrides */
:root {
--primer-color-accent: 3, 255, 219;
--color-accent: rgb(var(--primer-color-accent));
--hue-accent: 90deg;
}
vv-shell {
display: flex;
flex-direction: column;
gap: var(--padding);
width: 100%;
max-width: 1200px;
overflow-x: initial;
}

View file

@ -1,167 +0,0 @@
/* # Overrides */
:root {
--primer-color-accent: 3, 255, 219;
--color-accent: rgb(var(--primer-color-accent));
--hue-accent: 90deg;
--primer-color-reflect: 220, 26, 0;
--primer-color-vegvisir: 0, 128, 255;
}
vv-shell {
display: flex;
flex-direction: column;
gap: var(--padding);
width: 100%;
max-width: 1200px;
overflow-x: initial;
}
/* # Sections */
/* ## Hero */
section.hero {
--color-accent: rgb(255, 255, 255);
display: grid;
gap: var(--padding);
grid-template-columns: repeat(1, 1fr);
}
section.hero .item {
width: 100%;
position: relative;
border-radius: 6px;
}
section.hero .wrapper {
gap: var(--padding);
z-index: 1;
height: 100%;
display: flex;
position: relative;
align-items: baseline;
flex-direction: column;
padding: calc(var(--padding) * 1.5);
}
section.hero .item .title {
display: grid;
align-items: center;
gap: var(--padding);
grid-template-columns: 40px 1fr;
}
section.hero .item .title svg {
height: 3em;
border-radius: 4px;
}
section.hero .actions {
margin-top: auto;
}
/* ### Vegivisr */
section.hero .item.vegvisir {
--primer-color-accent: var(--primer-color-vegvisir);
--color-accent: rgb(var(--primer-color-vegvisir));
color: var(--color-vegvisir);
background-color: rgba(var(--primer-color-vegvisir), .1);
}
/* ### Reflect */
section.hero .item.reflect {
--primer-color-accent: var(--primer-color-reflect);
--color-accent: rgb(var(--primer-color-reflect));
color: var(--color-reflect);
background-color: rgba(var(--primer-color-reflect), .2);
}
/* ## Heading */
section.heading {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
}
section.heading svg {
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);
}
section.featured featured-item img {
width: 100%;
border-radius: 4px;
}
section.featured featured-item .actions {
gap: var(--padding);
width: 100%;
display: flex;
flex-direction: column;
padding-top: var(--padding);
margin-top: auto;
}
/* # Size queries */
@media (min-width: 400px) {
section.featured featured-item .actions {
flex-direction: row;
}
}
@media (min-width: 600px) {
section.hero {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 900px) {
section.featured {
grid-template-columns: repeat(3, 1fr);
}
section.featured featured-item .actions button.collapse p {
display: none;
}
}

View file

@ -1,16 +0,0 @@
/* # Overrides */
:root {
--primer-color-accent: 3, 255, 219;
--color-accent: rgb(var(--primer-color-accent));
--hue-accent: 90deg;
}
vv-shell {
display: flex;
flex-direction: column;
gap: var(--padding);
width: 100%;
max-width: 1200px;
overflow-x: initial;
}

View file

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

View file

@ -1,106 +0,0 @@
// Click to copy email button
{
const EMAIL_CPY_ANIM_DUR_MSECONDS = 1000;
// Run email copied splash animation
const emailCopiedAnimation = () => {
const CONFETTI_COUNT = 40;
const CONFETTI_SCALE_PIXELS = 300;
const randomIntFromInterval = (min, max) => {
return Math.floor(Math.random() * (max - min + 1) + min)
}
// Create new splash element
const splashElement = document.createElement("splash");
splashElement.innerText = "copied!";
// Set inline display to none to hide this element on pages where the splash element has no override styles defined.
splashElement.style.display = "none";
// Array of box-shadow strings as "confetti"
const confetti = [];
// Generate random confetti
for (let i = 0; i < CONFETTI_COUNT; i++) {
// Random confetti position
const x = randomIntFromInterval(CONFETTI_SCALE_PIXELS * -1, CONFETTI_SCALE_PIXELS);
const y = randomIntFromInterval(CONFETTI_SCALE_PIXELS * -1, CONFETTI_SCALE_PIXELS);
// Random confetti RGB color
const rgb = [
randomIntFromInterval(0, 255),
randomIntFromInterval(0, 255),
randomIntFromInterval(0, 255)
];
// Interpolate random values and append to outer confetti array
confetti.push(`${x}px ${y}px 0 rgb(${rgb.join(",")})`);
}
// Set CSS variable on splash element that in turn will be used by pseudo-element
splashElement.style.setProperty("--confetti", confetti.join(","));
// Start animation by appending the created element to the document body
document.body.appendChild(splashElement);
// Run hide animation
setTimeout(() => {
splashElement.classList.add("hide");
// Selfdestruct element when hide animation finishes
setTimeout(() => splashElement.remove(), 400);
}, EMAIL_CPY_ANIM_DUR_MSECONDS + 100);
}
document.querySelector(".email").addEventListener("click", async () => {
try {
await navigator.clipboard.writeText("victor@vlw.se");
// Run "email copied" animation!
emailCopiedAnimation();
// NOTE: I don't know, spamming the button is kinda fun
// Prevent interactions with the copy email elements while the animation is running
/*[...document.querySelectorAll("[vv-call='copyEmail']")].forEach(element => {
//element.classList.add("lock");
setTimeout(() => element.classList.remove("lock"), EMAIL_CPY_ANIM_DUR_MSECONDS);
});*/
} catch (error) {
console.error(error.message);
}
});
}
// Change site accent color on hover of menu items
{
if (window.matchMedia("(hover: hover)")) {
// Update root CSS variables
const updateColor = (rgb = null, hue = 0) => {
if (!rgb) {
document.documentElement.style.removeProperty("--hue-accent");
document.documentElement.style.removeProperty("--primer-color-accent");
document.documentElement.style.removeProperty("--color-accent");
return;
}
document.documentElement.style.setProperty("--hue-accent", `${hue}deg`);
document.documentElement.style.setProperty("--primer-color-accent", `${rgb}`);
// Compiled color variable must to be updated to receive the new RGB values
document.documentElement.style.setProperty("--color-accent", "rgb(var(--primer-color-accent)");
};
[...document.querySelectorAll("menu li")].forEach(element => {
// Change site accent color to RGB and HUE rotation defined in element dataset
element.addEventListener("mouseenter", (event) => updateColor(event.target.dataset.rgb, event.target.dataset.hue));
// Reset initial accent color and hues
element.addEventListener("mouseleave", () => updateColor());
});
// Reset color on navigation
VV.shell.addEventListener(VV.EVENT.START, () => updateColor(), { once: true });
}
}

View file

@ -1,4 +0,0 @@
// Redirect to work page if no href is defined
if (!new URLSearchParams(window.location.search).has("href")) {
new VV().navigate("/work");
}

View file

@ -1,45 +0,0 @@
const DEBOUNCE_TIMEOUT_MS = 100;
const CLASSNAME_SEARCHBOX_ACTIVE = "searchboxActive";
// Navigate to the start page if the logo in the header is clicked
document.querySelector("header .logo").addEventListener("click", () => new VV().navigate("/"));
// Navigate to the start page if the logo in the header is clicked
document.querySelector("header .logo").addEventListener("click", () => new VV().navigate("/"));
// Scroll page to top on navigation
VV.shell.addEventListener(VV.EVENT.FINISH, () => window.scrollTo({ top: 0 }));
// Open search box
document.querySelector(".searchbox-open").addEventListener("click", () => {
document.querySelector("header").classList.add(CLASSNAME_SEARCHBOX_ACTIVE);
// Select searchbox inner input element
document.querySelector("searchbox input").focus();
});
// Close searchbox
document.querySelector(".searchbox-close").addEventListener("click", () => {
// Disable search button interaction while animation is running
// This is required to prevent conflicts with the :hover "peak" transformation
const searchButtonElement = document.querySelector("header button.search");
const transformDuration = parseInt(window.getComputedStyle(searchButtonElement).getPropertyValue("--transform-duration"));
searchButtonElement.style.setProperty("pointer-events", "none");
document.querySelector("header").classList.remove(CLASSNAME_SEARCHBOX_ACTIVE);
// Wait for the transform animation to finish
setTimeout(() => searchButtonElement.style.removeProperty("pointer-events"), transformDuration);
});
// Close searchbox on top shell navigations
VV.shell.addEventListener(VV.EVENT.START, () => document.querySelector("header").classList.remove(CLASSNAME_SEARCHBOX_ACTIVE));
// Handle search logic
document.querySelector("header input[type='search']").addEventListener("input", (event) => {
// Debounce user input
clearTimeout(event.target._throttle);
event.target._throttle = setTimeout(() => {
// Navigate search-results element on user input
new VV(document.querySelector("search-results")).navigate(`/search?q=${event.target.value}`);
}, DEBOUNCE_TIMEOUT_MS);
});

View file

@ -1 +0,0 @@
<svg class="chevron" viewBox="0 0 78.743 46.968" xmlns="http://www.w3.org/2000/svg"><path d="m530.436 290.342 6.658 8.935c4.438 5.956 8.512 11.037 12.222 15.242 3.71 4.205 7.691 8.797 11.945 13.776a590.023 590.023 0 0 1 11.95 14.425c3.713 4.638 9.56 11.441 17.54 20.41 7.98 8.97 15.892 17.872 23.736 26.707a2267.256 2267.256 0 0 0 21.633 24.05c6.578 7.197 11.225 8.056 13.942 2.576 2.717-5.48 8.45-12.8 17.199-21.96 8.748-9.162 15.904-16.447 21.468-21.856a2287.547 2287.547 0 0 1 17.272-16.611 674.569 674.569 0 0 0 16.506-16.229c5.054-5.154 9.753-9.968 14.098-14.443 4.344-4.475 9.494-10.016 15.448-16.624 5.954-6.607 10.747-11.748 14.38-15.422 3.631-3.675 6.18-6.009 7.645-7.003a16.094 16.094 0 0 1 4.757-2.202 16.095 16.095 0 0 1 5.212-.568c1.768.096 3.474.47 5.12 1.126a16.094 16.094 0 0 1 4.492 2.701 16.094 16.094 0 0 1 3.394 3.995 16.094 16.094 0 0 1 1.941 4.87c.381 1.729.476 3.473.285 5.234a16.094 16.094 0 0 1-1.402 5.051 16.095 16.095 0 0 1-2.94 4.34c-15.004 15.842-39.417-8.906-22.252-23.487a16.094 16.094 0 0 1 4.491-2.703 16.094 16.094 0 0 1 5.12-1.127 16.095 16.095 0 0 1 5.211.567 16.095 16.095 0 0 1 4.758 2.2 16.094 16.094 0 0 1 3.805 3.606 16.094 16.094 0 0 1 2.456 4.631c.565 1.678.848 3.403.848 5.173 0 1.771-.283 3.495-.849 5.173a16.095 16.095 0 0 1-2.455 4.632l-1.606 2.112-5.37 4.582c-3.583 3.055-8.51 7.767-14.786 14.135a11933.445 11933.445 0 0 0-15.839 16.1c-4.284 4.364-8.943 9.26-13.975 14.688a75638.076 75638.076 0 0 1-15.826 17.063 2642.057 2642.057 0 0 0-15.89 17.273c-5.073 5.567-11.507 12.866-19.3 21.897-7.794 9.03-13.63 15.948-17.51 20.753a496.057 496.057 0 0 1-12.49 14.836c-4.447 5.085-9.976 7.77-16.588 8.052-6.612.283-12.583-2.325-17.913-7.822-5.33-5.498-11.117-11.861-17.361-19.091-6.245-7.23-11.274-13.11-15.087-17.64a385.872 385.872 0 0 0-11.496-13.06 905.91 905.91 0 0 1-12.117-13.426c-4.228-4.774-10.087-11.613-17.577-20.518-7.49-8.905-13.37-15.797-17.635-20.676-4.266-4.88-8.437-9.663-12.511-14.35-4.075-4.687-8.453-9.915-13.135-15.684-4.681-5.77-7.355-9.208-8.02-10.317a15.914 15.914 0 0 1-1.567-3.517 15.937 15.937 0 0 1 .724-11.305 15.914 15.914 0 0 1 2.003-3.288 15.914 15.914 0 0 1 2.733-2.713 15.913 15.913 0 0 1 3.302-1.98 15.914 15.914 0 0 1 3.68-1.132 15.914 15.914 0 0 1 3.845-.219c1.29.083 2.552.319 3.785.708 1.233.39 2.401.92 3.505 1.593a15.914 15.914 0 0 1 3.023 2.386l1.366 1.375z" transform="matrix(.26458 0 0 .26458 -132.758 -75.015)"/></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.3 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 984 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8 KiB

Some files were not shown because too many files have changed in this diff Show more