feat: release 1.0.0 (#1)

* wip: 2024-02-13T12:59:17+0100 (1707825557)

* wip: 2024-02-21T03:16:48+0100 (1708481808)

* wip: 2024-02-21T20:50:20+0100 (1708545020)

* wip: 2024-02-21T20:50:20+0100 (1708545020)

* wip: 2024-03-01T13:17:58+0100 (1709295478)

* wip: 2024-03-06T12:06:58+0100 (1709723218)

* wip: 2024-03-07T15:07:57+0100 (1709820477)

* wip: 2024-03-09T01:36:44+0100 (1709944604)

* wip: 2024-03-14T23:24:12+0100 (1710455052)

* wip: 2024-03-28T18:27:40+0100 (1711646860)

* wip: 2024-03-28T18:27:40+0100 (1711646860)

* feat: create README

* wip: 2024-04-01T12:21:45+0200 (1711966905)
This commit is contained in:
Victor Westerlund 2024-04-01 10:22:25 +00:00 committed by GitHub
parent 0d2cd5f01d
commit 140132fa72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 4723 additions and 128 deletions

4
.env.example.ini Executable file
View file

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

144
.gitignore vendored Normal file → Executable file
View file

@ -1,130 +1,18 @@
# Logs assets/media/content
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) # Bootstrapping #
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json #################
vendor
.env.ini
# Runtime data # OS generated files #
pids ######################
*.pid .DS_Store
*.seed .DS_Store?
*.pid.lock ._*
.Spotlight-V100
# Directory for instrumented libs generated by jscoverage/JSCover .Trashes
lib-cov Icon?
ehthumbs.db
# Coverage directory used by tools like istanbul Thumbs.db
coverage .directory
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

0
LICENSE Normal file → Executable file
View file

65
README.md Normal file
View file

@ -0,0 +1,65 @@
# vlw.se
This is the source code behind [vlw.se](https://vlw.se) which has been written from the ground up by me. This website is built on top of my [Vegvisir web framework](https://github.com/victorwesterlund/vegvisir) and my [Reflect API framework](https://github.com/victorwesterlund/reflect).
# Installation
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).
## Website (Vegvisir)
1. **Download this repo**
Git clone or download this repo to any local folder
```
git clone https://github.com/VictorWesterlund/vlw.se
```
2. **Download and install Vegvisir**
Follow the installation instructions for [Vegvisir](https://github.com/victorwesterlund/vegvisir) and point the `site_path` variable to the local vlw.se folder.
3. **Install dependencies**
Install dependencies with composer.
```
composer install --optimize-autoloader
```
Et voila! You probably want to install the API-side too but the website itself should now be accessible from your configured Vegvisir host.
## API (Reflect)
The API (and database) is where most content is stored and served from on this website.
1. **Download this repo**
**You can skip this if you've already downloaded the repo from step 1 in the website installation.**
Otherwise... Git clone or download this repo to any local folder
```
git clone https://github.com/VictorWesterlund/vlw.se
```
2. **Download and install Reflect**
Follow the installation instructions for [Reflect](https://github.com/victorwesterlund/vegvisir) and point the `endpoints` variable to the `/api` subdirectory in the local vlw.se folder.
3. **Install dependencies**
Install dependencies with composer.
```
composer install --optimize-autoloader
```
4. **Create and import database**
[Create and] import the two databases associated with vlw.se data and the Reflect API configurations from `.sql` files on the Releases page.
5. **Set environment variables**
Make a copy of `/api/.env.example.ini` and change the `[vlwdb]` variables with your MariaDB credentials.
You also have to generate a [GitHub access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) if you wish to use the `releases` endpoint.
[Read more about this endpoint here](#)
6. **Set environment variables for website**
It's reasonable to assume if you've installed the website from this repo that you'd also want to use the API with it. Start my making a copy of `/.env.example.ini` (root directory) and change the `[api]` variables to point to your API hostname.

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

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

7
api/composer.json Executable file
View file

@ -0,0 +1,7 @@
{
"require": {
"reflect/plugin-rules": "^1.5",
"victorwesterlund/innodb-fk": "^1.0",
"victorwesterlund/xenum": "^1.1"
}
}

171
api/composer.lock generated Executable file
View file

@ -0,0 +1,171 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "ba3fa8466aa20501e06050d722c86a35",
"packages": [
{
"name": "reflect/plugin-rules",
"version": "1.5.0",
"source": {
"type": "git",
"url": "https://github.com/VictorWesterlund/reflect-rules-plugin.git",
"reference": "9c837fd1944133edfed70a63ce8b32bf67f0d94b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/VictorWesterlund/reflect-rules-plugin/zipball/9c837fd1944133edfed70a63ce8b32bf67f0d94b",
"reference": "9c837fd1944133edfed70a63ce8b32bf67f0d94b",
"shasum": ""
},
"type": "library",
"autoload": {
"psr-4": {
"ReflectRules\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Victor Westerlund",
"email": "victor.vesterlund@gmail.com"
}
],
"description": "Add request search paramter and request body constraints to an API built with Reflect",
"support": {
"issues": "https://github.com/VictorWesterlund/reflect-rules-plugin/issues",
"source": "https://github.com/VictorWesterlund/reflect-rules-plugin/tree/1.5.0"
},
"time": "2024-01-17T11:07:44+00:00"
},
{
"name": "victorwesterlund/innodb-fk",
"version": "1.0.3",
"source": {
"type": "git",
"url": "https://github.com/VictorWesterlund/php-libinnodb-fk.git",
"reference": "ffea024f16613e6d6857c93200185cf0a20a9640"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/VictorWesterlund/php-libinnodb-fk/zipball/ffea024f16613e6d6857c93200185cf0a20a9640",
"reference": "ffea024f16613e6d6857c93200185cf0a20a9640",
"shasum": ""
},
"require": {
"victorwesterlund/libmysqldriver": "^3.0",
"victorwesterlund/xenum": "^1.0"
},
"type": "library",
"autoload": {
"psr-4": {
"victorwesterlund\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Victor Westerlund",
"email": "victor.vesterlund@gmail.com"
}
],
"description": "Retrievie and optionally resolves foreign keys in a MySQL/MariaDB InnoDB database",
"support": {
"issues": "https://github.com/VictorWesterlund/php-libinnodb-fk/issues",
"source": "https://github.com/VictorWesterlund/php-libinnodb-fk/tree/1.0.3"
},
"time": "2023-11-02T13:26:34+00:00"
},
{
"name": "victorwesterlund/libmysqldriver",
"version": "3.5.1",
"source": {
"type": "git",
"url": "https://github.com/VictorWesterlund/php-libmysqldriver.git",
"reference": "73b5d858ffa8d83c5cbe6b3d3de4af314a5ffbe5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/VictorWesterlund/php-libmysqldriver/zipball/73b5d858ffa8d83c5cbe6b3d3de4af314a5ffbe5",
"reference": "73b5d858ffa8d83c5cbe6b3d3de4af314a5ffbe5",
"shasum": ""
},
"type": "library",
"autoload": {
"psr-4": {
"libmysqldriver\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Victor Westerlund",
"email": "victor.vesterlund@gmail.com"
}
],
"description": "Abstraction library for common mysqli features",
"support": {
"issues": "https://github.com/VictorWesterlund/php-libmysqldriver/issues",
"source": "https://github.com/VictorWesterlund/php-libmysqldriver/tree/3.5.1"
},
"time": "2024-02-26T12:51:52+00:00"
},
{
"name": "victorwesterlund/xenum",
"version": "1.1.1",
"source": {
"type": "git",
"url": "https://github.com/VictorWesterlund/php-xenum.git",
"reference": "8972f06f42abd1f382807a67e937d5564bb89699"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/VictorWesterlund/php-xenum/zipball/8972f06f42abd1f382807a67e937d5564bb89699",
"reference": "8972f06f42abd1f382807a67e937d5564bb89699",
"shasum": ""
},
"type": "library",
"autoload": {
"psr-4": {
"victorwesterlund\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Victor Westerlund",
"email": "victor.vesterlund@gmail.com"
}
],
"description": "PHP eXtended Enums. The missing quality-of-life features from PHP 8+ Enums",
"support": {
"issues": "https://github.com/VictorWesterlund/php-xenum/issues",
"source": "https://github.com/VictorWesterlund/php-xenum/tree/1.1.1"
},
"time": "2023-11-20T10:10:39+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"prefer-stable": false,
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
"plugin-api-version": "2.0.0"
}

39
api/endpoints/coffee/GET.php Executable file
View file

@ -0,0 +1,39 @@
<?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();
}
}

36
api/endpoints/coffee/POST.php Executable file
View file

@ -0,0 +1,36 @@
<?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();
}
}

95
api/endpoints/media/GET.php Executable file
View file

@ -0,0 +1,95 @@
<?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;
}
}

117
api/endpoints/media/POST.php Executable file
View file

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

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

@ -0,0 +1,55 @@
<?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();
}
}

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

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

223
api/endpoints/releases/POST.php Executable file
View file

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

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

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

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

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

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

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

@ -0,0 +1,50 @@
<?php
namespace VLW\API\Databases\VLWdb;
use libmysqldriver\MySQL;
class VLWdb {
const MYSQL_TEXT_MAX_LENGTH = 65538;
const MYSQL_VARCHAR_MAX_LENGTH = 255;
const MYSQL_INT_MAX_LENGHT = 2147483647;
protected MySQL $db;
public function __construct() {
// Create new MariaDB connection
$this->db = new MySQL(
$_ENV["vlwdb"]["mariadb_host"],
$_ENV["vlwdb"]["mariadb_user"],
$_ENV["vlwdb"]["mariadb_pass"],
$_ENV["vlwdb"]["mariadb_db"],
);
}
// Generate and return UUID4 string
public static function gen_uuid4(): string {
return sprintf("%04x%04x-%04x-%04x-%04x-%04x%04x%04x",
// 32 bits for "time_low"
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
// 16 bits for "time_mid"
mt_rand(0, 0xffff),
// 16 bits for "time_hi_and_version",
// four most significant bits holds version number 4
mt_rand(0, 0x0fff) | 0x4000,
// 16 bits, 8 bits for "clk_seq_hi_res",
// 8 bits for "clk_seq_low",
// two most significant bits holds zero and one for variant DCE1.1
mt_rand(0, 0x3fff) | 0x8000,
// 48 bits for "node"
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
public static function is_mysqli_result(\mysqli_result|bool $resp): bool {
return $resp instanceof \mysqli_result;
}
}

View file

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

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

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

@ -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_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_TIMESTAMP_MODIFIED = "date_timestamp_modified";
case DATE_TIMESTAMP_CREATED = "date_timestamp_created";
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,20 @@
<?php
namespace VLW\API\Databases\VLWdb\Models\Work;
use victorwesterlund\xEnum;
enum WorkTagsNameEnum: 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";
}

311
assets/css/document.css Executable file
View file

@ -0,0 +1,311 @@
: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;
}
::-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;
}
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);
}
/* ## Buttons */
button {
font-size: inherit;
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: 14px;
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: 18px;
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;
}
/* /> */
}

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

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

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

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

184
assets/css/pages/contact.css Executable file
View file

@ -0,0 +1,184 @@
/* # Overrides */
:root {
--primer-color-accent: 255, 195, 255;
--color-accent: rgb(var(--primer-color-accent));
}
main {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--padding);
}
/* # Sections */
main > svg {
margin: var(--padding) 0;
}
/* ## Social */
section.social {
--icon-size: 60px;
display: grid;
grid-template-columns: repeat(3, var(--icon-size));
grid-template-rows: var(--icon-size);
align-items: center;
fill: white;
gap: var(--padding);
}
section.social social {
transition: 200ms fill;
position: relative;
}
/* ### Hover tooltip */
section.social social p {
display: none;
position: absolute;
top: 0;
left: 0;
transform: translate(0, 0);
background-color: rgba(var(--primer-color-accent), .1);
padding: 5px 10px;
font-size: 17px;
white-space: nowrap;
pointer-events: none;
border-radius: 6px;
-webkit-backdrop-filter: brightness(.2) blur(20px);
backdrop-filter: brightness(.2) blur(20px);
}
section.social social:hover {
fill: var(--color-accent);
}
section.social social.hovering p {
display: initial;
}
/* ## OpenPGP key */
section.pgp {
max-width: 800px;
position: relative;
text-align: center;
background-color: rgba(var(--primer-color-accent), .15);
padding: calc(var(--padding) * 1.5);
transform: rotate(-1.5deg);
}
section.pgp > svg {
position: absolute;
top: -30px;
right: -20px;
width: 60px;
fill: var(--color-accent);
}
section.pgp > p {
margin-bottom: var(--padding);
padding: var(--padding);
}
section.pgp .buttons {
display: flex;
flex-direction: column;
gap: var(--padding);
}
/* ## Contact form */
section.form :is(input, textarea) {
min-width: 100%;
max-width: 100%;
color: black;
font-size: 15px;
padding: var(--padding);
border-radius: 4px;
border: none;
outline: none;
}
section.form input {
height: calc(var(--running-size) - var(--padding));
}
section.form textarea {
min-height: calc(var(--running-size) * 1.5);
}
section.form {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--padding);
width: 100%;
}
section.form form {
display: contents;
}
section.form input-group {
width: 100%;
display: flex;
flex-direction: column;
gap: calc(var(--padding) / 2);
}
section.form input-group label {
color: var(--color-accent);
}
section.form button {
width: 100%;
max-width: 500px;
}
/* ### Contact form messages */
section.form-message {
width: 100%;
display: flex;
flex-direction: column;
gap: calc(var(--padding) / 2);
padding: var(--padding);
background-color: white;
margin: var(--padding) 0;
color: black;
}
section.form-message h3 {
text-align: center;
}
section.form-message pre {
font-size: 11px;
padding: var(--padding);
background-color: rgba(0, 0, 0, .15);
}
section.form-message.error {
background-color: #ec4444;
color: white;
}
section.form-message.sent {
background-color: var(--color-accent);
}
/* # Size queries */
@media (min-width: 460px) {
section.pgp .buttons {
flex-direction: row;
justify-content: center;
}
}

51
assets/css/pages/error.css Executable file
View file

@ -0,0 +1,51 @@
/* # Overrides */
header {
background-color: transparent;
-webkit-backdrop-filter: unset;
backdrop-filter: unset;
}
main {
max-width: unset;
display: grid;
justify-items: center;
}
/* # Glitch effects */
/* ## Canvas */
canvas {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
opacity: .3;
transform: scale(1.3);
}
/* ## Text */
[glitch-text] {
transition: 50ms text-shadow;
}
/* # Sections */
section.error h1 {
font-size: 30vw;
user-select: none;
animation: rumble 100ms infinite linear;
opacity: .2;
}
section.error h1 span {
}
@keyframes rumble {
to { transform: translateX(1px); }
}

170
assets/css/pages/index.css Executable file
View file

@ -0,0 +1,170 @@
/* # Main styles */
/* ## Picture */
main {
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: column-reverse;
}
main img {
margin: auto;
width: 25vh;
pointer-events: none;
-webkit-filter: hue-rotate(var(--hue-accent));
filter: hue-rotate(var(--hue-accent));
}
/* ## Menu */
.menu {
width: 100%;
max-width: 300px;
display: flex;
flex-direction: column;
}
.menu menu {
margin: var(--padding) 0;
list-style: none;
padding: unset;
text-align: right;
font-size: clamp(20px, 8vh, 60px);
font-weight: 900;
line-height: clamp(20px, 8vh, 60px);
color: var(--color-accent);
}
.menu menu li {
transition: 200ms opacity, 200ms color;
}
.menu svg {
width: 100%;
}
/* ### Copy email button */
.menu button {
text-align: right;
border: unset;
padding: var(--padding) 0;
}
.menu button p:first-of-type {
color: var(--color-accent);
}
/* # Email-copied splash */
splash {
--confetti: unset;
--text-shadow: 0 0 30px black;
display: initial !important;
transition: 300ms opacity;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 80px;
color: white;
z-index: 200;
font-weight: 900;
pointer-events: none;
perspective: 300px;
text-shadow:
var(--text-shadow),
var(--text-shadow),
var(--text-shadow),
var(--text-shadow)
;
animation: splash-reveal 1s ease;
}
splash.hide {
opacity: 0;
}
splash::after {
content: "";
top: 50%;
left: 50%;
position: absolute;
width: 8px;
height: 16px;
background-color: transparent;
box-shadow: var(--confetti);
animation: splash-confetti 1s ease;
opacity: 0;
}
/* ## Keyframes */
@keyframes splash-confetti {
0% {
transform: rotate(12deg) scale(0);
opacity: 1;
}
60% {
opacity: 1;
}
100% {
transform: rotate(-10deg) scale(1);
opacity: 0;
}
}
@keyframes splash-reveal {
0% { transform: translate(-50%, -50%) rotate(-8deg) scale(0); }
35% { transform: translate(-50%, -50%) rotate(-3deg) scale(1.1); }
100% { transform: translate(-50%, -50%) rotate(0deg) scale(1); }
}
/* # Features */
.cta::before {
content: "tap ";
}
@media (pointer: fine) {
.cta::before {
content: "click ";
}
}
/* # Feature queries */
@media (hover: hover) {
.menu menu:hover li {
opacity: .6;
}
.menu menu li:hover {
opacity: 1;
text-shadow: 0 0 10px rgba(var(--primer-color-accent), .4);
}
}
/* # Size quries */
@media (min-width: 900px) {
main {
display: grid;
grid-template-columns: repeat(2, 1fr);
justify-items: center;
align-items: center;
}
main img {
width: 35vh;
}
button:hover {
background-color: transparent;
}
}

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

@ -0,0 +1,94 @@
/* # Overrides */
[vv-page="/search"]:not(body) {
display: flex;
flex-direction: column;
gap: var(--padding);
}
/* # Sections */
/* ## Search */
section.search {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--padding);
background-color: rgba(255, 255, 255, .05);
padding: calc(var(--padding) * 1.5);
margin-bottom: calc(var(--padding) * 2);
}
section.search form {
display: contents;
}
section.search search {
width: 100%;
}
section.search input {
width: 100%;
}
section.search button[type="submit"] {
width: 100%;
max-width: 350px;
}
body:not([vv-page="/search"]) section.search {
display: none;
}
/* # Search results */
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 */
section.title h2 {
color: var(--color-accent);
}
section.title a h2::before {
content: "// ";
color: white;
}
/* ## Work */
section.results.work {
display: grid;
grid-template-columns: 1fr;
gap: calc(var(--padding) / 2);
}
section.results.work .result {
padding: var(--padding);
background-color: rgba(255, 255, 255, .1);
border-radius: 6px;
}
/* # Feature queries */
@media (hover: hover) {
section.results.work .result {
transition: 300ms background-color;
}
section.results.work .result:hover {
background-color: rgba(255, 255, 255, .2);
box-shadow: 0 5px 70px 10px rgba(0, 0, 0, .3);
}
}

207
assets/css/pages/work.css Executable file
View file

@ -0,0 +1,207 @@
/* # Overrides */
:root {
--primer-color-accent: 3, 255, 219;
--color-accent: rgb(var(--primer-color-accent));
}
main {
display: flex;
flex-direction: column;
gap: var(--padding);
padding: calc(var(--padding) * 1.5);
width: 100%;
max-width: 1200px;
overflow-x: initial;
}
/* # Sections */
/* ## Git */
section.git {
display: flex;
flex-direction: column;
gap: var(--padding);
background-color: rgba(var(--primer-color-accent), .1);
padding: calc(var(--padding) * 1.5);
border-radius: 6px;
}
section.git svg {
width: 60px;
}
section.git .buttons {
display: flex;
flex-direction: column;
gap: var(--padding);
}
/* ## Timeline */
section.timeline {
--timestamp-gap: calc(var(--padding) / 2);
width: 100%;
}
section.timeline :is(.year, .month, .day) {
display: grid;
grid-template-columns: calc(40px + var(--timestamp-gap)) 1fr;
grid-template-rows: 1fr;
}
section.timeline .track {
--opacity: .15;
--width: 2%;
background: linear-gradient(90deg,
transparent 0%, transparent calc(50% - var(--width)),
rgba(255, 255, 255, var(--opacity)) calc(50% - var(--width)), rgba(255, 255, 255, var(--opacity)) calc(50% + var(--width)),
transparent calc(50% + var(--width)), transparent 100%
);
}
section.timeline .track p {
position: sticky;
top: calc(var(--running-size) + var(--padding));
padding: calc(var(--padding) / 2) 0;
background-color: black;
color: var(--color-accent);
}
section.timeline :not(.year) > .track p::before {
content: "/ ";
color: rgba(255, 255, 255, .3);
}
/* ### Item */
section.timeline .items .item {
display: flex;
flex-direction: column;
gap: calc(var(--padding) / 2);
padding: var(--padding);
}
section.timeline .items .item + .item {
border-top: solid 2px rgba(255, 255, 255, .2);
}
section.timeline .items .item:first-of-type {
margin-top: var(--padding);
border-top: solid 2px var(--color-accent);
}
/* No border style for the latest item (from the top) in the list */
section.timeline .year:first-of-type .month:first-of-type .day:first-of-type .items .item:first-of-type {
margin-top: unset;
border-top: unset;
}
section.timeline .items .item .tags {
display: flex;
gap: calc(var(--padding) / 2);
}
section.timeline .items .item .tags .tag {
font-size: 11px;
letter-spacing: 1px;
color: rgba(255, 255, 255, .7);
background-color: rgba(255, 255, 255, .15);
border-radius: 4px;
padding: 5px 10px;
}
section.timeline .items .item h2 {
font-size: 30px;
}
section.timeline .items .item h3 {
font-size: 25px;
}
section.timeline .items .item p {
font-size: 16px;
}
section.timeline .items .item img {
max-width: 100%;
height: 250px;
}
section.timeline .items .item .actions {
margin-top: 7px;
font-size: 13px;
}
/* ## Note */
section.note {
text-align: center;
}
/* # Size queries */
@media (min-width: 460px) {
section.git .buttons {
flex-direction: row;
}
}
@media (min-width: 900px) {
section.git {
display: grid;
grid-template-columns: 70px 1fr 400px;
align-items: center;
gap: calc(var(--padding) * 1.5);
}
section.git svg {
width: 100%;
}
section.git .buttons {
justify-content: end;
}
}
@media (max-width: 500px) {
section.timeline {
padding: unset;
}
section.timeline .track {
position: relative;
left: calc(var(--padding) * 1.5);
background: unset;
z-index: 10;
pointer-events: none;
}
section.timeline .track p {
background-color: black;
}
section.timeline :is(.years, .year, .months, .month, .days, .day) {
width: 0;
}
section.timeline .items {
position: relative;
left: -140px;
}
section.timeline .items .item {
width: calc(100vw - (var(--padding) * 3.5));
}
section.timeline .items .item:first-of-type {
border-top-color: rgba(var(--primer-color-accent), .2);
}
section.timeline .year:first-of-type .month:first-of-type .day:first-of-type .items .item:first-of-type {
margin-top: var(--padding);
}
}

Binary file not shown.

Binary file not shown.

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

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

@ -0,0 +1,79 @@
// Fetch and create glitchy background effects
class Generator {
constructor() {
this.bg = {
_this: this,
_image: null,
_dir: location,
_dir_rel: "assets/media/glitch_b64/",
count: 4,
// Get or set current background
get current () { return this._image; },
set current (image) {
this._image = image;
this._this.setBg(image);
},
// Get or set the path to where base64 images are stored
get dir () { return this._dir; },
set dir (newPath) {
const url = new URL(newPath);
// 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();
}
}
}
// Genrate random int in range
static randInt(min, max) {
if(min === max) return min;
return Math.round(Math.random() * (max - min) + min);
}
// Generate random string of length from charset
static randStr(length = 2) {
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let output = "";
for(let i = 0; i < length; i++) {
output += charset.charAt(Math.floor(Math.random() * charset.length));
}
return output;
}
// Give generated background image to parent thread
setBg(image) {
if(typeof image !== "string") throw new TypeError("Image must be of type 'string'");
postMessage(["BG_UPDATE", image]);
}
// Generate and set a glitchy image
glitch() {
if(!this.bg.current) return;
const image = this.bg.current.replaceAll(Generator.randStr(), Generator.randStr());
this.setBg(image);
}
// Fetch a base64 encoded background image
async fetchBg(id) {
const url = new URL(this.bg.dir);
url.pathname += id + ".txt";
const image = await fetch(url);
if(!image.ok) throw new Error("Failed to fetch background image");
return image.text();
}
// Load a random background from the image set
async randBg() {
const id = Generator.randInt(1, this.bg.count);
const image = await this.fetchBg(id);
this.bg.current = image;
}
}

View file

@ -0,0 +1,41 @@
export default class Glitch {
constructor(target) {
this.worker = new Worker(this.getWorkerScriptURL());
this.worker.addEventListener("message", event => this.message(event));
this.target = target ? target : document.body;
}
// Update the target CSS background with an image URL
setVisibleBg(image) {
this.target.style.setProperty("background-image", `url(${image})`);
}
// Get URL for the dedicated worker
getWorkerScriptURL() {
const name = "GlitchWorker.js";
const url = new URL(import.meta.url);
// Replace pathname of this file with worker
const path = url.pathname.split("/");
path[path.length - 1] = name;
url.pathname = path.join("/");
return url.toString();
}
// Event handler for messages from worker thread
message(event) {
const data = typeof event.data === "object" ? event.data : [event.data];
switch(data[0]) {
case "READY":
this.worker.postMessage(["START", new URL(location).toString()]);
break;
case "BG_UPDATE":
this.setVisibleBg(data[1]);
break;
}
}
}

View file

@ -0,0 +1,54 @@
importScripts("./Generator.mjs");
class GlitchWorker extends Generator {
constructor() {
super();
// Delay between these values
this.config = {
glitch: { min: 500, max: 2500 },
randBg: { min: 5000, max: 5000 }
}
this._timers = {};
self.addEventListener("message", event => this.message(event));
self.postMessage("READY");
}
// Run a scoped function on a random interval between
queue(func) {
clearTimeout(this._timers[func]);
const next = Generator.randInt(this.config[func].min, this.config[func].max);
this._timers[func] = setTimeout(() => this.queue(func), next);
this[func]?.();
}
// Set background by id and stop randBg animation
async forceBg(id) {
clearTimeout(this._timers.randBg);
const image = await this.fetchBg(id);
this.bg.current = image;
this.setBg(image);
}
// Event handler for messages from parent thread
message(event) {
const data = typeof event.data === "object" ? event.data : [event.data];
switch(data[0]) {
case "START":
this.bg.dir = data[1];
this.randBg();
for(const func of Object.keys(this.config)) {
this.queue(func);
}
break;
}
}
}
self.glitch = new GlitchWorker();

65
assets/js/pages/about.js Executable file
View file

@ -0,0 +1,65 @@
new vv.Interactions("about");
const randomIntFromInterval = (min, max) => {
return Math.floor(Math.random() * (max - min + 1) + min);
}
// Interest explosion effect from origin position
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");
wrapper.classList.add("active");
[...wrapper.querySelectorAll("p")].forEach(element => {
/*
Generate random visuals for current element
*/
const hue = randomIntFromInterval(0, 360);
const rotate = randomIntFromInterval(-5, 5);
const transX = randomIntFromInterval(TRANS_LIMIT * -1, TRANS_LIMIT);
const transY = randomIntFromInterval(TRANS_LIMIT * -1, TRANS_LIMIT);
// Set initial position
element.style.setProperty("top", `${originY}px`);
element.style.setProperty("left", `${originX}px`);
// Set random HUE rotation
element.style.setProperty("-webkit-filter", `hue-rotate(${hue}deg)`);
element.style.setProperty("filter", `hue-rotate(${hue}deg)`);
// Translate and rotate to random position from origin
element.style.setProperty("transform", `translate(${transX}px, ${transY}px) rotate(${rotate}deg)`);
});
};
// Interest implotion effect from explodeInterests()
const implodeInterests = () => {
const wrapper = document.querySelector("div.interests");
wrapper.classList.remove("active");
[...wrapper.querySelectorAll("p")].forEach(element => {
// Reset to initial position
element.style.setProperty("transform", "translate(0, 0)");
});
};
// Bind triggers for interests explosion and implotion
{
const interestsElement = document.querySelector("section.about span.interests");
// Bind mouse or touch events depending on pointer type of device
const canHover = window.matchMedia("(pointer: fine)").matches;
interestsElement.addEventListener(canHover ? "mouseenter" : "touchstart", () => {
// Get absolute position of the trigger element
const size = interestsElement.getBoundingClientRect();
const x = size.x - 80;
const y = size.y - 10;
explodeInterests(x, y);
});
interestsElement.addEventListener(canHover ? "mouseleave" : "touchend", () => implodeInterests());
}

87
assets/js/pages/contact.js Executable file
View file

@ -0,0 +1,87 @@
class ContactForm {
static STORAGE_KEY = "contact_form_message";
constructor(form) {
this.form = form;
this.getSavedMessageAndPopulateFields();
// Save message each time a button is pressed on a form element
[...document.querySelectorAll("form :is(input, textarea)")].forEach(element => {
element.addEventListener("keyup", () => this.saveMessage());
});
}
// Get saved message as JSON from SessionStorage
static getSavedMessage() {
const data = window.sessionStorage.getItem(ContactForm.STORAGE_KEY);
// Return message data as JSON
return data ? JSON.parse(data) : {};
}
// Remove saved message from SessionStorage if it exists
static removeSavedMessage() {
return window.sessionStorage.removeItem(ContactForm.STORAGE_KEY);
}
// Populate from input fields with data from SessionStorage
getSavedMessageAndPopulateFields() {
const message = ContactForm.getSavedMessage();
// Remove message and bail out if there is no saved message or if it is already sent
if (!message) {
return ContactForm.removeSavedMessage();
}
for (const [name, value] of Object.entries(message)) {
this.form.querySelector(`[name="${name}"]`).value = value;
}
}
// Save current message in SessionStorage
saveMessage() {
const message = {};
// Copy field name and value from FormData into object
(new FormData(this.form)).forEach((v, k) => message[k] = v);
// Save message data to SessionStorage as JSON
window.sessionStorage.setItem(ContactForm.STORAGE_KEY, JSON.stringify(message));
}
}
// Initialize contact form handler
{
const form = document.querySelector("section.form form");
// Create a new form handler or remove any saved message if the form element can't be found
form ? (new ContactForm(form)) : ContactForm.removeSavedMessage();
}
// Social links hover
{
const socialElementHover = (target) => {
const element = target.querySelector("p");
target.classList.add("hovering");
target.addEventListener("mousemove", (event) => {
const x = event.layerX - (element.clientWidth / 2);
const y = event.layerY + element.clientHeight;
element.style.setProperty("transform", `translate(${x}px, ${y}px)`);
});
};
const elements = [...document.querySelectorAll("social")];
elements.forEach(element => {
element.addEventListener("mouseenter", () => socialElementHover(element));
element.addEventListener("mouseleave", () => {
elements.forEach(element => element.classList.remove("hovering"));
});
});
}

47
assets/js/pages/error.js Executable file
View file

@ -0,0 +1,47 @@
import { default as Glitch } from "/assets/js/modules/glitch/Glitch.mjs";
// Start glitch canvas
const canvas = document.querySelector("canvas");
canvas._glitch = new Glitch(canvas);
// Text glitching
{
const GLITCH_MAX_OFFSET_PIXELS = 5;
const GLITCH_COUNT_MAX = 4;
const UNSET_GLITCH_TIMEOUT = 100;
const NEXT_GLITCH_MIN = 100;
const NEXT_GLITCH_MAX = 500;
const randomIntFromInterval = (min, max) => {
return Math.floor(Math.random() * (max - min + 1) + min)
}
const glitchText = (target) => {
const glitch = [];
// Generate text-shadow property values
for (let i = 0; i < randomIntFromInterval(2, GLITCH_COUNT_MAX); i++) {
// Text-shadow x offset
const x = randomIntFromInterval(GLITCH_MAX_OFFSET_PIXELS * -1, GLITCH_MAX_OFFSET_PIXELS);
// Get red or blue color from random parity
const rgb = randomIntFromInterval(0, 1) ? "255,0,0" : "0,0,55";
// Generate random decimal transparancy
const alpha = randomIntFromInterval(30, 50) / 100;
glitch.push(`${x}px 0 0 rgba(${rgb}, ${alpha})`);
}
// Glitch the text!
target.style.setProperty("text-shadow", glitch.join(","));
// Remove glitch effect from text
setTimeout(() => target.style.setProperty("text-shadow", "unset"), UNSET_GLITCH_TIMEOUT);
// Glitch the text again after this timeout
setTimeout(() => glitchText(target), randomIntFromInterval(NEXT_GLITCH_MIN, NEXT_GLITCH_MAX));
};
[...document.querySelectorAll("[glitch-text]")].forEach(element => glitchText(element));
}

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

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

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

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

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

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

BIN
assets/media/gazing.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

1
assets/media/glitch_b64/1.txt Executable file

File diff suppressed because one or more lines are too long

1
assets/media/glitch_b64/2.txt Executable file

File diff suppressed because one or more lines are too long

1
assets/media/glitch_b64/3.txt Executable file

File diff suppressed because one or more lines are too long

1
assets/media/glitch_b64/4.txt Executable file

File diff suppressed because one or more lines are too long

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

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 239 236"><path data-name="Path 1" d="m56.05 4.206 5.38 9.33q5.38 9.331 10.547 17.266t10.32 15.826Q87.45 54.519 93.592 64.4t13.75 22.574q7.607 12.691 17.371 26.915t18.321 25.72q8.558 11.5 19.22 25.4t17.813 22.574q7.152 8.668 12.351 14.4t11.352 12.019q6.153 6.291 7.024 7.379a8.5 8.5 0 1 1-13.814 9.882c-.5-.784-.686-.762-4.874-5.963s-7.854-9.875-11-14.02-7.092-9.178-11.845-15.1-10.756-13.494-18.008-22.711-13.865-17.671-19.836-25.36-12.4-16.31-19.293-25.865-13.011-18.427-18.362-26.615-10.2-15.507-14.543-21.959-8.195-12.338-11.55-17.655-6.776-10.741-10.262-16.27-7.068-11.378-10.743-17.546-5.652-9.57-5.928-10.207a8.571 8.571 0 0 1 14.681-8.606l.631.828Z"/><path data-name="Path 2" d="m234.626 30.162-7 5.514q-7 5.513-13.294 10.98T201.45 57.742q-6.585 5.619-15.323 12.609t-17.72 13.832q-8.985 6.837-19.309 14.616t-21.494 16.446q-11.17 8.668-20.711 16.438t-17.934 15.5q-8.394 7.737-15.018 14.391t-11.856 12.181q-5.233 5.528-10.333 10.628t-10.218 10.656a122.852 122.852 0 0 0-10.088 12.623 101.231 101.231 0 0 0-8.137 13.531q-3.168 6.462-4.367 7.783a10.882 10.882 0 1 1-15.281-15.451c.89-.789-1.79 1.849-1.3 1.246s1.9-2.719 4.221-6.348a113.082 113.082 0 0 1 10.1-13q6.621-7.555 12.336-13.16t11.026-10.17q5.311-4.564 11.007-9.726t12.723-11.6q7.027-6.434 15.677-14.413t18.107-16.071q9.451-8.1 20.354-17.172t20.851-17.4q9.948-8.33 18.507-15.272t16.979-14.28q8.42-7.339 15.067-12.819t13.389-11.057q6.743-5.57 13.844-10.95t8.03-5.933a8.91 8.91 0 0 1 10.816 14Z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
assets/media/icons/email.svg Executable file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

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

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

After

Width:  |  Height:  |  Size: 957 B

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

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 335 354"><path d="m4.282 228.922 4.221-3.787q4.221-3.785 10.552-9.4a96.139 96.139 0 0 0 11.118-11.566q4.787-5.948 8.771-11.169t7.962-10.84q3.976-5.617 7.7-10.789t9.253-12.405q5.533-7.234 9.435-12.755t8.481-11.973q4.577-6.451 8.539-13.025a123.492 123.492 0 0 1 7.968-11.753q4.006-5.181 15.726-8.841t17.147 1.24a41.994 41.994 0 0 1 9.112 12.03q3.684 7.13 6.793 13.964a85.442 85.442 0 0 1 4.91 13.711 124.509 124.509 0 0 0 4.638 14.073 71.427 71.427 0 0 0 6.267 12.521q3.431 5.325-4.376 5.791t-6.017-6.709a143.041 143.041 0 0 0 2.847-14.7q1.059-7.532 1.912-15.545t2.177-14.869a146.725 146.725 0 0 1 4.113-15.76 98.169 98.169 0 0 1 7.468-17.519q4.68-8.616 8.275-15.912t7.955-15.405a130 130 0 0 0 7.707-17.353q3.347-9.243 6.289-16.358a59.929 59.929 0 0 1 8.592-14.233A24.912 24.912 0 0 1 224.829.544q9.363-1.924 15.383 3.042t8.962 15.588q2.944 10.623 4.476 17.994t2.721 13.838q1.188 6.467 2.762 12.955t3.392 16.373q1.816 9.887 3.794 17.266a165.539 165.539 0 0 1 3.558 17.848q1.581 10.469 3.738 21.82t3.708 17.461q1.549 6.113 3.554 13.734a119.018 119.018 0 0 0 4.524 13.922 71.589 71.589 0 0 0 8.17 14.279q5.649 7.977 10.2 13.907a40.582 40.582 0 0 0 10.919 9.871q6.363 3.94 7.851 4.356a19.674 19.674 0 0 1 3.612 1.516 14.583 14.583 0 1 1-14.781 25.091c-1.328-.882.961.847-4.089-1.261s-10.518-5.971-16.4-11.587a89.892 89.892 0 0 1-13.464-15.589q-4.632-7.16-10.11-15.125a73.9 73.9 0 0 1-8.465-15.967 140.875 140.875 0 0 1-4.89-15.764q-1.9-7.764-4.021-14.737t-3.614-13.318q-1.5-6.345-3.676-17.333t-3.785-18.237q-1.61-7.251-3.848-16.343t-4.393-17.354q-2.154-8.262-3.581-15.729t-3.294-16.456a49.3 49.3 0 0 1-.91-16.322q.954-7.334 9.149-8.432t2.988 5.432a56.293 56.293 0 0 0-8.018 13.215q-2.812 6.688-5.712 15.158a124.28 124.28 0 0 1-6.71 15.984q-3.81 7.511-7.54 15.545t-7.977 17.165a105.238 105.238 0 0 0-6.454 17.512 108.416 108.416 0 0 0-3.058 17.166q-.852 8.787-1.73 15.2t-2.637 14.577a144.528 144.528 0 0 1-4.417 15.974 24.9 24.9 0 0 1-9.925 12.771q-7.269 4.956-16.707 3.016t-14.6-8.5a70.59 70.59 0 0 1-8.741-14.427q-3.581-7.864-5.857-13.989a104.862 104.862 0 0 1-3.725-12.2 65.416 65.416 0 0 0-4.172-12.036 39.6 39.6 0 0 1-3.39-12.509q-.672-6.538 5.584-4.243t1.352 9.379q-4.9 7.084-8.586 12.283t-8.853 12.3q-5.173 7.1-9.809 12.526t-8.727 10.8q-4.09 5.378-8.917 11.444t-8.846 11.319q-4.021 5.253-9.061 11.375t-9.726 11.044a142.178 142.178 0 0 1-11.149 10.385q-6.464 5.463-10.672 9.264t-5.512 4.715a13.133 13.133 0 0 1-17.443-19.381l1.048-1.2Z"/></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.8 KiB

1
assets/media/icons/pin.svg Executable file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.4 KiB

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

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 215 188"><path d="m93.953 20.937-3.844-.636a89.261 89.261 0 0 0-10.214-.915 65.816 65.816 0 0 0-12.984.744 26.831 26.831 0 0 0-10.74 3.746 71.473 71.473 0 0 0-7.614 5.76 58.339 58.339 0 0 0-7.21 7.73q-3.724 4.691-7.392 9.793t-6.721 9.921a31.347 31.347 0 0 0-4.23 10.48 61.669 61.669 0 0 0-1.25 11.85q-.075 6.189-.08 11.366a48.734 48.734 0 0 0 1.392 11.141 58.244 58.244 0 0 0 3.355 10.358 20.74 20.74 0 0 0 7.128 8.124 40.9 40.9 0 0 0 11.869 5.82 75.256 75.256 0 0 0 12.41 2.847 100.81 100.81 0 0 0 10.68.885 85.309 85.309 0 0 0 9.525-.269 30.44 30.44 0 0 0 9.787-2.748q5.228-2.353 10.69-4.912a62.845 62.845 0 0 0 10.663-6.4 58.863 58.863 0 0 0 9.23-8.406 45.142 45.142 0 0 0 6.636-9.825 50.637 50.637 0 0 0 4.017-11.63 72.073 72.073 0 0 0 1.687-11.322q.28-4.954.287-9.711a33.212 33.212 0 0 0-2.539-11.468q-2.546-6.71-4.883-11.7a76.923 76.923 0 0 0-4.854-8.906 15.489 15.489 0 0 0-7.315-5.922q-4.8-2-6.775-3.035a21.745 21.745 0 0 0-3.7-1.555 10.967 10.967 0 1 1 7.5-20.577c1.1.471 4.692 1.834 8.341 3.532a61.673 61.673 0 0 1 10.979 6.682 32.938 32.938 0 0 1 8.537 9.12 65.928 65.928 0 0 1 4.78 9.276q1.746 4.29 3.9 9.226t3.905 9.7a33.4 33.4 0 0 1 2.006 9.888q.258 5.126-.011 11.147a117.871 117.871 0 0 1-1.45 13.35q-1.18 7.33-2.332 11.762a61.571 61.571 0 0 1-3 8.743 57.354 57.354 0 0 1-6.708 10.848 77.454 77.454 0 0 1-9.992 11.191q-5.13 4.655-9.55 7.924t-9.753 6.459a127.772 127.772 0 0 1-12.015 6.27 46.306 46.306 0 0 1-12.578 3.944 62.968 62.968 0 0 1-10.52.755q-4.623-.111-11.732-.52a90.462 90.462 0 0 1-13.41-1.735 97.521 97.521 0 0 1-12.833-3.67 54.564 54.564 0 0 1-14.36-8.358q-7.826-6.015-10.81-10.994a72.852 72.852 0 0 1-5.572-11.9 63.621 63.621 0 0 1-3.447-12.587 71.467 71.467 0 0 1-.846-10.91q.014-5.242.1-10.024t.53-9.761a56.963 56.963 0 0 1 1.755-9.806 44.556 44.556 0 0 1 4.216-10.028q2.908-5.2 6.66-11.074t7.6-11.094a94.722 94.722 0 0 1 7.394-8.967 69.511 69.511 0 0 1 7.187-6.635 63.56 63.56 0 0 1 9.217-5.911 68.124 68.124 0 0 1 12.237-5.09A46.635 46.635 0 0 1 68.859.052q5.313-.2 12.923.3t11.456 1.143q3.846.646 4.947.97a9.457 9.457 0 0 1-3.087 18.52l-1.147-.05Z"/><path d="m138.796 108.547 2.611 2.184q2.614 2.184 9.589 7.249t13 9.4q6.022 4.332 11.825 8.654t10.615 8.4q4.812 4.074 10.1 9.344t8.723 8.885q3.436 3.614 4.065 6.053a15.364 15.364 0 0 1-25.646 14.8 29.966 29.966 0 0 1-2.731-3.643c-1.1-1.427-1.029-1.362-4.666-4.416s-7.006-6.029-10.108-8.927-6.491-5.877-10.17-8.94-7.587-6.234-11.726-9.515-8.16-6.593-12.062-9.94-6.691-5.787-8.366-7.322-2.868-2.7-3.579-3.491a13.161 13.161 0 0 1 17.208-19.665l1.32.9Z"/></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

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

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="307" height="1" viewBox="0 0 307 1"><line x2="306" transform="translate(0.5 0.5)" fill="none" stroke="#fff" stroke-linecap="round" stroke-width="1" stroke-dasharray="16" opacity="0.3"/></svg>

After

Width:  |  Height:  |  Size: 238 B

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

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

After

Width:  |  Height:  |  Size: 351 B

5
composer.json Executable file
View file

@ -0,0 +1,5 @@
{
"require": {
"reflect/client": "^2.1"
}
}

56
composer.lock generated Executable file
View file

@ -0,0 +1,56 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "3e10269d358ba18734f4f011cd22c42e",
"packages": [
{
"name": "reflect/client",
"version": "2.1.4",
"source": {
"type": "git",
"url": "https://github.com/VictorWesterlund/reflect-client-php.git",
"reference": "47cee961d1bfdd9261a58dde753d824947e91636"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/VictorWesterlund/reflect-client-php/zipball/47cee961d1bfdd9261a58dde753d824947e91636",
"reference": "47cee961d1bfdd9261a58dde753d824947e91636",
"shasum": ""
},
"type": "library",
"autoload": {
"psr-4": {
"Reflect\\": "src/Reflect/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL-2.0-only"
],
"authors": [
{
"name": "Victor Westerlund",
"email": "victor.vesterlund@gmail.com"
}
],
"description": "Extendable PHP interface for communicating with Reflect API over HTTP or UNIX sockets",
"support": {
"issues": "https://github.com/VictorWesterlund/reflect-client-php/issues",
"source": "https://github.com/VictorWesterlund/reflect-client-php/tree/2.1.4"
},
"time": "2023-08-18T14:41:31+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"prefer-stable": false,
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
"plugin-api-version": "2.0.0"
}

73
pages/about.php Executable file
View file

@ -0,0 +1,73 @@
<?php
use Reflect\Client;
use Reflect\Method;
// Connect to VLW API
function api_client(): Client {
return new Client($_ENV["api"]["base_url"], $_ENV["api"]["api_key"], https_peer_verify: $_ENV["api"]["verify_peer"]);
}
// Return the amount of cups of coffee had in the last 24 hours
function get_coffee_24h(): int {
// Retreive coffee list from endpoint
$resp = api_client()->call("/coffee", Method::GET);
$offset = 86400; // 24 hours in seconds
$now = time();
// Get only timestamps from response
$coffee_dates = array_column($resp[1], "date_timestamp_created");
// Filter array for timestamps between now and $offset
$coffee_last_day = array_filter($coffee_dates, fn(int $time): bool => $time >= ($now - $offset));
return count($coffee_last_day);
}
?>
<style><?= VV::css("pages/about") ?></style>
<section class="intro">
<h2 aria-hidden="true">Hi, I'm</h2>
<h1>Victor Westerlund</h1>
</section>
<hr aria-hidden="true">
<section class="about">
<p>I&ZeroWidthSpace;'m a full-stack web developer from Sweden, currently working as IT-Lead at <a href="https://icellate.com">iCellate&nbsp;Medical</a> in Solna, Stockholm - a biopharma start-up developing precision oncology. I develop and maintain <a href="https://docs.vlw.one/vegvisir">my own web framework</a> and use it to build web apps and websites - including this one.</p>
<p>The &lt;programming/markup/command&gt;-languages I currently use the most are (in a mostly accurate decending order): PHP, JavaScript, CSS, MySQL, Python, SQLite, Bash, and [raw] HTML. In the process of learning Rust!</p>
</section>
<section class="about">
<h2>This website</h2>
<p>This site and all of its components are 100% Free Software; licensed under the GNU GPLv3. It's built on top of my own <a href="">Vegvisir</a> (web) and <a href="">Reflect</a> (API) framework. There are no cookies or trackers on this site and analytics <strong>only</strong> consist of basic access and error logs; and from which IP address.</p>
</section>
<section class="about">
<h2>Projects</h2>
<p>These are my top projects I'm working on right now:</p>
<p>* <a href="">Vegvisir</a>: A web framework written in PHP, for PHP developers.</p>
<p>* <a href="">Reflect</a>: An API framework also written in PHP, for PHP developers.</p>
<p>See more on my <a href="work" vv="about" vv-call="navigate">works page</a>. And even more including smaller projects on my <a href="https://github.com/VictorWesterlund">GitHub</a>.</p>
</section>
<section class="about">
<h2>Personal</h2>
<p>At times, I can become a real sucker for a <span class="interests">variety of topics I find interesting</span>, and spend hours reading as much as I can about them too. When I'm not glued to a computer screen, I like me some skiing and occasional hobby photography. I'm also a real coffeeholic.</p>
<p>Let's work on something together, have a chat, or anything else. <a href="contact" vv="about" vv-call="navigate">write me a line!</a></p>
</section>
<section class="about">
<h2>Philosophy</h2>
<p>I believe in a world where humans treat other humans as humans, not products of profit and control. While my focus primarily lies in software freedom - that is, software that respects the user's right to freedom. My main goal is to preserve and promote liberalism.</p>
<?php //<p>See my unstructured "blog" for posts (rants) about this now and then if this sounds interesting to you.</p> ?>
</section>
<hr>
<section class="version">
<p>website version: <?= VV::include("about/version") ?></p>
</section>
<div class="interests" aria-hidden="true">
<p>practical&nbsp;engineering</p>
<p>music</p>
<p>astronomy</p>
<p>electronics</p>
<p>aviation</p>
<p>marine&nbsp;technology</p>
<p>typography</p>
</div>
<script><?= VV::js("pages/about") ?></script>

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

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

81
pages/contact.php Executable file
View file

@ -0,0 +1,81 @@
<?php
use Reflect\Client;
use Reflect\Method;
// Connect to VLW API
$api = new Client($_ENV["api"]["base_url"], $_ENV["api"]["api_key"], https_peer_verify: $_ENV["api"]["verify_peer"]);
$message_sent = null;
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$post_message = $api->call("messages", Method::POST, $_POST);
// Set message sent to true if ok, false if something went wrong
$message_sent = $post_message[0] === 201;
}
?>
<style><?= VV::css("pages/contact") ?></style>
<section>
<h1>Let's chat</h1>
<p>The best way to get in touch is by email, or with the form on this page. The time in Sweden right now is <span></span> so I will probably reply within a few hours.</p>
</section>
<section class="social">
<a href="mailto:victor@vlw.se"><social>
<?= VV::media("icons/email.svg") ?>
<p>e-mail</p>
</social></a>
<a href="https://mastodon.social/@vlwone"><social>
<?= VV::media("icons/mastodon.svg") ?>
<p>mastodon</p>
</social></a>
<a href="https://web.libera.chat/#vlw.se"><social>
<?= VV::media("icons/libera.svg") ?>
<p>libera.chat</p>
</social></a>
</section>
<?= VV::media("line.svg") ?>
<section class="pgp">
<?= VV::media("icons/pin.svg") ?>
<h3>encrypt your message with my OpenPGP key.</h3>
<p>my key is also listed on the <a href="https://keys.openpgp.org/search?q=victor%40vlw.se" target="_blank" rel="noopener noreferer">openPGP key server</a> for victor@vlw.se so your e-mail client can automatically retreive it if supported.</p>
<div class="buttons">
<a href="https://keys.openpgp.org/vks/v1/by-fingerprint/DCE987311CB5D2A252F58951D0AD730E1057DFC6"><button class="solid">download ASC</button></a>
<a href="https://emailselfdefense.fsf.org/en/" target="_blank" rel="noopener noreferer"><button>more info</button></a>
</div>
</section>
<?= VV::media("line.svg") ?>
<?php // Show contact form if a message has not been (sucessfully) sent ?>
<?php if ($message_sent !== true): ?>
<?php // Show error message if something went wrong ?>
<?php if ($message_sent === false): ?>
<section class="form-message error">
<h3>😟 Oh no, something went wrong</h3>
<p>Response from API:</p>
<pre><?= json_encode($post_message[1], JSON_PRETTY_PRINT) ?></pre>
</section>
<?php endif; ?>
<section class="form">
<form method="POST">
<input-group>
<label>your email</label>
<input type="email" name="email" placeholder="nissehult@example.com" autocomplete="off"></input>
</input-group>
<input-group>
<label title="this field is required">your message<sup>*</sup></label>
<textarea name="message" required placeholder="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed molestie dignissim mauris vel dignissim. Sed et aliquet odio, id egestas libero. Vestibulum ut dui a turpis aliquam hendrerit id et dui. Morbi eu tristique quam, sit amet dictum felis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin ac nibh a ex accumsan ullamcorper non quis eros. Nam at suscipit lacus. Nullam placerat semper sapien, vitae aliquet nisl elementum a. Duis viverra quam eros, eu vestibulum quam egestas sit amet. Duis lobortis varius malesuada. Mauris in fringilla mi. "></textarea>
</input-group>
<button class="solid">send</button>
</form>
</section>
<?php else: ?>
<section class="form-message sent">
<h3>🙏 Message sent!</h3>
</section>
<?php endif; ?>
<script><?= VV::js("pages/contact") ?></script>

71
pages/document.php Executable file
View file

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>
<!--//--><![CDATA[//><!--
/**
* @licstart The following is the entire license notice for the JavaScript
* code in this page.
*
* Copyright (C) 2020 Free Software Foundation.
*
* The JavaScript code in this page is free software: you can redistribute
* it and/or modify it under the terms of the GNU General Public License
* (GNU GPL) as published by the Free Software Foundation, either version 3
* of the License, or (at your option) any later version. The code is
* distributed WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU GPL
* for more details.
*
* As additional permission under GNU GPL version 3 section 7, you may
* distribute non-source (e.g., minimized or compacted) forms of that code
* without the copy of the GNU GPL normally required by section 4, provided
* you include this license notice and a URL through which recipients can
* access the Corresponding Source.
*
* @licend The above is the entire license notice for the JavaScript code
* in this page.
*/
//--><!]]>
</script>
<?php // Bootstrapping ?>
<style><?= VV::css("fonts") ?></style>
<style><?= VV::css("document") ?></style>
<title>Victor Westerlund</title>
</head>
<body>
<header>
<nav>
<p><a href="/" vv="document" vv-call="navigate">victor westerlund</a></p>
</nav>
<searchbox>
<?= VV::media("icons/search.svg") ?>
<p>search anything...</p>
</searchbox>
<a href="/" vv="document" vv-call="navigate">
<div class="logo">
<?= VV::media("vw.svg") ?>
</div>
</a>
</header>
<main></main>
<dialog class="search">
<search>
<input type="text" placeholder="start typing to search..."></input>
<search-results></search-results>
</search>
</dialog>
<?php // Bootstrapping ?>
<script><?= VV::init() ?></script>
<script><?= VV::js("document") ?></script>
</body>
</html>

6
pages/error.php Executable file
View file

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

31
pages/index.php Executable file
View file

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

107
pages/search.php Executable file
View file

@ -0,0 +1,107 @@
<?php
use Reflect\Client;
use Reflect\Method;
// Connect to VLW API
$api = new Client($_ENV["api"]["base_url"], $_ENV["api"]["api_key"], https_peer_verify: $_ENV["api"]["verify_peer"]);
// Get query string from search parameter if set
$query = array_key_exists("q", $_GET) ? $_GET["q"] : null;
// Retreive rows from search endpoint if search parameter is set
$resp = $query ? $api->call("/search?q={$query}", Method::GET) : null;
// ISO 8601: YYYY-MM-DD
$date_format = "Y-m-d";
?>
<style><?= VV::css("pages/search") ?></style>
<section class="search">
<form method="GET">
<search>
<input name="q" type="text" placeholder="search anything..." value="<?= $_GET["q"] ?>"></input>
</search>
<button type="submit" class="solid">Search</button>
</form>
<?= VV::media("line.svg") ?>
<button>advanced search options</button>
</section>
<?php if ($resp): ?>
<?php // Get response body ?>
<?php $body = $resp[1]; ?>
<?php // Get search results from API response if successful ?>
<?php if ($resp[0] === 200): ?>
<?php // Show category sections if search matches were found ?>
<?php if ($body["total_num_results"] > 0): ?>
<?php // Get search results by category ?>
<?php $categories = $body["results"]; ?>
<?php // Search response: Work ?>
<?php if (!empty($categories["work"])): ?>
<section class="title work">
<a href="/work" vv="search" vv-call="navigate"><h2>Work</h2></a>
<p><?= count($categories["work"]) ?> search result(s) from my public work</p>
</section>
<section class="results work">
<?php foreach ($categories["work"] as $result): ?>
<a href="/work/<?= $result["id"] ?>" vv="search" vv-call="navigate"><div class="result">
<h3><?= $result["title"] ?></h3>
<p><?= $result["summary"] ?></p>
<p><?= date($date_format, $result["date_timestamp_created"]) ?></p>
</div></a>
<?php endforeach; ?>
</section>
<?php endif; ?>
<?php // No search matches were found ?>
<?php else: ?>
<section class="empty">
<p>No results for search term "<?= $_GET["q"] ?>"</p>
</section>
<?php endif; ?>
<?php // Didn't get a 200 response from endpoint ?>
<?php else: ?>
<?php // Request validation issue if response code is 422 ?>
<?php if ($resp[0] === 422): ?>
<?php // Get all validation errors for query and list them ?>
<?php foreach ($body["GET"]["q"] as $error_code => $error_msg): ?>
<?php // Check the error code of the current error ?>
<?php switch ($error_code): default: ?>
<section class="error">
<p>Unknown request validation error</p>
</section>
<?php break; ?>
<?php // Search query string is not long enough ?>
<?php case "VALUE_MIN_ERROR": ?>
<section class="error">
<p>Type at least <?= $error_msg ?> characters to search!</p>
</section>
<?php break; ?>
<?php endswitch; ?>
<?php endforeach; ?>
<?php // Something unexpected went wrong ?>
<?php else: ?>
<section class="error">
<p>Something went wrong</p>
</section>
<?php endif; ?>
<?php endif; ?>
<?php // No query search paramter set, show general information ?>
<?php else: ?>
<?= VV::media("icons/search.svg") ?>
<?php endif; ?>
<script><?= VV::js("pages/search") ?></script>

162
pages/work.php Executable file
View file

@ -0,0 +1,162 @@
<?php
use Reflect\Client;
use Reflect\Method;
// Connect to VLW API
$api = new Client($_ENV["api"]["base_url"], $_ENV["api"]["api_key"], https_peer_verify: $_ENV["api"]["verify_peer"]);
// Retreive rows from work endpoint
$resp = $api->call("/work", Method::GET);
?>
<style><?= VV::css("pages/work") ?></style>
<section class="git">
<?= VV::media("icons/github.svg") ?>
<p>Most of my free open-source software is available on GitHub and it's also mirrored on my server</p>
<div class="buttons">
<a href="https://github.com/victorwesterlund"><button class="solid">open GitHub</button></a>
<a href="https://git.vlw.se"><button>mirror</button></a>
</div>
</section>
<?php if ($resp[0] === 200): ?>
<?php
/*
Order response from endpoint into a multi-dimensional array.
For example, a single item created at 14th of February 2024 would be ordered like this
[2024 => [[02 => [14 => [<row_data>]]]]]
*/
$rows = [];
// Create array of arrays ordered by decending year, month, day, items
foreach ($resp[1] as $row) {
// Create array for current year if it doesn't exist
if (!array_key_exists($row["date_year"], $rows)) {
$rows[$row["date_year"]] = [];
}
// Create array for current month if it doesn't exist
if (!array_key_exists($row["date_month"], $rows[$row["date_year"]])) {
$rows[$row["date_year"]][$row["date_month"]] = [];
}
// Create array for current day if it doesn't exist
if (!array_key_exists($row["date_day"], $rows[$row["date_year"]][$row["date_month"]])) {
$rows[$row["date_year"]][$row["date_month"]][$row["date_day"]] = [];
}
// Append item to ordered array
$rows[$row["date_year"]][$row["date_month"]][$row["date_day"]][] = $row;
}
?>
<section class="timeline">
<?php // Get year int from key and array of months for current year ?>
<?php foreach($rows as $year => $months): ?>
<div class="year">
<div class="track">
<p><?= $year ?></p>
</div>
<div class="months">
<?php // Get month int from key and array of days for current month ?>
<?php foreach($months as $month => $days): ?>
<div class="month">
<div class="track">
<?php // Append leading zero to month ?>
<p><?= sprintf("%02d", $month) ?></p>
</div>
<div class="days">
<?php // Get day int from key and array of items for current day ?>
<?php foreach($days as $day => $items): ?>
<div class="day">
<div class="track">
<?php // Append leading zero to day ?>
<p><?= sprintf("%02d", $day) ?></p>
</div>
<div class="items">
<?php foreach($items as $item): ?>
<div class="item">
<?php // List tags if defined for item ?>
<?php if(!empty($item["tags"])): ?>
<div class="tags">
<?php foreach($item["tags"] as $tag): ?>
<p class="tag <?= $tag["name"] ?>"><?= $tag["name"] ?></p>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php // Show large heading if defined ?>
<?php if (!empty($item["title"])): ?>
<h2><?= $item["title"] ?></h2>
<?php endif; ?>
<?php // Show cover image if defined for item ?>
<?php if (!empty($item["cover_srcset"])): ?>
<picture>
<?php // List all srcset images ?>
<?php foreach ($item["cover_srcset"]["srcset"] as $srcset): ?>
<?php // Skip any media that isn't an image ?>
<?php if ($srcset["type"] !== "IMAGE"): continue; endif; ?>
<srcset src="/assets/media/content/<?= $srcset["id"] ?>.<?= $srcset["extension"] ?>" type="<?= $srcset["mime"] ?>"></srcset>
<?php endforeach; ?>
<?php
// Get the default/fallback image for this srcset
$default = $item["cover_srcset"]["default"];
?>
<img src="/assets/media/content/<?= $default["id"] ?>.<?= $default["extension"] ?>" type="<?= $default["mime"] ?>" loading="lazy"/>
</picture>
<?php endif; ?>
<p><?= $item["summary"] ?></p>
<?php // List actions if defined for item ?>
<?php if(!empty($item["actions"])): ?>
<div class="actions">
<?php foreach($item["actions"] as $action): ?>
<?php
// Bind VV interactions for buttons or add new tab target if external link
$link_attr = !$action["external"] ? "vv='work' vv-call='navigate'" : "target='_blank'";
// Self-reference to a work page with the item id if no href is set
$link_href = $action["href"] === null ? "/work/{$item["id"]}" : $action["href"];
?>
<a href="<?= $link_href ?>" <?= $link_attr ?>><button class="<?= $action["class_list"] ?>"><?= $action["display_text"] ?></button></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</section>
<section class="note">
<p>This is not really the end of the list. I will add some of my notable older work at some point.</p>
</section>
<?php else: ?>
<p>Something went wrong!</p>
<?php endif; ?>
<script><?= VV::js("pages/work") ?></script>