Compare commits
1 commit
68647a3b4d
...
04e3762514
Author | SHA1 | Date | |
---|---|---|---|
04e3762514 |
|
@ -1,20 +1,7 @@
|
|||
[client_api]
|
||||
base_url = ""
|
||||
[api]
|
||||
base_url = "https://api.vlw.one/"
|
||||
api_key = ""
|
||||
verify_peer = true
|
||||
verify_peer = 0
|
||||
|
||||
[client_time_available]
|
||||
time_zone = "Europe/Stockholm"
|
||||
available_to_hour = 0;
|
||||
reply_average_hours = 0;
|
||||
available_from_hour = 0;
|
||||
|
||||
[server_database]
|
||||
host = ""
|
||||
user = ""
|
||||
pass = ""
|
||||
db = ""
|
||||
|
||||
[server_forgejo]
|
||||
base_url = ""
|
||||
scan_profiles = ""
|
||||
[time]
|
||||
date_time_zone = "Europe/Stockholm"
|
6
.gitignore
vendored
|
@ -1,12 +1,8 @@
|
|||
# Public assets #
|
||||
#################
|
||||
public/.well-known
|
||||
public/assets/js/modules/npm
|
||||
assets/media/content
|
||||
|
||||
# Bootstrapping #
|
||||
#################
|
||||
vendor
|
||||
node_modules
|
||||
.env.ini
|
||||
|
||||
# OS generated files #
|
||||
|
|
101
README.md
|
@ -1,67 +1,70 @@
|
|||
# vlw.se
|
||||
This is the source code behind [vlw.se](https://vlw.se) which is my personal website that I have written and designed from the ground up. The website is built on top of my own [web framework](https://vegvisir.vlw.se) and its API is also built on top of my own [API framework](https://reflect.vlw.se).
|
||||
This is the source code behind [vlw.se](https://vlw.se) which has been written from the ground up by me. This website is built on top of my [Vegvisir web framework](https://github.com/victorwesterlund/vegvisir) and my [Reflect API framework](https://github.com/victorwesterlund/reflect).
|
||||
|
||||
# Installation
|
||||
Here's how you get my website up and running on your own machine. Note, I have only tested this on Linux and the install script we will run later is written in bash.
|
||||
If you for whatever reason want to get this website up and running for yourself this is how that is done.
|
||||
|
||||
**Make sure you have both of these package managers installed before proceeding:**
|
||||
- [Composer](https://getcomposer.org/)
|
||||
- [NPM](https://www.npmjs.com/)
|
||||
This website is built for PHP 8.0+ and MariaDB 14+ (for the API database).
|
||||
|
||||
## 1. Clone this repo
|
||||
Clone/download this repo to your machine. Preferably to a non-public directory - the frameworks will handle that.
|
||||
**Confimed supported framework versions:**
|
||||
Vegvisir|Reflect
|
||||
--|--
|
||||
✅ [`2.4.4`](https://github.com/VictorWesterlund/vegvisir/releases/tag/2.4.4)|✅ [`2.7.1`](https://github.com/VictorWesterlund/reflect/releases/tag/2.7.1)
|
||||
|
||||
```
|
||||
git clone https://codeberg.org/vlw/vlw.se --depth 1
|
||||
```
|
||||
## 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.
|
||||
|
||||
## 2. Install [Vegvisir](https://vegvisir.vlw.se) and [Reflect](https://reflect.vlw.se)
|
||||
Follow the installation instructions for my web, and API framework. This site uses the default configuration for both frameworks so the only thing you need to do after you've installed both is to point the `root_path` and `endpoints` directory respectively to the directory where you cloned this repo.
|
||||
3. **Install dependencies**
|
||||
|
||||
- [Vegvisir installation](https://vegvisir.vlw.se)
|
||||
- [Reflect installation](https://reflect.vlw.se)
|
||||
Install dependencies with composer.
|
||||
```
|
||||
composer install --optimize-autoloader
|
||||
```
|
||||
|
||||
*Example:*
|
||||
```sh
|
||||
# Vegvisir
|
||||
root_path = "/var/www/vlw.se"
|
||||
# Reflect
|
||||
endpoints = "/var/www/vlw.se"
|
||||
```
|
||||
Et voila! You probably want to install the API-side too but the website itself should now be accessible from your configured Vegvisir host.
|
||||
|
||||
## 3. Run the install script
|
||||
Run the `install.sh` script from the root of the repo directory. [Make sure you have the required package managers installed](#installation).
|
||||
## API (Reflect)
|
||||
The API (and database) is where most content is stored and served from on this website.
|
||||
|
||||
**Example:**
|
||||
```sh
|
||||
# vlw@example:$
|
||||
cd /var/www/vlw.se
|
||||
# vlw@example:/var/www/vlw.se$
|
||||
./install.sh
|
||||
```
|
||||
1. **Download this repo**
|
||||
|
||||
## 4. Import the database templates
|
||||
There's are two SQL files in [`/src/Database/Seeds/`](https://codeberg.org/vlw/vlw.se/src/branch/master/src/Database/Seeds) that you can use to initialize the two databases required for this website.
|
||||
**You can skip this if you've already downloaded the repo from step 1 in the website installation.**
|
||||
|
||||
- `vlw` - This database has the website data and should be added to the `db` variable under `server_database` in `/.env.ini`
|
||||
- `vlw_reflect` - This is the Reflect database that has all the endpoints pre-configured. You'll have to add your own ACL rules.
|
||||
Otherwise... Git clone or download this repo to any local folder
|
||||
```
|
||||
git clone https://github.com/VictorWesterlund/vlw.se
|
||||
```
|
||||
|
||||
## 5. Set environment variables
|
||||
Make a copy of the `.env.example.ini` file called `.env.ini` from the root directory of the repo. There are a few parameters you can change here but the required ones are the following:
|
||||
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.
|
||||
|
||||
```ini
|
||||
[client_api]
|
||||
base_url = ""
|
||||
api_key = ""
|
||||
3. **Install dependencies**
|
||||
|
||||
[server_database]
|
||||
host = ""
|
||||
user = ""
|
||||
pass = ""
|
||||
db = ""
|
||||
```
|
||||
Install dependencies with composer.
|
||||
```
|
||||
composer install --optimize-autoloader
|
||||
```
|
||||
|
||||
Please refer to the comments in the ini file for more information about each field.
|
||||
4. **Create and import database**
|
||||
|
||||
## Done!
|
||||
That should be it. Navigate to your configured Vegvisir public host!
|
||||
[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
|
@ -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 = ""
|
14
api/composer.json
Executable file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"require": {
|
||||
"local/api.endpoints": "1.0.0-dev",
|
||||
"reflect/plugin-rules": "^1.5",
|
||||
"victorwesterlund/xenum": "^1.1"
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"repositories": [
|
||||
{
|
||||
"type": "path",
|
||||
"url": "src/packages/Endpoints"
|
||||
}
|
||||
]
|
||||
}
|
120
api/composer.lock
generated
Executable file
|
@ -0,0 +1,120 @@
|
|||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "9da96ba90ef20d885034442b30dce0a3",
|
||||
"packages": [
|
||||
{
|
||||
"name": "local/api.endpoints",
|
||||
"version": "1.0.0-dev",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "src/packages/Endpoints",
|
||||
"reference": "89b7b9a4cc504abddb4aeec8e05a95c9d9087575"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"VLW\\API\\": "src/"
|
||||
}
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
"name": "Victor Westerlund",
|
||||
"email": "victor@vlw.se"
|
||||
}
|
||||
],
|
||||
"description": "Endpoint pathmappings for VLW API",
|
||||
"transport-options": {
|
||||
"relative": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reflect/plugin-rules",
|
||||
"version": "1.5.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/VictorWesterlund/reflect-rules-plugin.git",
|
||||
"reference": "9c837fd1944133edfed70a63ce8b32bf67f0d94b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/VictorWesterlund/reflect-rules-plugin/zipball/9c837fd1944133edfed70a63ce8b32bf67f0d94b",
|
||||
"reference": "9c837fd1944133edfed70a63ce8b32bf67f0d94b",
|
||||
"shasum": ""
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"ReflectRules\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"GPL-3.0-only"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Victor Westerlund",
|
||||
"email": "victor.vesterlund@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Add request search paramter and request body constraints to an API built with Reflect",
|
||||
"support": {
|
||||
"issues": "https://github.com/VictorWesterlund/reflect-rules-plugin/issues",
|
||||
"source": "https://github.com/VictorWesterlund/reflect-rules-plugin/tree/1.5.0"
|
||||
},
|
||||
"time": "2024-01-17T11:07:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "victorwesterlund/xenum",
|
||||
"version": "1.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/VictorWesterlund/php-xenum.git",
|
||||
"reference": "8972f06f42abd1f382807a67e937d5564bb89699"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/VictorWesterlund/php-xenum/zipball/8972f06f42abd1f382807a67e937d5564bb89699",
|
||||
"reference": "8972f06f42abd1f382807a67e937d5564bb89699",
|
||||
"shasum": ""
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"victorwesterlund\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"GPL-3.0-only"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Victor Westerlund",
|
||||
"email": "victor.vesterlund@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "PHP eXtended Enums. The missing quality-of-life features from PHP 8+ Enums",
|
||||
"support": {
|
||||
"issues": "https://github.com/VictorWesterlund/php-xenum/issues",
|
||||
"source": "https://github.com/VictorWesterlund/php-xenum/tree/1.1.1"
|
||||
},
|
||||
"time": "2023-11-20T10:10:39+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "dev",
|
||||
"stability-flags": {
|
||||
"local/api.endpoints": 20
|
||||
},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": [],
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.0.0"
|
||||
}
|
39
api/endpoints/coffee/GET.php
Executable 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
|
@ -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
|
@ -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
|
@ -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();
|
||||
}
|
||||
}
|
106
api/endpoints/media/srcset/GET.php
Executable 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)
|
||||
]);
|
||||
}
|
||||
}
|
55
api/endpoints/media/srcset/POST.php
Executable 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
|
@ -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
|
@ -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);
|
||||
}
|
||||
}
|
140
api/endpoints/search/GET.php
Executable file
|
@ -0,0 +1,140 @@
|
|||
<?php
|
||||
|
||||
use Reflect\Call;
|
||||
use Reflect\Path;
|
||||
use Reflect\Response;
|
||||
use ReflectRules\Type;
|
||||
use ReflectRules\Rules;
|
||||
use ReflectRules\Ruleset;
|
||||
|
||||
use VLW\API\Endpoints;
|
||||
|
||||
use VLW\API\Databases\VLWdb\VLWdb;
|
||||
use VLW\API\Databases\VLWdb\Models\Work\WorkModel;
|
||||
use VLW\API\Databases\VLWdb\Models\Work\WorkActionsModel;
|
||||
|
||||
require_once Path::root("src/databases/VLWdb.php");
|
||||
require_once Path::root("src/databases/models/Work.php");
|
||||
require_once Path::root("src/databases/models/WorkActions.php");
|
||||
|
||||
class GET_Search extends VLWdb {
|
||||
const GET_QUERY = "q";
|
||||
|
||||
protected Ruleset $ruleset;
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct();
|
||||
$this->ruleset = new Ruleset(strict: true);
|
||||
|
||||
$this->ruleset->GET([
|
||||
(new Rules(self::GET_QUERY))
|
||||
->required()
|
||||
->type(Type::STRING)
|
||||
->min(2)
|
||||
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
|
||||
]);
|
||||
}
|
||||
|
||||
// Return an SQL string from array for use in prepared statements
|
||||
private static function array_to_wildcard_sql(array $columns): string {
|
||||
$sql = array_map(fn(string $column): string => "{$column} LIKE CONCAT('%', ?, '%')", $columns);
|
||||
|
||||
return implode(" OR ", $sql);
|
||||
}
|
||||
|
||||
// Return chained AND statements from array for use in prepared statements
|
||||
private static function array_to_and_statement(array $keys): string {
|
||||
$sql = array_map(fn(string $k): string => "{$k} = ?", $keys);
|
||||
|
||||
return implode(" AND ", $sql);
|
||||
}
|
||||
|
||||
// Wildcard search columns in table with query string from query string
|
||||
// This has to be implemented manually until "libmysqldriver/MySQL" supports wildcard SELECT
|
||||
private function search(string $table, array $columns, array $conditions = null): array {
|
||||
// Create CSV from columns array
|
||||
$columns_concat = implode(",", $columns);
|
||||
|
||||
// Create SQL LIKE wildcard statement for each column.
|
||||
$where = self::array_to_wildcard_sql($columns);
|
||||
|
||||
// Create array of values from query string for each colum
|
||||
$values = array_fill(0, count($columns), $_GET[self::GET_QUERY]);
|
||||
|
||||
if ($conditions) {
|
||||
$conditions_sql = self::array_to_and_statement(array_keys($conditions));
|
||||
|
||||
// Wrap positive where statements and prepare new group of conditions
|
||||
// WHERE (<search_terms>) AND (<conditions>)
|
||||
$where = "({$where}) AND ({$conditions_sql})";
|
||||
|
||||
// Append values from conditions statements to prepared statement
|
||||
array_push($values, ...array_values($conditions));
|
||||
}
|
||||
|
||||
// Order the rows by the array index of $colums received
|
||||
$rows = $this->db->exec("SELECT {$columns_concat} FROM {$table} WHERE {$where} ORDER BY {$columns_concat}", $values);
|
||||
// Return results as assoc or empty array
|
||||
return parent::is_mysqli_result($rows) ? $rows->fetch_all(MYSQLI_ASSOC) : [];
|
||||
}
|
||||
|
||||
// Search work table
|
||||
private function search_work(): array {
|
||||
$search = [
|
||||
WorkModel::TITLE->value,
|
||||
WorkModel::SUMMARY->value,
|
||||
WorkModel::DATE_TIMESTAMP_CREATED->value,
|
||||
WorkModel::ID->value
|
||||
];
|
||||
|
||||
$conditions = [
|
||||
WorkModel::IS_LISTABLE->value => true
|
||||
];
|
||||
|
||||
$results = $this->search(WorkModel::TABLE, $search, $conditions);
|
||||
|
||||
foreach ($results as &$result) {
|
||||
$result["actions"] = (new Call(Endpoints::WORK_ACTIONS->value))
|
||||
->params([WorkActionsModel::ANCHOR->value => $result[WorkModel::ID->value]])
|
||||
->get()->output();
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
// # Responses
|
||||
|
||||
// Return 422 Unprocessable Content error if request validation failed
|
||||
private function resp_rules_invalid(): Response {
|
||||
return new Response($this->ruleset->get_errors(), 422);
|
||||
}
|
||||
|
||||
// Return a 503 Service Unavailable error if something went wrong with the database call
|
||||
private function resp_database_error(): Response {
|
||||
return new Response("Failed to get work data, please try again later", 503);
|
||||
}
|
||||
|
||||
public function main(): Response {
|
||||
// Bail out if request validation failed
|
||||
if (!$this->ruleset->is_valid()) {
|
||||
return $this->resp_rules_invalid();
|
||||
}
|
||||
|
||||
// Get search results for each category
|
||||
$categories = [
|
||||
WorkModel::TABLE => $this->search_work()
|
||||
];
|
||||
|
||||
// Count total number of results from all categories
|
||||
$total_num_results = 0;
|
||||
foreach (array_values($categories) as $results) {
|
||||
$total_num_results += count($results);
|
||||
}
|
||||
|
||||
return new Response([
|
||||
"query" => $_GET[self::GET_QUERY],
|
||||
"results" => $categories,
|
||||
"total_num_results" => $total_num_results
|
||||
]);
|
||||
}
|
||||
}
|
60
api/endpoints/work/DELETE.php
Executable 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();
|
||||
}
|
||||
}
|
136
api/endpoints/work/GET.php
Executable file
|
@ -0,0 +1,136 @@
|
|||
<?php
|
||||
|
||||
use Reflect\Call;
|
||||
use Reflect\Path;
|
||||
use Reflect\Method;
|
||||
use Reflect\Response;
|
||||
use ReflectRules\Type;
|
||||
use ReflectRules\Rules;
|
||||
use ReflectRules\Ruleset;
|
||||
|
||||
use VLW\API\Endpoints;
|
||||
|
||||
use VLW\API\Databases\VLWdb\VLWdb;
|
||||
use VLW\API\Databases\VLWdb\Models\Work\WorkModel;
|
||||
use VLW\API\Databases\VLWdb\Models\Work\WorkTagsModel;
|
||||
use VLW\API\Databases\VLWdb\Models\Work\WorkActionsModel;
|
||||
|
||||
require_once Path::root("src/databases/VLWdb.php");
|
||||
require_once Path::root("src/databases/models/Work.php");
|
||||
require_once Path::root("src/databases/models/WorkTags.php");
|
||||
require_once Path::root("src/databases/models/WorkActions.php");
|
||||
|
||||
class GET_Work extends VLWdb {
|
||||
protected Ruleset $ruleset;
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct();
|
||||
$this->ruleset = new Ruleset(strict: true);
|
||||
|
||||
$this->ruleset->GET([
|
||||
(new Rules("id"))
|
||||
->type(Type::STRING)
|
||||
->min(1)
|
||||
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
|
||||
->default(null)
|
||||
]);
|
||||
}
|
||||
|
||||
// # Helper methods
|
||||
|
||||
private function fetch_row_tags(string $id): array {
|
||||
$resp = $this->db->for(WorkTagsModel::TABLE)
|
||||
->where([WorkTagsModel::ANCHOR->value => $id])
|
||||
->select(WorkTagsModel::NAME->value);
|
||||
|
||||
return parent::is_mysqli_result($resp) ? $resp->fetch_all(MYSQLI_ASSOC) : [];
|
||||
}
|
||||
|
||||
// # Responses
|
||||
|
||||
// Return 422 Unprocessable Content error if request validation failed
|
||||
private function resp_rules_invalid(): Response {
|
||||
return new Response($this->ruleset->get_errors(), 422);
|
||||
}
|
||||
|
||||
// Return a 503 Service Unavailable error if something went wrong with the database call
|
||||
private function resp_database_error(): Response {
|
||||
return new Response("Failed to get work data, please try again later", 503);
|
||||
}
|
||||
|
||||
private function resp_item_details(string $id): Response {
|
||||
$resp = $this->db->for(WorkModel::TABLE)
|
||||
->where([
|
||||
WorkModel::ID->value => $id,
|
||||
WorkModel::IS_READABLE->value => true
|
||||
])
|
||||
->limit(1)
|
||||
->select([
|
||||
WorkModel::ID->value,
|
||||
WorkModel::TITLE->value,
|
||||
WorkModel::SUMMARY->value,
|
||||
WorkModel::COVER_SRCSET->value,
|
||||
WorkModel::DATE_YEAR->value,
|
||||
WorkModel::DATE_MONTH->value,
|
||||
WorkModel::DATE_DAY->value,
|
||||
WorkModel::DATE_TIMESTAMP_MODIFIED->value,
|
||||
WorkModel::DATE_TIMESTAMP_CREATED->value
|
||||
]);
|
||||
|
||||
// Bail out if something went wrong retrieving rows from the database
|
||||
if (!parent::is_mysqli_result($resp)) {
|
||||
return $this->resp_database_error();
|
||||
}
|
||||
|
||||
return $resp->num_rows === 1
|
||||
? new Response($resp->fetch_assoc())
|
||||
: new Response("No entity with id '{$id}' was found", 404);
|
||||
}
|
||||
|
||||
public function main(): Response {
|
||||
// Bail out if request validation failed
|
||||
if (!$this->ruleset->is_valid()) {
|
||||
return $this->resp_rules_invalid();
|
||||
}
|
||||
|
||||
// Return details about a specific item by id
|
||||
if (!empty($_GET["id"])) {
|
||||
return $this->resp_item_details($_GET["id"]);
|
||||
}
|
||||
|
||||
$resp = $this->db->for(WorkModel::TABLE)
|
||||
->where([WorkModel::IS_LISTABLE->value => true])
|
||||
->order([WorkModel::DATE_TIMESTAMP_CREATED->value => "DESC"])
|
||||
->select([
|
||||
WorkModel::ID->value,
|
||||
WorkModel::TITLE->value,
|
||||
WorkModel::SUMMARY->value,
|
||||
WorkModel::COVER_SRCSET->value,
|
||||
WorkModel::DATE_YEAR->value,
|
||||
WorkModel::DATE_MONTH->value,
|
||||
WorkModel::DATE_DAY->value,
|
||||
WorkModel::DATE_TIMESTAMP_MODIFIED->value,
|
||||
WorkModel::DATE_TIMESTAMP_CREATED->value
|
||||
]);
|
||||
|
||||
// Bail out if something went wrong retrieving rows from the database
|
||||
if (!parent::is_mysqli_result($resp)) {
|
||||
return $this->resp_database_error();
|
||||
}
|
||||
|
||||
// Resolve foreign keys
|
||||
$rows = [];
|
||||
while ($row = $resp->fetch_assoc()) {
|
||||
$row["tags"] = $this->fetch_row_tags($row["id"]);
|
||||
|
||||
// Fetch actions for work entity by id from endpoint
|
||||
$row["actions"] = (new Call(Endpoints::WORK_ACTIONS->value))
|
||||
->params([WorkActionsModel::ANCHOR->value => $row[WorkModel::ID->value]])
|
||||
->get()->output();
|
||||
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
return new Response($rows);
|
||||
}
|
||||
}
|
201
api/endpoints/work/PATCH.php
Executable 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
|
@ -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);
|
||||
}
|
||||
}
|
73
api/endpoints/work/actions/DELETE.php
Executable 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();
|
||||
}
|
||||
}
|
67
api/endpoints/work/actions/GET.php
Normal file
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
use Reflect\Path;
|
||||
use Reflect\Response;
|
||||
use ReflectRules\Type;
|
||||
use ReflectRules\Rules;
|
||||
use ReflectRules\Ruleset;
|
||||
|
||||
use VLW\API\Databases\VLWdb\VLWdb;
|
||||
use VLW\API\Databases\VLWdb\Models\Work\WorkActionsModel;
|
||||
|
||||
require_once Path::root("src/databases/VLWdb.php");
|
||||
require_once Path::root("src/databases/models/WorkActions.php");
|
||||
|
||||
class GET_WorkActions extends VLWdb {
|
||||
protected Ruleset $ruleset;
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct();
|
||||
$this->ruleset = new Ruleset(strict: true);
|
||||
|
||||
$this->ruleset->GET([
|
||||
(new Rules(WorkActionsModel::ANCHOR->value))
|
||||
->required()
|
||||
->type(Type::STRING)
|
||||
->min(1)
|
||||
->max(parent::MYSQL_VARCHAR_MAX_LENGTH)
|
||||
]);
|
||||
}
|
||||
|
||||
// # Responses
|
||||
|
||||
// Return 422 Unprocessable Content error if request validation failed
|
||||
private function resp_rules_invalid(): Response {
|
||||
return new Response($this->ruleset->get_errors(), 422);
|
||||
}
|
||||
|
||||
// Return a 503 Service Unavailable error if something went wrong with the database call
|
||||
private function resp_database_error(): Response {
|
||||
return new Response("Failed to get work data, please try again later", 503);
|
||||
}
|
||||
|
||||
public function main(): Response {
|
||||
// Bail out if request validation failed
|
||||
if (!$this->ruleset->is_valid()) {
|
||||
return $this->resp_rules_invalid();
|
||||
}
|
||||
|
||||
$resp = $this->db->for(WorkActionsModel::TABLE)
|
||||
->where([WorkActionsModel::ANCHOR->value => $_GET[WorkActionsModel::ANCHOR->value]])
|
||||
->select([
|
||||
WorkActionsModel::DISPLAY_TEXT->value,
|
||||
WorkActionsModel::HREF->value,
|
||||
WorkActionsModel::CLASS_LIST->value,
|
||||
WorkActionsModel::EXTERNAL->value
|
||||
]);
|
||||
|
||||
// Bail out if something went wrong retrieving rows from the database
|
||||
if (!parent::is_mysqli_result($resp)) {
|
||||
return $this->resp_database_error();
|
||||
}
|
||||
|
||||
return $resp->num_rows > 0
|
||||
? new Response($resp->fetch_all(MYSQLI_ASSOC))
|
||||
: new Response([]);
|
||||
}
|
||||
}
|
102
api/endpoints/work/actions/POST.php
Executable 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();
|
||||
}
|
||||
}
|
60
api/endpoints/work/permalinks/GET.php
Executable 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();
|
||||
}
|
||||
}
|
83
api/endpoints/work/permalinks/POST.php
Executable 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();
|
||||
}
|
||||
}
|
80
api/endpoints/work/tags/DELETE.php
Executable 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();
|
||||
}
|
||||
}
|
93
api/endpoints/work/tags/POST.php
Executable 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();
|
||||
}
|
||||
}
|
52
api/src/databases/VLWdb.php
Executable file
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace VLW\API\Databases\VLWdb;
|
||||
|
||||
use libmysqldriver\MySQL;
|
||||
|
||||
class VLWdb {
|
||||
const UUID_LENGTH = 36;
|
||||
|
||||
const MYSQL_TEXT_MAX_LENGTH = 65538;
|
||||
const MYSQL_VARCHAR_MAX_LENGTH = 255;
|
||||
const MYSQL_INT_MAX_LENGHT = 2147483647;
|
||||
|
||||
protected MySQL $db;
|
||||
|
||||
public function __construct() {
|
||||
// Create new MariaDB connection
|
||||
$this->db = new MySQL(
|
||||
$_ENV["vlwdb"]["mariadb_host"],
|
||||
$_ENV["vlwdb"]["mariadb_user"],
|
||||
$_ENV["vlwdb"]["mariadb_pass"],
|
||||
$_ENV["vlwdb"]["mariadb_db"],
|
||||
);
|
||||
}
|
||||
|
||||
// Generate and return UUID4 string
|
||||
public static function gen_uuid4(): string {
|
||||
return sprintf("%04x%04x-%04x-%04x-%04x-%04x%04x%04x",
|
||||
// 32 bits for "time_low"
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||
|
||||
// 16 bits for "time_mid"
|
||||
mt_rand(0, 0xffff),
|
||||
|
||||
// 16 bits for "time_hi_and_version",
|
||||
// four most significant bits holds version number 4
|
||||
mt_rand(0, 0x0fff) | 0x4000,
|
||||
|
||||
// 16 bits, 8 bits for "clk_seq_hi_res",
|
||||
// 8 bits for "clk_seq_low",
|
||||
// two most significant bits holds zero and one for variant DCE1.1
|
||||
mt_rand(0, 0x3fff) | 0x8000,
|
||||
|
||||
// 48 bits for "node"
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||
);
|
||||
}
|
||||
|
||||
public static function is_mysqli_result(\mysqli_result|bool $resp): bool {
|
||||
return $resp instanceof \mysqli_result;
|
||||
}
|
||||
}
|
10
api/src/databases/models/Coffee.php
Executable 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";
|
||||
}
|
24
api/src/databases/models/Media.php
Executable 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";
|
||||
}
|
10
api/src/databases/models/MediaSrcset.php
Executable 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";
|
||||
}
|
15
api/src/databases/models/Messages.php
Executable 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";
|
||||
}
|
19
api/src/databases/models/Work.php
Executable 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";
|
||||
}
|
14
api/src/databases/models/WorkActions.php
Executable 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";
|
||||
}
|
10
api/src/databases/models/WorkMedia.php
Executable 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";
|
||||
}
|
6
src/Database/Tables/Work/Permalinks.php → api/src/databases/models/WorkPermalinks.php
Normal file → Executable file
|
@ -1,9 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace VLW\Database\Tables\Work;
|
||||
namespace VLW\API\Databases\VLWdb\Models\WorkPermalinks;
|
||||
|
||||
enum PermalinksTable: string {
|
||||
const NAME = "work_permalinks";
|
||||
enum WorkPermalinksModel: string {
|
||||
const TABLE = "work_permalinks";
|
||||
|
||||
case SLUG = "slug";
|
||||
case ANCHOR = "anchor";
|
20
api/src/databases/models/WorkTags.php
Executable 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";
|
||||
}
|
17
api/src/packages/Endpoints/composer.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "local/api.endpoints",
|
||||
"description": "Endpoint pathmappings for VLW API",
|
||||
"type": "library",
|
||||
"version": "1.0.0-dev",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Victor Westerlund",
|
||||
"email": "victor@vlw.se"
|
||||
}
|
||||
],
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"VLW\\API\\": "src/"
|
||||
}
|
||||
}
|
||||
}
|
11
api/src/packages/Endpoints/src/Endpoints.php
Normal file
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace VLW\API;
|
||||
|
||||
enum Endpoints: string {
|
||||
case WORK = "/work";
|
||||
case SEARCH = "/search";
|
||||
case MESSAGES = "/messages";
|
||||
case WORK_TAGS = "/work/tags";
|
||||
case WORK_ACTIONS = "/work/actions";
|
||||
}
|
145
public/assets/css/shell.css → assets/css/document.css
Normal file → Executable file
|
@ -11,8 +11,7 @@
|
|||
/* # Cornerstones */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
fill: inherit;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: "Roboto Mono", sans-serif;
|
||||
color: inherit;
|
||||
|
@ -54,10 +53,14 @@ body::before {
|
|||
}
|
||||
|
||||
/* "enable" the corner glow effect on initial load when a page has been fully loaded */
|
||||
body[vv-top-page]::before {
|
||||
body[vv-page]::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
body.search-dialog-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
a {
|
||||
display: contents;
|
||||
color: inherit;
|
||||
|
@ -89,32 +92,6 @@ h3 {
|
|||
font-size: 25px;
|
||||
}
|
||||
|
||||
/* ## Page transition */
|
||||
|
||||
[vv-loading] * {
|
||||
transition: 200ms opacity;
|
||||
}
|
||||
|
||||
[vv-loading="true"] * {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[vv-loading="true"]::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 45px;
|
||||
height: 49px;
|
||||
background-size: contain;
|
||||
image-rendering: pixelated;
|
||||
transform: translate(-50%, -50%);
|
||||
background-image: url("/assets/media/spinner.gif");
|
||||
-webkit-filter: hue-rotate(var(--hue-accent));
|
||||
filter: hue-rotate(var(--hue-accent));
|
||||
}
|
||||
|
||||
/* ## Buttons */
|
||||
|
||||
button {
|
||||
|
@ -128,44 +105,35 @@ button {
|
|||
/* ### Inline */
|
||||
|
||||
button.inline {
|
||||
gap: 10px;
|
||||
display: flex;
|
||||
border-radius: 7px;
|
||||
align-items: center;
|
||||
fill: var(--color-accent);
|
||||
padding: calc(var(--padding) / 1.5);
|
||||
background: linear-gradient(139deg, rgba(0, 0, 0, 0) 0%, rgba(var(--primer-color-accent), .1) 100%);
|
||||
}
|
||||
|
||||
button.inline:not(.solid) {
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(var(--primer-color-accent), .1),
|
||||
10px 7px 40px 3px rgba(var(--primer-color-accent), .06)
|
||||
;
|
||||
}
|
||||
|
||||
button.inline svg {
|
||||
flex: none;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
button.inline svg:last-child {
|
||||
width: 1.5em;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
button.inline svg.chevron:last-child {
|
||||
transform: rotate(-90deg);
|
||||
padding: calc(var(--padding) / 2) var(--padding);
|
||||
color: white;
|
||||
border: solid 2px white;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
button.inline.solid {
|
||||
fill: black;
|
||||
color: black;
|
||||
border: solid 2px rgba(var(--primer-color-accent), 1);
|
||||
border-color: var(--color-accent);
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
a > button::after {
|
||||
content: " ➜";
|
||||
}
|
||||
|
||||
/* ### Text links */
|
||||
|
||||
a[target="_blank"] > button::after,
|
||||
:is(h1, h2, h3, p, li) > a[target="_blank"]::after {
|
||||
content: " ↑";
|
||||
color: var(--color-accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
a > button.solid:not(:hover)::after {
|
||||
color: black;
|
||||
}
|
||||
|
||||
/* ## Header */
|
||||
|
||||
header {
|
||||
|
@ -200,10 +168,8 @@ header > * {
|
|||
|
||||
/* enable 3d flip animation */
|
||||
@media not (prefers-reduced-motion: reduce) {
|
||||
header > * {
|
||||
--transform-duration: 600ms;
|
||||
|
||||
transition: var(--transform-duration) transform, 300ms background-color;
|
||||
header > * {
|
||||
transition: 600ms transform, 300ms background-color;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -213,10 +179,6 @@ header nav {
|
|||
padding: var(--padding);
|
||||
}
|
||||
|
||||
header .logo {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
header .logo path.stroke {
|
||||
fill: var(--color-accent);
|
||||
}
|
||||
|
@ -290,29 +252,37 @@ header searchbox input {
|
|||
|
||||
header.searchboxActive > * {
|
||||
transform: rotateX(-180deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
header.searchboxActive searchbox {
|
||||
transform: rotateX(0);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* ## vv-shell */
|
||||
/* ## Main */
|
||||
|
||||
vv-shell {
|
||||
main {
|
||||
position: relative;
|
||||
padding: calc(var(--padding) * 1.5);
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
main > * {
|
||||
transition: 100ms opacity;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
main.loading > * {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ## Search results */
|
||||
|
||||
search-results {
|
||||
transition: 500ms opacity, 300ms transform;
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--padding);
|
||||
top: var(--running-size);
|
||||
right: 0;
|
||||
width: 100%;
|
||||
|
@ -324,7 +294,12 @@ search-results {
|
|||
transform: scale(.99);
|
||||
transform-origin: 100% 0;
|
||||
overflow-y: scroll;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
search-results:not([vv-page]) {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
header.searchboxActive ~ search-results {
|
||||
|
@ -333,10 +308,6 @@ header.searchboxActive ~ search-results {
|
|||
transform: scale(1);
|
||||
}
|
||||
|
||||
search-results section.search {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ### "Start typing" prompt */
|
||||
|
||||
search-results .info {
|
||||
|
@ -344,11 +315,11 @@ search-results .info {
|
|||
align-items: center;
|
||||
flex-direction: column;
|
||||
margin: auto;
|
||||
gap: var(--padding);
|
||||
gap: 3svh;
|
||||
}
|
||||
|
||||
search-results .info :is(svg, img) {
|
||||
width: 60px;
|
||||
width: 128px;
|
||||
fill: var(--color-accent);
|
||||
}
|
||||
|
||||
|
@ -364,8 +335,7 @@ search-results .info :is(svg, img) {
|
|||
/* # Components */
|
||||
|
||||
button.inline {
|
||||
transition-duration: 300ms;
|
||||
transition-property: background-color, border-color, box-shadow, color, fill;
|
||||
transition: 200ms background-color, 200ms border-color, 200ms color;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
|
@ -373,19 +343,8 @@ search-results .info :is(svg, img) {
|
|||
background-color: rgba(255, 255, 255, .1);
|
||||
}
|
||||
|
||||
button.inline:hover {
|
||||
fill: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
button.inline:not(.solid):hover {
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(var(--primer-color-accent), 1),
|
||||
10px 7px 30px 3px rgba(var(--primer-color-accent), .07)
|
||||
;
|
||||
}
|
||||
|
||||
button.solid:hover {
|
||||
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);
|
16
assets/css/fonts.css
Executable file
|
@ -0,0 +1,16 @@
|
|||
@font-face {
|
||||
font-family: "Roboto Mono";
|
||||
ascent-override: 100%;
|
||||
font-weight: 400;
|
||||
size-adjust: 105%;
|
||||
font-stretch: 97.5% 112.5%;
|
||||
src: local("Roboto Mono Regular"), url("/assets/fonts/roboto-mono-regular.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Roboto Mono";
|
||||
ascent-override: 100%;
|
||||
size-adjust: 95%;
|
||||
font-weight: 800;
|
||||
src: local("Roboto Mono Bold"), url("/assets/fonts/roboto-mono-bold.woff2") format("woff2");
|
||||
}
|
90
assets/css/pages/about.css
Executable file
|
@ -0,0 +1,90 @@
|
|||
/* # Overrides */
|
||||
|
||||
:root {
|
||||
--primer-color-accent: 148, 255, 21;
|
||||
--color-accent: rgb(var(--primer-color-accent));
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--padding);
|
||||
}
|
||||
|
||||
/* # Sections */
|
||||
|
||||
/* ## Divider */
|
||||
|
||||
main > hr {
|
||||
border-color: rgba(255, 255, 255, .1);
|
||||
}
|
||||
|
||||
/* ## About */
|
||||
|
||||
section.about {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--padding) / 2);
|
||||
}
|
||||
|
||||
section.about p:first-of-type:first-letter {
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
margin-right: .1rem;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
section.about span.interests {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
color: var(--color-accent);
|
||||
animation: interests-hue 5s infinite linear;
|
||||
}
|
||||
|
||||
/* ## Version */
|
||||
|
||||
section.version {
|
||||
color: rgba(255, 255, 255, .2);
|
||||
}
|
||||
|
||||
/* # Interests */
|
||||
|
||||
div.interests {
|
||||
--text-shadow-blur: 30px;
|
||||
|
||||
transition: 300ms opacity;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-weight: bold;
|
||||
pointer-events: none;
|
||||
font-size: clamp(16px, 15vw, 50px);
|
||||
color: var(--color-accent);
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
div.interests.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
div.interests p {
|
||||
transition: 500ms transform cubic-bezier(.34,0,0,.99);
|
||||
position: absolute;
|
||||
text-shadow:
|
||||
0 0 var(--text-shadow-blur) black,
|
||||
0 0 var(--text-shadow-blur) black,
|
||||
0 0 var(--text-shadow-blur) black,
|
||||
0 0 var(--text-shadow-blur) black,
|
||||
0 0 var(--text-shadow-blur) black;
|
||||
}
|
||||
|
||||
@keyframes interests-hue {
|
||||
to {
|
||||
-webkit-filter: hue-rotate(360deg);
|
||||
filter: hue-rotate(360deg);
|
||||
}
|
||||
}
|
33
public/assets/css/pages/contact.css → assets/css/pages/contact.css
Normal file → Executable file
|
@ -3,32 +3,21 @@
|
|||
:root {
|
||||
--primer-color-accent: 255, 195, 255;
|
||||
--color-accent: rgb(var(--primer-color-accent));
|
||||
--hue-accent: 200deg;
|
||||
}
|
||||
|
||||
vv-shell {
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--padding);
|
||||
}
|
||||
|
||||
.fingerprint {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* # Sections */
|
||||
|
||||
vv-shell > svg {
|
||||
main > svg {
|
||||
margin: var(--padding) 0;
|
||||
}
|
||||
|
||||
/* ## Modifiers */
|
||||
|
||||
section.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ## Social */
|
||||
|
||||
section.social {
|
||||
|
@ -68,22 +57,18 @@ section.social social:hover {
|
|||
fill: var(--color-accent);
|
||||
}
|
||||
|
||||
section.social social p.hovering {
|
||||
section.social social.hovering p {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
/* ## PGP key */
|
||||
/* ## OpenPGP key */
|
||||
|
||||
section.pgp {
|
||||
max-width: 800px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
border-radius: 12px;
|
||||
flex-direction: column;
|
||||
gap: var(--padding);
|
||||
text-align: center;
|
||||
background-color: rgba(var(--primer-color-accent), .15);
|
||||
padding: var(--padding);
|
||||
padding: calc(var(--padding) * 1.5);
|
||||
transform: rotate(-1.5deg);
|
||||
}
|
||||
|
||||
|
@ -96,13 +81,13 @@ section.pgp > svg {
|
|||
}
|
||||
|
||||
section.pgp > p {
|
||||
padding: 0 var(--padding);
|
||||
margin-bottom: var(--padding);
|
||||
padding: var(--padding);
|
||||
}
|
||||
|
||||
section.pgp .buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: var(--padding);
|
||||
gap: var(--padding);
|
||||
}
|
||||
|
||||
|
@ -194,10 +179,6 @@ section.form-message.sent + section.form {
|
|||
/* # Size queries */
|
||||
|
||||
@media (min-width: 460px) {
|
||||
section.pgp {
|
||||
padding: calc(var(--padding) * 1.5);
|
||||
}
|
||||
|
||||
section.pgp .buttons {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
2
public/assets/css/pages/error.css → assets/css/pages/error.css
Normal file → Executable file
|
@ -6,7 +6,7 @@ header {
|
|||
backdrop-filter: unset;
|
||||
}
|
||||
|
||||
vv-shell {
|
||||
main {
|
||||
max-width: unset;
|
||||
display: grid;
|
||||
justify-items: center;
|
20
public/assets/css/pages/index.css → assets/css/pages/index.css
Normal file → Executable file
|
@ -1,21 +1,21 @@
|
|||
/* # Overrides */
|
||||
|
||||
body[vv-top-page="/"]::before {
|
||||
body[vv-page="/"]::before {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* # vv-shell styles */
|
||||
/* # Main styles */
|
||||
|
||||
/* ## Picture */
|
||||
|
||||
vv-shell {
|
||||
main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
vv-shell img {
|
||||
main img {
|
||||
margin: auto;
|
||||
width: 25vh;
|
||||
pointer-events: none;
|
||||
|
@ -152,33 +152,25 @@ splash::after {
|
|||
|
||||
.menu menu li:hover {
|
||||
opacity: 1;
|
||||
font-weight: 100;
|
||||
text-shadow: 0 0 10px rgba(var(--primer-color-accent), .4);
|
||||
}
|
||||
|
||||
button.email:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* enable font-weight hover animation */
|
||||
@media not (prefers-reduced-motion: reduce) {
|
||||
.menu menu li {
|
||||
transition: 200ms opacity, 200ms color, 300ms font-weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* # Size quries */
|
||||
|
||||
@media (min-width: 900px) {
|
||||
vv-shell {
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
vv-shell img {
|
||||
main img {
|
||||
width: 35vh;
|
||||
}
|
||||
}
|
85
assets/css/pages/search.css
Executable file
|
@ -0,0 +1,85 @@
|
|||
/* # Overrides */
|
||||
|
||||
[vv-page="/search"]:not(body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--padding);
|
||||
}
|
||||
|
||||
/* # Sections */
|
||||
|
||||
/* ## Search */
|
||||
|
||||
section.search {
|
||||
width: 100%;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--padding);
|
||||
background-color: rgba(255, 255, 255, .05);
|
||||
padding: calc(var(--padding) * 1.5);
|
||||
margin-bottom: calc(var(--padding) * 2);
|
||||
}
|
||||
|
||||
main[vv-page="/search"] > section.search {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
section.search form {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
section.search search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
section.search input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
color: black;
|
||||
outline: none;
|
||||
padding: var(--padding);
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
section.search button[type="submit"] {
|
||||
width: 100%;
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
section.search > svg {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* # Search results */
|
||||
|
||||
section.results .result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--padding) / 2);
|
||||
}
|
||||
|
||||
/* ## Titles */
|
||||
|
||||
section.title a h2 {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
section.title a h2::before {
|
||||
content: "// ";
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ## Work */
|
||||
|
||||
section.results.work {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: calc(var(--padding) / 2);
|
||||
}
|
||||
|
||||
section.results.work .result {
|
||||
padding: var(--padding);
|
||||
background-color: rgba(255, 255, 255, .03);
|
||||
border-radius: 6px;
|
||||
}
|
53
public/assets/css/pages/work/timeline.css → assets/css/pages/work.css
Normal file → Executable file
|
@ -3,10 +3,9 @@
|
|||
:root {
|
||||
--primer-color-accent: 3, 255, 219;
|
||||
--color-accent: rgb(var(--primer-color-accent));
|
||||
--hue-accent: 90deg;
|
||||
}
|
||||
|
||||
vv-shell {
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--padding);
|
||||
|
@ -28,8 +27,7 @@ section.git {
|
|||
border-radius: 6px;
|
||||
}
|
||||
|
||||
section.git > svg {
|
||||
fill: white;
|
||||
section.git svg {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
|
@ -121,10 +119,13 @@ section.timeline .items .item img {
|
|||
}
|
||||
|
||||
section.timeline .items .item .actions {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-top: 7px;
|
||||
gap: var(--padding);
|
||||
}
|
||||
|
||||
/* ## Note */
|
||||
|
||||
section.note {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* # Size queries */
|
||||
|
@ -135,6 +136,23 @@ section.timeline .items .item .actions {
|
|||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
section.git {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 1fr 400px;
|
||||
align-items: center;
|
||||
gap: calc(var(--padding) * 1.5);
|
||||
}
|
||||
|
||||
section.git svg {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
section.git .buttons {
|
||||
justify-content: end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
section.timeline {
|
||||
padding: unset;
|
||||
|
@ -169,28 +187,7 @@ section.timeline .items .item .actions {
|
|||
border-top-color: rgba(var(--primer-color-accent), .2);
|
||||
}
|
||||
|
||||
section.timeline .items .item .actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
section.timeline .year:first-of-type .month:first-of-type .day:first-of-type .items .item:first-of-type {
|
||||
margin-top: var(--padding);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
section.git {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 1fr 400px;
|
||||
align-items: center;
|
||||
gap: calc(var(--padding) * 1.5);
|
||||
}
|
||||
|
||||
section.git svg {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
section.git .buttons {
|
||||
justify-content: end;
|
||||
}
|
||||
}
|
BIN
assets/fonts/roboto-mono-bold.woff2
Executable file
BIN
assets/fonts/roboto-mono-regular.woff2
Executable file
40
assets/js/document.js
Executable file
|
@ -0,0 +1,40 @@
|
|||
new vv.Interactions("document", {
|
||||
navigateHome: () => new vv.Navigation("/").navigate(),
|
||||
closeSearchbox: () => document.querySelector("header").classList.remove("searchboxActive"),
|
||||
openSearchbox: () => {
|
||||
document.querySelector("header").classList.add("searchboxActive");
|
||||
// Select searchbox inner input element
|
||||
document.querySelector("searchbox input").focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Crossfade pages on navigation
|
||||
{
|
||||
const mainElement = document.querySelector(vv._env.MAIN);
|
||||
|
||||
mainElement.addEventListener(vv.Navigation.events.LOADING, () => {
|
||||
mainElement.classList.add("loading");
|
||||
});
|
||||
|
||||
mainElement.addEventListener(vv.Navigation.events.LOADED, () => {
|
||||
// Close searchbox on main page navigation
|
||||
document.querySelector("header").classList.remove("searchboxActive");
|
||||
|
||||
// Wait 200ms for the page fade-in animation to finish
|
||||
setTimeout(() => mainElement.classList.remove("loading"), 200);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle search logic
|
||||
{
|
||||
const searchResultsElement = document.querySelector("search-results");
|
||||
|
||||
document.querySelector("header input[type='search']").addEventListener("input", (event) => {
|
||||
// Debounce user input
|
||||
clearTimeout(event.target._throttle);
|
||||
event.target._throttle = setTimeout(() => {
|
||||
// Navigate search-results element on user input
|
||||
new vv.Navigation(`/search?q=${event.target.value}`).navigate(searchResultsElement);
|
||||
}, 100);
|
||||
});
|
||||
}
|
0
public/assets/js/modules/glitch/Generator.mjs → assets/js/modules/glitch/Generator.mjs
Normal file → Executable file
0
public/assets/js/modules/glitch/Glitch.mjs → assets/js/modules/glitch/Glitch.mjs
Normal file → Executable file
0
public/assets/js/modules/glitch/GlitchWorker.js → assets/js/modules/glitch/GlitchWorker.js
Normal file → Executable file
15
public/assets/js/pages/about.js → assets/js/pages/about.js
Normal file → Executable file
|
@ -1,4 +1,4 @@
|
|||
import { Hoverpop } from "/assets/js/modules/Hoverpop.mjs";
|
||||
new vv.Interactions("about");
|
||||
|
||||
const randomIntFromInterval = (min, max) => {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
|
@ -58,11 +58,12 @@ const implodeInterests = () => {
|
|||
// Bind mouse or touch events depending on pointer type of device
|
||||
const canHover = window.matchMedia("(pointer: fine)").matches;
|
||||
|
||||
// Explode interests when mouse hovers or touch hold starts
|
||||
interestsElement.addEventListener(canHover ? "mouseenter" : "touchstart", (event) => explodeInterests(event.x, event.y));
|
||||
// Implode interests when mouse leaves or touch hold ends
|
||||
interestsElement.addEventListener(canHover ? "mouseenter" : "touchstart", () => {
|
||||
// Get absolute position of the trigger element
|
||||
const size = interestsElement.getBoundingClientRect();
|
||||
|
||||
explodeInterests(size.x, size.y);
|
||||
});
|
||||
|
||||
interestsElement.addEventListener(canHover ? "mouseleave" : "touchend", () => implodeInterests());
|
||||
}
|
||||
|
||||
// Languages stacking bar chart hoverpop
|
||||
new Hoverpop(document.querySelectorAll("stacked-bar-chart chart-segment"));
|
29
public/assets/js/pages/contact.js → assets/js/pages/contact.js
Normal file → Executable file
|
@ -1,5 +1,3 @@
|
|||
import { Hoverpop } from "/assets/js/modules/Hoverpop.mjs";
|
||||
|
||||
class ContactForm {
|
||||
static STORAGE_KEY = "contact_form_message";
|
||||
|
||||
|
@ -12,6 +10,8 @@ class ContactForm {
|
|||
[...document.querySelectorAll("form :is(input, textarea)")].forEach(element => {
|
||||
element.addEventListener("keyup", () => this.saveMessage());
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
// Get saved message as JSON from SessionStorage
|
||||
|
@ -36,7 +36,6 @@ class ContactForm {
|
|||
return ContactForm.removeSavedMessage();
|
||||
}
|
||||
|
||||
// Set value of each input field in DOM by name attribute
|
||||
for (const [name, value] of Object.entries(message)) {
|
||||
this.form.querySelector(`[name="${name}"]`).value = value;
|
||||
}
|
||||
|
@ -62,7 +61,27 @@ class ContactForm {
|
|||
form ? (new ContactForm(form)) : ContactForm.removeSavedMessage();
|
||||
}
|
||||
|
||||
// Social links hoverpop
|
||||
// Social links hover
|
||||
{
|
||||
new Hoverpop(document.querySelectorAll("social"));
|
||||
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"));
|
||||
});
|
||||
});
|
||||
}
|
0
public/assets/js/pages/error.js → assets/js/pages/error.js
Normal file → Executable file
108
assets/js/pages/index.js
Executable file
|
@ -0,0 +1,108 @@
|
|||
const EMAIL_CPY_ANIM_DUR_MSECONDS = 1000;
|
||||
|
||||
// Run email copied splash animation
|
||||
const emailCopiedAnimation = () => {
|
||||
const CONFETTI_COUNT = 40;
|
||||
const CONFETTI_SCALE_PIXELS = 300;
|
||||
|
||||
const randomIntFromInterval = (min, max) => {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min)
|
||||
}
|
||||
|
||||
// Create new splash element
|
||||
const splashElement = document.createElement("splash");
|
||||
splashElement.innerText = "copied!";
|
||||
|
||||
// Set inline display to none to hide this element on pages where the splash element has no override styles defined.
|
||||
splashElement.style.display = "none";
|
||||
|
||||
// Array of box-shadow strings as "confetti"
|
||||
const confetti = [];
|
||||
|
||||
// Generate random confetti
|
||||
for (let i = 0; i < CONFETTI_COUNT; i++) {
|
||||
// Random confetti position
|
||||
const x = randomIntFromInterval(CONFETTI_SCALE_PIXELS * -1, CONFETTI_SCALE_PIXELS);
|
||||
const y = randomIntFromInterval(CONFETTI_SCALE_PIXELS * -1, CONFETTI_SCALE_PIXELS);
|
||||
|
||||
// Random confetti RGB color
|
||||
const rgb = [
|
||||
randomIntFromInterval(0, 255),
|
||||
randomIntFromInterval(0, 255),
|
||||
randomIntFromInterval(0, 255)
|
||||
];
|
||||
|
||||
// Interpolate random values and append to outer confetti array
|
||||
confetti.push(`${x}px ${y}px 0 rgb(${rgb.join(",")})`);
|
||||
}
|
||||
|
||||
// Set CSS variable on splash element that in turn will be used by pseudo-element
|
||||
splashElement.style.setProperty("--confetti", confetti.join(","));
|
||||
|
||||
// Start animation by appending the created element to the document body
|
||||
document.body.appendChild(splashElement);
|
||||
|
||||
// Run hide animation
|
||||
setTimeout(() => {
|
||||
splashElement.classList.add("hide");
|
||||
|
||||
// Selfdestruct element when hide animation finishes
|
||||
setTimeout(() => splashElement.remove(), 400);
|
||||
}, EMAIL_CPY_ANIM_DUR_MSECONDS + 100);
|
||||
}
|
||||
|
||||
new vv.Interactions("index", {
|
||||
// Copy email address to clipboard
|
||||
copyEmail: async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText("victor@vlw.se");
|
||||
|
||||
// Run "email copied" animation!
|
||||
emailCopiedAnimation();
|
||||
|
||||
// NOTE: I don't know, spamming the button is kinda fun
|
||||
// Prevent interactions with the copy email elements while the animation is running
|
||||
/*[...document.querySelectorAll("[vv-call='copyEmail']")].forEach(element => {
|
||||
//element.classList.add("lock");
|
||||
|
||||
setTimeout(() => element.classList.remove("lock"), EMAIL_CPY_ANIM_DUR_MSECONDS);
|
||||
});*/
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
},
|
||||
// Open the fullscreen menu
|
||||
openMenu: () => document.querySelector("menu").classList.add("active"),
|
||||
// Close the fullscreen menu
|
||||
closeMenu: () => document.querySelector("menu").classList.remove("active")
|
||||
});
|
||||
|
||||
// Change site accent color on hover of menu items
|
||||
if (window.matchMedia("(hover: hover)")) {
|
||||
// Update root CSS variables
|
||||
const updateColor = (rgb = null, hue = 0) => {
|
||||
if (!rgb) {
|
||||
document.documentElement.style.removeProperty("--hue-accent");
|
||||
document.documentElement.style.removeProperty("--primer-color-accent");
|
||||
document.documentElement.style.removeProperty("--color-accent");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.style.setProperty("--hue-accent", `${hue}deg`);
|
||||
|
||||
document.documentElement.style.setProperty("--primer-color-accent", `${rgb}`);
|
||||
// Compiled color variable must to be updated to receive the new RGB values
|
||||
document.documentElement.style.setProperty("--color-accent", "rgb(var(--primer-color-accent)");
|
||||
};
|
||||
|
||||
[...document.querySelectorAll("menu li")].forEach(element => {
|
||||
// Change site accent color to RGB and HUE rotation defined in element dataset
|
||||
element.addEventListener("mouseenter", (event) => updateColor(event.target.dataset.rgb, event.target.dataset.hue));
|
||||
// Reset initial accent color and hues
|
||||
element.addEventListener("mouseleave", () => updateColor());
|
||||
});
|
||||
|
||||
// Reset color on navigation
|
||||
document.querySelector(vv._env.MAIN).addEventListener(vv.Navigation.events.LOADED, () => updateColor(), { once: true });
|
||||
}
|
1
assets/js/pages/search.js
Executable file
|
@ -0,0 +1 @@
|
|||
new vv.Interactions("search");
|
1
assets/js/pages/work.js
Executable file
|
@ -0,0 +1 @@
|
|||
new vv.Interactions("work");
|
0
public/assets/media/gazing.jpg → assets/media/gazing.jpg
Normal file → Executable file
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
0
public/assets/media/glitch_b64/1.txt → assets/media/glitch_b64/1.txt
Normal file → Executable file
0
public/assets/media/glitch_b64/2.txt → assets/media/glitch_b64/2.txt
Normal file → Executable file
0
public/assets/media/glitch_b64/3.txt → assets/media/glitch_b64/3.txt
Normal file → Executable file
0
public/assets/media/glitch_b64/4.txt → assets/media/glitch_b64/4.txt
Normal file → Executable file
0
public/assets/media/icons/close.svg → assets/media/icons/close.svg
Normal file → Executable file
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
0
public/assets/media/icons/email.svg → assets/media/icons/email.svg
Normal file → Executable file
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
1
assets/media/icons/github.svg
Executable 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 |
0
public/assets/media/icons/libera.svg → assets/media/icons/libera.svg
Normal file → Executable file
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
1
assets/media/icons/mastodon.svg
Executable file
After Width: | Height: | Size: 6.8 KiB |
0
public/assets/media/icons/pin.svg → assets/media/icons/pin.svg
Normal file → Executable file
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.4 KiB |
0
public/assets/media/icons/search.svg → assets/media/icons/search.svg
Normal file → Executable file
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
0
public/assets/media/line.svg → assets/media/line.svg
Normal file → Executable file
Before Width: | Height: | Size: 238 B After Width: | Height: | Size: 238 B |
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 238 KiB |
1
assets/media/vw.svg
Executable 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 |
|
@ -1,9 +1,17 @@
|
|||
{
|
||||
"require": {
|
||||
"reflect/client": "dev-master",
|
||||
"reflect/plugin-rules": "dev-master",
|
||||
"vlw/mysql": "dev-master",
|
||||
"vlw/xenum": "dev-master"
|
||||
"local/api.client": "1.0.0-dev",
|
||||
"local/api.endpoints": "1.0.0-dev"
|
||||
},
|
||||
"minimum-stability": "dev"
|
||||
"minimum-stability": "dev",
|
||||
"repositories": [
|
||||
{
|
||||
"type": "path",
|
||||
"url": "src/packages/API"
|
||||
},
|
||||
{
|
||||
"type": "path",
|
||||
"url": "api/src/packages/Endpoints"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
162
composer.lock
generated
|
@ -4,17 +4,75 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "cb70f9f3f538a72aa8bcf906fdc906bf",
|
||||
"content-hash": "73a61bf0308871f9dc9ad050aedfe13e",
|
||||
"packages": [
|
||||
{
|
||||
"name": "local/api.client",
|
||||
"version": "1.0.0-dev",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "src/packages/API",
|
||||
"reference": "020275feb0e0017fa91ae0b33213bc54f35cac75"
|
||||
},
|
||||
"require": {
|
||||
"reflect/client": "^3.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"VLW\\API\\": "src/"
|
||||
}
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
"name": "Victor Westerlund",
|
||||
"email": "victor@vlw.se"
|
||||
}
|
||||
],
|
||||
"description": "Wrapper for vlw.se API",
|
||||
"transport-options": {
|
||||
"relative": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "local/api.endpoints",
|
||||
"version": "1.0.0-dev",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "api/src/packages/Endpoints",
|
||||
"reference": "89b7b9a4cc504abddb4aeec8e05a95c9d9087575"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"VLW\\API\\": "src/"
|
||||
}
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
"name": "Victor Westerlund",
|
||||
"email": "victor@vlw.se"
|
||||
}
|
||||
],
|
||||
"description": "Endpoint pathmappings for VLW API",
|
||||
"transport-options": {
|
||||
"relative": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reflect/client",
|
||||
"version": "dev-master",
|
||||
"version": "3.0.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://codeberg.org/reflect/client-php",
|
||||
"url": "https://github.com/VictorWesterlund/reflect-client-php.git",
|
||||
"reference": "89a8c041044c8c60cefafc4716d5d61b96c43e06"
|
||||
},
|
||||
"default-branch": true,
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/VictorWesterlund/reflect-client-php/zipball/89a8c041044c8c60cefafc4716d5d61b96c43e06",
|
||||
"reference": "89a8c041044c8c60cefafc4716d5d61b96c43e06",
|
||||
"shasum": ""
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
@ -32,105 +90,23 @@
|
|||
}
|
||||
],
|
||||
"description": "Extendable PHP interface for communicating with Reflect API over HTTP or UNIX sockets",
|
||||
"support": {
|
||||
"issues": "https://github.com/VictorWesterlund/reflect-client-php/issues",
|
||||
"source": "https://github.com/VictorWesterlund/reflect-client-php/tree/3.0.6"
|
||||
},
|
||||
"time": "2024-04-06T14:55:04+00:00"
|
||||
},
|
||||
{
|
||||
"name": "reflect/plugin-rules",
|
||||
"version": "dev-master",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://codeberg.org/reflect/rules-plugin",
|
||||
"reference": "aa7d969350f50d00d7dce01b948276946fcc0e81"
|
||||
},
|
||||
"default-branch": true,
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"ReflectRules\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"GPL-3.0-only"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Victor Westerlund",
|
||||
"email": "victor.vesterlund@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Add request search paramter and request body constraints to an API built with Reflect",
|
||||
"time": "2024-11-28T17:05:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "vlw/mysql",
|
||||
"version": "dev-master",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://codeberg.org/vlw/php-mysql",
|
||||
"reference": "c64eb96049907da60dc9f237d26aef0e531b0015"
|
||||
},
|
||||
"default-branch": true,
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"vlw\\MySQL\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"GPL-3.0-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Victor Westerlund",
|
||||
"email": "victor@vlw.se"
|
||||
}
|
||||
],
|
||||
"description": "Abstraction library for common MySQL/MariaDB DML operations with php-mysqli",
|
||||
"time": "2025-01-30T09:33:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "vlw/xenum",
|
||||
"version": "dev-master",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://codeberg.org/vlw/php-xenum",
|
||||
"reference": "1c997a5574656b88a62f5ee160ee5a6439932a2f"
|
||||
},
|
||||
"default-branch": true,
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"vlw\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"GPL-3.0-only"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Victor Westerlund",
|
||||
"email": "victor@vlw.se"
|
||||
}
|
||||
],
|
||||
"description": "PHP eXtended Enums. The missing quality-of-life features from PHP 8+ Enums",
|
||||
"time": "2024-12-02T10:36:32+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "dev",
|
||||
"stability-flags": {
|
||||
"reflect/client": 20,
|
||||
"reflect/plugin-rules": 20,
|
||||
"vlw/mysql": 20,
|
||||
"vlw/xenum": 20
|
||||
"local/api.client": 20,
|
||||
"local/api.endpoints": 20
|
||||
},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": [],
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.3.0"
|
||||
"plugin-api-version": "2.0.0"
|
||||
}
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
<?php
|
||||
|
||||
use ReflectRules\Ruleset;
|
||||
use Reflect\{Response, Path, Call};
|
||||
|
||||
use VLW\API\Endpoints;
|
||||
use VLW\Database\Database;
|
||||
use VLW\Database\Tables\About\LanguagesTable;
|
||||
|
||||
require_once Path::root("src/API/Endpoints.php");
|
||||
require_once Path::root("src/Database/Database.php");
|
||||
require_once Path::root("src/Database/Tables/About/Languages.php");
|
||||
|
||||
class DELETE_AboutLanguages extends Database {
|
||||
protected readonly Ruleset $ruleset;
|
||||
|
||||
public function __construct() {
|
||||
$this->ruleset = new Ruleset(strict: true);
|
||||
$this->ruleset->validate_or_exit();
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
private function languages(): array {
|
||||
$resp = (new Call(Endpoints::ABOUT_LANGUAGES->value))->get();
|
||||
|
||||
return array_column($resp->output(), LanguagesTable::ID->value);
|
||||
}
|
||||
|
||||
// Delete languages cache file if it exists
|
||||
public function main(): Response {
|
||||
$this->db->for(LanguagesTable::NAME);
|
||||
|
||||
foreach ($this->languages() as $language){
|
||||
$this->db->delete([LanguagesTable::ID->value => $language]);
|
||||
}
|
||||
|
||||
return new Response();
|
||||
}
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
<?php
|
||||
|
||||
use vlw\MySQL\Order;
|
||||
use Reflect\{Response, Path, Call};
|
||||
use ReflectRules\{Ruleset, Rules, Type};
|
||||
|
||||
use VLW\API\Endpoints;
|
||||
use VLW\Database\Database;
|
||||
use VLW\Database\Tables\About\LanguagesTable;
|
||||
|
||||
require_once Path::root("src/API/Endpoints.php");
|
||||
require_once Path::root("src/Database/Database.php");
|
||||
require_once Path::root("src/Database/Tables/About/Languages.php");
|
||||
|
||||
class GET_AboutLanguages extends Database {
|
||||
protected readonly Ruleset $ruleset;
|
||||
|
||||
public function __construct() {
|
||||
$this->ruleset = new Ruleset(strict: true);
|
||||
|
||||
$this->ruleset->GET([
|
||||
(new Rules(LanguagesTable::ID->value))
|
||||
->type(Type::STRING)
|
||||
->min(1)
|
||||
->max(parent::SIZE_VARCHAR),
|
||||
|
||||
(new Rules(LanguagesTable::BYTES->value))
|
||||
->type(Type::NUMBER)
|
||||
->min(1)
|
||||
->max(parent::SIZE_UINT32)
|
||||
]);
|
||||
|
||||
$this->ruleset->validate_or_exit();
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function main(): Response {
|
||||
return $this->list(LanguagesTable::NAME, LanguagesTable::values(), [
|
||||
LanguagesTable::BYTES->value => Order::DESC
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -1,102 +0,0 @@
|
|||
<?php
|
||||
|
||||
use ReflectRules\Ruleset;
|
||||
use Reflect\{Response, Path, Call};
|
||||
|
||||
use VLW\API\Endpoints;
|
||||
use VLW\Database\Database;
|
||||
use VLW\Database\Tables\About\LanguagesTable;
|
||||
use const VLW\{FORGEJO_ENDPOINT_USER, FORGEJO_ENDPOINT_SEARCH};
|
||||
|
||||
require_once Path::root("src/Consts.php");
|
||||
require_once Path::root("src/API/Endpoints.php");
|
||||
require_once Path::root("src/Database/Tables/About/Languages.php");
|
||||
|
||||
class POST_AboutLanguages extends Database {
|
||||
protected readonly Ruleset $ruleset;
|
||||
|
||||
// Tally of all languages used in all configured repositories
|
||||
private array $languages = [];
|
||||
|
||||
public function __construct() {
|
||||
$this->ruleset = new Ruleset(strict: true);
|
||||
$this->ruleset->validate_or_exit();
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
// Fetch JSON from URL
|
||||
private static function fetch_json(string $url): array {
|
||||
return json_decode(file_get_contents($url), true);
|
||||
}
|
||||
|
||||
// Fetch JSON from a Forgejo endpoint
|
||||
private static function fetch_endpoint(string $endpoint): array {
|
||||
$url = $_ENV["server_forgejo"]["base_url"] . $endpoint;
|
||||
return self::fetch_json($url);
|
||||
}
|
||||
|
||||
// Write $this->languages to a JSON file
|
||||
private function cache_languages(): void {
|
||||
// Delete existing cache
|
||||
(new Call(Endpoints::ABOUT_LANGUAGES->value))->delete();
|
||||
|
||||
$this->db->for(LanguagesTable::NAME);
|
||||
|
||||
foreach ($this->languages as $language => $bytes) {
|
||||
$this->db->insert([
|
||||
LanguagesTable::ID->value => $language,
|
||||
LanguagesTable::BYTES->value => $bytes
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch and add languages to total from a fully-qualified Forgejo URL
|
||||
private function add_repository_languages(string $url): void {
|
||||
foreach(self::fetch_json($url) as $language => $bytes) {
|
||||
// Create key for language if it doesn't exist
|
||||
if (!array_key_exists($language, $this->languages)) {
|
||||
$this->languages[$language] = 0;
|
||||
}
|
||||
|
||||
// Add bytes to language in total
|
||||
$this->languages[$language] += $bytes;
|
||||
}
|
||||
}
|
||||
|
||||
// Tally languages from public repositories for user id
|
||||
private function add_public_repositores(int $uid): bool {
|
||||
$resp = self::fetch_endpoint(sprintf(FORGEJO_ENDPOINT_SEARCH, $uid));
|
||||
|
||||
// Bail out if request failed or if response indicated a problem
|
||||
if (!$resp or $resp["ok"] === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add langauges for each public repository
|
||||
foreach ($resp["data"] as $repo) {
|
||||
$this->add_repository_languages($repo["languages_url"]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Add languages from all public repositories for profiles in config
|
||||
private function add_repositories_from_config_profiles(): void {
|
||||
foreach(explode(",", $_ENV["server_forgejo"]["scan_profiles"]) as $profile) {
|
||||
// Resolve user data from username
|
||||
$user = self::fetch_endpoint(sprintf(FORGEJO_ENDPOINT_USER, $profile));
|
||||
$this->add_public_repositores($user["id"]);
|
||||
}
|
||||
}
|
||||
|
||||
public function main(): Response {
|
||||
$this->add_repositories_from_config_profiles();
|
||||
|
||||
// Sort langauges bytes tally by largest in descending order
|
||||
arsort($this->languages);
|
||||
|
||||
$this->cache_languages();
|
||||
return new Response($this->languages);
|
||||
}
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
<?php
|
||||
|
||||
use vlw\MySQL\Order;
|
||||
use Reflect\{Response, Path, Call};
|
||||
use ReflectRules\{Ruleset, Rules, Type};
|
||||
|
||||
use VLW\API\Endpoints;
|
||||
use VLW\Database\Database;
|
||||
use VLW\Database\Tables\Coffee\CoffeeTable;
|
||||
|
||||
require_once Path::root("src/API/Endpoints.php");
|
||||
require_once Path::root("src/Database/Database.php");
|
||||
require_once Path::root("src/Database/Tables/Coffee/Coffee.php");
|
||||
|
||||
class GET_Coffee extends Database {
|
||||
protected readonly Ruleset $ruleset;
|
||||
|
||||
public function __construct() {
|
||||
$this->ruleset = new Ruleset(strict: true);
|
||||
|
||||
$this->ruleset->GET([
|
||||
(new Rules(CoffeeTable::ID->value))
|
||||
->type(Type::NUMBER)
|
||||
->min(1)
|
||||
->max(parent::SIZE_UINT32)
|
||||
]);
|
||||
|
||||
$this->ruleset->validate_or_exit();
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function main(): Response {
|
||||
return $this->list(CoffeeTable::NAME, CoffeeTable::values(), [
|
||||
CoffeeTable::ID->value => Order::DESC
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Reflect\{Response, Path, Call};
|
||||
use ReflectRules\{Ruleset, Rules, Type};
|
||||
|
||||
use VLW\API\Endpoints;
|
||||
use VLW\Database\Database;
|
||||
use VLW\Database\Tables\Coffee\CoffeeTable;
|
||||
|
||||
require_once Path::root("src/API/Endpoints.php");
|
||||
require_once Path::root("src/Database/Database.php");
|
||||
require_once Path::root("src/Database/Tables/Coffee/Coffee.php");
|
||||
|
||||
class POST_Coffee extends Database {
|
||||
protected Ruleset $ruleset;
|
||||
|
||||
public function __construct() {
|
||||
$this->ruleset = new Ruleset(strict: true);
|
||||
|
||||
$this->ruleset->POST([
|
||||
(new Rules(CoffeeTable::ID->value))
|
||||
->type(Type::NUMBER)
|
||||
->min(1)
|
||||
->max(parent::SIZE_UINT32)
|
||||
->default(time()),
|
||||
]);
|
||||
|
||||
$this->ruleset->validate_or_exit();
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function main(): Response {
|
||||
return $this->db->for(CoffeeTable::NAME)->insert($_POST) === true
|
||||
? new Response(null, 201)
|
||||
: new Response("Database error", 500);
|
||||
}
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
<?php
|
||||
|
||||
use vlw\MySQL\Order;
|
||||
use Reflect\{Response, Path, Call};
|
||||
use ReflectRules\{Ruleset, Rules, Type};
|
||||
|
||||
use VLW\API\Endpoints;
|
||||
use VLW\Database\Database;
|
||||
use VLW\Database\Tables\Coffee\StatsTable;
|
||||
|
||||
require_once Path::root("src/API/Endpoints.php");
|
||||
require_once Path::root("src/Database/Database.php");
|
||||
require_once Path::root("src/Database/Tables/Coffee/Stats.php");
|
||||
|
||||
class GET_CoffeeStats extends Database {
|
||||
protected readonly Ruleset $ruleset;
|
||||
|
||||
public function __construct() {
|
||||
$this->ruleset = new Ruleset(strict: true);
|
||||
$this->ruleset->validate_or_exit();
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function main(): Response {
|
||||
return $this->list(StatsTable::NAME, StatsTable::values());
|
||||
}
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Reflect\{Response, Path, Call};
|
||||
use ReflectRules\{Ruleset, Rules, Type};
|
||||
|
||||
use VLW\API\Endpoints;
|
||||
use VLW\Database\Database;
|
||||
use VLW\Database\Tables\Coffee\{CoffeeTable, StatsTable};
|
||||
|
||||
require_once Path::root("src/API/Endpoints.php");
|
||||
require_once Path::root("src/Database/Database.php");
|
||||
require_once Path::root("src/Database/Tables/Coffee/Stats.php");
|
||||
require_once Path::root("src/Database/Tables/Coffee/Coffee.php");
|
||||
|
||||
class POST_CoffeeStats extends Database {
|
||||
protected Ruleset $ruleset;
|
||||
|
||||
public function __construct() {
|
||||
$this->ruleset = new Ruleset(strict: true);
|
||||
$this->ruleset->validate_or_exit();
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function main(): Response {
|
||||
$truncate = $this->db->execute_query("DELETE FROM `" . StatsTable::NAME . "`");
|
||||
|
||||
// Add a dummy row to run the MariaDB INSERT AFTER Trigger on the coffee database table
|
||||
$insert = $this->db->for(CoffeeTable::NAME)->insert([CoffeeTable::ID->value => 0]);
|
||||
// Remove the dummy row
|
||||
$remove = $this->db->for(CoffeeTable::NAME)->where([CoffeeTable::ID->value => 0])->delete();
|
||||
|
||||
return $truncate && $insert && $remove ? new Response() : new Response("Error", 500);
|
||||
}
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Reflect\{Response, Path};
|
||||
use ReflectRules\{Ruleset, Rules, Type};
|
||||
|
||||
use VLW\Database\Database;
|
||||
use VLW\Database\Tables\Messages\MessagesTable;
|
||||
|
||||
require_once Path::root("src/Database/Database.php");
|
||||
require_once Path::root("src/Database/Tables/Messages/Messages.php");
|
||||
|
||||
class POST_Messages extends Database {
|
||||
protected Ruleset $ruleset;
|
||||
|
||||
public function __construct() {
|
||||
$this->ruleset = new Ruleset(strict: true);
|
||||
|
||||
$this->ruleset->POST([
|
||||
(new Rules(MessagesTable::EMAIL->value))
|
||||
->type(Type::STRING)
|
||||
->max(255)
|
||||
->default(null),
|
||||
|
||||
(new Rules(MessagesTable::MESSAGE->value))
|
||||
->required()
|
||||
->type(Type::STRING)
|
||||
->min(1)
|
||||
->max(parent::SIZE_TEXT)
|
||||
]);
|
||||
|
||||
$this->ruleset->validate_or_exit();
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function main(): Response {
|
||||
$_POST[MessagesTable::TIMESTAMP_CREATED->value] = time();
|
||||
|
||||
return $this->db->for(MessagesTable::NAME)->insert($_POST) === true
|
||||
? new Response(null, 201)
|
||||
: new Response("Failed to send message", 500);
|
||||
}
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
<?php
|
||||
|
||||
use vlw\MySQL\Operators;
|
||||
use Reflect\{Response, Path};
|
||||
use ReflectRules\{Ruleset, Rules, Type};
|
||||
|
||||
use VLW\Database\Database;
|
||||
use VLW\Database\Tables\Search\SearchTable;
|
||||
|
||||
require_once Path::root("src/Database/Database.php");
|
||||
require_once Path::root("src/Database/Tables/Search/Search.php");
|
||||
|
||||
class DELETE_Search extends Database {
|
||||
protected Ruleset $ruleset;
|
||||
|
||||
public function __construct() {
|
||||
$this->ruleset = new Ruleset(strict: true);
|
||||
|
||||
$this->ruleset->POST([
|
||||
(new Rules(SearchTable::ID->value))
|
||||
->required()
|
||||
->type(Type::STRING)
|
||||
->min(2)
|
||||
->max(parent::SIZE_VARCHAR)
|
||||
]);
|
||||
|
||||
$this->ruleset->validate_or_exit();
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function main(): Response {
|
||||
return $this->db->for(SearchTable::NAME)->delete($_POST) === true ? new Response() : new Response("", 500);
|
||||
}
|
||||
}
|
|
@ -1,85 +0,0 @@
|
|||
<?php
|
||||
|
||||
use vlw\MySQL\Operators;
|
||||
use Reflect\{Response, Path, Call};
|
||||
use ReflectRules\{Ruleset, Rules, Type};
|
||||
|
||||
use VLW\API\Endpoints;
|
||||
use VLW\Database\Database;
|
||||
use VLW\Database\Tables\Search\{SearchTable, SearchCategoryEnum};
|
||||
|
||||
require_once Path::root("src/API/Endpoints.php");
|
||||
require_once Path::root("src/Database/Database.php");
|
||||
require_once Path::root("src/Database/Tables/Search/Search.php");
|
||||
|
||||
class GET_Search extends Database {
|
||||
protected Ruleset $ruleset;
|
||||
|
||||
public function __construct() {
|
||||
$this->ruleset = new Ruleset(strict: true);
|
||||
|
||||
$this->ruleset->GET([
|
||||
(new Rules(SearchTable::QUERY->value))
|
||||
->type(Type::STRING)
|
||||
->min(2)
|
||||
->max(parent::SIZE_VARCHAR)
|
||||
->default(null),
|
||||
|
||||
(new Rules(SearchTable::ID->value))
|
||||
->type(Type::STRING)
|
||||
->min(1)
|
||||
->max(10)
|
||||
->default(null),
|
||||
|
||||
(new Rules(SearchTable::CATEGORY->value))
|
||||
->type(Type::ENUM, SearchCategoryEnum::names())
|
||||
->default(null)
|
||||
]);
|
||||
|
||||
$this->ruleset->validate_or_exit();
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
private static function get_query(): string {
|
||||
preg_match_all("/[a-zA-Z0-9]+/", $_GET[SearchTable::QUERY->value], $matches);
|
||||
|
||||
return strtolower(implode("", $matches[0]));
|
||||
}
|
||||
|
||||
public function main(): Response {
|
||||
$result = $this->db->for(SearchTable::NAME);
|
||||
|
||||
if ($_GET[SearchTable::ID->value]) {
|
||||
$result = $result->where([SearchTable::ID->value => $_GET[SearchTable::ID->value]]);
|
||||
} else if ($_GET[SearchTable::QUERY->value]) {
|
||||
$query = self::get_query();
|
||||
|
||||
$filter = [
|
||||
SearchTable::QUERY->value => [
|
||||
Operators::LIKE->value => "%{$query}%"
|
||||
]
|
||||
];
|
||||
|
||||
if ($_GET[SearchTable::CATEGORY->value]) {
|
||||
$filter[SearchTable::CATEGORY->value] = $_GET[SearchTable::CATEGORY->value];
|
||||
}
|
||||
|
||||
$result = $result->where($filter);
|
||||
} else {
|
||||
new Response([], 400);
|
||||
}
|
||||
|
||||
$result = $result->select([
|
||||
SearchTable::ID->value,
|
||||
SearchTable::TITLE->value,
|
||||
SearchTable::SUMMARY->value,
|
||||
SearchTable::CATEGORY->value,
|
||||
SearchTable::HREF->value
|
||||
]);
|
||||
|
||||
return $result->num_rows > 0
|
||||
? new Response($result->fetch_all(MYSQLI_ASSOC))
|
||||
: new Response([], 404);
|
||||
}
|
||||
}
|
|
@ -1,73 +0,0 @@
|
|||
<?php
|
||||
|
||||
use vlw\MySQL\Operators;
|
||||
use Reflect\{Response, Path, Call};
|
||||
use ReflectRules\{Ruleset, Rules, Type};
|
||||
|
||||
use VLW\API\Endpoints;
|
||||
use VLW\Database\Database;
|
||||
use const VLW\SEARCH_QUERY_MAX_LENGTH;
|
||||
use VLW\Database\Tables\Search\{SearchTable, SearchCategoryEnum};
|
||||
use VLW\Database\Tables\Work\{WorkTable, ActionsTable};
|
||||
|
||||
require_once Path::root("src/Consts.php");
|
||||
require_once Path::root("src/API/Endpoints.php");
|
||||
require_once Path::root("src/Database/Database.php");
|
||||
require_once Path::root("src/Database/Tables/Work/Work.php");
|
||||
require_once Path::root("src/Database/Tables/Work/Actions.php");
|
||||
require_once Path::root("src/Database/Tables/Search/Search.php");
|
||||
|
||||
class POST_Search extends Database {
|
||||
protected Ruleset $ruleset;
|
||||
|
||||
public function __construct() {
|
||||
$this->ruleset = new Ruleset(strict: true);
|
||||
$this->ruleset->validate_or_exit();
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
private static function truncate_string(string $input): string {
|
||||
return substr($input, 0, SEARCH_QUERY_MAX_LENGTH);
|
||||
}
|
||||
|
||||
private static function create_query(string $input): string {
|
||||
preg_match_all("/[a-zA-Z0-9]+/", $input, $matches);
|
||||
|
||||
return self::truncate_string(strtolower(implode("", $matches[0])));
|
||||
}
|
||||
|
||||
private function index_work(): void {
|
||||
foreach ((new Call(Endpoints::WORK->value))->get()->output() as $result) {
|
||||
$query = self::create_query(implode("", array_values($result)), 0, SEARCH_QUERY_MAX_LENGTH);
|
||||
|
||||
// Get actions related to current result
|
||||
$actions = (new Call(Endpoints::WORK_ACTIONS->value))->params([
|
||||
ActionsTable::REF_WORK_ID->value => $result[WorkTable::ID->value]
|
||||
])->get()->output();
|
||||
|
||||
$this->db->for(SearchTable::NAME)->insert([
|
||||
SearchTable::QUERY->value => $query,
|
||||
SearchTable::ID->value => crc32($query),
|
||||
SearchTable::TITLE->value => self::truncate_string($result[WorkTable::TITLE->value]),
|
||||
SearchTable::SUMMARY->value => self::truncate_string($result[WorkTable::SUMMARY->value]),
|
||||
SearchTable::CATEGORY->value => SearchCategoryEnum::WORK->name,
|
||||
// Use first action as link for search result
|
||||
SearchTable::HREF->value => $actions
|
||||
? self::truncate_string($actions[0][ActionsTable::HREF->value])
|
||||
: null
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function main(): Response {
|
||||
// Truncate existing cache
|
||||
(new Call(Endpoints::SEARCH->value))->delete();
|
||||
|
||||
$this->index_work();
|
||||
|
||||
return $result->num_rows > 0
|
||||
? new Response($result->fetch_all(MYSQLI_ASSOC))
|
||||
: new Response([], 404);
|
||||
}
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Reflect\{Response, Path, Call};
|
||||
use ReflectRules\{Ruleset, Rules, Type};
|
||||
|
||||
use VLW\API\Endpoints;
|
||||
|
||||
require_once Path::root("src/API/Endpoints.php");
|
||||
|
||||
class GET_Update {
|
||||
protected Ruleset $ruleset;
|
||||
|
||||
public function __construct() {
|
||||
$this->ruleset = new Ruleset(strict: true);
|
||||
$this->ruleset->validate_or_exit();
|
||||
}
|
||||
|
||||
// Update all runtime database endpoints
|
||||
public function main(): Response {
|
||||
(new Call(Endpoints::SEARCH->value))->post();
|
||||
(new Call(Endpoints::COFFEE_STATS->value))->post();
|
||||
(new Call(Endpoints::ABOUT_LANGUAGES->value))->post();
|
||||
|
||||
return new Response();
|
||||
}
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
<?php
|
||||
|
||||
use vlw\MySQL\Order;
|
||||
use Reflect\{Response, Path};
|
||||
use ReflectRules\{Ruleset, Rules, Type};
|
||||
|
||||
use VLW\Database\Database;
|
||||
use VLW\Database\Tables\Work\WorkTable;
|
||||
|
||||
require_once Path::root("src/Database/Database.php");
|
||||
require_once Path::root("src/Database/Tables/Work/Work.php");
|
||||
|
||||
class GET_Work extends Database {
|
||||
protected Ruleset $ruleset;
|
||||
|
||||
public function __construct() {
|
||||
$this->ruleset = new Ruleset(strict: true);
|
||||
|
||||
$this->ruleset->GET([
|
||||
(new Rules(WorkTable::ID->value))
|
||||
->type(Type::STRING)
|
||||
->min(1)
|
||||
->max(parent::SIZE_VARCHAR),
|
||||
|
||||
(new Rules(WorkTable::TITLE->value))
|
||||
->type(Type::STRING)
|
||||
->max(parent::SIZE_VARCHAR),
|
||||
|
||||
(new Rules(WorkTable::SUMMARY->value))
|
||||
->type(Type::STRING)
|
||||
->max(parent::SIZE_TEXT),
|
||||
|
||||
(new Rules(WorkTable::CREATED->value))
|
||||
->type(Type::STRING)
|
||||
->min(1)
|
||||
->max(parent::SIZE_VARCHAR)
|
||||
]);
|
||||
|
||||
$this->ruleset->validate_or_exit();
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function main(): Response {
|
||||
return $this->list(WorkTable::NAME, WorkTable::values(), [
|
||||
WorkTable::CREATED->value => Order::DESC
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
<?php
|
||||
|
||||
use vlw\MySQL\Order;
|
||||
use Reflect\{Response, Path};
|
||||
use ReflectRules\{Ruleset, Rules, Type};
|
||||
|
||||
use VLW\Database\Database;
|
||||
use VLW\Database\Tables\Work\ActionsTable;
|
||||
|
||||
require_once Path::root("src/Database/Database.php");
|
||||
require_once Path::root("src/Database/Tables/Work/Actions.php");
|
||||
|
||||
class GET_WorkActions extends Database {
|
||||
protected Ruleset $ruleset;
|
||||
|
||||
public function __construct() {
|
||||
$this->ruleset = new Ruleset(strict: true);
|
||||
|
||||
$this->ruleset->GET([
|
||||
(new Rules(ActionsTable::REF_WORK_ID->value))
|
||||
->type(Type::STRING)
|
||||
->min(1)
|
||||
->max(parent::SIZE_VARCHAR)
|
||||
]);
|
||||
|
||||
$this->ruleset->validate_or_exit();
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function main(): Response {
|
||||
return $this->list(ActionsTable::NAME, ActionsTable::values(), [
|
||||
ActionsTable::ORDER_IDX->value => Order::DESC
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Reflect\{Response, Path};
|
||||
use ReflectRules\{Ruleset, Rules, Type};
|
||||
|
||||
use VLW\Database\Database;
|
||||
use VLW\Database\Tables\Work\{TagsTable, TagsLabelEnum};
|
||||
|
||||
require_once Path::root("src/Database/Database.php");
|
||||
require_once Path::root("src/Database/Tables/Work/Tags.php");
|
||||
|
||||
class GET_WorkTags extends Database {
|
||||
private Ruleset $ruleset;
|
||||
|
||||
public function __construct() {
|
||||
$this->ruleset = new Ruleset(strict: true);
|
||||
|
||||
$this->ruleset->GET([
|
||||
(new Rules(TagsTable::REF_WORK_ID->value))
|
||||
->min(1)
|
||||
->max(parent::SIZE_VARCHAR),
|
||||
|
||||
(new Rules(TagsTable::LABEL->value))
|
||||
->type(Type::ENUM, TagsLabelEnum::names())
|
||||
]);
|
||||
|
||||
$this->ruleset->validate_or_exit();
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function main(): Response {
|
||||
return $this->list(TagsTable::NAME, TagsTable::values());
|
||||
}
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
<?php
|
||||
|
||||
use vlw\MySQL\Order;
|
||||
use Reflect\{Response, Path};
|
||||
use ReflectRules\{Ruleset, Rules, Type};
|
||||
|
||||
use VLW\Database\Database;
|
||||
use VLW\Database\Tables\Work\TimelineTable;
|
||||
|
||||
require_once Path::root("src/Database/Database.php");
|
||||
require_once Path::root("src/Database/Tables/Work/Timeline.php");
|
||||
|
||||
class GET_WorkTimeline extends Database {
|
||||
protected Ruleset $ruleset;
|
||||
|
||||
public function __construct() {
|
||||
$this->ruleset = new Ruleset(strict: true);
|
||||
|
||||
$this->ruleset->GET([
|
||||
(new Rules(TimelineTable::REF_WORK_ID->value))
|
||||
->type(Type::STRING)
|
||||
->min(1)
|
||||
->max(parent::SIZE_VARCHAR),
|
||||
|
||||
(new Rules(TimelineTable::YEAR->value))
|
||||
->type(Type::NUMBER)
|
||||
->min(0)
|
||||
->max(parent::SIZE_UINT16),
|
||||
|
||||
(new Rules(TimelineTable::MONTH->value))
|
||||
->type(Type::NUMBER)
|
||||
->min(0)
|
||||
->max(parent::SIZE_UINT8),
|
||||
|
||||
(new Rules(TimelineTable::DAY->value))
|
||||
->type(Type::NUMBER)
|
||||
->min(0)
|
||||
->max(parent::SIZE_UINT8)
|
||||
]);
|
||||
|
||||
$this->ruleset->validate_or_exit();
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function main(): Response {
|
||||
return $this->list(TimelineTable::NAME, TimelineTable::values(), [
|
||||
TimelineTable::YEAR->value => Order::DESC,
|
||||
TimelineTable::MONTH->value => Order::DESC,
|
||||
TimelineTable::DAY->value => Order::DESC
|
||||
]);
|
||||
}
|
||||
}
|
10
install.sh
|
@ -1,10 +0,0 @@
|
|||
# Install dependencies
|
||||
composer install --optimize-autoloader
|
||||
npm install
|
||||
|
||||
# (Re)create public NPM modules folder
|
||||
rm -r public/assets/js/modules/npm
|
||||
mkdir public/assets/js/modules/npm
|
||||
|
||||
# Create link to Elevent MJS from public JS modules folder
|
||||
ln -sr node_modules/elevent/src/Elevent.mjs public/assets/js/modules/npm/Elevent.mjs
|
17
package-lock.json
generated
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"name": "vlw.se",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"elevent": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/elevent": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/elevent/-/elevent-1.0.2.tgz",
|
||||
"integrity": "sha512-ks5LBUBTg4Bpfmj99OcFAzuDGzBRDEZhTyxmq/Y3RbsdBQ4JCaIUYB0M15OBvBWgIn1BnCo4WCSmw0/YbCJliw=="
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"elevent": "^1.0.2"
|
||||
}
|
||||
}
|
45
pages/about.php
Executable file
|
@ -0,0 +1,45 @@
|
|||
<style><?= VV::css("pages/about") ?></style>
|
||||
<section class="intro">
|
||||
<h2 aria-hidden="true">Hi, I'm</h2>
|
||||
<h1>Victor Westerlund</h1>
|
||||
</section>
|
||||
<hr aria-hidden="true">
|
||||
<section class="about">
|
||||
<p>I​'m a full-stack web developer from Sweden, currently working as IT-Lead at the biopharma start-up <a href="https://icellate.com">iCellate Medical</a> in Solna, Stockholm. I also develop and maintain <a href="https://github.com/VictorWesterlund/vegvisir">my own web framework</a> and use it to build web apps and websites - including this one.</p>
|
||||
<p>The <programming/markup/command>-languages I currently use the most are (in a mostly accurate decending order): PHP, JavaScript, CSS, MySQL, Python, SQLite, Bash, and [pure] HTML.</p>
|
||||
</section>
|
||||
<section class="about">
|
||||
<h2>This website</h2>
|
||||
<p>This site and all of its components are 100% free and open source software. The website is designed and built by me from the ground up on top of my own <a href="https://github.com/victorwesterlund/vegvisir">web</a> and <a href="https://github.com/victorwesterlund/reflect">API</a> frameworks. There are <i>no cookies or trackers</i> here. The only information I have about you is your public IP-address and which resources on this site your browser requests. None of this data is used for any kind of analytics.</p>
|
||||
<p><a href="https://github.com/victorwesterlund/vlw.se">Checkout the website source code on GitHub</a></p>
|
||||
</section>
|
||||
<section class="about">
|
||||
<h2>Personal</h2>
|
||||
<p>With a cup of coffee ready at hand, I can at times become a real armchair detective for a <span class="interests">variety of nerdy topics I find interesting</span>, and spend hours reading as much as I can about them too. I like to skii when I'm not glued in front of a computer screen.</p>
|
||||
</section>
|
||||
<section class="about">
|
||||
<h2>Projects</h2>
|
||||
<p>Here are the projects I'm working on right now:</p>
|
||||
<p>* <a href="https://github.com/victorwesterlund/reflect">Reflect</a>: An API framework written in PHP - for PHP developers.</p>
|
||||
<p>* <a href="https://github.com/victorwesterlund/vegvisir">Vegvisir</a>: A web framework written in PHP and JavaScript - for PHP and JavaScript developers.</p>
|
||||
<p>See more on my <a href="work" vv="about" vv-call="navigate">works page</a>. And even more, including smaller projects on <a href="https://github.com/VictorWesterlund">my GitHub profile</a>.</p>
|
||||
</section>
|
||||
<hr>
|
||||
<section>
|
||||
<p>Let's work on something together or just have a chat. <a href="contact" vv="about" vv-call="navigate">Write me a line!</a></p>
|
||||
</section>
|
||||
<hr>
|
||||
<section class="version">
|
||||
<p>website version: <?= VV::include("pages/about/version") ?></p>
|
||||
</section>
|
||||
|
||||
<div class="interests" aria-hidden="true">
|
||||
<p>practical engineering</p>
|
||||
<p>music</p>
|
||||
<p>astronomy</p>
|
||||
<p>electronics</p>
|
||||
<p>aviation</p>
|
||||
<p>marine technology</p>
|
||||
<p>typography</p>
|
||||
</div>
|
||||
<script><?= VV::js("pages/about") ?></script>
|
19
pages/about/version.php
Executable file
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
A pretty naive website version fetcher that assumes the latest git tag is the
|
||||
version the website is currently displaying. The intent is that any live-master
|
||||
of this website should always track the master branch and pull the latest HEAD
|
||||
without any exceptions.
|
||||
*/
|
||||
|
||||
use Vegvisir\Path;
|
||||
|
||||
// Get tags from local git folder
|
||||
$dir = scandir(Path::root(".git/refs/tags"));
|
||||
|
||||
// Get current version number from latest tag
|
||||
$version = end($dir);
|
||||
|
||||
?>
|
||||
<a href="https://github.com/victorwesterlund/vlw.se/releases/<?= $version ?>"><?= $version ?></a>
|
87
pages/contact.php
Executable file
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
use VLW\API\Client;
|
||||
use VLW\API\Endpoints;
|
||||
|
||||
enum ContactFieldsEnum: string {
|
||||
case EMAIL = "email";
|
||||
case MESSAGE = "message";
|
||||
}
|
||||
|
||||
// Connect to VLW API
|
||||
$api = new Client();
|
||||
|
||||
?>
|
||||
<style><?= VV::css("pages/contact") ?></style>
|
||||
<section>
|
||||
<h1>Let's chat</h1>
|
||||
<p>The best way to get in touch is by email, or with the form on this page. I will try to reply as quickly as possible, probably within a few hours. The time is <i><?= (new DateTime("now", new DateTimeZone($_ENV["time"]["date_time_zone"])))->format("h:i a") ?></i> in Sweden right now.</p>
|
||||
</section>
|
||||
<section class="social">
|
||||
<a href="mailto:victor@vlw.se"><social>
|
||||
<?= VV::media("icons/email.svg") ?>
|
||||
<p>e-mail</p>
|
||||
</social></a>
|
||||
<a href="https://mastodon.social/@vlwone"><social>
|
||||
<?= VV::media("icons/mastodon.svg") ?>
|
||||
<p>mastodon</p>
|
||||
</social></a>
|
||||
<a href="https://web.libera.chat/#vlw.se"><social>
|
||||
<?= VV::media("icons/libera.svg") ?>
|
||||
<p>libera.chat</p>
|
||||
</social></a>
|
||||
</section>
|
||||
<?= VV::media("line.svg") ?>
|
||||
<section class="pgp">
|
||||
<?= VV::media("icons/pin.svg") ?>
|
||||
<h3>encrypt your message with my OpenPGP key.</h3>
|
||||
<p>my key is also listed on the <a href="https://keys.openpgp.org/search?q=victor%40vlw.se" target="_blank" rel="noopener noreferer">openPGP key server</a> for victor@vlw.se so your e-mail client can automatically retreive it if supported.</p>
|
||||
<div class="buttons">
|
||||
<a href="https://keys.openpgp.org/vks/v1/by-fingerprint/DCE987311CB5D2A252F58951D0AD730E1057DFC6"><button class="inline solid">download ASC</button></a>
|
||||
<a href="https://emailselfdefense.fsf.org/en/" target="_blank" rel="noopener noreferer"><button class="inline">more info</button></a>
|
||||
</div>
|
||||
</section>
|
||||
<?= VV::media("line.svg") ?>
|
||||
|
||||
<?php // Send message on POST request ?>
|
||||
<?php if ($_SERVER["REQUEST_METHOD"] === "POST"): ?>
|
||||
|
||||
<?php
|
||||
|
||||
// Send message via API
|
||||
$send = $api->call(Endpoints::MESSAGES->value)->post([
|
||||
ContactFieldsEnum::EMAIL->value => $_POST[ContactFieldsEnum::EMAIL->value],
|
||||
ContactFieldsEnum::MESSAGE->value => $_POST[ContactFieldsEnum::MESSAGE->value]
|
||||
]);
|
||||
|
||||
?>
|
||||
|
||||
<?php if ($send->ok): ?>
|
||||
<section class="form-message sent">
|
||||
<h3>🙏 Message sent!</h3>
|
||||
</section>
|
||||
<?php else: ?>
|
||||
<?php // Show response body from endpoint as error if request failed ?>
|
||||
<section class="form-message error">
|
||||
<h3>😟 Oh no, something went wrong</h3>
|
||||
<p>Response from API:</p>
|
||||
<pre><?= $send->output() ?></pre>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="form">
|
||||
<form method="POST">
|
||||
<input-group>
|
||||
<label>your email (optional)</label>
|
||||
<input type="email" name="<?= ContactFieldsEnum::EMAIL->value ?>" placeholder="nissehult@example.com" autocomplete="off"></input>
|
||||
</input-group>
|
||||
<input-group>
|
||||
<label title="this field is required">your message (required)</label>
|
||||
<textarea name="<?= ContactFieldsEnum::MESSAGE->value ?>" required placeholder="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed molestie dignissim mauris vel dignissim. Sed et aliquet odio, id egestas libero. Vestibulum ut dui a turpis aliquam hendrerit id et dui. Morbi eu tristique quam, sit amet dictum felis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin ac nibh a ex accumsan ullamcorper non quis eros. Nam at suscipit lacus. Nullam placerat semper sapien, vitae aliquet nisl elementum a. Duis viverra quam eros, eu vestibulum quam egestas sit amet. Duis lobortis varius malesuada. Mauris in fringilla mi. "></textarea>
|
||||
</input-group>
|
||||
<button class="inline solid">send</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<script><?= VV::js("pages/contact") ?></script>
|
31
public/shell.php → pages/document.php
Normal file → Executable file
|
@ -1,12 +1,8 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"/>
|
||||
<meta property="og:title" content="Victor L. Westerlund"/>
|
||||
<meta property="og:type" content="website"/>
|
||||
<meta property="og:description" content="Full-stack PHP and JavaScript web developer from Sweden"/>
|
||||
<meta property="og:image" content="https://vlw.se/assets/media/ogp.jpg"/>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
|
||||
<script>
|
||||
<!--//--><![CDATA[//><!--
|
||||
|
@ -38,39 +34,38 @@
|
|||
</script>
|
||||
|
||||
<?php // Bootstrapping ?>
|
||||
<style><?= VV::css("public/assets/css/fonts") ?></style>
|
||||
<style><?= VV::css("public/assets/css/shell") ?></style>
|
||||
<style><?= VV::css("fonts") ?></style>
|
||||
<style><?= VV::css("document") ?></style>
|
||||
|
||||
<title>Victor Westerlund</title>
|
||||
<link rel="icon" href="/assets/media/vw.svg"/>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<p><a href="/">victor westerlund</a></p>
|
||||
<p><a href="/" vv="document" vv-call="navigate">victor westerlund</a></p>
|
||||
</nav>
|
||||
<button class="search searchbox-open">
|
||||
<?= VV::embed("public/assets/media/icons/search.svg") ?>
|
||||
<button class="search" vv="document" vv-call="openSearchbox">
|
||||
<?= VV::media("icons/search.svg") ?>
|
||||
<p>search vlw.se...</p>
|
||||
</button>
|
||||
<button class="logo" vv="/"><?= VV::embed("public/assets/media/vw.svg") ?></button>
|
||||
<button class="logo" vv="document" vv-call="navigateHome"><?= VV::media("vw.svg") ?></button>
|
||||
<searchbox>
|
||||
<input type="search" autocomplete="off" placeholder="search vlw.se...">
|
||||
<button class="close searchbox-close"><?= VV::embed("public/assets/media/icons/close.svg") ?></button>
|
||||
<button class="close" vv="document" vv-call="closeSearchbox"><?= VV::media("icons/close.svg") ?></button>
|
||||
</searchbox>
|
||||
</header>
|
||||
|
||||
<vv-shell></vv-shell>
|
||||
<main></main>
|
||||
|
||||
<search-results>
|
||||
<div class="info empty">
|
||||
<?= VV::embed("public/assets/media/icons/search.svg") ?>
|
||||
<?= VV::media("icons/search.svg") ?>
|
||||
<p>start typing to search</p>
|
||||
</div>
|
||||
</search-results>
|
||||
|
||||
<?php // Bootstrapping ?>
|
||||
<?= VV::init() ?>
|
||||
<script type="module"><?= VV::js("public/assets/js/shell") ?></script>
|
||||
<script><?= VV::init() ?></script>
|
||||
<script><?= VV::js("document") ?></script>
|
||||
</body>
|
||||
</html>
|
6
pages/error.php
Executable 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
|
@ -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>
|