drop axios/date-fns/fontawesome

This commit is contained in:
2023-02-14 15:01:06 +10:00
parent ac4d3d8ad0
commit afc3c94b04
75 changed files with 3416 additions and 2000 deletions

View File

@@ -240,7 +240,7 @@ class UserController extends ApiController
} }
return $this->respondError([ return $this->respondError([
'code' => 'The code was not found or has expired' 'code' => 'The code was not found or has expired.'
]); ]);
} }
@@ -278,7 +278,7 @@ class UserController extends ApiController
}//end if }//end if
return $this->respondWithErrors([ return $this->respondWithErrors([
'code' => 'The code was not found or has expired' 'code' => 'The code was not found or has expired.'
]); ]);
} }

View File

@@ -0,0 +1,698 @@
<?php
namespace App\Services;
class AnimatedGifService
{
/**
* Check if a GIF file at a path is animated or not
*
* @param string $filenameOrBlob GIF file path or data blob if dataSize > 0.
* @param integer $dataSize GIF blob size.
* @return boolean GIF file/blob is animated.
*/
public static function isAnimatedGif(string $filenameOrBlob, int $dataSize = 0)
{
$regex = '#\x00\x21\xF9\x04.{4}\x00(\x2C|\x21)#s';
$count = 0;
if ($dataSize > 0) {
if (($fh = @fopen($filenameOrBlob, 'rb')) === false) {
return false;
}
$chunk = false;
while (feof($fh) === false && $count < 2) {
$chunk = ($chunk !== '' ? substr($chunk, -20) : "") . fread($fh, (1024 * 100)); //read 100kb at a time
$count += preg_match_all($regex, $chunk, $matches);
}
fclose($fh);
} else {
$count = preg_match_all($regex, $filenameOrBlob, $matches);
}
return $count > 1;
}
/**
* Extract frames of a GIF
*
* @param string $filenameOrBlob GIF filename path
* @param integer $dataSize GIF blob size.
* @param boolean $originalFrames Get original frames (with transparent background)
*
* @return array
*/
public function extract(string $filenameOrBlob, int $dataSize = 0, $originalFrames = false)
{
if (self::isAnimatedGif($filenameOrBlob) === false) {
return [];
}
$this->reset();
$this->parseFramesInfo($filename);
$prevImg = null;
for ($i = 0; $i < count($this->frameSources); $i++) {
$this->frames[$i] = [];
$this->frameDurations[$i] = $this->frames[$i]['duration'] = $this->frameSources[$i]['delay_time'];
$img = imagecreatefromstring($this->fileHeader["gifheader"] . $this->frameSources[$i]["graphicsextension"] . $this->frameSources[$i]["imagedata"] . chr(0x3b));
if (!$originalFrames) {
if ($i > 0) {
$prevImg = $this->frames[($i - 1)]['image'];
} else {
$prevImg = $img;
}
$sprite = imagecreate($this->gifMaxWidth, $this->gifMaxHeight);
imagesavealpha($sprite, true);
$transparent = imagecolortransparent($prevImg);
if ($transparent > -1 && imagecolorstotal($prevImg) > $transparent) {
$actualTrans = imagecolorsforindex($prevImg, $transparent);
imagecolortransparent($sprite, imagecolorallocate($sprite, $actualTrans['red'], $actualTrans['green'], $actualTrans['blue']));
}
if ((int) $this->frameSources[$i]['disposal_method'] == 1 && $i > 0) {
imagecopy($sprite, $prevImg, 0, 0, 0, 0, $this->gifMaxWidth, $this->gifMaxHeight);
}
imagecopyresampled($sprite, $img, $this->frameSources[$i]["offset_left"], $this->frameSources[$i]["offset_top"], 0, 0, $this->gifMaxWidth, $this->gifMaxHeight, $this->gifMaxWidth, $this->gifMaxHeight);
$img = $sprite;
}//end if
$this->frameImages[$i] = $this->frames[$i]['image'] = $img;
}//end for
return $this->frames;
}
}
class GifFrameExtractor
{
// Properties
// ===================================================================================
/**
* @var resource
*/
private $gif;
/**
* @var array
*/
private $frames;
/**
* @var array
*/
private $frameDurations;
/**
* @var array
*/
private $frameImages;
/**
* @var array
*/
private $framePositions;
/**
* @var array
*/
private $frameDimensions;
/**
* @var integer
*
* (old: $this->index)
*/
private $frameNumber;
/**
* @var array
*
* (old: $this->imagedata)
*/
private $frameSources;
/**
* @var array
*
* (old: $this->fileHeader)
*/
private $fileHeader;
/**
* @var integer The reader pointer in the file source
*
* (old: $this->pointer)
*/
private $pointer;
/**
* @var integer
*/
private $gifMaxWidth;
/**
* @var integer
*/
private $gifMaxHeight;
/**
* @var integer
*/
private $totalDuration;
/**
* @var integer
*/
private $handle;
/**
* @var array
*
* (old: globaldata)
*/
private $globaldata;
/**
* @var array
*
* (old: orgvars)
*/
private $orgvars;
// Methods
// ===================================================================================
/**
* Parse the frame informations contained in the GIF file
*
* @param string $filename GIF filename path
*/
private function parseFramesInfo($filename)
{
$this->openFile($filename);
$this->parseGifHeader();
$this->parseGraphicsExtension(0);
$this->getApplicationData();
$this->getApplicationData();
$this->getFrameString(0);
$this->parseGraphicsExtension(1);
$this->getCommentData();
$this->getApplicationData();
$this->getFrameString(1);
while (!$this->checkByte(0x3b) && !$this->checkEOF()) {
$this->getCommentData(1);
$this->parseGraphicsExtension(2);
$this->getFrameString(2);
$this->getApplicationData();
}
}
/**
* Parse the gif header (old: get_gif_header)
*/
private function parseGifHeader()
{
$this->pointerForward(10);
if ($this->readBits(($mybyte = $this->readByteInt()), 0, 1) == 1) {
$this->pointerForward(2);
$this->pointerForward(pow(2, ($this->readBits($mybyte, 5, 3) + 1)) * 3);
} else {
$this->pointerForward(2);
}
$this->fileHeader["gifheader"] = $this->dataPart(0, $this->pointer);
// Decoding
$this->orgvars["gifheader"] = $this->fileHeader["gifheader"];
$this->orgvars["background_color"] = $this->orgvars["gifheader"][11];
}
/**
* Parse the application data of the frames (old: get_application_data)
*/
private function getApplicationData()
{
$startdata = $this->readByte(2);
if ($startdata == chr(0x21) . chr(0xff)) {
$start = ($this->pointer - 2);
$this->pointerForward($this->readByteInt());
$this->readDataStream($this->readByteInt());
$this->fileHeader["applicationdata"] = $this->dataPart($start, ($this->pointer - $start));
} else {
$this->pointerRewind(2);
}
}
/**
* Parse the comment data of the frames (old: get_comment_data)
*/
private function getCommentData()
{
$startdata = $this->readByte(2);
if ($startdata == chr(0x21) . chr(0xfe)) {
$start = ($this->pointer - 2);
$this->readDataStream($this->readByteInt());
$this->fileHeader["commentdata"] = $this->dataPart($start, ($this->pointer - $start));
} else {
$this->pointerRewind(2);
}
}
/**
* Parse the graphic extension of the frames (old: get_graphics_extension)
*
* @param integer $type
*/
private function parseGraphicsExtension($type)
{
$startdata = $this->readByte(2);
if ($startdata == chr(0x21) . chr(0xf9)) {
$start = ($this->pointer - 2);
$this->pointerForward($this->readByteInt());
$this->pointerForward(1);
if ($type == 2) {
$this->frameSources[$this->frameNumber]["graphicsextension"] = $this->dataPart($start, ($this->pointer - $start));
} elseif ($type == 1) {
$this->orgvars["hasgx_type_1"] = 1;
$this->globaldata["graphicsextension"] = $this->dataPart($start, ($this->pointer - $start));
} elseif ($type == 0) {
$this->orgvars["hasgx_type_0"] = 1;
$this->globaldata["graphicsextension_0"] = $this->dataPart($start, ($this->pointer - $start));
}
} else {
$this->pointerRewind(2);
}//end if
}
/**
* Get the full frame string block (old: get_image_block)
*
* @param integer $type
*/
private function getFrameString($type)
{
if ($this->checkByte(0x2c)) {
$start = $this->pointer;
$this->pointerForward(9);
if ($this->readBits(($mybyte = $this->readByteInt()), 0, 1) == 1) {
$this->pointerForward(pow(2, ($this->readBits($mybyte, 5, 3) + 1)) * 3);
}
$this->pointerForward(1);
$this->readDataStream($this->readByteInt());
$this->frameSources[$this->frameNumber]["imagedata"] = $this->dataPart($start, ($this->pointer - $start));
if ($type == 0) {
$this->orgvars["hasgx_type_0"] = 0;
if (isset($this->globaldata["graphicsextension_0"])) {
$this->frameSources[$this->frameNumber]["graphicsextension"] = $this->globaldata["graphicsextension_0"];
} else {
$this->frameSources[$this->frameNumber]["graphicsextension"] = null;
}
unset($this->globaldata["graphicsextension_0"]);
} elseif ($type == 1) {
if (isset($this->orgvars["hasgx_type_1"]) && $this->orgvars["hasgx_type_1"] == 1) {
$this->orgvars["hasgx_type_1"] = 0;
$this->frameSources[$this->frameNumber]["graphicsextension"] = $this->globaldata["graphicsextension"];
unset($this->globaldata["graphicsextension"]);
} else {
$this->orgvars["hasgx_type_0"] = 0;
$this->frameSources[$this->frameNumber]["graphicsextension"] = $this->globaldata["graphicsextension_0"];
unset($this->globaldata["graphicsextension_0"]);
}
}//end if
$this->parseFrameData();
$this->frameNumber++;
}//end if
}
/**
* Parse frame data string into an array (old: parse_image_data)
*/
private function parseFrameData()
{
$this->frameSources[$this->frameNumber]["disposal_method"] = $this->getImageDataBit("ext", 3, 3, 3);
$this->frameSources[$this->frameNumber]["user_input_flag"] = $this->getImageDataBit("ext", 3, 6, 1);
$this->frameSources[$this->frameNumber]["transparent_color_flag"] = $this->getImageDataBit("ext", 3, 7, 1);
$this->frameSources[$this->frameNumber]["delay_time"] = $this->dualByteVal($this->getImageDataByte("ext", 4, 2));
$this->totalDuration += (int) $this->frameSources[$this->frameNumber]["delay_time"];
$this->frameSources[$this->frameNumber]["transparent_color_index"] = ord($this->getImageDataByte("ext", 6, 1));
$this->frameSources[$this->frameNumber]["offset_left"] = $this->dualByteVal($this->getImageDataByte("dat", 1, 2));
$this->frameSources[$this->frameNumber]["offset_top"] = $this->dualByteVal($this->getImageDataByte("dat", 3, 2));
$this->frameSources[$this->frameNumber]["width"] = $this->dualByteVal($this->getImageDataByte("dat", 5, 2));
$this->frameSources[$this->frameNumber]["height"] = $this->dualByteVal($this->getImageDataByte("dat", 7, 2));
$this->frameSources[$this->frameNumber]["local_color_table_flag"] = $this->getImageDataBit("dat", 9, 0, 1);
$this->frameSources[$this->frameNumber]["interlace_flag"] = $this->getImageDataBit("dat", 9, 1, 1);
$this->frameSources[$this->frameNumber]["sort_flag"] = $this->getImageDataBit("dat", 9, 2, 1);
$this->frameSources[$this->frameNumber]["color_table_size"] = (pow(2, ($this->getImageDataBit("dat", 9, 5, 3) + 1)) * 3);
$this->frameSources[$this->frameNumber]["color_table"] = substr($this->frameSources[$this->frameNumber]["imagedata"], 10, $this->frameSources[$this->frameNumber]["color_table_size"]);
$this->frameSources[$this->frameNumber]["lzw_code_size"] = ord($this->getImageDataByte("dat", 10, 1));
$this->framePositions[$this->frameNumber] = [
'x' => $this->frameSources[$this->frameNumber]["offset_left"],
'y' => $this->frameSources[$this->frameNumber]["offset_top"],
];
$this->frameDimensions[$this->frameNumber] = [
'width' => $this->frameSources[$this->frameNumber]["width"],
'height' => $this->frameSources[$this->frameNumber]["height"],
];
// Decoding
$this->orgvars[$this->frameNumber]["transparent_color_flag"] = $this->frameSources[$this->frameNumber]["transparent_color_flag"];
$this->orgvars[$this->frameNumber]["transparent_color_index"] = $this->frameSources[$this->frameNumber]["transparent_color_index"];
$this->orgvars[$this->frameNumber]["delay_time"] = $this->frameSources[$this->frameNumber]["delay_time"];
$this->orgvars[$this->frameNumber]["disposal_method"] = $this->frameSources[$this->frameNumber]["disposal_method"];
$this->orgvars[$this->frameNumber]["offset_left"] = $this->frameSources[$this->frameNumber]["offset_left"];
$this->orgvars[$this->frameNumber]["offset_top"] = $this->frameSources[$this->frameNumber]["offset_top"];
// Updating the max width
if ($this->gifMaxWidth < $this->frameSources[$this->frameNumber]["width"]) {
$this->gifMaxWidth = $this->frameSources[$this->frameNumber]["width"];
}
// Updating the max height
if ($this->gifMaxHeight < $this->frameSources[$this->frameNumber]["height"]) {
$this->gifMaxHeight = $this->frameSources[$this->frameNumber]["height"];
}
}
/**
* Get the image data byte (old: get_imagedata_byte)
*
* @param string $type
* @param integer $start
* @param integer $length
*
* @return string
*/
private function getImageDataByte($type, $start, $length)
{
if ($type == "ext") {
return substr($this->frameSources[$this->frameNumber]["graphicsextension"], $start, $length);
}
// "dat"
return substr($this->frameSources[$this->frameNumber]["imagedata"], $start, $length);
}
/**
* Get the image data bit (old: get_imagedata_bit)
*
* @param string $type
* @param integer $byteIndex
* @param integer $bitStart
* @param integer $bitLength
*
* @return number
*/
private function getImageDataBit($type, $byteIndex, $bitStart, $bitLength)
{
if ($type == "ext") {
return $this->readBits(ord(substr($this->frameSources[$this->frameNumber]["graphicsextension"], $byteIndex, 1)), $bitStart, $bitLength);
}
// "dat"
return $this->readBits(ord(substr($this->frameSources[$this->frameNumber]["imagedata"], $byteIndex, 1)), $bitStart, $bitLength);
}
/**
* Return the value of 2 ASCII chars (old: dualbyteval)
*
* @param string $s
*
* @return integer
*/
private function dualByteVal($s)
{
$i = (ord($s[1]) * 256 + ord($s[0]));
return $i;
}
/**
* Read the data stream (old: read_data_stream)
*
* @param integer $firstLength
*/
private function readDataStream($firstLength)
{
$this->pointerForward($firstLength);
$length = $this->readByteInt();
if ($length != 0) {
while ($length != 0) {
$this->pointerForward($length);
$length = $this->readByteInt();
}
}
}
/**
* Open the gif file (old: loadfile)
*
* @param string $filename
*/
private function openFile($filename)
{
$this->handle = fopen($filename, "rb");
$this->pointer = 0;
$imageSize = getimagesize($filename);
$this->gifWidth = $imageSize[0];
$this->gifHeight = $imageSize[1];
}
/**
* Close the read gif file (old: closefile)
*/
private function closeFile()
{
fclose($this->handle);
$this->handle = 0;
}
/**
* Read the file from the beginning to $byteCount in binary (old: readbyte)
*
* @param integer $byteCount
*
* @return string
*/
private function readByte($byteCount)
{
$data = fread($this->handle, $byteCount);
$this->pointer += $byteCount;
return $data;
}
/**
* Read a byte and return ASCII value (old: readbyte_int)
*
* @return integer
*/
private function readByteInt()
{
$data = fread($this->handle, 1);
$this->pointer++;
return ord($data);
}
/**
* Convert a $byte to decimal (old: readbits)
*
* @param string $byte
* @param integer $start
* @param integer $length
*
* @return number
*/
private function readBits($byte, $start, $length)
{
$bin = str_pad(decbin($byte), 8, "0", STR_PAD_LEFT);
$data = substr($bin, $start, $length);
return bindec($data);
}
/**
* Rewind the file pointer reader (old: p_rewind)
*
* @param integer $length
*/
private function pointerRewind($length)
{
$this->pointer -= $length;
fseek($this->handle, $this->pointer);
}
/**
* Forward the file pointer reader (old: p_forward)
*
* @param integer $length
*/
private function pointerForward($length)
{
$this->pointer += $length;
fseek($this->handle, $this->pointer);
}
/**
* Get a section of the data from $start to $start + $length (old: datapart)
*
* @param integer $start
* @param integer $length
*
* @return string
*/
private function dataPart($start, $length)
{
fseek($this->handle, $start);
$data = fread($this->handle, $length);
fseek($this->handle, $this->pointer);
return $data;
}
/**
* Check if a character if a byte (old: checkbyte)
*
* @param integer $byte
*
* @return boolean
*/
private function checkByte($byte)
{
if (fgetc($this->handle) == chr($byte)) {
fseek($this->handle, $this->pointer);
return true;
}
fseek($this->handle, $this->pointer);
return false;
}
/**
* Check the end of the file (old: checkEOF)
*
* @return boolean
*/
private function checkEOF()
{
if (fgetc($this->handle) === false) {
return true;
}
fseek($this->handle, $this->pointer);
return false;
}
/**
* Reset and clear this current object
*/
private function reset()
{
$this->gif = null;
$this->totalDuration = $this->gifMaxHeight = $this->gifMaxWidth = $this->handle = $this->pointer = $this->frameNumber = 0;
$this->frameDimensions = $this->framePositions = $this->frameImages = $this->frameDurations = $this->globaldata = $this->orgvars = $this->frames = $this->fileHeader = $this->frameSources = [];
}
// Getter / Setter
// ===================================================================================
/**
* Get the total of all added frame duration
*
* @return integer
*/
public function getTotalDuration()
{
return $this->totalDuration;
}
/**
* Get the number of extracted frames
*
* @return integer
*/
public function getFrameNumber()
{
return $this->frameNumber;
}
/**
* Get the extracted frames (images and durations)
*
* @return array
*/
public function getFrames()
{
return $this->frames;
}
/**
* Get the extracted frame positions
*
* @return array
*/
public function getFramePositions()
{
return $this->framePositions;
}
/**
* Get the extracted frame dimensions
*
* @return array
*/
public function getFrameDimensions()
{
return $this->frameDimensions;
}
/**
* Get the extracted frame images
*
* @return array
*/
public function getFrameImages()
{
return $this->frameImages;
}
/**
* Get the extracted frame durations
*
* @return array
*/
public function getFrameDurations()
{
return $this->frameDurations;
}
}

View File

@@ -1,9 +0,0 @@
<?php
namespace App\Services;
use ImageIntervention;
class ImageService
{
}

314
package-lock.json generated
View File

@@ -1,20 +1,13 @@
{ {
"name": "Website", "name": "website",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-brands-svg-icons": "^6.2.1",
"@fortawesome/free-regular-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1",
"@fortawesome/vue-fontawesome": "^3.0.2",
"@vitejs/plugin-vue": "^4.0.0", "@vitejs/plugin-vue": "^4.0.0",
"@vuepic/vue-datepicker": "^3.6.4", "@vuepic/vue-datepicker": "^3.6.4",
"date-fns": "^2.29.3",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"element-plus": "^2.2.27",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"pinia": "^2.0.28", "pinia": "^2.0.28",
"pinia-plugin-persistedstate": "^3.0.1", "pinia-plugin-persistedstate": "^3.0.1",
@@ -32,7 +25,6 @@
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/eslint-plugin": "^5.48.1",
"@typescript-eslint/parser": "^5.48.1", "@typescript-eslint/parser": "^5.48.1",
"axios": "^1.1.2",
"eslint": "^8.31.0", "eslint": "^8.31.0",
"eslint-config-prettier": "^8.6.0", "eslint-config-prettier": "^8.6.0",
"eslint-plugin-jsdoc": "^39.6.4", "eslint-plugin-jsdoc": "^39.6.4",
@@ -56,22 +48,6 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@ctrl/tinycolor": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.5.0.tgz",
"integrity": "sha512-tlJpwF40DEQcfR/QF+wNMVyGMaO9FQp6Z1Wahj4Gk3CJQYHwA2xVG7iKDFdW6zuxZY9XWOpGcfNCTsX4McOsOg==",
"engines": {
"node": ">=10"
}
},
"node_modules/@element-plus/icons-vue": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.0.10.tgz",
"integrity": "sha512-ygEZ1mwPjcPo/OulhzLE7mtDrQBWI8vZzEWSNB2W/RNCRjoQGwbaK4N8lV4rid7Ts4qvySU3njMN7YCiSlSaTQ==",
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/@es-joy/jsdoccomment": { "node_modules/@es-joy/jsdoccomment": {
"version": "0.36.1", "version": "0.36.1",
"resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.36.1.tgz", "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.36.1.tgz",
@@ -439,85 +415,6 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.1.1.tgz",
"integrity": "sha512-PL7g3dhA4dHgZfujkuD8Q+tfJJynEtnNQSPzmucCnxMvkxf4cLBJw/ZYqZUn4HCh33U3WHrAfv2R2tbi9UCSmw=="
},
"node_modules/@floating-ui/dom": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.1.1.tgz",
"integrity": "sha512-TpIO93+DIujg3g7SykEAGZMDtbJRrmnYRCNYSjJlvIbGhBjRSNTLVbNeDQBrzy9qDgUbiWdc7KA0uZHZ2tJmiw==",
"dependencies": {
"@floating-ui/core": "^1.1.0"
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.2.1.tgz",
"integrity": "sha512-Sz07mnQrTekFWLz5BMjOzHl/+NooTdW8F8kDQxjWwbpOJcnoSg4vUDng8d/WR1wOxM0O+CY9Zw0nR054riNYtQ==",
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.2.1.tgz",
"integrity": "sha512-HELwwbCz6C1XEcjzyT1Jugmz2NNklMrSPjZOWMlc+ZsHIVk+XOvOXLGGQtFBwSyqfJDNgRq4xBCwWOaZ/d9DEA==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.2.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.2.1.tgz",
"integrity": "sha512-L8l4MfdHPmZlJ72PvzdfwOwbwcCAL0vx48tJRnI6u1PJXh+j2f3yDoKyQgO3qjEsgD5Fr2tQV/cPP8F/k6aUig==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.2.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.2.1.tgz",
"integrity": "sha512-wiqcNDNom75x+pe88FclpKz7aOSqS2lOivZeicMV5KRwOAeypxEYWAK/0v+7r+LrEY30+qzh8r2XDaEHvoLsMA==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.2.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.2.1.tgz",
"integrity": "sha512-oKuqrP5jbfEPJWTij4sM+/RvgX+RMFwx3QZCZcK9PrBDgxC35zuc7AOFsyMjMd/PIFPeB2JxyqDr5zs/DZFPPw==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.2.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/vue-fontawesome": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.0.3.tgz",
"integrity": "sha512-KCPHi9QemVXGMrfuwf3nNnNo129resAIQWut9QTAMXmXqL2ErABC6ohd2yY5Ipq0CLWNbKHk8TMdTXL/Zf3ZhA==",
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~1 || ~6",
"vue": ">= 3.0.0 < 4"
}
},
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.11.8", "version": "0.11.8",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
@@ -644,16 +541,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.7",
"resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz",
"integrity": "sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@types/eslint": { "node_modules/@types/eslint": {
"version": "8.21.0", "version": "8.21.0",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.21.0.tgz", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.21.0.tgz",
@@ -685,19 +572,6 @@
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
"integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==" "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ=="
}, },
"node_modules/@types/lodash": {
"version": "4.14.191",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz",
"integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ=="
},
"node_modules/@types/lodash-es": {
"version": "4.17.6",
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.6.tgz",
"integrity": "sha512-R+zTeVUKDdfoRxpAryaQNRKk3105Rrgx2CFRClIgRGaqDTdjsm8h6IYA8ir584W3ePzkZfst5xIgDwYrlh9HLg==",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "18.11.18", "version": "18.11.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz",
@@ -710,11 +584,6 @@
"integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==", "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==",
"dev": true "dev": true
}, },
"node_modules/@types/web-bluetooth": {
"version": "0.0.16",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
"integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ=="
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "5.50.0", "version": "5.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.50.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.50.0.tgz",
@@ -1037,39 +906,6 @@
"vue": ">=3.2.0" "vue": ">=3.2.0"
} }
}, },
"node_modules/@vueuse/core": {
"version": "9.12.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-9.12.0.tgz",
"integrity": "sha512-h/Di8Bvf6xRcvS/PvUVheiMYYz3U0tH3X25YxONSaAUBa841ayMwxkuzx/DGUMCW/wHWzD8tRy2zYmOC36r4sg==",
"dependencies": {
"@types/web-bluetooth": "^0.0.16",
"@vueuse/metadata": "9.12.0",
"@vueuse/shared": "9.12.0",
"vue-demi": "*"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/metadata": {
"version": "9.12.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-9.12.0.tgz",
"integrity": "sha512-9oJ9MM9lFLlmvxXUqsR1wLt1uF7EVbP5iYaHJYqk+G2PbMjY6EXvZeTjbdO89HgoF5cI6z49o2zT/jD9SVoNpQ==",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "9.12.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.12.0.tgz",
"integrity": "sha512-TWuJLACQ0BVithVTRbex4Wf1a1VaRuSpVeyEd4vMUWl54PzlE0ciFUshKCXnlLuD0lxIaLK4Ypj3NXYzZh4+SQ==",
"dependencies": {
"vue-demi": "*"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@webassemblyjs/ast": { "node_modules/@webassemblyjs/ast": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
@@ -1331,28 +1167,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg=="
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
},
"node_modules/axios": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.3.1.tgz",
"integrity": "sha512-78pWJsQTceInlyaeBQeYZ/QgZeWS8hGeKiIJiDKQe3hEyBb7sEMq0K4gjx+Va6WHTYO4zI/RRl8qGRzn0YMadA==",
"dev": true,
"dependencies": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1538,18 +1352,6 @@
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": { "node_modules/commander": {
"version": "2.20.3", "version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@@ -1622,11 +1424,6 @@
"date-fns": ">=2.0.0" "date-fns": ">=2.0.0"
} }
}, },
"node_modules/dayjs": {
"version": "1.11.7",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz",
"integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ=="
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.4", "version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -1650,15 +1447,6 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true "dev": true
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dir-glob": { "node_modules/dir-glob": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -1702,31 +1490,6 @@
"integrity": "sha512-47o4PPgxfU1KMNejz+Dgaodf7YTcg48uOfV1oM6cs3adrl2+7R+dHkt3Jpxqo0LRCbGJEzTKMUt0RdvByb/leg==", "integrity": "sha512-47o4PPgxfU1KMNejz+Dgaodf7YTcg48uOfV1oM6cs3adrl2+7R+dHkt3Jpxqo0LRCbGJEzTKMUt0RdvByb/leg==",
"peer": true "peer": true
}, },
"node_modules/element-plus": {
"version": "2.2.29",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.2.29.tgz",
"integrity": "sha512-g4dcrURrKkR5uUX8n5RVnnqGnimoki9HfqS4yHHG6XwCHBkZGozdq4x+478BzeWUe31h++BO+7dakSx4VnM8RQ==",
"dependencies": {
"@ctrl/tinycolor": "^3.4.1",
"@element-plus/icons-vue": "^2.0.6",
"@floating-ui/dom": "^1.0.1",
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7",
"@types/lodash": "^4.14.182",
"@types/lodash-es": "^4.17.6",
"@vueuse/core": "^9.1.0",
"async-validator": "^4.2.5",
"dayjs": "^1.11.3",
"escape-html": "^1.0.3",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"lodash-unified": "^1.0.2",
"memoize-one": "^6.0.0",
"normalize-wheel-es": "^1.2.0"
},
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/emojis-list": { "node_modules/emojis-list": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz",
@@ -1799,11 +1562,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
},
"node_modules/escape-string-regexp": { "node_modules/escape-string-regexp": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -2195,40 +1953,6 @@
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
"dev": true "dev": true
}, },
"node_modules/follow-redirects": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dev": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fs.realpath": { "node_modules/fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -2638,22 +2362,8 @@
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
}, "dev": true
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
},
"node_modules/lodash-unified": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz",
"integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
"peerDependencies": {
"@types/lodash-es": "*",
"lodash": "*",
"lodash-es": "*"
}
}, },
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
@@ -2681,11 +2391,6 @@
"sourcemap-codec": "^1.4.8" "sourcemap-codec": "^1.4.8"
} }
}, },
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
},
"node_modules/merge-stream": { "node_modules/merge-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -2718,6 +2423,7 @@
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"peer": true,
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -2726,6 +2432,7 @@
"version": "2.1.35", "version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"peer": true,
"dependencies": { "dependencies": {
"mime-db": "1.52.0" "mime-db": "1.52.0"
}, },
@@ -2794,11 +2501,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/normalize-wheel-es": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw=="
},
"node_modules/normalize.css": { "node_modules/normalize.css": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/normalize.css/-/normalize.css-8.0.1.tgz", "resolved": "https://registry.npmjs.org/normalize.css/-/normalize.css-8.0.1.tgz",
@@ -3034,12 +2736,6 @@
"url": "https://github.com/prettier/prettier?sponsor=1" "url": "https://github.com/prettier/prettier?sponsor=1"
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz",

View File

@@ -9,7 +9,6 @@
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/eslint-plugin": "^5.48.1",
"@typescript-eslint/parser": "^5.48.1", "@typescript-eslint/parser": "^5.48.1",
"axios": "^1.1.2",
"eslint": "^8.31.0", "eslint": "^8.31.0",
"eslint-config-prettier": "^8.6.0", "eslint-config-prettier": "^8.6.0",
"eslint-plugin-jsdoc": "^39.6.4", "eslint-plugin-jsdoc": "^39.6.4",
@@ -22,16 +21,9 @@
"vite": "^4.0.0" "vite": "^4.0.0"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-brands-svg-icons": "^6.2.1",
"@fortawesome/free-regular-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1",
"@fortawesome/vue-fontawesome": "^3.0.2",
"@vitejs/plugin-vue": "^4.0.0", "@vitejs/plugin-vue": "^4.0.0",
"@vuepic/vue-datepicker": "^3.6.4", "@vuepic/vue-datepicker": "^3.6.4",
"date-fns": "^2.29.3",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"element-plus": "^2.2.27",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"pinia": "^2.0.28", "pinia": "^2.0.28",
"pinia-plugin-persistedstate": "^3.0.1", "pinia-plugin-persistedstate": "^3.0.1",

BIN
public/img/background.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -43,50 +43,31 @@ h1 {
font-weight: 800; font-weight: 800;
} }
label {
display: block;
margin-bottom: map-get($spacer, 1);
&.required:after {
content: " *";
color: $danger-color;
}
&.inline {
display: inline-block;
margin-right: map-get($spacer, 3);
&:after {
content: ":";
}
}
}
p { p {
line-height: 1.5rem; line-height: 1.5rem;
margin-top: 0; margin-top: 0;
} }
input, // input,
select, // select,
textarea { // textarea {
box-sizing: border-box; // box-sizing: border-box;
display: block; // display: block;
width: 100%; // width: 100%;
border: 1px solid $border-color; // border: 1px solid $border-color;
border-radius: 12px; // border-radius: 12px;
padding: map-get($spacer, 2) map-get($spacer, 3); // padding: map-get($spacer, 2) map-get($spacer, 3);
color: $font-color; // color: $font-color;
margin-bottom: map-get($spacer, 4); // margin-bottom: map-get($spacer, 4);
-webkit-appearance: none; // -webkit-appearance: none;
-moz-appearance: none; // -moz-appearance: none;
appearance: none; // appearance: none;
} // }
textarea { // textarea {
resize: none; // resize: none;
} // }
select { select {
padding-right: 2.5rem; padding-right: 2.5rem;
@@ -178,39 +159,6 @@ code {
} }
} }
/* Loader */
.loader-cover {
position: fixed;
display: flex;
justify-content: center;
align-items: center;
top: 0;
left: 0;
bottom: 0;
right: 0;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(4px);
background-color: rgba(255, 255, 255, 0.5);
.loader {
display: flex;
flex-direction: column;
padding: map-get($spacer, 5) calc(map-get($spacer, 5) * 2);
border: 1px solid transparent;
border-radius: 24px;
svg {
font-size: calc(map-get($spacer, 5) * 1.5);
}
span {
font-size: map-get($spacer, 4);
padding-top: map-get($spacer, 3);
}
}
}
/* Button */ /* Button */
button.button, button.button,
a.button, a.button,
@@ -301,53 +249,6 @@ label.button {
} }
} }
/* Form Group */
.form-group {
margin-bottom: map-get($spacer, 3);
padding: 0 4px;
flex: 1;
input,
textarea {
margin-bottom: map-get($spacer, 1);
}
.form-group-info {
font-size: 85%;
margin-bottom: map-get($spacer, 1);
}
.form-group-error {
// display: none;
font-size: 85%;
margin-bottom: map-get($spacer, 1);
color: $danger-color;
}
.form-group-help {
font-size: 85%;
margin-bottom: map-get($spacer, 1);
color: $secondary-color;
svg {
vertical-align: middle !important;
}
}
&.has-error {
input,
textarea,
.input-file-group,
.input-media-group .input-media-display {
border: 2px solid $danger-color;
}
.form-group-error {
display: block;
}
}
}
/* Page Errors */ /* Page Errors */
.page-error { .page-error {
display: flex; display: flex;

View File

@@ -125,33 +125,33 @@
/* Margin */ /* Margin */
@each $index, $size in $spacer { @each $index, $size in $spacer {
.m-#{$index} { .m-#{$index} {
margin: #{$size}; margin: #{$size} !important;
} }
.mt-#{$index} { .mt-#{$index} {
margin-top: #{$size}; margin-top: #{$size} !important;
} }
.mb-#{$index} { .mb-#{$index} {
margin-bottom: #{$size}; margin-bottom: #{$size} !important;
} }
.ml-#{$index} { .ml-#{$index} {
margin-left: #{$size}; margin-left: #{$size} !important;
} }
.mr-#{$index} { .mr-#{$index} {
margin-right: #{$size}; margin-right: #{$size} !important;
} }
.mx-#{$index} { .mx-#{$index} {
margin-left: #{$size}; margin-left: #{$size} !important;
margin-right: #{$size}; margin-right: #{$size} !important;
} }
.my-#{$index} { .my-#{$index} {
margin-top: #{$size}; margin-top: #{$size} !important;
margin-bottom: #{$size}; margin-bottom: #{$size} !important;
} }
} }

View File

@@ -1,4 +1,4 @@
import _ from 'lodash'; import _ from "lodash";
window._ = _; window._ = _;
/** /**
@@ -7,10 +7,10 @@ window._ = _;
* CSRF token as a header based on the value of the "XSRF" token cookie. * CSRF token as a header based on the value of the "XSRF" token cookie.
*/ */
import axios from 'axios'; // import axios from 'axios';
window.axios = axios; // window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; // window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
/** /**
* Echo exposes an expressive API for subscribing to channels and listening * Echo exposes an expressive API for subscribing to channels and listening

View File

@@ -11,7 +11,7 @@
]" ]"
:type="buttonType"> :type="buttonType">
{{ label }} {{ label }}
<font-awesome-icon v-if="icon" :icon="icon" /> <ion-icon v-if="icon" :icon="icon" />
</a> </a>
<button <button
v-else-if="to == null" v-else-if="to == null"
@@ -24,7 +24,7 @@
]" ]"
:type="buttonType"> :type="buttonType">
{{ label }} {{ label }}
<font-awesome-icon v-if="icon" :icon="icon" /> <ion-icon v-if="icon" :icon="icon" />
</button> </button>
<router-link <router-link
v-else v-else
@@ -37,7 +37,7 @@
{ 'button-block': block }, { 'button-block': block },
]"> ]">
{{ label }} {{ label }}
<font-awesome-icon v-if="icon" :icon="icon" /> <ion-icon v-if="icon" :icon="icon" />
</router-link> </router-link>
</template> </template>
@@ -83,5 +83,11 @@ const classType = props.type == "submit" ? "primary" : props.type;
display: block; display: block;
width: 100%; width: 100%;
} }
ion-icon {
height: 1.2rem;
width: 1.2rem;
vertical-align: middle;
}
} }
</style> </style>

View File

@@ -7,22 +7,20 @@
<slot></slot> <slot></slot>
</div> </div>
<div class="carousel-slide-prev" @click="handleSlidePrev"> <div class="carousel-slide-prev" @click="handleSlidePrev">
<font-awesome-icon icon="fa-solid fa-chevron-left" /> <ion-icon name="chevron-back-outline" />
</div> </div>
<div class="carousel-slide-next" @click="handleSlideNext"> <div class="carousel-slide-next" @click="handleSlideNext">
<font-awesome-icon icon="fa-solid fa-chevron-right" /> <ion-icon name="chevron-forward-outline" />
</div> </div>
<div class="carousel-slide-indicators"> <div class="carousel-slide-indicators">
<div <div
v-for="(indicator, index) in slideElements" v-for="(indicator, index) in slideElements"
:key="index" :key="index"
class="carousel-slide-indicator-dot"> :class="[
<font-awesome-icon 'carousel-slide-indicator-item',
v-if="currentSlide != index" { highlighted: currentSlide == index },
icon="fa-regular fa-circle" ]"
@click="handleIndicator(index)" /> @click="handleIndicator(index)"></div>
<font-awesome-icon v-else icon="fa-solid fa-circle" />
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -184,12 +182,20 @@ const disconnectMutationObserver = () => {
opacity: 0.75; opacity: 0.75;
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
svg { .carousel-slide-indicator-item {
height: map-get($spacer, 1);
width: map-get($spacer, 1);
border: 1px solid white;
border-radius: 50%;
cursor: pointer; cursor: pointer;
font-size: 80%; font-size: 80%;
padding: 0 0.25rem; margin: 0 calc(#{map-get($spacer, 1)} / 3);
color: #fff; color: #fff;
filter: drop-shadow(0px 0px 2px rgba(0, 0, 0, 1)); filter: drop-shadow(0px 0px 2px rgba(0, 0, 0, 1));
&.highlighted {
background-color: white;
}
} }
} }
} }

View File

@@ -3,7 +3,7 @@
class="carousel-slide" class="carousel-slide"
:style="{ backgroundImage: `url('${imageUrl}')` }"> :style="{ backgroundImage: `url('${imageUrl}')` }">
<div v-if="imageUrl == null" class="carousel-slide-loading"> <div v-if="imageUrl == null" class="carousel-slide-loading">
<font-awesome-icon icon="fa-solid fa-spinner" pulse /> <SMLoadingIcon />
</div> </div>
<div v-else class="carousel-slide-body"> <div v-else class="carousel-slide-body">
<div class="carousel-slide-content"> <div class="carousel-slide-content">
@@ -20,9 +20,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import axios from "axios";
import { ref } from "vue"; import { ref } from "vue";
import { api } from "../helpers/api";
import SMButton from "./SMButton.vue"; import SMButton from "./SMButton.vue";
import SMLoadingIcon from "./SMLoadingIcon.vue";
const props = defineProps({ const props = defineProps({
title: { title: {
@@ -56,9 +57,9 @@ let imageUrl = ref(null);
const handleLoad = async () => { const handleLoad = async () => {
try { try {
let result = await axios.get(`media/${props.image}`); let result = await api.get(`/media/${props.image}`);
if (result.data.medium) { if (result.json.medium) {
imageUrl.value = result.data.medium.url; imageUrl.value = result.json.medium.url;
} }
} catch (error) { } catch (error) {
imageUrl.value = ""; imageUrl.value = "";

View File

@@ -1,5 +1,5 @@
<template> <template>
<div :class="['container', { full: isFull }]"> <div :class="['container', { full: isFull }]" :style="styleObject">
<SMLoader :loading="loading"> <SMLoader :loading="loading">
<d-error-forbidden <d-error-forbidden
v-if="pageError == 403 || !hasPermission()"></d-error-forbidden> v-if="pageError == 403 || !hasPermission()"></d-error-forbidden>
@@ -50,9 +50,19 @@ const props = defineProps({
default: false, default: false,
required: false, required: false,
}, },
background: {
type: String,
default: "",
required: false,
},
}); });
const slots = useSlots(); const slots = useSlots();
const userStore = useUserStore(); const userStore = useUserStore();
let styleObject = {};
if (props.background != "") {
styleObject["backgroundImage"] = `url('${props.background}')`;
}
const hasPermission = () => { const hasPermission = () => {
return ( return (
@@ -76,6 +86,9 @@ const isFull = computed(() => {
width: 100%; width: 100%;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
&.full { &.full {
padding-left: 0; padding-left: 0;

View File

@@ -20,7 +20,7 @@
<slot></slot> <slot></slot>
</div> </div>
<div v-if="help" class="form-group-help"> <div v-if="help" class="form-group-help">
<font-awesome-icon v-if="helpIcon" :icon="helpIcon" /> <!-- <font-awesome-icon v-if="helpIcon" :icon="helpIcon" /> -->
{{ help }} {{ help }}
</div> </div>
</div> </div>

View File

@@ -8,7 +8,7 @@
<transition name="fade"> <transition name="fade">
<div v-if="loading" class="dialog-loading-cover"> <div v-if="loading" class="dialog-loading-cover">
<div class="dialog-loading"> <div class="dialog-loading">
<font-awesome-icon icon="fa-solid fa-spinner" pulse /> <SMLoadingIcon />
<span>{{ loadingMessage }}</span> <span>{{ loadingMessage }}</span>
</div> </div>
</div> </div>
@@ -18,6 +18,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import SMLoadingIcon from "./SMLoadingIcon.vue";
defineProps({ defineProps({
loading: { loading: {
type: Boolean, type: Boolean,
@@ -50,6 +52,7 @@ defineProps({
border-radius: 24px; border-radius: 24px;
overflow: hidden; overflow: hidden;
min-width: map-get($spacer, 5) * 12; min-width: map-get($spacer, 5) * 12;
box-shadow: 4px 4px 20px rgba(0, 0, 0, 0.5);
& > h1 { & > h1 {
margin-top: 0; margin-top: 0;

View File

@@ -5,33 +5,33 @@
<ul> <ul>
<li> <li>
<a href="https://facebook.com/stemmechanics" <a href="https://facebook.com/stemmechanics"
><font-awesome-icon icon="fa-brands fa-facebook" ><ion-icon name="logo-facebook"></ion-icon
/></a> ></a>
</li> </li>
<li> <li>
<a href="https://mastodon.au/@stemmechanics" <a href="https://mastodon.au/@stemmechanics"
><font-awesome-icon icon="fa-brands fa-mastodon" ><ion-icon name="logo-mastodon"></ion-icon
/></a> ></a>
</li> </li>
<li> <li>
<a href="https://www.youtube.com/@stemmechanics" <a href="https://www.youtube.com/@stemmechanics"
><font-awesome-icon icon="fa-brands fa-youtube" ><ion-icon name="logo-youtube"></ion-icon
/></a> ></a>
</li> </li>
<li> <li>
<a href="https://twitter.com/stemmechanics" <a href="https://twitter.com/stemmechanics"
><font-awesome-icon icon="fa-brands fa-twitter" ><ion-icon name="logo-twitter"></ion-icon
/></a> ></a>
</li> </li>
<li> <li>
<a href="https://github.com/stemmechanics" <a href="https://github.com/stemmechanics"
><font-awesome-icon icon="fa-brands fa-github" ><ion-icon name="logo-github"></ion-icon
/></a> ></a>
</li> </li>
<li> <li>
<a href="https://discord.gg/yNzk4x7mpD" <a href="https://discord.gg/yNzk4x7mpD"
><font-awesome-icon icon="fa-brands fa-discord" ><ion-icon name="logo-discord"></ion-icon
/></a> ></a>
</li> </li>
</ul> </ul>
</SMColumn> </SMColumn>

View File

@@ -0,0 +1,36 @@
<template>
<form @submit.prevent="handleSubmit">
<SMLoader :loading="props.modelValue._loading"></SMLoader>
<SMMessage
v-if="props.modelValue._message.length > 0"
:message="props.modelValue._message"
:type="props.modelValue._messageType"
:icon="props.modelValue._messageIcon" />
<slot></slot>
</form>
</template>
<script setup lang="ts">
import { provide } from "vue";
import SMLoader from "../components/SMLoader.vue";
import SMMessage from "./SMMessage.vue";
const props = defineProps({
modelValue: {
type: Object,
required: true,
},
});
const emits = defineEmits(["submit"]);
const handleSubmit = function () {
if (props.modelValue.validate()) {
emits("submit");
}
};
provide("form", props.modelValue);
</script>
<style lang="scss"></style>

View File

@@ -1,26 +1,25 @@
<template> <template>
<div class="form-footer"> <div class="sm-form-footer">
<div class="form-footer-column form-footer-column-left"> <div class="sm-form-footer-column sm-form-footer-column-left">
<slot name="left"></slot> <slot name="left"></slot>
</div> </div>
<div class="form-footer-column form-footer-column-right"> <div class="sm-form-footer-column sm-form-footer-column-right">
<slot name="right"></slot> <slot name="right"></slot>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss"> <style lang="scss">
.form-footer { .sm-form-footer {
display: flex; display: flex;
flex: 1; flex: 1;
// margin-bottom: map-get($spacer, 3);
.form-footer-column { .sm-form-footer-column {
display: flex; display: flex;
align-items: center; align-items: center;
&.form-footer-column-left, &.sm-form-footer-column-left,
&.form-footer-column-right { &.sm-form-footer-column-right {
a, a,
button { button {
margin-left: map-get($spacer, 1); margin-left: map-get($spacer, 1);
@@ -36,7 +35,7 @@
} }
} }
&.form-footer-column-right { &.sm-form-footer-column-right {
flex: 1; flex: 1;
justify-content: flex-end; justify-content: flex-end;
} }
@@ -44,12 +43,12 @@
} }
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.form-footer { .sm-form-footer {
flex-direction: column-reverse; flex-direction: column-reverse;
.form-footer-column { .sm-form-footer-column {
&.form-footer-column-left, &.sm-form-footer-column-left,
&.form-footer-column-right { &.sm-form-footer-column-right {
display: flex; display: flex;
flex-direction: column-reverse; flex-direction: column-reverse;
justify-content: center; justify-content: center;
@@ -66,11 +65,11 @@
} }
} }
&.form-footer-column-left { &.sm-form-footer-column-left {
margin-bottom: -#{calc(map-get($spacer, 1) / 2)}; margin-bottom: -#{calc(map-get($spacer, 1) / 2)};
} }
&.form-footer-column-right { &.sm-form-footer-column-right {
margin-top: -#{calc(map-get($spacer, 1) / 2)}; margin-top: -#{calc(map-get($spacer, 1) / 2)};
} }
} }

View File

@@ -4,13 +4,13 @@
v-if="back != ''" v-if="back != ''"
:to="{ name: back }" :to="{ name: back }"
class="heading-back"> class="heading-back">
<font-awesome-icon icon="fa-solid fa-arrow-left" />{{ backLabel }} <ion-icon name="arrow-back-outline" />{{ backLabel }}
</router-link> </router-link>
<router-link v-if="close != ''" :to="{ name: close }" class="close"> <router-link v-if="close != ''" :to="{ name: close }" class="close">
<font-awesome-icon icon="fa-solid fa-close" /> <ion-icon name="close-outline" />
</router-link> </router-link>
<span v-if="closeBack" class="close" @click="handleBack"> <span v-if="closeBack" class="close" @click="handleBack">
<font-awesome-icon icon="fa-solid fa-close" /> <ion-icon name="close-outline" />
</span> </span>
<h1>{{ heading }}</h1> <h1>{{ heading }}</h1>
</div> </div>

View File

@@ -1,29 +1,39 @@
<template> <template>
<div :class="['form-group', { 'has-error': error }]"> <div
<label v-if="label" :class="{ required: required, inline: inline }">{{ :class="[
label 'sm-input-group',
}}</label> {
'sm-input-active': inputActive,
'sm-feedback-invalid': feedbackInvalid,
},
]">
<label v-if="label">{{ label }}</label>
<ion-icon
class="sm-invalid-icon"
name="alert-circle-outline"></ion-icon>
<input <input
v-if=" v-if="
type == 'text' || type == 'text' ||
type == 'email' ||
type == 'password' || type == 'password' ||
type == 'email' || type == 'email' ||
type == 'url' type == 'url'
" "
:type="type" :type="type"
:value="modelValue"
:placeholder="placeholder" :placeholder="placeholder"
@input="input" :value="value"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur" @blur="handleBlur"
@keydown="handleBlur" /> @keydown="handleKeydown" />
<textarea <textarea
v-if="type == 'textarea'" v-if="type == 'textarea'"
rows="5" rows="5"
:value="modelValue" :value="value"
:placeholder="placeholder" @input="handleInput"
@input="input" @focus="handleFocus"
@blur="handleBlur" @blur="handleBlur"
@keydown="handleBlur"></textarea> @keydown="handleKeydown"></textarea>
<div v-if="type == 'file'" class="input-file-group"> <div v-if="type == 'file'" class="input-file-group">
<input <input
id="file" id="file"
@@ -40,37 +50,31 @@
props.modelValue props.modelValue
}}</a> }}</a>
<span v-if="type == 'static'">{{ props.modelValue }}</span> <span v-if="type == 'static'">{{ props.modelValue }}</span>
<div v-if="type == 'media'" class="input-media-group"> <div v-if="slots.default || feedbackInvalid" class="sm-input-help">
<div class="input-media-display"> <span v-if="feedbackInvalid" class="sm-input-invalid">{{
<img v-if="mediaUrl.length > 0" :src="mediaUrl" /> feedbackInvalid
<font-awesome-icon v-else icon="fa-regular fa-image" /> }}</span>
</div> <span v-if="slots.default" class="sm-input-info">
<div v-if="type == 'media'" class="form-group-error"> <slot></slot>
{{ error }} </span>
</div>
<a class="button" @click.prevent="handleMediaSelect">Select file</a>
</div>
<div v-if="type != 'media'" class="form-group-error">{{ error }}</div>
<div v-if="slots.default" class="form-group-info">
<slot></slot>
</div> </div>
<div v-if="help" class="form-group-help"> <div v-if="help" class="form-group-help">
<font-awesome-icon v-if="helpIcon" :icon="helpIcon" /> <ion-icon v-if="helpIcon" name="information-circle-outline" />
{{ help }} {{ help }}
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, useSlots, ref, watch } from "vue"; import { watch, computed, useSlots, ref, inject } from "vue";
import SMDialogMedia from "./dialogs/SMDialogMedia.vue"; import { toTitleCase } from "../helpers/string";
import { openDialog } from "vue3-promise-dialog"; import { isEmpty } from "../helpers/utils";
import axios from "axios";
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: String, type: String,
default: "", default: "",
required: false,
}, },
label: { label: {
type: String, type: String,
@@ -90,7 +94,7 @@ const props = defineProps({
type: String, type: String,
default: "text", default: "text",
}, },
error: { feedbackInvalid: {
type: String, type: String,
default: "", default: "",
}, },
@@ -106,100 +110,215 @@ const props = defineProps({
type: String, type: String,
default: "", default: "",
}, },
href: { control: {
type: String, type: String,
default: "", default: "",
}, },
form: {
type: Object,
default: () => {
return {};
},
required: false,
},
}); });
const emits = defineEmits(["update:modelValue", "blur"]); const emits = defineEmits(["update:modelValue", "focus", "blur", "keydown"]);
const slots = useSlots(); const slots = useSlots();
const mediaUrl = ref("");
const objForm = inject("form", props.form);
const objControl =
!isEmpty(objForm) && props.control != "" ? objForm[props.control] : null;
const label = ref("");
const feedbackInvalid = ref("");
watch(
() => props.label,
(newValue) => {
label.value = newValue;
}
);
const value = ref(props.modelValue);
if (objControl) {
if (value.value.length > 0) {
objControl.value = value.value;
} else {
value.value = objControl.value;
}
if (label.value.length == 0) {
label.value = toTitleCase(props.control);
}
watch(
() => objControl.validation.result.valid,
(newValue) => {
feedbackInvalid.value = newValue
? ""
: objControl.validation.result.invalidMessages[0];
},
{ deep: true }
);
}
watch(
() => props.modelValue,
(newValue) => {
value.value = newValue;
}
);
watch(
() => props.feedbackInvalid,
(newValue) => {
feedbackInvalid.value = newValue;
}
);
const inputActive = ref(value.value.length > 0);
const handleChange = (event) => { const handleChange = (event) => {
emits("update:modelValue", event.target.files[0]); emits("update:modelValue", event.target.files[0]);
};
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement;
value.value = target.value;
emits("update:modelValue", target.value);
if (objControl) {
objControl.value = target.value;
feedbackInvalid.value = "";
}
};
const handleFocus = (event: Event) => {
inputActive.value = true;
if (event instanceof KeyboardEvent) {
if (event.key === undefined || event.key === "Tab") {
emits("blur", event);
}
}
emits("focus", event);
};
const handleBlur = (event: Event) => {
if (objControl) {
objForm.validate(props.control);
feedbackInvalid.value = objForm[props.control].validation.result.valid
? ""
: objForm[props.control].validation.result.invalidMessages[0];
}
const target = event.target as HTMLInputElement;
if (target.value.length == 0) {
inputActive.value = false;
}
emits("blur", event); emits("blur", event);
}; };
const input = (event) => { const handleKeydown = (event: Event) => {
emits("update:modelValue", event.target.value); emits("keydown", event);
};
const handleBlur = (event) => {
if (event.keyCode == undefined || event.keyCode == 9) {
emits("blur", event);
}
};
const handleMediaSelect = async (event) => {
let result = await openDialog(SMDialogMedia);
console.log(result);
if (result) {
mediaUrl.value = result.url;
emits("update:modelValue", result.id);
}
}; };
const inline = computed(() => { const inline = computed(() => {
return ["static", "link"].includes(props.type); return ["static", "link"].includes(props.type);
}); });
const handleLoad = async () => {
if (props.type == "media" && props.modelValue.length > 0) {
try {
let result = await axios.get(`media/${props.modelValue}`);
mediaUrl.value = result.data.medium.url;
} catch (error) {
/* empty */
}
}
};
watch(
() => props.modelValue,
() => {
handleLoad();
}
);
</script> </script>
<style lang="scss"> <style lang="scss">
.input-media-group { .sm-input-group {
position: relative;
display: flex; display: flex;
margin: 0 auto;
max-width: 26rem;
flex-direction: column; flex-direction: column;
align-items: center; margin-bottom: map-get($spacer, 4);
.input-media-display { &.sm-input-active {
display: flex; label {
margin-bottom: 1rem; transform: translate(8px, -3px) scale(0.7);
color: $secondary-color-dark;
}
input {
padding: calc(#{map-get($spacer, 2)} * 1.5) map-get($spacer, 3)
calc(#{map-get($spacer, 2)} / 2) map-get($spacer, 3);
}
textarea {
padding: calc(#{map-get($spacer, 2)} * 2) map-get($spacer, 3)
calc(#{map-get($spacer, 2)} / 2) map-get($spacer, 3);
}
}
&.sm-feedback-invalid {
input,
select,
textarea {
border: 2px solid $danger-color;
}
.sm-invalid-icon {
display: block;
}
}
label {
position: absolute;
display: block;
padding: map-get($spacer, 2) map-get($spacer, 3);
line-height: 1.5;
transform-origin: top left;
transform: translate(0, 1px) scale(1);
transition: all 0.1s ease-in-out;
color: $font-color;
pointer-events: none;
}
.sm-invalid-icon {
position: absolute;
display: none;
right: 0;
top: 2px;
padding: map-get($spacer, 2) map-get($spacer, 3);
color: $danger-color;
font-size: 120%;
}
input,
select,
textarea {
box-sizing: border-box;
display: block;
width: 100%;
border: 1px solid $border-color; border: 1px solid $border-color;
background-color: #fff; border-radius: 12px;
padding: map-get($spacer, 2) map-get($spacer, 3);
color: $font-color;
margin-bottom: map-get($spacer, 1);
img { -webkit-appearance: none;
max-width: 100%; -moz-appearance: none;
max-height: 100%; appearance: none;
}
svg {
padding: 4rem;
}
} }
.button { textarea {
max-width: 13rem; resize: none;
} }
}
.input-media-group + .form-group-error { .sm-input-help {
text-align: center; font-size: 75%;
} margin: 0 map-get($spacer, 1);
@media screen and (max-width: 768px) { .sm-input-invalid {
.input-media-group { color: $danger-color;
max-width: 13rem; padding-right: map-get($spacer, 1);
}
} }
} }
</style> </style>

View File

@@ -1,10 +1,8 @@
<template> <template>
<template v-if="loading"> <template v-if="loading">
<transition name="fade"> <transition name="fade">
<div v-if="loading" class="loader-cover"> <div v-if="loading" class="sm-loader">
<div class="loader"> <SMLoadingIcon />
<font-awesome-icon icon="fa-solid fa-spinner" pulse />
</div>
</div> </div>
</transition> </transition>
</template> </template>
@@ -12,6 +10,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import SMLoadingIcon from "./SMLoadingIcon.vue";
defineProps({ defineProps({
loading: { loading: {
type: Boolean, type: Boolean,
@@ -19,3 +19,20 @@ defineProps({
}, },
}); });
</script> </script>
<style lang="scss">
.sm-loader {
position: fixed;
display: flex;
justify-content: center;
align-items: center;
top: 0;
left: 0;
bottom: 0;
right: 0;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(4px);
background-color: rgba(255, 255, 255, 0.5);
z-index: 10000;
}
</style>

View File

@@ -0,0 +1,66 @@
<template>
<div class="sm-loading-icon">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</template>
<style lang="scss">
.sm-loading-icon {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.sm-loading-icon div {
position: absolute;
top: 33px;
width: 13px;
height: 13px;
border-radius: 50%;
background: #000;
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.sm-loading-icon div:nth-child(1) {
left: 8px;
animation: sm-loading-icon1 0.6s infinite;
}
.sm-loading-icon div:nth-child(2) {
left: 8px;
animation: sm-loading-icon2 0.6s infinite;
}
.sm-loading-icon div:nth-child(3) {
left: 32px;
animation: sm-loading-icon2 0.6s infinite;
}
.sm-loading-icon div:nth-child(4) {
left: 56px;
animation: sm-loading-icon3 0.6s infinite;
}
@keyframes sm-loading-icon1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes sm-loading-icon3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes sm-loading-icon2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
}
}
</style>

View File

@@ -0,0 +1,360 @@
<template>
<div
:class="[
'sm-input-group',
{ 'sm-input-active': inputActive, 'sm-has-error': error },
]">
<label v-if="label" :class="{ required: required, inline: inline }">{{
label
}}</label>
<ion-icon class="sm-error-icon" name="alert-circle-outline"></ion-icon>
<input
v-if="
type == 'text' ||
type == 'email' ||
type == 'password' ||
type == 'email' ||
type == 'url'
"
:type="type"
:value="modelValue"
:placeholder="placeholder"
@input="input"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeydown" />
<textarea
v-if="type == 'textarea'"
rows="5"
:value="modelValue"
:placeholder="placeholder"
@input="input"
@blur="handleBlur"
@keydown="handleBlur"></textarea>
<div v-if="type == 'file'" class="input-file-group">
<input
id="file"
type="file"
class="file"
:accept="props.accept"
@change="handleChange" />
<label class="button" for="file">Select file</label>
<div class="file-name">
{{ modelValue?.name ? modelValue.name : modelValue }}
</div>
</div>
<a v-if="type == 'link'" :href="href" target="_blank">{{
props.modelValue
}}</a>
<span v-if="type == 'static'">{{ props.modelValue }}</span>
<div v-if="type == 'media'" class="input-media-group">
<div class="input-media-display">
<img v-if="mediaUrl.length > 0" :src="mediaUrl" />
<ion-icon v-else name="image-outline" />
</div>
<div v-if="type == 'media'" class="form-group-error">
{{ error }}
</div>
<a class="button" @click.prevent="handleMediaSelect">Select file</a>
</div>
<div v-if="slots.default || error" class="sm-input-help">
<span v-if="type != 'media'" class="sm-input-error">{{
error
}}</span>
<span v-if="slots.default" class="sm-input-info">
<slot></slot>
</span>
</div>
<div v-if="help" class="form-group-help">
<ion-icon v-if="helpIcon" name="information-circle-outline" />
{{ help }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, useSlots, ref, watch } from "vue";
import SMDialogMedia from "./dialogs/SMDialogMedia.vue";
import { openDialog } from "vue3-promise-dialog";
const props = defineProps({
modelValue: {
type: String,
default: "",
},
label: {
type: String,
default: "",
required: false,
},
placeholder: {
type: String,
default: "",
required: false,
},
required: {
type: Boolean,
default: false,
},
type: {
type: String,
default: "text",
},
error: {
type: String,
default: "",
},
help: {
type: String,
default: "",
},
helpIcon: {
type: String,
default: "",
},
accept: {
type: String,
default: "",
},
href: {
type: String,
default: "",
},
});
const emits = defineEmits(["update:modelValue", "blur"]);
const slots = useSlots();
const mediaUrl = ref("");
let inputActive = ref(false);
const handleChange = (event) => {
emits("update:modelValue", event.target.files[0]);
emits("blur", event);
};
const input = (event) => {
emits("update:modelValue", event.target.value);
};
const handleBlur = (event) => {
if (props.modelValue.length == 0) {
inputActive.value = false;
}
if (event.keyCode == undefined || event.keyCode == 9) {
emits("blur", event);
}
};
const handleFocus = (event) => {
inputActive.value = true;
if (event.keyCode == undefined || event.keyCode == 9) {
emits("blur", event);
}
};
const handleKeydown = (event) => {};
const handleMediaSelect = async (event) => {
let result = await openDialog(SMDialogMedia);
if (result) {
mediaUrl.value = result.url;
emits("update:modelValue", result.id);
}
};
const inline = computed(() => {
return ["static", "link"].includes(props.type);
});
const handleLoad = async () => {
if (props.type == "media" && props.modelValue.length > 0) {
try {
let result = await api.get(`/media/${props.modelValue}`);
mediaUrl.value = result.json.medium.url;
} catch (error) {
/* empty */
}
}
};
watch(
() => props.modelValue,
() => {
handleLoad();
}
);
</script>
<style lang="scss">
.sm-input-group {
position: relative;
display: flex;
flex-direction: column;
margin-bottom: map-get($spacer, 4);
&.sm-input-active {
label {
transform: translate(8px, -3px) scale(0.7);
color: $secondary-color-dark;
}
input {
padding: calc(#{map-get($spacer, 2)} * 1.5) map-get($spacer, 3)
calc(#{map-get($spacer, 2)} / 2) map-get($spacer, 3);
}
}
&.sm-has-error {
input,
select,
textarea {
border: 2px solid $danger-color;
}
.sm-error-icon {
display: block;
}
}
label {
position: absolute;
display: block;
padding: map-get($spacer, 2) map-get($spacer, 3);
line-height: 1.5;
transform-origin: top left;
transform: translate(0, 1px) scale(1);
transition: all 0.1s ease-in-out;
color: $font-color;
}
.sm-error-icon {
position: absolute;
display: none;
right: 0;
top: 2px;
padding: map-get($spacer, 2) map-get($spacer, 3);
color: $danger-color;
font-size: 120%;
}
input,
select,
textarea {
box-sizing: border-box;
display: block;
width: 100%;
border: 1px solid $border-color;
border-radius: 12px;
padding: map-get($spacer, 2) map-get($spacer, 3);
color: $font-color;
margin-bottom: map-get($spacer, 1);
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
textarea {
resize: none;
}
.sm-input-help {
font-size: 75%;
margin: 0 map-get($spacer, 1);
.sm-input-error {
color: $danger-color;
padding-right: map-get($spacer, 1);
}
}
}
// .form-group {
// margin-bottom: map-get($spacer, 3);
// padding: 0 4px;
// flex: 1;
// input,
// textarea {
// margin-bottom: map-get($spacer, 1);
// }
// label {
// position: absolute;
// }
// .form-group-info {
// font-size: 85%;
// margin-bottom: map-get($spacer, 1);
// }
// .form-group-error {
// // display: none;
// font-size: 85%;
// margin-bottom: map-get($spacer, 1);
// color: $danger-color;
// }
// .form-group-help {
// font-size: 85%;
// margin-bottom: map-get($spacer, 1);
// color: $secondary-color;
// svg {
// vertical-align: middle !important;
// }
// }
// &.has-error {
// input,
// textarea,
// .input-file-group,
// .input-media-group .input-media-display {
// border: 2px solid $danger-color;
// }
// .form-group-error {
// display: block;
// }
// }
// }
// .input-media-group {
// display: flex;
// margin: 0 auto;
// max-width: 26rem;
// flex-direction: column;
// align-items: center;
// .input-media-display {
// display: flex;
// margin-bottom: 1rem;
// border: 1px solid $border-color;
// background-color: #fff;
// img {
// max-width: 100%;
// max-height: 100%;
// }
// svg {
// padding: 4rem;
// }
// }
// .button {
// max-width: 13rem;
// }
// }
// .input-media-group + .form-group-error {
// text-align: center;
// }
// @media screen and (max-width: 768px) {
// .input-media-group {
// max-width: 13rem;
// }
// }
</style>

View File

@@ -1,6 +1,7 @@
<template> <template>
<div :class="['message', type]"> <div :class="['message', type]">
<font-awesome-icon v-if="icon" :icon="icon" />{{ message }} <ion-icon v-if="icon" :name="icon"></ion-icon>
<p>{{ message }}</p>
</div> </div>
</template> </template>
@@ -23,16 +24,13 @@ defineProps({
<style lang="scss"> <style lang="scss">
.message { .message {
display: flex;
padding: map-get($spacer, 2) map-get($spacer, 3); padding: map-get($spacer, 2) map-get($spacer, 3);
margin-bottom: map-get($spacer, 4); margin-bottom: map-get($spacer, 4);
text-align: center; text-align: center;
font-size: 90%; font-size: 90%;
word-break: break-word; word-break: break-word;
svg {
padding-right: map-get($spacer, 1);
}
&.primary { &.primary {
background-color: $primary-color-lighter; background-color: $primary-color-lighter;
color: $primary-color-darker; color: $primary-color-darker;
@@ -53,5 +51,20 @@ defineProps({
border: 1px solid $danger-color-lighter; border: 1px solid $danger-color-lighter;
border-radius: 12px; border-radius: 12px;
} }
ion-icon {
height: 1.3em;
width: 1.3em;
justify-content: center;
align-self: center;
}
p {
flex: 1;
margin-bottom: 0;
justify-content: center;
align-self: center;
white-space: pre-wrap;
}
} }
</style> </style>

View File

@@ -1,7 +1,7 @@
<template> <template>
<SMContainer <SMContainer
:full="true" :full="true"
:class="['navbar', { showDropdown: showToggle }]" :class="['sm-navbar', { showDropdown: showToggle }]"
@click="handleHideMenu"> @click="handleHideMenu">
<template #inner> <template #inner>
<div class="navbar-container"> <div class="navbar-container">
@@ -24,12 +24,12 @@
:to="{ name: 'workshop-list' }" :to="{ name: 'workshop-list' }"
class="navbar-cta" class="navbar-cta"
label="Find a workshop" label="Find a workshop"
icon="fa-solid fa-arrow-right" /> icon="arrow-forward-outline" />
<div class="menuButton" @click.stop="handleToggleMenu"> <div class="menuButton" @click.stop="handleToggleMenu">
<span>Menu</span <span>Menu</span
><font-awesome-icon ><ion-icon
icon="fa-solid fa-bars" class="menuButtonIcon"
class="menuButtonIcon" /> name="reorder-three-outline"></ion-icon>
</div> </div>
</div> </div>
</template> </template>
@@ -37,9 +37,7 @@
<ul class="navbar-dropdown"> <ul class="navbar-dropdown">
<li class="ml-auto"> <li class="ml-auto">
<div class="menuClose" @click.stop="handleToggleMenu"> <div class="menuClose" @click.stop="handleToggleMenu">
<font-awesome-icon <ion-icon name="close-outline"></ion-icon>
icon="fa-solid fa-xmark"
class="menuCloseIcon" />
</div> </div>
</li> </li>
<template v-for="item in menuItems"> <template v-for="item in menuItems">
@@ -47,7 +45,7 @@
v-if="item.show == undefined || item.show()" v-if="item.show == undefined || item.show()"
:key="item.name"> :key="item.name">
<router-link :to="item.to" <router-link :to="item.to"
><font-awesome-icon :icon="item.icon" />{{ ><ion-icon :name="item.icon" />{{
item.label item.label
}}</router-link }}</router-link
> >
@@ -69,13 +67,13 @@ const menuItems = [
name: "news", name: "news",
label: "News", label: "News",
to: "/news", to: "/news",
icon: "fa-regular fa-newspaper", icon: "newspaper-outline",
}, },
{ {
name: "workshops", name: "workshops",
label: "Workshops", label: "Workshops",
to: "/workshops", to: "/workshops",
icon: "fa-solid fa-pen-ruler", icon: "shapes-outline",
}, },
// { // {
// name: "courses", // name: "courses",
@@ -87,13 +85,13 @@ const menuItems = [
name: "contact", name: "contact",
label: "Contact us", label: "Contact us",
to: "/contact", to: "/contact",
icon: "fa-regular fa-envelope", icon: "mail-outline",
}, },
{ {
name: "register", name: "register",
label: "Register", label: "Register",
to: "/register", to: "/register",
icon: "fa-solid fa-pen-to-square", icon: "person-add-outline",
show: () => !userStore.id, show: () => !userStore.id,
inNav: false, inNav: false,
}, },
@@ -101,7 +99,7 @@ const menuItems = [
name: "login", name: "login",
label: "Log in", label: "Log in",
to: "/login", to: "/login",
icon: "fa-solid fa-right-to-bracket", icon: "log-in-outline",
show: () => !userStore.id, show: () => !userStore.id,
inNav: false, inNav: false,
}, },
@@ -109,7 +107,7 @@ const menuItems = [
name: "dashboard", name: "dashboard",
label: "Dashboard", label: "Dashboard",
to: "/dashboard", to: "/dashboard",
icon: "fa-regular fa-circle-user", icon: "apps-outline",
show: () => userStore.id, show: () => userStore.id,
inNav: false, inNav: false,
}, },
@@ -117,7 +115,7 @@ const menuItems = [
name: "logout", name: "logout",
label: "Log out", label: "Log out",
to: "/logout", to: "/logout",
icon: "fa-solid fa-right-from-bracket", icon: "log-out-outline",
show: () => userStore.id, show: () => userStore.id,
inNav: false, inNav: false,
}, },
@@ -135,7 +133,7 @@ const handleHideMenu = () => {
</script> </script>
<style lang="scss"> <style lang="scss">
.navbar { .sm-navbar {
height: 4.5rem; height: 4.5rem;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -143,6 +141,7 @@ const handleHideMenu = () => {
position: relative; position: relative;
flex: 0 0 auto !important; flex: 0 0 auto !important;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2); box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
z-index: 1000;
&.showDropdown { &.showDropdown {
.navbar-dropdown-cover { .navbar-dropdown-cover {
@@ -195,8 +194,10 @@ const handleHideMenu = () => {
display: inline-block; display: inline-block;
width: map-get($spacer, 5) * 3; width: map-get($spacer, 5) * 3;
svg { ion-icon {
padding-right: map-get($spacer, 1); padding-right: map-get($spacer, 1);
font-size: map-get($spacer, 4);
vertical-align: middle;
} }
} }
} }
@@ -224,7 +225,7 @@ const handleHideMenu = () => {
} }
} }
.menuClose svg { .menuClose ion-icon {
cursor: pointer; cursor: pointer;
font-size: map-get($spacer, 4); font-size: map-get($spacer, 4);
padding-left: map-get($spacer, 1); padding-left: map-get($spacer, 1);
@@ -271,7 +272,7 @@ const handleHideMenu = () => {
.menuButtonIcon { .menuButtonIcon {
margin-left: 0.5rem; margin-left: 0.5rem;
font-size: 1.4rem; font-size: map-get($spacer, 4);
} }
} }
} }

View File

@@ -0,0 +1,97 @@
<template>
<div :class="['sm-page-outer', { 'sm-no-breadcrumbs': noBreadcrumbs }]">
<SMBreadcrumbs v-if="!noBreadcrumbs" />
<SMLoader :loading="loading">
<SMErrorForbidden
v-if="pageError == 403 || !hasPermission()"></SMErrorForbidden>
<SMErrorInternal
v-if="pageError >= 500 && hasPermission()"></SMErrorInternal>
<SMErrorNotFound
v-if="pageError == 404 && hasPermission()"></SMErrorNotFound>
<div
v-if="pageError < 300 && hasPermission()"
class="sm-page"
:style="styleObject">
<slot></slot>
</div>
</SMLoader>
</div>
</template>
<script setup lang="ts">
import SMLoader from "./SMLoader.vue";
import SMErrorForbidden from "./errors/Forbidden.vue";
import SMErrorInternal from "./errors/Internal.vue";
import SMErrorNotFound from "./errors/NotFound.vue";
import SMBreadcrumbs from "../components/SMBreadcrumbs.vue";
import { useUserStore } from "../store/UserStore";
const props = defineProps({
pageError: {
type: Number,
default: 200,
required: false,
},
permission: {
type: String,
default: "",
required: false,
},
loading: {
type: Boolean,
default: false,
required: false,
},
background: {
type: String,
default: "",
required: false,
},
noBreadcrumbs: {
type: Boolean,
default: false,
required: false,
},
});
const userStore = useUserStore();
let styleObject = {};
if (props.background != "") {
styleObject["backgroundImage"] = `url('${props.background}')`;
}
const hasPermission = () => {
return (
props.permission.length == 0 ||
userStore.permissions.includes(props.permission)
);
};
</script>
<style lang="scss">
.sm-page-outer {
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
margin-bottom: calc(map-get($spacer, 5) * 2);
&.sm-no-breadcrumbs {
margin-bottom: 0;
.sm-page {
// padding-top: calc(map-get($spacer, 5) * 2);
padding-bottom: calc(map-get($spacer, 5) * 2);
}
}
.sm-page {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
display: flex;
flex-direction: column;
flex: 1;
}
}
</style>

View File

@@ -2,7 +2,7 @@
<d-error-forbidden v-if="error == 403"></d-error-forbidden> <d-error-forbidden v-if="error == 403"></d-error-forbidden>
<d-error-internal v-if="error >= 500"></d-error-internal> <d-error-internal v-if="error >= 500"></d-error-internal>
<d-error-not-found v-if="error == 404"></d-error-not-found> <d-error-not-found v-if="error == 404"></d-error-not-found>
<template v-if="slots.default"> <template v-if="slots.default && error < 300">
<slot></slot> <slot></slot>
</template> </template>
</template> </template>

View File

@@ -11,23 +11,22 @@
{{ format(new Date(date), "MMM") }} {{ format(new Date(date), "MMM") }}
</div> </div>
</div> </div>
<font-awesome-icon <ion-icon
v-if="hideImageLoader == false" v-if="hideImageLoader == false"
class="panel-image-loader" class="panel-image-loader"
icon="fa-regular fa-image" /> name="image-outline" />
</div> </div>
<div class="panel-body"> <div class="panel-body">
<h3 class="panel-title">{{ title }}</h3> <h3 class="panel-title">{{ title }}</h3>
<div v-if="showDate" class="panel-date"> <div v-if="showDate" class="panel-date">
<font-awesome-icon <ion-icon
v-if="showTime == false && endDate.length == 0" v-if="showTime == false && endDate.length == 0"
icon="fa-regular fa-calendar" /><font-awesome-icon name="calendar-outline" />
v-else <ion-icon v-else name="time-outline" />
icon="fa-regular fa-clock" />
<p>{{ panelDate }}</p> <p>{{ panelDate }}</p>
</div> </div>
<div v-if="location" class="panel-location"> <div v-if="location" class="panel-location">
<font-awesome-icon icon="fa-solid fa-location-dot" /> <ion-icon name="location-outline" />
<p>{{ location }}</p> <p>{{ location }}</p>
</div> </div>
<div v-if="content" class="panel-content">{{ panelContent }}</div> <div v-if="content" class="panel-content">{{ panelContent }}</div>
@@ -39,7 +38,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import axios from "axios";
import { onMounted, computed, ref } from "vue"; import { onMounted, computed, ref } from "vue";
import { import {
excerpt, excerpt,
@@ -49,6 +47,7 @@ import {
} from "../helpers/common"; } from "../helpers/common";
import { format } from "date-fns"; import { format } from "date-fns";
import SMButton from "./SMButton.vue"; import SMButton from "./SMButton.vue";
import { api } from "../helpers/api";
const props = defineProps({ const props = defineProps({
title: { title: {
@@ -151,10 +150,10 @@ const hideImageLoader = computed(() => {
onMounted(async () => { onMounted(async () => {
if (imageUrl.value && imageUrl.value.length > 0 && isUUID(imageUrl.value)) { if (imageUrl.value && imageUrl.value.length > 0 && isUUID(imageUrl.value)) {
try { try {
let result = await axios.get(`media/${props.image}`); let result = await api.get(`/media/${props.image}`);
if (result.data.medium) { if (result.json.medium) {
imageUrl.value = result.data.medium.url; imageUrl.value = result.json.medium.url;
} }
} catch (error) { } catch (error) {
/* empty */ /* empty */

View File

@@ -1,10 +1,10 @@
<template> <template>
<div class="panel-list"> <div class="panel-list">
<div v-if="loading" class="panel-list-loading"> <div v-if="loading" class="panel-list-loading">
<font-awesome-icon icon="fa-solid fa-spinner" pulse /> <SMLoadingIcon />
</div> </div>
<div v-else-if="notFound" class="panel-list-not-found"> <div v-else-if="notFound" class="panel-list-not-found">
<font-awesome-icon icon="fa-regular fa-face-frown-open" /> <ion-icon name="alert-circle-outline" />
<p>{{ notFoundText }}</p> <p>{{ notFoundText }}</p>
</div> </div>
<slot></slot> <slot></slot>
@@ -12,6 +12,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import SMLoadingIcon from "./SMLoadingIcon.vue";
defineProps({ defineProps({
loading: { loading: {
type: Boolean, type: Boolean,

View File

@@ -19,7 +19,7 @@
<slot></slot> <slot></slot>
</div> </div>
<div v-if="help" class="form-group-help"> <div v-if="help" class="form-group-help">
<font-awesome-icon v-if="helpIcon" :icon="helpIcon" /> <ion-icon v-if="helpIcon" name="information-circle-outline" />
{{ help }} {{ help }}
</div> </div>
</div> </div>

View File

@@ -33,7 +33,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import axios from "axios";
import { useUserStore } from "../../store/UserStore"; import { useUserStore } from "../../store/UserStore";
import { ref, reactive, computed, onMounted, onUnmounted } from "vue"; import { ref, reactive, computed, onMounted, onUnmounted } from "vue";
import { closeDialog } from "vue3-promise-dialog"; import { closeDialog } from "vue3-promise-dialog";
@@ -82,15 +81,17 @@ const handleConfirm = async () => {
if (isValidated(formData)) { if (isValidated(formData)) {
try { try {
formLoading.value = true; formLoading.value = true;
await axios.put(`users/${userStore.id}`, { await api.put({
password: formData.password.value, url: `/users/${userStore.id}`,
body: {
password: formData.password.value,
},
}); });
isSuccessful.value = true; isSuccessful.value = true;
} catch (err) { } catch (err) {
formData.password.error = formData.password.error =
err.response?.data?.message || err.json?.message || "An unexpected error occurred";
"An unexpected error occurred";
} }
} }
} }

View File

@@ -26,19 +26,19 @@
>Page {{ page }} of {{ totalPages }}</span >Page {{ page }} of {{ totalPages }}</span
> >
<span class="media-browser-page-changer"> <span class="media-browser-page-changer">
<font-awesome-icon <ion-icon
name="chevron-back-outline"
:class="[ :class="[
'changer-button', 'changer-button',
{ disabled: prevDisabled }, { disabled: prevDisabled },
]" ]"
icon="fa-solid fa-angle-left"
@click="handlePrev" /> @click="handlePrev" />
<font-awesome-icon <ion-icon
name="chevron-forward-outline"
:class="[ :class="[
'changer-button', 'changer-button',
{ disabled: nextDisabled }, { disabled: nextDisabled },
]" ]"
icon="fa-solid fa-angle-right"
@click="handleNext" /> @click="handleNext" />
</span> </span>
</div> </div>
@@ -73,7 +73,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import axios from "axios";
import { computed, watch, ref, reactive, onMounted, onUnmounted } from "vue"; import { computed, watch, ref, reactive, onMounted, onUnmounted } from "vue";
import { closeDialog } from "vue3-promise-dialog"; import { closeDialog } from "vue3-promise-dialog";
import SMButton from "../SMButton.vue"; import SMButton from "../SMButton.vue";
@@ -82,6 +81,7 @@ import SMDialog from "../SMDialog.vue";
import SMMessage from "../SMMessage.vue"; import SMMessage from "../SMMessage.vue";
import SMModal from "../SMModal.vue"; import SMModal from "../SMModal.vue";
import { toParamString } from "../../helpers/common"; import { toParamString } from "../../helpers/common";
import { api } from "../../helpers/api";
const uploader = ref(null); const uploader = ref(null);
const formLoading = ref(false); const formLoading = ref(false);
@@ -134,18 +134,18 @@ const handleLoad = async () => {
params.limit = perPage.value; params.limit = perPage.value;
// params.fields = "url"; // params.fields = "url";
let res = await axios.get(`media${toParamString(params)}`); let res = await api.get(`/media${toParamString(params)}`);
totalItems.value = res.data.total; totalItems.value = res.json.total;
mediaItems.value = res.data.media; mediaItems.value = res.json.media;
} catch (error) { } catch (error) {
if (error.response.status == 404) { if (error.status == 404) {
formMessage.type = "primary"; formMessage.type = "primary";
formMessage.icon = "fa-regular fa-folder-open"; formMessage.icon = "fa-regular fa-folder-open";
formMessage.message = "No media items found"; formMessage.message = "No media items found";
} else { } else {
formMessage.message = formMessage.message =
error.response?.data?.message || "An unexpected error occurred"; error?.json?.message || "An unexpected error occurred";
} }
} }
}; };
@@ -165,18 +165,20 @@ const handleUpload = async () => {
if (uploader.value.files[0] instanceof File) { if (uploader.value.files[0] instanceof File) {
submitFormData.append("file", uploader.value.files[0]); submitFormData.append("file", uploader.value.files[0]);
let res = await axios.post("media", submitFormData, { let res = await api.post({
url: "/media",
body: submitFormData,
headers: { headers: {
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",
}, },
onUploadProgress: (progressEvent) => // onUploadProgress: (progressEvent) =>
(formLoadingMessage.value = `Uploading Files ${Math.floor( // (formLoadingMessage.value = `Uploading Files ${Math.floor(
(progressEvent.loaded / progressEvent.total) * 100 // (progressEvent.loaded / progressEvent.total) * 100
)}%`), // )}%`),
}); });
if (res.data.medium) { if (res.json.medium) {
closeDialog(res.data.medium); closeDialog(res.json.medium);
} else { } else {
formMessage.message = formMessage.message =
"An unexpected response was received from the server"; "An unexpected response was received from the server";

View File

@@ -1,15 +1,21 @@
<template> <template>
<div class="page-error forbidden"> <SMPage no-breadcrumbs>
<div class="image"></div> <div class="page-error forbidden">
<div class="content"> <div class="image"></div>
<h1>The cat says no!</h1> <div class="content">
<p>You do not have the needed access to see this page</p> <h1>The cat says no!</h1>
<p>You do not have the needed access to see this page</p>
</div>
</div> </div>
</div> </SMPage>
</template> </template>
<script setup lang="ts">
import SMPage from "../SMPage.vue";
</script>
<style lang="scss"> <style lang="scss">
.page-error.forbidden .image { .page-error.forbidden .image {
background-image: url('/img/403.jpg'); background-image: url("/img/403.jpg");
} }
</style> </style>

View File

@@ -1,15 +1,24 @@
<template> <template>
<div class="page-error internal"> <SMPage no-breadcrumbs>
<div class="image"></div> <div class="page-error internal">
<div class="content"> <div class="image"></div>
<h1>The cat has broken something</h1> <div class="content">
<p>We are working to fix that what was broken. Please try again later.</p> <h1>The cat has broken something</h1>
<p>
We are working to fix that what was broken. Please try again
later.
</p>
</div>
</div> </div>
</div> </SMPage>
</template> </template>
<script setup lang="ts">
import SMPage from "../SMPage.vue";
</script>
<style lang="scss"> <style lang="scss">
.page-error.internal .image { .page-error.internal .image {
background-image: url('/img/500.jpg'); background-image: url("/img/500.jpg");
} }
</style> </style>

View File

@@ -1,15 +1,21 @@
<template> <template>
<div class="page-error not-found"> <SMPage no-breadcrumbs>
<div class="image"></div> <div class="page-error not-found">
<div class="content"> <div class="image"></div>
<h1>Opps</h1> <div class="content">
<p>The page you asked for was not found</p> <h1>Opps</h1>
<p>The page you asked for was not found</p>
</div>
</div> </div>
</div> </SMPage>
</template> </template>
<script setup lang="ts">
import SMPage from "../SMPage.vue";
</script>
<style lang="scss"> <style lang="scss">
.page-error.not-found .image { .page-error.not-found .image {
background-image: url('/img/404.jpg'); background-image: url("/img/404.jpg");
} }
</style> </style>

190
resources/js/helpers/api.ts Normal file
View File

@@ -0,0 +1,190 @@
/* https://blog.logrocket.com/axios-vs-fetch-best-http-requests/ */
import { useUserStore } from "../store/UserStore";
interface ApiProgressData {
loaded: number;
total: number;
}
type ApiProgressCallback = (progress: ApiProgressData) => void;
interface ApiOptions {
url: string;
params?: object;
method?: string;
headers?: HeadersInit;
body?: string | object;
progress?: ApiProgressCallback;
}
interface ApiResponse {
status: number;
message: string;
data: object;
}
const apiDefaultHeaders = {
Accept: "application/json",
"Content-Type": "application/json;charset=UTF-8",
};
export const api = {
timeout: 8000,
baseUrl: "https://www.stemmechanics.com.au/api",
send: function (options: ApiOptions) {
return new Promise((resolve, reject) => {
let url = this.baseUrl + options.url;
if (options.params) {
url =
url +
"?" +
Object.keys(options.params)
.map((key) => key + "=" + options.params[key])
.join("&");
}
options.headers = {
...apiDefaultHeaders,
...(options.headers || {}),
};
const userStore = useUserStore();
if (userStore.id) {
options.headers["Authorization"] = `Bearer ${userStore.token}`;
}
if (options.body && typeof options.body === "object") {
options.body = JSON.stringify(options.body);
}
const fetchOptions: RequestInit = {
method: options.method || "GET",
headers: options.headers,
body: options.body,
};
let receivedData = false;
fetch(url, fetchOptions)
.then((response) => {
receivedData = true;
if (options.progress) {
if (!response.ok) {
return response;
}
if (!response.body) {
return response;
// return {
// status: 0,
// message:
// "ReadableStream not yet supported in this browser.",
// data: null,
// };
}
let contentLength =
response.headers.get("content-length");
if (!contentLength) {
contentLength = -1;
}
// parse the integer into a base-10 number
const total = parseInt(contentLength, 10);
let loaded = 0;
return new Response(
// create and return a readable stream
new ReadableStream({
start(controller) {
const reader = response.body.getReader();
read();
/**
*
*/
function read() {
reader
.read()
.then(({ done, value }) => {
if (done) {
controller.close();
return;
}
loaded += value.byteLength;
options.progress({
loaded,
total,
});
controller.enqueue(value);
read();
})
.catch((error) => {
controller.error(error);
reject({
status: 0,
message: "controller error",
data: null,
});
});
}
},
})
);
}
return response;
})
.then(async (response) => {
const data = response.json ? await response.json() : {};
const result = {
status: response.status,
statusText: response.statusText,
url: response.url,
headers: response.headers,
data: data,
};
if (response.status >= 300) {
reject(result);
}
resolve(result);
})
.catch((error) => {
console.log(error);
// Handle any errors thrown during the fetch process
const { response, ...rest } = error;
reject({
...rest,
response: response && response.json(),
});
});
});
},
get: async function (options: ApiOptions | string): Promise<ApiResponse> {
let apiOptions = {} as ApiOptions;
if (typeof options == "string") {
apiOptions.url = options;
} else {
apiOptions = options;
}
apiOptions.method = "GET";
return await this.send(apiOptions);
},
post: async function (options: ApiOptions): Promise<ApiResponse> {
options.method = "POST";
return await this.send(options);
},
delete: async function (options: ApiOptions): Promise<ApiResponse> {
options.method = "DELETE";
return await this.send(options);
},
};

View File

@@ -1,5 +1,3 @@
import { format } from "date-fns";
const transitionEndEventName = () => { const transitionEndEventName = () => {
var i, var i,
undefined, undefined,
@@ -173,41 +171,6 @@ export function parseErrorType(
return def; return def;
} }
export const relativeDate = (d) => {
if (isString(d)) {
d = new Date(d);
}
// const d = new Date(0);
// // d.setUTCSeconds(parseInt(epoch));
// d.setUTCSeconds(epoch);
const now = new Date();
const dif = Math.round((now.getTime() - d.getTime()) / 1000);
if (dif < 60) {
// let v = dif;
// return v + " sec" + (v != 1 ? "s" : "") + " ago";
return "Just now";
} else if (dif < 3600) {
const v = Math.round(dif / 60);
return v + " min" + (v != 1 ? "s" : "") + " ago";
} else if (dif < 86400) {
const v = Math.round(dif / 3600);
return v + " hour" + (v != 1 ? "s" : "") + " ago";
} else if (dif < 604800) {
const v = Math.round(dif / 86400);
return v + " day" + (v != 1 ? "s" : "") + " ago";
} else if (dif < 2419200) {
const v = Math.round(dif / 604800);
return v + " week" + (v != 1 ? "s" : "") + " ago";
}
return (
monthString[d.getMonth()] + " " + d.getDate() + ", " + d.getFullYear()
);
};
export const buildUrlQuery = (url, query) => { export const buildUrlQuery = (url, query) => {
let s = ""; let s = "";
@@ -347,90 +310,6 @@ export const isUUID = (uuid) => {
); );
}; };
export const timestampUtcToLocal = (utc) => {
try {
let iso = new Date(
utc.replace(
/([0-9]{4}-[0-9]{2}-[0-9]{2}),? ([0-9]{2}:[0-9]{2}:[0-9]{2})/,
"$1T$2.000Z"
)
);
return format(iso, "yyyy/MM/dd HH:mm:ss");
} catch (error) {
/* empty */
}
return "";
};
export const timestampLocalToUtc = (local) => {
try {
let d = new Date(local);
return d
.toISOString()
.replace(
/([0-9]{4}-[0-9]{2}-[0-9]{2})T([0-9]{2}:[0-9]{2}:[0-9]{2}).*/,
"$1 $2"
);
} catch (error) {
/* empty */
}
return "";
};
export const timestampNowLocal = () => {
let d = new Date();
return (
d.getFullYear() +
"-" +
("0" + (d.getMonth() + 1)).slice(-2) +
"-"("0" + d.getDate()).slice(-2) +
" " +
("0" + d.getHours()).slice(-2) +
":" +
("0" + d.getMinutes()).slice(-2) +
":" +
("0" + d.getSeconds()).slice(-2)
);
};
export const timestampNowUtc = () => {
try {
let d = new Date();
return d
.toISOString()
.replace(
/([0-9]{4}-[0-9]{2}-[0-9]{2})T([0-9]{2}:[0-9]{2}:[0-9]{2}).*/,
"$1 $2"
);
} catch (error) {
/* empty */
}
return "";
};
export const timestampBeforeNow = (timestamp) => {
try {
return new Date(timestamp) < new Date();
} catch (error) {
/* empty */
}
return false;
};
export const timestampAfterNow = (timestamp) => {
try {
return new Date(timestamp) > new Date();
} catch (error) {
/* empty */
}
return false;
};
export { export {
transitionEndEventName, transitionEndEventName,
waitForElementRender, waitForElementRender,

View File

@@ -0,0 +1,221 @@
import { isString } from "../helpers/common";
export const dayString = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
export const fullDayString = [
"Sunday",
"Monday",
"Tueday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
export const monthString = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
export const fullMonthString = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
export const format = (objDate: Date, format: string): string => {
const result = format;
const year = objDate.getFullYear().toString();
const month = (objDate.getMonth() + 1).toString();
const date = objDate.getDate().toString();
const day = objDate.getDay().toString();
const hour = objDate.getHours().toString();
const min = objDate.getMinutes().toString();
const sec = objDate.getSeconds().toString();
const apm = objDate.getHours() >= 12 ? "am" : "pm";
/* eslint-disable indent */
const apmhours = (
objDate.getHours() > 12
? objDate.getHours() - 12
: objDate.getHours() == 0
? 12
: objDate.getHours()
).toString();
/* eslint-enable indent */
// year
result.replace(/\byy\b/g, year.slice(-2));
result.replace(/\byyyy\b/g, year);
// month
result.replace(/\bM\b/g, month);
result.replace(/\bMM\b/g, (0 + month).slice(-2));
result.replace(/\bMMM\b/g, monthString[month]);
result.replace(/\bMMMM\b/g, fullMonthString[month]);
// day
result.replace(/\bd\b/g, date);
result.replace(/\bdd\b/g, (0 + date).slice(-2));
result.replace(/\bddd\b/g, dayString[day]);
result.replace(/\bdddd\b/g, fullDayString[day]);
// hour
result.replace(/\bH\b/g, hour);
result.replace(/\bHH\b/g, (0 + hour).slice(-2));
result.replace(/\bh\b/g, apmhours);
result.replace(/\bhh\b/g, (0 + apmhours).slice(-2));
// min
result.replace(/\bm\b/g, min);
result.replace(/\bmm\b/g, (0 + min).slice(-2));
// sec
result.replace(/\bs\b/g, sec);
result.replace(/\bss\b/g, (0 + sec).slice(-2));
// am/pm
result.replace(/\baa\b/g, apm);
return result;
};
export const timestampUtcToLocal = (utc: string): string => {
try {
const iso = new Date(
utc.replace(
/([0-9]{4}-[0-9]{2}-[0-9]{2}),? ([0-9]{2}:[0-9]{2}:[0-9]{2})/,
"$1T$2.000Z"
)
);
return format(iso, "yyyy/MM/dd HH:mm:ss");
} catch (error) {
/* empty */
}
return "";
};
export const timestampLocalToUtc = (local) => {
try {
const d = new Date(local);
return d
.toISOString()
.replace(
/([0-9]{4}-[0-9]{2}-[0-9]{2})T([0-9]{2}:[0-9]{2}:[0-9]{2}).*/,
"$1 $2"
);
} catch (error) {
/* empty */
}
return "";
};
export const timestampNowLocal = () => {
const d = new Date();
return (
d.getFullYear() +
"-" +
("0" + (d.getMonth() + 1)).slice(-2) +
"-" +
("0" + d.getDate()).slice(-2) +
" " +
("0" + d.getHours()).slice(-2) +
":" +
("0" + d.getMinutes()).slice(-2) +
":" +
("0" + d.getSeconds()).slice(-2)
);
};
export const timestampNowUtc = () => {
try {
const d = new Date();
return d
.toISOString()
.replace(
/([0-9]{4}-[0-9]{2}-[0-9]{2})T([0-9]{2}:[0-9]{2}:[0-9]{2}).*/,
"$1 $2"
);
} catch (error) {
/* empty */
}
return "";
};
export const timestampBeforeNow = (timestamp) => {
try {
return new Date(timestamp) < new Date();
} catch (error) {
/* empty */
}
return false;
};
export const timestampAfterNow = (timestamp) => {
try {
return new Date(timestamp) > new Date();
} catch (error) {
/* empty */
}
return false;
};
export const relativeDate = (d) => {
if (isString(d)) {
d = new Date(d);
}
// const d = new Date(0);
// // d.setUTCSeconds(parseInt(epoch));
// d.setUTCSeconds(epoch);
const now = new Date();
const dif = Math.round((now.getTime() - d.getTime()) / 1000);
if (dif < 60) {
// let v = dif;
// return v + " sec" + (v != 1 ? "s" : "") + " ago";
return "Just now";
} else if (dif < 3600) {
const v = Math.round(dif / 60);
return v + " min" + (v != 1 ? "s" : "") + " ago";
} else if (dif < 86400) {
const v = Math.round(dif / 3600);
return v + " hour" + (v != 1 ? "s" : "") + " ago";
} else if (dif < 604800) {
const v = Math.round(dif / 86400);
return v + " day" + (v != 1 ? "s" : "") + " ago";
} else if (dif < 2419200) {
const v = Math.round(dif / 604800);
return v + " week" + (v != 1 ? "s" : "") + " ago";
}
return (
monthString[d.getMonth()] + " " + d.getDate() + ", " + d.getFullYear()
);
};

View File

@@ -0,0 +1,137 @@
import {
ValidationObject,
ValidationResult,
defaultValidationResult,
createValidationResult,
} from "./validate";
export const FormObject = (controls) => {
controls.validate = function (item = null) {
const keys = item ? [item] : Object.keys(this);
let valid = true;
keys.every((key) => {
if (
typeof this[key] == "object" &&
Object.keys(this[key]).includes("validation")
) {
this[key].validation.result = this[
key
].validation.validator.validate(this[key].value);
if (!this[key].validation.result.valid) {
valid = false;
}
}
return true;
});
return valid;
};
controls._loading = false;
controls.loading = function (state = true) {
this._loading = state;
};
controls._message = "";
controls._messageType = "primary";
controls._messageIcon = "";
controls.message = function (message = "", type = "", icon = "") {
this._message = message;
if (type.length > 0) {
this._messageType = type;
}
if (icon.length > 0) {
this._messageIcon = icon;
}
};
controls.error = function (message = "") {
if (message == "") {
this.message("");
} else {
this.message(message, "error", "alert-circle-outline");
}
};
controls.apiErrors = function (apiResponse) {
let foundKeys = false;
if (apiResponse?.json?.errors) {
Object.keys(apiResponse.json.errors).forEach((key) => {
if (
typeof this[key] == "object" &&
Object.keys(this[key]).includes("validation")
) {
foundKeys = true;
this[key].validation.result = createValidationResult(
false,
apiResponse.json.errors[key]
);
}
});
}
if (foundKeys == false) {
this.error(
apiResponse?.json?.message ||
"An unknown server error occurred.\nPlease try again later."
);
}
};
return controls;
};
interface FormControlValidation {
validator: ValidationObject;
result: ValidationResult;
}
const defaultFormControlValidation: FormControlValidation = {
validator: {
validate: (): ValidationResult => {
return defaultValidationResult;
},
},
result: defaultValidationResult,
};
type FormClearValidations = () => void;
type FormSetValidation = (
valid: boolean,
message?: string | Array<string>
) => ValidationResult;
interface FormControlObject {
value: string;
validation: FormControlValidation;
clearValidations: FormClearValidations;
setValidationResult: FormSetValidation;
}
/* eslint-disable indent */
export const FormControl = (
value = "",
validator: ValidationObject | null = null
): FormControlObject => {
return {
value: value,
validation:
validator == null
? defaultFormControlValidation
: {
validator: validator,
result: defaultValidationResult,
},
clearValidations: function () {
this.validation.result = defaultValidationResult;
},
setValidationResult: createValidationResult,
};
};
/* eslint-enable indent */

View File

@@ -0,0 +1,5 @@
export const toTitleCase = (str) => {
return str.replace(/\w\S*/g, function (txt) {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
});
};

View File

@@ -0,0 +1,11 @@
export const isEmpty = (obj: object | string) => {
if (obj) {
if (typeof obj === "string") {
return obj.length == 0;
} else if (typeof obj == "object" && Object.keys(obj).length === 0) {
return true;
}
}
return false;
};

View File

@@ -0,0 +1,440 @@
export interface ValidationObject {
validate: (value: string) => ValidationResult;
}
export interface ValidationResult {
valid: boolean;
invalidMessages: Array<string>;
}
export const defaultValidationResult: ValidationResult = {
valid: true,
invalidMessages: [],
};
export const createValidationResult = (
valid: boolean,
message: string | Array<string> = ""
) => {
if (typeof message == "string") {
message = [message];
}
return {
valid: valid,
invalidMessages: message,
};
};
/**
* Validation Min
*/
const VALIDATION_MIN_TYPE = ["String", "Number"];
type ValidationMinType = (typeof VALIDATION_MIN_TYPE)[number];
interface ValidationMinOptions {
min: number;
type?: ValidationMinType;
invalidMessage?: string | ((options: ValidationMinOptions) => string);
}
interface ValidationMinObject extends ValidationMinOptions {
validate: (value: string) => ValidationResult;
}
const defaultValidationMinOptions: ValidationMinOptions = {
min: 1,
type: "String",
invalidMessage: (options: ValidationMinOptions) => {
return options.type == "String"
? `Required to be at least ${options.min} characters.`
: `Required to be at least ${options.min}.`;
},
};
export function Min(
minOrOptions: number | ValidationMinOptions,
options?: ValidationMinOptions
);
export function Min(options: ValidationMinOptions): ValidationMinObject;
/**
* Validate field length or number is at minimum or higher/larger
*
* @param minOrOptions minimum number or options data
* @param options options data
* @returns ValidationMinObject
*/
export function Min(
minOrOptions: number | ValidationMinOptions,
options?: ValidationMinOptions
): ValidationMinObject {
if (typeof minOrOptions === "number") {
options = { ...defaultValidationMinOptions, ...(options || {}) };
options.min = minOrOptions;
} else {
options = { ...defaultValidationMinOptions, ...(minOrOptions || {}) };
}
return {
...options,
validate: function (value: string): ValidationResult {
return {
valid:
this.type == "String"
? value.toString().length >= this.min
: parseInt(value) >= this.min,
invalidMessages: [
typeof this.invalidMessage === "string"
? this.invalidMessage
: this.invalidMessage(this),
],
};
},
};
}
/**
* Validation Max
*/
const VALIDATION_MAX_TYPE = ["String", "Number"];
type ValidationMaxType = (typeof VALIDATION_MAX_TYPE)[number];
interface ValidationMaxOptions {
max: number;
type?: ValidationMaxType;
invalidMessage?: string | ((options: ValidationMaxOptions) => string);
}
interface ValidationMaxObject extends ValidationMaxOptions {
validate: (value: string) => ValidationResult;
}
const defaultValidationMaxOptions: ValidationMaxOptions = {
max: 1,
type: "String",
invalidMessage: (options: ValidationMaxOptions) => {
return options.type == "String"
? `Required to be less than ${options.max + 1} characters.`
: `Required to be less than ${options.max + 1}.`;
},
};
export function Max(
maxOrOptions: number | ValidationMaxOptions,
options?: ValidationMaxOptions
): ValidationMaxObject;
export function Max(options: ValidationMaxOptions): ValidationMaxObject;
/**
* Validate field length or number is at maximum or smaller
*
* @param maxOrOptions maximum number or options data
* @param options options data
* @returns ValidationMaxObject
*/
export function Max(
maxOrOptions: number | ValidationMaxOptions,
options?: ValidationMaxOptions
): ValidationMaxObject {
if (typeof maxOrOptions === "number") {
options = { ...defaultValidationMaxOptions, ...(options || {}) };
options.max = maxOrOptions;
} else {
options = { ...defaultValidationMaxOptions, ...(maxOrOptions || {}) };
}
return {
...options,
validate: function (value: string): ValidationResult {
return {
valid:
this.type == "String"
? value.toString().length <= this.max
: parseInt(value) <= this.max,
invalidMessages: [
typeof this.invalidMessage === "string"
? this.invalidMessage
: this.invalidMessage(this),
],
};
},
};
}
/**
* PASSWORD
*/
interface ValidationPasswordOptions {
invalidMessage?: string | ((options: ValidationPasswordOptions) => string);
}
interface ValidationPasswordObject extends ValidationPasswordOptions {
validate: (value: string) => ValidationResult;
}
const defaultValidationPasswordOptions: ValidationPasswordOptions = {
invalidMessage:
"Your password needs to have at least a letter, a number and a special character.",
};
/**
* Validate field is in a valid password format
*
* @param options options data
* @returns ValidationPasswordObject
*/
export function Password(
options?: ValidationPasswordOptions
): ValidationPasswordObject {
options = { ...defaultValidationPasswordOptions, ...(options || {}) };
return {
...options,
validate: function (value: string): ValidationResult {
return {
valid: /(?=.*[A-Za-z])(?=.*\d)(?=.*[.@$!%*#?&])[A-Za-z\d.@$!%*#?&]{1,}$/.test(
value
),
invalidMessages: [
typeof this.invalidMessage === "string"
? this.invalidMessage
: this.invalidMessage(this),
],
};
},
};
}
/**
* EMAIL
*/
interface ValidationEmailOptions {
invalidMessage?: string | ((options: ValidationEmailOptions) => string);
}
interface ValidationEmailObject extends ValidationEmailOptions {
validate: (value: string) => ValidationResult;
}
const defaultValidationEmailOptions: ValidationEmailOptions = {
invalidMessage: "Your Email is not in a supported format.",
};
/**
* Validate field is in a valid Email format
*
* @param options options data
* @returns ValidationEmailObject
*/
export function Email(options?: ValidationEmailOptions): ValidationEmailObject {
options = { ...defaultValidationEmailOptions, ...(options || {}) };
return {
...options,
validate: function (value: string): ValidationResult {
return {
valid: /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(
value
),
invalidMessages: [
typeof this.invalidMessage === "string"
? this.invalidMessage
: this.invalidMessage(this),
],
};
},
};
}
/**
* PHONE
*/
interface ValidationPhoneOptions {
invalidMessage?: string | ((options: ValidationPhoneOptions) => string);
}
interface ValidationPhoneObject extends ValidationPhoneOptions {
validate: (value: string) => ValidationResult;
}
const defaultValidationPhoneOptions: ValidationPhoneOptions = {
invalidMessage: "Your Phone number is not in a supported format.",
};
/**
* Validate field is in a valid Phone format
*
* @param options options data
* @returns ValidationPhoneObject
*/
export function Phone(options?: ValidationPhoneOptions): ValidationPhoneObject {
options = { ...defaultValidationPhoneOptions, ...(options || {}) };
return {
...options,
validate: function (value: string): ValidationResult {
return {
valid: /^(\+|00)?[0-9][0-9 \-().]{7,32}$/.test(value),
invalidMessages: [
typeof this.invalidMessage === "string"
? this.invalidMessage
: this.invalidMessage(this),
],
};
},
};
}
/**
* CUSTOM
*/
type ValidationCustomCallback = (value: string) => boolean | string;
interface ValidationCustomOptions {
callback: ValidationCustomCallback;
invalidMessage?: string | ((options: ValidationCustomOptions) => string);
}
interface ValidationCustomObject extends ValidationCustomOptions {
validate: (value: string) => ValidationResult;
}
const defaultValidationCustomOptions: ValidationCustomOptions = {
callback: () => {
return true;
},
invalidMessage: "Your Custom number is not in a supported format.",
};
export function Custom(
callbackOrOptions: ValidationCustomCallback | ValidationCustomOptions,
options?: ValidationCustomOptions
);
export function Custom(
options: ValidationCustomOptions
): ValidationCustomObject;
/**
* Validate field is in a valid Custom format
*
* @param callbackOrOptions
* @param options options data
* @returns ValidationCustomObject
*/
export function Custom(
callbackOrOptions: ValidationCustomCallback | ValidationCustomOptions,
options?: ValidationCustomOptions
): ValidationCustomObject {
if (typeof callbackOrOptions === "function") {
options = { ...defaultValidationCustomOptions, ...(options || {}) };
options.callback = callbackOrOptions;
} else {
options = {
...defaultValidationCustomOptions,
...(callbackOrOptions || {}),
};
}
return {
...options,
validate: function (value: string): ValidationResult {
const validateResult = {
valid: true,
invalidMessages: [
typeof this.invalidMessage === "string"
? this.invalidMessage
: this.invalidMessage(this),
],
};
const callbackResult =
typeof this.callback === "function"
? this.callback(value)
: true;
if (typeof callbackResult === "string") {
if (callbackResult.length > 0) {
validateResult.valid = false;
validateResult.invalidMessages = [callbackResult];
}
} else if (callbackResult !== true) {
validateResult.valid = false;
}
return validateResult;
},
};
}
/**
* And
*
* @param list
*/
export const And = (list: Array<ValidationObject>) => {
return {
list: list,
validate: function (value: string) {
const validationResult: ValidationResult = {
valid: true,
invalidMessages: [],
};
this.list.every((item: ValidationObject) => {
const validationItemResult = item.validate(value);
if (validationItemResult.valid == false) {
validationResult.valid = false;
validationResult.invalidMessages =
validationResult.invalidMessages.concat(
validationItemResult.invalidMessages
);
}
return true;
});
return validationResult;
},
};
};
/**
* Required
*/
interface ValidationRequiredOptions {
invalidMessage?: string | ((options: ValidationRequiredOptions) => string);
}
interface ValidationRequiredObject extends ValidationRequiredOptions {
validate: (value: string) => ValidationResult;
}
const defaultValidationRequiredOptions: ValidationRequiredOptions = {
invalidMessage: "This field is required.",
};
/**
* Validate field contains value
*
* @param options options data
* @returns ValidationRequiredObject
*/
export function Required(
options?: ValidationRequiredOptions
): ValidationRequiredObject {
options = { ...defaultValidationRequiredOptions, ...(options || {}) };
return {
...options,
validate: function (value: string): ValidationResult {
return {
valid: value.length > 0,
invalidMessages:
typeof this.invalidMessage === "string"
? this.invalidMessage
: this.invalidMessage(this),
};
},
};
}

View File

@@ -3,11 +3,11 @@ import { createApp } from "vue";
import { createPinia } from "pinia"; import { createPinia } from "pinia";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate"; import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
import Router from "@/router"; import Router from "@/router";
import "./axios.js"; // import "./axios.js";
import "normalize.css"; import "normalize.css";
import "../css/app.scss"; import "../css/app.scss";
import App from "./views/App.vue"; import App from "./views/App.vue";
import FontAwesomeIcon from "@/helpers/fontawesome"; // import FontAwesomeIcon from "@/helpers/fontawesome";
import SMContainer from "./components/SMContainer.vue"; import SMContainer from "./components/SMContainer.vue";
import SMRow from "./components/SMRow.vue"; import SMRow from "./components/SMRow.vue";
import SMColumn from "./components/SMColumn.vue"; import SMColumn from "./components/SMColumn.vue";
@@ -19,7 +19,7 @@ const pinia = createPinia();
pinia.use(piniaPluginPersistedstate); pinia.use(piniaPluginPersistedstate);
createApp(App) createApp(App)
.component("FontAwesomeIcon", FontAwesomeIcon) // .component("FontAwesomeIcon", FontAwesomeIcon)
.use(pinia) .use(pinia)
.use(Router) .use(Router)
.use(PromiseDialog) .use(PromiseDialog)

View File

@@ -1,7 +1,7 @@
import axios from "axios";
import { createWebHistory, createRouter } from "vue-router"; import { createWebHistory, createRouter } from "vue-router";
import { useUserStore } from "@/store/UserStore"; import { useUserStore } from "@/store/UserStore";
import { useApplicationStore } from "../store/ApplicationStore"; import { useApplicationStore } from "../store/ApplicationStore";
import { api } from "../helpers/api";
export const routes = [ export const routes = [
{ {
@@ -386,10 +386,10 @@ router.beforeEach(async (to, from, next) => {
let redirect = false; let redirect = false;
try { try {
let res = await axios.get("me"); let res = await api.get("/me");
userStore.setUserDetails(res.data.user); userStore.setUserDetails(res.json.user);
} catch (err) { } catch (err) {
if (err.response.status == 401) { if (err.status == 401) {
userStore.clearUser(); userStore.clearUser();
redirect = true; redirect = true;
} }

View File

@@ -1,4 +1,4 @@
import axios from "axios"; import { api } from "../helpers/api";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
export interface UserDetails { export interface UserDetails {
@@ -51,16 +51,16 @@ export const useUserStore = defineStore({
}, },
async fetchUser() { async fetchUser() {
const res = await axios.get("users/" + this.$state.id); const res = await api.get("/users/" + this.$state.id);
this.$state.id = res.data.user.id; this.$state.id = res.json.user.id;
this.$state.token = res.data.token; this.$state.token = res.json.token;
this.$state.username = res.data.user.username; this.$state.username = res.json.user.username;
this.$state.firstName = res.data.user.first_name; this.$state.firstName = res.json.user.first_name;
this.$state.lastName = res.data.user.last_name; this.$state.lastName = res.json.user.last_name;
this.$state.email = res.data.user.email; this.$state.email = res.json.user.email;
this.$state.phone = res.data.user.phone; this.$state.phone = res.json.user.phone;
this.$state.permissions = res.data.user.permissions || []; this.$state.permissions = res.json.user.permissions || [];
}, },
clearUser() { clearUser() {

View File

@@ -1,6 +1,5 @@
<template> <template>
<SMNavbar /> <SMNavbar />
<SMBreadcrumbs />
<main> <main>
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<transition name="fade" mode="out-in"> <transition name="fade" mode="out-in">
@@ -14,7 +13,6 @@
<script setup lang="ts"> <script setup lang="ts">
import SMNavbar from "../components/SMNavbar.vue"; import SMNavbar from "../components/SMNavbar.vue";
import SMBreadcrumbs from "../components/SMBreadcrumbs.vue";
import SMFooter from "../components/SMFooter.vue"; import SMFooter from "../components/SMFooter.vue";
import { DialogWrapper } from "vue3-promise-dialog"; import { DialogWrapper } from "vue3-promise-dialog";
</script> </script>

View File

@@ -1,5 +1,5 @@
<template> <template>
<SMContainer class="page-contact"> <SMPage class="page-contact">
<SMRow break-large> <SMRow break-large>
<SMColumn> <SMColumn>
<h1 class="text-left">Contact Us</h1> <h1 class="text-left">Contact Us</h1>
@@ -42,43 +42,20 @@
</SMColumn> </SMColumn>
<SMColumn> <SMColumn>
<div> <div>
<SMDialog narrow :loading="formLoading"> <SMDialog narrow>
<template v-if="!formDone"> <template v-if="!formSubmitted">
<SMMessage <SMForm v-model="form" @submit="handleSubmit">
v-if="formMessage.message" <SMInput control="name" />
:type="formMessage.type" <SMInput control="email" type="email" />
:message="formMessage.message"
:icon="formMessage.icon" />
<form @submit.prevent="submit">
<SMInput <SMInput
v-model="formData.name.value" control="content"
name="name"
label="Name"
required
:error="formData.name.error"
@blur="fieldValidate(formData.name)" />
<SMInput
v-model="formData.email.value"
name="email"
label="Email"
required
:error="formData.email.error"
@blur="fieldValidate(formData.email)" />
<SMInput
v-model="formData.content.value"
name="content"
type="textarea"
label="Message" label="Message"
required type="textarea" />
:error="formData.content.error"
@blur="fieldValidate(formData.content)" />
<SMCaptchaNotice />
<SMButton <SMButton
type="submit" type="submit"
block block
label="Send Message" label="Send Message" />
icon="fa-regular fa-paper-plane" /> </SMForm>
</form>
</template> </template>
<template v-else> <template v-else>
<h1>Message Sent!</h1> <h1>Message Sent!</h1>
@@ -86,98 +63,65 @@
Your message as been sent to us. We will respond Your message as been sent to us. We will respond
as soon as we can. as soon as we can.
</p> </p>
<SMButton block to="/" label="Home" /> <SMButton
block
:to="{ name: 'home' }"
label="Home" />
</template> </template>
</SMDialog> </SMDialog>
</div> </div>
</SMColumn> </SMColumn>
</SMRow> </SMRow>
</SMContainer> </SMPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive } from "vue";
import SMInput from "../components/SMInput.vue";
import SMButton from "../components/SMButton.vue"; import SMButton from "../components/SMButton.vue";
import SMDialog from "../components/SMDialog.vue"; import SMDialog from "../components/SMDialog.vue";
import SMMessage from "../components/SMMessage.vue"; import SMForm from "../components/SMForm.vue";
import axios from "axios"; import SMInput from "../components/SMInput.vue";
import { import SMPage from "../components/SMPage.vue";
useValidation,
isValidated, import { api } from "../helpers/api";
fieldValidate, import { FormObject, FormControl } from "../helpers/form";
restParseErrors, import { And, Email, Min, Required } from "../helpers/validate";
} from "../helpers/validation";
import { ref, reactive } from "vue";
import { useReCaptcha } from "vue-recaptcha-v3"; import { useReCaptcha } from "vue-recaptcha-v3";
import SMCaptchaNotice from "../components/SMCaptchaNotice.vue";
const { executeRecaptcha, recaptchaLoaded } = useReCaptcha(); const { executeRecaptcha, recaptchaLoaded } = useReCaptcha();
const formLoading = ref(false); const form = reactive(
const formDone = ref(false); FormObject({
const formMessage = reactive({ name: FormControl("", And([Required(), Min(4)])),
message: "", email: FormControl("", And([Required(), Email()])),
type: "error", content: FormControl("", And([Required(), Min(8)])),
icon: "", })
}); );
const formData = reactive({ const formSubmitted = ref(false);
name: {
value: "",
error: "",
rules: {
required: true,
required_message: "A name is needed",
min: 4,
min_message: "A name needs to be is at least 4 characters",
},
},
email: {
value: "",
error: "",
rules: {
required: true,
required_message: "A email address is needed",
email: true,
email_message: "That email address does not look right",
},
},
content: {
value: "",
error: "",
rules: {
required: true,
required_message: "A message is required",
min: 8,
min_message: "The message needs to be at least %d characters",
},
},
});
useValidation(formData); const handleSubmit = async () => {
form.loading(true);
const submit = async () => {
formLoading.value = true;
try { try {
if (isValidated(formData)) { await recaptchaLoaded();
await recaptchaLoaded(); const captcha = await executeRecaptcha("submit");
const captcha = await executeRecaptcha("submit");
await axios.post("contact", { await api.post({
name: formData.name.value, url: "/contact",
email: formData.email.value, body: {
name: form.name.value,
email: form.email.value,
captcha_token: captcha, captcha_token: captcha,
content: formData.content.value, content: form.content.value,
}); },
});
formDone.value = true; formSubmitted.value = true;
}
} catch (err) { } catch (err) {
formLoading.value = false; console.log(err);
formMessage.type = "error"; form.apiErrors(err);
formMessage.icon = "fa-solid fa-circle-exclamation";
restParseErrors(formData, [formMessage, "message"], err);
} }
formLoading.value = false; form.loading(false);
}; };
</script> </script>

View File

@@ -1,26 +1,14 @@
<template> <template>
<SMContainer> <SMPage no-breadcrumbs background="/img/background.jpg">
<SMRow> <SMRow>
<SMDialog narrow :loading="formLoading"> <SMDialog class="mt-5" narrow>
<template v-if="!formDone"> <template v-if="!formDone">
<h1>Email Verify</h1> <h1>Email Verify</h1>
<SMMessage <SMForm v-model="form" @submit="handleSubmit">
v-if="formMessage.message" <SMInput control="code" />
:type="formMessage.type"
:message="formMessage.message"
:icon="formMessage.icon" />
<form @submit.prevent="submit">
<SMInput
v-model="formData.code.value"
name="code"
label="Code"
required
:error="formData.code.error"
@blur="fieldValidate(formData.code)" />
<SMCaptchaNotice />
<SMFormFooter> <SMFormFooter>
<template #left> <template #left>
<div> <div class="small">
<router-link to="/resend-verify-email" <router-link to="/resend-verify-email"
>Resend Code</router-link >Resend Code</router-link
> >
@@ -30,10 +18,10 @@
<SMButton <SMButton
type="submit" type="submit"
label="Verify Code" label="Verify Code"
icon="fa-solid fa-arrow-right" /> icon="arrow-forward-outline" />
</template> </template>
</SMFormFooter> </SMFormFooter>
</form> </SMForm>
</template> </template>
<template v-else> <template v-else>
<h1>Email Verified!</h1> <h1>Email Verified!</h1>
@@ -48,7 +36,7 @@
</template> </template>
</SMDialog> </SMDialog>
</SMRow> </SMRow>
</SMContainer> </SMPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -57,70 +45,47 @@ import SMInput from "../components/SMInput.vue";
import SMButton from "../components/SMButton.vue"; import SMButton from "../components/SMButton.vue";
import SMFormFooter from "../components/SMFormFooter.vue"; import SMFormFooter from "../components/SMFormFooter.vue";
import SMDialog from "../components/SMDialog.vue"; import SMDialog from "../components/SMDialog.vue";
import SMMessage from "../components/SMMessage.vue"; import SMPage from "../components/SMPage.vue";
import axios from "axios"; import SMForm from "../components/SMForm.vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { import { And, Max, Min, Required } from "../helpers/validate";
useValidation,
isValidated,
fieldValidate,
restParseErrors,
} from "../helpers/validation";
import SMCaptchaNotice from "../components/SMCaptchaNotice.vue";
import { useReCaptcha } from "vue-recaptcha-v3"; import { useReCaptcha } from "vue-recaptcha-v3";
import { FormControl, FormObject } from "../helpers/form";
import { api } from "../helpers/api";
const { executeRecaptcha, recaptchaLoaded } = useReCaptcha(); const { executeRecaptcha, recaptchaLoaded } = useReCaptcha();
const formLoading = ref(false);
const formDone = ref(false); const formDone = ref(false);
const formMessage = reactive({ const form = reactive(
message: "", FormObject({
type: "error", code: FormControl("", And([Required(), Min(6), Max(6)])),
icon: "", })
}); );
const formData = reactive({
code: {
value: "",
error: "",
rules: {
required: true,
required_message: "The code is needed",
min: 6,
min_message: "The code should be 6 characters",
max: 6,
max_message: "The code should be 6 characters",
},
},
});
useValidation(formData); const handleSubmit = async () => {
form.loading(true);
const submit = async () => {
formLoading.value = true;
formMessage.type = "error";
formMessage.icon = "fa-solid fa-circle-exclamation";
formMessage.message = "";
try { try {
if (isValidated(formData)) { await recaptchaLoaded();
await recaptchaLoaded(); const captcha = await executeRecaptcha("submit");
const captcha = await executeRecaptcha("submit");
await axios.post("users/verifyEmail", { await api.post({
code: formData.code.value, url: "/users/verifyEmail",
body: {
code: form.code.value,
captcha_token: captcha, captcha_token: captcha,
}); },
});
formDone.value = true; formDone.value = true;
}
} catch (err) { } catch (err) {
restParseErrors(formData, [formMessage, "message"], err); form.apiErrors(err);
} }
formLoading.value = false; form.loading(false);
}; };
if (useRoute().query.code !== undefined) { if (useRoute().query.code !== undefined) {
formData.code.value = useRoute().query.code; form.code.value = useRoute().query.code;
submit(); handleSubmit();
} }
</script> </script>

View File

@@ -23,7 +23,7 @@ import SMMessage from "../components/SMMessage.vue";
import SMPanelList from "../components/SMPanelList.vue"; import SMPanelList from "../components/SMPanelList.vue";
import SMPanel from "../components/SMPanel.vue"; import SMPanel from "../components/SMPanel.vue";
import { reactive } from "vue"; import { reactive } from "vue";
import axios from "axios"; import { api } from "../helpers/api";
const events = reactive([]); const events = reactive([]);
@@ -39,12 +39,16 @@ const handleLoad = async () => {
formMessage.message = ""; formMessage.message = "";
try { try {
let result = await axios.get("events?limit=10"); let result = await api.get({
events.value = result.data.events; url: "/events",
params: {
limit: 10,
},
});
events.value = result.json.events;
} catch (error) { } catch (error) {
formMessage.message = formMessage.message =
error.response?.data?.message || error.json?.message || "Could not load any events from the server.";
"Could not load any events from the server.";
} }
}; };

View File

@@ -1,26 +1,14 @@
<template> <template>
<SMContainer> <SMPage no-breadcrumbs background="/img/background.jpg">
<SMRow> <SMRow>
<SMDialog narrow :loading="formLoading"> <SMDialog narrow class="mt-5">
<template v-if="!formDone"> <template v-if="!formDone">
<h1>Forgot Password</h1> <h1>Forgot Password</h1>
<SMMessage <SMForm v-model="form" @submit="handleSubmit">
v-if="formMessage.message" <SMInput control="username" />
:type="formMessage.type"
:message="formMessage.message"
:icon="formMessage.icon" />
<form @submit.prevent="submit">
<SMInput
v-model="formData.username.value"
name="username"
label="Username"
required
:error="formData.username.error"
@blur="fieldValidate(formData.username)" />
<SMCaptchaNotice />
<SMFormFooter> <SMFormFooter>
<template #left> <template #left>
<div> <div class="small">
<span class="pr-1">Remember?</span <span class="pr-1">Remember?</span
><router-link :to="{ name: 'login' }" ><router-link :to="{ name: 'login' }"
>Log in</router-link >Log in</router-link
@@ -31,10 +19,10 @@
<SMButton <SMButton
type="submit" type="submit"
label="Send" label="Send"
icon="fa-solid fa-arrow-right" /> icon="arrow-forward-outline" />
</template> </template>
</SMFormFooter> </SMFormFooter>
</form> </SMForm>
</template> </template>
<template v-else> <template v-else>
<h1>Email Sent!</h1> <h1>Email Sent!</h1>
@@ -51,78 +39,54 @@
</template> </template>
</SMDialog> </SMDialog>
</SMRow> </SMRow>
</SMContainer> </SMPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { api } from "../helpers/api";
import { FormObject, FormControl } from "../helpers/form";
import { And, Required, Min } from "../helpers/validate";
import { ref, reactive } from "vue"; import { ref, reactive } from "vue";
import SMInput from "../components/SMInput.vue";
import SMButton from "../components/SMButton.vue";
import SMFormFooter from "../components/SMFormFooter.vue";
import SMDialog from "../components/SMDialog.vue";
import SMMessage from "../components/SMMessage.vue";
import axios from "axios";
import { useRoute } from "vue-router";
import {
useValidation,
isValidated,
fieldValidate,
restParseErrors,
} from "../helpers/validation";
import SMCaptchaNotice from "../components/SMCaptchaNotice.vue";
import { useReCaptcha } from "vue-recaptcha-v3"; import { useReCaptcha } from "vue-recaptcha-v3";
import SMButton from "../components/SMButton.vue";
import SMDialog from "../components/SMDialog.vue";
import SMFormFooter from "../components/SMFormFooter.vue";
import SMInput from "../components/SMInput.vue";
import SMPage from "../components/SMPage.vue";
const { executeRecaptcha, recaptchaLoaded } = useReCaptcha(); const { executeRecaptcha, recaptchaLoaded } = useReCaptcha();
const formLoading = ref(false);
const formDone = ref(false); const formDone = ref(false);
const formMessage = reactive({ const form = reactive(
message: "", FormObject({
type: "error", username: FormControl("", And([Required(), Min(4)])),
icon: "", })
}); );
const formData = reactive({
username: {
value: "",
error: "",
rules: {
required: true,
required_message: "Your username is needed",
min: 4,
min_message: "Your username is at least %d characters",
},
},
});
useValidation(formData); const handleSubmit = async () => {
form.loading(true);
const submit = async () => {
formLoading.value = true;
formMessage.type = "error";
formMessage.icon = "fa-solid fa-circle-exclamation";
formMessage.message = "";
try { try {
if (isValidated(formData)) { await recaptchaLoaded();
await recaptchaLoaded(); const captcha = await executeRecaptcha("submit");
const captcha = await executeRecaptcha("submit");
let res = await axios.post("users/forgotPassword", { await api.post({
username: formData.username.value, url: "/users/forgotPassword",
body: {
username: form.username.value,
captcha_token: captcha, captcha_token: captcha,
}); },
});
formDone.value = true; formDone.value = true;
} } catch (error) {
} catch (err) { if (error.status == 422) {
if (err.response.status == 422) {
formDone.value = true; formDone.value = true;
} else { } else {
restParseErrors(formData, [formMessage, "message"], err); form.apiErrors(error);
} }
} }
formLoading.value = false; form.loading(false);
}; };
</script> </script>
<style lang="scss"></style>

View File

@@ -1,26 +1,14 @@
<template> <template>
<SMContainer> <SMPage no-breadcrumbs background="/img/background.jpg">
<SMRow> <SMRow>
<SMDialog narrow :loading="formLoading"> <SMDialog narrow class="mt-5">
<template v-if="!formDone"> <template v-if="!formDone">
<h1>Forgot Username</h1> <h1>Forgot Username</h1>
<SMMessage <SMForm v-model="form" @submit="handleSubmit">
v-if="formMessage.message" <SMInput control="email" />
:type="formMessage.type"
:message="formMessage.message"
:icon="formMessage.icon" />
<form @submit.prevent="submit">
<SMInput
v-model:error="formData.email.error"
v-model="formData.email.value"
name="email"
label="Email"
required
@blur="fieldValidate(formData.email)" />
<SMCaptchaNotice />
<SMFormFooter> <SMFormFooter>
<template #left> <template #left>
<div> <div class="small">
<span class="pr-1">Remember?</span <span class="pr-1">Remember?</span
><router-link :to="{ name: 'login' }" ><router-link :to="{ name: 'login' }"
>Log in</router-link >Log in</router-link
@@ -31,10 +19,10 @@
<SMButton <SMButton
type="submit" type="submit"
label="Send" label="Send"
icon="fa-solid fa-arrow-right" /> icon="arrow-forward-outline" />
</template> </template>
</SMFormFooter> </SMFormFooter>
</form> </SMForm>
</template> </template>
<template v-else> <template v-else>
<h1>Email Sent!</h1> <h1>Email Sent!</h1>
@@ -50,72 +38,52 @@
</template> </template>
</SMDialog> </SMDialog>
</SMRow> </SMRow>
</SMContainer> </SMPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive } from "vue"; import { ref, reactive } from "vue";
import SMInput from "../components/SMInput.vue"; import { api } from "../helpers/api";
import { FormObject, FormControl } from "../helpers/form";
import { And, Required, Email } from "../helpers/validate";
import SMButton from "../components/SMButton.vue"; import SMButton from "../components/SMButton.vue";
import SMFormFooter from "../components/SMFormFooter.vue";
import SMDialog from "../components/SMDialog.vue"; import SMDialog from "../components/SMDialog.vue";
import SMMessage from "../components/SMMessage.vue"; import SMForm from "../components/SMForm.vue";
import axios from "axios"; import SMFormFooter from "../components/SMFormFooter.vue";
import { import SMInput from "../components/SMInput.vue";
useValidation, import SMPage from "../components/SMPage.vue";
isValidated,
fieldValidate,
restParseErrors,
} from "../helpers/validation";
import SMCaptchaNotice from "../components/SMCaptchaNotice.vue";
import { useReCaptcha } from "vue-recaptcha-v3"; import { useReCaptcha } from "vue-recaptcha-v3";
const { executeRecaptcha, recaptchaLoaded } = useReCaptcha(); const { executeRecaptcha, recaptchaLoaded } = useReCaptcha();
const formLoading = ref(false);
const formDone = ref(false); const formDone = ref(false);
const formMessage = reactive({ const form = reactive(
message: "", FormObject({
type: "error", email: FormControl("", And([Required(), Email()])),
icon: "", })
}); );
const formData = reactive({
email: {
value: "",
error: "",
rules: {
required: true,
required_message: "An email address is required",
email: true,
},
},
});
useValidation(formData); const handleSubmit = async () => {
form.loading(true);
const submit = async () => {
formLoading.value = true;
formMessage.type = "error";
formMessage.icon = "fa-solid fa-circle-exclamation";
formMessage.message = "";
try { try {
if (isValidated(formData)) { await recaptchaLoaded();
await recaptchaLoaded(); const captcha = await executeRecaptcha("submit");
const captcha = await executeRecaptcha("submit");
let res = await axios.post("users/forgotUsername", { await api.post({
email: formData.email.value, url: "/users/forgotUsername",
body: {
email: form.email.value,
captcha_token: captcha, captcha_token: captcha,
}); },
});
formDone.value = true; formDone.value = true;
} } catch (error) {
} catch (err) { form.apiErrors(error);
restParseErrors(formData, [formMessage, "message"], err);
} }
formLoading.value = false; form.loading(false);
}; };
</script> </script>
<style lang="scss"></style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<SMContainer full class="home"> <SMPage full class="home">
<SMCarousel> <SMCarousel>
<SMCarouselSlide <SMCarouselSlide
v-for="(slide, index) in slides" v-for="(slide, index) in slides"
@@ -110,100 +110,85 @@
Sign up for our mailing list to receive expert tips and tricks, Sign up for our mailing list to receive expert tips and tricks,
as well as updates on upcoming workshops. as well as updates on upcoming workshops.
</p> </p>
<SMDialog :loading="formLoading" class="p-0"> <SMDialog class="p-0">
<form @submit.prevent="handleSubscribe"> <SMForm v-model="form" @submit.prevent="handleSubscribe">
<div class="form-row"> <div class="form-row">
<SMMessage <SMInput control="email" />
v-if="formMessage.message"
:type="formMessage.type"
:message="formMessage.message"
:icon="formMessage.icon" />
<SMInput
v-model="subscribeFormData.email.value"
placeholder="Email address"
:error="subscribeFormData.email.error"
@blur="fieldValidate(subscribeFormData.email)" />
<SMCaptchaNotice />
<SMButton type="submit" label="Subscribe" /> <SMButton type="submit" label="Subscribe" />
</div> </div>
</form> </SMForm>
</SMDialog> </SMDialog>
</SMContainer> </SMContainer>
</SMContainer> </SMPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import axios from "axios";
import { reactive, ref } from "vue"; import { reactive, ref } from "vue";
import { buildUrlQuery, excerpt, timestampNowUtc } from "../helpers/common"; import { excerpt } from "../helpers/common";
import { import { timestampNowUtc } from "../helpers/datetime";
useValidation,
isValidated,
fieldValidate,
restParseErrors,
clearFormData,
} from "../helpers/validation";
import SMInput from "../components/SMInput.vue"; import SMInput from "../components/SMInput.vue";
import SMButton from "../components/SMButton.vue"; import SMButton from "../components/SMButton.vue";
import SMCarousel from "../components/SMCarousel.vue"; import SMCarousel from "../components/SMCarousel.vue";
import SMCarouselSlide from "../components/SMCarouselSlide.vue"; import SMCarouselSlide from "../components/SMCarouselSlide.vue";
import SMMessage from "../components/SMMessage.vue"; import SMForm from "../components/SMForm.vue";
import SMCaptchaNotice from "../components/SMCaptchaNotice.vue";
import SMDialog from "../components/SMDialog.vue"; import SMDialog from "../components/SMDialog.vue";
import SMPage from "../components/SMPage.vue";
import { useReCaptcha } from "vue-recaptcha-v3"; import { useReCaptcha } from "vue-recaptcha-v3";
import { FormObject, FormControl } from "../helpers/form";
import { And, Email, Required } from "../helpers/validate";
import { api } from "../helpers/api";
const slides = ref([]); const slides = ref([]);
const { executeRecaptcha, recaptchaLoaded } = useReCaptcha(); const { executeRecaptcha, recaptchaLoaded } = useReCaptcha();
const subscribeFormData = reactive({ const form = reactive(
email: { FormObject({
value: "", email: FormControl("", And([Required(), Email()])),
error: "", })
rules: { );
required: true,
required_message: "An email address is needed.",
email: true,
email_message: "That does not appear to be an email address.",
},
},
});
const formMessage = reactive({
message: "",
type: "error",
icon: "",
});
const formLoading = ref(false);
const handleLoad = async () => { const handleLoad = async () => {
slides.value = []; slides.value = [];
let posts = []; let posts = [];
let events = []; let events = [];
try { api.get({
let result = await axios.get(buildUrlQuery("posts", { limit: 3 })); url: "/posts",
if (result.data.posts) { params: {
result.data.posts.forEach((post) => {
posts.push({
title: post.title,
content: excerpt(post.content, 200),
image: post.hero,
url: { name: "post-view", params: { slug: post.slug } },
cta: "Read More...",
});
});
}
} catch (error) {
/* empty */
}
try {
let query = {
limit: 3, limit: 3,
end_at: ">" + timestampNowUtc(), },
}; progress: ({ loaded, total }) => {
console.log("progress", `${loaded} - ${total}`);
},
})
.then((response) => {
if (response.data.posts) {
response.data.posts.forEach((post) => {
posts.push({
title: post.title,
content: excerpt(post.content, 200),
image: post.hero,
url: { name: "post-view", params: { slug: post.slug } },
cta: "Read More...",
});
});
}
})
.catch((error) => {
console.log("error", error);
/* empty */
});
let result = await axios.get(buildUrlQuery("events", query)); try {
if (result.data.events) { let result = await api.get({
result.data.events.forEach((event) => { url: "/events",
params: {
limit: 3,
end_at: ">" + timestampNowUtc(),
},
});
if (result.json.events) {
result.json.events.forEach((event) => {
events.push({ events.push({
title: event.title, title: event.title,
content: excerpt(event.content, 200), content: excerpt(event.content, 200),
@@ -228,34 +213,30 @@ const handleLoad = async () => {
}; };
const handleSubscribe = async () => { const handleSubscribe = async () => {
formLoading.value = true; form.loading(true);
formMessage.icon = ""; form.message();
formMessage.type = "error";
formMessage.message = "";
try { try {
if (isValidated(subscribeFormData)) { await recaptchaLoaded();
await recaptchaLoaded(); const captcha = await executeRecaptcha("submit");
const captcha = await executeRecaptcha("submit");
await axios.post("subscriptions", { await api.post({
email: subscribeFormData.email.value, url: "/subscriptions",
body: {
email: form.email.value,
captcha_token: captcha, captcha_token: captcha,
}); },
});
clearFormData(subscribeFormData); form.email.value = "";
form.message("Your email address has been subscribed.", "success");
formMessage.type = "success";
formMessage.message = "Your email address has been subscribed.";
}
} catch (err) { } catch (err) {
restParseErrors(subscribeFormData, [formMessage, "message"], err); form.apiErrors(err);
} }
formLoading.value = false; form.loading(false);
}; };
useValidation(subscribeFormData);
handleLoad(); handleLoad();
</script> </script>

View File

@@ -1,157 +1,96 @@
<template> <template>
<SMContainer> <SMPage no-breadcrumbs background="/img/background.jpg">
<SMRow> <SMDialog narrow class="mt-5">
<SMColumn> <h1>Log in</h1>
<SMDialog narrow> <SMForm v-model="form" @submit="handleSubmit">
<h1>Log in</h1> <SMInput control="username">
<SMMessage <router-link to="/forgot-username"
v-if="formMessage.message" >Forgot username?</router-link
:type="formMessage.type" >
:message="formMessage.message" </SMInput>
:icon="formMessage.icon" /> <SMInput control="password" type="password">
<form @submit.prevent="submit"> <router-link to="/forgot-password"
<SMInput >Forgot password?</router-link
v-model:error="formData.username.error" >
v-model="formData.username.value" </SMInput>
name="username" <SMFormFooter>
label="Username" <template #left>
required <div class="small">
@blur="fieldValidate(formData.username)"> <span class="pr-1">Need an account?</span
<router-link to="/forgot-username" ><router-link to="/register">Register</router-link>
>Forgot username?</router-link </div>
> </template>
</SMInput> <template #right>
<SMInput <SMButton
v-model="formData.password.value" type="submit"
name="password" label="Log in"
type="password" icon="arrow-forward-outline" />
label="Password" </template>
required </SMFormFooter>
:error="formData.password.error" </SMForm>
@blur="fieldValidate(formData.password)"> </SMDialog>
<router-link to="/forgot-password" </SMPage>
>Forgot password?</router-link
>
</SMInput>
<SMCaptchaNotice />
<SMFormFooter>
<template #left>
<div>
<span class="pr-1">Need an account?</span
><router-link to="/register"
>Register</router-link
>
</div>
</template>
<template #right>
<SMButton
type="submit"
label="Log in"
icon="fa-solid fa-arrow-right" />
</template>
</SMFormFooter>
</form>
</SMDialog>
</SMColumn>
</SMRow>
</SMContainer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive } from "vue"; import { reactive } from "vue";
import { useUserStore } from "../store/UserStore";
import { useRoute, useRouter } from "vue-router";
import { api } from "../helpers/api";
import { FormObject, FormControl } from "../helpers/form";
import { And, Min, Required, Password } from "../helpers/validate";
import SMPage from "../components/SMPage.vue";
import SMInput from "../components/SMInput.vue"; import SMInput from "../components/SMInput.vue";
import SMButton from "../components/SMButton.vue"; import SMButton from "../components/SMButton.vue";
import SMFormFooter from "../components/SMFormFooter.vue"; import SMFormFooter from "../components/SMFormFooter.vue";
import SMDialog from "../components/SMDialog.vue"; import SMDialog from "../components/SMDialog.vue";
import SMMessage from "../components/SMMessage.vue"; import SMForm from "../components/SMForm.vue";
import axios from "axios";
import {
useValidation,
isValidated,
fieldValidate,
restParseErrors,
} from "../helpers/validation";
import { useUserStore } from "../store/UserStore";
import { useRoute, useRouter } from "vue-router";
import SMCaptchaNotice from "../components/SMCaptchaNotice.vue";
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const formLoading = ref(false); const form = reactive(
const formMessage = reactive({ FormObject({
message: "", username: FormControl("", And([Required(), Min(4)])),
type: "error", password: FormControl("", Password()),
icon: "", })
}); );
const formData = reactive({
username: {
value: "",
error: "",
rules: {
required: true,
required_message: "Your username is needed",
min: 4,
min_message: "Your username is at least 6 characters",
},
},
password: {
value: "",
error: "",
rules: {
required: true,
required_message: "A password is required",
min: 8,
min_message: "Your password needs to be at least %d characters",
password: "special",
password_message:
"Your password needs to have at least a letter, a number and a special character",
},
},
});
useValidation(formData);
const redirect = useRoute().query.redirect; const redirect = useRoute().query.redirect;
const submit = async () => { const handleSubmit = async () => {
formLoading.value = true; form.message();
formMessage.type = "error"; form.loading(true);
formMessage.icon = "fa-solid fa-circle-exclamation";
formMessage.message = "";
try { try {
if (isValidated(formData)) { let res = await api.post({
let res = await axios.post("login", { url: "/login",
username: formData.username.value, body: {
password: formData.password.value, username: form.username.value,
}); password: form.password.value,
},
});
if (res.data.token !== undefined) { userStore.setUserDetails(res.json.user);
userStore.setUserDetails(res.data.user); userStore.setUserToken(res.json.token);
userStore.setUserToken(res.data.token); if (redirect !== undefined) {
if (redirect !== undefined) { if (redirect.startsWith("api/")) {
if (redirect.startsWith("api/")) { window.location.href =
window.location.href = redirect + "?token=" + encodeURIComponent(res.json.token);
redirect +
"?token=" +
encodeURIComponent(res.data.token);
} else {
router.push({ path: redirect });
}
} else {
router.push({ name: "dashboard" });
}
} else { } else {
formMessage.message = router.push({ path: redirect });
"An unexpected error occurred on the server. Please try again later";
} }
} else {
router.push({ name: "dashboard" });
} }
} catch (err) { } catch (err) {
restParseErrors(formData, [formMessage, "message"], err); console.log(err);
form.apiErrors(err);
} }
formLoading.value = false; form.loading(false);
}; };
</script>
<style lang="scss"></style> if (userStore.token) {
userStore.clearUser();
}
</script>

View File

@@ -1,7 +1,7 @@
<template> <template>
<SMContainer> <SMPage no-breadcrumbs background="/img/background.jpg">
<SMRow> <SMRow>
<SMDialog narrow> <SMDialog narrow class="mt-5" :loading="formLoading">
<h1>Logged out</h1> <h1>Logged out</h1>
<SMRow> <SMRow>
<SMColumn class="justify-content-center"> <SMColumn class="justify-content-center">
@@ -17,33 +17,28 @@
</SMRow> </SMRow>
</SMDialog> </SMDialog>
</SMRow> </SMRow>
</SMContainer> </SMPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive } from "vue"; import { api } from "../helpers/api";
import SMButton from "@/components/SMButton.vue"; import { ref } from "vue";
import SMDialog from "../components/SMDialog.vue";
import SMMessage from "../components/SMMessage.vue";
import axios from "axios";
import { useUserStore } from "../store/UserStore"; import { useUserStore } from "../store/UserStore";
import { useRouter } from "vue-router";
const router = useRouter(); import SMButton from "../components/SMButton.vue";
import SMDialog from "../components/SMDialog.vue";
import SMPage from "../components/SMPage.vue";
const userStore = useUserStore(); const userStore = useUserStore();
const formLoading = ref(false); const formLoading = ref(false);
const formMessage = reactive({
type: "info",
message: "Logging you out...",
icon: "",
});
formLoading.value = true;
const logout = async () => { const logout = async () => {
formLoading.value = true; formLoading.value = true;
try { try {
await axios.post("logout"); await api.post({
url: "/logout",
});
} catch (err) { } catch (err) {
console.log(err); console.log(err);
} }

View File

@@ -1,5 +1,5 @@
<template> <template>
<SMContainer class="news-list"> <SMPage class="news-list">
<SMMessage <SMMessage
v-if="formMessage.message" v-if="formMessage.message"
:icon="formMessage.icon" :icon="formMessage.icon"
@@ -22,15 +22,16 @@
button="Read More" button="Read More"
button-type="outline" /> button-type="outline" />
</SMPanelList> </SMPanelList>
</SMContainer> </SMPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref } from "vue"; import { reactive, ref } from "vue";
import axios from "axios"; import { api } from "../helpers/api";
import SMMessage from "../components/SMMessage.vue"; import SMMessage from "../components/SMMessage.vue";
import SMPanelList from "../components/SMPanelList.vue"; import SMPanelList from "../components/SMPanelList.vue";
import SMPanel from "../components/SMPanel.vue"; import SMPanel from "../components/SMPanel.vue";
import SMPage from "../components/SMPage.vue";
import { timestampUtcToLocal } from "../helpers/common"; import { timestampUtcToLocal } from "../helpers/common";
const formMessage = reactive({ const formMessage = reactive({
@@ -48,8 +49,13 @@ const handleLoad = async () => {
formMessage.message = ""; formMessage.message = "";
try { try {
let result = await axios.get("posts?limit=5"); let result = await api.get({
posts.value = result.data.posts; url: "/posts",
params: {
limit: 5,
},
});
posts.value = result.json.posts;
posts.value.forEach((post) => { posts.value.forEach((post) => {
post.publish_at = timestampUtcToLocal(post.publish_at); post.publish_at = timestampUtcToLocal(post.publish_at);
@@ -65,5 +71,3 @@ const handleLoad = async () => {
handleLoad(); handleLoad();
</script> </script>
<style lang="scss"></style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<SMContainer :loading="pageLoading" full class="page-post-view"> <SMPage :loading="pageLoading" full class="page-post-view">
<SMPageError :error="error"> <SMPageError :error="error">
<div <div
class="heading-image" class="heading-image"
@@ -10,7 +10,7 @@
<div class="heading-info"> <div class="heading-info">
<h1>{{ post.title }}</h1> <h1>{{ post.title }}</h1>
<div class="date-author"> <div class="date-author">
<font-awesome-icon icon="fa-solid fa-calendar" /> <ion-icon name="calendar-outline" />
{{ formattedPublishAt(post.publish_at) }}, by {{ formattedPublishAt(post.publish_at) }}, by
{{ post.user_username }} {{ post.user_username }}
</div> </div>
@@ -18,16 +18,17 @@
<component :is="formattedContent" ref="content"></component> <component :is="formattedContent" ref="content"></component>
</SMContainer> </SMContainer>
</SMPageError> </SMPageError>
</SMContainer> </SMPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import axios from "axios";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import SMPageError from "../components/SMPageError.vue"; import SMPageError from "../components/SMPageError.vue";
import { fullMonthString, timestampUtcToLocal } from "../helpers/common"; import { fullMonthString, timestampUtcToLocal } from "../helpers/common";
import { useApplicationStore } from "../store/ApplicationStore"; import { useApplicationStore } from "../store/ApplicationStore";
import { api } from "../helpers/api";
import SMPage from "../components/SMPage.vue";
const applicationStore = useApplicationStore(); const applicationStore = useApplicationStore();
const route = useRoute(); const route = useRoute();
@@ -39,16 +40,20 @@ let pageLoading = ref(true);
const loadData = async () => { const loadData = async () => {
if (route.params.slug) { if (route.params.slug) {
try { try {
let res = await axios.get( let res = await api.get({
`posts?slug==${route.params.slug}&limit=1` url: "/posts",
); params: {
if (!res.data.posts) { slug: `=${route.params.slug}`,
limit: 1,
},
});
if (!res.json.posts) {
error.value = 500; error.value = 500;
} else { } else {
if (res.data.total == 0) { if (res.json.total == 0) {
error.value = 404; error.value = 404;
} else { } else {
post.value = res.data.posts[0]; post.value = res.json.posts[0];
post.value.publish_at = timestampUtcToLocal( post.value.publish_at = timestampUtcToLocal(
post.value.publish_at post.value.publish_at
@@ -57,19 +62,19 @@ const loadData = async () => {
applicationStore.setDynamicTitle(post.value.title); applicationStore.setDynamicTitle(post.value.title);
try { try {
let result = await axios.get( let result = await api.get({
`media/${post.value.hero}` url: `/media/${post.value.hero}`,
); });
post.value.hero_url = result.data.medium.url; post.value.hero_url = result.json.medium.url;
} catch (error) { } catch (error) {
/* empty */ /* empty */
} }
try { try {
let result = await axios.get( let result = await api.get({
`users/${post.value.user_id}` url: `/users/${post.value.user_id}`,
); });
post.value.user_username = result.data.user.username; post.value.user_username = result.json.user.username;
} catch (error) { } catch (error) {
/* empty */ /* empty */
} }
@@ -144,12 +149,15 @@ loadData();
.content { .content {
margin-top: map-get($spacer, 4); margin-top: map-get($spacer, 4);
line-height: 1.5rem;
padding: 0 map-get($spacer, 3); padding: 0 map-get($spacer, 3);
a span { a span {
color: $primary-color !important; color: $primary-color !important;
} }
p {
line-height: 1.5rem;
}
} }
} }

View File

@@ -1,76 +1,38 @@
<template> <template>
<SMContainer> <SMContainer>
<SMRow> <SMRow>
<SMDialog :narrow="formDone" :loading="formLoading"> <SMDialog :narrow="formDone">
<template v-if="!formDone"> <template v-if="!formDone">
<h1>Register</h1> <h1>Register</h1>
<SMMessage <SMForm v-model="form" @submit="handleSubmit">
v-if="formMessage.message"
:type="formMessage.type"
:message="formMessage.message"
:icon="formMessage.icon" />
<form @submit.prevent="submit">
<SMRow> <SMRow>
<SMColumn> <SMColumn>
<SMInput <SMInput control="username" />
v-model="formData.username.value"
label="Username"
required
:error="formData.username.error"
@blur="
fieldValidate(formData.username)
"></SMInput>
</SMColumn> </SMColumn>
<SMColumn> <SMColumn>
<SMInput <SMInput
v-model="formData.password.value" control="password"
type="password" type="password"></SMInput>
label="Password"
required
:error="formData.password.error"
@blur="
fieldValidate(formData.password)
"></SMInput>
</SMColumn> </SMColumn>
</SMRow> </SMRow>
<SMRow> <SMRow>
<SMColumn> <SMColumn>
<SMInput <SMInput control="first_name" />
v-model="formData.first_name.value"
label="First Name"
required
:error="formData.first_name.error"
@blur="
fieldValidate(formData.first_name)
" />
</SMColumn> </SMColumn>
<SMColumn> <SMColumn>
<SMInput <SMInput control="last_name" />
v-model="formData.last_name.value"
label="Last Name"
required
:error="formData.last_name.error"
@blur="fieldValidate(formData.last_name)" />
</SMColumn> </SMColumn>
</SMRow> </SMRow>
<SMRow> <SMRow>
<SMColumn> <SMColumn>
<SMInput <SMInput control="email" />
v-model="formData.email.value"
label="Email"
required
:error="formData.email.error"
@blur="fieldValidate(formData.email)" />
</SMColumn> </SMColumn>
<SMColumn> <SMColumn>
<SMInput <SMInput control="phone">
v-model="formData.phone.value" This field is optional.
label="Phone Number" </SMInput>
:error="formData.phone.error"
@blur="fieldValidate(formData.phone)" />
</SMColumn> </SMColumn>
</SMRow> </SMRow>
<SMCaptchaNotice />
<SMFormFooter> <SMFormFooter>
<template #left> <template #left>
<div> <div>
@@ -85,10 +47,10 @@
<SMButton <SMButton
type="submit" type="submit"
label="Register" label="Register"
icon="fa-solid fa-arrow-right" /> icon="arrow-forward-outline" />
</template> </template>
</SMFormFooter> </SMFormFooter>
</form> </SMForm>
</template> </template>
<template v-else> <template v-else>
<h1>Email Sent!</h1> <h1>Email Sent!</h1>
@@ -113,154 +75,95 @@ import SMInput from "../components/SMInput.vue";
import SMButton from "../components/SMButton.vue"; import SMButton from "../components/SMButton.vue";
import SMFormFooter from "../components/SMFormFooter.vue"; import SMFormFooter from "../components/SMFormFooter.vue";
import SMDialog from "../components/SMDialog.vue"; import SMDialog from "../components/SMDialog.vue";
import SMMessage from "../components/SMMessage.vue"; import SMForm from "../components/SMForm.vue";
import axios from "axios"; import { api } from "../helpers/api";
import { FormControl, FormObject } from "../helpers/form";
import { import {
useValidation, And,
isValidated, Custom,
fieldValidate, Email,
restParseErrors, Min,
} from "../helpers/validation"; Password,
Phone,
Required,
} from "../helpers/validate";
import { debounce } from "../helpers/common"; import { debounce } from "../helpers/common";
import SMCaptchaNotice from "../components/SMCaptchaNotice.vue";
import { useReCaptcha } from "vue-recaptcha-v3"; import { useReCaptcha } from "vue-recaptcha-v3";
const { executeRecaptcha, recaptchaLoaded } = useReCaptcha(); const { executeRecaptcha, recaptchaLoaded } = useReCaptcha();
const lastUsernameCheck = ref("");
const formLoading = ref(false); const checkUsername = (value: string): boolean | string => {
const formDone = ref(false); if (lastUsernameCheck.value != form.username.value) {
const formMessage = reactive({ lastUsernameCheck.value = form.username.value;
message: "", api.get({
type: "error", url: "/users",
icon: "", params: {
}); username: form.username.value,
const formData = reactive({
first_name: {
value: "",
error: "",
rules: {
required: true,
required_message: "A first name is needed",
min: 2,
min_message: "Your first name should be at least 2 letters long",
},
},
last_name: {
value: "",
error: "",
rules: {
required: true,
required_message: "A last name is needed",
min: 2,
min_message: "Your last name should be at least 2 letters long",
},
},
email: {
value: "",
error: "",
rules: {
required: true,
required_message: "A email address is needed",
email: true,
email_message: "Your email address is not correct",
},
},
phone: {
value: "",
error: "",
rules: {
phone: true,
phone_message: "Your phone number does not look correct",
},
},
username: {
value: "",
error: "",
rules: {
required: true,
required_message: "A username is needed",
min: 4,
min_message: "Your username needs to be at least %d characters",
custom: () => {
checkUsername();
}, },
}, })
}, .then((response) => {
password: { return "The username has already been taken.";
value: "", })
error: "", .catch((error) => {
rules: { if (error.status != 404) {
required: true, return (
required_message: "A password is needed", error.json?.message ||
min: 8, "An unexpected server error occurred."
min_message: "Your password needs to be at least %d characters", );
password: "special", }
password_message:
"Your password needs to have at least a letter, a number and a special character",
},
},
});
useValidation(formData);
const submit = async () => {
formLoading.value = true;
formMessage.type = "error";
formMessage.icon = "fa-solid fa-circle-exclamation";
formMessage.message = "";
try {
if (isValidated(formData)) {
await recaptchaLoaded();
const captcha = await executeRecaptcha("submit");
let res = await axios.post("register", {
first_name: formData.first_name.value,
last_name: formData.last_name.value,
email: formData.email.value,
phone: formData.phone.value,
username: formData.username.value,
password: formData.password.value,
captcha_token: captcha,
}); });
formDone.value = true;
}
} catch (err) {
restParseErrors(formData, [formMessage, "message"], err);
} }
formLoading.value = false; return true;
}; };
const checkUsername = async () => { const formDone = ref(false);
const form = reactive(
FormObject({
first_name: FormControl("", Required()),
last_name: FormControl("", Required()),
email: FormControl("", And([Required(), Email()])),
phone: FormControl("", Phone()),
username: FormControl("", And([Min(4), Custom(checkUsername)])),
password: FormControl("", And([Required(), Password()])),
})
);
const handleSubmit = async () => {
form.loading(true);
try { try {
if ( await recaptchaLoaded();
formData.username.value.length >= 4 && const captcha = await executeRecaptcha("submit");
lastUsernameCheck.value != formData.username.value
) { await api.post({
lastUsernameCheck.value = formData.username.value; url: "/register",
await axios.get(`users?username=${formData.username.value}`); body: {
formData.username.error = "The username has already been taken."; first_name: form.first_name.value,
} last_name: form.last_name.value,
} catch (error) { email: form.email.value,
if (error.response.status == 404) { phone: form.phone.value,
formData.username.error = ""; username: form.username.value,
} else { password: form.password.value,
formMessage.type = "error"; captcha_token: captcha,
formMessage.icon = "fa-solid fa-circle-exclamation"; },
formMessage.message = });
error.response.message ||
"An unexpected server error occurred."; formDone.value = true;
} } catch (err) {
form.apiErrors(err);
} }
form.loading(false);
}; };
const lastUsernameCheck = ref("");
const debouncedFilter = debounce(checkUsername, 1000); const debouncedFilter = debounce(checkUsername, 1000);
let oldUsernameValue = ""; let oldUsernameValue = "";
watch( watch(
formData, form,
(value) => { (value) => {
if (value.username.value !== oldUsernameValue) { if (value.username.value !== oldUsernameValue) {
oldUsernameValue = value.username.value; oldUsernameValue = value.username.value;

View File

@@ -1,26 +1,14 @@
<template> <template>
<SMContainer> <SMPage no-breadcrumbs background="/img/background.jpg">
<SMRow> <SMRow>
<SMDialog narrow :loading="formLoading"> <SMDialog narrow>
<template v-if="!formDone"> <template v-if="!formDone">
<h1>Resend Verify Email</h1> <h1>Resend Verify Email</h1>
<SMMessage <SMForm v-model="form" @submit="handleSubmit">
v-if="formMessage.message" <SMInput control="username" />
:type="formMessage.type"
:message="formMessage.message"
:icon="formMessage.icon" />
<form @submit.prevent="submit">
<SMInput
v-model="formData.username.value"
name="username"
label="Username"
required
:error="formData.username.error"
@blur="fieldValidate(formData.username)" />
<SMCaptchaNotice />
<SMFormFooter> <SMFormFooter>
<template #left> <template #left>
<div> <div class="small">
<span class="pr-1">Stuck?</span <span class="pr-1">Stuck?</span
><router-link to="/contact" ><router-link to="/contact"
>Contact Us</router-link >Contact Us</router-link
@@ -31,10 +19,10 @@
<SMButton <SMButton
type="submit" type="submit"
label="Send" label="Send"
icon="fa-solid fa-arrow-right" /> icon="arrow-forward-outline" />
</template> </template>
</SMFormFooter> </SMFormFooter>
</form> </SMForm>
</template> </template>
<template v-else> <template v-else>
<h1>Email Sent!</h1> <h1>Email Sent!</h1>
@@ -51,7 +39,7 @@
</template> </template>
</SMDialog> </SMDialog>
</SMRow> </SMRow>
</SMContainer> </SMPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -60,69 +48,45 @@ import SMInput from "../components/SMInput.vue";
import SMButton from "../components/SMButton.vue"; import SMButton from "../components/SMButton.vue";
import SMFormFooter from "../components/SMFormFooter.vue"; import SMFormFooter from "../components/SMFormFooter.vue";
import SMDialog from "../components/SMDialog.vue"; import SMDialog from "../components/SMDialog.vue";
import SMMessage from "../components/SMMessage.vue"; import SMForm from "../components/SMForm.vue";
import axios from "axios"; import SMPage from "../components/SMPage.vue";
import { useRoute } from "vue-router"; import { api } from "../helpers/api";
import { import { Required } from "../helpers/validate";
useValidation,
isValidated,
fieldValidate,
restParseErrors,
} from "../helpers/validation";
import SMCaptchaNotice from "../components/SMCaptchaNotice.vue";
import { useReCaptcha } from "vue-recaptcha-v3"; import { useReCaptcha } from "vue-recaptcha-v3";
import { FormObject, FormControl } from "../helpers/form";
const { executeRecaptcha, recaptchaLoaded } = useReCaptcha(); const { executeRecaptcha, recaptchaLoaded } = useReCaptcha();
const formLoading = ref(false);
const formDone = ref(false); const formDone = ref(false);
const formMessage = reactive({ const form = reactive(
message: "", FormObject({
type: "error", username: FormControl("", Required()),
icon: "", })
}); );
const formData = reactive({
username: {
value: "",
error: "",
rules: {
required: true,
required_message: "Your username is needed",
min: 4,
min_message: "Your username is at least %d characters",
},
},
});
useValidation(formData); const handleSubmit = async () => {
form.loading(true);
const submit = async () => {
formLoading.value = true;
formMessage.type = "error";
formMessage.icon = "fa-solid fa-circle-exclamation";
formMessage.message = "";
try { try {
if (isValidated(formData)) { await recaptchaLoaded();
await recaptchaLoaded(); const captcha = await executeRecaptcha("submit");
const captcha = await executeRecaptcha("submit");
let res = await axios.post("users/resendVerifyEmailCode", { await api.post({
username: formData.username.value, url: "/users/resendVerifyEmailCode",
body: {
username: form.username.value,
captcha_token: captcha, captcha_token: captcha,
}); },
});
formDone.value = true; formDone.value = true;
} } catch (error) {
} catch (err) { if (error.status == 422) {
if (err.response.status == 422) {
formDone.value = true; formDone.value = true;
} else { } else {
restParseErrors(formData, [formMessage, "message"], err); form.apiErrors(error);
} }
} }
formLoading.value = false; form.loading(false);
}; };
</script> </script>
<style lang="scss"></style>

View File

@@ -1,34 +1,15 @@
<template> <template>
<SMContainer> <SMPage no-breadcrumbs background="/img/background.jpg">
<SMRow> <SMRow>
<SMDialog narrow :loading="formLoading"> <SMDialog narrow>
<template v-if="!formDone"> <template v-if="!formDone">
<h1>Reset Password</h1> <h1>Reset Password</h1>
<SMMessage <SMForm v-model="form" @submit="handleSubmit">
v-if="formMessage.message" <SMInput control="code" />
:type="formMessage.type" <SMInput control="password" type="password" />
:message="formMessage.message"
:icon="formMessage.icon" />
<form @submit.prevent="submit">
<SMInput
v-model="formData.code.value"
name="code"
label="Reset Code"
required
:error="formData.code.error"
@blur="fieldValidate(formData.code)" />
<SMInput
v-model="formData.password.value"
type="password"
name="password"
label="New Password"
required
:error="formData.password.error"
@blur="fieldValidate(formData.password)" />
<SMCaptchaNotice />
<SMFormFooter> <SMFormFooter>
<template #left> <template #left>
<div> <div class="small">
<router-link <router-link
:to="{ name: 'forgot-password' }" :to="{ name: 'forgot-password' }"
>Resend Code</router-link >Resend Code</router-link
@@ -39,10 +20,10 @@
<SMButton <SMButton
type="submit" type="submit"
label="Reset Password" label="Reset Password"
icon="fa-solid fa-arrow-right" /> icon="arrow-forward-outline" />
</template> </template>
</SMFormFooter> </SMFormFooter>
</form> </SMForm>
</template> </template>
<template v-else> <template v-else>
<h1>Password Reset!</h1> <h1>Password Reset!</h1>
@@ -57,94 +38,57 @@
</template> </template>
</SMDialog> </SMDialog>
</SMRow> </SMRow>
</SMContainer> </SMPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { api } from "../helpers/api";
import { FormObject, FormControl } from "../helpers/form";
import { And, Required, Min, Max, Password } from "../helpers/validate";
import { ref, reactive } from "vue"; import { ref, reactive } from "vue";
import SMInput from "../components/SMInput.vue";
import SMButton from "../components/SMButton.vue";
import SMFormFooter from "../components/SMFormFooter.vue";
import SMDialog from "../components/SMDialog.vue";
import SMMessage from "../components/SMMessage.vue";
import axios from "axios";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import {
useValidation,
isValidated,
fieldValidate,
restParseErrors,
} from "../helpers/validation";
import SMCaptchaNotice from "../components/SMCaptchaNotice.vue";
import { useReCaptcha } from "vue-recaptcha-v3"; import { useReCaptcha } from "vue-recaptcha-v3";
import SMButton from "../components/SMButton.vue";
import SMDialog from "../components/SMDialog.vue";
import SMForm from "../components/SMForm.vue";
import SMFormFooter from "../components/SMFormFooter.vue";
import SMInput from "../components/SMInput.vue";
import SMPage from "../components/SMPage.vue";
const { executeRecaptcha, recaptchaLoaded } = useReCaptcha(); const { executeRecaptcha, recaptchaLoaded } = useReCaptcha();
const formLoading = ref(false);
const formDone = ref(false); const formDone = ref(false);
const formMessage = reactive({ const form = reactive(
message: "", FormObject({
type: "error", code: FormControl("", And([Required(), Min(6), Max(6)])),
icon: "", password: FormControl("", And([Required(), Password()])),
}); })
const formData = reactive({ );
code: {
value: "",
error: "",
rules: {
required: true,
required_message: "The code is needed",
min: 6,
min_message: "The code should be 6 characters",
max: 6,
max_message: "The code should be 6 characters",
},
},
password: {
value: "",
error: "",
rules: {
required: true,
required_message: "A new password is required",
min: 8,
min_message: "Your password needs to be at least %d characters",
password: "special",
password_message:
"Your password needs to have at least a letter, a number and a special character",
},
},
});
useValidation(formData);
if (useRoute().query.code !== undefined) { if (useRoute().query.code !== undefined) {
formData.code.value = useRoute().query.code; form.code.value = useRoute().query.code;
} }
const submit = async () => { const handleSubmit = async () => {
formLoading.value = true; form.loading(true);
formMessage.type = "error";
formMessage.icon = "fa-solid fa-circle-exclamation";
formMessage.message = "";
try { try {
if (isValidated(formData)) { await recaptchaLoaded();
await recaptchaLoaded(); const captcha = await executeRecaptcha("submit");
const captcha = await executeRecaptcha("submit");
let res = await axios.post("users/resetPassword", { await api.post({
code: formData.code.value, url: "/users/resetPassword",
password: formData.password.value, body: {
code: form.code.value,
password: form.password.value,
captcha_token: captcha, captcha_token: captcha,
}); },
});
formDone.value = true; formDone.value = true;
} } catch (error) {
} catch (err) { form.apiError(error);
restParseErrors(formData, [formMessage, "message"], err);
} }
formLoading.value = false; form.loading(false);
}; };
</script> </script>
<style lang="scss"></style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<SMContainer class="rules"> <SMPage class="rules">
<h1>Rules</h1> <h1>Rules</h1>
<p> <p>
Oh gosh, no body likes rules but to ensure that we have a fun, Oh gosh, no body likes rules but to ensure that we have a fun,
@@ -72,9 +72,13 @@
grief other players builds outside of the Survival game-mode. grief other players builds outside of the Survival game-mode.
</li> </li>
</ul> </ul>
</SMContainer> </SMPage>
</template> </template>
<script setup lang="ts">
import SMPage from "../components/SMPage.vue";
</script>
<style lang="scss"> <style lang="scss">
.rules { .rules {
h2 { h2 {

View File

@@ -1,5 +1,5 @@
<template> <template>
<SMContainer class="terms"> <SMPage class="terms">
<h1>Terms and Conditions</h1> <h1>Terms and Conditions</h1>
<p> <p>
Please read these terms carefully. By accessing or using our website Please read these terms carefully. By accessing or using our website
@@ -560,5 +560,9 @@
be responsible for warranty and after sales service but this is be responsible for warranty and after sales service but this is
allowed under the Law. allowed under the Law.
</p> </p>
</SMContainer> </SMPage>
</template> </template>
<script setup lang="ts">
import SMPage from "../components/SMPage.vue";
</script>

View File

@@ -1,33 +1,21 @@
<template> <template>
<SMContainer> <SMPage no-breadcrumbs background="/img/background.jpg">
<SMRow> <SMRow>
<SMDialog narrow :loading="formLoading"> <SMDialog narrow>
<template v-if="!formDone"> <template v-if="!formDone">
<h1>Unsubscribe</h1> <h1>Unsubscribe</h1>
<p> <p>
If you would like to unsubscribe from our mailing list, If you would like to unsubscribe from our mailing list,
you have come to the right page! you have come to the right page!
</p> </p>
<SMMessage <SMForm v-model="form" @submit="handleSubmit">
v-if="formMessage.message" <SMInput control="email" />
:type="formMessage.type"
:message="formMessage.message"
:icon="formMessage.icon" />
<form @submit.prevent="submit">
<SMInput
v-model="formData.email.value"
name="email"
label="Email"
required
:error="formData.email.error"
@blur="fieldValidate(formData.email)" />
<SMCaptchaNotice />
<SMFormFooter> <SMFormFooter>
<template #right> <template #right>
<SMButton type="submit" label="Unsubscribe" /> <SMButton type="submit" label="Unsubscribe" />
</template> </template>
</SMFormFooter> </SMFormFooter>
</form> </SMForm>
</template> </template>
<template v-else> <template v-else>
<h1>Unsubscribed</h1> <h1>Unsubscribed</h1>
@@ -42,79 +30,56 @@
</template> </template>
</SMDialog> </SMDialog>
</SMRow> </SMRow>
</SMContainer> </SMPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { api } from "../helpers/api";
import { FormObject, FormControl } from "../helpers/form";
import { And, Email, Required } from "../helpers/validate";
import { ref, reactive } from "vue"; import { ref, reactive } from "vue";
import SMInput from "../components/SMInput.vue";
import SMButton from "../components/SMButton.vue";
import SMFormFooter from "../components/SMFormFooter.vue";
import SMDialog from "../components/SMDialog.vue";
import SMMessage from "../components/SMMessage.vue";
import axios from "axios";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import {
useValidation,
isValidated,
fieldValidate,
restParseErrors,
} from "../helpers/validation";
import SMCaptchaNotice from "../components/SMCaptchaNotice.vue";
import { useReCaptcha } from "vue-recaptcha-v3"; import { useReCaptcha } from "vue-recaptcha-v3";
import SMButton from "../components/SMButton.vue";
import SMDialog from "../components/SMDialog.vue";
import SMForm from "../components/SMForm.vue";
import SMFormFooter from "../components/SMFormFooter.vue";
import SMInput from "../components/SMInput.vue";
import SMPage from "../components/SMPage.vue";
const { executeRecaptcha, recaptchaLoaded } = useReCaptcha(); const { executeRecaptcha, recaptchaLoaded } = useReCaptcha();
const formLoading = ref(false);
const formDone = ref(false); const formDone = ref(false);
const formMessage = reactive({ const form = reactive(
message: "", FormObject({
type: "error", email: FormControl("", And([Required(), Email()])),
icon: "", })
}); );
const formData = reactive({
email: {
value: "",
error: "",
rules: {
required: true,
required_message: "An email address is required.",
email: true,
email_message: "That does not look like an email address.",
},
},
});
useValidation(formData); const handleSubmit = async () => {
form.loading(true);
const submit = async () => {
formLoading.value = true;
formMessage.type = "error";
formMessage.icon = "fa-solid fa-circle-exclamation";
formMessage.message = "";
try { try {
if (isValidated(formData)) { await recaptchaLoaded();
await recaptchaLoaded(); const captcha = await executeRecaptcha("submit");
const captcha = await executeRecaptcha("submit");
await axios.delete("subscriptions", { await api.delete({
data: { url: "/subscriptions",
email: formData.email.value, body: {
captcha_token: captcha, email: form.email.value,
}, captcha_token: captcha,
}); },
});
formDone.value = true; formDone.value = true;
} } catch (error) {
} catch (err) { form.apiErrors(error);
restParseErrors(formData, [formMessage, "message"], err);
} }
formLoading.value = false; form.loading(false);
}; };
if (useRoute().query.email !== undefined) { if (useRoute().query.email !== undefined) {
formData.email.value = useRoute().query.email; form.email.value = useRoute().query.email;
submit(); handleSubmit();
} }
</script> </script>

View File

@@ -1,5 +1,5 @@
<template> <template>
<SMContainer class="mx-auto workshop-list"> <SMPage class="mx-auto workshop-list">
<h1>Workshops</h1> <h1>Workshops</h1>
<div class="toolbar"> <div class="toolbar">
<SMInput <SMInput
@@ -40,7 +40,7 @@
event.location == 'online' ? 'Online Event' : event.address event.location == 'online' ? 'Online Event' : event.address
"></SMPanel> "></SMPanel>
</SMPanelList> </SMPanelList>
</SMContainer> </SMPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -49,15 +49,14 @@ import SMInput from "../components/SMInput.vue";
import SMMessage from "../components/SMMessage.vue"; import SMMessage from "../components/SMMessage.vue";
import SMPanelList from "../components/SMPanelList.vue"; import SMPanelList from "../components/SMPanelList.vue";
import SMPanel from "../components/SMPanel.vue"; import SMPanel from "../components/SMPanel.vue";
import SMPage from "../components/SMPage.vue";
import { reactive, ref } from "vue"; import { reactive, ref } from "vue";
import axios from "axios"; import { api } from "../helpers/api";
import { import {
buildUrlQuery,
timestampLocalToUtc, timestampLocalToUtc,
timestampNowUtc, timestampNowUtc,
timestampUtcToLocal, timestampUtcToLocal,
} from "../helpers/common"; } from "../helpers/datetime";
import { format, parse, parseISO } from "date-fns";
const loading = ref(true); const loading = ref(true);
const events = reactive([]); const events = reactive([]);
@@ -103,11 +102,13 @@ const handleLoad = async () => {
query["end_at"] = ">" + timestampNowUtc(); query["end_at"] = ">" + timestampNowUtc();
} }
const url = buildUrlQuery("events", query); let result = await api.get({
let result = await axios.get(url); url: "/events",
params: query,
});
if (result.data.events) { if (result.json.events) {
events.value = result.data.events; events.value = result.json.events;
events.value.forEach((item) => { events.value.forEach((item) => {
item.start_at = timestampUtcToLocal(item.start_at); item.start_at = timestampUtcToLocal(item.start_at);

View File

@@ -3,10 +3,10 @@
<div <div
class="workshop-image" class="workshop-image"
:style="{ backgroundImage: `url('${imageUrl}')` }"> :style="{ backgroundImage: `url('${imageUrl}')` }">
<font-awesome-icon <ion-icon
v-if="imageUrl.length == 0" v-if="imageUrl.length == 0"
class="workshop-image-loader" class="workshop-image-loader"
icon="fa-regular fa-image" /> name="image-outline" />
</div> </div>
<template #inner> <template #inner>
<SMMessage <SMMessage
@@ -57,10 +57,7 @@
label="Register for Event"></SMButton> label="Register for Event"></SMButton>
</div> </div>
<div class="workshop-date"> <div class="workshop-date">
<h4> <h4><ion-icon name="calendar-outline" />Date / Time</h4>
<font-awesome-icon
icon="fa-regular fa-calendar" />Date / Time
</h4>
<p <p
v-for="(line, index) in workshopDate" v-for="(line, index) in workshopDate"
:key="index" :key="index"
@@ -69,10 +66,7 @@
</p> </p>
</div> </div>
<div class="workshop-location"> <div class="workshop-location">
<h4> <h4><ion-icon name="location-outline" />Location</h4>
<font-awesome-icon
icon="fa-solid fa-location-dot" />Location
</h4>
<p> <p>
{{ {{
event.location == "online" event.location == "online"
@@ -88,19 +82,19 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import axios from "axios"; import { api } from "../helpers/api";
import { computed, ref, reactive } from "vue"; import { computed, ref, reactive } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { useApplicationStore } from "../store/ApplicationStore"; import { useApplicationStore } from "../store/ApplicationStore";
import { format } from "date-fns";
import SMButton from "../components/SMButton.vue"; import SMButton from "../components/SMButton.vue";
import SMHTML from "../components/SMHTML.vue"; import SMHTML from "../components/SMHTML.vue";
import SMMessage from "../components/SMMessage.vue"; import SMMessage from "../components/SMMessage.vue";
import { import {
format,
timestampUtcToLocal, timestampUtcToLocal,
timestampBeforeNow, timestampBeforeNow,
timestampAfterNow, timestampAfterNow,
} from "../helpers/common"; } from "../helpers/datetime";
const applicationStore = useApplicationStore(); const applicationStore = useApplicationStore();
const event = ref({}); const event = ref({});
@@ -162,8 +156,8 @@ const handleLoad = async () => {
formMessage.message = ""; formMessage.message = "";
try { try {
const result = await axios.get(`events/${route.params.id}`); const result = await api.get(`events/${route.params.id}`);
event.value = result.data.event; event.value = result.json.event;
event.value.start_at = timestampUtcToLocal(event.value.start_at); event.value.start_at = timestampUtcToLocal(event.value.start_at);
event.value.end_at = timestampUtcToLocal(event.value.end_at); event.value.end_at = timestampUtcToLocal(event.value.end_at);
@@ -172,16 +166,16 @@ const handleLoad = async () => {
handleLoadImage(); handleLoadImage();
} catch (error) { } catch (error) {
formMessage.message = formMessage.message =
error.response?.data?.message || error.json?.message ||
"Could not load event information from the server."; "Could not load event information from the server.";
} }
}; };
const handleLoadImage = async () => { const handleLoadImage = async () => {
try { try {
const result = await axios.get(`media/${event.value.hero}`); const result = await api.get(`media/${event.value.hero}`);
if (result.data.medium) { if (result.json.medium) {
imageUrl.value = result.data.medium.url; imageUrl.value = result.json.medium.url;
} }
} catch (error) { } catch (error) {
/* empty */ /* empty */

View File

@@ -1,118 +1,72 @@
<template> <template>
<SMContainer> <SMPage>
<SMMessage <SMForm v-model="form" @submit="handleSubmit">
v-if="formMessage.message"
:type="formMessage.type"
:message="formMessage.message"
:icon="formMessage.icon" />
<form @submit.prevent="submit">
<SMRow> <SMRow>
<SMInput <SMInput control="title" />
v-model="formData.title.value"
label="Title"
required
:error="formData.title.error"
@blur="fieldValidate(formData.title)" />
</SMRow> </SMRow>
<SMRow> <SMRow>
<SMEditor <SMEditor
id="content" id="content"
v-model="formData.content.value" v-model="form.content.value"
@file-accept="fileAccept" @file-accept="fileAccept"
@attachment-add="attachmentAdd" /> @attachment-add="attachmentAdd" />
</SMRow> </SMRow>
<SMRow> <SMRow>
<SMButton type="submit" label="Save" /> <SMButton type="submit" label="Save" />
</SMRow> </SMRow>
</form> </SMForm>
</SMContainer> </SMPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive } from "vue"; import { api } from "../../helpers/api";
import DEditor from "../../components/SMEditor.vue"; import { FormObject, FormControl } from "../../helpers/form";
import { And, Required, Min } from "../../helpers/validate";
import { reactive } from "vue";
import { useRoute } from "vue-router";
import SMInput from "../../components/SMInput.vue"; import SMInput from "../../components/SMInput.vue";
import SMButton from "../../components/SMButton.vue"; import SMButton from "../../components/SMButton.vue";
import SMDialog from "../../components/SMDialog.vue"; import SMPage from "../../components/SMPage.vue";
import SMMessage from "../../components/SMMessage.vue"; import SMForm from "../../components/SMForm.vue";
import axios from "axios";
import {
useValidation,
isValidated,
fieldValidate,
restParseErrors,
} from "../../helpers/validation";
import { useUserStore } from "@/store/UserStore";
import { useRoute } from "vue-router";
import { createTemplateLiteral } from "@vue/compiler-core";
const route = useRoute(); const route = useRoute();
const userStore = useUserStore(); const form = reactive(
const formMessage = reactive({ FormObject({
icon: "", title: FormControl("", And([Required(), Min(2)])),
type: "", content: FormControl("", Required()),
message: "", })
}); );
const formData = reactive({
title: {
value: "",
error: "",
rules: {
required: true,
required_message: "A first name is needed",
min: 2,
min_message: "Your first name should be at least 2 letters long",
},
},
content: {
value: "<div>Hello <strong>People</strong> persons!</div>",
error: "",
rules: {
required: true,
required_message: "A last name is needed",
min: 2,
min_message: "Your last name should be at least 2 letters long",
},
},
});
useValidation(formData); // const getPostById = async () => {
// try {
// if (isValidated(formData)) {
// let res = await axios.get("posts/" + route.params.id);
const getPostById = async () => { // formData.title.value = res.data.title;
// formData.content.value = res.data.content;
// }
// } catch (err) {
// console.log(err);
// formMessage.icon = "";
// formMessage.type = "error";
// formMessage.message = "";
// restParseErrors(formData, [formMessage, "message"], err);
// }
// };
const handleSubmit = async () => {
try { try {
if (isValidated(formData)) { await api.post({
let res = await axios.get("posts/" + route.params.id); url: "/posts",
body: {
title: form.title.value,
content: form.content.value,
},
});
formData.title.value = res.data.title; form.message("The post has been saved", "success");
formData.content.value = res.data.content; } catch (error) {
} form.apiError(error);
} catch (err) {
console.log(err);
formMessage.icon = "";
formMessage.type = "error";
formMessage.message = "";
restParseErrors(formData, [formMessage, "message"], err);
}
};
const submit = async () => {
try {
if (isValidated(formData)) {
let res = await axios.post("posts", {
title: formData.title.value,
content: formData.content.value,
});
console.log(ref);
formMessage.type = "success";
formMessage.message = "Your details have been updated";
}
} catch (err) {
console.log(err);
formMessage.icon = "";
formMessage.type = "error";
formMessage.message = "";
restParseErrors(formData, [formMessage, "message"], err);
} }
}; };
@@ -162,21 +116,3 @@ const attachmentAdd = async (event) => {
} }
}; };
</script> </script>
<style lang="scss">
// .dialog {
// flex-direction: column;
// margin: 0 auto;
// max-width: 600px;
// }
// .buttonFooter {
// flex-direction: row;
// }
// @media screen and (max-width: 768px) {
// .buttonFooter {
// flex-direction: column-reverse;
// }
// }
</style>

View File

@@ -1,58 +1,59 @@
<template> <template>
<SMContainer class="dashboard mx-auto"> <SMPage class="dashboard mx-auto">
<h1>Dashboard</h1> <h1>Dashboard</h1>
<div class="boxes"> <div class="boxes">
<router-link to="/dashboard/details" class="box"> <router-link to="/dashboard/details" class="box">
<font-awesome-icon icon="fa-solid fa-user-pen" /> <ion-icon name="location-outline" />
<h2>My Details</h2> <h2>My Details</h2>
</router-link> </router-link>
<router-link <router-link
v-if="userStore.permissions.includes('admin/posts')" v-if="userStore.permissions.includes('admin/posts')"
to="/dashboard/posts" to="/dashboard/posts"
class="box"> class="box">
<font-awesome-icon icon="fa-regular fa-newspaper" /> <ion-icon name="newspaper-outline" />
<h2>Posts</h2> <h2>Posts</h2>
</router-link> </router-link>
<router-link <router-link
v-if="userStore.permissions.includes('admin/users')" v-if="userStore.permissions.includes('admin/users')"
:to="{ name: 'user-list' }" :to="{ name: 'user-list' }"
class="box"> class="box">
<font-awesome-icon icon="fa-solid fa-users" /> <ion-icon name="people-outline" />
<h2>Users</h2> <h2>Users</h2>
</router-link> </router-link>
<router-link <router-link
v-if="userStore.permissions.includes('admin/events')" v-if="userStore.permissions.includes('admin/events')"
to="/dashboard/events" to="/dashboard/events"
class="box"> class="box">
<font-awesome-icon icon="fa-regular fa-calendar" /> <ion-icon name="calendar-outline" />
<h2>Events</h2> <h2>Events</h2>
</router-link> </router-link>
<router-link <router-link
v-if="userStore.permissions.includes('admin/courses')" v-if="userStore.permissions.includes('admin/courses')"
to="/dashboard/courses" to="/dashboard/courses"
class="box"> class="box">
<font-awesome-icon icon="fa-solid fa-graduation-cap" /> <ion-icon name="school-outline" />
<h2>{{ courseBoxTitle }}</h2> <h2>{{ courseBoxTitle }}</h2>
</router-link> </router-link>
<router-link <router-link
v-if="userStore.permissions.includes('admin/media')" v-if="userStore.permissions.includes('admin/media')"
to="/dashboard/media" to="/dashboard/media"
class="box"> class="box">
<font-awesome-icon icon="fa-solid fa-photo-film" /> <ion-icon name="film-outline" />
<h2>Media</h2> <h2>Media</h2>
</router-link> </router-link>
<router-link <router-link
v-if="userStore.permissions.includes('logs/discord')" v-if="userStore.permissions.includes('logs/discord')"
:to="{ name: 'discord-bot-logs' }" :to="{ name: 'discord-bot-logs' }"
class="box"> class="box">
<font-awesome-icon icon="fa-brands fa-discord" /> <ion-icon name="logo-discord" />
<h2>Discord Bot Logs</h2> <h2>Discord Bot Logs</h2>
</router-link> </router-link>
</div> </div>
</SMContainer> </SMPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import SMPage from "../../components/SMPage.vue";
import { computed } from "vue"; import { computed } from "vue";
import { useUserStore } from "../../store/UserStore"; import { useUserStore } from "../../store/UserStore";
@@ -90,6 +91,7 @@ const courseBoxTitle = computed(() => {
font-size: map-get($spacer, 3); font-size: map-get($spacer, 3);
color: $font-color; color: $font-color;
transition: background-color 0.3s, border 0.3s; transition: background-color 0.3s, border 0.3s;
align-items: center;
text-align: center; text-align: center;
h2 { h2 {
@@ -97,7 +99,7 @@ const courseBoxTitle = computed(() => {
margin-bottom: 0; margin-bottom: 0;
} }
svg { ion-icon {
font-size: map-get($spacer, 5); font-size: map-get($spacer, 5);
} }

View File

@@ -166,7 +166,6 @@ import {
} from "../../helpers/validation"; } from "../../helpers/validation";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { timestampLocalToUtc, timestampUtcToLocal } from "../../helpers/common"; import { timestampLocalToUtc, timestampUtcToLocal } from "../../helpers/common";
import { parseISO } from "date-fns";
const route = useRoute(); const route = useRoute();
const formLoading = ref(false); const formLoading = ref(false);

View File

@@ -26,7 +26,7 @@
:items="items" :items="items"
:search-value="search"> :search-value="search">
<template #loading> <template #loading>
<font-awesome-icon icon="fa-solid fa-spinner" pulse /> <SMLoadingIcon />
</template> </template>
<template #item-title="item"> <template #item-title="item">
<router-link <router-link
@@ -36,12 +36,12 @@
</template> </template>
<template #item-actions="item"> <template #item-actions="item">
<div class="action-wrapper"> <div class="action-wrapper">
<font-awesome-icon <!-- <font-awesome-icon
icon="fa-solid fa-pen-to-square" icon="fa-solid fa-pen-to-square"
@click="handleEdit(item)" /> @click="handleEdit(item)" />
<font-awesome-icon <font-awesome-icon
icon="fa-regular fa-trash-can" icon="fa-regular fa-trash-can"
@click="handleDelete(item)" /> @click="handleDelete(item)" /> -->
</div> </div>
</template> </template>
</EasyDataTable> </EasyDataTable>
@@ -66,6 +66,7 @@ import { debounce } from "../../helpers/common";
import SMHeading from "../../components/SMHeading.vue"; import SMHeading from "../../components/SMHeading.vue";
import SMMessage from "../../components/SMMessage.vue"; import SMMessage from "../../components/SMMessage.vue";
import { restParseErrors } from "../../helpers/validation"; import { restParseErrors } from "../../helpers/validation";
import SMLoadingIcon from "../../components/SMLoadingIcon.vue";
const router = useRouter(); const router = useRouter();
const search = ref(""); const search = ref("");

View File

@@ -29,19 +29,19 @@
:items="items" :items="items"
:search-value="search"> :search-value="search">
<template #loading> <template #loading>
<font-awesome-icon icon="fa-solid fa-spinner" pulse /> <SMLoadingIcon />
</template> </template>
<template #item-size="item"> <template #item-size="item">
{{ bytesReadable(item.size) }} {{ bytesReadable(item.size) }}
</template> </template>
<template #item-actions="item"> <template #item-actions="item">
<div class="action-wrapper"> <div class="action-wrapper">
<font-awesome-icon <!-- <font-awesome-icon
icon="fa-solid fa-pen-to-square" icon="fa-solid fa-pen-to-square"
@click.stop="handleEdit(item)" /> @click.stop="handleEdit(item)" />
<font-awesome-icon <font-awesome-icon
icon="fa-regular fa-trash-can" icon="fa-regular fa-trash-can"
@click.stop="handleDelete(item)" /> @click.stop="handleDelete(item)" /> -->
<d-file-link :href="item.url" target="_blank" @click.stop="" <d-file-link :href="item.url" target="_blank" @click.stop=""
><font-awesome-icon icon="fa-solid fa-download" ><font-awesome-icon icon="fa-solid fa-download"
/></d-file-link> /></d-file-link>
@@ -65,6 +65,7 @@ import { debounce, parseErrorType, bytesReadable } from "../../helpers/common";
import SMMessage from "../../components/SMMessage.vue"; import SMMessage from "../../components/SMMessage.vue";
import DFileLink from "../../components/DFileLink.vue"; import DFileLink from "../../components/DFileLink.vue";
import { useUserStore } from "../../store/UserStore"; import { useUserStore } from "../../store/UserStore";
import SMLoadingIcon from "../../components/SMLoadingIcon.vue";
const router = useRouter(); const router = useRouter();
const search = ref(""); const search = ref("");

View File

@@ -26,7 +26,7 @@
:items="items" :items="items"
:search-value="search"> :search-value="search">
<template #loading> <template #loading>
<font-awesome-icon icon="fa-solid fa-spinner" pulse /> <SMLoadingIcon />
</template> </template>
<template #item-title="item"> <template #item-title="item">
<router-link <router-link
@@ -36,12 +36,12 @@
</template> </template>
<template #item-actions="item"> <template #item-actions="item">
<div class="action-wrapper"> <div class="action-wrapper">
<font-awesome-icon <!-- <font-awesome-icon
icon="fa-solid fa-pen-to-square" icon="fa-solid fa-pen-to-square"
@click="handleEdit(item)" /> @click="handleEdit(item)" />
<font-awesome-icon <font-awesome-icon
icon="fa-regular fa-trash-can" icon="fa-regular fa-trash-can"
@click="handleDelete(item)" /> @click="handleDelete(item)" /> -->
</div> </div>
</template> </template>
</EasyDataTable> </EasyDataTable>
@@ -62,6 +62,7 @@ import SMButton from "../../components/SMButton.vue";
import { debounce } from "../../helpers/common"; import { debounce } from "../../helpers/common";
import SMHeading from "../../components/SMHeading.vue"; import SMHeading from "../../components/SMHeading.vue";
import SMMessage from "../../components/SMMessage.vue"; import SMMessage from "../../components/SMMessage.vue";
import SMLoadingIcon from "../../components/SMLoadingIcon.vue";
const router = useRouter(); const router = useRouter();
const search = ref(""); const search = ref("");

View File

@@ -16,16 +16,16 @@
:header-item-class-name="headerItemClassNameFunction" :header-item-class-name="headerItemClassNameFunction"
:body-item-class-name="bodyItemClassNameFunction"> :body-item-class-name="bodyItemClassNameFunction">
<template #loading> <template #loading>
<font-awesome-icon icon="fa-solid fa-spinner" pulse /> <SMLoadingIcon />
</template> </template>
<template #item-actions="item"> <template #item-actions="item">
<div class="action-wrapper"> <div class="action-wrapper">
<font-awesome-icon <!-- <font-awesome-icon
icon="fa-solid fa-pen-to-square" icon="fa-solid fa-pen-to-square"
@click="handleEdit(item)" /> @click="handleEdit(item)" />
<font-awesome-icon <font-awesome-icon
icon="fa-regular fa-trash-can" icon="fa-regular fa-trash-can"
@click="handleDelete(item)" /> @click="handleDelete(item)" /> -->
</div> </div>
</template> </template>
</EasyDataTable> </EasyDataTable>
@@ -42,6 +42,7 @@ import DialogConfirm from "../../components/dialogs/SMDialogConfirm.vue";
import { openDialog } from "vue3-promise-dialog"; import { openDialog } from "vue3-promise-dialog";
import SMHeading from "../../components/SMHeading.vue"; import SMHeading from "../../components/SMHeading.vue";
import SMMessage from "../../components/SMMessage.vue"; import SMMessage from "../../components/SMMessage.vue";
import SMLoadingIcon from "../../components/SMLoadingIcon.vue";
const router = useRouter(); const router = useRouter();
const searchValue = ref(""); const searchValue = ref("");

View File

@@ -8,5 +8,7 @@
<body> <body>
<div id="app"></div> <div id="app"></div>
@vite('resources/js/main.js') @vite('resources/js/main.js')
<script type="module" src="https://unpkg.com/ionicons@5.5.2/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@5.5.2/dist/ionicons/ionicons.js"></script>
</body> </body>
</html> </html>

View File

@@ -7,7 +7,8 @@ export default defineConfig({
vue({ vue({
template: { template: {
compilerOptions: { compilerOptions: {
isCustomElement: (tag) => ["trix-editor"].includes(tag), isCustomElement: (tag) =>
["trix-editor", "ion-icon"].includes(tag),
}, },
transformAssetUrls: { transformAssetUrls: {
base: null, base: null,