Compare commits

...

37 commits

Author SHA1 Message Date
4e5e999a25 feat: add git submodules for vegvisir and reflect 2025-03-11 08:14:19 +01:00
041d175757 fix: rfc9116 compatible and signed security.txt (#26)
Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/26
2025-03-05 12:37:49 +00:00
vlw
0bb7a3a8be fix: allow index of everything in robots.txt (#25)
Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/25
Co-authored-by: vlw <victor@vlw.se>
Co-committed-by: vlw <victor@vlw.se>
2025-03-05 12:37:21 +00:00
0baa6f8d85 chore: clean up some missed references to battlestation (#27)
It looks like I missed a few references to the "battlestation" pages and API that I removed in #24

Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/27
2025-03-05 12:37:01 +00:00
4b71bbd10e fix(content): remove "battlestation" pages (#24)
Its time to retire the "battlestation" pages that showed some of the computers I've built using funny graphics. I don't feel like maintaining these pages, at least not on my main website. I might create a separate site for stuff like this and list it under the [playground pages](#19) when that gets merged.

I attached a video of what it looked like, and also its database for reference.

Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/24
2025-02-05 04:58:13 +00:00
56cf142e0d refactor: major refactor, design overhaul and merge of Reflect API and Vegvisir sources into the same root (#23)
The PR is a huge refactor of all Reflect and Vegvisir code. I've merged the API and "Front-end" codebases together into the same root, this will allow for both Reflect and Vegvisir to use the same resources. Not only that, but I've also added proper database modeling with actual OOP inheritance for database tables.

Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/23
2025-02-05 04:49:23 +00:00
2fa62991f9 feat: scroll page to top on Vegvisir navigations (#21)
Simple but effective

Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/21
2025-01-28 15:29:48 +00:00
359342c7f7 chore(content): rewording of the encryption text on the contact page (#18)
Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/18
2025-01-28 15:25:22 +00:00
e420f33d4a feat: new design for button.inline elements (#22)
Redesign of the buttons I use on the site here and there. This changes all: `button.inline` and `button.inline.solid`

I think the new buttons look more "cutesy" or "cozy" with my hand drawn little icons, soft gradients and shadows, and larger size.

# Old
![image](/attachments/9c1082aa-551f-43f8-9428-89c190178335)
![image](/attachments/0cdb44fd-6378-4b22-8493-a2f52e1dfe65)
![image](/attachments/736c44d6-3bf4-4f83-a48c-da40008b7aed)

# New
![image](/attachments/9f4de1d9-8b60-4b54-ba2f-8166eb1126c1)
![image](/attachments/c42a20e6-a8ae-4b27-bcfa-b064e365a7c7)
![image](/attachments/8105c00d-caf3-4a9e-8e2d-0140cbc44caa)

Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/22
2025-01-28 15:23:55 +00:00
e25b1b6689 feat: add language chart to about page (#14)
Replaces this section on the `/about` page:
![image](/attachments/67ac2f42-3784-4c69-9240-0a7961afb47d)
with:
![image](/attachments/fa073c9c-a016-4281-a3fb-30b7be95881f)

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

Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/14
2025-01-28 14:45:52 +00:00
3b51458dd4 feat: featured works on the /work landingpage and moved timeline (#16)
Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/16
2025-01-28 14:45:00 +00:00
vlw
ff7d4f5397 chore: bump supported Vegvisir version (#13)
Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/13
Co-authored-by: vlw <victor@vlw.se>
Co-committed-by: vlw <victor@vlw.se>
2024-11-18 21:26:46 +00:00
07e0046828 chore(content): fix pathname to Codeberg logo embed on /work (#12)
Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/12
Co-authored-by: Victor Westerlund <victor.vesterlund@gmail.com>
Co-committed-by: Victor Westerlund <victor.vesterlund@gmail.com>
2024-11-18 21:11:10 +00:00
18e1ae5088 fix: order work by descending date_created (#9)
Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/9
Co-authored-by: Victor Westerlund <victor.vesterlund@gmail.com>
Co-committed-by: Victor Westerlund <victor.vesterlund@gmail.com>
2024-11-18 21:00:08 +00:00
9e3c549e0a fix(content): rewording of the Codeberg banner text on the work page (#10)
Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/10
Co-authored-by: Victor Westerlund <victor.vesterlund@gmail.com>
Co-committed-by: Victor Westerlund <victor.vesterlund@gmail.com>
2024-11-18 20:59:17 +00:00
80c6579136 chore: add support for Vegvisir 3.1 (#11)
This PR adds basic support for the upcoming release Vegvisir 3.1

Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/11
Co-authored-by: Victor Westerlund <victor.vesterlund@gmail.com>
Co-committed-by: Victor Westerlund <victor.vesterlund@gmail.com>
2024-11-18 20:53:29 +00:00
1ac2704124 feat: add .txt-files and gitignore rules (#7)
This PR adds some .txt files! And a rule that prevents robots.txt from being tracked, as that one should be added on a per-installation basis.

Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/7
Co-authored-by: Victor Westerlund <victor.vesterlund@gmail.com>
Co-committed-by: Victor Westerlund <victor.vesterlund@gmail.com>
2024-09-27 15:09:56 +00:00
f551d5d889 fix: remove website version page and import (#6)
This PR removes the website version page and reference from the about page. Its pretty unreliable and doesn't really matter to the reader anyways.

Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/6
Co-authored-by: Victor Westerlund <victor.vesterlund@gmail.com>
Co-committed-by: Victor Westerlund <victor.vesterlund@gmail.com>
2024-09-27 14:53:19 +00:00
51e8215e78 feat(content): add Matrix social link (#5)
This PR replaces the mastodon link and icon with a matrix handle instead. I don't use Mastodon anymore, and never really did in the first place.

Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/5
Co-authored-by: Victor Westerlund <victor.vesterlund@gmail.com>
Co-committed-by: Victor Westerlund <victor.vesterlund@gmail.com>
2024-09-27 14:48:07 +00:00
7f4b54685e feat(content): update texts and replace references to GitHub with Codeberg (#4)
This PR features a lot of text revisions, updated references to GitHub with Codeberg and more.

Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/4
Co-authored-by: Victor Westerlund <victor.vesterlund@gmail.com>
Co-committed-by: Victor Westerlund <victor.vesterlund@gmail.com>
2024-09-27 13:27:18 +00:00
6dad22f226 fix: delete old, unused database class (#3)
A relic from the past which was never used.

Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/3
Co-authored-by: Victor Westerlund <victor.vesterlund@gmail.com>
Co-committed-by: Victor Westerlund <victor.vesterlund@gmail.com>
2024-09-27 09:48:37 +00:00
0483e092dd chore: change MySQL library composer source (#2)
[victorwesterlund/libmysqldriver](https://packagist.org/packages/victorwesterlund/libmysqldriver) has been replaced by [vlw/mysql](https://packagist.org/packages/vlw/mysql) for [reasons stated here](https://codeberg.org/vlw/php-mysql/pulls/36). This PR reflects this change.

Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/2
Co-authored-by: Victor Westerlund <victor.vesterlund@gmail.com>
Co-committed-by: Victor Westerlund <victor.vesterlund@gmail.com>
2024-09-27 09:41:14 +00:00
vlw
ae1e992c5f chore: add support for Vegvisir 3 (#1)
This PR adds support for the latest major release of the [Vegvisir framework](https://vegvisir.vlw.se)

Reviewed-on: https://codeberg.org/vlw/vlw.se/pulls/1
Co-authored-by: vlw <victor@vlw.se>
Co-committed-by: vlw <victor@vlw.se>
2024-09-27 09:32:52 +00:00
43ddf1fdf6
fix: add size quries to battlestation-retired (#45) 2024-07-07 10:47:42 +00:00
903a14b3ae
feat: add variable font for roboto-mono (#43) 2024-07-07 10:37:31 +00:00
99e9996e93
feat: add "battlestation" (#40)
* wip: 2024-06-19T13:31:53+0200 (1718796713)

* fix: final touchups with bugfixes

* fix: typo in widlcardsearch function

* wip: 2024-07-05T14:14:12+0200 (1720181652)

* wip: 2024-07-07T11:22:34+0200 (1720344154)

* wip: 2024-07-07T11:22:34+0200 (1720344154)
2024-07-07 10:37:17 +00:00
7cd41d3e13
feat: add favicon (#44) 2024-07-07 10:37:01 +00:00
bef1bab522
feat(content): add Open Graph tags and ogp to document (#41)
* feat: add Open Graph tags with ogp to document

* fix: correct resolution for linkedin
2024-07-04 15:08:59 +00:00
285bdc3980
fix(content): add alt text for gazing.jpg on index page (#42) 2024-07-04 15:08:29 +00:00
ce1f3e3bab
chore: change body[vv-top-page] CSS for Vegvisir 2.5.0 (#39)
* chore: change body[vv-top-page] CSS for Vegvisir 2.5.0

* chore(doc): update supported framework versions
2024-07-04 15:08:15 +00:00
32f13b356a
feat(content): add more interests to /about (#38)
* feat(content): add new interest

* feat(content): add more interests
2024-07-04 15:03:26 +00:00
608f775f24
refactor: streamlined all API endpoints and remove local API packages (#36)
* wip: 2024-06-13T07:32:13+0200 (1718256733)

* wip: 2024-06-16T13:05:34+0200 (1718535934)

* wip: 2024-06-16T15:07:31+0200 (1718543251)
2024-06-16 14:02:34 +00:00
e1e4c3fd1a
fix: update about page text (#35) 2024-06-14 16:06:47 +00:00
0478685791
fix: prevent searchbox hover animation conflicts with delay (#34) 2024-06-14 16:06:34 +00:00
fd04c3d5ae
doc: bump compat with vegvisir 2.4.4 and reflect 2.7.1 (#32) 2024-05-21 13:18:31 +00:00
9b3ab0b17b
fix: broken (missing) links to frameworks on about page (#29) 2024-05-10 07:25:48 +00:00
a7655f9cdb
fix: correct and configurable timezone on contact page (#30) 2024-05-10 07:25:22 +00:00
190 changed files with 4009 additions and 3509 deletions

View file

@ -1,4 +1,20 @@
[api]
base_url = "https://api.vlw.one/"
[client_api]
base_url = ""
api_key = ""
verify_peer = 0
verify_peer = true
[client_time_available]
time_zone = "Europe/Stockholm"
available_to_hour = 0;
reply_average_hours = 0;
available_from_hour = 0;
[server_database]
host = ""
user = ""
pass = ""
db = ""
[server_forgejo]
base_url = ""
scan_profiles = ""

6
.gitignore vendored
View file

@ -1,8 +1,12 @@
assets/media/content
# Public assets #
#################
public/.well-known
public/assets/js/modules/npm
# Bootstrapping #
#################
vendor
node_modules
.env.ini
# OS generated files #

6
.gitmodules vendored Normal file
View file

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

100
README.md
View file

@ -1,70 +1,66 @@
# 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).
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 [web framework](https://vegvisir.vlw.se) and its API is also built on top of my own [API framework](https://reflect.vlw.se).
# Installation
If you for whatever reason want to get this website up and running for yourself this is how that is done.
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 later is written in bash.
This website is built for PHP 8.0+ and MariaDB 14+ (for the API database).
**Make sure you have both of these package managers installed before proceeding:**
- [Composer](https://getcomposer.org/)
- [NPM](https://www.npmjs.com/)
**Confimed supported framework versions:**
Vegvisir|Reflect
--|--
✅ [`2.4.3`](https://github.com/VictorWesterlund/vegvisir/releases/tag/2.4.3)|✅ [`2.7.0`](https://github.com/VictorWesterlund/reflect/releases/tag/2.7.0)
## 1. Clone this repo
Clone/download this repo to your machine. Preferably to a non-public directory - the frameworks will handle that.
## Website (Vegvisir)
1. **Download this repo**
```
git clone https://codeberg.org/vlw/vlw.se --depth 1
```
Git clone or download this repo to any local folder
```
git clone https://github.com/VictorWesterlund/vlw.se
```
2. **Download and install Vegvisir**
## 2. Install [Vegvisir](https://vegvisir.vlw.se) and [Reflect](https://reflect.vlw.se)
Follow the installation instructions for my web, and API framework. This site uses the default configuration for both frameworks so the only thing you need to do after you've installed both is to point the `root_path` and `endpoints` directory respectively to the directory where you cloned this repo.
Follow the installation instructions for [Vegvisir](https://github.com/victorwesterlund/vegvisir) and point the `site_path` variable to the local vlw.se folder.
- [Vegvisir installation](https://vegvisir.vlw.se)
- [Reflect installation](https://reflect.vlw.se)
3. **Install dependencies**
*Example:*
```sh
# Vegvisir
root_path = "/var/www/vlw.se"
# Reflect
endpoints = "/var/www/vlw.se"
```
Install dependencies with composer.
```
composer install --optimize-autoloader
```
## 3. Run the install script
Run the `install.sh` script from the root of the repo directory. [Make sure you have the required package managers installed](#installation).
Et voila! You probably want to install the API-side too but the website itself should now be accessible from your configured Vegvisir host.
**Example:**
```sh
# vlw@example:$
cd /var/www/vlw.se
# vlw@example:/var/www/vlw.se$
./install.sh
```
## API (Reflect)
The API (and database) is where most content is stored and served from on this website.
## 4. Import the database templates
There's are two SQL files that you can download from the releases page that has a snapshot of the MariaDB databases I use on my live website. The snapshot data for the website databse is not guaranteed to be up to date; but the database structure will be. Download and import these files into two existing databases. One for the website data, and the other has the Reflect API configurations.
1. **Download this repo**
- [Download SQL-snapshots](https://codeberg.org/vlw/vlw.se/releases)
**You can skip this if you've already downloaded the repo from step 1 in the website installation.**
## 5. Set environment variables
Make a copy of the `.env.example.ini` file called `.env.ini` from the root directory of the repo. There are a few parameters you can change here but the required ones are the following:
Otherwise... Git clone or download this repo to any local folder
```
git clone https://github.com/VictorWesterlund/vlw.se
```
```ini
[client_api]
base_url = ""
api_key = ""
2. **Download and install Reflect**
[server_database]
host = ""
user = ""
pass = ""
db = ""
```
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.
Please refer to the comments in the ini file for more information about each field.
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.
## Done!
That should be it. Navigate to your configured Vegvisir public host!

View file

@ -1,11 +0,0 @@
[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,14 +0,0 @@
{
"require": {
"local/api.endpoints": "1.0.0-dev",
"reflect/plugin-rules": "^1.5",
"victorwesterlund/xenum": "^1.1"
},
"minimum-stability": "dev",
"repositories": [
{
"type": "path",
"url": "src/packages/Endpoints"
}
]
}

120
api/composer.lock generated
View file

@ -1,120 +0,0 @@
{
"_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": "9da96ba90ef20d885034442b30dce0a3",
"packages": [
{
"name": "local/api.endpoints",
"version": "1.0.0-dev",
"dist": {
"type": "path",
"url": "src/packages/Endpoints",
"reference": "89b7b9a4cc504abddb4aeec8e05a95c9d9087575"
},
"type": "library",
"autoload": {
"psr-4": {
"VLW\\API\\": "src/"
}
},
"authors": [
{
"name": "Victor Westerlund",
"email": "victor@vlw.se"
}
],
"description": "Endpoint pathmappings for VLW API",
"transport-options": {
"relative": true
}
},
{
"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/xenum",
"version": "1.1.1",
"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": ""
},
"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": {
"local/api.endpoints": 20
},
"prefer-stable": false,
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
"plugin-api-version": "2.0.0"
}

View file

@ -1,39 +0,0 @@
<?php
use Reflect\Path;
use Reflect\Response;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Coffee\CoffeeModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Coffee.php");
class GET_Coffee extends VLWdb {
const LIST_LIMIT = 20;
public function __construct() {
parent::__construct();
}
// Return a 503 Service Unavailable error if something went wrong with the database call
private function resp_database_error(): Response {
return new Response("Failed to get work data, please try again later", 503);
}
public function main(): Response {
// Get the last LIST_LIMIT coffees from the database
$resp = $this->db->for(CoffeeModel::TABLE)
->order([CoffeeModel::DATE_TIMESTAMP_CREATED->value => "DESC"])
->limit(self::LIST_LIMIT)
->select([
CoffeeModel::ID->value,
CoffeeModel::DATE_TIMESTAMP_CREATED->value
]);
return parent::is_mysqli_result($resp)
? new Response($resp->fetch_all(MYSQLI_ASSOC))
: $this->resp_database_error();
}
}

View file

@ -1,36 +0,0 @@
<?php
use Reflect\Path;
use Reflect\Response;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Coffee\CoffeeModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Coffee.php");
class POST_Coffee extends VLWdb {
public function __construct() {
parent::__construct();
}
// Return a 503 Service Unavailable error if something went wrong with the database call
private function resp_database_error(): Response {
return new Response("Failed to record coffee! Ugh please take a note somewhere else", 503);
}
public function main(): Response {
// Generate UUID for entity
$id = parent::gen_uuid4();
// Attempt to create new entity
$insert = $this->db->for(CoffeeModel::TABLE)
->insert([
CoffeeModel::ID->value => $id,
CoffeeModel::DATE_TIMESTAMP_CREATED->value => time(),
]);
// Return 201 Created and entity id if successful
return $insert ? new Response($id, 201) : $this->resp_database_error();
}
}

View file

@ -1,95 +0,0 @@
<?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\Media\MediaModel;
use victorwesterlund\xEnum;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Media.php");
enum MediaDispositionEnum: string {
use xEnum;
case METADATA = "metadata";
case INLINE = "inline";
case DOWNLOAD = "download";
}
class GET_Media extends VLWdb {
const GET_DISPOSITION_KEY = "disposition";
protected Ruleset $ruleset;
public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(MediaModel::ID->value))
->required()
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(self::GET_DISPOSITION_KEY))
->type(Type::ENUM, MediaDispositionEnum::values())
->default(MediaDispositionEnum::METADATA->value)
]);
}
// # Helper methods
private function fetch_srcset(string $id): array {
$resp = $this->db->for(WorkTagsModel::TABLE)
->where([WorkTagsModel::ANCHOR->value => $id])
->select(WorkTagsModel::NAME->value);
return parent::is_mysqli_result($resp) ? $resp->fetch_all(MYSQLI_ASSOC) : [];
}
// # Responses
// Return 422 Unprocessable Content error if request validation failed
private function resp_rules_invalid(): Response {
return new Response($this->ruleset->get_errors(), 422);
}
// Return a 503 Service Unavailable error if something went wrong with the database call
private function resp_database_error(): Response {
return new Response("Failed to get work data, please try again later", 503);
}
public function main(): Response {
// Bail out if request validation failed
if (!$this->ruleset->is_valid()) {
return $this->resp_rules_invalid();
}
$resp = $this->db->for(MediaModel::TABLE)
->where([MediaModel::ID->value => $_GET[MediaModel::ID->value]])
->select([
MediaModel::ID->value,
MediaModel::NAME->value,
MediaModel::TYPE->value,
MediaModel::MIME->value,
MediaModel::EXTENSION->value,
MediaModel::SRCSET->value,
MediaModel::DATE_TIMESTAMP_CREATED->value,
]);
// Bail out if something went wrong retrieving rows from the database
if (!parent::is_mysqli_result($resp)) {
return $this->resp_database_error();
}
$media = $resp->fetch_assoc();
$test = true;
}
}

View file

@ -1,117 +0,0 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use Reflect\Method;
use function Reflect\Call;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Media\MediaModel;
use VLW\API\Databases\VLWdb\Models\Media\MediaTypeEnum;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Media.php");
class POST_Media extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->POST([
(new Rules(MediaModel::ID->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
->default(parent::gen_uuid4()),
(new Rules(MediaModel::NAME->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
->default(null),
(new Rules(MediaModel::TYPE->value))
->type(Type::ENUM, MediaTypeEnum::values())
->default(null),
(new Rules(MediaModel::EXTENSION->value))
->type(Type::STRING)
->min(3)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
->default(null),
(new Rules(MediaModel::MIME->value))
->type(Type::STRING)
->min(3)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
->default(null),
(new Rules(MediaModel::SRCSET->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
->default(null)
]);
}
// # Helper methods
// Returns true if an srcset exists for provided key
private static function media_srcset_exists(): bool {
// No srcet get parameter has been set
if (empty($_POST[MediaModel::SRCSET->value])) {
return true;
}
// Check if the provided srcset exists by calling the srcset endpoint
return Call("media/srcset?id={$_POST[MediaModel::SRCSET->value]}", Method::GET)->ok;
}
// # Responses
// Return 422 Unprocessable Content error if request validation failed
private function resp_rules_invalid(): Response {
return new Response($this->ruleset->get_errors(), 422);
}
// Return a 503 Service Unavailable error if something went wrong with the database call
private function resp_database_error(): Response {
return new Response("Failed to get work data, please try again later", 503);
}
public function main(): Response {
// Bail out if request validation failed
if (!$this->ruleset->is_valid()) {
return $this->resp_rules_invalid();
}
// Bail out if an srcset doesn't exist
if (!self::media_srcset_exists()) {
return new Response("No media srcset exists with id '{$_POST[MediaModel::SRCSET->value]}'", 404);
}
$insert = $this->db->for(MediaModel::TABLE)
->insert([
MediaModel::ID->value => $_POST[MediaModel::ID->value],
MediaModel::NAME->value => $_POST[MediaModel::NAME->value],
MediaModel::MIME->value => $_POST[MediaModel::MIME->value],
// Strip dots from extension string if set
MediaModel::EXTENSION->value => $_POST[MediaModel::EXTENSION->value]
? str_replace(".", "", $_POST[MediaModel::EXTENSION->value])
: null,
MediaModel::SRCSET->value => $_POST[MediaModel::SRCSET->value],
MediaModel::DATE_TIMESTAMP_CREATED->value => time()
]);
// Return media id if insert was successful
return $insert
? new Response($_POST[MediaModel::ID->value], 201)
: $this->resp_database_error();
}
}

View file

@ -1,106 +0,0 @@
<?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\Media\MediaModel;
use VLW\API\Databases\VLWdb\Models\MediaSrcset\MediaSrcsetModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Media.php");
require_once Path::root("src/databases/models/MediaSrcset.php");
class GET_MediaSrcset extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(MediaSrcsetModel::ID->value))
->required()
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
]);
}
// # Helper methods
// Get metadata for the requested srcset
private function get_srcset(): array|false {
$srcset = $this->db->for(MediaSrcsetModel::TABLE)
->where([MediaSrcsetModel::ID->value => $_GET[MediaSrcsetModel::ID->value]])
->select([MediaSrcsetModel::ANCHOR_DEFAULT->value]);
// Something went wrong retrieving rows from the database
if (!parent::is_mysqli_result($srcset)) {
return false;
}
// Return assoc array of srcset data if it exists
return $srcset->num_rows === 1 ? $srcset->fetch_assoc() : false;
}
// Get all media entities that are part of the requested srcset
private function get_srcset_media(): mysqli_result|false {
$media = $this->db->for(MediaModel::TABLE)
->where([MediaModel::SRCSET->value => $_GET[MediaSrcsetModel::ID->value]])
->select([
MediaModel::ID->value,
MediaModel::TYPE->value,
MediaModel::MIME->value,
MediaModel::EXTENSION->value
]);
return parent::is_mysqli_result($media) ? $media : false;
}
// # Responses
// Return 422 Unprocessable Content error if request validation failed
private function resp_rules_invalid(): Response {
return new Response($this->ruleset->get_errors(), 422);
}
// Return a 503 Service Unavailable error if something went wrong with the database call
private function resp_database_error(): Response {
return new Response("Failed to get work data, please try again later", 503);
}
public function main(): Response {
// Bail out if request validation failed
if (!$this->ruleset->is_valid()) {
return $this->resp_rules_invalid();
}
// Get srcset data
$srcset = $this->get_srcset();
if (!$srcset) {
return new Response("No media srcset exist with id '{$_GET[MediaSrcsetModel::ID->value]}'", 404);
}
$media = $this->get_srcset_media();
if (!$media) {
return new Response("Failed to fetch srcset media", 500);
}
$media_entities = $media->fetch_all(MYSQLI_ASSOC);
// This is the id of the media entity that is considered the default or "fallback"
$srcet_default_media_id = $srcset[MediaSrcsetModel::ANCHOR_DEFAULT->value];
// Return assoc array of all media entities that are in this srcset
return new Response([
// Return default media entity separately from the rest of the srcset as an assoc array
"default" => array_filter($media_entities, fn(array $entity) => $entity[MediaModel::ID->value] === $srcet_default_media_id)[0],
// Return all media that isn't default as array of assoc arrays
"srcset" => array_filter($media_entities, fn(array $entity) => $entity[MediaModel::ID->value] !== $srcet_default_media_id)
]);
}
}

View file

@ -1,55 +0,0 @@
<?php
use Reflect\Path;
use Reflect\Response;
use Reflect\Method;
use function Reflect\Call;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\MediaSrcset\MediaSrcsetModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Media.php");
require_once Path::root("src/databases/models/MediaSrcset.php");
class POST_MediaSrcset extends VLWdb {
public function __construct() {
parent::__construct();
}
// # Responses
// Return a 503 Service Unavailable error if something went wrong with the database call
private function resp_database_error(): Response {
return new Response("Failed to get work data, please try again later", 503);
}
public function main(): Response {
// Generate a random UUID for this srcset
$id = parent::gen_uuid4();
// Ensure an srcset with the generated id doesn't exist, although it shouldn't realistically ever happen
$srcset_existing = Call("media/srcset?id={$id}", Method::GET);
if ($srcset_existing->code !== 404) {
// Wow a UUID4 collision... buy a lottery ticket
if ($srcset_existing->code === 200) {
return $this->main();
}
// Failed to get srcset
return new Response("Something went wrong when checking if the srcset exists", 500);
}
// Create new srcset entity
$insert = $this->db->for(MediaSrcsetModel::TABLE)
->insert([
MediaSrcsetModel::ID->value => $id
]);
// Return created srcset id if successful
return $insert
? new Response($id, 201)
: $this->resp_database_error();
}
}

View file

@ -1,79 +0,0 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use Reflect\Method;
use function Reflect\Call;
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.php");
class POST_Messages extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
parent::__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)
]);
}
// # Responses
// Return 422 Unprocessable Content error if request validation failed
private function resp_rules_invalid(): Response {
return new Response($this->ruleset->get_errors(), 422);
}
// Return a 503 Service Unavailable error if something went wrong with the database call
private function resp_database_error(): Response {
return new Response("Failed to get work data, please try again later", 503);
}
public function main(): Response {
//return new Response(["hello" => "maybe"], 500);
// Bail out if request validation failed
if (!$this->ruleset->is_valid()) {
return $this->resp_rules_invalid();
}
// Generate UUID for entity
$id = parent::gen_uuid4();
// Attempt to create new entity
$insert = $this->db->for(MessagesModel::TABLE)
->insert([
MessagesModel::ID->value => $id,
MessagesModel::EMAIL->value => $_POST["email"],
MessagesModel::MESSAGE->value => $_POST["message"],
MessagesModel::DATE_TIMESTAMP_CREATED->value => time(),
]);
// Bail out if insert failed
if (!$insert) {
return $this->resp_database_error();
}
// Return 201 Created and entity id
return new Response($id, 201);
}
}

View file

@ -1,223 +0,0 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use Reflect\Method;
use function Reflect\Call;
use VLW\API\Databases\VLWdb\Models\Work\WorkModel;
use VLW\API\Databases\VLWdb\Models\Work\WorkTagsModel;
use VLW\API\Databases\VLWdb\Models\Work\WorkTagsNameEnum;
use VLW\API\Databases\VLWdb\Models\Work\WorkActionsModel;
require_once Path::root("src/databases/models/Work.php");
require_once Path::root("src/databases/models/WorkTags.php");
require_once Path::root("src/databases/models/WorkActions.php");
// "Virtual" database model for the POST request body since we're not writing to a db directly
enum ReleasesPostModel: string {
case GITHUB_USER = "user";
case GITHUB_REPO = "repo";
case GITHUB_TAG = "tag";
}
class POST_Releases {
// Base URL of the GitHub API (no tailing slash)
const GITHUB_API = "https://api.github.com";
const REGEX_HANDLE = "/@[\w]+/";
const REGEX_URL = "/\b(?:https?):\/\/\S+\b/";
protected Ruleset $ruleset;
protected CurlHandle $curl;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->POST([
(new Rules(ReleasesPostModel::GITHUB_USER->value))
->required()
->type(Type::STRING)
->min(1),
(new Rules(ReleasesPostModel::GITHUB_REPO->value))
->required()
->type(Type::STRING)
->min(1),
(new Rules(ReleasesPostModel::GITHUB_TAG->value))
->required()
->type(Type::STRING)
->type(Type::NUMBER)
->min(1)
]);
$this->curl = curl_init();
curl_setopt($this->curl, CURLOPT_USERAGENT, $_ENV["github"]["user_agent"]);
curl_setopt($this->curl, CURLOPT_HEADER, true);
curl_setopt($this->curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($this->curl, CURLOPT_HTTPHEADER, [
"Accept" => "application/vnd.github+json",
"Authorization" => "token {$_ENV["github"]["api_key"]}",
"X-GitHub-Api-Version" => "2022-11-28"
]);
}
// # GitHub
// Generate HTML from a GitHub "auto-generate" release body
protected static function gh_auto_release_md_to_html(string $md): string {
$output = "";
// Parse each line of markdown
$lines = explode(PHP_EOL, $md);
foreach ($lines as $i => $line) {
// Ignore header line from releases
if ($i < 1) continue;
// Replace all URLs with HTMLAnchor tags, they will be PRs
$links = [];
preg_match_all(self::REGEX_URL, $line, $links, PREG_UNMATCHED_AS_NULL);
foreach ($links as $i => $link) {
if (empty($link)) continue;
// Last crumb from link pathname will be the PR id
$pr_id = explode("/", $link[$i]);
$pr_id = end($pr_id);
$line = str_replace($link, "<a href='{$link[$i]}'>{$pr_id}</a>", $line);
}
// Replace all at-handles with links to GitHub user profiles
$handles = [];
preg_match_all(self::REGEX_HANDLE, $line, $handles, PREG_UNMATCHED_AS_NULL);
foreach ($handles as $i => $handle) {
if (empty($handle)) continue;
// GitHub user URL without the "@"
$url = "https://github.com/" . substr($handle[$i], 1);
$line = str_replace($handle, "<a href='{$url}'>{$handle[$i]}</a>", $line);
}
$output .= "<p>{$line}</p>";
}
return $output;
}
// Return fully qualified URL to GitHub API releases endpoint
private static function get_url(): string {
return implode("/", [
self::GITHUB_API,
"repos",
$_POST[ReleasesPostModel::GITHUB_USER->value],
$_POST[ReleasesPostModel::GITHUB_REPO->value],
"releases",
"tags",
$_POST[ReleasesPostModel::GITHUB_TAG->value],
]);
}
// Fetch release information from GitHub API
private function fetch_release_data(): array {
$url = self::get_url();
curl_setopt($this->curl, CURLOPT_URL, self::get_url());
$resp = curl_exec($this->curl);
$header_size = curl_getinfo($this->curl, CURLINFO_HEADER_SIZE);
$header = substr($resp, 0, $header_size);
$body = substr($resp, $header_size);
return json_decode($body, true);
}
// # Sup
private function create_link_to_release_page(string $id, string $href): Response {
return Call("work/actions?id={$id}", Method::POST, [
WorkActionsModel::DISPLAY_TEXT->value => "Release details",
WorkActionsModel::HREF->value => $href,
WorkActionsModel::EXTERNAL->value => true
]);
}
// Create a tag for entity
private function create_tag(string $id, WorkTagsNameEnum $tag): Response {
return Call("work/tags?id={$id}", Method::POST, [
// Set "RELEASE" tag on new entity
WorkTagsModel::NAME->value => $tag->value
]);
}
// # Responses
// Return 422 Unprocessable Content error if request validation failed
private function resp_rules_invalid(): Response {
return new Response($this->ruleset->get_errors(), 422);
}
public function main(): Response {
// Bail out if request validation failed
if (!$this->ruleset->is_valid()) {
return $this->resp_rules_invalid();
}
$data = $this->fetch_release_data();
if (!$data) {
return new Response("Failed to fetch release data", 500);
}
// Transform repo name to lowercase for summary title
$title = strtolower($_POST["repo"]);
// Use repo name and tag name as heading for summary
$summary = "<h3>Release {$title}@{$data["name"]}</h3>";
// Append HTML-ified release notes from GitHub to summary
$summary .= self::gh_auto_release_md_to_html($data["body"]);
$date_published = new \DateTime($data["published_at"], new \DateTimeZone("UTC"));
// Create work entity
$work_entity = Call("work", Method::POST, [
WorkModel::SUMMARY->value => $summary,
// Convert time created to Unix timestamp for work endpoint
WorkModel::DATE_TIMESTAMP_CREATED->value => $date_published->format("U"),
]);
// Bail out if creating the work entity failed
if (!$work_entity->ok) {
return new Response("Failed to create work entity for release", 500);
}
$work_entity_id = $work_entity->output();
// Create entity tags for release
$tags = [
WorkTagsNameEnum::VLW,
WorkTagsNameEnum::RELEASE
];
foreach ($tags as $tag) {
// Create entity tag for release or exit if failed to create
if (!$this->create_tag($work_entity_id, $tag)->ok) {
return new Response("Failed to create {$tag->name} tag for release entity", 500);
}
}
// Create link to release page on GitHub
if (!$this->create_link_to_release_page($work_entity_id, $data["html_url"])) {
return new Response("Failed to create link to release page on GitHub", 500);
}
return new Response($work_entity_id, 201);
}
}

View file

@ -1,140 +0,0 @@
<?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;
use VLW\API\Databases\VLWdb\Models\Work\WorkActionsModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Work.php");
require_once Path::root("src/databases/models/WorkActions.php");
class GET_Search extends VLWdb {
const GET_QUERY = "q";
protected Ruleset $ruleset;
public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(self::GET_QUERY))
->required()
->type(Type::STRING)
->min(2)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
]);
}
// Return an SQL string from array for use in prepared statements
private static function array_to_wildcard_sql(array $columns): string {
$sql = array_map(fn(string $column): string => "{$column} LIKE CONCAT('%', ?, '%')", $columns);
return implode(" OR ", $sql);
}
// Return chained AND statements from array for use in prepared statements
private static function array_to_and_statement(array $keys): string {
$sql = array_map(fn(string $k): string => "{$k} = ?", $keys);
return implode(" AND ", $sql);
}
// Wildcard search columns in table with query string from query string
// This has to be implemented manually until "libmysqldriver/MySQL" supports wildcard SELECT
private function search(string $table, array $columns, array $conditions = null): array {
// Create CSV from columns array
$columns_concat = implode(",", $columns);
// Create SQL LIKE wildcard statement for each column.
$where = self::array_to_wildcard_sql($columns);
// Create array of values from query string for each colum
$values = array_fill(0, count($columns), $_GET[self::GET_QUERY]);
if ($conditions) {
$conditions_sql = self::array_to_and_statement(array_keys($conditions));
// Wrap positive where statements and prepare new group of conditions
// WHERE (<search_terms>) AND (<conditions>)
$where = "({$where}) AND ({$conditions_sql})";
// Append values from conditions statements to prepared statement
array_push($values, ...array_values($conditions));
}
// Order the rows by the array index of $colums received
$rows = $this->db->exec("SELECT {$columns_concat} FROM {$table} WHERE {$where} ORDER BY {$columns_concat}", $values);
// Return results as assoc or empty array
return parent::is_mysqli_result($rows) ? $rows->fetch_all(MYSQLI_ASSOC) : [];
}
// Search work table
private function search_work(): array {
$search = [
WorkModel::TITLE->value,
WorkModel::SUMMARY->value,
WorkModel::DATE_TIMESTAMP_CREATED->value,
WorkModel::ID->value
];
$conditions = [
WorkModel::IS_LISTABLE->value => true
];
$results = $this->search(WorkModel::TABLE, $search, $conditions);
foreach ($results as &$result) {
$result["actions"] = (new Call(Endpoints::WORK_ACTIONS->value))
->params([WorkActionsModel::ANCHOR->value => $result[WorkModel::ID->value]])
->get()->output();
}
return $results;
}
// # Responses
// Return 422 Unprocessable Content error if request validation failed
private function resp_rules_invalid(): Response {
return new Response($this->ruleset->get_errors(), 422);
}
// Return a 503 Service Unavailable error if something went wrong with the database call
private function resp_database_error(): Response {
return new Response("Failed to get work data, please try again later", 503);
}
public function main(): Response {
// Bail out if request validation failed
if (!$this->ruleset->is_valid()) {
return $this->resp_rules_invalid();
}
// Get search results for each category
$categories = [
WorkModel::TABLE => $this->search_work()
];
// Count total number of results from all categories
$total_num_results = 0;
foreach (array_values($categories) as $results) {
$total_num_results += count($results);
}
return new Response([
"query" => $_GET[self::GET_QUERY],
"results" => $categories,
"total_num_results" => $total_num_results
]);
}
}

View file

@ -1,60 +0,0 @@
<?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.php");
class DELETE_Work extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules("id"))
->required()
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
]);
}
// # Responses
// Return 422 Unprocessable Content error if request validation failed
private function resp_rules_invalid(): Response {
return new Response($this->ruleset->get_errors(), 422);
}
// Return a 503 Service Unavailable error if something went wrong with the database call
private function resp_database_error(): Response {
return new Response("Failed to delete work data, please try again later", 503);
}
public function main(): Response {
// Bail out if request validation failed
if (!$this->ruleset->is_valid()) {
return $this->resp_rules_invalid();
}
// Attempt to update the entity
$update = $this->db->for(WorkModel::TABLE)
->where([WorkModel::ID->value => $_GET["id"]])
->update([
WorkModel::IS_LISTABLE->value => false,
WorkModel::IS_READABLE->value => false
]);
return $update ? new Response($_GET["id"]) : $this->resp_database_error();
}
}

View file

@ -1,136 +0,0 @@
<?php
use Reflect\Call;
use Reflect\Path;
use Reflect\Method;
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;
use VLW\API\Databases\VLWdb\Models\Work\WorkTagsModel;
use VLW\API\Databases\VLWdb\Models\Work\WorkActionsModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Work.php");
require_once Path::root("src/databases/models/WorkTags.php");
require_once Path::root("src/databases/models/WorkActions.php");
class GET_Work extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules("id"))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
->default(null)
]);
}
// # Helper methods
private function fetch_row_tags(string $id): array {
$resp = $this->db->for(WorkTagsModel::TABLE)
->where([WorkTagsModel::ANCHOR->value => $id])
->select(WorkTagsModel::NAME->value);
return parent::is_mysqli_result($resp) ? $resp->fetch_all(MYSQLI_ASSOC) : [];
}
// # Responses
// Return 422 Unprocessable Content error if request validation failed
private function resp_rules_invalid(): Response {
return new Response($this->ruleset->get_errors(), 422);
}
// Return a 503 Service Unavailable error if something went wrong with the database call
private function resp_database_error(): Response {
return new Response("Failed to get work data, please try again later", 503);
}
private function resp_item_details(string $id): Response {
$resp = $this->db->for(WorkModel::TABLE)
->where([
WorkModel::ID->value => $id,
WorkModel::IS_READABLE->value => true
])
->limit(1)
->select([
WorkModel::ID->value,
WorkModel::TITLE->value,
WorkModel::SUMMARY->value,
WorkModel::COVER_SRCSET->value,
WorkModel::DATE_YEAR->value,
WorkModel::DATE_MONTH->value,
WorkModel::DATE_DAY->value,
WorkModel::DATE_TIMESTAMP_MODIFIED->value,
WorkModel::DATE_TIMESTAMP_CREATED->value
]);
// Bail out if something went wrong retrieving rows from the database
if (!parent::is_mysqli_result($resp)) {
return $this->resp_database_error();
}
return $resp->num_rows === 1
? new Response($resp->fetch_assoc())
: new Response("No entity with id '{$id}' was found", 404);
}
public function main(): Response {
// Bail out if request validation failed
if (!$this->ruleset->is_valid()) {
return $this->resp_rules_invalid();
}
// Return details about a specific item by id
if (!empty($_GET["id"])) {
return $this->resp_item_details($_GET["id"]);
}
$resp = $this->db->for(WorkModel::TABLE)
->where([WorkModel::IS_LISTABLE->value => true])
->order([WorkModel::DATE_TIMESTAMP_CREATED->value => "DESC"])
->select([
WorkModel::ID->value,
WorkModel::TITLE->value,
WorkModel::SUMMARY->value,
WorkModel::COVER_SRCSET->value,
WorkModel::DATE_YEAR->value,
WorkModel::DATE_MONTH->value,
WorkModel::DATE_DAY->value,
WorkModel::DATE_TIMESTAMP_MODIFIED->value,
WorkModel::DATE_TIMESTAMP_CREATED->value
]);
// Bail out if something went wrong retrieving rows from the database
if (!parent::is_mysqli_result($resp)) {
return $this->resp_database_error();
}
// Resolve foreign keys
$rows = [];
while ($row = $resp->fetch_assoc()) {
$row["tags"] = $this->fetch_row_tags($row["id"]);
// Fetch actions for work entity by id from endpoint
$row["actions"] = (new Call(Endpoints::WORK_ACTIONS->value))
->params([WorkActionsModel::ANCHOR->value => $row[WorkModel::ID->value]])
->get()->output();
$rows[] = $row;
}
return new Response($rows);
}
}

View file

@ -1,201 +0,0 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use Reflect\Method;
use function Reflect\Call;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Work\WorkModel;
use VLW\API\Databases\VLWdb\Models\WorkPermalinks\WorkPermalinksModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Work.php");
require_once Path::root("src/databases/models/WorkPermalinks.php");
class PATCH_Work extends VLWdb {
protected Ruleset $ruleset;
protected Response $current_entity;
protected array $updated_entity;
public function __construct() {
parent::__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_TIMESTAMP_CREATED->value))
->type(Type::NUMBER)
->min(0)
->max(parent::MYSQL_INT_MAX_LENGHT)
]);
$this->get_existing_entity();
// Copy all provided post data into a new array
$this->updated_entity = $_POST;
// Set date modified timestamp
$this->updated_entity[WorkModel::DATE_TIMESTAMP_MODIFIED->value] = time();
}
// Generate a slug URL from string
private static function gen_slug(string $input): string {
return strtolower(trim(preg_replace('/[^A-Za-z0-9-]+/', '-', $input)));
}
// # Helper methods
private function get_existing_entity(): Response {
// Check if an entity already exists with slugified title from GET endpoint
$this->current_entity = Call("work?id={$_GET["id"]}", Method::GET);
// Response is not 404 (Not found) so we can't create the entity
if ($this->current_entity->code !== 200) {
// Response is not a valid entity, something went wrong
if ($this->current_entity->code !== 404) {
return $this->resp_database_error();
}
// Return 402 Conflict
return new Response("No entity with id '{$_GET["id"]}' was found", 404);
}
return $this->current_entity;
}
// Create new permalink for entity slug
private function create_permalink(string $slug): bool {
$create = Call("work/permalinks", Method::POST, [
WorkPermalinksModel::SLUG->value => $slug,
WorkPermalinksModel::ANCHOR->value => $slug
]);
return $create->ok;
}
// ## Updated entity
private function change_slug(): bool {
if (!array_key_exists(WorkModel::ID->value, $this->updated_entity)) {
return true;
}
// Generate new permalink for entity id
return $this->create_permalink($this->updated_entity[WorkModel::ID->value]);
}
private function timestamp_to_dates(): void {
if (!array_key_exists(WorkModel::DATE_TIMESTAMP_CREATED->value, $this->updated_entity)) {
return;
}
// Get timestamp from post data
$timestamp = $this->updated_entity[WorkModel::DATE_TIMESTAMP_CREATED->value];
// Update fractured dates from timestamp
$this->updated_entity[WorkModel::DATE_YEAR->value] = date("Y", $timestamp);
$this->updated_entity[WorkModel::DATE_MONTH ->value] = date("n", $timestamp);
$this->updated_entity[WorkModel::DATE_DAY->value] = date("j", $timestamp);
}
// # Responses
// Return 422 Unprocessable Content error if request validation failed
private function resp_rules_invalid(): Response {
return new Response($this->ruleset->get_errors(), 422);
}
// Return a 503 Service Unavailable error if something went wrong with the database call
private function resp_database_error(): Response {
return new Response("Failed to get work data, please try again later", 503);
}
// Return a 422 Unprocessable Entity if there is nothing to change
private function resp_no_changes(): Response {
return new Response("No columns to update", 422);
}
// Rollback changes and return error response
private function resp_permalink_error_rollback(): Response {
$update = $this->db->for(WorkModel::TABLE)
->where([WorkModel::ID->value => $_GET["id"]])
->update($this->current_entity->output());
return $update
? new Response("Failed to create new permalink for updated entity. Changes have been rolled back", 500)
: new Reponse("Failed to create new permalink for updated entity. Changes failed to rollback, this is bad.", 500);
}
public function main(): Response {
// Bail out if request validation failed
if (!$this->ruleset->is_valid()) {
return $this->resp_rules_invalid();
}
// Empty payload, nothing to do
if (empty($_POST)) {
return $this->resp_no_changes();
}
// Generate new slug for entity if title is updated
if (array_key_exists(WorkModel::TITLE->value, $_POST)) {
// Generate URL slug from title text or UUID if undefined
$slug = self::gen_slug($_POST["title"]);
// Save generated slug from title if it's different from existing slug
if ($slug !== $this->current_entity->output()[WorkModel::ID->value]) {
$this->updated_entity[WorkModel::ID->value] = $slug;
}
}
// Update fractured dates from timestamp
$this->timestamp_to_dates();
// Attempt to update the entity
$update = $this->db->for(WorkModel::TABLE)
->where([WorkModel::ID->value => $_GET["id"]])
->update($this->updated_entity);
// Bail out if update failed
if (!$update) {
return $this->resp_database_error();
}
// Create new slug for entity if title was changed
if (!$this->change_slug()) {
return $this->resp_permalink_error_rollback();
}
// Return 200 OK and new or existing entity slug as body
return new Response($this->current_entity->output()[WorkModel::ID->value]);
}
}

View file

@ -1,133 +0,0 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use Reflect\Method;
use function Reflect\Call;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Work\WorkModel;
use VLW\API\Databases\VLWdb\Models\WorkPermalinks\WorkPermalinksModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Work.php");
require_once Path::root("src/databases/models/WorkPermalinks.php");
class POST_Work extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
parent::__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::DATE_TIMESTAMP_CREATED->value))
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_INT_MAX_LENGHT)
->default(null)
]);
}
// Generate a slug URL from string
private static function gen_slug(string $input): string {
return strtolower(trim(preg_replace('/[^A-Za-z0-9-]+/', '-', $input)));
}
// Create permalink for entity slug
private function create_permalink(string $slug): bool {
$create = Call("work/permalinks", Method::POST, [
WorkPermalinksModel::SLUG->value => $slug,
WorkPermalinksModel::ANCHOR->value => $slug
]);
return $create->ok;
}
// # Responses
// Return 422 Unprocessable Content error if request validation failed
private function resp_rules_invalid(): Response {
return new Response($this->ruleset->get_errors(), 422);
}
// Return a 503 Service Unavailable error if something went wrong with the database call
private function resp_database_error(): Response {
return new Response("Failed to get work data, please try again later", 503);
}
public function main(): Response {
// Bail out if request validation failed
if (!$this->ruleset->is_valid()) {
return $this->resp_rules_invalid();
}
// Generate URL slug from title text or UUID if undefined
$slug = !empty($_POST["title"]) ? self::gen_slug($_POST["title"]) : parent::gen_uuid4();
// Check if an entity already exists with slugified title from GET endpoint
$existing_entity = Call("work?id={$slug}", Method::GET);
// Response is not 404 (Not found) so we can't create the entity
if ($existing_entity->code !== 404) {
// Response is not a valid entity, something went wrong
if ($existing_entity->code !== 200) {
return $this->resp_database_error();
}
// Return 402 Conflict
return new Response("Entity with id '{$slug}' already exists", 402);
}
// Get created timestamp from payload or use current time if not specified
$created_timestamp = $_POST[WorkModel::DATE_TIMESTAMP_CREATED->value]
? $_POST[WorkModel::DATE_TIMESTAMP_CREATED->value]
: time();
// Attempt to create new entity
$insert = $this->db->for(WorkModel::TABLE)
->insert([
WorkModel::ID->value => $slug,
WorkModel::TITLE->value => $_POST["title"],
WorkModel::SUMMARY->value => $_POST["summary"],
WorkModel::IS_LISTABLE->value => true,
WorkModel::IS_READABLE->value => true,
WorkModel::DATE_YEAR->value => date("Y", $created_timestamp),
WorkModel::DATE_MONTH ->value => date("n", $created_timestamp),
WorkModel::DATE_DAY->value => date("j", $created_timestamp),
WorkModel::DATE_TIMESTAMP_MODIFIED->value => null,
WorkModel::DATE_TIMESTAMP_CREATED->value => $created_timestamp,
]);
// Bail out if insert failed
if (!$insert) {
return $this->resp_database_error();
}
// Create permalink for new entity
if (!$this->create_permalink($slug)) {
// Rollback created entity if permalink creation failed
Call("work", Method::DELETE, [WorkModel::ID->value => $slug]);
return new Response("Failed to create permalink", 500);
}
// Return 201 Created and entity slug as body
return new Response($slug, 201);
}
}

View file

@ -1,73 +0,0 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use Reflect\Method;
use function Reflect\Call;
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("id"))
->required()
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
]);
}
// # Responses
// Return 422 Unprocessable Content error if request validation failed
private function resp_rules_invalid(): Response {
return new Response($this->ruleset->get_errors(), 422);
}
// Return a 503 Service Unavailable error if something went wrong with the database call
private function resp_database_error(): Response {
return new Response("Failed to get work data, please try again later", 503);
}
public function main(): Response {
// Bail out if request validation failed
if (!$this->ruleset->is_valid()) {
return $this->resp_rules_invalid();
}
// Ensure the action exists by id
$existing_action = $this->db->for(WorkActionsModel::TABLE)
->where([
WorkActionsModel::ID->value => $_POST["id"]
])
->select(null);
// Return idempotent deletion if the action does not exist
if ($existing_action->num_rows === 0) {
return new Response($_POST["id"]);
}
// Attempt to delete action by id
$delete = $this->db->for(WorkActionsModel::TABLE)
->delete([
WorkActionsModel::ID->value => $_POST["id"]
]);
// Return 201 Created and entity id as body if insert was successful
return $delete === true ? new Response($_POST["id"], 201) : $this->resp_database_error();
}
}

View file

@ -1,67 +0,0 @@
<?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/WorkActions.php");
class GET_WorkActions extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(WorkActionsModel::ANCHOR->value))
->required()
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
]);
}
// # Responses
// Return 422 Unprocessable Content error if request validation failed
private function resp_rules_invalid(): Response {
return new Response($this->ruleset->get_errors(), 422);
}
// Return a 503 Service Unavailable error if something went wrong with the database call
private function resp_database_error(): Response {
return new Response("Failed to get work data, please try again later", 503);
}
public function main(): Response {
// Bail out if request validation failed
if (!$this->ruleset->is_valid()) {
return $this->resp_rules_invalid();
}
$resp = $this->db->for(WorkActionsModel::TABLE)
->where([WorkActionsModel::ANCHOR->value => $_GET[WorkActionsModel::ANCHOR->value]])
->select([
WorkActionsModel::DISPLAY_TEXT->value,
WorkActionsModel::HREF->value,
WorkActionsModel::CLASS_LIST->value,
WorkActionsModel::EXTERNAL->value
]);
// Bail out if something went wrong retrieving rows from the database
if (!parent::is_mysqli_result($resp)) {
return $this->resp_database_error();
}
return $resp->num_rows > 0
? new Response($resp->fetch_all(MYSQLI_ASSOC))
: new Response([]);
}
}

View file

@ -1,102 +0,0 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use Reflect\Method;
use function Reflect\Call;
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 POST_WorkActions extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules("id"))
->required()
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
]);
$this->ruleset->POST([
(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)
->max(4)
->default([]),
(new Rules(WorkActionsModel::EXTERNAL->value))
->type(Type::BOOLEAN)
->default(false)
]);
}
// # Responses
// Return 422 Unprocessable Content error if request validation failed
private function resp_rules_invalid(): Response {
return new Response($this->ruleset->get_errors(), 422);
}
// Return a 503 Service Unavailable error if something went wrong with the database call
private function resp_database_error(): Response {
return new Response("Failed to get work data, please try again later", 503);
}
public function main(): Response {
// Bail out if request validation failed
if (!$this->ruleset->is_valid()) {
return $this->resp_rules_invalid();
}
// Ensure an entity with the provided id exists
$entity = Call("work?id={$_GET["id"]}", Method::GET);
if ($entity->code !== 200) {
// Response from endpoint is not 404, something went wrong
if ($entity->code !== 404) {
return $this->resp_database_error();
}
return new Response("No entity with id '{$_GET["id"]}' was found", 404);
}
// Attempt to create action for entity
$insert = $this->db->for(WorkActionsModel::TABLE)
->insert([
WorkActionsModel::ID->value => parent::gen_uuid4(),
WorkActionsModel::ANCHOR->value => $_GET["id"],
WorkActionsModel::DISPLAY_TEXT->value => $_POST[WorkActionsModel::DISPLAY_TEXT->value],
WorkActionsModel::HREF->value => $_POST[WorkActionsModel::HREF->value],
WorkActionsModel::CLASS_LIST->value => implode(",", $_POST[WorkActionsModel::CLASS_LIST->value]),
WorkActionsModel::EXTERNAL->value => $_POST[WorkActionsModel::EXTERNAL->value],
]);
// Return 201 Created and entity id as body if insert was successful
return $insert === true ? new Response($_GET["id"], 201) : $this->resp_database_error();
}
}

View file

@ -1,60 +0,0 @@
<?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\WorkPermalinks\WorkPermalinksModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/WorkPermalinks.php");
class GET_WorkPermalinks extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules("id"))
->required()
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
]);
}
// # Responses
// Return 422 Unprocessable Content error if request validation failed
private function resp_rules_invalid(): Response {
return new Response($this->ruleset->get_errors(), 422);
}
// Return a 503 Service Unavailable error if something went wrong with the database call
private function resp_database_error(): Response {
return new Response("Failed to resolve permalink, please try again later", 503);
}
public function main(): Response {
// Bail out if request validation failed
if (!$this->ruleset->is_valid()) {
return $this->resp_rules_invalid();
}
// Get all anchors that match the requested slug
$resolve = $this->db->for(WorkPermalinksModel::TABLE)
->where([WorkPermalinksModel::SLUG->value => $_GET["id"]])
->select(WorkPermalinksModel::ANCHOR->value);
// Return array of all matched work table ids. Or empty array if none found
return parent::is_mysqli_result($resolve)
? new Response(array_column($resolve->fetch_all(MYSQLI_ASSOC), WorkPermalinksModel::ANCHOR->value))
: $this->resp_database_error();
}
}

View file

@ -1,83 +0,0 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use Reflect\Method;
use function Reflect\Call;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\WorkPermalinks\WorkPermalinksModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/WorkPermalinks.php");
class POST_WorkPermalinks extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->POST([
(new Rules("slug"))
->required()
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules("anchor"))
->required()
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
]);
}
// # Responses
// Return 422 Unprocessable Content error if request validation failed
private function resp_rules_invalid(): Response {
return new Response($this->ruleset->get_errors(), 422);
}
// Return a 503 Service Unavailable error if something went wrong with the database call
private function resp_database_error(): Response {
return new Response("Failed to resolve permalink, please try again later", 503);
}
public function main(): Response {
// Bail out if request validation failed
if (!$this->ruleset->is_valid()) {
return $this->resp_rules_invalid();
}
// Check if an entity exists with slug
$existing_entity = Call("work?id={$_POST["slug"]}", Method::GET);
// Response is not 404 (Not found) so we can't create the entity
if ($existing_entity->code !== 200) {
// Response is not a valid entity, something went wrong
if ($existing_entity->code !== 404) {
return $this->resp_database_error();
}
// Return 402 Conflict
return new Response("No work entity with id '{$_POST["slug"]}' was found to permalink", 404);
}
// Attempt to create new entity
$insert = $this->db->for(WorkPermalinksModel::TABLE)
->insert([
WorkPermalinksModel::SLUG->value => $_POST["slug"],
WorkPermalinksModel::ANCHOR->value => $_POST["anchor"],
WorkPermalinksModel::DATE_TIMESTAMP_CREATED->value => time(),
]);
// Return 201 Created and entity slug as body if insert was successful
return $insert === true ? new Response($_POST["slug"], 201) : $this->resp_database_error();
}
}

View file

@ -1,80 +0,0 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use Reflect\Method;
use function Reflect\Call;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Work\WorkTagsModel;
use VLW\API\Databases\VLWdb\Models\Work\WorkTagsNameEnum;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/WorkTags.php");
class DELETE_WorkTags extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->POST([
(new Rules("id"))
->required()
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkTagsModel::NAME->value))
->required()
->type(Type::ENUM, WorkTagsNameEnum::names())
]);
}
// # Responses
// Return 422 Unprocessable Content error if request validation failed
private function resp_rules_invalid(): Response {
return new Response($this->ruleset->get_errors(), 422);
}
// Return a 503 Service Unavailable error if something went wrong with the database call
private function resp_database_error(): Response {
return new Response("Failed to get work data, please try again later", 503);
}
public function main(): Response {
// Bail out if request validation failed
if (!$this->ruleset->is_valid()) {
return $this->resp_rules_invalid();
}
// Ensure the tag exists for entity id
$existing_tag = $this->db->for(WorkTagsModel::TABLE)
->where([
WorkTagsModel::ANCHOR->value => $_POST["id"],
WorkTagsModel::NAME->value => $_POST["name"]
])
->select(null);
// Return idempotent deletion if the tag does not exist
if ($existing_tag->num_rows === 0) {
return new Response($_POST["id"]);
}
// Attempt to delete tag for entity
$delete = $this->db->for(WorkTagsModel::TABLE)
->delete([
WorkTagsModel::ANCHOR->value => $_POST["id"],
WorkTagsModel::NAME->value => $_POST["name"]
]);
// Return 201 Created and entity id as body if insert was successful
return $delete === true ? new Response($_POST["id"], 201) : $this->resp_database_error();
}
}

View file

@ -1,93 +0,0 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use Reflect\Method;
use function Reflect\Call;
use VLW\API\Databases\VLWdb\VLWdb;
use VLW\API\Databases\VLWdb\Models\Work\WorkTagsModel;
use VLW\API\Databases\VLWdb\Models\Work\WorkTagsNameEnum;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/WorkTags.php");
class POST_WorkTags extends VLWdb {
protected Ruleset $ruleset;
public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules("id"))
->required()
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
]);
$this->ruleset->POST([
(new Rules(WorkTagsModel::NAME->value))
->required()
->type(Type::ENUM, WorkTagsNameEnum::names())
]);
}
// # Responses
// Return 422 Unprocessable Content error if request validation failed
private function resp_rules_invalid(): Response {
return new Response($this->ruleset->get_errors(), 422);
}
// Return a 503 Service Unavailable error if something went wrong with the database call
private function resp_database_error(): Response {
return new Response("Failed to get work data, please try again later", 503);
}
public function main(): Response {
// Bail out if request validation failed
if (!$this->ruleset->is_valid()) {
return $this->resp_rules_invalid();
}
// Ensure an entity with the provided id exists
$entity = Call("work?id={$_GET["id"]}", Method::GET);
if ($entity->code !== 200) {
// Response from endpoint is not 404, something went wrong
if ($entity->code !== 404) {
return $this->resp_database_error();
}
return new Response("No entity with id '{$_GET["id"]}' was found", 404);
}
// Ensure the tag does not already exist for entity
$existing_tag = $this->db->for(WorkTagsModel::TABLE)
->where([
WorkTagsModel::ANCHOR->value => $_GET["id"],
WorkTagsModel::NAME->value => $_POST["name"]
])
->select(null);
// Bail out if this tag already exists
if ($existing_tag->num_rows !== 0) {
return new Response("Tag '{$_POST["name"]}' is already set on entity id '{$_GET["id"]}'", 402);
}
// Attempt to create tag for entity
$insert = $this->db->for(WorkTagsModel::TABLE)
->insert([
WorkTagsModel::ANCHOR->value => $_GET["id"],
WorkTagsModel::NAME->value => $_POST["name"]
]);
// Return 201 Created and entity id as body if insert was successful
return $insert === true ? new Response($_GET["id"], 201) : $this->resp_database_error();
}
}

View file

@ -1,52 +0,0 @@
<?php
namespace VLW\API\Databases\VLWdb;
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 MySQL $db;
public function __construct() {
// 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)
);
}
public static function is_mysqli_result(\mysqli_result|bool $resp): bool {
return $resp instanceof \mysqli_result;
}
}

View file

@ -1,10 +0,0 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Coffee;
enum CoffeeModel: string {
const TABLE = "coffee";
case ID = "id";
case DATE_TIMESTAMP_CREATED = "date_timestamp_created";
}

View file

@ -1,24 +0,0 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Media;
use victorwesterlund\xEnum;
enum MediaTypeEnum: string {
use xEnum;
case BLOB = "BLOB";
case IMAGE = "IMAGE";
}
enum MediaModel: string {
const TABLE = "media";
case ID = "id";
case NAME = "name";
case TYPE = "type";
case MIME = "mime";
case EXTENSION = "extension";
case SRCSET = "srcset";
case DATE_TIMESTAMP_CREATED = "date_timestamp_created";
}

View file

@ -1,10 +0,0 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\MediaSrcset;
enum MediaSrcsetModel: string {
const TABLE = "media_srcset";
case ID = "id";
case ANCHOR_DEFAULT = "anchor_default";
}

View file

@ -1,15 +0,0 @@
<?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_TIMESTAMP_CREATED = "date_timestamp_created";
}

View file

@ -1,19 +0,0 @@
<?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_TIMESTAMP_MODIFIED = "date_timestamp_modified";
case DATE_TIMESTAMP_CREATED = "date_timestamp_created";
}

View file

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

View file

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

View file

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

View file

@ -1,17 +0,0 @@
{
"name": "local/api.endpoints",
"description": "Endpoint pathmappings for VLW API",
"type": "library",
"version": "1.0.0-dev",
"authors": [
{
"name": "Victor Westerlund",
"email": "victor@vlw.se"
}
],
"autoload": {
"psr-4": {
"VLW\\API\\": "src/"
}
}
}

View file

@ -1,11 +0,0 @@
<?php
namespace VLW\API;
enum Endpoints: string {
case WORK = "/work";
case SEARCH = "/search";
case MESSAGES = "/messages";
case WORK_TAGS = "/work/tags";
case WORK_ACTIONS = "/work/actions";
}

View file

@ -1,16 +0,0 @@
@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");
}

View file

@ -1,90 +0,0 @@
/* # 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

@ -1,85 +0,0 @@
/* # 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;
}

Binary file not shown.

View file

@ -1,40 +0,0 @@
new vv.Interactions("document", {
navigateHome: () => new vv.Navigation("/").navigate(),
closeSearchbox: () => document.querySelector("header").classList.remove("searchboxActive"),
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,108 +0,0 @@
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 });
}

View file

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

View file

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

View file

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

Before

Width:  |  Height:  |  Size: 957 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.8 KiB

View file

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

Before

Width:  |  Height:  |  Size: 351 B

View file

@ -1,17 +1,9 @@
{
"require": {
"local/api.client": "1.0.0-dev",
"local/api.endpoints": "1.0.0-dev"
"reflect/client": "dev-master",
"reflect/plugin-rules": "dev-master",
"vlw/mysql": "dev-master",
"vlw/xenum": "dev-master"
},
"minimum-stability": "dev",
"repositories": [
{
"type": "path",
"url": "src/packages/API"
},
{
"type": "path",
"url": "api/src/packages/Endpoints"
}
]
"minimum-stability": "dev"
}

162
composer.lock generated
View file

@ -4,75 +4,17 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "73a61bf0308871f9dc9ad050aedfe13e",
"content-hash": "cb70f9f3f538a72aa8bcf906fdc906bf",
"packages": [
{
"name": "local/api.client",
"version": "1.0.0-dev",
"dist": {
"type": "path",
"url": "src/packages/API",
"reference": "020275feb0e0017fa91ae0b33213bc54f35cac75"
},
"require": {
"reflect/client": "^3.0"
},
"type": "library",
"autoload": {
"psr-4": {
"VLW\\API\\": "src/"
}
},
"authors": [
{
"name": "Victor Westerlund",
"email": "victor@vlw.se"
}
],
"description": "Wrapper for vlw.se API",
"transport-options": {
"relative": true
}
},
{
"name": "local/api.endpoints",
"version": "1.0.0-dev",
"dist": {
"type": "path",
"url": "api/src/packages/Endpoints",
"reference": "89b7b9a4cc504abddb4aeec8e05a95c9d9087575"
},
"type": "library",
"autoload": {
"psr-4": {
"VLW\\API\\": "src/"
}
},
"authors": [
{
"name": "Victor Westerlund",
"email": "victor@vlw.se"
}
],
"description": "Endpoint pathmappings for VLW API",
"transport-options": {
"relative": true
}
},
{
"name": "reflect/client",
"version": "3.0.6",
"version": "dev-master",
"source": {
"type": "git",
"url": "https://github.com/VictorWesterlund/reflect-client-php.git",
"url": "https://codeberg.org/reflect/client-php",
"reference": "89a8c041044c8c60cefafc4716d5d61b96c43e06"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/VictorWesterlund/reflect-client-php/zipball/89a8c041044c8c60cefafc4716d5d61b96c43e06",
"reference": "89a8c041044c8c60cefafc4716d5d61b96c43e06",
"shasum": ""
},
"default-branch": true,
"type": "library",
"autoload": {
"psr-4": {
@ -90,23 +32,105 @@
}
],
"description": "Extendable PHP interface for communicating with Reflect API over HTTP or UNIX sockets",
"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": "reflect/plugin-rules",
"version": "dev-master",
"source": {
"type": "git",
"url": "https://codeberg.org/reflect/rules-plugin",
"reference": "aa7d969350f50d00d7dce01b948276946fcc0e81"
},
"default-branch": true,
"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",
"time": "2024-11-28T17:05:16+00:00"
},
{
"name": "vlw/mysql",
"version": "dev-master",
"source": {
"type": "git",
"url": "https://codeberg.org/vlw/php-mysql",
"reference": "c64eb96049907da60dc9f237d26aef0e531b0015"
},
"default-branch": true,
"type": "library",
"autoload": {
"psr-4": {
"vlw\\MySQL\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL-3.0-or-later"
],
"authors": [
{
"name": "Victor Westerlund",
"email": "victor@vlw.se"
}
],
"description": "Abstraction library for common MySQL/MariaDB DML operations with php-mysqli",
"time": "2025-01-30T09:33:10+00:00"
},
{
"name": "vlw/xenum",
"version": "dev-master",
"source": {
"type": "git",
"url": "https://codeberg.org/vlw/php-xenum",
"reference": "1c997a5574656b88a62f5ee160ee5a6439932a2f"
},
"default-branch": true,
"type": "library",
"autoload": {
"psr-4": {
"vlw\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Victor Westerlund",
"email": "victor@vlw.se"
}
],
"description": "PHP eXtended Enums. The missing quality-of-life features from PHP 8+ Enums",
"time": "2024-12-02T10:36:32+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "dev",
"stability-flags": {
"local/api.client": 20,
"local/api.endpoints": 20
"reflect/client": 20,
"reflect/plugin-rules": 20,
"vlw/mysql": 20,
"vlw/xenum": 20
},
"prefer-stable": false,
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
"plugin-api-version": "2.0.0"
"plugin-api-version": "2.3.0"
}

View file

@ -0,0 +1,40 @@
<?php
use ReflectRules\Ruleset;
use Reflect\{Response, Path, Call};
use VLW\API\Endpoints;
use VLW\Database\Database;
use VLW\Database\Tables\About\LanguagesTable;
require_once Path::root("src/API/Endpoints.php");
require_once Path::root("src/Database/Database.php");
require_once Path::root("src/Database/Tables/About/Languages.php");
class DELETE_AboutLanguages extends Database {
protected readonly Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->validate_or_exit();
parent::__construct();
}
private function languages(): array {
$resp = (new Call(Endpoints::ABOUT_LANGUAGES->value))->get();
return array_column($resp->output(), LanguagesTable::ID->value);
}
// Delete languages cache file if it exists
public function main(): Response {
$this->db->for(LanguagesTable::NAME);
foreach ($this->languages() as $language){
$this->db->delete([LanguagesTable::ID->value => $language]);
}
return new Response();
}
}

View file

@ -0,0 +1,54 @@
<?php
use vlw\MySQL\Order;
use Reflect\{Response, Path, Call};
use ReflectRules\{Ruleset, Rules, Type};
use VLW\API\Endpoints;
use VLW\Database\Database;
use const VLW\FORGEJO_UPDATE_CACHE_PARAM;
use VLW\Database\Tables\About\LanguagesTable;
require_once Path::root("src/Consts.php");
require_once Path::root("src/API/Endpoints.php");
require_once Path::root("src/Database/Database.php");
require_once Path::root("src/Database/Tables/About/Languages.php");
class GET_AboutLanguages extends Database {
protected readonly Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(LanguagesTable::ID->value))
->type(Type::STRING)
->min(1)
->max(parent::SIZE_VARCHAR),
(new Rules(LanguagesTable::BYTES->value))
->type(Type::NUMBER)
->min(1)
->max(parent::SIZE_UINT32),
(new Rules(FORGEJO_UPDATE_CACHE_PARAM))
->type(Type::BOOLEAN)
->default(false)
]);
$this->ruleset->validate_or_exit();
parent::__construct();
}
public function main(): Response {
// Refresh the language cache if param is set
if ($_GET[FORGEJO_UPDATE_CACHE_PARAM]) {
(new Call(Endpoints::ABOUT_LANGUAGES->value))->post();
}
return $this->list(LanguagesTable::NAME, LanguagesTable::values(), [
LanguagesTable::BYTES->value => Order::DESC
]);
}
}

View file

@ -0,0 +1,102 @@
<?php
use ReflectRules\Ruleset;
use Reflect\{Response, Path, Call};
use VLW\API\Endpoints;
use VLW\Database\Database;
use VLW\Database\Tables\About\LanguagesTable;
use const VLW\{FORGEJO_ENDPOINT_USER, FORGEJO_ENDPOINT_SEARCH};
require_once Path::root("src/Consts.php");
require_once Path::root("src/API/Endpoints.php");
require_once Path::root("src/Database/Tables/About/Languages.php");
class POST_AboutLanguages extends Database {
protected readonly Ruleset $ruleset;
// Tally of all languages used in all configured repositories
private array $languages = [];
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->validate_or_exit();
parent::__construct();
}
// Fetch JSON from URL
private static function fetch_json(string $url): array {
return json_decode(file_get_contents($url), true);
}
// Fetch JSON from a Forgejo endpoint
private static function fetch_endpoint(string $endpoint): array {
$url = $_ENV["server_forgejo"]["base_url"] . $endpoint;
return self::fetch_json($url);
}
// Write $this->languages to a JSON file
private function cache_languages(): void {
// Delete existing cache
(new Call(Endpoints::ABOUT_LANGUAGES->value))->delete();
$this->db->for(LanguagesTable::NAME);
foreach ($this->languages as $language => $bytes) {
$this->db->insert([
LanguagesTable::ID->value => $language,
LanguagesTable::BYTES->value => $bytes
]);
}
}
// Fetch and add languages to total from a fully-qualified Forgejo URL
private function add_repository_languages(string $url): void {
foreach(self::fetch_json($url) as $language => $bytes) {
// Create key for language if it doesn't exist
if (!array_key_exists($language, $this->languages)) {
$this->languages[$language] = 0;
}
// Add bytes to language in total
$this->languages[$language] += $bytes;
}
}
// Tally languages from public repositories for user id
private function add_public_repositores(int $uid): bool {
$resp = self::fetch_endpoint(sprintf(FORGEJO_ENDPOINT_SEARCH, $uid));
// Bail out if request failed or if response indicated a problem
if (!$resp or $resp["ok"] === false) {
return false;
}
// Add langauges for each public repository
foreach ($resp["data"] as $repo) {
$this->add_repository_languages($repo["languages_url"]);
}
return true;
}
// Add languages from all public repositories for profiles in config
private function add_repositories_from_config_profiles(): void {
foreach(explode(",", $_ENV["server_forgejo"]["scan_profiles"]) as $profile) {
// Resolve user data from username
$user = self::fetch_endpoint(sprintf(FORGEJO_ENDPOINT_USER, $profile));
$this->add_public_repositores($user["id"]);
}
}
public function main(): Response {
$this->add_repositories_from_config_profiles();
// Sort langauges bytes tally by largest in descending order
arsort($this->languages);
$this->cache_languages();
return new Response($this->languages);
}
}

View file

@ -0,0 +1,43 @@
<?php
use Reflect\{Response, Path};
use ReflectRules\{Ruleset, Rules, Type};
use VLW\Database\Database;
use VLW\Database\Tables\Messages\MessagesTable;
require_once Path::root("src/Database/Database.php");
require_once Path::root("src/Database/Tables/Messages/Messages.php");
class POST_Messages extends Database {
protected Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->POST([
(new Rules(MessagesTable::EMAIL->value))
->type(Type::STRING)
->max(255)
->default(null),
(new Rules(MessagesTable::MESSAGE->value))
->required()
->type(Type::STRING)
->min(1)
->max(parent::SIZE_TEXT)
]);
$this->ruleset->validate_or_exit();
parent::__construct();
}
public function main(): Response {
$_POST[MessagesTable::TIMESTAMP_CREATED->value] = time();
return $this->db->for(MessagesTable::NAME)->insert($_POST) === true
? new Response(null, 201)
: new Response("Failed to send message", 500);
}
}

View file

@ -0,0 +1,35 @@
<?php
use vlw\MySQL\Operators;
use Reflect\{Response, Path};
use ReflectRules\{Ruleset, Rules, Type};
use VLW\Database\Database;
use VLW\Database\Tables\Search\SearchTable;
require_once Path::root("src/Database/Database.php");
require_once Path::root("src/Database/Tables/Search/Search.php");
class DELETE_Search extends Database {
protected Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->POST([
(new Rules(SearchTable::ID->value))
->required()
->type(Type::STRING)
->min(2)
->max(parent::SIZE_VARCHAR)
]);
$this->ruleset->validate_or_exit();
parent::__construct();
}
public function main(): Response {
return $this->db->for(SearchTable::NAME)->delete($_POST) === true ? new Response() : new Response("", 500);
}
}

96
endpoints/search/GET.php Normal file
View file

@ -0,0 +1,96 @@
<?php
use vlw\MySQL\Operators;
use Reflect\{Response, Path, Call};
use ReflectRules\{Ruleset, Rules, Type};
use VLW\API\Endpoints;
use VLW\Database\Database;
use const VLW\SEARCH_UPDATE_CACHE_PARM;
use VLW\Database\Tables\Search\{SearchTable, SearchCategoryEnum};
require_once Path::root("src/Consts.php");
require_once Path::root("src/API/Endpoints.php");
require_once Path::root("src/Database/Database.php");
require_once Path::root("src/Database/Tables/Search/Search.php");
class GET_Search extends Database {
protected Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(SearchTable::QUERY->value))
->type(Type::STRING)
->min(2)
->max(parent::SIZE_VARCHAR)
->default(null),
(new Rules(SearchTable::ID->value))
->type(Type::STRING)
->min(1)
->max(10)
->default(null),
(new Rules(SearchTable::CATEGORY->value))
->type(Type::ENUM, SearchCategoryEnum::names())
->default(null),
(new Rules(SEARCH_UPDATE_CACHE_PARM))
->type(Type::BOOLEAN)
->default(false)
]);
$this->ruleset->validate_or_exit();
parent::__construct();
}
private static function get_query(): string {
preg_match_all("/[a-zA-Z0-9]+/", $_GET[SearchTable::QUERY->value], $matches);
return strtolower(implode("", $matches[0]));
}
public function main(): Response {
// Freshen cache if update flag is set
if ($_GET[SEARCH_UPDATE_CACHE_PARM]) {
(new Call(Endpoints::SEARCH->value))->post();
}
$result = $this->db->for(SearchTable::NAME);
if ($_GET[SearchTable::ID->value]) {
$result = $result->where([SearchTable::ID->value => $_GET[SearchTable::ID->value]]);
} else if ($_GET[SearchTable::QUERY->value]) {
$query = self::get_query();
$filter = [
SearchTable::QUERY->value => [
Operators::LIKE->value => "%{$query}%"
]
];
if ($_GET[SearchTable::CATEGORY->value]) {
$filter[SearchTable::CATEGORY->value] = $_GET[SearchTable::CATEGORY->value];
}
$result = $result->where($filter);
} else {
new Response([], 400);
}
$result = $result->select([
SearchTable::ID->value,
SearchTable::TITLE->value,
SearchTable::SUMMARY->value,
SearchTable::CATEGORY->value,
SearchTable::HREF->value
]);
return $result->num_rows > 0
? new Response($result->fetch_all(MYSQLI_ASSOC))
: new Response([], 404);
}
}

73
endpoints/search/POST.php Normal file
View file

@ -0,0 +1,73 @@
<?php
use vlw\MySQL\Operators;
use Reflect\{Response, Path, Call};
use ReflectRules\{Ruleset, Rules, Type};
use VLW\API\Endpoints;
use VLW\Database\Database;
use const VLW\SEARCH_QUERY_MAX_LENGTH;
use VLW\Database\Tables\Search\{SearchTable, SearchCategoryEnum};
use VLW\Database\Tables\Work\{WorkTable, ActionsTable};
require_once Path::root("src/Consts.php");
require_once Path::root("src/API/Endpoints.php");
require_once Path::root("src/Database/Database.php");
require_once Path::root("src/Database/Tables/Work/Work.php");
require_once Path::root("src/Database/Tables/Work/Actions.php");
require_once Path::root("src/Database/Tables/Search/Search.php");
class POST_Search extends Database {
protected Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->validate_or_exit();
parent::__construct();
}
private static function truncate_string(string $input): string {
return substr($input, 0, SEARCH_QUERY_MAX_LENGTH);
}
private static function create_query(string $input): string {
preg_match_all("/[a-zA-Z0-9]+/", $input, $matches);
return self::truncate_string(strtolower(implode("", $matches[0])));
}
private function index_work(): void {
foreach ((new Call(Endpoints::WORK->value))->get()->output() as $result) {
$query = self::create_query(implode("", array_values($result)), 0, SEARCH_QUERY_MAX_LENGTH);
// Get actions related to current result
$actions = (new Call(Endpoints::WORK_ACTIONS->value))->params([
ActionsTable::REF_WORK_ID->value => $result[WorkTable::ID->value]
])->get()->output();
$this->db->for(SearchTable::NAME)->insert([
SearchTable::QUERY->value => $query,
SearchTable::ID->value => crc32($query),
SearchTable::TITLE->value => self::truncate_string($result[WorkTable::TITLE->value]),
SearchTable::SUMMARY->value => self::truncate_string($result[WorkTable::SUMMARY->value]),
SearchTable::CATEGORY->value => SearchCategoryEnum::WORK->name,
// Use first action as link for search result
SearchTable::HREF->value => $actions
? self::truncate_string($actions[0][ActionsTable::HREF->value])
: null
]);
}
}
public function main(): Response {
// Truncate existing cache
(new Call(Endpoints::SEARCH->value))->delete();
$this->index_work();
return $result->num_rows > 0
? new Response($result->fetch_all(MYSQLI_ASSOC))
: new Response([], 404);
}
}

49
endpoints/work/GET.php Normal file
View file

@ -0,0 +1,49 @@
<?php
use vlw\MySQL\Order;
use Reflect\{Response, Path};
use ReflectRules\{Ruleset, Rules, Type};
use VLW\Database\Database;
use VLW\Database\Tables\Work\WorkTable;
require_once Path::root("src/Database/Database.php");
require_once Path::root("src/Database/Tables/Work/Work.php");
class GET_Work extends Database {
protected Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(WorkTable::ID->value))
->type(Type::STRING)
->min(1)
->max(parent::SIZE_VARCHAR),
(new Rules(WorkTable::TITLE->value))
->type(Type::STRING)
->max(parent::SIZE_VARCHAR),
(new Rules(WorkTable::SUMMARY->value))
->type(Type::STRING)
->max(parent::SIZE_TEXT),
(new Rules(WorkTable::CREATED->value))
->type(Type::STRING)
->min(1)
->max(parent::SIZE_VARCHAR)
]);
$this->ruleset->validate_or_exit();
parent::__construct();
}
public function main(): Response {
return $this->list(WorkTable::NAME, WorkTable::values(), [
WorkTable::CREATED->value => Order::DESC
]);
}
}

View file

@ -0,0 +1,36 @@
<?php
use vlw\MySQL\Order;
use Reflect\{Response, Path};
use ReflectRules\{Ruleset, Rules, Type};
use VLW\Database\Database;
use VLW\Database\Tables\Work\ActionsTable;
require_once Path::root("src/Database/Database.php");
require_once Path::root("src/Database/Tables/Work/Actions.php");
class GET_WorkActions extends Database {
protected Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(ActionsTable::REF_WORK_ID->value))
->type(Type::STRING)
->min(1)
->max(parent::SIZE_VARCHAR)
]);
$this->ruleset->validate_or_exit();
parent::__construct();
}
public function main(): Response {
return $this->list(ActionsTable::NAME, ActionsTable::values(), [
ActionsTable::ORDER_IDX->value => Order::DESC
]);
}
}

View file

@ -0,0 +1,35 @@
<?php
use Reflect\{Response, Path};
use ReflectRules\{Ruleset, Rules, Type};
use VLW\Database\Database;
use VLW\Database\Tables\Work\{TagsTable, TagsLabelEnum};
require_once Path::root("src/Database/Database.php");
require_once Path::root("src/Database/Tables/Work/Tags.php");
class GET_WorkTags extends Database {
private Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(TagsTable::REF_WORK_ID->value))
->min(1)
->max(parent::SIZE_VARCHAR),
(new Rules(TagsTable::LABEL->value))
->type(Type::ENUM, TagsLabelEnum::names())
]);
$this->ruleset->validate_or_exit();
parent::__construct();
}
public function main(): Response {
return $this->list(TagsTable::NAME, TagsTable::values());
}
}

View file

@ -0,0 +1,53 @@
<?php
use vlw\MySQL\Order;
use Reflect\{Response, Path};
use ReflectRules\{Ruleset, Rules, Type};
use VLW\Database\Database;
use VLW\Database\Tables\Work\TimelineTable;
require_once Path::root("src/Database/Database.php");
require_once Path::root("src/Database/Tables/Work/Timeline.php");
class GET_WorkTimeline extends Database {
protected Ruleset $ruleset;
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(TimelineTable::REF_WORK_ID->value))
->type(Type::STRING)
->min(1)
->max(parent::SIZE_VARCHAR),
(new Rules(TimelineTable::YEAR->value))
->type(Type::NUMBER)
->min(0)
->max(parent::SIZE_UINT16),
(new Rules(TimelineTable::MONTH->value))
->type(Type::NUMBER)
->min(0)
->max(parent::SIZE_UINT8),
(new Rules(TimelineTable::DAY->value))
->type(Type::NUMBER)
->min(0)
->max(parent::SIZE_UINT8)
]);
$this->ruleset->validate_or_exit();
parent::__construct();
}
public function main(): Response {
return $this->list(TimelineTable::NAME, TimelineTable::values(), [
TimelineTable::YEAR->value => Order::DESC,
TimelineTable::MONTH->value => Order::DESC,
TimelineTable::DAY->value => Order::DESC
]);
}
}

10
install.sh Normal file
View file

@ -0,0 +1,10 @@
# Install dependencies
composer install --optimize-autoloader
npm install
# (Re)create public NPM modules folder
rm -r public/assets/js/modules/npm
mkdir public/assets/js/modules/npm
# Create link to Elevent MJS from public JS modules folder
ln -sr node_modules/elevent/src/Elevent.mjs public/assets/js/modules/npm/Elevent.mjs

17
package-lock.json generated Normal file
View file

@ -0,0 +1,17 @@
{
"name": "vlw.se",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"elevent": "^1.0.2"
}
},
"node_modules/elevent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/elevent/-/elevent-1.0.2.tgz",
"integrity": "sha512-ks5LBUBTg4Bpfmj99OcFAzuDGzBRDEZhTyxmq/Y3RbsdBQ4JCaIUYB0M15OBvBWgIn1BnCo4WCSmw0/YbCJliw=="
}
}
}

5
package.json Normal file
View file

@ -0,0 +1,5 @@
{
"dependencies": {
"elevent": "^1.0.2"
}
}

View file

@ -1,45 +0,0 @@
<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 <a href="https://icellate.com">iCellate&nbsp;Medical</a>. iCellate is a biopharma start-up developing precision oncology in Solna, Stockholm. I 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="">Vegvisir</a> (web) and <a href="">Reflect</a> (API) framework. There are <i>no cookies or trackers</i> on this site. The only information I have about you is your public client/proxy IP-address <i>(<?= $_SERVER["REMOTE_ADDR"] ?>)</i> plus the pages and resources your browser requests. None of this data is used for 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>I can at times become a real sucker for a <span class="interests">variety of topics I find interesting</span>, and spend hours reading as much as I can about them too. When I'm not glued to a computer screen with a cup of coffee ready at-hand, I like to skii and venture out on occasional hobby photography trips.</p>
</section>
<section class="about">
<h2>Projects</h2>
<p>These are my top 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>

View file

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

View file

@ -1,87 +0,0 @@
<?php
use VLW\API\Client;
use VLW\API\Endpoints;
enum ContactFieldsEnum: string {
case EMAIL = "email";
case MESSAGE = "message";
}
// Connect to VLW API
$api = new Client();
?>
<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><?= date("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([
ContactFieldsEnum::EMAIL->value => $_POST[ContactFieldsEnum::EMAIL->value],
ContactFieldsEnum::MESSAGE->value => $_POST[ContactFieldsEnum::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="<?= ContactFieldsEnum::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="<?= ContactFieldsEnum::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>

View file

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

View file

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

View file

@ -1,139 +0,0 @@
<?php
use VLW\API\Client;
use VLW\API\Endpoints;
$api = new Client();
$query = $_GET["q"] ?? null;
// Get search results from endpoint
$response = $api->call(Endpoints::SEARCH->value)
// Get query string from search parameter if set
->params(["q" => $query])
->get();
?>
<style><?= VV::css("pages/search") ?></style>
<section class="search">
<form method="GET">
<search>
<input name="q" type="text" placeholder="search vlw.se..." value="<?= $query ?>"></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): ?>
<?php // Get response body ?>
<?php $body = $response->json(); ?>
<?php // Do things depending on the response code from API ?>
<?php switch ($response->code): default: ?>
<?php // An unknown error occured ?>
<section class="info">
<p>Something went wrong</p>
</section>
<?php break; ?>
<?php // Query was successful! (Doesn't meant we got search results tho) ?>
<?php case 200: ?>
<?php // Show category sections if search matches were found ?>
<?php if ($body["total_num_results"] > 0): ?>
<?php // Get search results by category ?>
<?php $categories = $body["results"]; ?>
<?php // Results category: work ?>
<?php if (!empty($categories["work"])): ?>
<section class="title work">
<a href="/work" vv="search" vv-call="navigate"><h2>Work</h2></a>
<p><?= count($categories["work"]) ?> search result(s) from my public work</p>
</section>
<section class="results work">
<?php // List all work category search results ?>
<?php foreach ($categories["work"] as $result): ?>
<div class="result">
<h3><?= $result["title"] ?></h3>
<p><?= $result["summary"] ?></p>
<p><?= date(Client::DATE_FORMAT, $result["date_timestamp_created"]) ?></p>
<?php // Result has actions defined ?>
<?php if (!empty($result["actions"])): ?>
<div class="actions">
<?php // List all actions ?>
<?php foreach ($result["actions"] as $action): ?>
<?php if (!$action["external"]): ?>
<a href="<?= $action["href"] ?>" vv="search" vv-call="navigate"><button class="inline <?= $action["class_list"] ?>"><?= $action["display_text"] ?></button></a>
<?php else: ?>
<a href="<?= $action["href"] ?>" target="_blank"><button class="inline <?= $action["class_list"] ?>"><?= $action["display_text"] ?></button></a>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</section>
<?php endif; ?>
<?php // No search matches were found ?>
<?php else: ?>
<section class="info noresults">
<img src="/assets/media/travolta.gif" alt="">
<p>No results for search term "<?= $_GET["q"] ?>"</p>
</section>
<?php endif; ?>
<?php break; ?>
<?php // No access to the search endpoint ?>
<?php case 404: ?>
<section class="info">
<p>Connection to VLW API was successful but lacking permission to search</p>
</section>
<?php break; ?>
<?php // Got a request validation error from the endpoint ?>
<?php case 422: ?>
<?php // Get all validation errors for query and list them ?>
<?php foreach ($body["GET"]["q"] as $error_code => $error_msg): ?>
<?php // Check the error code of the current error ?>
<?php switch ($error_code): default: ?>
<section class="info">
<p>Unknown request validation error</p>
</section>
<?php break; ?>
<?php // Search query string is not long enough ?>
<?php case "VALUE_MIN_ERROR": ?>
<section class="info">
<?= VV::media("icons/search.svg") ?>
<p>type at least <?= $error_msg ?> characters to search!</p>
</section>
<?php break; ?>
<?php endswitch; ?>
<?php endforeach; ?>
<?php break; ?>
<?php endswitch; ?>
<?php // No query search paramter set, show general information ?>
<?php else: ?>
<?= VV::media("icons/search.svg") ?>
<?php endif; ?>
<script><?= VV::js("pages/search") ?></script>

View file

@ -1,162 +0,0 @@
<?php
use VLW\API\Client;
use VLW\API\Endpoints;
// Connect to VLW API
$api = new Client();
// Retreive rows from work endpoint
$response = $api->call(Endpoints::WORK->value)->get();
?>
<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 ($response->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 ($response->json() as $row) {
// Create array for current year if it doesn't exist
if (!array_key_exists($row["date_year"], $rows)) {
$rows[$row["date_year"]] = [];
}
// Create array for current month if it doesn't exist
if (!array_key_exists($row["date_month"], $rows[$row["date_year"]])) {
$rows[$row["date_year"]][$row["date_month"]] = [];
}
// Create array for current day if it doesn't exist
if (!array_key_exists($row["date_day"], $rows[$row["date_year"]][$row["date_month"]])) {
$rows[$row["date_year"]][$row["date_month"]][$row["date_day"]] = [];
}
// Append item to ordered array
$rows[$row["date_year"]][$row["date_month"]][$row["date_day"]][] = $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 // List tags if defined for item ?>
<?php if(!empty($item["tags"])): ?>
<div class="tags">
<?php foreach($item["tags"] as $tag): ?>
<p class="tag <?= $tag["name"] ?>"><?= $tag["name"] ?></p>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php // Show large heading if defined ?>
<?php if (!empty($item["title"])): ?>
<h2><?= $item["title"] ?></h2>
<?php endif; ?>
<?php // Show cover image if defined for item ?>
<?php if (!empty($item["cover_srcset"])): ?>
<picture>
<?php // List all srcset images ?>
<?php foreach ($item["cover_srcset"]["srcset"] as $srcset): ?>
<?php // Skip any media that isn't an image ?>
<?php if ($srcset["type"] !== "IMAGE"): continue; endif; ?>
<srcset src="/assets/media/content/<?= $srcset["id"] ?>.<?= $srcset["extension"] ?>" type="<?= $srcset["mime"] ?>"></srcset>
<?php endforeach; ?>
<?php
// Get the default/fallback image for this srcset
$default = $item["cover_srcset"]["default"];
?>
<img src="/assets/media/content/<?= $default["id"] ?>.<?= $default["extension"] ?>" type="<?= $default["mime"] ?>" loading="lazy"/>
</picture>
<?php endif; ?>
<p><?= $item["summary"] ?></p>
<?php // List actions if defined for item ?>
<?php if(!empty($item["actions"])): ?>
<div class="actions">
<?php foreach($item["actions"] as $action): ?>
<?php
// Bind VV interactions for buttons or add new tab target if external link
$link_attr = !$action["external"] ? "vv='work' vv-call='navigate'" : "target='_blank'";
// Self-reference to a work page with the item id if no href is set
$link_href = $action["href"] === null ? "/work/{$item["id"]}" : $action["href"];
?>
<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>

130
public/about.php Normal file
View file

@ -0,0 +1,130 @@
<?php
use VLW\Database\Models\About\Language;
use const VLW\{
FORGEJO_HREF,
FORGEJO_SI_BYTE_MULTIPLE,
DEFAULT_BUTTON_ICON
};
require_once VV::root("src/Consts.php");
require_once VV::root("src/Database/Models/About/Language.php");
$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) . " " . FORGEJO_SI_BYTE_MULTIPLE[$factor];
}
}
?>
<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.</p>
<p>I used to list the &lt;programming/markup/command/whatever&gt;-languages here that I use the most and order them by guesstimating how much I use each one. But then I thought it would be better to just show you instead using this chart that <a href="https://git.vlw.se/config/vlw.se">automatically pulls the total bytes</a> for each language from my public mirrors and sources on <a href="https://git.vlw.se/vlw">Forgejo</a>.</p>
</section>
<section class="languages">
<stacked-bar-chart>
<?php foreach ($languages::all() as $language): ?>
<a href="<?= FORGEJO_HREF . $language->id ?>" target="_blank"><chart-segment style="--size:<?= $languages->percent($language) ?>%;" data-lang="<?= $language->id ?>" data-bytes="<?= $language->bytes() ?>">
<span data-hover><strong><?= $languages->percent_string($language) ?> <?= $language->id ?></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_HREF . $language->id ?>"><button data-lang="<?= $language->id ?>" class="inline">
<p><?= $languages->percent_string($language) ?></p>
<p class="lang"><?= $language->id ?></p>
<p><?= $languages->bytes_si_string($language) ?></p>
<?= VV::embed(DEFAULT_BUTTON_ICON) ?>
</button></a>
<?php endforeach; ?>
</languages-list>
<stacked-bar-chart>
<?php foreach ($languages::all() as $language): ?>
<a href="<?= FORGEJO_HREF . $language->id ?>" target="_blank"><chart-segment style="--size:<?= $languages->percent($language) ?>%;" data-lang="<?= $language->id ?>" data-bytes="<?= $language->bytes() ?>">
<span data-hover><strong><?= $languages->percent_string($language) ?> <?= $language->id ?></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 are <a href="https://codeberg.org/vlw/vlw.se">100% free and open source software</a>. The website is designed and built by me from the ground up using my <a href="https://vegvisir.vlw.se">web</a> and <a href="https://reflect.vlw.se">API</a> frameworks as the foundation. You will find no cookies or trackers 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://srv.vlw.se"><i>See detailed information about all servers/services on this domain</i></a></p>
</section>
<section class="about">
<h2>Personal</h2>
<p>Coffee, of course.. and..</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 will spend a disproportionate to real-world-personal-use amount of time reading about that stuff too.</p>
<p>Another silent passion of mine that comes out every few years is building computers and fiddling with weird networking stuff.</p>
<p>And then of course I don't mind some occational gaming, and watching movies and TV-series.</p>
</section>
<section class="about">
<h2>Projects</h2>
<p>Here are some projects I'm working on right now:</p>
<p>* <a href="https://vegvisir.vlw.se">Vegvisir</a>: A web navigation framework for PHP.</p>
<p>* <a href="https://reflect.vlw.se">Reflect</a>: A REST API framework for PHP developers.</p>
<p>There is more stuff on my <a href="work">works page</a> and even more stuff on <a href="https://codeberg.org/vlw">my Codeberg profile</a>.</p>
<p><a href="https://git.vlw.se/vlw"><i>and even EVEN more stuff on my Forgejo</i></a></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> for their increasing number of injustices againts its users, last betrayal being GitHub's for-profit "Copilot" product which was illegaly trained on copylefted software on its platform.</p>
<p>I signed up and started using GitHub before I became aware of how opressive to to its users and deceptive their business model is. I wasn't aware of the situation.</p>
<p>While I am a bit skeptical to do this in case history repeats itself; [most of] <a href="https://codeberg.org/vlw">my work is now on Codeberg</a> instead. Unfortunately some things like old pull-requests, issues, and branch archives can not be migrated completely.</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>ISO&nbsp;8601</p>
</div>
<script type="module"><?= VV::js("public/assets/js/pages/about") ?></script>

View file

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

@ -0,0 +1,290 @@
/* # 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;
}
section.about p i:not(:hover) {
opacity: .3;
}
/* ## Languages */
section.languages {
margin: calc(var(--padding) / 1.5) 0;
}
section.languages stacked-bar-chart {
gap: 3px;
width: 100%;
display: flex;
border-radius: 100px;
height: var(--padding);
background-color: rgba(255, 255, 255, 0);
}
section.languages stacked-bar-chart:last-of-type {
flex-direction: row-reverse;
}
section.languages stacked-bar-chart:hover chart-segment {
opacity: .5;
}
section.languages stacked-bar-chart chart-segment {
--border-corner-radius: 100px;
transition: 150ms opacity;
width: var(--size, 0%);
min-width: 3%;
height: 100%;
color: white;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, .1);
border-radius: 2px;
}
section.languages stacked-bar-chart a:nth-child(odd) chart-segment {
background-color: rgba(255, 255, 255, .3);
}
/* ### Round corners */
section.languages stacked-bar-chart a:first-child chart-segment {
border-top-right-radius: var(--padding);
border-bottom-right-radius: var(--padding);
border-top-left-radius: var(--border-corner-radius);
border-bottom-left-radius: var(--border-corner-radius);
}
section.languages stacked-bar-chart a:last-child chart-segment {
border-top-left-radius: var(--padding);
border-bottom-left-radius: var(--padding);
border-top-right-radius: var(--border-corner-radius);
border-bottom-right-radius: var(--border-corner-radius);
}
section.languages stacked-bar-chart:last-of-type a:first-child chart-segment {
border-top-left-radius: var(--padding);
border-bottom-left-radius: var(--padding);
border-top-right-radius: var(--border-corner-radius);
border-bottom-right-radius: var(--border-corner-radius);
}
section.languages stacked-bar-chart:last-of-type a:last-child chart-segment {
border-top-right-radius: var(--padding);
border-bottom-right-radius: var(--padding);
border-top-left-radius: var(--border-corner-radius);
border-bottom-left-radius: var(--border-corner-radius);
}
/* ### Texts */
section.languages stacked-bar-chart chart-segment p {
text-align: center;
color: inherit;
overflow: hidden;
white-space: nowrap;
pointer-events: none;
text-overflow: ellipsis;
padding: 0 3px;
}
section.languages stacked-bar-chart chart-segment[style="--size:0%;"] p span {
display: none;
}
section.languages stacked-bar-chart chart-segment[style="--size:0%;"] p::before {
content: "<1%";
opacity: .5;
}
section.languages stacked-bar-chart chart-segment [data-hover] {
display: none;
}
/* ### Colors */
section.languages stacked-bar-chart a chart-segment[data-lang="Go"] { background-color: var(--color-go); }
section.languages stacked-bar-chart a chart-segment[data-lang="PHP"] { background-color: var(--color-php); }
section.languages stacked-bar-chart a chart-segment[data-lang="CSS"] { background-color: var(--color-css); }
section.languages stacked-bar-chart a chart-segment[data-lang="HTML"] { background-color: var(--color-html); }
section.languages stacked-bar-chart a chart-segment[data-lang="Python"] { background-color: var(--color-python); }
section.languages stacked-bar-chart a chart-segment[data-lang="TypeScript"] { background-color: var(--color-typescript); }
section.languages stacked-bar-chart a chart-segment[data-lang="Shell"] { background-color: var(--color-shell); color: black; }
section.languages stacked-bar-chart a chart-segment[data-lang="JavaScript"] { background-color: var(--color-javascript); color: black; }
/* ### Legend */
section.languages languages-list {
gap: calc(var(--padding) / 2);
display: grid;
grid-template-columns: repeat(3, 1fr);
margin: var(--padding) 0;
}
section.languages languages-list language-item {
gap: 10px;
display: flex;
border-radius: 8px;
align-items: center;
fill: var(--color-php);
padding: calc(var(--padding) / 1.5);
border: solid 1px rgba(255, 255, 255, .1);
background: linear-gradient(139deg, rgba(0, 0, 0, 0) 0%, rgba(79, 93, 144, .2) 100%);
}
section.languages languages-list language-item 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

@ -0,0 +1,45 @@
/* # Overrides */
:root {
--primer-color-accent: 148, 255, 21;
--color-accent: rgb(var(--primer-color-accent));
}
vv-shell {
display: flex;
flex-direction: column;
gap: var(--padding);
}
/* # Content */
/* ## Title */
section.title {
display: flex;
flex-direction: column;
gap: 5px;
}
/* ## Actions */
section.actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--padding);
}
/* # Size quries */
@media (max-width: 800px) {
section.actions {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
section.actions {
display: flex;
flex-direction: column;
}
}

View file

@ -0,0 +1,290 @@
/* # Overrides */
:root {
--primer-color-accent: 148, 255, 21;
--color-accent: rgb(var(--primer-color-accent));
}
vv-shell {
display: flex;
flex-direction: column;
gap: var(--padding);
}
/* # Content */
/* ## Title */
section.title {
display: flex;
flex-direction: column;
gap: 5px;
padding: calc(var(--padding) * 1.5);
background-color: rgba(var(--primer-color-accent), .1);
border-radius: 6px;
}
section.title > div {
margin-top: calc(var(--padding) / 2);
display: flex;
gap: var(--padding);
}
/* ## Heading */
section.heading h1::before,
section.heading h1::after {
opacity: .4;
}
section.heading h1::before {
content: "“";
}
section.heading h1::after {
content: "”";
}
/* ## Config */
section.config {
position: relative;
display: grid;
grid-template-columns: 300px 1fr;
gap: calc(var(--padding) * 2);
}
section.config:nth-child(4n+2) {
grid-template-columns: 1fr 300px;
}
section.config:nth-child(4n+2) > svg {
order: 1;
}
/* ### PC */
section.config > svg {
position: sticky;
top: calc(var(--running-size) + var(--padding));
width: 100%;
}
section.config > svg :is(rect, path) {
transition: 300ms;
stroke: white;
}
section.config > svg.active :is(rect, path),
section.config > svg:hover :is(rect, path) {
opacity: .4;
}
section.config > svg g.active rect,
section.config > svg g.active path,
section.config > svg g:not(.group):hover rect,
section.config > svg g:not(.group):hover path {
opacity: 1;
stroke: var(--color-accent);
}
section.config > svg g.active rect,
section.config > svg g:not(.group):hover rect {
filter: drop-shadow(0 0 10px rgba(var(--primer-color-accent), .4));
}
/* #### Case */
section.config g.case:not(:hover, .active) :is(rect, path) {
opacity: .2;
}
section.config > svg g.active path,
section.config > svg g:not(.group):hover path {
fill: var(--color-accent);
}
/* #### Motherboard */
section.config > svg .mb .chips {
opacity: 0;
}
/* #### Active states */
section.config > svg g:not(.group) {
display: none;
}
section.config[data-dram="1"] > svg g.drams g.dram:nth-child(1),
section.config[data-dram="2"] > svg g.drams g.dram:nth-child(3n+1),
section.config[data-dram="3"] > svg g.drams g.dram:nth-child(-n+3),
section.config[data-dram="4"] > svg g.drams g.dram,
section.config[data-drives-mdottwo="1"] > svg g.mdottwo g.drive:nth-child(1),
section.config[data-drives-mdottwo="2"] > svg g.mdottwo g.drive:nth-child(-n+2),
section.config[data-drives-mdottwo="3"] > svg g.mdottwo g.drive:nth-child(-n+3),
section.config[data-drives-twodotfive="1"] > svg g.twodotfive g.drive:nth-child(1),
section.config[data-drives-twodotfive="2"] > svg g.twodotfive g.drive:nth-child(-n+2),
section.config[data-drives-twodotfive="3"] > svg g.twodotfive g.drive:nth-child(-n+3),
section.config[data-drives-threedotfive="1"] > svg g.threedotfive g.drive:nth-child(1),
section.config[data-drives-threedotfive="2"] > svg g.threedotfive g.drive:nth-child(-n+2),
section.config[data-drives-threedotfive="3"] > svg g.threedotfive g.drive:nth-child(-n+3),
section.config[data-mb="1"] > svg g.mb,
section.config[data-psu="1"] > svg g.psu,
section.config[data-gpu="1"] > svg g.gpu,
section.config[data-cpu="1"] > svg g.cpu,
section.config[data-case="1"] > svg g.case {
display: initial;
}
/* ## Specs */
section.config .specs {
position: relative;
display: flex;
flex-direction: column;
gap: calc(var(--padding) / 2);
border-radius: 6px;
}
section.config .specs :is(.spec, .group) {
--border-width: 4px;
transition: 300ms background-color, 300ms border-color, 500ms box-shadow;
padding: calc(var(--padding) - var(--border-width));
border: solid var(--border-width) transparent;
background-color: rgba(255, 255, 255, .03);
border-radius: 6px;
cursor: pointer;
}
section.config .specs :is(.spec, .group) * {
pointer-events: none;
}
/* ### Active state */
section.config .specs.active {
background-color: rgba(255, 255, 255, .03);
}
section.config .specs.active :is(.group, .spec:not(.active)) {
display: none;
}
/* ### Spec */
section.config .specs .spec {
display: flex;
flex-direction: column;
}
section.config .specs .spec:hover {
border-color: rgba(255, 255, 255, .05);
background-color: rgba(255, 255, 255, .1);
box-shadow: 0 0 30px 10px rgba(255, 255, 255, .05);
}
section.config .specs .spec.active {
border-color: var(--color-accent);
background-color: rgba(var(--primer-color-accent), .1);
box-shadow: 0 0 30px 10px rgba(var(--primer-color-accent), .05);
cursor: initial;
}
section.config .specs.active .spec.active {
position: sticky;
top: calc(var(--running-size) + var(--padding));
}
section.config .specs .spec h3 {
color: rgba(255, 255, 255, .3);
}
section.config .specs .spec span {
color: white;
}
section.config .specs .spec > div {
display: none;
grid-template-columns: repeat(2, 1fr);
gap: calc(var(--padding) / 2);
margin-top: var(--padding);
}
section.config .specs .spec.active > div {
display: grid;
}
section.config .specs .spec > div label {
color: var(--color-accent);
}
section.config .specs .spec > svg {
display: none;
height: calc(var(--padding) / 2);
margin: 0 auto;
margin-top: calc(var(--padding) / 2);
fill: var(--color-accent);
}
/* ### Group */
section.config .specs .group {
display: flex;
justify-content: space-between;
align-items: center;
}
section.config .specs .group.active {
background-color: rgba(255, 255, 255, .2);
}
section.config .specs .group:hover {
background-color: rgba(255, 255, 255, .1);
}
section.config .specs .group.active:hover {
background-color: rgba(255, 255, 255, .3);
}
section.config .specs .group > svg {
transition: 300ms transform;
fill: var(--color-accent);
height: 10px;
}
section.config .specs .group.active > svg {
transform: rotateX(180deg);
}
/* #### Collection */
section.config .specs .collection {
display: none;
}
section.config .specs .group.active + .collection {
display: contents;
}
/* # Size quries */
@media (max-width: 700px) {
section.title > div {
flex-direction: column;
}
section.config,
section.config:nth-child(4n+2) {
grid-template-columns: 1fr;
}
section.config > svg {
display: none;
}
}

View file

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

View file

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

View file

@ -1,21 +1,21 @@
/* # Overrides */
body[vv-page="/"]::before {
body[vv-top-page="/"]::before {
opacity: 0;
}
/* # Main styles */
/* # vv-shell styles */
/* ## Picture */
main {
vv-shell {
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: column-reverse;
}
main img {
vv-shell img {
margin: auto;
width: 25vh;
pointer-events: none;
@ -152,25 +152,33 @@ splash::after {
.menu menu li:hover {
opacity: 1;
font-weight: 100;
text-shadow: 0 0 10px rgba(var(--primer-color-accent), .4);
}
button.email:hover {
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 */
@media (min-width: 900px) {
main {
vv-shell {
display: grid;
grid-template-columns: repeat(2, 1fr);
justify-items: center;
align-items: center;
}
main img {
vv-shell img {
width: 35vh;
}
}

View file

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

@ -0,0 +1,154 @@
/* # 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);
}
/* ### Languages */
/* ### Actions */
section.featured featured-item .actions {
gap: 5px;
display: flex;
padding-top: var(--padding);
margin-top: auto;
}
/* # Size queries */
@media (min-width: 600px) {
section.hero {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 900px) {
section.featured {
grid-template-columns: repeat(3, 1fr);
}
}

View file

@ -3,9 +3,10 @@
:root {
--primer-color-accent: 3, 255, 219;
--color-accent: rgb(var(--primer-color-accent));
--hue-accent: 90deg;
}
main {
vv-shell {
display: flex;
flex-direction: column;
gap: var(--padding);
@ -27,7 +28,8 @@ section.git {
border-radius: 6px;
}
section.git svg {
section.git > svg {
fill: white;
width: 60px;
}
@ -119,13 +121,10 @@ section.timeline .items .item img {
}
section.timeline .items .item .actions {
display: flex;
align-items: baseline;
margin-top: 7px;
}
/* ## Note */
section.note {
text-align: center;
gap: var(--padding);
}
/* # Size queries */
@ -136,23 +135,6 @@ section.note {
}
}
@media (min-width: 900px) {
section.git {
display: grid;
grid-template-columns: 70px 1fr 400px;
align-items: center;
gap: calc(var(--padding) * 1.5);
}
section.git svg {
width: 100%;
}
section.git .buttons {
justify-content: end;
}
}
@media (max-width: 500px) {
section.timeline {
padding: unset;
@ -187,7 +169,28 @@ section.note {
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 {
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;
}
}

View file

@ -0,0 +1,16 @@
/* # 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;
}

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

@ -11,7 +11,8 @@
/* # Cornerstones */
* {
margin: 0;
margin: 0;
fill: inherit;
box-sizing: border-box;
font-family: "Roboto Mono", sans-serif;
color: inherit;
@ -53,14 +54,10 @@ body::before {
}
/* "enable" the corner glow effect on initial load when a page has been fully loaded */
body[vv-page]::before {
body[vv-top-page]::before {
opacity: 1;
}
body.search-dialog-open {
overflow: hidden;
}
a {
display: contents;
color: inherit;
@ -92,6 +89,32 @@ h3 {
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 */
button {
@ -105,35 +128,44 @@ button {
/* ### Inline */
button.inline {
padding: calc(var(--padding) / 2) var(--padding);
color: white;
border: solid 2px white;
border-radius: 6px;
gap: 10px;
display: flex;
border-radius: 7px;
align-items: center;
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 {
fill: black;
color: black;
border: solid 2px rgba(var(--primer-color-accent), 1);
border-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 {
@ -169,7 +201,9 @@ header > * {
/* enable 3d flip animation */
@media not (prefers-reduced-motion: reduce) {
header > * {
transition: 600ms transform, 300ms background-color;
--transform-duration: 600ms;
transition: var(--transform-duration) transform, 300ms background-color;
}
}
@ -179,6 +213,10 @@ header nav {
padding: var(--padding);
}
header .logo {
fill: none;
}
header .logo path.stroke {
fill: var(--color-accent);
}
@ -252,37 +290,29 @@ header searchbox input {
header.searchboxActive > * {
transform: rotateX(-180deg);
pointer-events: none;
}
header.searchboxActive searchbox {
transform: rotateX(0);
pointer-events: all;
}
/* ## Main */
/* ## vv-shell */
main {
vv-shell {
position: relative;
padding: calc(var(--padding) * 1.5);
width: 100%;
max-width: 1000px;
}
main > * {
transition: 100ms opacity;
opacity: 1;
}
main.loading > * {
opacity: 0;
}
/* ## Search results */
search-results {
transition: 500ms opacity, 300ms transform;
position: fixed;
display: flex;
flex-direction: column;
gap: var(--padding);
top: var(--running-size);
right: 0;
width: 100%;
@ -294,12 +324,7 @@ search-results {
transform: scale(.99);
transform-origin: 100% 0;
overflow-y: scroll;
}
search-results:not([vv-page]) {
display: grid;
align-items: center;
justify-items: center;
z-index: 50;
}
header.searchboxActive ~ search-results {
@ -308,6 +333,10 @@ header.searchboxActive ~ search-results {
transform: scale(1);
}
search-results section.search {
display: none;
}
/* ### "Start typing" prompt */
search-results .info {
@ -315,11 +344,11 @@ search-results .info {
align-items: center;
flex-direction: column;
margin: auto;
gap: 3svh;
gap: var(--padding);
}
search-results .info :is(svg, img) {
width: 128px;
width: 60px;
fill: var(--color-accent);
}
@ -335,7 +364,8 @@ search-results .info :is(svg, img) {
/* # Components */
button.inline {
transition: 200ms background-color, 200ms border-color, 200ms color;
transition-duration: 300ms;
transition-property: background-color, border-color, box-shadow, color, fill;
}
button:hover {
@ -343,8 +373,19 @@ search-results .info :is(svg, img) {
background-color: rgba(255, 255, 255, .1);
}
button.solid:hover {
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 {
border-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);

Binary file not shown.

View file

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

View file

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

View file

@ -0,0 +1,72 @@
import { Elevent } from "/assets/js/modules/npm/Elevent.mjs";
new Elevent("click", document.querySelectorAll(".group"), (event) => {
// Collapse self if already active and current target
if (event.target.classList.contains("active")) {
return event.target.classList.remove("active");
}
// Collapse all and open current target
[...event.target.closest(".specs").querySelectorAll(".group")].forEach(element => element.classList.remove("active"));
event.target.classList.add("active");
});
new Elevent("click", document.querySelectorAll(".spec"), (event) => {
event.target.classList.add("active");
event.target.addEventListener("mouseleave", () => event.target.classList.remove("active"));
});
// Bind hover listeners for components in the SVGs
[...document.querySelectorAll("section.config g:not(.group)")].forEach(element => {
element.addEventListener("mouseenter", () => {
// Find an element in the most adjacent speclist and highlighit it
const target = element.closest("section.config").querySelector(`.spec[data-target="${element.dataset.target}"]`);
// Get closest specs wrapper element
const specsElement = target.closest(".specs");
// Spec item is part of a collection, we need to expand the group if that is the case
const collectionElement = target.closest(".collection") ?? null;
// Don't close the group after hove ends
let closeGroupOnLeave = false;
// Set fixed height on .specs wrapper to prevent glitchy page jumping when scrolled
specsElement.style.setProperty("height", `${specsElement.offsetHeight}px`);
target.classList.add("active");
specsElement.classList.add("active");
if (collectionElement) {
// Close the group on leave if the group wasn't active before hovering
closeGroupOnLeave = !collectionElement.previousElementSibling.classList.contains("active");
collectionElement.previousElementSibling.classList.add("active");
}
// Bind hover leave listener
element.addEventListener("mouseleave", () => {
// Reset to initial states
target.classList.remove("active");
specsElement.classList.remove("active");
specsElement.style.removeProperty("height");
// Group was closed prior to hover, let's close it on hover leave
if (closeGroupOnLeave) {
collectionElement.previousElementSibling.classList.remove("active");
}
}, { once: true });
});
});
// Bind event listeners for components in the spec lists
[...document.querySelectorAll("section.config .spec:not(.group)")].forEach(element => {
element.addEventListener("mouseenter", () => {
const svgTarget = element.closest("section.config").querySelector(`svg`);
const target = svgTarget.querySelector(`svg g[data-target="${element.dataset.target}"]`);
svgTarget.classList.add("active");
target.classList.add("active");
element.addEventListener("mouseleave", () => {
svgTarget.classList.remove("active");
target.classList.remove("active");
}, { once: true });
});
});

View file

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

View file

View file

@ -0,0 +1,108 @@
import { Elevent } from "/assets/js/modules/npm/Elevent.mjs";
// 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);
}
new Elevent("click", document.querySelector(".email"), 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.Navigation.shellElement.addEventListener(vv.Navigation.EVENTS.STARTED, () => updateColor(), { once: true });
}
}

View file

View file

56
public/assets/js/shell.js Normal file
View file

@ -0,0 +1,56 @@
import { Elevent } from "/assets/js/modules/npm/Elevent.mjs";
const CLASSNAME_SEARCHBOX_ACTIVE = "searchboxActive";
// Set global Vegvisir naviation delay for page transition effect
globalThis.vegvisir.globalNavigationDelayMs = 100;
// Handle search box open/close buttons
{
// Open search box
new Elevent("click", document.querySelector(".searchbox-open"), () => {
document.querySelector("header").classList.add(CLASSNAME_SEARCHBOX_ACTIVE);
// Select searchbox inner input element
document.querySelector("searchbox input").focus();
});
// Close searchbox
new Elevent("click", document.querySelector(".searchbox-close"), () => {
// 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);
});
}
// Root shell navigation event handlers
{
// On all top shell navigations
new Elevent(vv.Navigation.EVENTS.STARTED, vv.Navigation.rootShellElement, () => {
// Close searchbox on top shell navigations
document.querySelector("header").classList.remove(CLASSNAME_SEARCHBOX_ACTIVE);
});
}
// 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?query=${event.target.value}`).navigate(searchResultsElement);
}, 100);
});
}
// Scroll page to top on navigation
document.addEventListener(vegvisir.Navigation.EVENTS.FINISHED, () => window.scrollTo({ top: 0 }));

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