Compare commits

..

40 commits

Author SHA1 Message Date
474ea9b4ae fix(api): order work by descending date_created 2024-10-13 15:02:37 +02: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
412a457bee
fix: error ouput from message submit endpoint for contact page (#27) 2024-05-08 15:10:03 +00:00
982acc6a40
doc(dependency): bump reflect@2.7.0 compatability (#28) 2024-05-08 15:08:38 +00:00
3fd7ce6bf0
feat: add new search UI (#24)
* feat: remove old search features

* feat: add new page search ui
2024-05-06 17:17:29 +00:00
e1e3a4a68a
content: text changes to about and contact pages (#25) 2024-05-06 17:17:12 +00:00
87cf63c884
fix: remove unused searchbox queryselect from index js (#23) 2024-05-06 08:02:28 +00:00
3154afab3c
fix: interests explosion effects clamped to screen size (#22) 2024-05-06 08:02:18 +00:00
f963a8993d
feat: add page glow effect (#21) 2024-05-06 08:02:07 +00:00
3f944e1a33
fix(doc): update confirmed framework versions 2024-05-01 21:58:27 +00:00
94d20d51ae
fix(dependencies): bump vegvisir to 2.4.3 and reflect to 2.6.3 (#20) 2024-05-01 21:57:10 +00:00
a7d2730aac
feat(doc): add 'supported framework versions' table to README (#17)
* feat(doc): add supported framework version table

* fix(doc): reflect links
2024-04-17 16:12:45 +00:00
8dccd20d39
fix(content): remove duplicate about section (#15) 2024-04-09 15:09:25 +00:00
a82c853153
fix: version number check from git tag (#10) 2024-04-07 13:44:00 +00:00
6ef024db6f
fix: safari searchbox header render issue (#13) 2024-04-07 13:43:51 +00:00
a8a1c5791f
fix: another unicode arrow for external links (#14) 2024-04-07 13:43:41 +00:00
397484c5b4
fix: absolute path for b64 images on 404 page (#8) 2024-04-06 22:49:10 +00:00
efa84d9e6c
fix: add GitHub links to frameworks on about page (#6) 2024-04-06 22:48:59 +00:00
8c815956e2
fix(content): remove philosophy section (#7) 2024-04-06 22:45:51 +00:00
150559b075
feat: major post-launch fixes and bump of reflect/client to 3.0.6 (#9) 2024-04-06 22:45:33 +00:00
126 changed files with 4406 additions and 2925 deletions

View file

@ -1,4 +1,7 @@
[api] [api]
base_url = "https://api.vlw.one/" base_url = "https://api.vlw.one/"
api_key = "" api_key = ""
verify_peer = 0 verify_peer = 0
[time]
date_time_zone = "Europe/Stockholm"

8
.gitignore vendored
View file

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

View file

@ -1,27 +1,38 @@
# vlw.se # 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 has been written from the ground up by me. This website is built on top of my [Vegvisir web framework](https://vegvisir.vlw.se) and my [Reflect API framework](https://reflect.vlw.se).
# Installation # Installation
If you for whatever reason want to get this website up and running for yourself this is how that is done. If you for whatever reason want to get this website up and running for yourself this is how that is done.
This website is built for PHP 8.0+ and MariaDB 14+ (for the API database). ## This website requires the following prerequisites
- [PHP 8.0+](https://www.php.net/)
- [MariaDB 14+](https://mariadb.org/)
- [The NPM package manager](https://www.npmjs.com/)
- [The Reflect API framework](https://reflect.vlw.se)
- [The Vegvisir web framework](https://vegvisir.vlw.se)
- [The composer package manager](https://getcomposer.org/)
**Confimed supported framework versions:**
Vegvisir|Reflect
--|--
✅ [`3.0.1`](https://codeberg.org/vegvisir/vegvisir/releases/tag/3.0.1)|✅ [`2.7.2`](https://codeberg.org/reflect/reflect/releases/tag/2.7.2)
## Website (Vegvisir) ## Website (Vegvisir)
1. **Download this repo** 1. **Download this repo**
Git clone or download this repo to any local folder Git clone or download this repo to any local folder
``` ```
git clone https://github.com/VictorWesterlund/vlw.se git clone https://codeberg.org/vlw/vlw.se
``` ```
2. **Download and install Vegvisir** 2. **Download and install Vegvisir**
Follow the installation instructions for [Vegvisir](https://github.com/victorwesterlund/vegvisir) and point the `site_path` variable to the local vlw.se folder. Follow the installation instructions for [Vegvisir](https://vegvisir.vlw.se/docs/installation) and point the `root_path` variable to your local vlw.se folder.
3. **Install dependencies** 3. **Run the install script**
Install dependencies with composer. This bash script will install dependencies and make npm modules public.
``` ```
composer install --optimize-autoloader ./install.sh
``` ```
Et voila! You probably want to install the API-side too but the website itself should now be accessible from your configured Vegvisir host. Et voila! You probably want to install the API-side too but the website itself should now be accessible from your configured Vegvisir host.
@ -35,16 +46,16 @@ The API (and database) is where most content is stored and served from on this w
Otherwise... Git clone or download this repo to any local folder Otherwise... Git clone or download this repo to any local folder
``` ```
git clone https://github.com/VictorWesterlund/vlw.se git clone https://codeberg.org/vlw/vlw.se
``` ```
2. **Download and install Reflect** 2. **Download and install Reflect**
Follow the installation instructions for [Reflect](https://github.com/victorwesterlund/vegvisir) and point the `endpoints` variable to the `/api` subdirectory in the local vlw.se folder. Follow the installation instructions for [Reflect](https://reflect.vlw.se/docs/installation) and point the `endpoints` variable to the `/api` subdirectory in the local vlw.se folder.
3. **Install dependencies** 3. **Install dependencies**
Install dependencies with composer. `cd` into the api folder and install dependencies with composer.
``` ```
composer install --optimize-autoloader composer install --optimize-autoloader
``` ```
@ -57,9 +68,6 @@ The API (and database) is where most content is stored and served from on this w
Make a copy of `/api/.env.example.ini` and change the `[vlwdb]` variables with your MariaDB credentials. 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** 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. 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.

View file

@ -1,11 +1,8 @@
[vlwdb] [connect]
mariadb_host = "" host = ""
mariadb_user = "" user = ""
mariadb_pass = "" pass = ""
mariadb_db = ""
[github] [databases]
api_key = "" vlw = ""
# Use-Agent string sent to GitHub API battlestation = ""
# They recommend setting it to your GitHub username or app name
user_agent = ""

View file

@ -1,14 +1,8 @@
{ {
"require": { "require": {
"local/api.endpoints": "1.0.0-dev",
"reflect/plugin-rules": "^1.5", "reflect/plugin-rules": "^1.5",
"victorwesterlund/xenum": "^1.1" "victorwesterlund/xenum": "dev-master",
"vlw/mysql": "dev-master"
}, },
"minimum-stability": "dev", "minimum-stability": "dev"
"repositories": [
{
"type": "path",
"url": "src/packages/Endpoints"
}
]
} }

61
api/composer.lock generated
View file

@ -4,33 +4,8 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "9da96ba90ef20d885034442b30dce0a3", "content-hash": "f3f2b3cb3bd789eee6af4a93f4a6e0f9",
"packages": [ "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", "name": "reflect/plugin-rules",
"version": "1.5.0", "version": "1.5.0",
@ -70,7 +45,7 @@
}, },
{ {
"name": "victorwesterlund/xenum", "name": "victorwesterlund/xenum",
"version": "1.1.1", "version": "dev-master",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/VictorWesterlund/php-xenum.git", "url": "https://github.com/VictorWesterlund/php-xenum.git",
@ -82,6 +57,7 @@
"reference": "8972f06f42abd1f382807a67e937d5564bb89699", "reference": "8972f06f42abd1f382807a67e937d5564bb89699",
"shasum": "" "shasum": ""
}, },
"default-branch": true,
"type": "library", "type": "library",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@ -104,13 +80,42 @@
"source": "https://github.com/VictorWesterlund/php-xenum/tree/1.1.1" "source": "https://github.com/VictorWesterlund/php-xenum/tree/1.1.1"
}, },
"time": "2023-11-20T10:10:39+00:00" "time": "2023-11-20T10:10:39+00:00"
},
{
"name": "vlw/mysql",
"version": "dev-master",
"source": {
"type": "git",
"url": "https://codeberg.org/vlw/php-mysql",
"reference": "619f43b3bfab9eb034dca3e54c7466055240c861"
},
"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": "2024-09-25T13:28:15+00:00"
} }
], ],
"packages-dev": [], "packages-dev": [],
"aliases": [], "aliases": [],
"minimum-stability": "dev", "minimum-stability": "dev",
"stability-flags": { "stability-flags": {
"local/api.endpoints": 20 "victorwesterlund/xenum": 20,
"vlw/mysql": 20
}, },
"prefer-stable": false, "prefer-stable": false,
"prefer-lowest": false, "prefer-lowest": false,

View file

@ -0,0 +1,63 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Databases\VLWdb\{
VLWdb,
Databases
};
use VLW\API\Databases\VLWdb\Models\Battlestation\Config\ConfigModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Battlestation/Config/Config.php");
class GET_Battlestation extends VLWdb {
protected Ruleset $ruleset;
private array $query;
private array $results = [];
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(ConfigModel::REF_MB_ID->value))
->type(Type::STRING)
->min(parent::UUID_LENGTH)
->max(parent::UUID_LENGTH),
(new Rules(ConfigModel::FRIENDLY_NAME->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
]);
parent::__construct(Databases::BATTLESTATION, $this->ruleset);
// Use a copy of search parameters
$this->query = $_GET;
}
private function get_config(): array {
return $this->results = $this->db
->for(ConfigModel::TABLE)
->where($this->query)
->order([ConfigModel::DATE_BUILT->value => "DESC"])
->select(ConfigModel::values())
->fetch_all(MYSQLI_ASSOC);
}
public function main(): Response {
// Set properties as "searchable"
parent::make_wildcard_search(ConfigModel::FRIENDLY_NAME->value, $this->query);
$this->get_config();
// Return 404 Not Found if response array is empty
return new Response($this->results, $this->results ? 200 : 404);
}
}

View file

@ -0,0 +1,103 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Databases\VLWdb\{
VLWdb,
Databases
};
use VLW\API\Databases\VLWdb\Models\Battlestation\ChassisModel;
use VLW\API\Databases\VLWdb\Models\Battlestation\Config\ChassisMbModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Battlestation/Chassis.php");
require_once Path::root("src/databases/models/Battlestation/Config/ChassisMb.php");
class GET_BattlestationChassis extends VLWdb {
private const REL_MOTHERBOARDS = "motherboards";
protected Ruleset $ruleset;
private array $query;
private array $results = [];
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(ChassisModel::ID->value))
->type(Type::STRING)
->min(parent::UUID_LENGTH)
->max(parent::UUID_LENGTH),
(new Rules(ChassisModel::VENDOR_NAME->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(ChassisModel::VENDOR_MODEL->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(ChassisModel::STORAGE_TWOINCHFIVE->value))
->type(Type::NUMBER)
->type(Type::NULL)
->min(0)
->max(parent::MYSQL_TINYINT_MAX_LENGTH),
(new Rules(ChassisModel::STORAGE_THREEINCHFIVE->value))
->type(Type::NUMBER)
->type(Type::NULL)
->min(0)
->max(parent::MYSQL_TINYINT_MAX_LENGTH),
(new Rules(ChassisModel::IS_RETIRED->value))
->type(Type::BOOLEAN)
]);
parent::__construct(Databases::BATTLESTATION, $this->ruleset);
// Use a copy of search parameters
$this->query = $_GET;
}
private function get_motherboards(): void {
foreach ($this->results as &$result) {
// Get motherboard id from relationship by chassis id
$result[self::REL_MOTHERBOARDS] = $this->db
->for(ChassisMbModel::TABLE)
->where([ChassisMbModel::REF_CHASSIS_ID->value => $result[ChassisModel::ID->value]])
->select(ChassisMbModel::values())
->fetch_all(MYSQLI_ASSOC);
}
}
private function get_chassis(): array {
return $this->results = $this->db
->for(ChassisModel::TABLE)
->where($this->query)
->order([ChassisModel::DATE_AQUIRED->value => "DESC"])
->select(ChassisModel::values())
->fetch_all(MYSQLI_ASSOC);
}
public function main(): Response {
// Set properties as "searchable"
parent::make_wildcard_search(ChassisModel::VENDOR_NAME->value, $this->query);
parent::make_wildcard_search(ChassisModel::VENDOR_MODEL->value, $this->query);
// Get hardware
$this->get_chassis();
// Resolve hardware relationships
$this->get_motherboards();
// Return 404 Not Found if response array is empty
return new Response($this->results, $this->results ? 200 : 404);
}
}

View file

@ -0,0 +1,108 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Databases\VLWdb\{
VLWdb,
Databases
};
use VLW\API\Databases\VLWdb\Models\Battlestation\{
CoolerModel
};
use VLW\API\Databases\VLWdb\Models\Battlestation\Config\MbCpuCoolerModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Battlestation/Coolers.php");
require_once Path::root("src/databases/models/Battlestation/Config/MbCpuCooler.php");
class GET_BattlestationCoolers extends VLWdb {
private const REL_MOTHERBOARDS = "motherboards";
protected Ruleset $ruleset;
private array $query;
private array $results = [];
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(CoolerModel::ID->value))
->type(Type::STRING)
->min(parent::UUID_LENGTH)
->max(parent::UUID_LENGTH),
(new Rules(CoolerModel::TYPE_LIQUID->value))
->type(Type::BOOLEAN),
(new Rules(CoolerModel::SIZE_FAN->value))
->type(Type::NULL)
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_INTR_MAX_LENGTH),
(new Rules(CoolerModel::SIZE_RADIATOR->value))
->type(Type::NULL)
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_INTR_MAX_LENGTH),
(new Rules(CoolerModel::VENDOR_NAME->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(CoolerModel::VENDOR_MODEL->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(CoolerModel::IS_RETIRED->value))
->type(Type::BOOLEAN)
]);
parent::__construct(Databases::BATTLESTATION, $this->ruleset);
// Use a copy of search parameters
$this->query = $_GET;
}
private function get_motherboards(): void {
foreach ($this->results as &$result) {
// Get motherboard id from relationship by chassis id
$result[self::REL_MOTHERBOARDS] = $this->db
->for(MbCoolerModel::TABLE)
->where([MbCoolerModel::REF_COOLER_ID->value => $result[CoolerModel::ID->value]])
->select(MbCoolerModel::values())
->fetch_all(MYSQLI_ASSOC);
}
}
private function get_coolers(): array {
return $this->results = $this->db
->for(CoolerModel::TABLE)
->where($this->query)
->order([CoolerModel::DATE_AQUIRED->value => "DESC"])
->select(CoolerModel::values())
->fetch_all(MYSQLI_ASSOC);
}
public function main(): Response {
// Set properties as "searchable"
parent::make_wildcard_search(CoolerModel::VENDOR_NAME->value, $this->query);
parent::make_wildcard_search(CoolerModel::VENDOR_MODEL->value, $this->query);
// Get hardware
$this->get_coolers();
// Resolve hardware relationships
$this->get_motherboards();
// Return 404 Not Found if response array is empty
return new Response($this->results, $this->results ? 200 : 404);
}
}

View file

@ -0,0 +1,117 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Databases\VLWdb\{
VLWdb,
Databases
};
use VLW\API\Databases\VLWdb\Models\Battlestation\{
CpuModel,
ClassEnum
};
use VLW\API\Databases\VLWdb\Models\Battlestation\Config\MbCpuCoolerModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Battlestation/Cpu.php");
require_once Path::root("src/databases/models/Battlestation/Config/MbCpuCooler.php");
class GET_BattlestationCpu extends VLWdb {
private const REL_MOTHERBOARDS = "motherboards";
protected Ruleset $ruleset;
private array $query;
private array $results = [];
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(CpuModel::ID->value))
->type(Type::STRING)
->min(parent::UUID_LENGTH)
->max(parent::UUID_LENGTH),
(new Rules(CpuModel::CLOCK_BASE->value))
->type(Type::NUMBER)
->min(1),
(new Rules(CpuModel::CLOCK_TURBO->value))
->type(Type::NUMBER)
->min(1),
(new Rules(CpuModel::CORE_COUNT_PERFORMANCE->value))
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_TINYINT_MAX_LENGTH),
(new Rules(CpuModel::CORE_COUNT_EFFICIENCY->value))
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_TINYINT_MAX_LENGTH),
(new Rules(CpuModel::CORE_THREADS->value))
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_TINYINT_MAX_LENGTH),
(new Rules(CpuModel::VENDOR_NAME->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(CpuModel::VENDOR_MODEL->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(CpuModel::IS_RETIRED->value))
->type(Type::BOOLEAN)
]);
parent::__construct(Databases::BATTLESTATION, $this->ruleset);
// Use a copy of search parameters
$this->query = $_GET;
}
private function get_motherboards(): void {
foreach ($this->results as &$result) {
// Get motherboard id from relationship by chassis id
$result[self::REL_MOTHERBOARDS] = $this->db
->for(MbCpuCoolerModel::TABLE)
->where([MbCpuCoolerModel::REF_CPU_ID->value => $result[CpuModel::ID->value]])
->select(MbCpuCoolerModel::values())
->fetch_all(MYSQLI_ASSOC);
}
}
private function get_cpu(): array {
return $this->results = $this->db
->for(CpuModel::TABLE)
->where($this->query)
->order([CpuModel::DATE_AQUIRED->value => "DESC"])
->select(CpuModel::values())
->fetch_all(MYSQLI_ASSOC);
}
public function main(): Response {
// Set properties as "searchable"
parent::make_wildcard_search(CpuModel::VENDOR_NAME->value, $this->query);
parent::make_wildcard_search(CpuModel::VENDOR_MODEL->value, $this->query);
// Get hardware
$this->get_cpu();
// Resolve hardware relationships
$this->get_motherboards();
// Return 404 Not Found if response array is empty
return new Response($this->results, $this->results ? 200 : 404);
}
}

View file

@ -0,0 +1,115 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Databases\VLWdb\{
VLWdb,
Databases
};
use VLW\API\Databases\VLWdb\Models\Battlestation\{
DramModel,
DramFormfactorEnum,
DramTechnologyEnum
};
use VLW\API\Databases\VLWdb\Models\Battlestation\Config\MbDramModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Battlestation/Dram.php");
require_once Path::root("src/databases/models/Battlestation/Config/MbDram.php");
class GET_BattlestationDram extends VLWdb {
private const REL_MOTHERBOARDS = "motherboards";
protected Ruleset $ruleset;
private array $query;
private array $results = [];
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(DramModel::ID->value))
->type(Type::STRING)
->min(parent::UUID_LENGTH)
->max(parent::UUID_LENGTH),
(new Rules(DramModel::CAPACITY->value))
->type(Type::NUMBER)
->min(1),
(new Rules(DramModel::SPEED->value))
->type(Type::NUMBER)
->min(1),
(new Rules(DramModel::FORMFACTOR->value))
->type(Type::ENUM, DramFormfactorEnum::names()),
(new Rules(DramModel::TECHNOLOGY->value))
->type(Type::ENUM, DramTechnologyEnum::names()),
(new Rules(DramModel::ECC->value))
->type(Type::BOOLEAN),
(new Rules(DramModel::BUFFERED->value))
->type(Type::BOOLEAN),
(new Rules(DramModel::VENDOR_NAME->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(DramModel::VENDOR_MODEL->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(DramModel::IS_RETIRED->value))
->type(Type::BOOLEAN)
]);
parent::__construct(Databases::BATTLESTATION, $this->ruleset);
// Use a copy of search parameters
$this->query = $_GET;
}
private function get_motherboards(): void {
foreach ($this->results as &$result) {
// Get motherboard id from relationship by chassis id
$result[self::REL_MOTHERBOARDS] = $this->db
->for(MbDramModel::TABLE)
->where([MbDramModel::REF_DRAM_ID->value => $result[DramModel::ID->value]])
->select(MbDramModel::values())
->fetch_all(MYSQLI_ASSOC);
}
}
private function get_dram(): array {
return $this->results = $this->db
->for(DramModel::TABLE)
->where($this->query)
->order([DramModel::DATE_AQUIRED->value => "DESC"])
->select(DramModel::values())
->fetch_all(MYSQLI_ASSOC);
}
public function main(): Response {
// Set properties as "searchable"
parent::make_wildcard_search(DramModel::VENDOR_NAME->value, $this->query);
parent::make_wildcard_search(DramModel::VENDOR_MODEL->value, $this->query);
// Get hardware
$this->get_dram();
// Resolve hardware relationships
$this->get_motherboards();
// Return 404 Not Found if response array is empty
return new Response($this->results, $this->results ? 200 : 404);
}
}

View file

@ -0,0 +1,107 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Databases\VLWdb\{
VLWdb,
Databases
};
use VLW\API\Databases\VLWdb\Models\Battlestation\GpuModel;
use VLW\API\Databases\VLWdb\Models\Battlestation\Config\MbGpuModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Battlestation/Gpu.php");
require_once Path::root("src/databases/models/Battlestation/Config/MbGpu.php");
class GET_BattlestationGpu extends VLWdb {
private const REL_MOTHERBOARDS = "motherboards";
protected Ruleset $ruleset;
private array $query;
private array $results = [];
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(GpuModel::ID->value))
->type(Type::STRING)
->min(parent::UUID_LENGTH)
->max(parent::UUID_LENGTH),
(new Rules(GpuModel::MEMORY->value))
->type(Type::NUMBER)
->min(1),
(new Rules(GpuModel::VENDOR_NAME->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(GpuModel::VENDOR_MODEL->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(GpuModel::VENDOR_CHIP_NAME->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(GpuModel::VENDOR_CHIP_MODEL->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(GpuModel::IS_RETIRED->value))
->type(Type::BOOLEAN)
]);
parent::__construct(Databases::BATTLESTATION, $this->ruleset);
// Use a copy of search parameters
$this->query = $_GET;
}
private function get_motherboards(): void {
foreach ($this->results as &$result) {
// Get motherboard id from relationship by chassis id
$result[self::REL_MOTHERBOARDS] = $this->db
->for(MbGpuModel::TABLE)
->where([MbGpuModel::REF_GPU_ID->value => $result[GpuModel::ID->value]])
->select(MbGpuModel::values())
->fetch_all(MYSQLI_ASSOC);
}
}
private function get_gpu(): array {
return $this->results = $this->db
->for(GpuModel::TABLE)
->where($this->query)
->order([GpuModel::DATE_AQUIRED->value => "DESC"])
->select(GpuModel::values())
->fetch_all(MYSQLI_ASSOC);
}
public function main(): Response {
// Set properties as "searchable"
parent::make_wildcard_search(GpuModel::VENDOR_NAME->value, $this->query);
parent::make_wildcard_search(GpuModel::VENDOR_MODEL->value, $this->query);
parent::make_wildcard_search(GpuModel::VENDOR_CHIP_NAME->value, $this->query);
parent::make_wildcard_search(GpuModel::VENDOR_CHIP_MODEL->value, $this->query);
// Get hardware
$this->get_gpu();
// Resolve hardware relationships
$this->get_motherboards();
// Return 404 Not Found if response array is empty
return new Response($this->results, $this->results ? 200 : 404);
}
}

View file

@ -0,0 +1,194 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Databases\VLWdb\{
VLWdb,
Databases
};
use VLW\API\Databases\VLWdb\Models\Battlestation\{
MbModel,
MbFormfactorEnum
};
use VLW\API\Databases\VLWdb\Models\Battlestation\Config\{
MbGpuModel,
MbPsuModel,
MbDramModel,
MbStorageModel,
ChassisMbModel,
MbCpuCoolerModel
};
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Battlestation/Mb.php");
require_once Path::root("src/databases/models/Battlestation/Config/MbPsu.php");
require_once Path::root("src/databases/models/Battlestation/Config/MbGpu.php");
require_once Path::root("src/databases/models/Battlestation/Config/MbDram.php");
require_once Path::root("src/databases/models/Battlestation/Config/MbStorage.php");
require_once Path::root("src/databases/models/Battlestation/Config/ChassisMb.php");
require_once Path::root("src/databases/models/Battlestation/Config/MbCpuCooler.php");
class GET_BattlestationMb extends VLWdb {
private const REL_CPU = "cpus";
private const REL_PSU = "psus";
private const REL_GPU = "gpus";
private const REL_DRAM = "dram";
private const REL_STORAGE = "storage";
private const REL_CHASSIS = "chassis";
protected Ruleset $ruleset;
private array $query;
private array $results = [];
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(MbModel::ID->value))
->type(Type::STRING)
->min(parent::UUID_LENGTH)
->max(parent::UUID_LENGTH),
(new Rules(MbModel::FORMFACTOR->value))
->type(Type::ENUM, MbFormfactorEnum::names()),
(new Rules(MbModel::VENDOR_NAME->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(MbModel::VENDOR_MODEL->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(MbModel::NETWORK_ETHERNET->value))
->type(Type::NULL)
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(MbModel::NETWORK_WLAN->value))
->type(Type::NULL)
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(MbModel::NETWORK_BLUETOOTH->value))
->type(Type::NULL)
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(MbModel::IS_RETIRED->value))
->type(Type::BOOLEAN)
]);
parent::__construct(Databases::BATTLESTATION, $this->ruleset);
// Use a copy of search parameters
$this->query = $_GET;
}
private function get_chassis(): void {
foreach ($this->results as &$result) {
// Get motherboard id from relationship by chassis id
$result[self::REL_CHASSIS] = $this->db
->for(ChassisMbModel::TABLE)
->where([ChassisMbModel::REF_MB_ID->value => $result[MbModel::ID->value]])
->select(ChassisMbModel::values())
->fetch_all(MYSQLI_ASSOC);
}
}
private function get_psu(): void {
foreach ($this->results as &$result) {
// Get motherboard id from relationship by chassis id
$result[self::REL_PSU] = $this->db
->for(MbPsuModel::TABLE)
->where([MbPsuModel::REF_MB_ID->value => $result[MbModel::ID->value]])
->select(MbPsuModel::values())
->fetch_all(MYSQLI_ASSOC);
}
}
private function get_cpu(): void {
foreach ($this->results as &$result) {
// Get motherboard id from relationship by chassis id
$result[self::REL_CPU] = $this->db
->for(MbCpuCoolerModel::TABLE)
->where([MbCpuCoolerModel::REF_MB_ID->value => $result[MbModel::ID->value]])
->select(MbCpuCoolerModel::values())
->fetch_all(MYSQLI_ASSOC);
}
}
private function get_gpu(): void {
foreach ($this->results as &$result) {
// Get motherboard id from relationship by chassis id
$result[self::REL_GPU] = $this->db
->for(MbGpuModel::TABLE)
->where([MbGpuModel::REF_MB_ID->value => $result[MbModel::ID->value]])
->select(MbGpuModel::values())
->fetch_all(MYSQLI_ASSOC);
}
}
private function get_dram(): void {
foreach ($this->results as &$result) {
// Get motherboard id from relationship by chassis id
$result[self::REL_DRAM] = $this->db
->for(MbDramModel::TABLE)
->where([MbDramModel::REF_MB_ID->value => $result[MbModel::ID->value]])
->select(MbDramModel::values())
->fetch_all(MYSQLI_ASSOC);
}
}
private function get_storage(): void {
foreach ($this->results as &$result) {
// Get motherboard id from relationship by chassis id
$result[self::REL_STORAGE] = $this->db
->for(MbStorageModel::TABLE)
->where([MbStorageModel::REF_MB_ID->value => $result[MbModel::ID->value]])
->select(MbStorageModel::values())
->fetch_all(MYSQLI_ASSOC);
}
}
// ----
private function get_motherboards(): array {
return $this->results = $this->db
->for(MbModel::TABLE)
->where($this->query)
->order([MbModel::DATE_AQUIRED->value => "DESC"])
->select(MbModel::values())
->fetch_all(MYSQLI_ASSOC);
}
public function main(): Response {
// Set properties as "searchable"
parent::make_wildcard_search(MbModel::VENDOR_NAME->value, $this->query);
parent::make_wildcard_search(MbModel::VENDOR_MODEL->value, $this->query);
// Get hardware
$this->get_motherboards();
// Resolve hardware relationships
$this->get_chassis();
$this->get_cpu();
$this->get_psu();
$this->get_gpu();
$this->get_dram();
$this->get_storage();
// Return 404 Not Found if response array is empty
return new Response($this->results, $this->results ? 200 : 404);
}
}

View file

@ -0,0 +1,102 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Databases\VLWdb\{
VLWdb,
Databases
};
use VLW\API\Databases\VLWdb\Models\Battlestation\{
PsuModel,
EightyplusRatingEnum
};
use VLW\API\Databases\VLWdb\Models\Battlestation\Config\MbPsuModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Battlestation/Psu.php");
require_once Path::root("src/databases/models/Battlestation/Config/MbPsu.php");
class GET_BattlestationPsu extends VLWdb {
private const REL_MOTHERBOARDS = "motherboards";
protected Ruleset $ruleset;
private array $query;
private array $results = [];
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(PsuModel::ID->value))
->type(Type::STRING)
->min(parent::UUID_LENGTH)
->max(parent::UUID_LENGTH),
(new Rules(PsuModel::POWER->value))
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_INT_MAX_LENGTH),
(new Rules(PsuModel::EIGHTYPLUS_RATING->value))
->type(Type::ENUM, EightyplusRatingEnum::names()),
(new Rules(PsuModel::VENDOR_NAME->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(PsuModel::VENDOR_MODEL->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(PsuModel::IS_RETIRED->value))
->type(Type::BOOLEAN)
]);
parent::__construct(Databases::BATTLESTATION, $this->ruleset);
// Use a copy of search parameters
$this->query = $_GET;
}
private function get_motherboards(): void {
foreach ($this->results as &$result) {
// Get motherboard id from relationship by chassis id
$result[self::REL_MOTHERBOARDS] = $this->db
->for(MbPsuModel::TABLE)
->where([MbPsuModel::REF_PSU_ID->value => $result[PsuModel::ID->value]])
->select(MbPsuModel::values())
->fetch_all(MYSQLI_ASSOC);
}
}
private function get_psu(): array {
return $this->results = $this->db
->for(PsuModel::TABLE)
->where($this->query)
->order([PsuModel::DATE_AQUIRED->value => "DESC"])
->select(PsuModel::values())
->fetch_all(MYSQLI_ASSOC);
}
public function main(): Response {
// Set properties as "searchable"
parent::make_wildcard_search(PsuModel::VENDOR_NAME->value, $this->query);
parent::make_wildcard_search(PsuModel::VENDOR_MODEL->value, $this->query);
// Get hardware
$this->get_psu();
// Resolve hardware relationships
$this->get_motherboards();
// Return 404 Not Found if response array is empty
return new Response($this->results, $this->results ? 200 : 404);
}
}

View file

@ -0,0 +1,110 @@
<?php
use Reflect\Path;
use Reflect\Response;
use ReflectRules\Type;
use ReflectRules\Rules;
use ReflectRules\Ruleset;
use VLW\API\Databases\VLWdb\{
VLWdb,
Databases
};
use VLW\API\Databases\VLWdb\Models\Battlestation\{
StorageModel,
StorageDiskTypeEnum,
StorageDiskInterfaceEnum,
StorageDiskFormfactorEnum
};
use VLW\API\Databases\VLWdb\Models\Battlestation\Config\MbStorageModel;
require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Battlestation/Storage.php");
require_once Path::root("src/databases/models/Battlestation/Config/MbStorage.php");
class GET_BattlestationStorage extends VLWdb {
private const REL_MOTHERBOARDS = "motherboards";
protected Ruleset $ruleset;
private array $query;
private array $results = [];
public function __construct() {
$this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([
(new Rules(StorageModel::ID->value))
->type(Type::STRING)
->min(parent::UUID_LENGTH)
->max(parent::UUID_LENGTH),
(new Rules(StorageModel::DISK_TYPE->value))
->type(Type::ENUM, StorageDiskTypeEnum::names()),
(new Rules(StorageModel::DISK_SIZE->value))
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_INT_MAX_LENGTH),
(new Rules(StorageModel::DISK_INTERFACE->value))
->type(Type::ENUM, StorageDiskInterfaceEnum::names()),
(new Rules(StorageModel::DISK_FORMFACTOR->value))
->type(Type::ENUM, StorageDiskFormfactorEnum::names()),
(new Rules(StorageModel::VENDOR_NAME->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(StorageModel::VENDOR_MODEL->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(StorageModel::IS_RETIRED->value))
->type(Type::BOOLEAN)
]);
parent::__construct(Databases::BATTLESTATION, $this->ruleset);
// Use a copy of search parameters
$this->query = $_GET;
}
private function get_motherboards(): void {
foreach ($this->results as &$result) {
// Get motherboard id from relationship by chassis id
$result[self::REL_MOTHERBOARDS] = $this->db
->for(MbStorageModel::TABLE)
->where([MbStorageModel::REF_STORAGE_ID->value => $result[StorageModel::ID->value]])
->select(MbStorageModel::values())
->fetch_all(MYSQLI_ASSOC);
}
}
private function get_storage(): array {
return $this->results = $this->db
->for(StorageModel::TABLE)
->where($this->query)
->order([StorageModel::DATE_AQUIRED->value => "DESC"])
->select(StorageModel::values())
->fetch_all(MYSQLI_ASSOC);
}
public function main(): Response {
// Set properties as "searchable"
parent::make_wildcard_search(StorageModel::VENDOR_NAME->value, $this->query);
parent::make_wildcard_search(StorageModel::VENDOR_MODEL->value, $this->query);
// Get hardware
$this->get_storage();
// Resolve hardware relationships
$this->get_motherboards();
// Return 404 Not Found if response array is empty
return new Response($this->results, $this->results ? 200 : 404);
}
}

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

@ -6,20 +6,19 @@
use ReflectRules\Rules; use ReflectRules\Rules;
use ReflectRules\Ruleset; use ReflectRules\Ruleset;
use Reflect\Method; use VLW\API\Databases\VLWdb\{
use function Reflect\Call; VLWdb,
Databases
use VLW\API\Databases\VLWdb\VLWdb; };
use VLW\API\Databases\VLWdb\Models\Messages\MessagesModel; use VLW\API\Databases\VLWdb\Models\Messages\MessagesModel;
require_once Path::root("src/databases/VLWdb.php"); require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Messages.php"); require_once Path::root("src/databases/models/Messages/Messages.php");
class POST_Messages extends VLWdb { class POST_Messages extends VLWdb {
protected Ruleset $ruleset; protected Ruleset $ruleset;
public function __construct() { public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true); $this->ruleset = new Ruleset(strict: true);
$this->ruleset->POST([ $this->ruleset->POST([
@ -34,46 +33,19 @@
->min(1) ->min(1)
->max(parent::MYSQL_TEXT_MAX_LENGTH) ->max(parent::MYSQL_TEXT_MAX_LENGTH)
]); ]);
}
// # Responses parent::__construct(Databases::VLW, $this->ruleset);
// 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 { public function main(): Response {
//return new Response(["hello" => "maybe"], 500); // Use copy of request body as entity
$entity = $_POST;
// Bail out if request validation failed $entity[MessagesModel::ID->value] = parent::gen_uuid4();
if (!$this->ruleset->is_valid()) { $entity[MessagesModel::DATE_CREATED->value] = time();
return $this->resp_rules_invalid();
}
// Generate UUID for entity return $this->db->for(MessagesModel::TABLE)->insert($entity) === true
$id = parent::gen_uuid4(); ? new Response($entity[MessagesModel::ID->value], 201)
: new Response("Failed to create message", 500);
// 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

@ -9,13 +9,15 @@
use VLW\API\Endpoints; use VLW\API\Endpoints;
use VLW\API\Databases\VLWdb\VLWdb; use VLW\API\Databases\VLWdb\{
VLWdb,
Databases
};
use VLW\API\Databases\VLWdb\Models\Work\WorkModel; use VLW\API\Databases\VLWdb\Models\Work\WorkModel;
use VLW\API\Databases\VLWdb\Models\Work\WorkActionsModel;
require_once Path::root("src/Endpoints.php");
require_once Path::root("src/databases/VLWdb.php"); require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Work.php"); require_once Path::root("src/databases/models/Work/Work.php");
require_once Path::root("src/databases/models/WorkActions.php");
class GET_Search extends VLWdb { class GET_Search extends VLWdb {
const GET_QUERY = "q"; const GET_QUERY = "q";
@ -23,118 +25,35 @@
protected Ruleset $ruleset; protected Ruleset $ruleset;
public function __construct() { public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true); $this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([ $this->ruleset->GET([
(new Rules(self::GET_QUERY)) (new Rules(self::GET_QUERY))
->required() ->required()
->type(Type::STRING) ->type(Type::STRING)
->min(2) ->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH) ->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
]); ]);
parent::__construct(Databases::VLW, $this->ruleset);
} }
// Return an SQL string from array for use in prepared statements private function search_work(): Response {
private static function array_to_wildcard_sql(array $columns): string { return (new Call(Endpoints::WORK->value))->params([
$sql = array_map(fn(string $column): string => "{$column} LIKE CONCAT('%', ?, '%')", $columns); WorkModel::TITLE->value => $_GET[self::GET_QUERY],
WorkModel::SUMMARY->value => $_GET[self::GET_QUERY]
return implode(" OR ", $sql); ])->get();
}
// 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 { public function main(): Response {
// Bail out if request validation failed $results = [
if (!$this->ruleset->is_valid()) { Endpoints::WORK->value => $this->search_work()->output()
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 // Calculate the total number of results from all searched endpoints
$total_num_results = 0; $num_results = array_sum(array_map(fn(array $result): int => count($result), array_values($results)));
foreach (array_values($categories) as $results) {
$total_num_results += count($results);
}
return new Response([ // Return 404 if no search results
"query" => $_GET[self::GET_QUERY], return new Response($results, $num_results > 0 ? 200 : 404);
"results" => $categories,
"total_num_results" => $total_num_results
]);
} }
} }

View file

@ -1,15 +1,19 @@
<?php <?php
use Reflect\Path; use Reflect\Path;
use Reflect\Response; use Reflect\Response;
use ReflectRules\Type; use ReflectRules\Type;
use ReflectRules\Rules; use ReflectRules\Rules;
use ReflectRules\Ruleset; use ReflectRules\Ruleset;
use VLW\API\Databases\VLWdb\VLWdb; use const VLW\API\RESP_DELETE_OK;
use VLW\API\Databases\VLWdb\{
VLWdb,
Databases
};
use VLW\API\Databases\VLWdb\Models\Work\WorkModel; use VLW\API\Databases\VLWdb\Models\Work\WorkModel;
require_once Path::root("src/Endpoints.php");
require_once Path::root("src/databases/VLWdb.php"); require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Work.php"); require_once Path::root("src/databases/models/Work.php");
@ -17,44 +21,47 @@
protected Ruleset $ruleset; protected Ruleset $ruleset;
public function __construct() { public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true); $this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([ $this->ruleset->POST([
(new Rules("id")) (new Rules(WorkModel::ID->value))
->required()
->type(Type::STRING) ->type(Type::STRING)
->min(1) ->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH) ->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkModel::TITLE->value))
->type(Type::STRING)
->min(3)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkModel::SUMMARY->value))
->type(Type::STRING)
->min(1)
->max(parent::MYSQL_TEXT_MAX_LENGTH),
(new Rules(WorkModel::IS_LISTABLE->value))
->type(Type::BOOLEAN),
(new Rules(WorkModel::IS_READABLE->value))
->type(Type::BOOLEAN),
(new Rules(WorkModel::DATE_MODIFIED->value))
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_INT_MAX_LENGTH),
(new Rules(WorkModel::DATE_CREATED->value))
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_INT_MAX_LENGTH)
]); ]);
}
// # Responses parent::__construct(Databases::VLW, $this->ruleset);
// 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 { public function main(): Response {
// Bail out if request validation failed return $this->db->for(FieldsEnumsModel::TABLE)->delete($_POST) === true
if (!$this->ruleset->is_valid()) { ? new Response(RESP_DELETE_OK)
return $this->resp_rules_invalid(); : new Response("Failed to delete work entity", 500);
}
// 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 +1,98 @@
<?php <?php
use Reflect\Call;
use Reflect\Path; use Reflect\Path;
use Reflect\Method;
use Reflect\Response; use Reflect\Response;
use ReflectRules\Type; use ReflectRules\Type;
use ReflectRules\Rules; use ReflectRules\Rules;
use ReflectRules\Ruleset; use ReflectRules\Ruleset;
use VLW\API\Endpoints; use VLW\API\Databases\VLWdb\{
VLWdb,
use VLW\API\Databases\VLWdb\VLWdb; Databases
};
use VLW\API\Databases\VLWdb\Models\Work\WorkModel; 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/VLWdb.php");
require_once Path::root("src/databases/models/Work.php"); require_once Path::root("src/databases/models/Work/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 { class GET_Work extends VLWdb {
protected Ruleset $ruleset; protected Ruleset $ruleset;
public function __construct() { public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true); $this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([ $this->ruleset->GET([
(new Rules("id")) (new Rules(WorkModel::ID->value))
->type(Type::STRING) ->type(Type::STRING)
->min(1) ->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH) ->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
->default(null)
(new Rules(WorkModel::TITLE->value))
->type(Type::STRING)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkModel::SUMMARY->value))
->type(Type::STRING)
->max(parent::MYSQL_TEXT_MAX_LENGTH),
(new Rules(WorkModel::IS_LISTABLE->value))
->type(Type::BOOLEAN)
->default(true),
(new Rules(WorkModel::IS_READABLE->value))
->type(Type::BOOLEAN)
->default(true),
(new Rules(WorkModel::DATE_MODIFIED->value))
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_INT_MAX_LENGTH),
(new Rules(WorkModel::DATE_CREATED->value))
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_INT_MAX_LENGTH)
]); ]);
}
// # Helper methods parent::__construct(Databases::VLW, $this->ruleset);
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 { public function main(): Response {
// Bail out if request validation failed // Use copy of search paramters as filters
if (!$this->ruleset->is_valid()) { $filters = $_GET;
return $this->resp_rules_invalid();
// Do a wildcard search on the title column if provided
if (array_key_exists(WorkModel::TITLE->value, $_GET)) {
$filters[WorkModel::TITLE->value] = [
"LIKE" => "%{$_GET[WorkModel::TITLE->value]}%"
];
} }
// Return details about a specific item by id // Do a wildcard search on the summary column if provided
if (!empty($_GET["id"])) { if (array_key_exists(WorkModel::SUMMARY->value, $_GET)) {
return $this->resp_item_details($_GET["id"]); $filters[WorkModel::SUMMARY->value] = [
"LIKE" => "%{$_GET[WorkModel::SUMMARY->value]}%"
];
} }
$resp = $this->db->for(WorkModel::TABLE) $response = $this->db->for(WorkModel::TABLE)
->where([WorkModel::IS_LISTABLE->value => true]) ->where($filters)
->order([WorkModel::DATE_TIMESTAMP_CREATED->value => "DESC"]) ->order([WorkModel::DATE_CREATED->value => "DESC"])
->select([ ->select([
WorkModel::ID->value, WorkModel::ID->value,
WorkModel::TITLE->value, WorkModel::TITLE->value,
WorkModel::SUMMARY->value, WorkModel::SUMMARY->value,
WorkModel::COVER_SRCSET->value, WorkModel::IS_LISTABLE->value,
WorkModel::IS_READABLE->value,
WorkModel::DATE_YEAR->value, WorkModel::DATE_YEAR->value,
WorkModel::DATE_MONTH->value, WorkModel::DATE_MONTH->value,
WorkModel::DATE_DAY->value, WorkModel::DATE_DAY->value,
WorkModel::DATE_TIMESTAMP_MODIFIED->value, WorkModel::DATE_MODIFIED->value,
WorkModel::DATE_TIMESTAMP_CREATED->value WorkModel::DATE_CREATED->value
]); ]);
// Bail out if something went wrong retrieving rows from the database return $response->num_rows > 0
if (!parent::is_mysqli_result($resp)) { ? new Response($response->fetch_all(MYSQLI_ASSOC))
return $this->resp_database_error(); : new Response([], 404);
}
// 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,30 +1,31 @@
<?php <?php
use Reflect\Call;
use Reflect\Path; use Reflect\Path;
use Reflect\Response; use Reflect\Response;
use ReflectRules\Type; use ReflectRules\Type;
use ReflectRules\Rules; use ReflectRules\Rules;
use ReflectRules\Ruleset; use ReflectRules\Ruleset;
use Reflect\Method; use VLW\API\Endpoints;
use function Reflect\Call; use VLW\API\Databases\VLWdb\{
VLWdb,
use VLW\API\Databases\VLWdb\VLWdb; Databases
use VLW\API\Databases\VLWdb\Models\Work\WorkModel; };
use VLW\API\Databases\VLWdb\Models\WorkPermalinks\WorkPermalinksModel; use VLW\API\Databases\VLWdb\Models\Work\{
WorkModel,
WorkPermalinksModel
};
require_once Path::root("src/Endpoints.php");
require_once Path::root("src/databases/VLWdb.php"); require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Work.php"); require_once Path::root("src/databases/models/Work/Work.php");
require_once Path::root("src/databases/models/WorkPermalinks.php"); require_once Path::root("src/databases/models/Work/WorkPermalinks.php");
class PATCH_Work extends VLWdb { class PATCH_Work extends VLWdb {
protected Ruleset $ruleset; protected Ruleset $ruleset;
protected Response $current_entity;
protected array $updated_entity;
public function __construct() { public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true); $this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([ $this->ruleset->GET([
@ -52,19 +53,19 @@
(new Rules(WorkModel::IS_READABLE->value)) (new Rules(WorkModel::IS_READABLE->value))
->type(Type::BOOLEAN), ->type(Type::BOOLEAN),
(new Rules(WorkModel::DATE_TIMESTAMP_CREATED->value)) (new Rules(WorkModel::DATE_MODIFIED->value))
->type(Type::NUMBER) ->type(Type::NUMBER)
->min(0) ->min(1)
->max(parent::MYSQL_INT_MAX_LENGHT) ->max(parent::MYSQL_INT_MAX_LENGTH)
->default(time()),
(new Rules(WorkModel::DATE_CREATED->value))
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_INT_MAX_LENGTH)
]); ]);
$this->get_existing_entity(); parent::__construct();
// 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 // Generate a slug URL from string
@ -72,130 +73,46 @@
return strtolower(trim(preg_replace('/[^A-Za-z0-9-]+/', '-', $input))); return strtolower(trim(preg_replace('/[^A-Za-z0-9-]+/', '-', $input)));
} }
// # Helper methods // Compute and return modeled year, month, and day from Unix timestamp in request body
private static function gen_date_created(): array {
private function get_existing_entity(): Response { return [
// Check if an entity already exists with slugified title from GET endpoint WorkModel::DATE_YEAR->value => date("Y", $_POST[WorkModel::DATE_CREATED->value]),
$this->current_entity = Call("work?id={$_GET["id"]}", Method::GET); WorkModel::DATE_MONTH ->value => date("n", $_POST[WorkModel::DATE_CREATED->value]),
WorkModel::DATE_DAY->value => date("j", $_POST[WorkModel::DATE_CREATED->value])
// 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 get_entity_by_id(string $id): Response {
private function create_permalink(string $slug): bool { return (new Call(Endpoints::WORK->value))->params([
$create = Call("work/permalinks", Method::POST, [ WorkModel::ID->value => $id
WorkPermalinksModel::SLUG->value => $slug, ])->get();
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 { public function main(): Response {
// Bail out if request validation failed // Use copy of request body as entity
if (!$this->ruleset->is_valid()) { $entity = $_POST;
return $this->resp_rules_invalid();
}
// Empty payload, nothing to do // Generate a new slug id from title if changed
if (empty($_POST)) { if ($_POST[WorkModel::TITLE->value]) {
return $this->resp_no_changes(); $slug = $_POST[WorkModel::TITLE->value];
}
// Generate new slug for entity if title is updated // Bail out if the slug generated from the new tite already exist
if (array_key_exists(WorkModel::TITLE->value, $_POST)) { if ($this->get_entity_by_id($slug)) {
// Generate URL slug from title text or UUID if undefined return new Response("An entity with this title already exist", 409);
$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;
} }
// Add the new slug to update entity
$entity[WorkModel::ID] = $slug;
} }
// Update fractured dates from timestamp // Generate new work date fields from timestamp
$this->timestamp_to_dates(); if ($_POST[WorkModel::DATE_CREATED->value]) {
array_merge($entity, self::gen_date_created());
// 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 // Update entity by existing id
return new Response($this->current_entity->output()[WorkModel::ID->value]); return $this->db->for(WorkModel::TABLE)->where([WorkModel::ID->value => $_GET[WorkModel::ID->value]])->update($entity) === true
? new Response($_GET[WorkModel::ID->value])
: new Response("Failed to update entity", 500);
} }
} }

View file

@ -1,27 +1,31 @@
<?php <?php
use Reflect\Call;
use Reflect\Path; use Reflect\Path;
use Reflect\Response; use Reflect\Response;
use ReflectRules\Type; use ReflectRules\Type;
use ReflectRules\Rules; use ReflectRules\Rules;
use ReflectRules\Ruleset; use ReflectRules\Ruleset;
use Reflect\Method; use VLW\API\Endpoints;
use function Reflect\Call; use VLW\API\Databases\VLWdb\{
VLWdb,
use VLW\API\Databases\VLWdb\VLWdb; Databases
use VLW\API\Databases\VLWdb\Models\Work\WorkModel; };
use VLW\API\Databases\VLWdb\Models\WorkPermalinks\WorkPermalinksModel; use VLW\API\Databases\VLWdb\Models\Work\{
WorkModel,
WorkPermalinksModel
};
require_once Path::root("src/Endpoints.php");
require_once Path::root("src/databases/VLWdb.php"); require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/Work.php"); require_once Path::root("src/databases/models/Work/Work.php");
require_once Path::root("src/databases/models/WorkPermalinks.php"); require_once Path::root("src/databases/models/Work/WorkPermalinks.php");
class POST_Work extends VLWdb { class POST_Work extends VLWdb {
protected Ruleset $ruleset; protected Ruleset $ruleset;
public function __construct() { public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true); $this->ruleset = new Ruleset(strict: true);
$this->ruleset->POST([ $this->ruleset->POST([
@ -37,12 +41,22 @@
->max(parent::MYSQL_TEXT_MAX_LENGTH) ->max(parent::MYSQL_TEXT_MAX_LENGTH)
->default(null), ->default(null),
(new Rules(WorkModel::DATE_TIMESTAMP_CREATED->value)) (new Rules(WorkModel::IS_LISTABLE->value))
->type(Type::BOOLEAN)
->default(false),
(new Rules(WorkModel::IS_READABLE->value))
->type(Type::BOOLEAN)
->default(false),
(new Rules(WorkModel::DATE_CREATED->value))
->type(Type::NUMBER) ->type(Type::NUMBER)
->min(1) ->min(1)
->max(parent::MYSQL_INT_MAX_LENGHT) ->max(parent::MYSQL_INT_MAX_LENGTH)
->default(null) ->default(time())
]); ]);
parent::__construct(Databases::VLW, $this->ruleset);
} }
// Generate a slug URL from string // Generate a slug URL from string
@ -50,84 +64,51 @@
return strtolower(trim(preg_replace('/[^A-Za-z0-9-]+/', '-', $input))); return strtolower(trim(preg_replace('/[^A-Za-z0-9-]+/', '-', $input)));
} }
// Create permalink for entity slug // Compute and return modeled year, month, and day from a Unix timestamp
private function create_permalink(string $slug): bool { private static function gen_date_created(): array {
$create = Call("work/permalinks", Method::POST, [ // Use provided timestamp in request
WorkPermalinksModel::SLUG->value => $slug, $date_created = $_POST[WorkModel::DATE_CREATED->value];
WorkPermalinksModel::ANCHOR->value => $slug
]);
return $create->ok; return [
WorkModel::DATE_YEAR->value => date("Y", $date_created),
WorkModel::DATE_MONTH ->value => date("n", $date_created),
WorkModel::DATE_DAY->value => date("j", $date_created)
];
} }
// # Responses private function get_entity_by_id(string $id): Response {
return (new Call(Endpoints::WORK->value))->params([
// Return 422 Unprocessable Content error if request validation failed WorkModel::ID->value => $id
private function resp_rules_invalid(): Response { ])->get();
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 { public function main(): Response {
// Bail out if request validation failed // Use copy of request body as entity
if (!$this->ruleset->is_valid()) { $entity = $_POST;
return $this->resp_rules_invalid();
}
// Generate URL slug from title text or UUID if undefined // Generate URL slug from title text or UUID if undefined
$slug = !empty($_POST["title"]) ? self::gen_slug($_POST["title"]) : parent::gen_uuid4(); $entity[WorkModel::ID->value] = $_POST[WorkModel::TITLE->value]
? self::gen_slug($_POST[WorkModel::TITLE->value])
: parent::gen_uuid4();
// Check if an entity already exists with slugified title from GET endpoint // Bail out here if a work entry with id had been created already
$existing_entity = Call("work?id={$slug}", Method::GET); if ($this->get_entity_by_id($entity[WorkModel::ID->value])->ok) {
// Response is not 404 (Not found) so we can't create the entity return new Response("An entity with id '{$slug}' already exist", 409);
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 // Generate the necessary date fields
$created_timestamp = $_POST[WorkModel::DATE_TIMESTAMP_CREATED->value] array_merge($entity, self::gen_date_created());
? $_POST[WorkModel::DATE_TIMESTAMP_CREATED->value]
: time();
// Attempt to create new entity // Let's try to insert the new entity
$insert = $this->db->for(WorkModel::TABLE) if (!$this->db->for(WorkModel::TABLE)->insert($entity)) {
->insert([ return new Response("Failed to insert work entry", 500);
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 // Generate permalink for new entity
if (!$this->create_permalink($slug)) { return (new Call(Endpoints::WORK_PERMALINKS->value))->post([
// Rollback created entity if permalink creation failed WorkPermalinksModel::ID => $entity[WorkModel::ID->value],
Call("work", Method::DELETE, [WorkModel::ID->value => $slug]); WorkPermalinksModel::REF_WORK_ID => $entity[WorkModel::ID->value],
WorkPermalinksModel::DATE_CREATED => time()
return new Response("Failed to create permalink", 500); ]);
}
// Return 201 Created and entity slug as body
return new Response($slug, 201);
} }
} }

View file

@ -6,10 +6,11 @@
use ReflectRules\Rules; use ReflectRules\Rules;
use ReflectRules\Ruleset; use ReflectRules\Ruleset;
use Reflect\Method; use const VLW\API\RESP_DELETE_OK;
use function Reflect\Call; use VLW\API\Databases\VLWdb\{
VLWdb,
use VLW\API\Databases\VLWdb\VLWdb; Databases
};
use VLW\API\Databases\VLWdb\Models\Work\WorkActionsModel; use VLW\API\Databases\VLWdb\Models\Work\WorkActionsModel;
require_once Path::root("src/databases/VLWdb.php"); require_once Path::root("src/databases/VLWdb.php");
@ -23,51 +24,15 @@
$this->ruleset = new Ruleset(strict: true); $this->ruleset = new Ruleset(strict: true);
$this->ruleset->POST([ $this->ruleset->POST([
(new Rules("id")) (new Rules(WorkActionsModel::REF_WORK_ID->value))
->required()
->type(Type::STRING)
->min(1) ->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH) ->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 { public function main(): Response {
// Bail out if request validation failed return $this->db->for(WorkActionsModel::TABLE)->delete($_POST) === true
if (!$this->ruleset->is_valid()) { ? new Response(RESP_DELETE_OK)
return $this->resp_rules_invalid(); : new Response("Failed to delete action for work entity", 500);
}
// 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

@ -6,62 +6,44 @@
use ReflectRules\Rules; use ReflectRules\Rules;
use ReflectRules\Ruleset; use ReflectRules\Ruleset;
use VLW\API\Databases\VLWdb\VLWdb; use VLW\API\Databases\VLWdb\{
VLWdb,
Databases
};
use VLW\API\Databases\VLWdb\Models\Work\WorkActionsModel; use VLW\API\Databases\VLWdb\Models\Work\WorkActionsModel;
require_once Path::root("src/databases/VLWdb.php"); require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/WorkActions.php"); require_once Path::root("src/databases/models/Work/WorkActions.php");
class GET_WorkActions extends VLWdb { class GET_WorkActions extends VLWdb {
protected Ruleset $ruleset; protected Ruleset $ruleset;
public function __construct() { public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true); $this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([ $this->ruleset->GET([
(new Rules(WorkActionsModel::ANCHOR->value)) (new Rules(WorkActionsModel::REF_WORK_ID->value))
->required()
->type(Type::STRING) ->type(Type::STRING)
->min(1) ->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH) ->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
]); ]);
}
// # Responses parent::__construct(Databases::VLW, $this->ruleset);
// 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 { public function main(): Response {
// Bail out if request validation failed $response = $this->db->for(WorkActionsModel::TABLE)
if (!$this->ruleset->is_valid()) { ->where($_GET)
return $this->resp_rules_invalid();
}
$resp = $this->db->for(WorkActionsModel::TABLE)
->where([WorkActionsModel::ANCHOR->value => $_GET[WorkActionsModel::ANCHOR->value]])
->select([ ->select([
WorkActionsModel::REF_WORK_ID->value,
WorkActionsModel::DISPLAY_TEXT->value, WorkActionsModel::DISPLAY_TEXT->value,
WorkActionsModel::HREF->value, WorkActionsModel::HREF->value,
WorkActionsModel::CLASS_LIST->value, WorkActionsModel::CLASS_LIST->value,
WorkActionsModel::EXTERNAL->value WorkActionsModel::EXTERNAL->value
]); ]);
// Bail out if something went wrong retrieving rows from the database return $response->num_rows > 0
if (!parent::is_mysqli_result($resp)) { ? new Response($response->fetch_all(MYSQLI_ASSOC))
return $this->resp_database_error(); : new Response([], 404);
}
return $resp->num_rows > 0
? new Response($resp->fetch_all(MYSQLI_ASSOC))
: new Response([]);
} }
} }

View file

@ -1,36 +1,39 @@
<?php <?php
use Reflect\Call;
use Reflect\Path; use Reflect\Path;
use Reflect\Response; use Reflect\Response;
use ReflectRules\Type; use ReflectRules\Type;
use ReflectRules\Rules; use ReflectRules\Rules;
use ReflectRules\Ruleset; use ReflectRules\Ruleset;
use Reflect\Method; use VLW\API\Endpoints;
use function Reflect\Call; use VLW\API\Databases\VLWdb\{
VLWdb,
use VLW\API\Databases\VLWdb\VLWdb; Databases
use VLW\API\Databases\VLWdb\Models\Work\WorkActionsModel; };
use VLW\API\Databases\VLWdb\Models\Work\{
WorkModel,
WorkActionsModel
};
require_once Path::root("src/Endpoints.php");
require_once Path::root("src/databases/VLWdb.php"); require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/WorkActions.php"); require_once Path::root("src/databases/models/Work/Work.php");
require_once Path::root("src/databases/models/Work/WorkActions.php");
class POST_WorkActions extends VLWdb { class POST_WorkActions extends VLWdb {
protected Ruleset $ruleset; protected Ruleset $ruleset;
public function __construct() { public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true); $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([ $this->ruleset->POST([
(new Rules(WorkActionsModel::REF_WORK_ID->value))
->required()
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkActionsModel::DISPLAY_TEXT->value)) (new Rules(WorkActionsModel::DISPLAY_TEXT->value))
->required() ->required()
->type(Type::STRING) ->type(Type::STRING)
@ -47,56 +50,31 @@
(new Rules(WorkActionsModel::CLASS_LIST->value)) (new Rules(WorkActionsModel::CLASS_LIST->value))
->type(Type::ARRAY) ->type(Type::ARRAY)
->min(1) ->min(1)
->max(4)
->default([]), ->default([]),
(new Rules(WorkActionsModel::EXTERNAL->value)) (new Rules(WorkActionsModel::EXTERNAL->value))
->type(Type::BOOLEAN) ->type(Type::BOOLEAN)
->default(false) ->default(false)
]); ]);
parent::__construct(Databases::VLW, $this->ruleset);
} }
// # Responses private static function get_entity(): Response {
return (new Call(Endpoints::WORK->value))->params([
// Return 422 Unprocessable Content error if request validation failed WorkModel::ID->value => $_POST[WorkActionsModel::REF_WORK_ID->value]
private function resp_rules_invalid(): Response { ])->get();
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 { public function main(): Response {
// Bail out if request validation failed // Bail out if work entity could not be fetched
if (!$this->ruleset->is_valid()) { $entity = self::get_entity();
return $this->resp_rules_invalid(); if (!$entity->ok) {
return $entity;
} }
// Ensure an entity with the provided id exists return $this->db->for(WorkActionsModel::TABLE)->insert($_POST) === true
$entity = Call("work?id={$_GET["id"]}", Method::GET); ? new Response($_POST[WorkActionsModel::REF_WORK_ID->value], 201)
if ($entity->code !== 200) { : new Response("Failed to add action to work entity", 500);
// 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 +1,52 @@
<?php <?php
use Reflect\Path; use Reflect\Path;
use Reflect\Response; use Reflect\Response;
use ReflectRules\Type; use ReflectRules\Type;
use ReflectRules\Rules; use ReflectRules\Rules;
use ReflectRules\Ruleset; use ReflectRules\Ruleset;
use VLW\API\Databases\VLWdb\VLWdb; use VLW\API\Databases\VLWdb\{
use VLW\API\Databases\VLWdb\Models\WorkPermalinks\WorkPermalinksModel; VLWdb,
Databases
};
use VLW\API\Databases\VLWdb\Models\Work\WorkPermalinksModel;
require_once Path::root("src/databases/VLWdb.php"); require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/WorkPermalinks.php"); require_once Path::root("src/databases/models/Work/WorkPermalinks.php");
class GET_WorkPermalinks extends VLWdb { class GET_WorkPermalinks extends VLWdb {
protected Ruleset $ruleset; protected Ruleset $ruleset;
public function __construct() { public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true); $this->ruleset = new Ruleset(strict: true);
$this->ruleset->GET([ $this->ruleset->GET([
(new Rules("id")) (new Rules(WorkPermalinksModel::ID->value))
->required() ->type(Type::STRING)
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkPermalinksModel::REF_WORK_ID->value))
->type(Type::STRING) ->type(Type::STRING)
->min(1) ->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH) ->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
]); ]);
}
// # Responses parent::__construct(Databases::VLW, $this->ruleset);
// 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 { public function main(): Response {
// Bail out if request validation failed $response = $this->db->for(WorkPermalinksModel::TABLE)
if (!$this->ruleset->is_valid()) { ->where($_GET)
return $this->resp_rules_invalid(); ->select([
} WorkPermalinksModel::ID->value,
WorkPermalinksModel::REF_WORK_ID->value,
WorkPermalinksModel::DATE_CREATED->value
]);
// Get all anchors that match the requested slug return $response->num_rows > 0
$resolve = $this->db->for(WorkPermalinksModel::TABLE) ? new Response($response->fetch_all(MYSQLI_ASSOC))
->where([WorkPermalinksModel::SLUG->value => $_GET["id"]]) : new Response([], 404);
->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 +1,65 @@
<?php <?php
use Reflect\Call;
use Reflect\Path; use Reflect\Path;
use Reflect\Response; use Reflect\Response;
use ReflectRules\Type; use ReflectRules\Type;
use ReflectRules\Rules; use ReflectRules\Rules;
use ReflectRules\Ruleset; use ReflectRules\Ruleset;
use Reflect\Method; use VLW\API\Databases\VLWdb\{
use function Reflect\Call; VLWdb,
Databases
use VLW\API\Databases\VLWdb\VLWdb; };
use VLW\API\Databases\VLWdb\Models\WorkPermalinks\WorkPermalinksModel; use VLW\API\Databases\VLWdb\Models\Work\WorkPermalinksModel;
require_once Path::root("src/databases/VLWdb.php"); require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/WorkPermalinks.php"); require_once Path::root("src/databases/models/Work/WorkPermalinks.php");
class POST_WorkPermalinks extends VLWdb { class POST_WorkPermalinks extends VLWdb {
protected Ruleset $ruleset; protected Ruleset $ruleset;
public function __construct() { public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true); $this->ruleset = new Ruleset(strict: true);
$this->ruleset->POST([ $this->ruleset->POST([
(new Rules("slug")) (new Rules(WorkPermalinksModel::ID->value))
->required() ->required()
->type(Type::STRING) ->type(Type::STRING)
->min(1) ->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH), ->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules("anchor")) (new Rules(WorkPermalinksModel::REF_WORK_ID->value))
->required() ->required()
->type(Type::STRING) ->type(Type::STRING)
->min(1) ->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH) ->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkPermalinksModel::DATE_CREATED->value))
->type(Type::NUMBER)
->min(1)
->max(parent::MYSQL_INT_MAX_LENGTH)
->default(time())
]); ]);
parent::__construct(Databases::VLW, $this->ruleset);
} }
// # Responses private static function get_entity(): Response {
return (new Call(Endpoints::WORK->value))->params([
// Return 422 Unprocessable Content error if request validation failed WorkModel::ID->value => $_POST[WorkTagsModel::REF_WORK_ID->value]
private function resp_rules_invalid(): Response { ])->get();
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 { public function main(): Response {
// Bail out if request validation failed // Bail out if work entity could not be fetched
if (!$this->ruleset->is_valid()) { $entity = self::get_entity();
return $this->resp_rules_invalid(); if (!$entity->ok) {
return $entity;
} }
// Check if an entity exists with slug return $this->db->for(WorkPermalinksModel::TABLE)->insert($_POST) === true
$existing_entity = Call("work?id={$_POST["slug"]}", Method::GET); ? new Response($_POST[WorkPermalinksModel::ID->value], 201)
// Response is not 404 (Not found) so we can't create the entity : new Response("Failed to add permalink to work entity", 500);
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

@ -6,75 +6,37 @@
use ReflectRules\Rules; use ReflectRules\Rules;
use ReflectRules\Ruleset; use ReflectRules\Ruleset;
use Reflect\Method; use const VLW\API\RESP_DELETE_OK;
use function Reflect\Call; use VLW\API\Databases\VLWdb\{
VLWdb,
use VLW\API\Databases\VLWdb\VLWdb; Databases
};
use VLW\API\Databases\VLWdb\Models\Work\WorkTagsModel; 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/VLWdb.php");
require_once Path::root("src/databases/models/WorkTags.php"); require_once Path::root("src/databases/models/Work/WorkTags.php");
class DELETE_WorkTags extends VLWdb { class DELETE_WorkTags extends VLWdb {
protected Ruleset $ruleset; private Ruleset $ruleset;
public function __construct() { public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true); $this->ruleset = new Ruleset(strict: true);
$this->ruleset->POST([ $this->ruleset->GET([
(new Rules("id")) (new Rules(WorkTagsModel::REF_WORK_ID->value))
->required()
->type(Type::STRING)
->min(1) ->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH), ->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkTagsModel::NAME->value)) (new Rules(WorkTagsModel::NAME->value))
->required()
->type(Type::ENUM, WorkTagsNameEnum::names()) ->type(Type::ENUM, WorkTagsNameEnum::names())
]); ]);
}
// # Responses parent::__construct(Databases::VLW, $this->ruleset);
// 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 { public function main(): Response {
// Bail out if request validation failed return $this->db->for(WorkTagsModel::TABLE)->delete($_POST) === true
if (!$this->ruleset->is_valid()) { ? new Response(RESP_DELETE_OK)
return $this->resp_rules_invalid(); : new Response("Failed to delete value from document", 500);
}
// 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

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

View file

@ -1,93 +1,63 @@
<?php <?php
use Reflect\Call;
use Reflect\Path; use Reflect\Path;
use Reflect\Response; use Reflect\Response;
use ReflectRules\Type; use ReflectRules\Type;
use ReflectRules\Rules; use ReflectRules\Rules;
use ReflectRules\Ruleset; use ReflectRules\Ruleset;
use Reflect\Method; use VLW\API\Endpoints;
use function Reflect\Call; use VLW\API\Databases\VLWdb\{
VLWdb,
use VLW\API\Databases\VLWdb\VLWdb; Databases
use VLW\API\Databases\VLWdb\Models\Work\WorkTagsModel; };
use VLW\API\Databases\VLWdb\Models\Work\WorkTagsNameEnum; use VLW\API\Databases\VLWdb\Models\Work\{
WorkModel,
WorkTagsModel,
WorkTagsNameEnum
};
require_once Path::root("src/Endpoints.php");
require_once Path::root("src/databases/VLWdb.php"); require_once Path::root("src/databases/VLWdb.php");
require_once Path::root("src/databases/models/WorkTags.php"); require_once Path::root("src/databases/models/Work/Work.php");
require_once Path::root("src/databases/models/Work/WorkTags.php");
class POST_WorkTags extends VLWdb { class POST_WorkTags extends VLWdb {
protected Ruleset $ruleset; protected Ruleset $ruleset;
public function __construct() { public function __construct() {
parent::__construct();
$this->ruleset = new Ruleset(strict: true); $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([ $this->ruleset->POST([
(new Rules(WorkTagsModel::REF_WORK_ID->value))
->required()
->min(1)
->max(parent::MYSQL_VARCHAR_MAX_LENGTH),
(new Rules(WorkTagsModel::NAME->value)) (new Rules(WorkTagsModel::NAME->value))
->required() ->required()
->type(Type::ENUM, WorkTagsNameEnum::names()) ->type(Type::ENUM, WorkTagsNameEnum::names())
]); ]);
parent::__construct(Databases::VLW, $this->ruleset);
} }
// # Responses private static function get_entity(): Response {
return (new Call(Endpoints::WORK->value))->params([
// Return 422 Unprocessable Content error if request validation failed WorkModel::ID->value => $_POST[WorkTagsModel::REF_WORK_ID->value]
private function resp_rules_invalid(): Response { ])->get();
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 { public function main(): Response {
// Bail out if request validation failed // Bail out if work entity could not be fetched
if (!$this->ruleset->is_valid()) { $entity = self::get_entity();
return $this->resp_rules_invalid(); if (!$entity->ok) {
return $entity;
} }
// Ensure an entity with the provided id exists return $this->db->for(WorkTagsModel::TABLE)->insert($_POST) === true
$entity = Call("work?id={$_GET["id"]}", Method::GET); ? new Response($_POST[WorkTagsModel::REF_WORK_ID->value], 201)
if ($entity->code !== 200) { : new Response("Failed to add tag to work entity", 500);
// 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();
} }
} }

2
api/install.sh Normal file
View file

@ -0,0 +1,2 @@
# Install dependencies
composer install --optimize-autoloader

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

@ -0,0 +1,27 @@
<?php
namespace VLW\API;
// Default string to return when a DELETE request is successful
const RESP_DELETE_OK = "OK";
// Enum of all available VLW endpoints grouped by category
enum Endpoints: string {
case SEARCH = "/search";
case MESSAGES = "/messages";
case WORK = "/work";
case WORK_TAGS = "/work/tags";
case WORK_ACTIONS = "/work/actions";
case BATTLESTATION = "/battlestation";
case BATTLESTATION_MB = "/battlestation/mb";
case BATTLESTATION_CPU = "/battlestation/cpu";
case BATTLESTATION_GPU = "/battlestation/gpu";
case BATTLESTATION_PSU = "/battlestation/psu";
case BATTLESTATION_DRAM = "/battlestation/dram";
case BATTLESTATION_STORAGE = "/battlestation/storage";
case BATTLESTATION_COOLERS = "/battlestation/coolers";
case BATTLESTATION_CHASSIS = "/battlestation/chassis";
}

View file

@ -2,24 +2,38 @@
namespace VLW\API\Databases\VLWdb; namespace VLW\API\Databases\VLWdb;
use libmysqldriver\MySQL; use Reflect\Path;
use Reflect\Request;
use Reflect\Response;
use ReflectRules\Ruleset;
use vlw\MySQL\MySQL;
enum Databases: string {
case VLW = "vlw";
case BATTLESTATION = "battlestation";
}
class VLWdb { class VLWdb {
const UUID_LENGTH = 36; const UUID_LENGTH = 36;
const MYSQL_TEXT_MAX_LENGTH = 65538; const MYSQL_INT_MAX_LENGTH = 2147483647;
const MYSQL_TEXT_MAX_LENGTH = 65538;
const MYSQL_VARCHAR_MAX_LENGTH = 255; const MYSQL_VARCHAR_MAX_LENGTH = 255;
const MYSQL_INT_MAX_LENGHT = 2147483647; const MYSQL_TINYINT_MAX_LENGTH = 255;
protected MySQL $db; protected readonly MySQL $db;
public function __construct(Databases $database, Ruleset $ruleset) {
// Validate provided Ruleset before attempting to connect to the database
self::eval_ruleset_or_exit($ruleset);
public function __construct() {
// Create new MariaDB connection // Create new MariaDB connection
$this->db = new MySQL( $this->db = new MySQL(
$_ENV["vlwdb"]["mariadb_host"], $_ENV["connect"]["host"],
$_ENV["vlwdb"]["mariadb_user"], $_ENV["connect"]["user"],
$_ENV["vlwdb"]["mariadb_pass"], $_ENV["connect"]["pass"],
$_ENV["vlwdb"]["mariadb_db"], $_ENV["databases"][$database->value],
); );
} }
@ -46,7 +60,24 @@
); );
} }
public static function is_mysqli_result(\mysqli_result|bool $resp): bool { // Mutate the value by array key $property_name into a libmysqldriver\MySQL custom operator
return $resp instanceof \mysqli_result; // https://codeberg.org/vlw/php-mysql#define-custom-operators
public static function make_wildcard_search(string $property_name, array &$filters): array {
// Bail out if property name is not set in filters array or if its value is null
if (!array_key_exists($property_name, $filters) || $filters[$property_name] === null) {
return $filters;
}
// Mutate filter value into a custom operator array
$filters[$property_name] = [
"LIKE" => "%{$filters[$property_name]}%"
];
return $filters;
}
// Bail out if provided ReflectRules\Ruleset is invalid
private static function eval_ruleset_or_exit(Ruleset $ruleset): ?Response {
return !$ruleset->is_valid() ? new Response($ruleset->get_errors(), 422) : null;
} }
} }

View file

@ -0,0 +1,19 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Battlestation;
use victorwesterlund\xEnum;
enum ChassisModel: string {
use xEnum;
const TABLE = "chassis";
case ID = "id";
case VENDOR_NAME = "vendor_name";
case VENDOR_MODEL = "vendor_model";
case STORAGE_TWOINCHFIVE = "storage_2i5hi";
case STORAGE_THREEINCHFIVE = "storage_3i5hi";
case DATE_AQUIRED = "date_aquired";
case IS_RETIRED = "is_retired";
}

View file

@ -0,0 +1,14 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Battlestation\Config;
use victorwesterlund\xEnum;
enum ChassisMbModel: string {
use xEnum;
const TABLE = "config_chassis_mb";
case REF_CHASSIS_ID = "ref_chassis_id";
case REF_MB_ID = "ref_mb_id";
}

View file

@ -0,0 +1,15 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Battlestation\Config;
use victorwesterlund\xEnum;
enum ConfigModel: string {
use xEnum;
const TABLE = "config";
case REF_MB_ID = "ref_mb_id";
case FRIENDLY_NAME = "friendly_name";
case DATE_BUILT = "date_built";
}

View file

@ -0,0 +1,24 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Battlestation\Config;
use victorwesterlund\xEnum;
enum SocketTypeEnum {
use xEnum;
case SLOTTED;
case INTEGRATED;
}
enum MbCpuCoolerModel: string {
use xEnum;
const TABLE = "config_mb_cpu_cooler";
case REF_MB_ID = "ref_mb_id";
case REF_CPU_ID = "ref_cpu_id";
case REF_COOLER_ID = "ref_cooler_id";
case SOCKET = "socket";
case SOCKET_TYPE = "socket_type";
}

View file

@ -0,0 +1,23 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Battlestation\Config;
use victorwesterlund\xEnum;
enum SocketTypeModel {
use xEnum;
case SLOTTED;
case INTEGRATED;
}
enum MbDramModel: string {
use xEnum;
const TABLE = "config_mb_dram";
case REF_MB_ID = "ref_mb_id";
case REF_DRAM_ID = "ref_dram_id";
case SOCKET = "socket";
case SOCKET_TYPE = "socket_type";
}

View file

@ -0,0 +1,14 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Battlestation\Config;
use victorwesterlund\xEnum;
enum MbGpuModel: string {
use xEnum;
const TABLE = "config_mb_gpu";
case REF_MB_ID = "ref_mb_id";
case REF_GPU_ID = "ref_gpu_id";
}

View file

@ -0,0 +1,14 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Battlestation\Config;
use victorwesterlund\xEnum;
enum MbPsuModel: string {
use xEnum;
const TABLE = "config_mb_psu";
case REF_MB_ID = "ref_mb_id";
case REF_PSU_ID = "ref_psu_id";
}

View file

@ -0,0 +1,25 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Battlestation\Config;
use victorwesterlund\xEnum;
enum MbStorageSlotFormfactorEnum: string {
use xEnum;
case TWODOTFIVE = "2.5";
case THREEDOTFIVE = "3.5";
case MDOTTWO = "M.2";
case EXTERNAL = "EXTERNAL";
}
enum MbStorageModel: string {
use xEnum;
const TABLE = "config_mb_storage";
case REF_MB_ID = "ref_mb_id";
case REF_STORAGE_ID = "ref_storage_id";
case INTERFACE = "interface";
case SLOT_FORMFACTOR = "slot_formfactor";
}

View file

@ -0,0 +1,16 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Battlestation;
enum CoolersModel: string {
const TABLE = "coolers";
case ID = "id";
case TYPE_LIQUID = "type_liquid";
case SIZE_FAN = "size_fan";
case SIZE_RADIATOR = "size_radiator";
case VENDOR_NAME = "vendor_name";
case VENDOR_MODEL = "vendor_model";
case DATE_AQUIRED = "date_aquired";
case IS_RETIRED = "is_retired";
}

View file

@ -0,0 +1,31 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Battlestation;
use victorwesterlund\xEnum;
enum ClassEnum {
use xEnum;
case DESKTOP;
case LAPTOP;
case SERVER;
}
enum CpuModel: string {
use xEnum;
const TABLE = "cpu";
case ID = "id";
case CPU_CLASS = "class";
case CLOCK_BASE = "clock_base";
case CLOCK_TURBO = "clock_turbo";
case CORE_COUNT_PERFORMANCE = "core_count_performance";
case CORE_COUNT_EFFICIENCY = "core_count_efficiency";
case CORE_THREADS = "core_threads";
case VENDOR_NAME = "vendor_name";
case VENDOR_MODEL = "vendor_model";
case DATE_AQUIRED = "date_aquired";
case IS_RETIRED = "is_retired";
}

View file

@ -0,0 +1,37 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Battlestation;
use victorwesterlund\xEnum;
enum DramFormfactorEnum {
use xEnum;
case DIMM;
case SODIMM;
}
enum DramTechnologyEnum {
use xEnum;
case DDR4;
case DDR5;
}
enum DramModel: string {
use xEnum;
const TABLE = "dram";
case ID = "id";
case CAPACITY = "capacity";
case SPEED = "speed";
case FORMFACTOR = "formfactor";
case TECHNOLOGY = "technology";
case ECC = "ecc";
case BUFFERED = "buffered";
case VENDOR_NAME = "vendor_name";
case VENDOR_MODEL = "vendor_model";
case DATE_AQUIRED = "date_aquired";
case IS_RETIRED = "is_retired";
}

View file

@ -0,0 +1,20 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Battlestation;
use victorwesterlund\xEnum;
enum GpuModel: string {
use xEnum;
const TABLE = "gpu";
case ID = "id";
case MEMORY = "memory";
case VENDOR_NAME = "vendor_name";
case VENDOR_MODEL = "vendor_model";
case VENDOR_CHIP_NAME = "vendor_chip_name";
case VENDOR_CHIP_MODEL = "vendor_chip_model";
case DATE_AQUIRED = "date_aquired";
case IS_RETIRED = "is_retired";
}

View file

@ -0,0 +1,30 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Battlestation;
use victorwesterlund\xEnum;
enum MbFormfactorEnum {
use xEnum;
case ATX;
case MTX;
case ITX;
case LAPTOP;
}
enum MbModel: string {
use xEnum;
const TABLE = "mb";
case ID = "id";
case FORMFACTOR = "formfactor";
case VENDOR_NAME = "vendor_name";
case VENDOR_MODEL = "vendor_model";
case NETWORK_ETHERNET = "network_ethernet";
case NETWORK_WLAN = "network_wlan";
case NETWORK_BLUETOOTH = "network_bluetooth";
case DATE_AQUIRED = "date_aquired";
case IS_RETIRED = "is_retired";
}

View file

@ -0,0 +1,31 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Battlestation;
use victorwesterlund\xEnum;
enum EightyplusRatingEnum {
use xEnum;
case BASE;
case BRONZE;
case SILVER;
case GOLD;
case PLATINUM;
case TITANIUM;
}
enum PsuModel: string {
use xEnum;
const TABLE = "psu";
case ID = "id";
case POWER = "power";
case VENDOR_NAME = "vendor_name";
case VENDOR_MODEL = "vendor_model";
case TYPE_MODULAR = "type_modular";
case EIGHTYPLUS_RATING = "80plus_rating";
case DATE_AQUIRED = "date_aquired";
case IS_RETIRED = "is_retired";
}

View file

@ -0,0 +1,45 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Battlestation;
use victorwesterlund\xEnum;
enum StorageDiskTypeEnum {
use xEnum;
case SSD;
case HDD;
}
enum StorageDiskInterfaceEnum {
use xEnum;
case SATA;
case NVME;
case USB;
}
enum StorageDiskFormfactorEnum{
use xEnum;
case TWODOTFIVE;
case THREEDOTFIVE;
case MDOTTWO;
}
enum StorageModel: string {
use xEnum;
const TABLE = "storage";
case ID = "id";
case DISK_TYPE = "disk_type";
case DISK_SIZE = "disk_size";
case DISK_SECTORS = "disk_sectors";
case DISK_INTERFACE = "disk_interface";
case DISK_FORMFACTOR = "disk_formfactor";
case VENDOR_NAME = "vendor_name";
case VENDOR_MODEL = "vendor_model";
case DATE_AQUIRED = "date_aquired";
case IS_RETIRED = "is_retired";
}

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

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

View file

@ -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

@ -0,0 +1,19 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Work;
enum WorkModel: string {
const TABLE = "work";
case ID = "id";
case TITLE = "title";
case SUMMARY = "summary";
case COVER_SRCSET = "cover_srcset";
case IS_LISTABLE = "is_listable";
case IS_READABLE = "is_readable";
case DATE_YEAR = "date_year";
case DATE_MONTH = "date_month";
case DATE_DAY = "date_day";
case DATE_MODIFIED = "date_modified";
case DATE_CREATED = "date_created";
}

View file

@ -5,8 +5,7 @@
enum WorkActionsModel: string { enum WorkActionsModel: string {
const TABLE = "work_actions"; const TABLE = "work_actions";
case ID = "id"; case REF_WORK_ID = "ref_work_id";
case ANCHOR = "anchor";
case DISPLAY_TEXT = "display_text"; case DISPLAY_TEXT = "display_text";
case HREF = "href"; case HREF = "href";
case CLASS_LIST = "class_list"; case CLASS_LIST = "class_list";

View file

@ -1,6 +1,6 @@
<?php <?php
namespace VLW\API\Databases\VLWdb\Models\WorkPermalinks; namespace VLW\API\Databases\VLWdb\Models\Work;
enum WorkPermalinksModel: string { enum WorkPermalinksModel: string {
const TABLE = "work_permalinks"; const TABLE = "work_permalinks";

View file

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

View file

@ -1,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,325 +0,0 @@
:root {
--primer-color-accent: 255, 255, 0;
--color-accent: yellow;
--hue-accent: 0deg;
--padding: 20px;
--running-size: 80px;
}
/* # Cornerstones */
* {
margin: 0;
box-sizing: border-box;
font-family: "Roboto Mono", sans-serif;
color: inherit;
font-size: inherit;
}
::-webkit-scrollbar {
display: none;
}
body {
display: grid;
justify-items: center;
grid-template-rows: var(--running-size) 1fr;
overscroll-behavior: none;
background-color: black;
color: white;
overflow-x: hidden;
min-height: 100svh;
font-size: 15px;
}
body.search-dialog-open {
overflow: hidden;
}
a {
display: contents;
color: inherit;
text-decoration: none;
}
/* # Components */
:is(h1, h2, h3, p, li) > a {
--underline-tickness: 3px;
display: initial;
text-decoration: underline;
text-decoration-thickness: var(--underline-tickness);
text-underline-offset: var(--underline-tickness);
text-decoration-color: var(--color-accent);
}
h1 {
font-size: 30px;
color: var(--color-accent);
}
h2 {
font-size: 30px;
}
h3 {
font-size: 25px;
}
/* ## Buttons */
button {
padding: calc(var(--padding) / 2) var(--padding);
color: white;
border: solid 2px white;
border-radius: 6px;
background-color: transparent;
cursor: pointer;
}
button.solid {
color: black;
border-color: var(--color-accent);
background-color: var(--color-accent);
}
a > button::after {
content: " ➜";
}
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 {
--border-style: solid 1px rgba(255, 255, 255, .2);
position: sticky;
top: 0;
width: 100%;
height: var(--running-size);
border-bottom: var(--border-style);
display: grid;
align-items: stretch;
justify-items: end;
grid-template-columns: 1fr var(--running-size);
background-color: rgba(0, 0, 0, .8);
z-index: 100;
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
}
header nav {
display: flex;
align-items: center;
padding: var(--padding);
}
header .logo {
width: calc(var(--running-size) - 1px);
height: calc(var(--running-size) - 1px);
display: grid;
align-items: center;
justify-items: center;
border-left: var(--border-style);
}
header .logo path.stroke {
fill: var(--color-accent);
}
header searchbox {
display: none;
}
/* ## Main */
main {
transition: 400ms transform;
position: relative;
padding: calc(var(--padding) * 1.5);
width: 100%;
max-width: 1000px;
}
main > * {
transition: 100ms opacity;
opacity: 1;
}
main.loading > * {
opacity: 0;
}
/* ## Search */
/* ### Box */
searchbox {
display: grid;
width: 100%;
border-left: var(--border-style);
grid-template-columns: 25px 1fr;
align-items: center;
padding: var(--padding);
gap: var(--padding);
fill: var(--color-accent);
font-size: 13px;
color: rgba(255, 255, 255, .5);
cursor: pointer;
}
/* ### Dialog */
body.search-dialog-open main {
transform: scale(.94);
}
dialog.search {
transition: 200ms height cubic-bezier(.41,0,.34,.99);
margin: auto;
width: 100%;
max-width: 1000px;
height: calc(var(--running-size) + (var(--padding) * 5));
max-height: 1000px;
border-color: transparent;
background-color: transparent;
overflow: visible;
outline: none;
}
dialog.search.active {
height: 70vh;
}
dialog.search search {
transition: 400ms transform, 200ms opacity;
width: 100%;
height: 100%;
display: grid;
grid-template-rows: var(--running-size) 1fr;
gap: calc(var(--padding) * 2);
transform: scale(1.1);
overflow: hidden;
background-color: rgba(255, 255, 255, .05);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: brightness(.3) blur(20px);
border-radius: 12px;
box-shadow: 0 10px 30px 10px black;
opacity: 0;
}
body.search-dialog-open dialog.search search {
transform: scale(1);
padding: calc(var(--padding) * 1.5);
opacity: 1;
}
search input {
transition: 200ms background-color, 200ms box-shadow, 200ms color;
border-radius: 6px;
border: none;
outline: none;
color: black;
font-size: 16px;
padding: var(--padding) calc(var(--padding) * 1.5);
background-color: rgba(255, 255, 255, .05);
box-shadow: 0 5px 70px 10px rgba(0, 0, 0, .3);
color: white;
}
search input:focus {
background-color: rgba(255, 255, 255, .9);
box-shadow: 0 10px 30px 10px black;
color: black;
}
/* ### Search results */
dialog.search search search-results {
overflow-y: auto;
}
dialog.search search search-results > svg {
margin: auto;
width: 150px;
fill: rgba(255, 255, 255, .05);
}
/* # Feature queries */
@media (hover: hover) {
:is(h1, h2, h3, p, li) > a:hover {
text-underline-offset: 1px;
text-decoration-thickness: calc(var(--underline-tickness) * 2);
color: var(--color-accent);
}
/* # Components */
button {
transition: 200ms background-color, 200ms border-color, 200ms color;
}
button:hover {
border-color: rgba(255, 255, 255, .2);
background-color: rgba(255, 255, 255, .1);
}
button.solid:hover {
color: var(--color-accent);
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);
}
/* ## Header */
header .logo:hover path.solid {
fill: var(--color-accent);
}
searchbox {
transition: 200ms background-color;
}
searchbox:hover {
background-color: rgba(255, 255, 255, .07);
}
}
/* # Size queries */
@media (min-width: 700px) {
header {
grid-template-columns: 1fr 250px var(--running-size);
}
header nav {
justify-self: start;
margin: 0 calc(var(--padding) / 2);
}
/* # Menu */
/* < Move the search box to the header */
header searchbox {
display: grid;
}
menu searchbox {
display: none;
}
/* /> */
}

View file

@ -1,16 +1,8 @@
@font-face { @font-face {
font-family: "Roboto Mono"; font-family: "Roboto Mono";
ascent-override: 100%; src:
font-weight: 400; url("/assets/fonts/roboto-mono.woff2") format("woff2 supports variations"),
size-adjust: 105%; url("/assets/fonts/roboto-mono.woff2") format("woff2-variations")
font-stretch: 97.5% 112.5%; ;
src: local("Roboto Mono Regular"), url("/assets/fonts/roboto-mono-regular.woff2") format("woff2"); font-weight: 100 900;
}
@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

@ -5,7 +5,7 @@
--color-accent: rgb(var(--primer-color-accent)); --color-accent: rgb(var(--primer-color-accent));
} }
main { vv-shell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--padding); gap: var(--padding);
@ -15,7 +15,7 @@ main {
/* ## Divider */ /* ## Divider */
main > hr { vv-shell > hr {
border-color: rgba(255, 255, 255, .1); border-color: rgba(255, 255, 255, .1);
} }
@ -41,15 +41,15 @@ section.about span.interests {
animation: interests-hue 5s infinite linear; animation: interests-hue 5s infinite linear;
} }
/* ## Version */ section.about p i:not(:hover) {
opacity: .3;
section.version {
color: rgba(255, 255, 255, .2);
} }
/* # Interests */ /* # Interests */
div.interests { div.interests {
--text-shadow-blur: 30px;
transition: 300ms opacity; transition: 300ms opacity;
position: fixed; position: fixed;
top: 0; top: 0;
@ -58,7 +58,7 @@ div.interests {
height: 100%; height: 100%;
font-weight: bold; font-weight: bold;
pointer-events: none; pointer-events: none;
font-size: 50px; font-size: clamp(16px, 15vw, 50px);
color: var(--color-accent); color: var(--color-accent);
overflow: hidden; overflow: hidden;
opacity: 0; opacity: 0;
@ -70,9 +70,7 @@ div.interests.active {
} }
div.interests p { div.interests p {
--text-shadow-blur: 30px; transition: 500ms transform cubic-bezier(.34,0,0,.99);
transition: 300ms transform;
position: absolute; position: absolute;
text-shadow: text-shadow:
0 0 var(--text-shadow-blur) black, 0 0 var(--text-shadow-blur) black,

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

@ -5,19 +5,33 @@
--color-accent: rgb(var(--primer-color-accent)); --color-accent: rgb(var(--primer-color-accent));
} }
main { vv-shell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: var(--padding); gap: var(--padding);
} }
.fingerprint {
word-break: break-all;
}
/* # Sections */ /* # Sections */
main > svg { vv-shell > svg {
margin: var(--padding) 0; margin: var(--padding) 0;
} }
/* ## Modifiers */
section.center {
text-align: center;
}
section.fade {
opacity: .3;
}
/* ## Social */ /* ## Social */
section.social { section.social {
@ -61,11 +75,14 @@ section.social social.hovering p {
display: initial; display: initial;
} }
/* ## OpenPGP key */ /* ## PGP key */
section.pgp { section.pgp {
max-width: 800px; max-width: 800px;
position: relative; position: relative;
display: flex;
flex-direction: column;
gap: var(--padding);
text-align: center; text-align: center;
background-color: rgba(var(--primer-color-accent), .15); background-color: rgba(var(--primer-color-accent), .15);
padding: calc(var(--padding) * 1.5); padding: calc(var(--padding) * 1.5);
@ -81,13 +98,13 @@ section.pgp > svg {
} }
section.pgp > p { section.pgp > p {
margin-bottom: var(--padding); padding: 0 var(--padding);
padding: var(--padding);
} }
section.pgp .buttons { section.pgp .buttons {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-top: var(--padding);
gap: var(--padding); gap: var(--padding);
} }
@ -158,6 +175,7 @@ section.form-message h3 {
} }
section.form-message pre { section.form-message pre {
white-space: pre-wrap;
padding: var(--padding); padding: var(--padding);
background-color: rgba(0, 0, 0, .15); background-color: rgba(0, 0, 0, .15);
} }
@ -171,6 +189,10 @@ section.form-message.sent {
background-color: var(--color-accent); background-color: var(--color-accent);
} }
section.form-message.sent + section.form {
display: none;
}
/* # Size queries */ /* # Size queries */
@media (min-width: 460px) { @media (min-width: 460px) {

View file

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

View file

@ -1,15 +1,21 @@
/* # Main styles */ /* # Overrides */
body[vv-top-page="/"]::before {
opacity: 0;
}
/* # vv-shell styles */
/* ## Picture */ /* ## Picture */
main { vv-shell {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
flex-direction: column-reverse; flex-direction: column-reverse;
} }
main img { vv-shell img {
margin: auto; margin: auto;
width: 25vh; width: 25vh;
pointer-events: none; pointer-events: none;
@ -146,25 +152,33 @@ splash::after {
.menu menu li:hover { .menu menu li:hover {
opacity: 1; opacity: 1;
font-weight: 100;
text-shadow: 0 0 10px rgba(var(--primer-color-accent), .4); text-shadow: 0 0 10px rgba(var(--primer-color-accent), .4);
} }
button.email:hover {
background-color: transparent;
}
/* enable font-weight hover animation */
@media not (prefers-reduced-motion: reduce) {
.menu menu li {
transition: 200ms opacity, 200ms color, 300ms font-weight;
}
}
} }
/* # Size quries */ /* # Size quries */
@media (min-width: 900px) { @media (min-width: 900px) {
main { vv-shell {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
justify-items: center; justify-items: center;
align-items: center; align-items: center;
} }
main img { vv-shell img {
width: 35vh; width: 35vh;
} }
button:hover {
background-color: transparent;
}
} }

View file

@ -12,7 +12,7 @@
section.search { section.search {
width: 100%; width: 100%;
display: flex; display: none;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: var(--padding); gap: var(--padding);
@ -21,6 +21,10 @@ section.search {
margin-bottom: calc(var(--padding) * 2); margin-bottom: calc(var(--padding) * 2);
} }
vv-shell[vv-page="/search"] > section.search {
display: flex;
}
section.search form { section.search form {
display: contents; display: contents;
} }
@ -31,6 +35,11 @@ section.search search {
section.search input { section.search input {
width: 100%; width: 100%;
border: none;
color: black;
outline: none;
padding: var(--padding);
background-color: var(--color-accent);
} }
section.search button[type="submit"] { section.search button[type="submit"] {
@ -42,10 +51,6 @@ section.search > svg {
width: 100%; width: 100%;
} }
body:not([vv-page="/search"]) section.search {
display: none;
}
/* # Search results */ /* # Search results */
section.results .result { section.results .result {
@ -54,19 +59,6 @@ section.results .result {
gap: calc(var(--padding) / 2); gap: calc(var(--padding) / 2);
} }
/* ---- */
main > svg,
dialog.search search search-results > svg {
margin: auto;
width: 150px;
fill: rgba(255, 255, 255, .05);
}
dialog.search search search-results .empty {
text-align: center;
}
/* ## Titles */ /* ## Titles */
section.title a h2 { section.title a h2 {

View file

@ -5,7 +5,7 @@
--color-accent: rgb(var(--primer-color-accent)); --color-accent: rgb(var(--primer-color-accent));
} }
main { vv-shell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--padding); gap: var(--padding);
@ -28,6 +28,7 @@ section.git {
} }
section.git svg { section.git svg {
fill: white;
width: 60px; width: 60px;
} }

View file

@ -0,0 +1,434 @@
:root {
--primer-color-accent: 255, 255, 0;
--color-accent: yellow;
--hue-accent: 0deg;
--padding: 20px;
--running-size: 80px;
--header-search-size: var(--running-size);
}
/* # Cornerstones */
* {
margin: 0;
box-sizing: border-box;
font-family: "Roboto Mono", sans-serif;
color: inherit;
font-size: inherit;
}
::-webkit-scrollbar {
display: none;
}
body {
display: grid;
justify-items: center;
grid-template-rows: var(--running-size) 1fr;
overscroll-behavior: none;
background-color: black;
color: white;
overflow-x: hidden;
min-height: 100svh;
font-size: 15px;
}
body::before {
transition: 1s opacity;
content: "";
position: absolute;
top: -5%;
right: 0;
width: 20%;
height: 5%;
border-radius: 100%;
z-index: 1000;
box-shadow:
0 0 30svh 10svh rgba(var(--primer-color-accent), .2),
0 0 30svh 60svh rgba(var(--primer-color-accent), .1),
0 0 30svh 150svh rgba(var(--primer-color-accent), .02)
;
opacity: 0;
}
/* "enable" the corner glow effect on initial load when a page has been fully loaded */
body[vv-top-page]::before {
opacity: 1;
}
a {
display: contents;
color: inherit;
text-decoration: none;
}
/* # Components */
:is(h1, h2, h3, p, li) > a {
--underline-tickness: 3px;
display: initial;
text-decoration: underline;
text-decoration-thickness: var(--underline-tickness);
text-underline-offset: var(--underline-tickness);
text-decoration-color: var(--color-accent);
}
h1 {
font-size: 30px;
color: var(--color-accent);
}
h2 {
font-size: 30px;
}
h3 {
font-size: 25px;
}
/* ## Buttons */
button {
border: none;
background-color: transparent;
color: inherit;
fill: inherit;
cursor: pointer;
}
/* ### Inline */
button.inline {
padding: calc(var(--padding) / 2) var(--padding);
color: white;
border: solid 2px white;
border-radius: 6px;
}
button.inline.solid {
color: black;
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 {
--border-style: solid 1px rgba(255, 255, 255, .2);
position: sticky;
top: 0;
width: 100%;
height: var(--running-size);
border-bottom: var(--border-style);
display: grid;
align-items: stretch;
grid-template-columns: 1fr var(--header-search-size) var(--running-size);
grid-template-rows: var(--running-size);
background-color: rgba(0, 0, 0, .8);
z-index: 100;
perspective: 3000px;
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
overflow: hidden;
}
header > * {
--anim-3d-depth: 5px;
--anim-3d-peek: 25deg;
transition: 300ms background-color;
transform: rotateX(0deg);
backface-visibility: hidden;
box-shadow: 0 var(--anim-3d-depth) 0 0 rgba(255, 255, 255, .2);
}
/* enable 3d flip animation */
@media not (prefers-reduced-motion: reduce) {
header > * {
--transform-duration: 600ms;
transition: var(--transform-duration) transform, 300ms background-color;
}
}
header nav {
display: flex;
align-items: center;
padding: var(--padding);
}
header .logo {
fill: none;
}
header .logo path.stroke {
fill: var(--color-accent);
}
header header .search {
display: none;
}
/* ### Buttons */
header button {
--icon-size: 25px;
display: grid;
width: 100%;
border-left: var(--border-style);
grid-template-columns: 1fr;
align-items: center;
justify-items: center;
padding: var(--padding);
gap: var(--padding);
fill: var(--color-accent);
font-size: 13px;
color: rgba(255, 255, 255, .5);
cursor: pointer;
}
header button:not(.logo) svg {
width: var(--icon-size);
}
header button.search p {
display: none;
}
/* ### Searchbox */
header searchbox {
position: absolute;
right: 0;
width: 100%;
height: var(--running-size);
background-color: var(--color-accent);
display: grid;
align-items: stretch;
grid-template-columns: 1fr var(--running-size);
grid-template-rows: var(--running-size);
box-shadow: none;
transform: rotateX(180deg);
}
header searchbox > * {
box-shadow: 0 calc(var(--anim-3d-depth) * -1) 0 0 rgba(var(--primer-color-accent), .8);
}
header searchbox button {
transition: 300ms background-color, 300ms border-color;
border-color: rgba(0, 0, 0, .1);
fill: black;
}
header searchbox input {
padding: 0 var(--padding);
background-color: transparent;
outline: none;
color: black;
border: none;
}
/* #### Active */
header.searchboxActive > * {
transform: rotateX(-180deg);
}
header.searchboxActive searchbox {
transform: rotateX(0);
}
/* ## vv-shell */
vv-shell {
position: relative;
padding: calc(var(--padding) * 1.5);
width: 100%;
max-width: 1000px;
}
/* ## Search results */
search-results {
transition: 500ms opacity, 300ms transform;
position: fixed;
top: var(--running-size);
right: 0;
width: 100%;
padding: var(--padding);
height: calc(100svh - var(--running-size));
background-color: black;
pointer-events: none;
opacity: 0;
transform: scale(.99);
transform-origin: 100% 0;
overflow-y: scroll;
}
search-results:not([vv-page]) {
display: grid;
align-items: center;
justify-items: center;
}
header.searchboxActive ~ search-results {
opacity: 1;
pointer-events: all;
transform: scale(1);
}
/* ### "Start typing" prompt */
search-results .info {
display: flex;
align-items: center;
flex-direction: column;
margin: auto;
gap: 3svh;
}
search-results .info :is(svg, img) {
width: 128px;
fill: var(--color-accent);
}
/* # Feature queries */
@media (hover: hover) {
:is(h1, h2, h3, p, li) > a:hover {
text-underline-offset: 1px;
text-decoration-thickness: calc(var(--underline-tickness) * 2);
color: var(--color-accent);
}
/* # Components */
button.inline {
transition: 200ms background-color, 200ms border-color, 200ms color;
}
button:hover {
border-color: rgba(255, 255, 255, .2);
background-color: rgba(255, 255, 255, .1);
}
button.solid:hover {
color: var(--color-accent);
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);
}
/* ## Header */
header .logo:hover path.solid {
fill: var(--color-accent);
}
header searchbox button:hover {
background-color: rgba(0, 0, 0, .08);
}
/* ### Search */
@media not (prefers-reduced-motion: reduce) {
header:not(.searchboxActive) button.search:hover,
header:not(.searchboxActive) button.search:hover + button.logo {
transform: rotateX(calc(var(--anim-3d-peek) * -1));
}
header:not(.searchboxActive) button.search:hover ~ searchbox {
transform: rotateX(calc(180deg - var(--anim-3d-peek)));
}
}
}
/* # Size queries */
@media (min-width: 700px) {
:root {
--header-search-size: 250px;
}
/* # Cornerstones */
body::before {
right: unset;
left: 0;
box-shadow:
0 0 30svh 10svh rgba(var(--primer-color-accent), .1),
0 0 30svh 60svh rgba(var(--primer-color-accent), .05),
0 0 30svh 150svh rgba(var(--primer-color-accent), .02)
;
}
/* ## Header */
header nav {
margin: 0 calc(var(--padding) / 2);
}
header > button.search {
grid-template-columns: var(--icon-size) 1fr;
}
header > button.search p {
display: initial;
}
header.searchboxActive > nav {
transform: rotateX(0deg);
pointer-events: all;
}
/* ### Searchbox */
header searchbox {
width: calc(var(--header-search-size) + var(--running-size));
}
/* ### Menu */
/* Move the search box to the header */
header > button.search {
display: grid;
justify-items: baseline;
}
@media (min-height: 600px) {
search-results {
top: calc(var(--running-size) + var(--padding));
width: 50%;
height: calc(100svh - 100px);
background-color: rgba(0, 0, 0, .8);
box-shadow:
inset 0 0 100px 200px rgba(0, 0, 0, 1),
0 0 100px 200px rgba(0, 0, 0, 1)
;
--webkit-backdrop-filter: blur(15px);
backdrop-filter: blur(15px);
}
}
}

Binary file not shown.

Binary file not shown.

View file

@ -1,72 +0,0 @@
new vv.Interactions("document");
const mainElement = document.querySelector(vv._env.MAIN);
// Crossfade pages on navigation
// Or maybe I shouldn't... hmmm
mainElement.addEventListener(vv.Navigation.events.LOADING, () => {
mainElement.classList.add("loading");
// Clean up modified transform-origin if set after search dialog animation
mainElement.style.removeProperty("transform-origin");
});
mainElement.addEventListener(vv.Navigation.events.LOADED, () => {
[...document.querySelectorAll("dialog")].forEach(element => element.close())
// Wait 200ms for the page fade-in animation to finish
setTimeout(() => mainElement.classList.remove("loading"), 200);
});
// Search dialog open/close logic
{
const CLASNAME_DIALOG_OPEN = "search-dialog-open";
// Offset in pixels from scroll position when scaling the main element
const TRANSFORM_ORIGIN_Y_PADDING = 350;
const dialog = document.querySelector("dialog.search");
// "Polyfill" for HTMLDialogELement open and close events
(new MutationObserver((mutations) => {
// There is only one search dialog elemenet
const target = mutations[0].target;
// Set or unset dialog open class on body depending on dialog visibility
target.hasAttribute("open")
? target.dispatchEvent(new Event("open"))
: target.dispatchEvent(new Event("close"));
}).observe(dialog, { attributes: true }));
dialog.addEventListener("open", () => {
// Scale main element from the current scroll position
mainElement.style.setProperty("transform-origin", `50% calc(${window.scrollY}px + ${TRANSFORM_ORIGIN_Y_PADDING}px)`);
document.body.classList.add(CLASNAME_DIALOG_OPEN);
});
dialog.addEventListener("close", () => document.body.classList.remove(CLASNAME_DIALOG_OPEN));
// Close search dialog if dialog is clicked outside inner content
dialog.addEventListener("click", (event) => event.target === dialog ? dialog.close() : null);
// Open search dialog when searchbox is clicked
document.querySelector("searchbox").addEventListener("click", () => dialog.showModal());
}
// Search logic
{
const searchResultsElement = document.querySelector("search-results");
const search = (query) => {
new vv.Navigation(`/search?q=${query}`, {
carrySearchParams: true
}).navigate(searchResultsElement);
};
// Run search on keyup
document.querySelector("search input").addEventListener("keyup", (event) => search(event.target.value));
// Trigger expand search box animation
document.querySelector("search input").addEventListener("keydown", () => {
searchResultsElement.closest("dialog").classList.add("active");
}, { once: true });
}

View file

@ -17,12 +17,8 @@ class Generator {
get dir () { return this._dir; }, get dir () { return this._dir; },
set dir (newPath) { set dir (newPath) {
const url = new URL(newPath); const url = new URL(newPath);
url.pathname = this._dir_rel;
// Replace pathname of this file with relative path to assets
const path = url.pathname.split("/");
path[path.length - 1] = this._dir_rel;
url.pathname = path.join("/");
this._dir = url.toString(); this._dir = url.toString();
} }
} }

View file

@ -1,31 +1,38 @@
new vv.Interactions("about");
const randomIntFromInterval = (min, max) => { const randomIntFromInterval = (min, max) => {
return Math.floor(Math.random() * (max - min + 1) + min); return Math.floor(Math.random() * (max - min + 1) + min);
} }
// Interest explosion effect from origin position // Interest explosion effect from origin position
const explodeInterests = (originX, originY) => { const explodeInterests = (originX, originY) => {
// Elements can not translate more than negative- and positive from this number
const TRANS_LIMIT = 300;
const wrapper = document.querySelector("div.interests"); const wrapper = document.querySelector("div.interests");
wrapper.classList.add("active"); wrapper.classList.add("active");
// Elements can not expand further than positive or negative of these values
const transLimitX = window.innerWidth / 4;
const transLimitY = window.innerHeight / 3;
[...wrapper.querySelectorAll("p")].forEach(element => { [...wrapper.querySelectorAll("p")].forEach(element => {
/* const size = element.getBoundingClientRect();
Generate random visuals for current element
*/ // Generate random HUE wheel rotation degrees
const hue = randomIntFromInterval(0, 360); const hue = randomIntFromInterval(0, 360);
// Generate random element transform rotation
const rotate = randomIntFromInterval(-5, 5); const rotate = randomIntFromInterval(-5, 5);
const transX = randomIntFromInterval(TRANS_LIMIT * -1, TRANS_LIMIT);
const transY = randomIntFromInterval(TRANS_LIMIT * -1, TRANS_LIMIT); // Generate random offsets in each direction clamped to translation limit
let transX = randomIntFromInterval(transLimitX * -1, transLimitX);
let transY = randomIntFromInterval(transLimitY * -1, transLimitY);
// Clamp translation to screen left and right X size
transX = Math.max(0 - originX, Math.min((window.innerWidth - originX) - size.width, transX));
// Clamp translation to top and bottom Y size
transY = Math.max(0 - originY, Math.min((window.innerHeight - originY) - size.height, transY));
// Set initial position // Set initial position
element.style.setProperty("top", `${originY}px`); element.style.setProperty("top", `${originY}px`);
element.style.setProperty("left", `${originX}px`); element.style.setProperty("left", `${originX}px`);
// Set random HUE rotation // Set HUE rotation
element.style.setProperty("-webkit-filter", `hue-rotate(${hue}deg)`); element.style.setProperty("-webkit-filter", `hue-rotate(${hue}deg)`);
element.style.setProperty("filter", `hue-rotate(${hue}deg)`); element.style.setProperty("filter", `hue-rotate(${hue}deg)`);
@ -39,10 +46,8 @@ const implodeInterests = () => {
const wrapper = document.querySelector("div.interests"); const wrapper = document.querySelector("div.interests");
wrapper.classList.remove("active"); wrapper.classList.remove("active");
[...wrapper.querySelectorAll("p")].forEach(element => { // Reset to initial position
// Reset to initial position [...wrapper.querySelectorAll("p")].forEach(element => element.style.setProperty("transform", "translate(0, 0)"));
element.style.setProperty("transform", "translate(0, 0)");
});
}; };
// Bind triggers for interests explosion and implotion // Bind triggers for interests explosion and implotion
@ -55,10 +60,7 @@ const implodeInterests = () => {
// Get absolute position of the trigger element // Get absolute position of the trigger element
const size = interestsElement.getBoundingClientRect(); const size = interestsElement.getBoundingClientRect();
const x = size.x - 80; explodeInterests(size.x, size.y);
const y = size.y - 10;
explodeInterests(x, y);
}); });
interestsElement.addEventListener(canHover ? "mouseleave" : "touchend", () => implodeInterests()); interestsElement.addEventListener(canHover ? "mouseleave" : "touchend", () => implodeInterests());

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

@ -10,8 +10,6 @@ class ContactForm {
[...document.querySelectorAll("form :is(input, textarea)")].forEach(element => { [...document.querySelectorAll("form :is(input, textarea)")].forEach(element => {
element.addEventListener("keyup", () => this.saveMessage()); element.addEventListener("keyup", () => this.saveMessage());
}); });
} }
// Get saved message as JSON from SessionStorage // Get saved message as JSON from SessionStorage
@ -36,6 +34,7 @@ class ContactForm {
return ContactForm.removeSavedMessage(); return ContactForm.removeSavedMessage();
} }
// Set value of each input field in DOM by name attribute
for (const [name, value] of Object.entries(message)) { for (const [name, value] of Object.entries(message)) {
this.form.querySelector(`[name="${name}"]`).value = value; this.form.querySelector(`[name="${name}"]`).value = value;
} }

View file

@ -1,120 +1,108 @@
const EMAIL_CPY_ANIM_DUR_MSECONDS = 1000; import { Elevent } from "/assets/js/modules/npm/Elevent.mjs";
// Run email copied splash animation // Click to copy email button
const emailCopiedAnimation = () => { {
const CONFETTI_COUNT = 40; const EMAIL_CPY_ANIM_DUR_MSECONDS = 1000;
const CONFETTI_SCALE_PIXELS = 300;
const randomIntFromInterval = (min, max) => { // Run email copied splash animation
return Math.floor(Math.random() * (max - min + 1) + min) 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);
} }
// Create new splash element new Elevent("click", document.querySelector(".email"), async () => {
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 { try {
await navigator.clipboard.writeText("victor@vlw.se"); await navigator.clipboard.writeText("victor@vlw.se");
// Run "email copied" animation! // Run "email copied" animation!
emailCopiedAnimation(); emailCopiedAnimation();
// NOTE: I don't know, spamming the button is kinda fun // NOTE: I don't know, spamming the button is kinda fun
// Prevent interactions with the copy email elements while the animation is running // Prevent interactions with the copy email elements while the animation is running
/*[...document.querySelectorAll("[vv-call='copyEmail']")].forEach(element => { /*[...document.querySelectorAll("[vv-call='copyEmail']")].forEach(element => {
//element.classList.add("lock"); //element.classList.add("lock");
setTimeout(() => element.classList.remove("lock"), EMAIL_CPY_ANIM_DUR_MSECONDS); setTimeout(() => element.classList.remove("lock"), EMAIL_CPY_ANIM_DUR_MSECONDS);
});*/ });*/
} catch (error) { } catch (error) {
console.error(error.message); 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 });
} }
// Open search box from mobile fullscreen menu // Change site accent color on hover of menu items
{ {
// Open search dialog when searchbox is clicked if (window.matchMedia("(hover: hover)")) {
document.querySelector("menu searchbox").addEventListener("click", () => { // Update root CSS variables
// Search box dialog element const updateColor = (rgb = null, hue = 0) => {
document.querySelector("dialog.search").showModal(); if (!rgb) {
document.documentElement.style.removeProperty("--hue-accent");
document.documentElement.style.removeProperty("--primer-color-accent");
document.documentElement.style.removeProperty("--color-accent");
// Close fullscreen menu return;
document.querySelector("menu").classList.remove("active"); }
});
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.rootShellElement.addEventListener(vv.Navigation.EVENTS.STARTED, () => updateColor(), { once: true });
}
} }

View file

@ -1,25 +0,0 @@
// Don't open the search dialog overlay if search page is open stand-alone
{
const searchBox = document.querySelector("body:not(.search-dialog-open) searchbox");
// Page is stand-alone
if (searchBox) {
// Shift focus to the on-page search box instead of opening search dialog on click
const shiftSearchboxFocus = () => {
// Override normal "open search dialog" behavior
document.querySelector("dialog.search").close();
// Shift focus to the on-page search input instead
}
// Bind event listener to searchbox element
document.querySelector("body:not(.search-dialog-open) searchbox").addEventListener("click", shiftSearchboxFocus, true);
// Remove event listener from searchbox element on page navigation
mainElement.addEventListener(vv.Navigation.events.LOADING, () => {
searchBox.removeEventListener("click", shiftSearchboxFocus);
});
}
}
new vv.Interactions("search");

View file

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

View file

@ -0,0 +1,50 @@
import { Elevent } from "/assets/js/modules/npm/Elevent.mjs";
const CLASSNAME_SEARCHBOX_ACTIVE = "searchboxActive";
// 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?q=${event.target.value}`).navigate(searchResultsElement);
}, 100);
});
}

View file

@ -0,0 +1,173 @@
<svg viewBox="0 0 236 288" xmlns="http://www.w3.org/2000/svg">
<mask fill="#fff" id="a"><path d="M19 269a1 1 0 0 1-1-1v-11a1 1 0 0 1 1-1v13Z"></path></mask>
<mask fill="#fff" id="b"><path d="M87 241a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1v-7Z"></path></mask>
<mask fill="#fff" id="c"><path d="M87 251a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1v-7Z"></path></mask>
<mask fill="#fff" id="d"><path d="M179 272.051c0-.58.471-1.051 1.051-1.051h8.898c.58 0 1.051.471 1.051 1.051h-11Z"></path></mask>
<mask fill="#fff" id="e"><path d="M191 272.051c0-.58.471-1.051 1.051-1.051h2.898c.58 0 1.051.471 1.051 1.051h-5Z"></path></mask>
<mask fill="#fff" id="f"><path d="M179 257.333c0-.58.471-1.051 1.051-1.051h8.898c.58 0 1.051.471 1.051 1.051h-11Z"></path></mask>
<mask fill="#fff" id="g"><path d="M191 257.333c0-.58.471-1.051 1.051-1.051h2.898c.58 0 1.051.471 1.051 1.051h-5Z"></path></mask>
<mask fill="#fff" id="h"><path d="M179 242.615c0-.58.471-1.051 1.051-1.051h8.898c.58 0 1.051.471 1.051 1.051h-11Z"></path></mask>
<mask fill="#fff" id="i"><path d="M191 242.615c0-.58.471-1.051 1.051-1.051h2.898c.58 0 1.051.471 1.051 1.051h-5Z"></path></mask>
<mask fill="#fff" id="j"><path d="M133 257.333c0-.58.471-1.051 1.051-1.051h8.898c.58 0 1.051.471 1.051 1.051h-11Z"></path></mask>
<mask fill="#fff" id="k"><path d="M145 257.333c0-.58.471-1.051 1.051-1.051h2.898c.58 0 1.051.471 1.051 1.051h-5Z"></path></mask>
<mask fill="#fff" id="l"><path d="M133 242.615c0-.58.471-1.051 1.051-1.051h8.898c.58 0 1.051.471 1.051 1.051h-11Z"></path></mask>
<mask fill="#fff" id="m"><path d="M145 242.615c0-.58.471-1.051 1.051-1.051h2.898c.58 0 1.051.471 1.051 1.051h-5Z"></path></mask>
<mask fill="#fff" id="n"><path d="M133 272.051c0-.58.471-1.051 1.051-1.051h8.898c.58 0 1.051.471 1.051 1.051h-11Z"></path></mask>
<mask fill="#fff" id="o"><path d="M145 272.051c0-.58.471-1.051 1.051-1.051h2.898c.58 0 1.051.471 1.051 1.051h-5Z"></path></mask>
<mask fill="#fff" id="p"><path d="M186 40a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1h-11Z"></path></mask>
<mask fill="#fff" id="q"><path d="M198 40a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1h-5Z"></path></mask>
<mask fill="#fff" id="r"><path d="M186 51a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1h-11Z"></path></mask>
<mask fill="#fff" id="s"><path d="M198 51a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1h-5Z"></path></mask>
<mask fill="#fff" id="t"><path d="M186 62a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1h-11Z"></path></mask>
<mask fill="#fff" id="u"><path d="M198 62a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1h-5Z"></path></mask>
<mask fill="#fff" id="v"><path d="M186 73a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1h-11Z"></path></mask>
<mask fill="#fff" id="w"><path d="M198 73a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1h-5Z"></path></mask>
<mask fill="#fff" id="x"><path d="M129.535 123.781h-1.082a.453.453 0 0 1 0-.906h1.082v.906Z"></path></mask>
<mask fill="#fff" id="y"><path d="M129.535 115.625h-1.082a.453.453 0 0 1 0-.906h1.082v.906Z"></path></mask>
<mask fill="#fff" id="z"><path d="M129.535 121.969H129a1 1 0 0 1-1-1v-3.438a1 1 0 0 1 1-1h.535v5.438Z"></path></mask>
<mask fill="#fff" id="A"><path d="M92.535 123.781h-1.082a.453.453 0 0 1 0-.906h1.082v.906Z"></path></mask>
<mask fill="#fff" id="B"><path d="M92.535 115.625h-1.082a.453.453 0 0 1 0-.906h1.082v.906Z"></path></mask>
<mask fill="#fff" id="C"><path d="M92.535 121.969H92a1 1 0 0 1-1-1v-3.438a1 1 0 0 1 1-1h.535v5.438Z"></path></mask>
<mask fill="#fff" id="D"><path d="M68 143v-2a1 1 0 0 1 1-1h29a1 1 0 0 1 1 1v2H68Z"></path></mask>
<mask fill="#fff" id="E"><path d="M101 143v-2a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v2h-3Z"></path></mask>
<mask fill="#fff" id="F"><path d="M64 143v-2a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v2h-3Z"></path></mask>
<mask fill="#fff" id="G"><path d="M55.535 123.781h-1.082a.453.453 0 0 1 0-.906h1.082v.906Z"></path></mask>
<mask fill="#fff" id="H"><path d="M55.535 115.625h-1.082a.453.453 0 0 1 0-.906h1.082v.906Z"></path></mask>
<mask fill="#fff" id="I"><path d="M55.535 121.969H55a1 1 0 0 1-1-1v-3.438a1 1 0 0 1 1-1h.535v5.438Z"></path></mask>
<mask fill="#fff" id="J"><path d="M76 38h1a1 1 0 0 1 1 1v33a1 1 0 0 1-1 1h-1V38Z"></path></mask>
<mask fill="#fff" id="K"><path d="M76 76h1a1 1 0 0 1 1 1v22a1 1 0 0 1-1 1h-1V76Z"></path></mask>
<mask fill="#fff" id="L"><path d="M60 38h1a1 1 0 0 1 1 1v33a1 1 0 0 1-1 1h-1V38Z"></path></mask>
<mask fill="#fff" id="M"><path d="M60 76h1a1 1 0 0 1 1 1v22a1 1 0 0 1-1 1h-1V76Z"></path></mask>
<mask fill="#fff" id="N"><path d="M137 100h-1a1 1 0 0 1-1-1V66a1 1 0 0 1 1-1h1v35Z"></path></mask>
<mask fill="#fff" id="O"><path d="M137 62h-1a1 1 0 0 1-1-1V39a1 1 0 0 1 1-1h1v24Z"></path></mask>
<mask fill="#fff" id="P"><path d="M155 100h-1a1 1 0 0 1-1-1V66a1 1 0 0 1 1-1h1v35Z"></path></mask>
<mask fill="#fff" id="Q"><path d="M155 62h-1a1 1 0 0 1-1-1V39a1 1 0 0 1 1-1h1v24Z"></path></mask>
<g data-target="case" data-index="1" class="case">
<rect height="278" rx="3" stroke="#fff" stroke-width="6" width="230" x="3" y="7"></rect>
<path d="M20.639 216.906a2 2 0 0 1 1.674-.906h192.14a2 2 0 0 1 1.756 1.043l2.18 4c.726 1.333-.239 2.957-1.756 2.957H19.697c-1.589 0-2.543-1.764-1.674-3.094l2.616-4ZM5.451.793A2 2 0 0 1 7.046 0h222.808a2 2 0 0 1 1.692.934l2.521 4c.84 1.331-.118 3.066-1.692 3.066H4.021C2.369 8 1.429 6.11 2.425 4.793l3.026-4Z" fill="#fff"></path>
</g>
<g data-target="psu" class="psu">
<path d="M20 268v-11h-4v11h4Zm-3-12v13h4v-13h-4Zm3 1a1 1 0 0 1-1 1v-4a3 3 0 0 0-3 3h4Zm-1 10a1 1 0 0 1 1 1h-4a3 3 0 0 0 3 3v-4Z" fill="#fff" mask="url(#a)"></path>
<path d="M86 242v5h4v-5h-4Zm3 6v-7h-4v7h4Zm-3-1a1 1 0 0 1 1-1v4a3 3 0 0 0 3-3h-4Zm1-4a1 1 0 0 1-1-1h4a3 3 0 0 0-3-3v4Z" fill="#fff" mask="url(#b)"></path>
<path d="M86 252v5h4v-5h-4Zm3 6v-7h-4v7h4Zm-3-1a1 1 0 0 1 1-1v4a3 3 0 0 0 3-3h-4Zm1-4a1 1 0 0 1-1-1h4a3 3 0 0 0-3-3v4Z" fill="#fff" mask="url(#c)"></path>
<rect height="38" rx="3.5" stroke="#fff" stroke-width="3" width="65" x="20.5" y="234.5"></rect>
</g>
<g class="group drives threedotfive">
<g data-target="drive" class="drive">
<rect height="9.564" rx="2" stroke="#fff" stroke-width="2" width="41" x="176" y="263.436"></rect>
<path d="M180.051 273h8.898v-4h-8.898v4Zm9.949-2.949h-11v4h11v-4ZM188.949 273a.95.95 0 0 1-.949-.949h4a3.051 3.051 0 0 0-3.051-3.051v4Zm-8.898-4a3.051 3.051 0 0 0-3.051 3.051h4a.95.95 0 0 1-.949.949v-4Z" fill="#fff" mask="url(#d)"></path>
<path d="M192.051 273h2.898v-4h-2.898v4Zm3.949-2.949h-5v4h5v-4ZM194.949 273a.95.95 0 0 1-.949-.949h4a3.051 3.051 0 0 0-3.051-3.051v4Zm-2.898-4a3.051 3.051 0 0 0-3.051 3.051h4a.95.95 0 0 1-.949.949v-4Z" fill="#fff" mask="url(#e)"></path>
</g>
<g data-target="drive" class="drive">
<rect height="9.564" rx="2" stroke="#fff" stroke-width="2" width="41" x="176" y="248.718"></rect>
<path d="M192.051 258.282h2.898v-4h-2.898v4Zm3.949-2.949h-5v4h5v-4Zm-1.051 2.949a.95.95 0 0 1-.949-.949h4a3.051 3.051 0 0 0-3.051-3.051v4Zm-2.898-4a3.051 3.051 0 0 0-3.051 3.051h4a.95.95 0 0 1-.949.949v-4Z" fill="#fff" mask="url(#g)"></path>
<path d="M180.051 258.282h8.898v-4h-8.898v4Zm9.949-2.949h-11v4h11v-4Zm-1.051 2.949a.95.95 0 0 1-.949-.949h4a3.051 3.051 0 0 0-3.051-3.051v4Zm-8.898-4a3.051 3.051 0 0 0-3.051 3.051h4a.95.95 0 0 1-.949.949v-4Z" fill="#fff" mask="url(#f)"></path>
</g>
<g data-target="drive" class="drive">
<rect height="9.564" rx="2" stroke="#fff" stroke-width="2" width="41" x="176" y="234"></rect>
<path d="M192.051 243.564h2.898v-4h-2.898v4Zm3.949-2.949h-5v4h5v-4Zm-1.051 2.949a.95.95 0 0 1-.949-.949h4a3.051 3.051 0 0 0-3.051-3.051v4Zm-2.898-4a3.051 3.051 0 0 0-3.051 3.051h4a.95.95 0 0 1-.949.949v-4Z" fill="#fff" mask="url(#i)"></path>
<path d="M180.051 243.564h8.898v-4h-8.898v4Zm9.949-2.949h-11v4h11v-4Zm-1.051 2.949a.95.95 0 0 1-.949-.949h4a3.051 3.051 0 0 0-3.051-3.051v4Zm-8.898-4a3.051 3.051 0 0 0-3.051 3.051h4a.95.95 0 0 1-.949.949v-4Z" fill="#fff" mask="url(#h)"></path>
</g>
<g data-target="drive" class="drive">
<rect height="9.564" rx="2" stroke="#fff" stroke-width="2" width="41" x="130" y="248.718"></rect>
<path d="M134.051 258.282h8.898v-4h-8.898v4Zm9.949-2.949h-11v4h11v-4Zm-1.051 2.949a.95.95 0 0 1-.949-.949h4a3.051 3.051 0 0 0-3.051-3.051v4Zm-8.898-4a3.051 3.051 0 0 0-3.051 3.051h4a.95.95 0 0 1-.949.949v-4Z" fill="#fff" mask="url(#j)"></path>
<path d="M146.051 258.282h2.898v-4h-2.898v4Zm3.949-2.949h-5v4h5v-4Zm-1.051 2.949a.95.95 0 0 1-.949-.949h4a3.051 3.051 0 0 0-3.051-3.051v4Zm-2.898-4a3.051 3.051 0 0 0-3.051 3.051h4a.95.95 0 0 1-.949.949v-4Z" fill="#fff" mask="url(#k)"></path>
</g>
<g data-target="drive" class="drive">
<rect height="9.564" rx="2" stroke="#fff" stroke-width="2" width="41" x="130" y="234"></rect>
<path d="M134.051 243.564h8.898v-4h-8.898v4Zm9.949-2.949h-11v4h11v-4Zm-1.051 2.949a.95.95 0 0 1-.949-.949h4a3.051 3.051 0 0 0-3.051-3.051v4Zm-8.898-4a3.051 3.051 0 0 0-3.051 3.051h4a.95.95 0 0 1-.949.949v-4Z" fill="#fff" mask="url(#l)"></path>
<path d="M146.051 243.564h2.898v-4h-2.898v4Zm3.949-2.949h-5v4h5v-4Zm-1.051 2.949a.95.95 0 0 1-.949-.949h4a3.051 3.051 0 0 0-3.051-3.051v4Zm-2.898-4a3.051 3.051 0 0 0-3.051 3.051h4a.95.95 0 0 1-.949.949v-4Z" fill="#fff" mask="url(#m)"></path>
</g>
<g data-target="drive" class="drive">
<rect height="9.564" rx="2" stroke="#fff" stroke-width="2" width="41" x="130" y="263.436"></rect>
<path d="M134.051 273h8.898v-4h-8.898v4Zm9.949-2.949h-11v4h11v-4ZM142.949 273a.95.95 0 0 1-.949-.949h4a3.051 3.051 0 0 0-3.051-3.051v4Zm-8.898-4a3.051 3.051 0 0 0-3.051 3.051h4a.95.95 0 0 1-.949.949v-4Z" fill="#fff" mask="url(#n)"></path>
<path d="M146.051 273h2.898v-4h-2.898v4Zm3.949-2.949h-5v4h5v-4ZM148.949 273a.95.95 0 0 1-.949-.949h4a3.051 3.051 0 0 0-3.051-3.051v4Zm-2.898-4a3.051 3.051 0 0 0-3.051 3.051h4a.95.95 0 0 1-.949.949v-4Z" fill="#fff" mask="url(#o)"></path>
</g>
</g>
<g class="group drives twodotfive">
<g data-target="drive" class="drive">
<rect height="5" rx="2" stroke="#fff" stroke-width="2" width="41" x="183" y="36"></rect>
<path d="M187 41h9v-4h-9v4Zm10-3h-11v4h11v-4Zm-1 3a1 1 0 0 1-1-1h4a3 3 0 0 0-3-3v4Zm-9-4a3 3 0 0 0-3 3h4a1 1 0 0 1-1 1v-4Z" fill="#fff" mask="url(#p)"></path>
<path d="M199 41h3v-4h-3v4Zm4-3h-5v4h5v-4Zm-1 3a1 1 0 0 1-1-1h4a3 3 0 0 0-3-3v4Zm-3-4a3 3 0 0 0-3 3h4a1 1 0 0 1-1 1v-4Z" fill="#fff" mask="url(#q)"></path>
</g>
<g data-target="drive" class="drive">
<rect height="5" rx="2" stroke="#fff" stroke-width="2" width="41" x="183" y="47"></rect>
<path d="M187 52h9v-4h-9v4Zm10-3h-11v4h11v-4Zm-1 3a1 1 0 0 1-1-1h4a3 3 0 0 0-3-3v4Zm-9-4a3 3 0 0 0-3 3h4a1 1 0 0 1-1 1v-4Z" fill="#fff" mask="url(#r)"></path>
<path d="M199 52h3v-4h-3v4Zm4-3h-5v4h5v-4Zm-1 3a1 1 0 0 1-1-1h4a3 3 0 0 0-3-3v4Zm-3-4a3 3 0 0 0-3 3h4a1 1 0 0 1-1 1v-4Z" fill="#fff" mask="url(#s)"></path>
</g>
<g data-target="drive" class="drive">
<rect height="5" rx="2" stroke="#fff" stroke-width="2" width="41" x="183" y="58"></rect>
<path d="M187 63h9v-4h-9v4Zm10-3h-11v4h11v-4Zm-1 3a1 1 0 0 1-1-1h4a3 3 0 0 0-3-3v4Zm-9-4a3 3 0 0 0-3 3h4a1 1 0 0 1-1 1v-4Z" fill="#fff" mask="url(#t)"></path>
<path d="M199 63h3v-4h-3v4Zm4-3h-5v4h5v-4Zm-1 3a1 1 0 0 1-1-1h4a3 3 0 0 0-3-3v4Zm-3-4a3 3 0 0 0-3 3h4a1 1 0 0 1-1 1v-4Z" fill="#fff" mask="url(#u)"></path>
</g>
<g data-target="drive" class="drive">
<rect height="5" rx="2" stroke="#fff" stroke-width="2" width="41" x="183" y="69"></rect>
<path d="M187 74h9v-4h-9v4Zm10-3h-11v4h11v-4Zm-1 3a1 1 0 0 1-1-1h4a3 3 0 0 0-3-3v4Zm-9-4a3 3 0 0 0-3 3h4a1 1 0 0 1-1 1v-4Z" fill="#fff" mask="url(#v)"></path>
<path d="M199 74h3v-4h-3v4Zm4-3h-5v4h5v-4Zm-1 3a1 1 0 0 1-1-1h4a3 3 0 0 0-3-3v4Zm-3-4a3 3 0 0 0-3 3h4a1 1 0 0 1-1 1v-4Z" fill="#fff" mask="url(#w)"></path>
</g>
</g>
<g data-target="mb" class="mb">
<rect height="176" rx="3.5" stroke="#fff" stroke-width="3" width="152" x="22.5" y="25.5"></rect>
<path d="m31.19 123.309-.01.007-7.32 5.782c-1.968 1.554-4.86.153-4.86-2.354V42a3 3 0 0 1 3-3h15a3 3 0 0 1 3 3v73.048c0 .931-.432 1.81-1.17 2.377l-7.64 5.884Z" fill="#fff"></path>
<path d="m31.19 123.309-.01.007-7.32 5.782c-1.968 1.554-4.86.153-4.86-2.354V42a3 3 0 0 1 3-3h15a3 3 0 0 1 3 3v73.048c0 .931-.432 1.81-1.17 2.377l-7.64 5.884Z" stroke="#fff" stroke-width="2"></path>
<g class="chips">
<rect fill="#fff" height="13" rx="2" width="13" x="23" y="44"></rect>
<rect fill="#fff" height="13" rx="2" width="13" x="23" y="61"></rect>
<rect fill="#fff" height="13" rx="2" width="13" x="23" y="78"></rect>
</g>
</g>
<g class="group drives mdottwo">
<g data-target="drive" class="drive">
<path d="M129.535 123.781v2h2v-2h-2Zm0-.906h2v-2h-2v2Zm0-1.094h-1.082v4h1.082v-4Zm-1.082 3.094h1.082v-4h-1.082v4Zm-.918-2v.906h4v-.906h-4Zm2.465.453c0 .854-.693 1.547-1.547 1.547v-4a2.453 2.453 0 0 0-2.453 2.453h4Zm-1.547-1.547c.854 0 1.547.693 1.547 1.547h-4a2.453 2.453 0 0 0 2.453 2.453v-4Z" fill="#fff" mask="url(#x)"></path>
<path d="M129.535 115.625v2h2v-2h-2Zm0-.906h2v-2h-2v2Zm0-1.094h-1.082v4h1.082v-4Zm-1.082 3.094h1.082v-4h-1.082v4Zm-.918-2v.906h4v-.906h-4Zm2.465.453c0 .854-.693 1.547-1.547 1.547v-4a2.453 2.453 0 0 0-2.453 2.453h4Zm-1.547-1.547c.854 0 1.547.693 1.547 1.547h-4a2.453 2.453 0 0 0 2.453 2.453v-4Z" fill="#fff" mask="url(#y)"></path>
<path d="M129.535 121.969v2h2v-2h-2Zm0-5.438h2v-2h-2v2Zm0 3.438H129v4h.535v-4Zm.465 1v-3.438h-4v3.438h4Zm-1-2.438h.535v-4H129v4Zm-1.465-2v5.438h4v-5.438h-4Zm2.465 1a1 1 0 0 1-1 1v-4a3 3 0 0 0-3 3h4Zm-1 2.438a1 1 0 0 1 1 1h-4a3 3 0 0 0 3 3v-4Z" fill="#fff" mask="url(#z)"></path>
<rect height="12.5" rx="2" stroke="#fff" stroke-width="2" width="29.465" x="130.535" y="113"></rect>
</g>
<g data-target="drive" class="drive">
<path d="M92.535 123.781v2h2v-2h-2Zm0-.906h2v-2h-2v2Zm0-1.094h-1.082v4h1.082v-4Zm-1.082 3.094h1.082v-4h-1.082v4Zm-.918-2v.906h4v-.906h-4Zm2.465.453c0 .854-.693 1.547-1.547 1.547v-4A2.453 2.453 0 0 0 89 123.328h4Zm-1.547-1.547c.854 0 1.547.693 1.547 1.547h-4a2.453 2.453 0 0 0 2.453 2.453v-4Z" fill="#fff" mask="url(#A)"></path>
<path d="M92.535 115.625v2h2v-2h-2Zm0-.906h2v-2h-2v2Zm0-1.094h-1.082v4h1.082v-4Zm-1.082 3.094h1.082v-4h-1.082v4Zm-.918-2v.906h4v-.906h-4Zm2.465.453c0 .854-.693 1.547-1.547 1.547v-4A2.453 2.453 0 0 0 89 115.172h4Zm-1.547-1.547c.854 0 1.547.693 1.547 1.547h-4a2.453 2.453 0 0 0 2.453 2.453v-4Z" fill="#fff" mask="url(#B)"></path>
<path d="M92.535 121.969v2h2v-2h-2Zm0-5.438h2v-2h-2v2Zm0 3.438H92v4h.535v-4Zm.465 1v-3.438h-4v3.438h4Zm-1-2.438h.535v-4H92v4Zm-1.465-2v5.438h4v-5.438h-4Zm2.465 1a1 1 0 0 1-1 1v-4a3 3 0 0 0-3 3h4Zm-1 2.438a1 1 0 0 1 1 1h-4a3 3 0 0 0 3 3v-4Z" fill="#fff" mask="url(#C)"></path>
<rect height="12.5" rx="2" stroke="#fff" stroke-width="2" width="29.465" x="93.535" y="113"></rect>
</g>
<g data-target="drive" class="drive">
<path d="M55.535 123.781v2h2v-2h-2Zm0-.906h2v-2h-2v2Zm0-1.094h-1.082v4h1.082v-4Zm-1.082 3.094h1.082v-4h-1.082v4Zm-.918-2v.906h4v-.906h-4Zm2.465.453c0 .854-.693 1.547-1.547 1.547v-4A2.453 2.453 0 0 0 52 123.328h4Zm-1.547-1.547c.854 0 1.547.693 1.547 1.547h-4a2.453 2.453 0 0 0 2.453 2.453v-4Z" fill="#fff" mask="url(#G)"></path>
<path d="M55.535 115.625v2h2v-2h-2Zm0-.906h2v-2h-2v2Zm0-1.094h-1.082v4h1.082v-4Zm-1.082 3.094h1.082v-4h-1.082v4Zm-.918-2v.906h4v-.906h-4Zm2.465.453c0 .854-.693 1.547-1.547 1.547v-4A2.453 2.453 0 0 0 52 115.172h4Zm-1.547-1.547c.854 0 1.547.693 1.547 1.547h-4a2.453 2.453 0 0 0 2.453 2.453v-4Z" fill="#fff" mask="url(#H)"></path>
<path d="M55.535 121.969v2h2v-2h-2Zm0-5.438h2v-2h-2v2Zm0 3.438H55v4h.535v-4Zm.465 1v-3.438h-4v3.438h4Zm-1-2.438h.535v-4H55v4Zm-1.465-2v5.438h4v-5.438h-4Zm2.465 1a1 1 0 0 1-1 1v-4a3 3 0 0 0-3 3h4Zm-1 2.438a1 1 0 0 1 1 1h-4a3 3 0 0 0 3 3v-4Z" fill="#fff" mask="url(#I)"></path>
<rect height="12.5" rx="2" stroke="#fff" stroke-width="2" width="29.465" x="56.535" y="113"></rect>
</g>
</g>
<g data-target="gpu" class="gpu">
<path d="M59 148h86" stroke="#fff" stroke-linecap="round"></path>
<path d="M59.5 136a1.5 1.5 0 0 0-3 0h3Zm0 8v-8h-3v8h3Z" fill="#fff"></path>
<path d="M68 143v-2a1 1 0 0 1 1-1h29a1 1 0 0 1 1 1v2H68Z" mask="url(#D)" stroke="#fff" stroke-width="3"></path>
<path d="M101 143v-2a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v2h-3Z" mask="url(#E)" stroke="#fff" stroke-width="3"></path>
<path d="M64 143v-2a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v2h-3Z" mask="url(#F)" stroke="#fff" stroke-width="3"></path>
<path d="M55 147a3 3 0 0 1 3-3h88a3 3 0 0 1 3 3v.525c0 .237-.028.473-.083.703l-2.764 11.474a3.001 3.001 0 0 1-2.917 2.298H58a3 3 0 0 1-3-3v-12Z" stroke="#fff" stroke-width="2"></path>
</g>
<g class="group drams">
<g data-target="dram" class="dram">
<path d="M60 38v-2h-2v2h2Zm0 35h-2v2h2v-2Zm0-33h1v-4h-1v4Zm0-1v33h4V39h-4Zm1 32h-1v4h1v-4Zm1 2V38h-4v35h4Zm-2-1a1 1 0 0 1 1-1v4a3 3 0 0 0 3-3h-4Zm1-32a1 1 0 0 1-1-1h4a3 3 0 0 0-3-3v4Z" fill="#fff" mask="url(#L)"></path>
<path d="M60 76v-2h-2v2h2Zm0 24h-2v2h2v-2Zm0-22h1v-4h-1v4Zm0-1v22h4V77h-4Zm1 21h-1v4h1v-4Zm1 2V76h-4v24h4Zm-2-1a1 1 0 0 1 1-1v4a3 3 0 0 0 3-3h-4Zm1-21a1 1 0 0 1-1-1h4a3 3 0 0 0-3-3v4Z" fill="#fff" mask="url(#M)"></path>
<rect height="66" rx="2" stroke="#fff" stroke-width="2" width="4" x="55" y="36"></rect>
</g>
<g data-target="dram" class="dram">
<path d="M76 38v-2h-2v2h2Zm0 35h-2v2h2v-2Zm0-33h1v-4h-1v4Zm0-1v33h4V39h-4Zm1 32h-1v4h1v-4Zm1 2V38h-4v35h4Zm-2-1a1 1 0 0 1 1-1v4a3 3 0 0 0 3-3h-4Zm1-32a1 1 0 0 1-1-1h4a3 3 0 0 0-3-3v4Z" fill="#fff" mask="url(#J)"></path>
<path d="M76 76v-2h-2v2h2Zm0 24h-2v2h2v-2Zm0-22h1v-4h-1v4Zm0-1v22h4V77h-4Zm1 21h-1v4h1v-4Zm1 2V76h-4v24h4Zm-2-1a1 1 0 0 1 1-1v4a3 3 0 0 0 3-3h-4Zm1-21a1 1 0 0 1-1-1h4a3 3 0 0 0-3-3v4Z" fill="#fff" mask="url(#K)"></path>
<rect height="66" rx="2" stroke="#fff" stroke-width="2" width="4" x="71" y="36"></rect>
</g>
<g data-target="dram" class="dram">
<path d="M137 100v2h2v-2h-2Zm0-35h2v-2h-2v2Zm0 33h-1v4h1v-4Zm0 1V66h-4v33h4Zm-1-32h1v-4h-1v4Zm-1-2v35h4V65h-4Zm2 1a1 1 0 0 1-1 1v-4a3 3 0 0 0-3 3h4Zm-1 32a1 1 0 0 1 1 1h-4a3 3 0 0 0 3 3v-4Z" fill="#fff" mask="url(#N)"></path>
<path d="M137 62v2h2v-2h-2Zm0-24h2v-2h-2v2Zm0 22h-1v4h1v-4Zm0 1V39h-4v22h4Zm-1-21h1v-4h-1v4Zm-1-2v24h4V38h-4Zm2 1a1 1 0 0 1-1 1v-4a3 3 0 0 0-3 3h4Zm-1 21a1 1 0 0 1 1 1h-4a3 3 0 0 0 3 3v-4Z" fill="#fff" mask="url(#O)"></path>
<rect height="66" rx="2" stroke="#fff" stroke-width="2" transform="rotate(180 142 102)" width="4" x="142" y="102"></rect>
</g>
<g data-target="dram" class="dram">
<path d="M155 100v2h2v-2h-2Zm0-35h2v-2h-2v2Zm0 33h-1v4h1v-4Zm0 1V66h-4v33h4Zm-1-32h1v-4h-1v4Zm-1-2v35h4V65h-4Zm2 1a1 1 0 0 1-1 1v-4a3 3 0 0 0-3 3h4Zm-1 32a1 1 0 0 1 1 1h-4a3 3 0 0 0 3 3v-4Z" fill="#fff" mask="url(#P)"></path>
<path d="M155 62v2h2v-2h-2Zm0-24h2v-2h-2v2Zm0 22h-1v4h1v-4Zm0 1V39h-4v22h4Zm-1-21h1v-4h-1v4Zm-1-2v24h4V38h-4Zm2 1a1 1 0 0 1-1 1v-4a3 3 0 0 0-3 3h4Zm-1 21a1 1 0 0 1 1 1h-4a3 3 0 0 0 3 3v-4Z" fill="#fff" mask="url(#Q)"></path>
<rect height="66" rx="2" stroke="#fff" stroke-width="2" transform="rotate(180 160 102)" width="4" x="160" y="102"></rect>
</g>
</g>
<g data-target="cpu" class="cpu">
<path d="M98 56a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1h-3ZM113 56a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1h-3ZM103 56a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1h-3ZM108 56a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1h-3ZM116 82a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1h3ZM101 82a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1h3ZM111 82a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1h3ZM106 82a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1h3ZM120 60a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1v-3ZM120 75a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1v-3ZM120 65a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1v-3ZM120 70a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1v-3ZM94 78a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1v3ZM94 63a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1v3ZM94 73a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1v3ZM94 68a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1v3Z" fill="#fff"></path>
<rect height="24" rx="3" stroke="#fff" stroke-width="2" width="24" x="95" y="57"></rect>
</g>
</svg>

After

Width:  |  Height:  |  Size: 19 KiB

View file

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

After

Width:  |  Height:  |  Size: 2.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.6 KiB

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
assets/media/ogp.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
assets/media/travolta.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

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