Merge pull request #17 from STEMMechanics/dependency-refactor

Dependency refactor
This commit was merged in pull request #17.
This commit is contained in:
James Collins
2023-02-27 22:30:56 +10:00
committed by GitHub
158 changed files with 10313 additions and 6505 deletions

View File

@@ -11,7 +11,8 @@ module.exports = {
],
rules: {
"vue/multi-word-component-names": "off",
indent: ["error", 4],
indent: ["off", 4, { ignoredNodes: ["ConditionalExpression"] }],
"@typescript-eslint/no-inferrable-types": "off",
},
plugins: ["jsdoc", "@typescript-eslint"],
parser: "vue-eslint-parser",

View File

@@ -58,5 +58,6 @@ jobs:
php artisan migrate --force
npm install
npm run build
npm run prepare
php artisan optimize
php artisan up

9
.gitignore vendored
View File

@@ -237,4 +237,11 @@ dist/
### This Project ###
/public/uploads
/public/build
*.key
/public/tinymce
*.key
### Synk ###
.dccache
### TempCodeRunner ###
tempCodeRunnerFile.*

View File

@@ -2,7 +2,8 @@
"editor.formatOnType": true,
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
"source.fixAll.eslint": true,
"source.organizeImports": true
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[vue]": {

View File

@@ -165,9 +165,10 @@ abstract class FilterAbstract
*
* @param array $attributes Attributes currently visible.
* @param User|null $user Current logged in user or null.
* @param object $modelData Model data if a single object is requested.
* @return mixed
*/
protected function seeAttributes(array $attributes, mixed $user)
protected function seeAttributes(array $attributes, mixed $user, ?object $modelData = null)
{
return $attributes;
}
@@ -224,7 +225,7 @@ abstract class FilterAbstract
}
/* Run attribute modifiers*/
$modifiedAttribs = $this->seeAttributes($attributes, $this->request->user());
$modifiedAttribs = $this->seeAttributes($attributes, $this->request->user(), $model);
if (is_array($modifiedAttribs) === true) {
$attributes = $modifiedAttribs;
}

View File

@@ -19,11 +19,12 @@ class UserFilter extends FilterAbstract
*
* @param array $attributes Attributes currently visible.
* @param User|null $user Current logged in user or null.
* @param object $userData User model if single object is requested.
* @return mixed
*/
protected function seeAttributes(array $attributes, mixed $user)
protected function seeAttributes(array $attributes, mixed $user, ?object $userData = null)
{
if ($user?->hasPermission('admin/users') !== true) {
if ($user?->hasPermission('admin/users') !== true && ($user === null || $userData === null || $user?->id !== $userData?->id)) {
return ['id', 'username'];
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Attachment;
use Illuminate\Http\Request;
class AttachmentController extends ApiController
{
/**
* ApplicationController constructor.
*/
public function __construct()
{
$this->middleware('auth:sanctum')
->except(['store', 'destroyByEmail']);
}
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
//
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
//
}
/**
* Display the specified resource.
*
* @param \App\Models\Attachment $attachment
* @return \Illuminate\Http\Response
*/
public function show(Attachment $attachment)
{
//
}
/**
* Show the form for editing the specified resource.
*
* @param \App\Models\Attachment $attachment
* @return \Illuminate\Http\Response
*/
public function edit(Attachment $attachment)
{
//
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\Attachment $attachment
* @return \Illuminate\Http\Response
*/
public function update(Request $request, Attachment $attachment)
{
//
}
/**
* Remove the specified resource from storage.
*
* @param \App\Models\Attachment $attachment
* @return \Illuminate\Http\Response
*/
public function destroy(Attachment $attachment)
{
//
}
}

View File

@@ -59,7 +59,7 @@ class MediaController extends ApiController
{
$file = $request->file('file');
if ($file === null) {
return $this->respondError(['file' => 'An error occurred uploading the file to the server.']);
return $this->respondWithErrors(['file' => 'The browser did not upload the file correctly to the server.']);
}
if ($file->isValid() !== true) {
@@ -68,9 +68,9 @@ class MediaController extends ApiController
case UPLOAD_ERR_FORM_SIZE:
return $this->respondTooLarge();
case UPLOAD_ERR_PARTIAL:
return $this->respondError(['file' => 'The file upload was interrupted.']);
return $this->respondWithErrors(['file' => 'The file upload was interrupted.']);
default:
return $this->respondError(['file' => 'An error occurred uploading the file to the server.']);
return $this->respondWithErrors(['file' => 'An error occurred uploading the file to the server.']);
}
}
@@ -82,7 +82,7 @@ class MediaController extends ApiController
$mime = $file->getMimeType();
$fileInfo = Media::store($file, empty($request->input('permission')));
if ($fileInfo === null) {
return $this->respondError(
return $this->respondWithErrors(
['file' => 'The file could not be stored on the server'],
HttpResponseCodes::HTTP_INTERNAL_SERVER_ERROR
);
@@ -121,7 +121,7 @@ class MediaController extends ApiController
$oldPath = $medium->path();
$fileInfo = Media::store($file, empty($request->input('permission')));
if ($fileInfo === null) {
return $this->respondError(
return $this->respondWithErrors(
['file' => 'The file could not be stored on the server'],
HttpResponseCodes::HTTP_INTERNAL_SERVER_ERROR
);
@@ -218,6 +218,7 @@ class MediaController extends ApiController
$headerExpires = $updated_at->addMonth()->toRfc2822String();
}//end if
// deepcode ignore InsecureHash: Browsers expect Etag to be a md5 hash
$headerEtag = md5($updated_at->format('U'));
$headerLastModified = $updated_at->toRfc2822String();

View File

@@ -240,7 +240,7 @@ class UserController extends ApiController
}
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
return $this->respondWithErrors([
'code' => 'The code was not found or has expired'
'code' => 'The code was not found or has expired.'
]);
}

28
app/Models/Attachment.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Attachment extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'media_id',
];
/**
* Get attachments attachable
*/
public function attachable()
{
return $this->morphTo();
}}

View File

@@ -29,4 +29,12 @@ class Event extends Model
'hero',
'content'
];
/**
* Get all of the post's attachments.
*/
public function attachments()
{
return $this->morphMany('App\Attachment', 'attachable');
}
}

View File

@@ -27,7 +27,7 @@ class Post extends Model
/**
* Get the file user
* Get the post user
*
* @return BelongsTo
*/
@@ -35,4 +35,12 @@ class Post extends Model
{
return $this->belongsTo(User::class);
}
/**
* Get all of the post's attachments.
*/
public function attachments()
{
return $this->morphMany('App\Attachment', 'attachable');
}
}

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
{
}

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('attachments', function (Blueprint $table) {
$table->id();
$table->uuid('media_id');
$table->uuidMorphs('attachable');
$table->timestamps();
$table->foreign('media_id')->references('id')->on('media')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('media_attachments');
}
};

6
import-meta.ts Normal file
View File

@@ -0,0 +1,6 @@
export interface ImportMetaExtras extends ImportMeta {
env: {
APP_URL: string;
[key: string]: string;
};
}

1240
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,12 +4,13 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint \"**/*.{js,jsx,.vue}\" --ignore-path .gitignore",
"format": "prettier . --write"
"format": "prettier . --write",
"test": "vitest",
"prepare": "mkdir -p public/tinymce/skins && cp -r node_modules/tinymce/skins public/tinymce && mkdir -p public/tinymce/plugins/emoticons/js && cp node_modules/tinymce/plugins/emoticons/js/emojis.min.js public/tinymce/plugins/emoticons/js/"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.48.1",
"@typescript-eslint/parser": "^5.48.1",
"axios": "^1.1.2",
"eslint": "^8.31.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-jsdoc": "^39.6.4",
@@ -18,25 +19,23 @@
"lodash": "^4.17.19",
"postcss": "^8.1.14",
"prettier": "2.8.2",
"typescript": "^4.9.4",
"vite": "^4.0.0"
"rollup-plugin-analyzer": "^4.0.0",
"ts-node": "^10.9.1",
"typescript": "^4.9.5",
"vite": "^4.0.0",
"vitest": "^0.28.5"
},
"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",
"@tinymce/tinymce-vue": "^4.0.7",
"@vitejs/plugin-vue": "^4.0.0",
"@vuepic/vue-datepicker": "^3.6.4",
"date-fns": "^2.29.3",
"dompurify": "^3.0.0",
"dotenv": "^16.0.3",
"element-plus": "^2.2.27",
"normalize.css": "^8.0.1",
"pinia": "^2.0.28",
"pinia-plugin-persistedstate": "^3.0.1",
"sass": "^1.57.1",
"trix": "^2.0.4",
"tinymce": "^6.3.1",
"vue": "^3.2.36",
"vue-dompurify-html": "^3.1.2",
"vue-final-modal": "^3.4.11",

View File

@@ -3,6 +3,14 @@
Options -MultiViews -Indexes
</IfModule>
<IfModule mod_headers.c>
<FilesMatch "^(uploads|img)/.+">
<If "%{QUERY_STRING} =~ /(^|&)download=1($|&)/">
Header set Content-Disposition "attachment"
</If>
</FilesMatch>
</IfModule>
RewriteEngine On
# Add www subdomain if missing
@@ -18,6 +26,11 @@
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Pass to media handler if the media request has query
RewriteCond %{REQUEST_FILENAME} -f
RewriteCond %{QUERY_STRING} .
RewriteRule ^uploads/(.+)\.(jpe?g|png)$ media.php?url=uploads/$1.$2 [NC,QSA,L]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f

BIN
public/img/background.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
public/img/dashboard-bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -0,0 +1,3 @@
RewriteEngine on
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule \.png$ unknown.png [L]

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

60
public/media.php Normal file
View File

@@ -0,0 +1,60 @@
<?php
// file deepcode ignore PT: Input is sanitized using realpath which is ignored by Snyk
// file deepcode ignore Ssrf: Input is sanitized using realpath which is ignored by Snyk
$filepath = "";
if (isset($_GET['url'])) {
$filepath = realpath($_GET['url']);
}
if ($filepath !== false && strlen($filepath) > 0 && strpos($filepath, 'uploads/') === 0 && is_file($filepath)) {
$image = imagecreatefromstring(file_get_contents($filepath));
$newWidth = (isset($_GET['w']) ? intval($_GET['w']) : -1);
$newHeight = (isset($_GET['h']) ? intval($_GET['h']) : -1);
if($newWidth != -1 || $newHeight != -1) {
$width = imagesx($image);
$height = imagesy($image);
$aspectRatio = $width / $height;
if($newWidth == -1) {
$newWidth = intval($newHeight * $aspectRatio);
}
if($newHeight == -1) {
$newHeight = intval($newWidth / $aspectRatio);
}
$newImage = imagecreatetruecolor($newWidth, $newHeight);
imagecopyresampled($newImage, $image, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
// Output the resized image to the browser
$mime_type = mime_content_type($_GET['url']);
header('Content-Type: ' . $mime_type);
switch($mime_type) {
case "image/jpeg":
imagejpeg($newImage);
break;
case "image/gif":
imagegif($newImage);
break;
case "image/png":
imagepng($newImage);
break;
}
imagedestroy($newImage);
} else {
// Output the original image to the browser
header('Content-Type: '. mime_content_type($filepath));
readfile($filepath);
}
// Clean up the image resources
imagedestroy($image);
} else {
// Return a 404 error
header($_SERVER["SERVER_PROTOCOL"] . " 404 Not Found");
exit;
}

View File

@@ -1,7 +1,8 @@
@import "variables.scss";
@import "utils.scss";
@import "data-table.scss";
@import "datepicker.scss";
@import "tinymce.scss";
@import "prism.css";
* {
box-sizing: border-box;
@@ -20,6 +21,11 @@ body {
min-height: 100vh;
}
// Who knows why ion-icon randomally sets this to hidden.....
ion-icon {
visibility: visible;
}
#app {
display: flex;
flex-direction: column;
@@ -43,50 +49,31 @@ h1 {
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 {
line-height: 1.5rem;
margin-top: 0;
}
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, 4);
// 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, 4);
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
// -webkit-appearance: none;
// -moz-appearance: none;
// appearance: none;
// }
textarea {
resize: none;
}
// textarea {
// resize: none;
// }
select {
padding-right: 2.5rem;
@@ -137,11 +124,6 @@ select {
}
}
svg,
button {
@extend .prevent-select;
}
code {
display: block;
font-size: 0.8rem;
@@ -178,182 +160,13 @@ 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,
a.button,
label.button {
padding: map-get($spacer, 2) map-get($spacer, 4);
color: white;
font-weight: 800;
border-width: 2px;
border-style: solid;
border-radius: 24px;
transition: background-color 0.1s, color 0.1s;
cursor: pointer;
background-color: $secondary-color;
border-color: $secondary-color;
min-width: 7rem;
text-align: center;
&:disabled {
cursor: not-allowed;
background-color: $secondary-color !important;
border-color: $secondary-color !important;
opacity: 0.5;
}
&:hover:not(:disabled) {
text-decoration: none;
color: $secondary-color;
}
&.primary {
background-color: $primary-color;
border-color: $primary-color;
&:hover:not(:disabled) {
color: $primary-color;
}
}
&.secondary {
background-color: $secondary-color;
border-color: $secondary-color;
&:hover:not(:disabled) {
color: $secondary-color;
}
}
&.danger {
background-color: $danger-color;
border-color: $danger-color;
&:hover:not(:disabled) {
color: $danger-color;
}
}
&.outline {
background-color: transparent;
border-color: $outline-color;
color: $outline-color;
&:hover:not(:disabled) {
background-color: $outline-color;
border-color: $outline-color;
color: $outline-hover-color;
}
}
&:hover:not(:disabled) {
background-color: #fff;
}
svg {
padding-left: 0.5rem;
vertical-align: middle !important;
}
}
.button + .button {
margin: 0 map-get($spacer, 2);
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
}
/* 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-error {
display: flex;
flex-direction: row;
flex: 1;
margin-top: map-get($spacer, 5);
min-height: 50vh;
.image {
flex: 1;

3
resources/css/prism.css Normal file
View File

@@ -0,0 +1,3 @@
/* PrismJS 1.29.0
https://prismjs.com/download.html#themes=prism&languages=markup+clike+javascript+bash+c+javadoclike+js-extras+json+json5+log+markup-templating+objectivec+perl+php+phpdoc+php-extras+python+regex+sql+swift+typoscript+yaml */
code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}

View File

@@ -0,0 +1,3 @@
// @import "../../public/skins/ui/oxide/skin.min.css";
// @import "../../public/skins/ui/oxide/content.min.css";
// @import "../../public/skins/content/default/content.min.css";

View File

@@ -125,33 +125,33 @@
/* Margin */
@each $index, $size in $spacer {
.m-#{$index} {
margin: #{$size};
margin: #{$size} !important;
}
.mt-#{$index} {
margin-top: #{$size};
margin-top: #{$size} !important;
}
.mb-#{$index} {
margin-bottom: #{$size};
margin-bottom: #{$size} !important;
}
.ml-#{$index} {
margin-left: #{$size};
margin-left: #{$size} !important;
}
.mr-#{$index} {
margin-right: #{$size};
margin-right: #{$size} !important;
}
.mx-#{$index} {
margin-left: #{$size};
margin-right: #{$size};
margin-left: #{$size} !important;
margin-right: #{$size} !important;
}
.my-#{$index} {
margin-top: #{$size};
margin-bottom: #{$size};
margin-top: #{$size} !important;
margin-bottom: #{$size} !important;
}
}
@@ -193,6 +193,7 @@
/* Utility */
.prevent-select {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}

View File

@@ -27,6 +27,12 @@ $success-color: #198754;
$success-color-dark: #12653e;
$success-color-darker: #0c4329;
$warning-color-lighter: #fff8e2;
$warning-color-light: #fff6d9;
$warning-color: #fff3cd;
$warning-color-dark: #ffd75a;
$warning-color-darker: #ffc203;
$border-color: #dddddd;
$secondary-background-color: #efefef;

View File

@@ -1,44 +0,0 @@
import axios from "axios";
import { useUserStore } from "./store/UserStore";
import { useRouter } from "vue-router";
axios.defaults.baseURL = import.meta.env.APP_URL_API;
axios.defaults.withCredentials = true;
axios.defaults.headers.common["Accept"] = "application/json";
axios.interceptors.request.use((request) => {
const userStore = useUserStore();
if (userStore.id) {
request.headers["Authorization"] = `Bearer ${userStore.token}`;
}
return request;
});
axios.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error.config.redirect !== false && error.response.status === 401) {
const userStore = useUserStore();
const router = useRouter();
userStore.clearUser();
const url = new URL(error.request.responseURL);
router.push({ name: "login", query: { redirect: url.pathname } });
}
// if(error.config.redirect === true) {
// if(error.response.status === 403) {
// router.push({ name: 'error-forbidden' })
// } else if(error.response.status === 404) {
// router.push({ name: 'error-notfound' })
// } else if(error.response.status >= 500) {
// router.push({name: 'error-internal'})
// }
// }
return Promise.reject(error);
}
);

View File

@@ -1,34 +0,0 @@
import _ from 'lodash';
window._ = _;
/**
* We'll load the axios HTTP library which allows us to easily issue requests
* to our Laravel back-end. This library automatically handles sending the
* CSRF token as a header based on the value of the "XSRF" token cookie.
*/
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
/**
* Echo exposes an expressive API for subscribing to channels and listening
* for events that are broadcast by Laravel. Echo and event broadcasting
* allows your team to easily build robust real-time web applications.
*/
// import Echo from 'laravel-echo';
// import Pusher from 'pusher-js';
// window.Pusher = Pusher;
// window.Echo = new Echo({
// broadcaster: 'pusher',
// key: import.meta.env.VITE_PUSHER_APP_KEY,
// wsHost: import.meta.env.VITE_PUSHER_HOST ? import.meta.env.VITE_PUSHER_HOST : `ws-${import.meta.env.VITE_PUSHER_APP_CLUSTER}.pusher.com`,
// wsPort: import.meta.env.VITE_PUSHER_PORT ?? 80,
// wssPort: import.meta.env.VITE_PUSHER_PORT ?? 443,
// forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https',
// enabledTransports: ['ws', 'wss'],
// });

View File

@@ -1,58 +0,0 @@
<template>
<a :href="computedHref" :target="props.target" rel="noopener"
><slot></slot
></a>
</template>
<script setup lang="ts">
// import axios from 'axios'
import { computed } from "vue";
import { useUserStore } from "../store/UserStore";
const props = defineProps({
href: {
type: String,
required: true,
},
target: {
type: String,
default: "",
},
});
const userStore = useUserStore();
const computedHref = computed(() => {
const url = new URL(props.href);
if (url.pathname.startsWith("/api/") && userStore.token) {
return props.href + "?token=" + encodeURIComponent(userStore.token);
}
return props.href;
});
// const handleClick = async (event) => {
// const url = new URL(props.href)
// if(url.pathname.startsWith('/api/')) {
// console.log('api')
// event.preventDefault()
// axios.get(props.href, {responseType: 'blob'})
// .then(response => {
// const blob = new Blob([response.data], { type: response.data.type })
// const href = URL.createObjectURL(blob)
// const link = document.createElement('a')
// link.setAttribute('href', href)
// link.setAttribute('target', props.target)
// document.body.appendChild(link)
// link.click()
// document.body.removeChild(link)
// URL.revokeObjectURL(href)
// }).catch(e => {
// console.log(e)
// })
// }
// console.log('finish')
// }
</script>

View File

@@ -0,0 +1,68 @@
<template>
<SMContainer class="sm-attachments">
<h3 v-if="props.attachments && props.attachments.length > 0">
Attachments
</h3>
<div
v-for="file of props.attachments"
:key="file.id"
class="sm-attachment-row">
<div class="sm-attachment-file-icon">
<img
:src="getFileIconImagePath(file.title || file.name)"
height="48"
width="48" />
</div>
<a :href="file.url" class="sm-attachment-file-name">{{
file.title || file.name
}}</a>
<div class="sm-attachment-file-size">
({{ bytesReadable(file.size) }})
</div>
</div>
</SMContainer>
</template>
<script setup lang="ts">
import { bytesReadable } from "../helpers/types";
import { getFileIconImagePath } from "../helpers/utils";
import SMContainer from "./SMContainer.vue";
const props = defineProps({
attachments: {
type: Object,
required: true,
},
});
</script>
<style lang="scss">
.sm-attachments {
h3 {
margin-top: map-get($spacer, 3);
}
.sm-attachment-row {
border-bottom: 1px solid $secondary-background-color;
display: flex;
align-items: center;
padding: 0.5rem 0;
&:last-child {
border-bottom: 0;
}
.sm-attachment-file-icon {
display: flex;
width: 64px;
justify-content: center;
}
.sm-attachment-file-size {
font-size: 75%;
padding-left: 0.75rem;
color: $secondary-color-dark;
}
}
}
</style>

View File

@@ -1,51 +1,56 @@
<template>
<SMContainer
v-if="showBreadcrumbs"
:class="[
'flex-0',
'breadcrumbs-outer',
{ closed: breadcrumbs.length == 0 },
'sm-breadcrumbs-container',
{ closed: computedRouteCrumbs.length == 0 },
]">
<ul class="breadcrumbs">
<ul class="sm-breadcrumbs">
<li><router-link :to="{ name: 'home' }">Home</router-link></li>
<li v-for="(val, idx) of breadcrumbs" :key="val.name">
<li
v-for="(routeItem, index) of computedRouteCrumbs"
:key="routeItem.name">
<router-link
v-if="idx != breadcrumbs.length - 1"
:to="{ name: val.name }"
>{{ val.meta?.title || val.name }}</router-link
><span v-else>{{ val.meta?.title || val.name }}</span>
v-if="index != computedRouteCrumbs.length - 1"
:to="{ name: routeItem.name }"
>{{ routeItem.meta?.title || routeItem.name }}</router-link
><span v-else>{{
routeItem.meta?.title || routeItem.name
}}</span>
</li>
</ul>
</SMContainer>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRoute } from "vue-router";
import { computed, ComputedRef } from "vue";
import { RouteRecordRaw, useRoute } from "vue-router";
import { routes } from "../router";
import { useApplicationStore } from "../store/ApplicationStore";
const applicationStore = useApplicationStore();
const showBreadcrumbs = ref(true);
const breadcrumbs = computed(() => {
/**
* Return a list of routes from the current page back to the root
*/
const computedRouteCrumbs: ComputedRef<RouteRecordRaw[]> = computed(() => {
const currentPageName = useRoute().name;
if (currentPageName == "home") {
return [];
}
const findMatch = (list) => {
let found = null;
let index = null;
let child = null;
const findMatch = (list: RouteRecordRaw[]): RouteRecordRaw[] | null => {
let found: RouteRecordRaw[] | null = null;
let index: RouteRecordRaw | null = null;
let child: RouteRecordRaw[] | null = null;
list.every((entry) => {
list.every((entry: RouteRecordRaw) => {
if (index == null && "path" in entry && entry.path == "") {
index = entry;
}
if (child == null && "children" in entry) {
if (child == null && entry.children) {
child = findMatch(entry.children);
}
@@ -76,10 +81,10 @@ const breadcrumbs = computed(() => {
let itemList = findMatch(routes);
if (itemList) {
if (applicationStore.dynamicTitle.length > 0) {
let meta = [];
let meta = {};
if ("meta" in itemList) {
meta = itemList[itemList.length - 1];
if ("meta" in itemList[itemList.length - 1]) {
meta = itemList[itemList.length - 1]["meta"];
}
meta["title"] = applicationStore.dynamicTitle;
@@ -93,13 +98,13 @@ const breadcrumbs = computed(() => {
</script>
<style lang="scss">
.breadcrumbs-outer.closed .breadcrumbs {
.sm-breadcrumbs-container.closed .sm-breadcrumbs {
opacity: 0;
transition: opacity 0s;
transition-delay: 0s;
}
.breadcrumbs {
.sm-breadcrumbs {
height: 3.25rem;
display: flex;
max-width: 1200px;

View File

@@ -1,47 +1,76 @@
<template>
<a
v-if="href.length > 0 || typeof to == 'string'"
:href="href"
:disabled="disabled"
:class="[
'button',
'prevent-select',
classType,
{ 'button-block': block },
]"
:type="buttonType">
{{ label }}
<font-awesome-icon v-if="icon" :icon="icon" />
</a>
<button
v-else-if="to == null"
v-if="isEmpty(to)"
:disabled="disabled"
:class="[
'button',
'prevent-select',
'sm-button',
classType,
{ 'button-block': block },
{ 'sm-button-small': small },
{ 'sm-button-block': block },
{ 'sm-dropdown-button': dropdown },
]"
:type="buttonType"
@click="handleClick">
<ion-icon
v-if="icon && dropdown == null && iconLocation == 'before'"
:icon="icon"
class="sm-button-icon-before" />
<span>{{ label }}</span>
<ion-icon
v-if="icon && dropdown == null && iconLocation == 'after'"
:icon="icon"
class="sm-button-icon-after" />
<ion-icon
v-if="dropdown != null"
name="caret-down-outline"
class="sm-button-icon-dropdown"
@click.stop="handleClickToggleDropdown" />
<ul
v-if="dropdown != null"
ref="dropdownMenu"
@mouseleave="handleMouseLeave">
<li
v-for="(dropdownLabel, dropdownItem) in dropdown"
:key="dropdownItem"
@click.stop="handleClickItem(dropdownItem)">
{{ dropdownLabel }}
</li>
</ul>
</button>
<a
v-else-if="!isEmpty(to) && typeof to == 'string'"
:href="to"
:disabled="disabled"
:class="[
'sm-button',
classType,
{ 'sm-button-small': small },
{ 'sm-button-block': block },
]"
:type="buttonType">
{{ label }}
<font-awesome-icon v-if="icon" :icon="icon" />
</button>
<ion-icon v-if="icon" :icon="icon" />
</a>
<router-link
v-else
v-else-if="!isEmpty(to) && typeof to == 'object'"
:to="to"
:disabled="disabled"
:class="[
'button',
'prevent-select',
'sm-button',
classType,
{ 'button-block': block },
{ 'sm-button-small': small },
{ 'sm-button-block': block },
]">
<ion-icon v-if="icon && iconLocation == 'before'" :icon="icon" />
{{ label }}
<font-awesome-icon v-if="icon" :icon="icon" />
<ion-icon v-if="icon && iconLocation == 'after'" :icon="icon" />
</router-link>
</template>
<script setup lang="ts">
import { Ref, ref } from "vue";
import { isEmpty } from "../helpers/utils";
const props = defineProps({
label: { type: String, default: "Button", required: false },
type: { type: String, default: "primary", required: false },
@@ -50,17 +79,20 @@ const props = defineProps({
default: "",
required: false,
},
iconLocation: {
type: String,
default: "after",
required: false,
validator: (value: string) => {
return ["before", "after"].includes(value);
},
},
to: {
type: [String, Object],
default: null,
required: false,
validator: (prop) => typeof prop === "object" || prop === null,
},
href: {
type: String,
default: "",
required: false,
},
disabled: {
type: Boolean,
default: false,
@@ -71,17 +103,255 @@ const props = defineProps({
default: false,
required: false,
},
small: {
type: Boolean,
default: false,
required: false,
},
dropdown: {
type: Object,
default: null,
required: false,
validator: (prop) => typeof prop === "object" || prop === null,
},
});
const buttonType = props.type == "submit" ? "submit" : "button";
const buttonType: "submit" | "button" =
props.type == "submit" ? "submit" : "button";
const classType = props.type == "submit" ? "primary" : props.type;
const dropdownMenu: Ref<HTMLElement | null> = ref(null);
const emits = defineEmits(["click"]);
const handleClick = () => {
emits("click", "");
};
const handleClickToggleDropdown = () => {
if (dropdownMenu.value) {
dropdownMenu.value.style.display = "block";
}
};
const handleMouseLeave = () => {
if (dropdownMenu.value) {
dropdownMenu.value.style.display = "none";
}
};
const handleClickItem = (item: string) => {
emits("click", item);
};
</script>
<style lang="scss">
.button {
&.button-block {
a.sm-button,
.sm-button {
cursor: pointer;
position: relative;
padding: map-get($spacer, 2) map-get($spacer, 4);
color: white;
font-weight: 800;
border-width: 2px;
border-style: solid;
border-radius: 24px;
transition: background-color 0.1s, color 0.1s;
background-color: $secondary-color;
border-color: $secondary-color;
min-width: 7rem;
text-align: center;
display: inline-block;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
&.sm-button-block {
display: block;
width: 100%;
}
&.sm-button-small {
font-size: 85%;
font-weight: normal;
padding: map-get($spacer, 1) map-get($spacer, 3);
}
&.sm-dropdown-button {
padding: 0;
white-space: nowrap;
display: flex;
align-items: center;
font-weight: normal;
background: #fff !important;
color: $primary-color !important;
border-radius: 12px;
border-width: 1px;
font-size: 0.8rem;
min-width: auto;
span {
flex: 1;
border-right: 1px solid $primary-color-lighter;
padding-top: calc(#{map-get($spacer, 1)} / 1.5);
padding-bottom: calc(#{map-get($spacer, 1)} / 1.5);
padding-left: map-get($spacer, 3);
padding-right: map-get($spacer, 3);
}
.sm-button-icon-dropdown {
height: 1rem;
width: 1rem;
padding: 0 0.3rem 0 0.2rem;
}
&:hover {
background-color: $primary-color !important;
color: #fff !important;
span {
border-right: 1px solid $primary-color-light;
}
}
}
&:disabled {
cursor: not-allowed;
background-color: $secondary-color !important;
border-color: $secondary-color !important;
opacity: 0.5;
}
&:hover:not(:disabled) {
text-decoration: none;
color: $secondary-color;
}
&.primary {
background-color: $primary-color;
border-color: $primary-color;
&:hover:not(:disabled) {
color: $primary-color;
}
}
&.primary-outline {
background-color: transparent;
border-color: $primary-color;
color: $primary-color;
&:hover:not(:disabled) {
color: $primary-color;
}
}
&.secondary {
background-color: $secondary-color;
border-color: $secondary-color;
&:hover:not(:disabled) {
color: $secondary-color;
}
}
&.secondary-outline {
background-color: transparent;
border-color: $secondary-color;
color: $secondary-color;
&:hover:not(:disabled) {
color: $secondary-color;
}
}
&.danger {
background-color: $danger-color;
border-color: $danger-color;
&:hover:not(:disabled) {
color: $danger-color;
}
}
&.danger-outline {
background-color: transparent;
border-color: $danger-color;
color: $danger-color;
&:hover:not(:disabled) {
color: $danger-color;
}
}
&.outline {
background-color: transparent;
border-color: $outline-color;
color: $outline-color;
&:hover:not(:disabled) {
background-color: $outline-color;
border-color: $outline-color;
color: $outline-hover-color;
}
}
&:hover:not(:disabled) {
background-color: #fff;
}
ion-icon {
height: 1.2rem;
width: 1.2rem;
vertical-align: middle;
cursor: pointer;
}
ul {
position: absolute;
display: none;
z-index: 100;
top: 20%;
right: 0;
min-width: 100%;
list-style: none;
padding: 0;
margin: 0;
background-color: #f8f8f8;
border: 1px solid $border-color;
border-radius: 8px;
color: $primary-color;
box-shadow: 0 0 14px rgba(0, 0, 0, 0.5);
}
li {
padding: map-get($spacer, 1);
font-size: 100%;
cursor: pointer;
transition: background-color 0.1s ease-in-out;
&:first-child {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
&:last-child {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
}
li:hover {
background-color: $primary-color;
color: #f8f8f8;
}
.sm-button-icon-before {
margin-right: map-get($spacer, 1);
}
.sm-button-icon-after {
margin-left: map-get($spacer, 1);
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="captcha-notice">
<div class="sm-captcha-notice">
This site is protected by reCAPTCHA and the Google
<a href="https://policies.google.com/privacy">Privacy Policy</a> and
<a href="https://policies.google.com/terms">Terms of Service</a> apply.
@@ -7,7 +7,7 @@
</template>
<style lang="scss">
.captcha-notice {
.sm-captcha-notice {
color: $secondary-color;
font-size: 65%;
line-height: 1.2rem;

View File

@@ -1,64 +1,83 @@
<template>
<div
class="carousel"
class="sm-carousel"
@mouseover="handleMouseOver"
@mouseleave="handleMouseLeave">
<div ref="slides" class="carousel-slides">
<div ref="slides" class="sm-carousel-slides">
<slot></slot>
</div>
<div class="carousel-slide-prev" @click="handleSlidePrev">
<font-awesome-icon icon="fa-solid fa-chevron-left" />
<div class="sm-carousel-slide-prev" @click="handleClickSlidePrev">
<ion-icon name="chevron-back-outline" />
</div>
<div class="carousel-slide-next" @click="handleSlideNext">
<font-awesome-icon icon="fa-solid fa-chevron-right" />
<div class="sm-carousel-slide-next" @click="handleClickSlideNext">
<ion-icon name="chevron-forward-outline" />
</div>
<div class="carousel-slide-indicators">
<div class="sm-carousel-slide-indicators">
<div
v-for="(indicator, index) in slideElements"
:key="index"
class="carousel-slide-indicator-dot">
<font-awesome-icon
v-if="currentSlide != index"
icon="fa-regular fa-circle"
@click="handleIndicator(index)" />
<font-awesome-icon v-else icon="fa-solid fa-circle" />
</div>
:class="[
'sm-carousel-slide-indicator-item',
{ highlighted: currentSlide == index },
]"
@click="handleClickIndicator(index)"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { onMounted, onUnmounted, Ref, ref } from "vue";
const slides = ref(null);
let slideElements = ref([]);
/**
* Reference to slides element.
*/
const slides: Ref<HTMLElement | null> = ref(null);
/**
* The list of slide elements.
*/
let slideElements: Ref<NodeList | null> = ref(null);
/**
* Index of the current slide.
*/
let currentSlide = ref(0);
/**
* The maximum number of slides.
*/
let maxSlide = ref(0);
let intervalRef = null;
const mutationObserver = ref(null);
onMounted(() => {
connectMutationObserver();
handleUpdate();
startAutoSlide();
});
/**
* The window interval reference to slide the carousel.
*/
let intervalRef: number | null = null;
onUnmounted(() => {
stopAutoSlide();
disconnectMutationObserver();
});
/**
* The active mutation observer.
*/
const mutationObserver: Ref<MutationObserver | null> = ref(null);
/**
* Handle the user moving the mouse over the carousel.
*/
const handleMouseOver = () => {
stopAutoSlide();
};
/**
* Handle the user moving the mouse leaving the carousel.
*/
const handleMouseLeave = () => {
startAutoSlide();
};
const handleSlidePrev = () => {
/**
* Handle the user clicking the previous slider indicator.
*/
const handleClickSlidePrev = () => {
if (currentSlide.value == 0) {
currentSlide.value = maxSlide;
currentSlide.value = maxSlide.value;
} else {
currentSlide.value--;
}
@@ -66,7 +85,10 @@ const handleSlidePrev = () => {
updateSlidePositions();
};
const handleSlideNext = () => {
/**
* Handle the user clicking the next slider indicator.
*/
const handleClickSlideNext = () => {
if (currentSlide.value == maxSlide.value) {
currentSlide.value = 0;
} else {
@@ -76,34 +98,56 @@ const handleSlideNext = () => {
updateSlidePositions();
};
const handleIndicator = (index) => {
/**
* Handle the user clicking a slider indicator.
*
* @param {number} index The slide to move to.
*/
const handleClickIndicator = (index: number) => {
currentSlide.value = index;
updateSlidePositions();
};
const handleUpdate = () => {
slideElements.value = slides.value.querySelectorAll(".carousel-slide");
maxSlide.value = slideElements.value.length - 1;
/**
* Handle slides added/removed from the carousel and update the data/indicators.
*/
const handleCarouselUpdate = () => {
if (slides.value != null) {
slideElements.value =
slides.value.querySelectorAll(".sm-carousel-slide");
maxSlide.value = slideElements.value.length - 1;
}
updateSlidePositions();
};
/**
* Update the style transform of each slide.
*/
const updateSlidePositions = () => {
slideElements.value.forEach((slide, index) => {
slide.style.transform = `translateX(${
100 * (index - currentSlide.value)
}%)`;
});
if (slideElements.value != null) {
slideElements.value.forEach((slide, index) => {
(slide as HTMLElement).style.transform = `translateX(${
100 * (index - currentSlide.value)
}%)`;
});
}
};
/**
* Start the carousel slider.
*/
const startAutoSlide = () => {
if (intervalRef == null) {
intervalRef = window.setInterval(() => {
handleSlideNext();
handleClickSlideNext();
}, 7000);
}
};
/**
* Stop the carousel slider.
*/
const stopAutoSlide = () => {
if (intervalRef != null) {
window.clearInterval(intervalRef);
@@ -111,39 +155,60 @@ const stopAutoSlide = () => {
}
};
/**
* Connect the mutation observer to the slider.
*/
const connectMutationObserver = () => {
mutationObserver.value = new MutationObserver(handleUpdate);
if (slides.value != null) {
mutationObserver.value = new MutationObserver(handleCarouselUpdate);
mutationObserver.value.observe(slides.value, {
attributes: false,
childList: true,
characterData: true,
subtree: true,
});
mutationObserver.value.observe(slides.value, {
attributes: false,
childList: true,
characterData: true,
subtree: true,
});
}
};
/**
* Disconnect the mutation observer from the slider.
*/
const disconnectMutationObserver = () => {
mutationObserver.value.disconnect();
if (mutationObserver.value) {
mutationObserver.value.disconnect();
}
};
onMounted(() => {
connectMutationObserver();
handleCarouselUpdate();
startAutoSlide();
});
onUnmounted(() => {
stopAutoSlide();
disconnectMutationObserver();
});
</script>
<style lang="scss">
.carousel {
.sm-carousel {
position: relative;
height: 28rem;
background: #eee;
overflow: hidden;
&:hover {
.carousel-slide-prev,
.carousel-slide-next,
.carousel-slide-indicators {
.sm-carousel-slide-prev,
.sm-carousel-slide-next,
.sm-carousel-slide-indicators {
opacity: 1;
}
}
.carousel-slide-prev,
.carousel-slide-next {
.sm-carousel-slide-prev,
.sm-carousel-slide-next {
position: absolute;
top: 50%;
font-size: 300%;
@@ -155,7 +220,7 @@ const disconnectMutationObserver = () => {
transition: transform 0.2s ease-in-out, opacity 0.2s ease-in-out;
opacity: 0.75;
svg {
ion-icon {
filter: drop-shadow(0px 0px 2px rgba(0, 0, 0, 1));
}
@@ -165,15 +230,17 @@ const disconnectMutationObserver = () => {
}
}
.carousel-slide-prev {
.sm-carousel-slide-prev {
left: 1rem;
filter: drop-shadow(0px 0px 2px rgba(0, 0, 0, 1));
}
.carousel-slide-next {
.sm-carousel-slide-next {
right: 1rem;
filter: drop-shadow(0px 0px 2px rgba(0, 0, 0, 1));
}
.carousel-slide-indicators {
.sm-carousel-slide-indicators {
position: absolute;
display: flex;
justify-content: center;
@@ -184,20 +251,28 @@ const disconnectMutationObserver = () => {
opacity: 0.75;
transition: opacity 0.2s ease-in-out;
svg {
.sm-carousel-slide-indicator-item {
height: 12px;
width: 12px;
border: 1px solid white;
border-radius: 50%;
cursor: pointer;
font-size: 80%;
padding: 0 0.25rem;
margin: 0 calc(#{map-get($spacer, 1)} / 3);
color: #fff;
filter: drop-shadow(0px 0px 2px rgba(0, 0, 0, 1));
&.highlighted {
background-color: white;
}
}
}
}
@media only screen and (max-width: 400px) {
.carousel {
.carousel-slide-prev,
.carousel-slide-next {
.sm-carousel {
.sm-carousel-slide-prev,
.sm-carousel-slide-next {
font-size: 150%;
}
}

View File

@@ -1,17 +1,21 @@
<template>
<div
class="carousel-slide"
class="sm-carousel-slide"
:style="{ backgroundImage: `url('${imageUrl}')` }">
<div v-if="imageUrl == null" class="carousel-slide-loading">
<font-awesome-icon icon="fa-solid fa-spinner" pulse />
<div v-if="imageUrl.length == 0" class="sm-carousel-slide-loading">
<SMLoadingIcon />
</div>
<div v-else class="carousel-slide-body">
<div class="carousel-slide-content">
<div class="carousel-slide-content-inner">
<div v-else class="sm-carousel-slide-body">
<div class="sm-carousel-slide-content">
<div class="sm-carousel-slide-content-inner">
<h3>{{ title }}</h3>
<p v-if="content">{{ content }}</p>
<div class="carousel-slide-body-buttons">
<SMButton v-if="url" :to="url" :label="cta" />
<div class="sm-carousel-slide-body-buttons">
<SMButton
v-if="url"
:to="url"
:label="cta"
type="secondary-outline" />
</div>
</div>
</div>
@@ -20,9 +24,12 @@
</template>
<script setup lang="ts">
import axios from "axios";
import { ref } from "vue";
import { api } from "../helpers/api";
import { MediaResponse } from "../helpers/api.types";
import { imageLoad } from "../helpers/image";
import SMButton from "./SMButton.vue";
import SMLoadingIcon from "./SMLoadingIcon.vue";
const props = defineProps({
title: {
@@ -52,24 +59,34 @@ const props = defineProps({
},
});
let imageUrl = ref(null);
let imageUrl = ref("");
const handleLoad = async () => {
try {
let result = await axios.get(`media/${props.image}`);
if (result.data.medium) {
imageUrl.value = result.data.medium.url;
}
} catch (error) {
imageUrl.value = "";
}
/**
* Load the slider data.
*/
const handleLoad = () => {
imageUrl.value = "";
api.get({ url: "/media/{medium}", params: { medium: props.image } })
.then((result) => {
const data = result.data as MediaResponse;
if (data && data.medium) {
imageLoad(data.medium.url, (url) => {
imageUrl.value = url;
});
}
})
.catch(() => {
/* empty */
});
};
handleLoad();
</script>
<style lang="scss">
.carousel-slide {
.sm-carousel-slide {
position: absolute;
transition: all 0.5s;
width: 100%;
@@ -79,19 +96,14 @@ handleLoad();
background-size: cover;
overflow: hidden;
.carousel-slide-loading {
.sm-carousel-slide-loading {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
svg {
color: rgba(0, 0, 0, 0.1);
font-size: 300%;
}
}
.carousel-slide-body {
.sm-carousel-slide-body {
display: flex;
align-items: center;
height: 100%;
@@ -99,7 +111,7 @@ handleLoad();
margin: 0 auto;
padding: 1rem;
.carousel-slide-content {
.sm-carousel-slide-content {
display: flex;
justify-content: center;
align-items: center;
@@ -130,17 +142,15 @@ handleLoad();
text-align: left;
}
.carousel-slide-body-buttons {
.sm-carousel-slide-body-buttons {
margin-top: 2rem;
text-align: right;
max-width: 600px;
}
.button {
display: inline-block;
box-shadow: 0 0 12px rgba(0, 0, 0, 0.5);
background: transparent;
.secondary-outline {
border-color: #fff;
color: #fff;
&:hover {
color: #333;

View File

@@ -1,6 +1,6 @@
<template>
<div
:class="['column', { 'flex-fill': fill && width == '' }]"
:class="['sm-column', { 'flex-fill': fill && width == '' }]"
:style="styles">
<slot></slot>
</div>
@@ -28,13 +28,13 @@ if (props.width != "") {
</script>
<style lang="scss">
.column {
.sm-column {
display: flex;
margin: map-get($spacer, 2);
flex-direction: column;
}
.row .row .column {
.sm-row .sm-row .sm-column {
&:first-of-type {
margin-left: 0;
}
@@ -44,13 +44,13 @@ if (props.width != "") {
}
}
.dialog .column {
.sm-dialog .sm-column {
margin-top: 0;
margin-bottom: 0;
}
@media screen and (max-width: 768px) {
.column {
.sm-column {
flex-basis: auto !important;
width: 100%;
@@ -60,7 +60,7 @@ if (props.width != "") {
}
@media screen and (max-width: 640px) {
.column {
.sm-column {
flex-direction: column;
}
}

View File

@@ -1,50 +1,16 @@
<template>
<div :class="['container', { full: isFull }]">
<SMLoader :loading="loading">
<d-error-forbidden
v-if="pageError == 403 || !hasPermission()"></d-error-forbidden>
<d-error-internal
v-if="pageError >= 500 && hasPermission()"></d-error-internal>
<d-error-not-found v-if="pageError == 404 && hasPermission()"
>XX</d-error-not-found
>
<slot
v-if="
pageError < 300 && hasPermission() && slots.default
"></slot>
<div
v-if="pageError < 300 && hasPermission() && slots.inner"
class="container-inner">
<slot name="inner"></slot>
</div>
</SMLoader>
<div :class="['sm-container', { full: full }]">
<slot v-if="slots.default"></slot>
<div v-if="slots.inner" class="sm-container-inner">
<slot name="inner"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import SMLoader from "./SMLoader.vue";
import DErrorForbidden from "./errors/Forbidden.vue";
import DErrorInternal from "./errors/Internal.vue";
import DErrorNotFound from "./errors/NotFound.vue";
import { useUserStore } from "../store/UserStore";
import { computed, useSlots } from "vue";
import { useSlots } from "vue";
const props = defineProps({
pageError: {
type: Number,
default: 200,
required: false,
},
permission: {
type: String,
default: "",
required: false,
},
loading: {
type: Boolean,
default: false,
required: false,
},
defineProps({
full: {
type: Boolean,
default: false,
@@ -52,22 +18,10 @@ const props = defineProps({
},
});
const slots = useSlots();
const userStore = useUserStore();
const hasPermission = () => {
return (
props.permission.length == 0 ||
userStore.permissions.includes(props.permission)
);
};
const isFull = computed(() => {
return props.pageError == 200 ? props.full : false;
});
</script>
<style lang="scss">
.container {
.sm-container {
display: flex;
flex-direction: column;
flex: 1;
@@ -76,13 +30,16 @@ const isFull = computed(() => {
width: 100%;
max-width: 1200px;
margin: 0 auto;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
&.full {
padding-left: 0;
padding-right: 0;
max-width: 100%;
.container-inner {
.sm-container-inner {
padding-left: 1rem;
padding-right: 1rem;
width: 100%;

View File

@@ -20,7 +20,7 @@
<slot></slot>
</div>
<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 }}
</div>
</div>

View File

@@ -1,14 +1,15 @@
<template>
<div
:class="[
'dialog',
{ 'dialog-narrow': narrow },
{ 'dialog-full': full },
'sm-dialog',
{ 'sm-dialog-narrow': narrow },
{ 'sm-dialog-full': full },
{ 'sm-dialog-noshadow': noShadow },
]">
<transition name="fade">
<div v-if="loading" class="dialog-loading-cover">
<div class="dialog-loading">
<font-awesome-icon icon="fa-solid fa-spinner" pulse />
<div v-if="loading" class="sm-dialog-loading-cover">
<div class="sm-dialog-loading">
<SMLoadingIcon />
<span>{{ loadingMessage }}</span>
</div>
</div>
@@ -18,6 +19,8 @@
</template>
<script setup lang="ts">
import SMLoadingIcon from "./SMLoadingIcon.vue";
defineProps({
loading: {
type: Boolean,
@@ -35,14 +38,17 @@ defineProps({
type: Boolean,
default: false,
},
noShadow: {
type: Boolean,
default: false,
},
});
</script>
<style lang="scss">
.dialog {
.sm-dialog {
flex-direction: column;
margin: 0 auto;
flex: 1;
background-color: #eee;
padding: map-get($spacer, 5) map-get($spacer, 5)
calc(map-get($spacer, 5) / 1.5) map-get($spacer, 5);
@@ -50,21 +56,30 @@ defineProps({
border-radius: 24px;
overflow: hidden;
min-width: map-get($spacer, 5) * 12;
box-shadow: 4px 4px 20px rgba(0, 0, 0, 0.5);
&.sm-dialog-noshadow {
box-shadow: none !important;
}
& > h1 {
margin-top: 0;
}
&.dialog-narrow {
& > p {
font-size: 90%;
}
&.sm-dialog-narrow {
min-width: auto;
max-width: map-get($spacer, 5) * 10;
}
&.dialog-full {
&.sm-dialog-full {
width: 100%;
}
.dialog-loading-cover {
.sm-dialog-loading-cover {
position: fixed;
display: flex;
justify-content: center;
@@ -76,16 +91,18 @@ defineProps({
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(4px);
background-color: rgba(255, 255, 255, 0.5);
z-index: 19000;
.dialog-loading {
.sm-dialog-loading {
display: flex;
flex-direction: column;
padding: map-get($spacer, 5) calc(map-get($spacer, 5) * 2);
align-items: center;
border: 1px solid transparent;
border-radius: 24px;
svg {
ion-icon {
font-size: calc(map-get($spacer, 5) * 1.5);
}
@@ -98,12 +115,12 @@ defineProps({
}
@media only screen and (max-width: 640px) {
.dialog {
padding: map-get($spacer, 1) map-get($spacer, 3) map-get($spacer, 3)
map-get($spacer, 3);
.sm-dialog {
padding: map-get($spacer, 5) map-get($spacer, 4) map-get($spacer, 4)
map-get($spacer, 4);
min-width: auto;
.button {
.sm-button {
display: block;
width: 100%;
text-align: center;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
<template>
<a :href="computedUrl" :target="props.target" rel="noopener"
><slot></slot
></a>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useUserStore } from "../store/UserStore";
const props = defineProps({
href: {
type: String,
required: true,
},
target: {
type: String,
default: "_self",
},
});
const userStore = useUserStore();
/**
* Return the URL with a token param attached if the user is logged in and its a api media download request.
*/
const computedUrl = computed(() => {
const url = new URL(props.href);
const path = url.pathname;
const mediumRegex = /^\/media\/[a-zA-Z0-9]+\/download$/;
if (mediumRegex.test(path) && userStore.token) {
if (url.search) {
return `${props.href}&token=${encodeURIComponent(userStore.token)}`;
} else {
return `${props.href}?token=${encodeURIComponent(userStore.token)}`;
}
}
return props.href;
});
</script>

View File

@@ -1,37 +1,37 @@
<template>
<SMContainer :full="true" class="footer">
<SMRow class="social">
<SMContainer :full="true" class="sm-footer">
<SMRow class="sm-social">
<SMColumn class="align-items-center">
<ul>
<li>
<a href="https://facebook.com/stemmechanics"
><font-awesome-icon icon="fa-brands fa-facebook"
/></a>
><ion-icon name="logo-facebook"></ion-icon
></a>
</li>
<li>
<a href="https://mastodon.au/@stemmechanics"
><font-awesome-icon icon="fa-brands fa-mastodon"
/></a>
><ion-icon name="logo-mastodon"></ion-icon
></a>
</li>
<li>
<a href="https://www.youtube.com/@stemmechanics"
><font-awesome-icon icon="fa-brands fa-youtube"
/></a>
><ion-icon name="logo-youtube"></ion-icon
></a>
</li>
<li>
<a href="https://twitter.com/stemmechanics"
><font-awesome-icon icon="fa-brands fa-twitter"
/></a>
><ion-icon name="logo-twitter"></ion-icon
></a>
</li>
<li>
<a href="https://github.com/stemmechanics"
><font-awesome-icon icon="fa-brands fa-github"
/></a>
><ion-icon name="logo-github"></ion-icon
></a>
</li>
<li>
<a href="https://discord.gg/yNzk4x7mpD"
><font-awesome-icon icon="fa-brands fa-discord"
/></a>
><ion-icon name="logo-discord"></ion-icon
></a>
</li>
</ul>
</SMColumn>
@@ -40,9 +40,9 @@
<SMColumn
width="350px"
class="align-items-center justify-content-center align-items-center">
<router-link to="/" class="brand"></router-link>
<router-link to="/" class="sm-brand"></router-link>
</SMColumn>
<SMColumn class="footer-acknowledgement">
<SMColumn class="sm-footer-text">
<p>
STEMMechanics Australia acknowledges the Traditional Owners
of Country throughout Australia and the continuing
@@ -50,6 +50,17 @@
respect to Aboriginal and Torres Strait Islander cultures;
and to Elders both past, present and emerging.
</p>
<p class="small">
This site is protected by reCAPTCHA and the Google
<a href="https://policies.google.com/privacy"
>Privacy Policy</a
>
and
<a href="https://policies.google.com/terms"
>Terms of Service</a
>
apply.
</p>
</SMColumn>
</SMRow>
<SMRow>
@@ -58,7 +69,7 @@
class="justify-content-center align-items-center copyright"
>Made with - Copyright &copy; 2023</SMColumn
>
<SMColumn class="justify-content-center footer-links">
<SMColumn class="justify-content-center sm-footer-links">
<ul>
<li>
<router-link :to="{ name: 'contact' }"
@@ -85,15 +96,14 @@
</template>
<style lang="scss" scoped>
.footer {
.sm-footer {
flex: 0;
align-items: center;
margin-top: calc(map-get($spacer, 5) * 2);
font-size: 80%;
background-color: #f8f8f8;
padding: 0 0 map-get($spacer, 5) 0;
.social {
.sm-social {
font-size: 200%;
max-width: 100%;
border-top: 1px solid #ddd;
@@ -108,7 +118,7 @@
}
}
.brand {
.sm-brand {
display: inline-block;
background-image: url("/img/logo.png");
background-position: left top;
@@ -143,26 +153,30 @@
}
}
.footer-acknowledgement {
.sm-footer-text {
p {
padding: 0;
margin: 0 0 0.5rem 0;
}
p:last-of-type {
margin-bottom: 0;
}
}
}
@media only screen and (max-width: 768px) {
.footer {
.sm-footer {
.row:first-of-type {
padding-bottom: 0;
}
.footer-acknowledgement {
.sm-footer-text {
align-items: center;
text-align: center;
}
.footer-links {
.sm-footer-links {
align-items: center;
justify-content: center;
}
@@ -170,11 +184,11 @@
}
@media only screen and (max-width: 640px) {
.footer {
.footer-acknowledgement {
.sm-footer {
.sm-footer-text {
padding: 0 1rem;
}
.footer-links ul {
.sm-footer-links ul {
flex-direction: column;
li {
@@ -186,7 +200,7 @@
}
@media only screen and (max-width: 400px) {
.footer ul li {
.sm-footer ul li {
margin-left: 0.5rem;
margin-right: 0.5rem;
}

View File

@@ -0,0 +1,37 @@
<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"]);
/**
* Handle the user submitting the form.
*/
const handleSubmit = async function () {
if (await props.modelValue.validate()) {
emits("submit");
}
};
provide("form", props.modelValue);
</script>

View File

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

View File

@@ -1,9 +1,11 @@
<template>
<component :is="parsedContent"></component>
<component :is="computedContent"></component>
</template>
<script setup lang="ts">
import DOMPurify from "dompurify";
import { computed } from "vue";
import { ImportMetaExtras } from "../../../import-meta";
const props = defineProps({
html: {
@@ -13,14 +15,21 @@ const props = defineProps({
},
});
const parsedContent = computed(() => {
/**
* Return the html as a component, relative links as router-link and sanitized.
*/
const computedContent = computed(() => {
let html = "";
const regex = new RegExp(
`<a ([^>]*?)href="${import.meta.env.APP_URL}(.*?>.*?)</a>`,
`<a ([^>]*?)href="${
(import.meta as ImportMetaExtras).env.APP_URL
}(.*?>.*?)</a>`,
"ig"
);
html = props.html.replaceAll(regex, '<router-link $1to="$2</router-link>');
html = props.html.replace(regex, '<router-link $1to="$2</router-link>');
html = DOMPurify.sanitize(html);
return {
template: `<div class="content">${html}</div>`,

View File

@@ -1,16 +1,13 @@
<template>
<div class="heading">
<router-link
v-if="back != ''"
:to="{ name: back }"
class="heading-back">
<font-awesome-icon icon="fa-solid fa-arrow-left" />{{ backLabel }}
<div class="sm-heading">
<router-link v-if="back != ''" :to="{ name: back }" class="sm-back">
<ion-icon name="arrow-back-outline" />{{ backLabel }}
</router-link>
<router-link v-if="close != ''" :to="{ name: close }" class="close">
<font-awesome-icon icon="fa-solid fa-close" />
<router-link v-if="close != ''" :to="{ name: close }" class="sm-close">
<ion-icon name="close-outline" />
</router-link>
<span v-if="closeBack" class="close" @click="handleBack">
<font-awesome-icon icon="fa-solid fa-close" />
<span v-if="closeBack" class="sm-close" @click="handleBack">
<ion-icon name="close-outline" />
</span>
<h1>{{ heading }}</h1>
</div>
@@ -50,20 +47,16 @@ const handleBack = () => {
</script>
<style lang="scss">
.heading {
.sm-heading {
position: relative;
.heading-back {
.sm-back {
position: absolute;
padding-top: 2rem;
font-size: 80%;
svg {
margin-right: 0.5rem;
}
}
.close {
.sm-close {
right: -10px;
top: -10px;
position: absolute;
@@ -76,10 +69,8 @@ const handleBack = () => {
}
}
// @media screen and (max-width: 768px) {
@media only screen and (max-width: 640px) {
.heading .close {
.sm-heading .sm-close {
right: 0;
top: -20px;
}

View File

@@ -1,104 +1,119 @@
<template>
<div :class="['form-group', { 'has-error': error }]">
<label v-if="label" :class="{ required: required, inline: inline }">{{
label
}}</label>
<div
:class="[
'sm-input-group',
{
'sm-input-active': inputActive,
'sm-feedback-invalid': feedbackInvalid,
'sm-input-small': small,
},
computedClassType,
]">
<label v-if="label">{{ label }}</label>
<ion-icon
class="sm-invalid-icon"
name="alert-circle-outline"></ion-icon>
<input
v-if="
type == 'text' ||
type == 'email' ||
type == 'password' ||
type == 'email' ||
type == 'url'
type == 'url' ||
type == 'daterange' ||
type == 'datetime'
"
:type="type"
:value="modelValue"
:placeholder="placeholder"
@input="input"
:value="value"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleBlur" />
@keydown="handleKeydown" />
<textarea
v-if="type == 'textarea'"
v-else-if="type == 'textarea'"
rows="5"
:value="modelValue"
:placeholder="placeholder"
@input="input"
:value="value"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleBlur"></textarea>
<div v-if="type == 'file'" class="input-file-group">
@keydown="handleKeydown"></textarea>
<div v-else-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>
<label class="sm-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">
<select
v-else-if="type == 'select'"
:value="value"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeydown">
<option
v-for="(optionValue, key) in options"
:key="key"
:value="key"
:selected="key == value">
{{ optionValue }}
</option>
</select>
<div v-else-if="type == 'media'" class="sm-input-media">
<div class="sm-input-media-item">
<img v-if="mediaUrl.length > 0" :src="mediaUrl" />
<font-awesome-icon v-else icon="fa-regular fa-image" />
<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>
<a
class="sm-button sm-button-small"
@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 v-if="help" class="form-group-help">
<font-awesome-icon v-if="helpIcon" :icon="helpIcon" />
{{ help }}
<div v-if="slots.default || feedbackInvalid" class="sm-input-help">
<span v-if="feedbackInvalid" class="sm-input-invalid">{{
feedbackInvalid
}}</span>
<span v-if="slots.default" class="sm-input-info">
<slot></slot>
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, useSlots, ref, watch } from "vue";
import SMDialogMedia from "./dialogs/SMDialogMedia.vue";
import { computed, inject, ref, useSlots, watch } from "vue";
import { openDialog } from "vue3-promise-dialog";
import axios from "axios";
import { toTitleCase } from "../helpers/string";
import { isEmpty } from "../helpers/utils";
import SMDialogMedia from "./dialogs/SMDialogMedia.vue";
const props = defineProps({
modelValue: {
type: String,
default: "",
required: false,
},
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: "",
small: {
type: Boolean,
default: false,
required: false,
},
help: {
type: String,
default: "",
},
helpIcon: {
feedbackInvalid: {
type: String,
default: "",
},
@@ -106,100 +121,312 @@ const props = defineProps({
type: String,
default: "",
},
href: {
type: String,
options: {
type: Object,
default() {
return {};
},
},
control: {
type: [String, Object],
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 mediaUrl = ref("");
const objForm = inject("form", props.form);
const objControl =
typeof props.control == "object"
? props.control
: !isEmpty(objForm) &&
typeof props.control == "string" &&
props.control != ""
? objForm.controls[props.control]
: null;
const label = ref(props.label);
const feedbackInvalid = ref(props.feedbackInvalid);
const value = ref(props.modelValue);
const inputActive = ref(value.value.length > 0 || props.type == "select");
/**
* Return the classname based on type
*/
const computedClassType = computed(() => {
return `sm-input-${props.type}`;
});
watch(
() => props.label,
(newValue) => {
label.value = newValue;
}
);
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);
}
inputActive.value = value.value.length > 0;
watch(
() => objControl.validation.result.valid,
(newValue) => {
feedbackInvalid.value = newValue
? ""
: objControl.validation.result.invalidMessages[0];
},
{ deep: true }
);
watch(
() => objControl.value,
(newValue) => {
value.value = newValue;
},
{ deep: true }
);
}
watch(
() => props.modelValue,
(newValue) => {
value.value = newValue;
}
);
watch(
() => props.feedbackInvalid,
(newValue) => {
feedbackInvalid.value = newValue;
}
);
watch(
() => value.value,
(newValue) => {
inputActive.value = newValue.length > 0;
}
);
const handleChange = (event) => {
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 = async (event: Event) => {
if (objControl) {
await objControl.validate();
objControl.isValid();
}
const target = event.target as HTMLInputElement;
if (target.value.length == 0) {
inputActive.value = false;
}
emits("blur", event);
};
const input = (event) => {
emits("update:modelValue", event.target.value);
};
const handleBlur = (event) => {
if (event.keyCode == undefined || event.keyCode == 9) {
emits("blur", event);
}
const handleKeydown = (event: Event) => {
emits("keydown", 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(() => {
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>
<style lang="scss">
.input-media-group {
.sm-input-group {
position: relative;
display: flex;
margin: 0 auto;
max-width: 26rem;
flex-direction: column;
align-items: center;
margin-bottom: map-get($spacer, 4);
flex: 1;
width: 100%;
.input-media-display {
display: flex;
margin-bottom: 1rem;
&.sm-input-small {
font-size: 80%;
&.sm-input-active {
label {
transform: translate(6px, -3px) scale(0.7);
}
input {
padding: calc(#{map-get($spacer, 1)} * 1.5) map-get($spacer, 2)
calc(#{map-get($spacer, 1)} / 2) map-get($spacer, 2);
}
}
input,
label {
padding: map-get($spacer, 1) map-get($spacer, 2);
}
}
&.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);
}
textarea {
padding: calc(#{map-get($spacer, 2)} * 2) map-get($spacer, 3)
calc(#{map-get($spacer, 2)} / 2) map-get($spacer, 3);
}
select {
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: $secondary-color-dark;
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;
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 {
max-width: 100%;
max-height: 100%;
}
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
svg {
padding: 4rem;
textarea {
resize: none;
}
&.sm-input-media {
label {
position: relative;
transform: none;
}
}
.button {
max-width: 13rem;
.sm-input-media {
text-align: center;
.sm-input-media-item {
display: block;
margin-bottom: 0.5rem;
img {
max-width: 100%;
max-height: 100%;
}
ion-icon {
padding: 4rem;
font-size: 3rem;
border: 1px solid $border-color;
background-color: #fff;
}
}
.button {
display: inline-block;
}
}
}
.input-media-group + .form-group-error {
text-align: center;
}
.sm-input-help {
font-size: 75%;
margin: 0 map-get($spacer, 1);
@media screen and (max-width: 768px) {
.input-media-group {
max-width: 13rem;
.sm-input-invalid {
color: $danger-color;
padding-right: map-get($spacer, 1);
}
}
}
</style>

View File

@@ -0,0 +1,198 @@
<template>
<div class="sm-input-group sm-input-attachments">
<label>Attachments</label>
<ul>
<li v-if="mediaItems.length == 0" class="attachments-none">
<ion-icon name="sad-outline"></ion-icon>
<p>No attachments</p>
</li>
<li v-for="media of mediaItems" :key="media.id">
<div class="attachment-media-icon">
<img
:src="getFilePreview(media.url)"
height="48"
width="48" />
</div>
<div class="attachment-media-name">
{{ media.title || media.name }}
</div>
<div class="attachment-media-size">
({{ bytesReadable(media.size) }})
</div>
<div class="attachment-media-remove">
<ion-icon
name="close-outline"
title="Remove attachment"
@click="handleClickRemove(media.id)" />
</div>
</li>
</ul>
<a class="button" @click.prevent="handleClickAdd">Add media</a>
</div>
</template>
<script setup lang="ts">
import { ref, Ref, watch } from "vue";
import { openDialog } from "vue3-promise-dialog";
import { api } from "../helpers/api";
import { Media, MediaResponse } from "../helpers/api.types";
import { bytesReadable } from "../helpers/types";
import { getFilePreview } from "../helpers/utils";
import SMDialogMedia from "./dialogs/SMDialogMedia.vue";
const props = defineProps({
modelValue: {
type: Array<string>,
default: () => [],
required: true,
},
accept: {
type: String,
default: "",
},
});
const emits = defineEmits(["update:modelValue"]);
const value: Ref<string[]> = ref(props.modelValue);
const mediaItems: Ref<Media[]> = ref([]);
/**
* Handle the user adding a new media item.
*/
const handleClickAdd = async () => {
openDialog(SMDialogMedia, { mime: "", accepts: "" }).then((result) => {
const media = result as Media;
mediaItems.value.push(media);
value.value.push(media.id);
emits("update:modelValue", value);
});
};
/**
* Handle removing a media item from the attachment array.
*
* @param {string} media_id The media id to remove.
*/
const handleClickRemove = (media_id: string) => {
const index = value.value.indexOf(media_id);
if (index !== -1) {
value.value.splice(index, 1);
}
const mediaIndex = mediaItems.value.findIndex(
(media) => media.id === media_id
);
if (mediaIndex !== -1) {
mediaItems.value.splice(mediaIndex, 1);
}
emits("update:modelValue", value);
};
/**
* Load the attachment list
*/
const handleLoad = () => {
mediaItems.value = [];
value.value.forEach((item) => {
api.get({
url: `/media/${item}`,
})
.then((result) => {
if (result.data) {
const data = result.data as MediaResponse;
mediaItems.value.push(data.medium);
}
})
.catch(() => {
/* empty */
});
});
};
watch(
() => props.modelValue,
(newValue) => {
value.value = newValue;
}
);
handleLoad();
</script>
<style lang="scss">
.sm-input-group.sm-input-attachments {
display: block;
label {
position: relative;
display: block;
padding: map-get($spacer, 2) map-get($spacer, 3) map-get($spacer, 0)
map-get($spacer, 3);
line-height: 1.5;
color: $secondary-color-dark;
}
a.button {
display: inline-block;
}
ul {
list-style-type: none;
padding: 0;
border: 1px solid $border-color;
li {
background-color: #fff;
display: flex;
align-items: center;
padding: map-get($spacer, 2);
&.attachments-none {
justify-content: center;
ion-icon {
font-size: 1.5rem;
}
p {
margin: 0;
padding-left: 0.5rem;
}
}
.attachment-media-icon {
display: flex;
width: 64px;
justify-content: center;
}
.attachment-media-name {
flex: 1;
}
.attachment-media-size {
font-size: 75%;
padding-left: 0.75rem;
color: $secondary-color-dark;
}
.attachment-media-remove {
font-size: 1.5rem;
padding-top: 0.2rem;
margin-left: 1rem;
color: $font-color;
cursor: pointer;
&:hover {
color: $danger-color;
}
}
}
}
}
</style>

View File

@@ -1,10 +1,8 @@
<template>
<template v-if="loading">
<transition name="fade">
<div v-if="loading" class="loader-cover">
<div class="loader">
<font-awesome-icon icon="fa-solid fa-spinner" pulse />
</div>
<div v-if="loading" class="sm-loader">
<SMLoadingIcon />
</div>
</transition>
</template>
@@ -12,10 +10,30 @@
</template>
<script setup lang="ts">
import SMLoadingIcon from "./SMLoadingIcon.vue";
defineProps({
loading: {
type: Boolean,
default: false,
required: true,
},
});
</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,68 @@
<template>
<div class="sm-loading-icon-balls">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</template>
<style lang="scss">
.sm-loading-icon-balls {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
div {
position: absolute;
top: 33px;
width: 13px;
height: 13px;
border-radius: 50%;
background: #000;
animation-timing-function: cubic-bezier(0, 1, 1, 0);
box-shadow: 0 0 1px rgba(0, 0, 0, 1);
}
div:nth-child(1) {
left: 8px;
animation: sm-loading-icon1 0.6s infinite;
}
div:nth-child(2) {
left: 8px;
animation: sm-loading-icon2 0.6s infinite;
}
div:nth-child(3) {
left: 32px;
animation: sm-loading-icon2 0.6s infinite;
}
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

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

View File

@@ -1,5 +1,5 @@
<template>
<div class="modal">
<div class="sm-modal">
<slot></slot>
</div>
</template>

View File

@@ -1,12 +1,14 @@
<template>
<SMContainer
:full="true"
:class="['navbar', { showDropdown: showToggle }]"
@click="handleHideMenu">
:class="['sm-navbar', { 'sm-show-dropdown': showToggle }]"
@click="handleClickHideMenu">
<template #inner>
<div class="navbar-container">
<router-link :to="{ name: 'home' }" class="brand"></router-link>
<ul class="navmenu flex-fill">
<div class="sm-navbar-container">
<router-link
:to="{ name: 'home' }"
class="sm-brand"></router-link>
<ul class="sm-navmenu flex-fill">
<template v-for="item in menuItems">
<li
v-if="
@@ -21,25 +23,25 @@
</template>
</ul>
<SMButton
:to="{ name: 'workshop-list' }"
class="navbar-cta"
:to="{ name: 'event-list' }"
class="sm-navbar-cta"
label="Find a workshop"
icon="fa-solid fa-arrow-right" />
<div class="menuButton" @click.stop="handleToggleMenu">
icon="arrow-forward-outline" />
<div
class="sm-navbar-toggle-menu"
@click.stop="handleClickToggleMenu">
<span>Menu</span
><font-awesome-icon
icon="fa-solid fa-bars"
class="menuButtonIcon" />
><ion-icon name="reorder-three-outline"></ion-icon>
</div>
</div>
</template>
<div class="navbar-dropdown-cover"></div>
<ul class="navbar-dropdown">
<div class="sm-navbar-dropdown-cover"></div>
<ul class="sm-navbar-dropdown">
<li class="ml-auto">
<div class="menuClose" @click.stop="handleToggleMenu">
<font-awesome-icon
icon="fa-solid fa-xmark"
class="menuCloseIcon" />
<div
class="sm-navbar-close-menu"
@click.stop="handleClickToggleMenu">
<ion-icon name="close-outline"></ion-icon>
</div>
</li>
<template v-for="item in menuItems">
@@ -47,7 +49,7 @@
v-if="item.show == undefined || item.show()"
:key="item.name">
<router-link :to="item.to"
><font-awesome-icon :icon="item.icon" />{{
><ion-icon :name="item.icon" />{{
item.label
}}</router-link
>
@@ -68,66 +70,72 @@ const menuItems = [
{
name: "news",
label: "News",
to: "/news",
icon: "fa-regular fa-newspaper",
to: { name: "post-list" },
icon: "newspaper-outline",
},
{
name: "workshops",
label: "Workshops",
to: "/workshops",
icon: "fa-solid fa-pen-ruler",
to: { name: "event-list" },
icon: "library-outline",
},
{
name: "courses",
label: "Courses",
to: "/courses",
icon: "briefcase-outline",
},
// {
// name: "courses",
// label: "Courses",
// to: "/courses",
// icon: "fa-solid fa-graduation-cap",
// },
{
name: "contact",
label: "Contact us",
to: "/contact",
icon: "fa-regular fa-envelope",
label: "Contact",
to: { name: "contact" },
icon: "mail-outline",
},
{
name: "register",
label: "Register",
to: "/register",
icon: "fa-solid fa-pen-to-square",
to: { name: "register" },
icon: "person-add-outline",
show: () => !userStore.id,
inNav: false,
},
{
name: "login",
label: "Log in",
to: "/login",
icon: "fa-solid fa-right-to-bracket",
to: { name: "login" },
icon: "log-in-outline",
show: () => !userStore.id,
inNav: false,
},
{
name: "dashboard",
label: "Dashboard",
to: "/dashboard",
icon: "fa-regular fa-circle-user",
to: { name: "dashboard" },
icon: "grid-outline",
show: () => userStore.id,
inNav: false,
},
{
name: "logout",
label: "Log out",
to: "/logout",
icon: "fa-solid fa-right-from-bracket",
to: { name: "logout" },
icon: "log-out-outline",
show: () => userStore.id,
inNav: false,
},
];
const handleToggleMenu = () => {
/**
* Hanfle the user clicking an element to toggle the dropdown menu.
*/
const handleClickToggleMenu = () => {
showToggle.value = !showToggle.value;
};
const handleHideMenu = () => {
/**
* Handle the user clicking an element to hide the dropdown menu.
*/
const handleClickHideMenu = () => {
if (showToggle.value) {
showToggle.value = false;
}
@@ -135,7 +143,7 @@ const handleHideMenu = () => {
</script>
<style lang="scss">
.navbar {
.sm-navbar {
height: 4.5rem;
align-items: center;
justify-content: center;
@@ -143,21 +151,22 @@ const handleHideMenu = () => {
position: relative;
flex: 0 0 auto !important;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
z-index: 1000;
&.showDropdown {
.navbar-dropdown-cover {
&.sm-show-dropdown {
.sm-navbar-dropdown-cover {
visibility: visible;
opacity: 1;
transition: visibility 0.3s linear, opacity 0.3s linear;
}
.navbar-dropdown {
.sm-navbar-dropdown {
margin-top: 0;
transition: margin 0.5s ease-in-out;
}
}
.navbar-dropdown-cover {
.sm-navbar-dropdown-cover {
position: fixed;
visibility: hidden;
z-index: 2000;
@@ -170,7 +179,7 @@ const handleHideMenu = () => {
overflow: hidden;
}
.navbar-dropdown {
.sm-navbar-dropdown {
position: fixed;
z-index: 2001;
top: 0;
@@ -195,22 +204,21 @@ const handleHideMenu = () => {
display: inline-block;
width: map-get($spacer, 5) * 3;
svg {
ion-icon {
padding-right: map-get($spacer, 1);
font-size: map-get($spacer, 4);
vertical-align: middle;
}
}
}
}
.navmenu,
.navbar-dropdown {
.sm-navmenu,
.sm-navbar-dropdown {
padding-top: map-get($spacer, 4);
li {
// display: flex;
// width: 100%;
margin: 0 0.75rem;
// justify-content: center;
a {
color: rgba(0, 0, 0, 0.8);
@@ -224,32 +232,33 @@ const handleHideMenu = () => {
}
}
.menuClose svg {
.sm-navbar-close-menu ion-icon {
cursor: pointer;
font-size: map-get($spacer, 4);
padding-left: map-get($spacer, 1);
&:hover {
color: $danger-color;
}
}
}
.navbar-container {
.sm-navbar-container {
display: flex;
flex: 1;
align-items: center;
.brand {
.sm-brand {
display: inline-block;
background-image: url("/img/logo.png");
background-position: left top;
background-repeat: no-repeat;
background-size: contain;
// width: 16.5rem;
// height: 3rem;
width: 13.5rem;
height: 2rem;
// margin-bottom: 1rem;
}
.navmenu {
.sm-navmenu {
flex: 1;
display: flex;
justify-content: end;
@@ -257,9 +266,8 @@ const handleHideMenu = () => {
padding: 0 1rem;
}
.menuButton {
.sm-navbar-toggle-menu {
cursor: pointer;
// display: none;
align-items: center;
font-size: 0.9rem;
margin-left: 2rem;
@@ -269,37 +277,33 @@ const handleHideMenu = () => {
display: none;
}
.menuButtonIcon {
ion-icon {
margin-left: 0.5rem;
font-size: 1.4rem;
font-size: map-get($spacer, 4);
}
}
}
.navbar-cta {
.sm-navbar-cta {
font-size: 0.9rem;
padding: 0.6rem 1.1rem;
}
}
@media only screen and (max-width: 1200px) {
.navbar .navbar-container {
.navmenu li {
.sm-navbar .navbar-container {
.sm-navmenu li {
display: none;
}
.menuButton {
.sm-navbar-toggle-menu {
display: flex;
span {
// display: block;
}
}
}
}
@media only screen and (max-width: 992px) {
.navbar {
.sm-navbar {
height: 4.5rem;
.navbar-dropdown-cover {
@@ -307,13 +311,13 @@ const handleHideMenu = () => {
}
.navbar-container {
.brand {
.sm-brand {
width: 13.5rem;
height: 2rem;
margin-bottom: 0;
}
.navbar-cta {
.sm-navbar-cta {
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
}
@@ -322,30 +326,30 @@ const handleHideMenu = () => {
}
@media only screen and (max-width: 640px) {
.navbar {
.sm-navbar {
height: 4.5rem;
.navbar-dropdown-cover {
.sm-navbar-dropdown-cover {
margin-top: 4.5rem;
}
.navbar-container {
.brand {
.sm-navbar-container {
.sm-brand {
background-image: url("/img/logo-small.png");
width: 3rem;
height: 3rem;
}
.navbar-cta {
.sm-navbar-cta {
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
svg {
ion-icon {
display: none;
}
}
.menuButton {
.sm-menuButton {
margin-left: 1rem;
span {

View File

@@ -0,0 +1,103 @@
<template>
<div
:class="['sm-page-outer', { 'sm-no-breadcrumbs': noBreadcrumbs }]"
:style="styleObject">
<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">
<slot></slot>
<SMContainer v-if="slots.container"
><slot name="container"></slot
></SMContainer>
</div>
</SMLoader>
</div>
</template>
<script setup lang="ts">
import { useSlots } from "vue";
import SMBreadcrumbs from "../components/SMBreadcrumbs.vue";
import { useUserStore } from "../store/UserStore";
import SMErrorForbidden from "./errors/Forbidden.vue";
import SMErrorInternal from "./errors/Internal.vue";
import SMErrorNotFound from "./errors/NotFound.vue";
import SMContainer from "./SMContainer.vue";
import SMLoader from "./SMLoader.vue";
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 slots = useSlots();
const userStore = useUserStore();
let styleObject = {};
if (props.background != "") {
styleObject["backgroundImage"] = `url('${props.background}')`;
}
/**
* Return if the current user has the props.permission to view this page.
*
* @returns {boolean} If the user has the permission.
*/
const hasPermission = (): boolean => {
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%;
padding-bottom: calc(map-get($spacer, 5) * 2);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
&.sm-no-breadcrumbs {
margin-bottom: 0;
}
.sm-page {
display: flex;
flex-direction: column;
flex: 1;
}
}
</style>

View File

@@ -1,25 +0,0 @@
<template v-if="error >= 300 || slots.default">
<d-error-forbidden v-if="error == 403"></d-error-forbidden>
<d-error-internal v-if="error >= 500"></d-error-internal>
<d-error-not-found v-if="error == 404"></d-error-not-found>
<template v-if="slots.default">
<slot></slot>
</template>
</template>
<script setup lang="ts">
import { useSlots } from "vue";
import DErrorForbidden from "./errors/Forbidden.vue";
import DErrorInternal from "./errors/Internal.vue";
import DErrorNotFound from "./errors/NotFound.vue";
defineProps({
error: {
type: Number,
required: true,
default: 200,
},
});
const slots = useSlots();
</script>

View File

@@ -0,0 +1,135 @@
<template>
<div class="sm-pagination">
<ion-icon
name="chevron-back-outline"
:class="[{ disabled: computedDisablePrevButton }]"
@click="handleClickPrev" />
<span class="sm-pagination-info">{{ computedPaginationInfo }}</span>
<ion-icon
name="chevron-forward-outline"
:class="[{ disabled: computedDisableNextButton }]"
@click="handleClickNext" />
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps({
modelValue: {
type: Number,
required: true,
},
total: {
type: Number,
required: true,
},
perPage: {
type: Number,
required: true,
},
});
const emits = defineEmits(["update:modelValue"]);
/**
* Returns the pagination info
*/
const computedPaginationInfo = computed(() => {
if (props.total == 0) {
return "0 - 0 of 0";
}
const start = (props.modelValue - 1) * props.perPage + 1;
const end = Math.min(start + props.perPage - 1, props.total);
return `${start} - ${end} of ${props.total}`;
});
/**
* Return the total number of pages.
*/
const computedTotalPages = computed(() => {
return Math.ceil(props.total / props.perPage);
});
/**
* Return if the previous button should be disabled.
*/
const computedDisablePrevButton = computed(() => {
return props.modelValue <= 1;
});
/**
* Return if the next button should be disabled.
*/
const computedDisableNextButton = computed(() => {
return props.modelValue >= computedTotalPages.value;
});
/**
* Handle click on previous button
*
* @param {MouseEvent} $event The mouse event.
*/
const handleClickPrev = ($event: MouseEvent): void => {
if (
$event.target &&
($event.target as HTMLElement).classList.contains("disabled") ==
false &&
props.modelValue > 1
) {
emits("update:modelValue", props.modelValue - 1);
}
};
/**
* Handle click on next button
*
* @param {MouseEvent} $event The mouse event.
*/
const handleClickNext = ($event: MouseEvent): void => {
if (
$event.target &&
($event.target as HTMLElement).classList.contains("disabled") ==
false &&
props.modelValue < computedTotalPages.value
) {
emits("update:modelValue", props.modelValue + 1);
}
};
</script>
<style lang="scss">
.sm-pagination {
display: flex;
justify-content: center;
align-items: center;
ion-icon {
border: 1px solid $secondary-color;
border-radius: 4px;
padding: 0.25rem;
cursor: pointer;
transition: color 0.1s ease-in-out, background-color 0.1s ease-in-out;
color: $font-color;
&.disabled {
cursor: not-allowed;
color: $secondary-color;
}
&:not(.disabled) {
&:hover {
background-color: $secondary-color;
color: #eee;
}
}
}
.sm-pagination-info {
margin: 0 map-get($spacer, 3);
}
}
</style>

View File

@@ -1,53 +1,59 @@
<template>
<router-link :to="to" class="panel">
<div
class="panel-image"
:style="{ backgroundImage: `url('${imageUrl}')` }">
<div v-if="dateInImage" class="panel-image-date">
<div class="panel-image-date-day">
{{ format(new Date(date), "dd") }}
<router-link :to="to" class="sm-panel">
<div v-if="image" class="sm-panel-image" :style="styleObject">
<div v-if="dateInImage && date" class="sm-panel-image-date">
<div class="sm-panel-image-date-day">
{{ new SMDate(date, { format: "yMd" }).format("dd") }}
</div>
<div class="panel-image-date-month">
{{ format(new Date(date), "MMM") }}
<div class="sm-panel-image-date-month">
{{ new SMDate(date, { format: "yMd" }).format("MMM") }}
</div>
</div>
<font-awesome-icon
v-if="hideImageLoader == false"
class="panel-image-loader"
icon="fa-regular fa-image" />
<ion-icon
v-if="imageUrl.length == 0"
class="sm-panel-image-loader"
name="image-outline" />
</div>
<div class="panel-body">
<h3 class="panel-title">{{ title }}</h3>
<div v-if="showDate" class="panel-date">
<font-awesome-icon
<div class="sm-panel-body">
<h3 class="sm-panel-title">{{ title }}</h3>
<div v-if="showDate && date" class="sm-panel-date">
<ion-icon
v-if="showTime == false && endDate.length == 0"
icon="fa-regular fa-calendar" /><font-awesome-icon
v-else
icon="fa-regular fa-clock" />
<p>{{ panelDate }}</p>
name="calendar-outline" />
<ion-icon v-else name="time-outline" />
<p>{{ computedDate }}</p>
</div>
<div v-if="location" class="panel-location">
<font-awesome-icon icon="fa-solid fa-location-dot" />
<div v-if="location" class="sm-panel-location">
<ion-icon name="location-outline" />
<p>{{ location }}</p>
</div>
<div v-if="content" class="panel-content">{{ panelContent }}</div>
<div v-if="button.length > 0" class="panel-button">
<SMButton :to="to" :type="buttonType" :label="button" />
<div v-if="content" class="sm-panel-content">
{{ computedContent }}
</div>
<div v-if="button.length > 0" class="sm-panel-button">
<SMButton
:to="to"
:type="buttonType"
:block="true"
:label="button" />
</div>
<div
v-if="banner"
:class="['sm-panel-banner', `sm-panel-banner-${bannerType}`]">
{{ banner }}
</div>
</div>
</router-link>
</template>
<script setup lang="ts">
import axios from "axios";
import { onMounted, computed, ref } from "vue";
import {
excerpt,
isUUID,
replaceHtmlEntites,
stripHtmlTags,
} from "../helpers/common";
import { format } from "date-fns";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { api } from "../helpers/api";
import { MediaResponse } from "../helpers/api.types";
import { SMDate } from "../helpers/datetime";
import { imageLoad } from "../helpers/image";
import { excerpt, replaceHtmlEntites, stripHtmlTags } from "../helpers/string";
import { isUUID } from "../helpers/uuid";
import SMButton from "./SMButton.vue";
const props = defineProps({
@@ -59,7 +65,12 @@ const props = defineProps({
image: {
type: String,
default: "",
required: true,
required: false,
},
icon: {
type: String,
default: "",
required: false,
},
to: {
type: Object,
@@ -76,7 +87,7 @@ const props = defineProps({
date: {
type: String,
default: "",
required: true,
required: false,
},
endDate: {
type: String,
@@ -113,68 +124,103 @@ const props = defineProps({
default: "primary",
required: false,
},
banner: {
type: String,
default: "",
required: false,
},
bannerType: {
type: String,
default: "primary",
required: false,
},
});
let imageUrl = ref(props.image);
const panelDate = computed(() => {
let styleObject = reactive({});
let imageUrl = ref("");
/**
* Return a human readable date based on props.date and props.endDate.
*/
const computedDate = computed(() => {
let str = "";
if (
(props.endDate.length > 0 &&
props.date.substring(0, props.date.indexOf(" ")) !=
props.endDate.substring(0, props.endDate.indexOf(" "))) ||
props.showTime == false
) {
str = format(new Date(props.date), "dd/MM/yyyy");
if (props.endDate.length > 0) {
str = str + " - " + format(new Date(props.endDate), "dd/MM/yyyy");
if (props.date.length > 0) {
if (
(props.endDate.length > 0 &&
props.date.substring(0, props.date.indexOf(" ")) !=
props.endDate.substring(0, props.endDate.indexOf(" "))) ||
props.showTime == false
) {
str = new SMDate(props.date, { format: "yMd" }).format(
"dd/MM/yyyy"
);
if (props.endDate.length > 0) {
str =
str +
" - " +
new SMDate(props.endDate, { format: "yMd" }).format(
"dd/MM/yyyy"
);
}
} else {
str = new SMDate(props.endDate, { format: "yMd" }).format(
"dd/MM/yyyy @ h:mm aa"
);
}
} else {
str = format(new Date(props.date), "dd/MM/yyyy @ h:mm aa");
}
return str;
});
const panelContent = computed(() => {
/**
* Return the content string cleaned from HTML.
*/
const computedContent = computed(() => {
return excerpt(replaceHtmlEntites(stripHtmlTags(props.content)), 200);
});
const hideImageLoader = computed(() => {
return (
imageUrl.value &&
imageUrl.value.length > 0 &&
isUUID(imageUrl.value) == false
);
});
onMounted(async () => {
if (imageUrl.value && imageUrl.value.length > 0 && isUUID(imageUrl.value)) {
try {
let result = await axios.get(`media/${props.image}`);
if (props.image && props.image.length > 0 && isUUID(props.image)) {
api.get({ url: "/media/{medium}", params: { medium: props.image } })
.then((result) => {
const data = result.data as MediaResponse;
if (result.data.medium) {
imageUrl.value = result.data.medium.url;
}
} catch (error) {
/* empty */
}
if (data && data.medium) {
imageLoad(data.medium.url, (url) => {
imageUrl.value = url;
});
}
})
.catch(() => {
/* empty */
});
}
});
watch(
() => imageUrl.value,
(value) => {
styleObject["backgroundImage"] = `url('${value}')`;
}
);
</script>
<style lang="scss">
.panel {
.sm-panel {
display: flex;
flex-direction: column;
border: 1px solid $border-color;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 0 28px rgba(0, 0, 0, 0.05);
max-width: 21rem;
width: 100%;
color: $font-color !important;
margin-bottom: map-get($spacer, 5);
transition: box-shadow 0.2s ease-in-out;
position: relative;
overflow: hidden;
&:hover {
color: $font-color;
@@ -182,7 +228,7 @@ onMounted(async () => {
box-shadow: 0 0 14px rgba(0, 0, 0, 0.25);
}
.panel-image {
.sm-panel-image {
position: relative;
display: flex;
justify-content: center;
@@ -195,12 +241,12 @@ onMounted(async () => {
border-top-right-radius: 12px;
background-color: #eee;
.panel-image-loader {
.sm-panel-image-loader {
font-size: 5rem;
color: $secondary-color;
}
.panel-image-date {
.sm-panel-image-date {
background-color: #fff;
padding: 0.75rem 1rem;
text-align: center;
@@ -211,38 +257,39 @@ onMounted(async () => {
box-shadow: 4px 4px 15px rgba(0, 0, 0, 0.2);
text-align: center;
.panel-image-date-day {
.sm-panel-image-date-day {
font-weight: bold;
font-size: 130%;
}
.panel-image-date-month {
.sm-panel-image-date-month {
text-transform: uppercase;
font-size: 80%;
}
}
}
.panel-body {
.sm-panel-body {
display: flex;
flex-direction: column;
flex: 1;
padding: 0 map-get($spacer, 3) map-get($spacer, 3) map-get($spacer, 3);
background-color: #fff;
}
.panel-title {
.sm-panel-title {
margin-bottom: 1rem;
}
.panel-date,
.panel-location {
.sm-panel-date,
.sm-panel-location {
display: flex;
flex-direction: row;
align-items: top;
font-size: 80%;
margin-bottom: 0.4rem;
svg {
ion-icon {
flex: 0 1 1rem;
margin-right: map-get($spacer, 1);
padding-top: 0.1rem;
@@ -257,16 +304,50 @@ onMounted(async () => {
}
}
.panel-content {
.sm-panel-content {
margin-top: 1rem;
line-height: 130%;
flex: 1;
}
.panel-button {
.button {
display: block;
margin-top: 1.5rem;
.sm-panel-button {
margin-top: map-get($spacer, 4);
}
.sm-panel-banner {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
top: 65px;
right: -10px;
height: 20px;
width: 120px;
font-size: 70%;
text-transform: uppercase;
font-weight: 800;
color: #fff;
background-color: $primary-color;
transform-origin: 100%;
transform: rotateZ(45deg);
&.sm-panel-banner-success {
background-color: $success-color;
}
&.sm-panel-banner-danger {
background-color: $danger-color;
font-size: 60%;
}
&.sm-panel-banner-warning {
background-color: $warning-color-darker;
color: $font-color;
font-size: 60%;
}
&.sm-panel-banner-expired {
background-color: purple;
}
}
}

View File

@@ -1,10 +1,10 @@
<template>
<div class="panel-list">
<div v-if="loading" class="panel-list-loading">
<font-awesome-icon icon="fa-solid fa-spinner" pulse />
<div class="sm-panel-list">
<div v-if="loading" class="sm-panel-list-loading">
<SMLoadingIcon />
</div>
<div v-else-if="notFound" class="panel-list-not-found">
<font-awesome-icon icon="fa-regular fa-face-frown-open" />
<div v-else-if="notFound" class="sm-panel-list-not-found">
<ion-icon name="alert-circle-outline" />
<p>{{ notFoundText }}</p>
</div>
<slot></slot>
@@ -12,6 +12,8 @@
</template>
<script setup lang="ts">
import SMLoadingIcon from "./SMLoadingIcon.vue";
defineProps({
loading: {
type: Boolean,
@@ -32,7 +34,7 @@ defineProps({
</script>
<style lang="scss">
.panel-list {
.sm-panel-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
@@ -41,23 +43,20 @@ defineProps({
width: 100%;
margin: 0 auto;
.panel-list-loading {
.sm-panel-list-loading {
display: flex;
flex: 1;
justify-content: center;
svg {
font-size: 500%;
}
}
.panel-list-not-found {
.sm-panel-list-not-found {
display: flex;
flex-direction: column;
flex: 1;
justify-content: center;
align-items: center;
svg {
ion-icon {
font-size: 300%;
}

View File

@@ -0,0 +1,86 @@
<template>
<div>
<div
class="sm-progress-container"
:style="{ opacity: `${progressStore.opacity || 0}` }">
<div
class="sm-progress"
:style="{
width: `${(progressStore.status || 0) * 100}%`,
}"></div>
</div>
<div class="sm-spinner">
<div
class="sm-spinner-icon"
:style="{ opacity: `${progressStore.spinner || 0}` }"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { useProgressStore } from "../store/ProgressStore";
const progressStore = useProgressStore();
</script>
<style lang="scss">
.sm-progress-container {
position: fixed;
background-color: $border-color;
height: 2px;
top: 0;
left: 0;
right: 0;
z-index: 2000;
transition: opacity 0.2s ease-in-out;
.sm-progress {
background-color: $primary-color-dark;
width: 0%;
height: 100%;
transition: width 0.2s ease-in-out;
box-shadow: 0 0 10px $primary-color-dark, 0 0 4px $primary-color-dark;
opacity: 1;
}
}
.sm-spinner {
position: fixed;
top: 10px;
right: 10px;
opacity: 0.5;
.sm-spinner-icon {
width: 18px;
height: 18px;
box-sizing: border-box;
border: solid 2px transparent;
border-top-color: #29d;
border-left-color: #29d;
border-radius: 50%;
transition: opacity 0.2s ease-in-out;
-webkit-animation: sm-progress-spinner 500ms linear infinite;
animation: sm-progress-spinner 500ms linear infinite;
}
}
@-webkit-keyframes sm-progress-spinner {
0% {
-webkit-transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
}
}
@keyframes sm-progress-spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,6 +1,10 @@
<template>
<div
:class="['row', { 'row-break-lg': breakLarge }, { 'flex-fill': fill }]">
:class="[
'sm-row',
{ 'row-break-lg': breakLarge },
{ 'flex-fill': fill },
]">
<slot></slot>
</div>
</template>
@@ -21,7 +25,7 @@ defineProps({
</script>
<style lang="scss">
.row {
.sm-row {
display: flex;
flex-direction: row;
margin: 0 auto;
@@ -31,13 +35,13 @@ defineProps({
}
@media screen and (max-width: 992px) {
.row.row-break-lg {
.sm-row.row-break-lg {
flex-direction: column;
}
}
@media screen and (max-width: 768px) {
.row {
.sm-row {
flex-direction: column;
}
}

View File

@@ -1,95 +0,0 @@
<template>
<div :class="['form-group', { 'has-error': error }]">
<label v-if="label" :class="{ required: required }">{{ label }}</label>
<select
:value="modelValue"
@input="input"
@blur="handleBlur"
@keydown="handleBlur">
<option
v-for="(value, key) in options"
:key="key"
:value="key"
:selected="modelValue == value">
{{ value }}
</option>
</select>
<div class="form-group-error">{{ error }}</div>
<div v-if="slots.default" class="form-group-info">
<slot></slot>
</div>
<div v-if="help" class="form-group-help">
<font-awesome-icon v-if="helpIcon" :icon="helpIcon" />
{{ help }}
</div>
</div>
</template>
<script setup lang="ts">
import { useSlots, onMounted, computed, watch } from "vue";
const props = defineProps({
modelValue: {
type: String,
default: "",
},
options: {
type: Object,
default() {
return {};
},
},
label: {
type: String,
default: "",
},
required: {
type: Boolean,
default: false,
},
type: {
type: String,
default: "text",
},
error: {
type: String,
default: "",
},
help: {
type: String,
default: "",
},
helpIcon: {
type: String,
default: "",
},
});
const emits = defineEmits(["update:modelValue", "blur"]);
const slots = useSlots();
const input = (event) => {
emits("update:modelValue", event.target.value);
};
const handleBlur = (event) => {
if (event.keyCode == undefined || event.keyCode == 9) {
emits("blur", event);
}
};
const initialOptions = computed(() => {
return props.options;
});
watch(initialOptions, () => {
if (
props.modelValue.length > 0 &&
props.modelValue in Object.keys(props.options) == true
) {
emits("update:modelValue", props.modelValue);
} else if (Object.keys(props.options).length > 0) {
emits("update:modelValue", Object.keys(props.options)[0]);
}
});
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div v-show="label == selectedLabel" class="tab-content">
<div v-show="label == selectedLabel" class="sm-tab-content">
<slot></slot>
</div>
</template>
@@ -7,7 +7,7 @@
<script setup lang="ts">
import { inject } from "vue";
const props = defineProps({
defineProps({
label: {
type: String,
required: true,
@@ -18,7 +18,7 @@ const selectedLabel = inject("selectedLabel");
</script>
<style lang="scss">
.tab-content {
.sm-tab-content {
padding: map-get($spacer, 3);
background-color: #fff;
border: 1px solid $border-color;

View File

@@ -1,10 +1,10 @@
<template>
<div class="tab-group">
<ul class="tab-header">
<div class="sm-tab-group">
<ul class="sm-tab-header">
<li
v-for="label in tabLabels"
:key="label"
:class="['tab-item', { selected: selectedLabel == label }]"
:class="['sm-tab-item', { selected: selectedLabel == label }]"
@click="selectedLabel = label">
{{ label }}
</li>
@@ -14,7 +14,7 @@
</template>
<script setup lang="ts">
import { ref, useSlots, provide } from "vue";
import { provide, ref, useSlots } from "vue";
const slots = useSlots();
const tabLabels = ref(slots.default().map((tab) => tab.props.label));
@@ -24,17 +24,17 @@ provide("selectedLabel", selectedLabel);
</script>
<style lang="scss">
.tab-group {
.sm-tab-group {
margin-bottom: map-get($spacer, 4);
.tab-header {
.sm-tab-header {
// border-bottom: 1px solid $border-color;
list-style-type: none;
margin: 0;
padding: 0;
}
.tab-item {
.sm-tab-item {
display: inline-block;
padding: map-get($spacer, 2) map-get($spacer, 3);
border: 1px solid transparent;

View File

@@ -0,0 +1,134 @@
<template>
<div ref="toast" :class="['sm-toast', type]" :style="styles">
<div class="sm-toast-inner">
<h3 v-if="title && title.length > 0">
{{ title }}
</h3>
<p>{{ content }}</p>
<ion-icon name="close-outline" @click="handleClickClose" />
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useToastStore } from "../store/ToastStore";
const props = defineProps({
id: {
type: Number,
required: true,
},
title: {
type: String,
default: "",
required: false,
},
type: {
type: String,
default: "primary",
required: false,
},
content: {
type: String,
required: true,
},
});
const toastStore = useToastStore();
const toast = ref(null);
let height = 40;
let hideTimeoutID: number | null = null;
const styles = ref({
opacity: 0,
marginTop: "40px",
});
const handleClickClose = () => {
if (hideTimeoutID != null) {
window.clearTimeout(hideTimeoutID);
hideTimeoutID = null;
}
removeToast();
};
const removeToast = () => {
styles.value.opacity = 0;
styles.value.marginTop = `-${height}px`;
window.setTimeout(() => {
toastStore.clearToast(props.id);
}, 500);
};
onMounted(() => {
window.setTimeout(() => {
styles.value.opacity = 1;
styles.value.marginTop = 0;
if (toast.value != null) {
const styles = window.getComputedStyle(toast.value);
const marginBottom = parseFloat(styles.marginBottom);
height = toast.value.offsetHeight + parseFloat(marginBottom) || 0;
}
hideTimeoutID = window.setTimeout(() => {
hideTimeoutID = null;
removeToast();
}, 8000);
}, 200);
});
</script>
<style lang="scss">
.sm-toast {
position: relative;
font-size: 70%;
background-color: #fff;
padding: map-get($spacer, 2) map-get($spacer, 2) map-get($spacer, 2)
map-get($spacer, 2);
border-radius: 12px;
border: 1px solid $border-color;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.25);
margin-bottom: 1rem;
transition: opacity 0.2s ease-in, margin 0.2s ease-in;
.sm-toast-inner {
border-left: 6px solid $primary-color;
padding: map-get($spacer, 1) map-get($spacer, 4) map-get($spacer, 1)
map-get($spacer, 2);
max-width: 250px;
}
h3 {
margin-top: 0;
}
p {
margin-bottom: 0;
line-height: 1rem;
}
ion-icon {
font-size: 1.25rem;
position: absolute;
top: 15px;
right: 15px;
color: $font-color;
cursor: pointer;
transition: color 0.2s ease-in-out;
&:hover {
color: $danger-color;
}
}
&.success .sm-toast-inner {
border-left-color: $success-color;
}
&.danger .sm-toast-inner {
border-left-color: $danger-color;
}
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<div class="sm-toast-container">
<SMToast
v-for="toast of toastStore.toasts"
:id="toast.id"
:key="toast.id"
:type="toast.type"
:title="toast.title"
:content="toast.content" />
</div>
</template>
<script setup lang="ts">
import { useToastStore } from "../store/ToastStore";
import SMToast from "./SMToast.vue";
const toastStore = useToastStore();
</script>
<style lang="scss">
.sm-toast-container {
position: fixed;
height: 2px;
top: 3.5rem;
right: 0.75rem;
height: 100%;
z-index: 3000;
padding: 10px;
overflow: hidden;
}
</style>

View File

@@ -1,84 +1,68 @@
<template>
<div class="toolbar">
<div class="toolbar-column toolbar-column-left">
<div class="sm-toolbar">
<div v-if="slots.left" class="sm-toolbar-column sm-toolbar-column-left">
<slot name="left"></slot>
</div>
<div class="toolbar-column toolbar-column-right">
<div v-if="slots.default" class="sm-toolbar-column">
<slot></slot>
</div>
<div
v-if="slots.right"
class="sm-toolbar-column sm-toolbar-column-right">
<slot name="right"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { useSlots } from "vue";
const slots = useSlots();
</script>
<style lang="scss">
.toolbar {
.sm-toolbar {
display: flex;
justify-content: space-between;
margin-bottom: map-get($spacer, 2);
.toolbar-column {
.sm-toolbar-column {
display: flex;
flex: 1;
flex-direction: row;
align-items: center;
align-items: flex-start;
&.toolbar-column-left {
&.sm-toolbar-column-left {
justify-content: flex-start;
}
input {
margin-bottom: 0;
& > * {
margin: 0 map-get($spacer, 1);
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
}
// &.form-footer-column-left, &.form-footer-column-right {
// a, button {
// margin-left: map-get($spacer, 1);
// margin-right: map-get($spacer, 1);
// &:first-of-type {
// margin-left: 0;
// }
// &:last-of-type {
// margin-right: 0;
// }
// }
// }
&.toolbar-column-right {
&.sm-toolbar-column-right {
justify-content: flex-end;
}
// }
// }
}
}
// @media screen and (max-width: 768px) {
// .form-footer {
// flex-direction: column-reverse;
@media screen and (max-width: 768px) {
.sm-toolbar {
.sm-toolbar-column {
flex-direction: column;
// .form-footer-column {
// &.form-footer-column-left, &.form-footer-column-right {
// display: flex;
// flex-direction: column-reverse;
// justify-content: center;
// & > * {
// display: block;
// width: 100%;
// text-align: center;
// margin-top: map-get($spacer, 1);
// margin-bottom: map-get($spacer, 1);
// margin-left: 0 !important;
// margin-right: 0 !important;
// }
// }
// &.form-footer-column-left {
// margin-bottom: -#{map-get($spacer, 1) / 2};
// }
// &.form-footer-column-right {
// margin-top: -#{map-get($spacer, 1) / 2};
// }
// }
& > * {
margin: 0;
}
}
}
}
</style>

View File

@@ -1,118 +1,115 @@
<template>
<SMModal>
<SMDialog :loading="formLoading">
<SMDialog :loading="dialogLoading">
<h1>Change Password</h1>
<SMMessage
v-if="isSuccessful"
type="success"
message="Your password has been changed successfully" />
<SMInput
v-if="!isSuccessful"
v-model="formData.password.value"
type="password"
label="New Password"
required
:error="formData.password.error"
@blur="fieldValidate(formData.password)" />
<SMFormFooter>
<template v-if="!isSuccessful" #left>
<SMButton
type="secondary"
label="Cancel"
@click="handleCancel()" />
</template>
<template #right>
<SMButton
type="primary"
:label="btnConfirm"
@click="handleConfirm()" />
</template>
</SMFormFooter>
<p class="text-center">Enter your new password below</p>
<SMForm :model-value="form" @submit="handleSubmit">
<SMInput
control="password"
type="password"
label="New Password" />
<SMFormFooter>
<template #left>
<SMButton
type="secondary"
label="Cancel"
@click="handleClickCancel" />
</template>
<template #right>
<SMButton type="submit" label="Update" />
</template>
</SMFormFooter>
</SMForm>
</SMDialog>
</SMModal>
</template>
<script setup lang="ts">
import axios from "axios";
import { useUserStore } from "../../store/UserStore";
import { ref, reactive, computed, onMounted, onUnmounted } from "vue";
import { onMounted, onUnmounted, reactive, ref } from "vue";
import { closeDialog } from "vue3-promise-dialog";
import SMModal from "../SMModal.vue";
import SMDialog from "../SMDialog.vue";
import SMMessage from "../SMMessage.vue";
import { api } from "../../helpers/api";
import { Form, FormControl, FormObject } from "../../helpers/form";
import { And, Password, Required } from "../../helpers/validate";
import { useApplicationStore } from "../../store/ApplicationStore";
import { useToastStore } from "../../store/ToastStore";
import { useUserStore } from "../../store/UserStore";
import SMButton from "../SMButton.vue";
import SMDialog from "../SMDialog.vue";
import SMForm from "../SMForm.vue";
import SMFormFooter from "../SMFormFooter.vue";
import SMInput from "../SMInput.vue";
import {
useValidation,
isValidated,
fieldValidate,
} from "../../helpers/validation";
import SMModal from "../SMModal.vue";
const formData = reactive({
password: {
value: "",
error: "",
rules: {
required: true,
required_message: "A password is needed",
min: 8,
min_message: "Your password needs to be at least %d characters",
password: "special",
},
},
});
const form: FormObject = reactive(
Form({
password: FormControl("", And([Required(), Password()])),
})
);
const applicationStore = useApplicationStore();
const userStore = useUserStore();
const formLoading = ref(false);
const isSuccessful = ref(false);
const dialogLoading = ref(false);
const btnConfirm = computed(() => {
return isSuccessful.value ? "Close" : "Update";
});
const handleCancel = () => {
/**
* User clicks cancel button to close dialog
*/
const handleClickCancel = () => {
closeDialog(false);
};
const handleConfirm = async () => {
if (isSuccessful.value == true) {
closeDialog(true);
} else {
if (isValidated(formData)) {
try {
formLoading.value = true;
await axios.put(`users/${userStore.id}`, {
password: formData.password.value,
});
/**
* User clicks form submit button
*/
const handleSubmit = async () => {
dialogLoading.value = true;
isSuccessful.value = true;
} catch (err) {
formData.password.error =
err.response?.data?.message ||
"An unexpected error occurred";
}
}
}
api.put({
url: "/users/{id}",
params: {
id: userStore.id,
},
body: {
password: form.controls.password.value,
},
})
.then(() => {
const toastStore = useToastStore();
formLoading.value = false;
toastStore.addToast({
title: "Password Reset",
content: "Your password has been reset",
type: "success",
});
closeDialog(false);
})
.catch((error) => {
form.apiErrors(error);
})
.finally(() => {
dialogLoading.value = false;
});
};
const eventKeyUp = (event: KeyboardEvent) => {
/**
* Handle a keyboard event in this component.
*
* @param {KeyboardEvent} event The keyboard event.
* @returns {boolean} If the event was handled.
*/
const eventKeyUp = (event: KeyboardEvent): boolean => {
if (event.key === "Escape") {
handleCancel();
} else if (event.key === "Enter") {
handleConfirm();
handleClickCancel();
return true;
}
return false;
};
onMounted(() => {
document.addEventListener("keyup", eventKeyUp);
applicationStore.addKeyUpListener(eventKeyUp);
});
onUnmounted(() => {
document.removeEventListener("keyup", eventKeyUp);
applicationStore.removeKeyUpListener(eventKeyUp);
});
useValidation(formData);
</script>

View File

@@ -1,32 +1,35 @@
<template>
<div class="modal">
<div class="dialog dialog-narrow">
<SMModal>
<SMDialog>
<h1>{{ props.title }}</h1>
<p v-html="sanitizedHtml"></p>
<p v-html="computedSanitizedText"></p>
<SMFormFooter>
<template #left>
<SMButton
:type="props.cancel.type"
:label="props.cancel.label"
@click.stop="handleCancel()" />
@click="handleClickCancel()" />
</template>
<template #right>
<SMButton
:type="props.confirm.type"
:label="props.confirm.label"
@click.stop="handleConfirm()" />
@click="handleClickConfirm()" />
</template>
</SMFormFooter>
</div>
</div>
</SMDialog>
</SMModal>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from "vue";
import DOMPurify from "dompurify";
import { computed, onMounted, onUnmounted } from "vue";
import { closeDialog } from "vue3-promise-dialog";
import { useApplicationStore } from "../../store/ApplicationStore";
import SMButton from "../SMButton.vue";
import SMDialog from "../SMDialog.vue";
import SMFormFooter from "../SMFormFooter.vue";
// import sanitizeHtml from "sanitize-html";
import SMModal from "../SMModal.vue";
const props = defineProps({
title: {
@@ -57,30 +60,52 @@ const props = defineProps({
},
});
const handleCancel = () => {
const applicationStore = useApplicationStore();
/**
* Handle the user clicking the cancel button.
*/
const handleClickCancel = () => {
closeDialog(false);
};
const handleConfirm = () => {
/**
* Handle the user clicking the confirm button.
*/
const handleClickConfirm = () => {
closeDialog(true);
};
const eventKeyUp = (event: KeyboardEvent) => {
/**
* Sanitize the text property from XSS attacks.
*/
const computedSanitizedText = computed(() => {
return DOMPurify.sanitize(props.text);
});
/**
* Handle a keyboard event in this component.
*
* @param {KeyboardEvent} event The keyboard event.
* @returns {boolean} If the event was handled.
*/
const eventKeyUp = (event: KeyboardEvent): boolean => {
if (event.key === "Escape") {
handleCancel();
handleClickCancel();
return true;
} else if (event.key === "Enter") {
handleConfirm();
handleClickConfirm();
return true;
}
return false;
};
onMounted(() => {
document.addEventListener("keyup", eventKeyUp);
applicationStore.addKeyUpListener(eventKeyUp);
});
onUnmounted(() => {
document.removeEventListener("keyup", eventKeyUp);
applicationStore.removeKeyUpListener(eventKeyUp);
});
// const sanitizedHtml = sanitizeHtml(props.text);
const sanitizedHtml = props.text;
</script>

View File

@@ -1,46 +1,71 @@
<template>
<SMModal>
<SMDialog
:loading="formLoading"
:loading="dialogLoading"
full
:loading_message="formLoadingMessage">
:loading-message="dialogLoadingMessage"
class="sm-dialog-media">
<h1>Insert Media</h1>
<SMMessage
v-if="formMessage.message"
:icon="formMessage.icon"
:type="formMessage.type"
:message="formMessage.message" />
<div v-if="mediaItems.length > 0" class="media-browser">
<ul class="media-browser-list">
<li
v-for="item in mediaItems"
:key="item.id"
:class="[{ selected: item == selected }]"
@click="handleSelection(item)"
@dblclick="handlePickSelection(item)">
<img :src="item.url" :title="item.title" />
</li>
</ul>
<div class="media-browser-page-info">
<span class="media-browser-page-number"
>Page {{ page }} of {{ totalPages }}</span
>
<span class="media-browser-page-changer">
<font-awesome-icon
:class="[
'changer-button',
{ disabled: prevDisabled },
]"
icon="fa-solid fa-angle-left"
@click="handlePrev" />
<font-awesome-icon
:class="[
'changer-button',
{ disabled: nextDisabled },
]"
icon="fa-solid fa-angle-right"
@click="handleNext" />
</span>
v-if="formMessage"
icon="alert-circle-outline"
type="error"
:message="formMessage"
class="d-flex" />
<div class="media-browser" :class="mediaBrowserClasses">
<div class="media-browser-content">
<SMLoadingIcon v-if="mediaLoading" />
<div
v-if="!mediaLoading && mediaItems.length == 0"
class="media-none">
<ion-icon name="sad-outline"></ion-icon>
<p>No media found</p>
</div>
<ul v-if="!mediaLoading && mediaItems.length > 0">
<li
v-for="item in mediaItems"
:key="item.id"
:class="[{ selected: item.id == selected }]"
@click="handleClickItem(item.id)"
@dblclick="handleDblClickItem(item.id)">
<div
:style="{
backgroundImage: `url('${getFilePreview(
item.url
)}')`,
}"
class="media-image"></div>
<span class="media-title">{{ item.title }}</span>
<span class="media-size">{{
bytesReadable(item.size)
}}</span>
</li>
</ul>
</div>
<div class="media-browser-toolbar">
<div class="layout-buttons">
<ion-icon
name="grid-outline"
class="layout-button-grid"
@click="handleClickGridLayout"></ion-icon>
<ion-icon
name="list-outline"
class="layout-button-list"
@click="handleClickListLayout"></ion-icon>
</div>
<div class="pagination-buttons">
<ion-icon
name="chevron-back-outline"
:class="[{ disabled: computedDisablePrevButton }]"
@click="handleClickPrev" />
<span class="pagination-info">{{
computedPaginationInfo
}}</span>
<ion-icon
name="chevron-forward-outline"
:class="[{ disabled: computedDisableNextButton }]"
@click="handleClickNext" />
</div>
</div>
</div>
<SMFormFooter>
@@ -48,198 +73,407 @@
<SMButton
type="button"
label="Cancel"
@click="handleCancel" />
@click="handleClickCancel" />
</template>
<template #right>
<SMButton
v-if="props.allowUpload"
type="button"
label="Upload"
@click="handleAskUpload" />
@click="handleClickUpload" />
<SMButton
type="primary"
label="Insert"
:disabled="selected.length == 0"
@click="handleConfirm" />
@click="handleClickInsert" />
</template>
</SMFormFooter>
<input
v-if="props.allowUpload"
id="file"
ref="uploader"
ref="refUploadInput"
type="file"
style="display: none"
@change="handleUpload" />
:accept="computedAccepts"
@change="handleChangeUpload" />
</SMDialog>
</SMModal>
</template>
<script setup lang="ts">
import axios from "axios";
import { computed, watch, ref, reactive, onMounted, onUnmounted } from "vue";
import { computed, onMounted, onUnmounted, ref, Ref, watch } from "vue";
import { closeDialog } from "vue3-promise-dialog";
import { api } from "../../helpers/api";
import { Media, MediaCollection, MediaResponse } from "../../helpers/api.types";
import { bytesReadable } from "../../helpers/types";
import { getFilePreview } from "../../helpers/utils";
import { useApplicationStore } from "../../store/ApplicationStore";
import SMButton from "../SMButton.vue";
import SMFormFooter from "../SMFormFooter.vue";
import SMDialog from "../SMDialog.vue";
import SMFormFooter from "../SMFormFooter.vue";
import SMLoadingIcon from "../SMLoadingIcon.vue";
import SMMessage from "../SMMessage.vue";
import SMModal from "../SMModal.vue";
import { toParamString } from "../../helpers/common";
const uploader = ref(null);
const formLoading = ref(false);
const formLoadingMessage = ref("");
const formMessage = reactive({
icon: "",
type: "",
message: "",
const props = defineProps({
mime: {
type: String,
default: "image/",
required: false,
},
accepts: {
type: String,
default: "image/*",
required: false,
},
allowUpload: {
type: Boolean,
default: true,
required: false,
},
});
/**
* Reference to the File Upload Input element.
*/
const refUploadInput = ref<HTMLInputElement | null>(null);
/**
* Is the dialog loading/busy
*/
const dialogLoading = ref(false);
/**
* The dialog loading message to display
*/
const dialogLoadingMessage = ref("");
/**
* The form user message to display
*/
const formMessage = ref("");
/**
* Is the media loading/busy
*/
const mediaLoading = ref(true);
/**
* Classes to apply to the media browser
*/
const mediaBrowserClasses = ref(["media-browser-grid"]);
/**
* Current page.
*/
const page = ref(1);
/**
* Total media items expressed by API.
*/
const totalItems = ref(0);
const mediaItems = ref([]);
/**
* List of current media items.
*/
const mediaItems: Ref<Media[]> = ref([]);
/**
* Selected media item id.
*/
const selected = ref("");
/**
* How many media items are we showing per page.
*/
const perPage = ref(12);
const handleCancel = () => {
const applicationStore = useApplicationStore();
/**
* Returns the pagination info
*/
const computedPaginationInfo = computed(() => {
if (totalItems.value == 0) {
return "0 - 0 of 0";
}
const start = (page.value - 1) * perPage.value + 1;
const end = start + perPage.value - 1;
return `${start} - ${end} of ${totalItems.value}`;
});
/**
* Returns the file types accepted.
*/
const computedAccepts = computed(() => {
if (props.accepts.length > 0) {
return props.accepts;
}
if (props.mime.endsWith("/")) {
return `${props.mime}*`;
}
return props.mime;
});
/**
* Return the total number of pages.
*/
const computedTotalPages = computed(() => {
return Math.ceil(totalItems.value / perPage.value);
});
/**
* Return if the previous button should be disabled.
*/
const computedDisablePrevButton = computed(() => {
return page.value <= 1;
});
/**
* Return if the next button should be disabled.
*/
const computedDisableNextButton = computed(() => {
return page.value >= computedTotalPages.value;
});
/**
* Get the media item by id.
*
* @param {string} item_id The media item id.
* @returns {Media | null} The media object or null.
*/
const getMediaItem = (item_id: string): Media | null => {
let found: Media | null = null;
mediaItems.value.every((item) => {
if (item.id == item_id) {
found = item;
return false;
}
return true;
});
return found;
};
/**
* Handle user clicking the cancel/close button.
*/
const handleClickCancel = () => {
closeDialog(false);
};
const handleConfirm = () => {
/**
* Handle user clicking the insert button.
*/
const handleClickInsert = () => {
if (selected.value !== "") {
closeDialog(selected.value);
} else {
closeDialog(false);
}
};
const handleSelection = (item) => {
selected.value = item;
};
const handlePickSelection = (item) => {
closeDialog(item);
};
const handleLoad = async () => {
formMessage.type = "error";
formMessage.icon = "fa-solid fa-circle-exclamation";
formMessage.message = "";
selected.value = "";
try {
let params = {
page: 0,
limit: 0,
// fields: "",
};
params.page = page.value;
params.limit = perPage.value;
// params.fields = "url";
let res = await axios.get(`media${toParamString(params)}`);
totalItems.value = res.data.total;
mediaItems.value = res.data.media;
} catch (error) {
if (error.response.status == 404) {
formMessage.type = "primary";
formMessage.icon = "fa-regular fa-folder-open";
formMessage.message = "No media items found";
} else {
formMessage.message =
error.response?.data?.message || "An unexpected error occurred";
const mediaItem = getMediaItem(selected.value);
if (mediaItem != null) {
closeDialog(mediaItem);
return;
}
}
closeDialog(false);
};
const handleAskUpload = () => {
uploader.value.click();
/**
* Handle user clicking a media item (selecting).
*
* @param {string} item_id The media id.
*/
const handleClickItem = (item_id: string): void => {
selected.value = item_id;
};
const handleUpload = async () => {
formLoading.value = true;
formMessage.type = "error";
formMessage.icon = "fa-solid fa-circle-exclamation";
formMessage.message = "";
try {
let submitFormData = new FormData();
if (uploader.value.files[0] instanceof File) {
submitFormData.append("file", uploader.value.files[0]);
let res = await axios.post("media", submitFormData, {
headers: {
"Content-Type": "multipart/form-data",
},
onUploadProgress: (progressEvent) =>
(formLoadingMessage.value = `Uploading Files ${Math.floor(
(progressEvent.loaded / progressEvent.total) * 100
)}%`),
});
if (res.data.medium) {
closeDialog(res.data.medium);
} else {
formMessage.message =
"An unexpected response was received from the server";
}
} else {
formMessage.message = "No file was selected to upload";
}
} catch (err) {
console.log(err);
formMessage.message =
err.response?.data?.message || "An unexpected error occurred";
/**
* Handle user double clicking a media item.
*
* @param item_id The media id.
*/
const handleDblClickItem = (item_id: string): void => {
const mediaItem = getMediaItem(item_id);
if (mediaItem != null) {
closeDialog(mediaItem);
return;
}
formLoading.value = false;
closeDialog(false);
};
const handlePrev = ($event) => {
/**
* Handle Grid layout request click
*/
const handleClickGridLayout = () => {
mediaBrowserClasses.value = ["media-browser-grid"];
};
/**
* Handle List layout request click
*/
const handleClickListLayout = () => {
mediaBrowserClasses.value = ["media-browser-list"];
};
/**
* Handle click on previous button
*
* @param {MouseEvent} $event The mouse event.
*/
const handleClickPrev = ($event: MouseEvent): void => {
if (
$event.target.classList.contains("disabled") == false &&
$event.target &&
($event.target as HTMLElement).classList.contains("disabled") ==
false &&
page.value > 1
) {
page.value--;
}
};
const handleNext = ($event) => {
/**
* Handle click on next button
*
* @param {MouseEvent} $event The mouse event.
*/
const handleClickNext = ($event: MouseEvent): void => {
if (
$event.target.classList.contains("disabled") == false &&
page.value < totalPages.value
$event.target &&
($event.target as HTMLElement).classList.contains("disabled") ==
false &&
page.value < computedTotalPages.value
) {
page.value++;
}
};
const eventKeyUp = (event: KeyboardEvent) => {
if (event.key === "Escape") {
handleCancel();
} else if (event.key === "Enter") {
handleConfirm();
/**
* When the user clicks the upload button
*/
const handleClickUpload = () => {
if (refUploadInput.value != null) {
refUploadInput.value.click();
}
};
/**
* Upload the file to the server.
*/
const handleChangeUpload = async () => {
formMessage.value = "";
if (refUploadInput.value != null && refUploadInput.value.files != null) {
const firstFile: File | undefined = refUploadInput.value.files[0];
if (firstFile != null) {
let submitFormData = new FormData();
submitFormData.append("file", firstFile);
dialogLoading.value = true;
dialogLoadingMessage.value = "Uploading file...";
api.post({
url: "/media",
body: submitFormData,
headers: {
"Content-Type": "multipart/form-data",
},
progress: (progressData) =>
(dialogLoadingMessage.value = `Uploading Files ${Math.floor(
(progressData.loaded / progressData.total) * 100
)}%`),
})
.then((result) => {
if (result.data) {
const data = result.data as MediaResponse;
closeDialog(data.medium);
} else {
formMessage.value =
"An unexpected response was received from the server";
}
})
.catch((error) => {
formMessage.value =
error.response?.data?.message ||
"An unexpected error occurred";
})
.finally(() => {
dialogLoading.value = false;
});
} else {
formMessage.value = "No file was selected to upload";
}
} else {
formMessage.value = "No file was selected to upload";
}
};
/**
* Load the data of the dialog
*/
const handleLoad = async () => {
mediaLoading.value = true;
api.get({
url: "/media",
params: {
page: page.value,
limit: perPage.value,
},
})
.then((result) => {
if (result.data) {
const data = result.data as MediaCollection;
totalItems.value = data.total;
mediaItems.value = data.media;
}
})
.catch((error) => {
formMessage.value =
error?.data?.message || "An unexpected error occurred";
})
.finally(() => {
mediaLoading.value = false;
});
};
/**
* Handle a keyboard event in this component.
*
* @param {KeyboardEvent} event The keyboard event.
* @returns {boolean} If the event was handled.
*/
const eventKeyUp = (event: KeyboardEvent): boolean => {
if (event.key === "Escape") {
handleClickCancel();
return true;
} else if (event.key === "Enter") {
if (selected.value.length > 0) {
handleClickInsert();
}
return true;
}
return false;
};
onMounted(() => {
document.addEventListener("keyup", eventKeyUp);
applicationStore.addKeyUpListener(eventKeyUp);
});
onUnmounted(() => {
document.removeEventListener("keyup", eventKeyUp);
applicationStore.removeKeyUpListener(eventKeyUp);
});
const totalPages = computed(() => {
return Math.ceil(totalItems.value / perPage.value);
});
const prevDisabled = computed(() => {
return page.value <= 1;
});
const nextDisabled = computed(() => {
return page.value >= totalPages.value;
});
watch(page, (value) => {
watch(page, () => {
handleLoad();
});
@@ -247,60 +481,196 @@ handleLoad();
</script>
<style lang="scss">
.media-browser-list {
border: 1px solid $border-color;
background-color: #fff;
overflow: auto;
max-height: 40vh;
display: flex;
list-style-type: none;
margin: 0 0 1rem 0;
padding: map-get($spacer, 3);
justify-content: center;
gap: 0.3rem;
flex-wrap: wrap;
li {
.sm-dialog-media {
.media-browser {
display: flex;
height: 7.5rem;
width: 13rem;
border: 3px solid transparent;
padding: 1px;
flex-direction: column;
&.selected {
border-color: $primary-color-darker;
.media-browser-content {
display: flex;
height: 40vh;
border: 1px solid $border-color;
background-color: #fff;
justify-content: center;
align-items: center;
margin: 0 0 1rem 0;
.media-none {
font-size: 1.5rem;
text-align: center;
ion-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
}
}
ul {
display: block;
list-style-type: none;
overflow: auto;
max-height: 40vh;
height: 100%;
width: 100%;
gap: 1rem;
justify-content: center;
padding: map-get($spacer, 3);
li {
display: flex;
align-items: center;
border: 3px solid transparent;
box-sizing: content-box;
padding: 2px;
&.selected,
&:hover {
border-color: $primary-color-dark;
}
.media-image {
background-size: contain;
background-position: center;
background-repeat: no-repeat;
}
}
}
}
img {
width: 100%;
height: 100%;
}
}
}
.media-browser-toolbar {
display: flex;
margin-bottom: map-get($spacer, 3);
.media-browser-page-info {
margin-bottom: 1rem;
display: flex;
justify-content: flex-end;
.layout-buttons,
.pagination-buttons {
flex: 1;
display: flex;
align-items: center;
}
.media-browser-page-changer {
margin-left: 1rem;
}
.layout-buttons {
ion-icon {
&:first-of-type {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
&:last-of-type {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 0;
}
}
}
.changer-button {
cursor: pointer;
transition: color 0.1s ease-in;
color: $font-color;
margin: 0 0.25rem;
.pagination-buttons {
justify-content: right;
}
&.disabled {
cursor: not-allowed;
color: $secondary-color;
ion-icon {
border: 1px solid $secondary-color;
border-radius: 4px;
padding: 0.25rem;
cursor: pointer;
transition: color 0.1s ease-in-out,
background-color 0.1s ease-in-out;
color: $font-color;
&.disabled {
cursor: not-allowed;
color: $secondary-color;
}
&:not(.disabled) {
&:hover {
background-color: $secondary-color;
color: #eee;
}
}
}
.pagination-info {
margin: 0 map-get($spacer, 3);
}
}
&:not(.disabled) {
&:hover {
color: $primary-color;
&.media-browser-list {
ul {
flex-direction: column;
flex-wrap: nowrap;
}
li {
height: auto;
width: auto;
}
.media-image {
width: 64px;
height: 64px;
margin-right: map-get($spacer, 1);
}
.media-title {
flex: 1;
text-align: left;
}
.media-size {
font-size: 75%;
}
.media-browser-toolbar {
.layout-button-grid {
color: $font-color;
}
.layout-button-list {
color: $primary-color;
}
}
}
&.media-browser-grid {
ul {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
li {
flex-direction: column;
height: 194px;
width: 220px;
.media-image {
min-height: 132px;
min-width: 220px;
}
.media-title {
text-align: center;
padding: map-get($spacer, 1) 4px;
width: 13rem;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.media-size {
font-size: 75%;
}
}
.media-browser-toolbar {
.layout-button-grid {
color: $primary-color;
}
.layout-button-list {
color: $font-color;
}
}
}
}

View File

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

View File

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

View File

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

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

@@ -0,0 +1,191 @@
import { useProgressStore } from "../store/ProgressStore";
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;
signal?: AbortSignal | null;
progress?: ApiProgressCallback;
}
export interface ApiResponse {
status: number;
message: string;
data: unknown;
json?: Record<string, unknown>;
}
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) {
let params = "";
for (const [key, value] of Object.entries(options.params)) {
const placeholder = `{${key}}`;
if (url.includes(placeholder)) {
url = url.replace(placeholder, value);
} else {
params += `&${key}=${value}`;
}
}
url = url.replace(/{(.*?)}/g, "$1");
if (params.length > 0) {
url += (url.includes("?") ? "" : "?") + params.substring(1);
}
}
options.headers = {
...apiDefaultHeaders,
...(options.headers || {}),
};
const userStore = useUserStore();
if (userStore.id) {
options.headers["Authorization"] = `Bearer ${userStore.token}`;
}
if (options.body && typeof options.body === "object") {
if (options.body instanceof FormData) {
if (
Object.prototype.hasOwnProperty.call(
options.headers,
"Content-Type"
)
) {
// remove the "Content-Type" key from the headers object
delete options.headers["Content-Type"];
}
} else {
options.body = JSON.stringify(options.body);
}
}
const fetchOptions: RequestInit = {
method: options.method || "GET",
headers: options.headers,
signal: options.signal || null,
};
if (typeof options.body == "string" && options.body.length > 0) {
fetchOptions.body = options.body;
}
const progressStore = useProgressStore();
progressStore.start();
fetch(url, fetchOptions)
.then(async (response) => {
let data: string | object = "";
if (response.headers.get("content-type") == null) {
try {
data = response.json ? await response.json() : {};
} catch (error) {
data = response.text ? await response.text() : "";
}
} else {
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) => {
// Handle any errors thrown during the fetch process
const { response, ...rest } = error;
reject({
...rest,
response: response && response.json(),
});
})
.finally(() => {
progressStore.finish();
});
});
},
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 | string): Promise<ApiResponse> {
let apiOptions = {} as ApiOptions;
if (typeof options == "string") {
apiOptions.url = options;
} else {
apiOptions = options;
}
apiOptions.method = "POST";
return await this.send(options);
},
put: async function (options: ApiOptions | string): Promise<ApiResponse> {
let apiOptions = {} as ApiOptions;
if (typeof options == "string") {
apiOptions.url = options;
} else {
apiOptions = options;
}
apiOptions.method = "PUT";
return await this.send(options);
},
delete: async function (
options: ApiOptions | string
): Promise<ApiResponse> {
let apiOptions = {} as ApiOptions;
if (typeof options == "string") {
apiOptions.url = options;
} else {
apiOptions = options;
}
apiOptions.method = "DELETE";
return await this.send(options);
},
};

View File

@@ -0,0 +1,82 @@
export interface Event {
id: string;
title: string;
hero: string;
content: string;
start_at: string;
end_at: string;
location: string;
address: string;
status: string;
registration_type: string;
registration_data: string;
}
export interface EventResponse {
event: Event;
}
export interface EventCollection {
events: Event[];
total: number;
}
export interface Media {
id: string;
user_id: string;
title: string;
name: string;
mime: string;
permission: Array<string>;
size: number;
url: string;
created_at: string;
updated_at: string;
}
export interface MediaResponse {
medium: Media;
}
export interface MediaCollection {
media: Array<Media>;
total: number;
}
export interface Post {
id: string;
title: string;
slug: string;
user_id: string;
content: string;
publish_at: string;
hero: string;
attachments: Array<Media>;
}
export interface PostResponse {
post: Post;
}
export interface PostCollection {
posts: Array<Post>;
total: number;
}
export interface User {
id: string;
username: string;
}
export interface UserResponse {
user: User;
}
export interface UserCollection {
users: Array<User>;
}
export interface LoginResponse {
user: User;
token: string;
}

View File

@@ -0,0 +1,24 @@
/**
* Test if array has a match using basic search (* means anything)
*
* @param {Array<string>} arr The array to search.
* @param {string} str The string to find.
* @returns {boolean} if the array has the string.
*/
export const arrayHasBasicMatch = (
arr: Array<string>,
str: string
): boolean => {
let matches = false;
arr.every((elem) => {
elem = elem.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
const regex = new RegExp("^" + elem.replace("*", ".*?") + "$", "i");
if (str.match(regex)) {
matches = true;
}
return !matches;
});
return matches;
};

View File

@@ -1,439 +0,0 @@
import { format } from "date-fns";
const transitionEndEventName = () => {
var i,
undefined,
el = document.createElement("div"),
transitions = {
transition: "transitionend",
OTransition: "otransitionend",
MozTransition: "transitionend",
WebkitTransition: "webkitTransitionEnd",
};
for (i in transitions) {
if (transitions.hasOwnProperty(i) && el.style[i] !== undefined) {
return transitions[i];
}
}
return null;
};
const waitForElementRender = (elem) => {
return new Promise((resolve) => {
if (document.contains(elem.value)) {
return resolve(elem.value);
}
const MutationObserver =
window.MutationObserver ||
window.WebKitMutationObserver ||
window.MozMutationObserver;
const observer = new MutationObserver((mutations) => {
if (document.contains(elem.value)) {
resolve(elem.value);
observer.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
});
};
const transitionEnter = (elem, transition) => {
waitForElementRender(elem).then((e) => {
window.setTimeout(() => {
e.classList.replace(
transition + "-enter-from",
transition + "-enter-active"
);
const transitionName = transitionEndEventName();
e.addEventListener(
transitionName,
() => {
e.classList.replace(
transition + "-enter-active",
transition + "-enter-to"
);
},
false
);
}, 1);
});
};
const transitionLeave = (elem, transition, callback = null) => {
elem.value.classList.remove(transition + "-enter-to");
elem.value.classList.add(transition + "-leave-from");
window.setTimeout(() => {
elem.value.classList.replace(
transition + "-leave-from",
transition + "-leave-active"
);
const transitionName = transitionEndEventName();
elem.value.addEventListener(
transitionName,
() => {
elem.value.classList.replace(
transition + "-leave-active",
transition + "-leave-to"
);
if (callback) {
callback();
}
},
false
);
}, 1);
};
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",
];
/**
*
* @param target
*/
export function isBool(target) {
return typeof target === "boolean";
}
/**
*
* @param target
*/
export function isNumber(target) {
return typeof target === "number";
}
/**
*
* @param target
*/
export function isObject(target) {
return typeof target === "object" && target !== null;
}
/**
*
* @param target
*/
export function isString(target) {
return typeof target === "string" && target !== null;
}
/**
*
* @param target
* @param def
*/
export function parseErrorType(
target,
def = "An unknown error occurred. Please try again later."
) {
if (target.response?.message) {
return target.response.message;
} else if (target instanceof Error) {
target.message;
} else if (isString(err)) {
return target;
}
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) => {
let s = "";
if (Object.keys(query).length > 0) {
s = "?";
}
s += Object.keys(query)
.map((key) => key + "=" + query[key])
.join("&");
return url + s;
};
export const toParamString = (obj, q = true) => {
let s = "";
if (q && Object.keys(obj).length > 0) {
s = "?";
}
s += Object.keys(obj)
.map((key) => key + "=" + obj[key])
.join("&");
return s;
};
export const getLocale = () => {
return (
navigator.userLanguage ||
(navigator.languages &&
navigator.languages.length &&
navigator.languages[0]) ||
navigator.language ||
navigator.browserLanguage ||
navigator.systemLanguage ||
"en"
);
};
export const debounce = (fn, delay) => {
var timeoutID = null;
return function () {
clearTimeout(timeoutID);
var args = arguments;
var that = this;
timeoutID = setTimeout(function () {
fn.apply(that, args);
}, delay);
};
};
export const bytesReadable = (bytes) => {
if (isNaN(bytes)) {
return "0 Bytes";
}
if (Math.abs(bytes) < 1024) {
return bytes + " Bytes";
}
const units = ["KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
let u = -1;
const r = 10 ** 1;
do {
bytes /= 1000;
++u;
} while (
Math.round(Math.abs(bytes) * r) / r >= 1000 &&
u < units.length - 1
);
return bytes.toFixed(1) + " " + units[u];
};
export const arrayIncludesMatchBasic = (arr, str) => {
let matches = false;
arr.every((elem) => {
elem = elem.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
let regex = new RegExp("^" + elem.replace("*", ".*?") + "$", "i");
if (str.match(regex)) {
matches = true;
}
return !matches;
});
return matches;
};
export const excerpt = (txt, maxLen = 150, strip = true) => {
if (strip) {
txt = stripHtmlTags(replaceHtmlEntites(txt));
}
let txtPieces = txt.split(" ");
let excerptPieces = [];
let curLen = 0;
txtPieces.every((itm) => {
if (curLen + itm.length >= maxLen) {
return false;
}
excerptPieces.push(itm);
curLen += itm.length + 1;
return true;
});
return excerptPieces.join(" ") + (curLen < txt.length ? "..." : "");
};
export const stripHtmlTags = (txt) => {
txt = txt.replace(/<(p|br)([ /]*?>|[ /]+.*?>)/g, " ");
return txt.replace(/<[a-zA-Z/][^>]+(>|$)/g, "");
};
export const replaceHtmlEntites = (txt) => {
var translate_re = /&(nbsp|amp|quot|lt|gt);/g;
var translate = {
nbsp: " ",
amp: "&",
quot: '"',
lt: "<",
gt: ">",
};
return txt.replace(translate_re, function (match, entity) {
return translate[entity];
});
};
export const isUUID = (uuid) => {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
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 {
transitionEndEventName,
waitForElementRender,
transitionEnter,
transitionLeave,
};

View File

@@ -0,0 +1,487 @@
import { transformWithEsbuild } from "vite";
export class SMDate {
date: Date | null = null;
dayString: string[] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
fullDayString: string[] = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
monthString: string[] = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
fullMonthString: string[] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
constructor(
dateOrString: string | Date = "",
options: { format?: string; utc?: boolean } = {}
) {
this.date = null;
if (typeof dateOrString === "string") {
if (dateOrString.length > 0) {
this.parse(dateOrString, options);
}
} else if (
dateOrString instanceof Date &&
!Number.isNaN(dateOrString.getTime())
) {
this.date = dateOrString;
}
}
/**
* Parse a string date into a Date object
*
* @param {string} dateString The date string.
* @param {object} options (optional) Options object.
* @param {string} options.format (optional) The format of the date string.
* @param {boolean} options.utc (optional) The date string is UTC.
* @returns {SMDate} SMDate object.
*/
public parse(
dateString: string,
options: { format?: string; utc?: boolean } = {}
): SMDate {
const now = new Date();
if (dateString.toLowerCase() == "now") {
this.date = now;
return this;
}
// Parse the date format to determine the order of the date components
const order = (options.format || "dmy").toLowerCase().split("");
options.utc = options.utc || false;
// Split the date string into an array of components based on the length of each date component
const components = dateString.split(/[ /-]/);
let time = "";
for (let i = 0; i < components.length; i++) {
if (components[i].includes(":")) {
time = components[i];
components.splice(i, 1);
if (
i < components.length &&
/^(am?|a\.m\.|pm?|p\.m\.)$/i.test(components[i])
) {
time += " " + components[i].toUpperCase();
components.splice(i, 1);
}
break;
}
}
if (components.every((v) => !isNaN(parseInt(v))) == false) {
return this;
}
if (components.length > 3) {
return this;
}
// Map the date components to the expected order based on the format
const [day, month, year] =
order[0] === "d"
? [components[0], components[1], components[2]]
: order[0] === "m"
? [components[1], components[0], components[2]]
: [components[2], components[1], components[0]];
let parsedDay: number = 0,
parsedMonth: number = 0,
parsedYear: number = 0;
if (year.length == 3 || year.length >= 5) {
return this;
}
if (day && day.length != 0 && month && month.length != 0) {
// Parse the day, month, and year components
parsedDay = parseInt(day.padStart(2, "0"), 10);
parsedMonth = this.getMonthAsNumber(month);
parsedYear = year
? parseInt(year.padStart(4, "20"), 10)
: now.getFullYear();
} else {
parsedDay = now.getDate();
parsedMonth = now.getMonth() + 1;
parsedYear = now.getFullYear();
}
let parsedHours: number = 0,
parsedMinutes: number = 0,
parsedSeconds: number = 0;
if (time) {
const regEx = new RegExp(
/^(\d+)(?::(\d+))?(?::(\d+))? ?(am?|a\.m\.|pm?|p\.m\.)?$/,
"i"
);
if (regEx.test(time)) {
const match = time.match(regEx);
if (match) {
parsedHours = parseInt(match[1]);
parsedMinutes = match[2] ? parseInt(match[2]) : 0;
parsedSeconds = match[3] ? parseInt(match[3]) : 0;
if (match[4] && /pm/i.test(match[4])) {
parsedHours += 12;
}
if (
match[4] &&
/am/i.test(match[4]) &&
parsedHours === 12
) {
parsedHours = 0;
}
} else {
return this;
}
} else {
return this;
}
}
// Create a date object with the parsed components
let date: Date | null = null;
if (options.utc) {
date = new Date(
Date.UTC(
parsedYear,
parsedMonth - 1,
parsedDay,
parsedHours,
parsedMinutes,
parsedSeconds
)
);
} else {
date = new Date(
parsedYear,
parsedMonth - 1,
parsedDay,
parsedHours,
parsedMinutes,
parsedSeconds
);
}
// Test created date object
let checkYear: number,
checkMonth: number,
checkDay: number,
checkHours: number,
checkMinutes: number,
checkSeconds: number;
if (options.utc) {
const isoDate = date.toISOString();
checkYear = parseInt(isoDate.substring(0, 4), 10);
checkMonth = parseInt(isoDate.substring(5, 7), 10);
checkDay = new Date(isoDate).getUTCDate();
checkHours = parseInt(isoDate.substring(11, 13), 10);
checkMinutes = parseInt(isoDate.substring(14, 16), 10);
checkSeconds = parseInt(isoDate.substring(17, 18), 10);
} else {
checkYear = date.getFullYear();
checkMonth = date.getMonth() + 1;
checkDay = date.getDate();
checkHours = date.getHours();
checkMinutes = date.getMinutes();
checkSeconds = date.getSeconds();
}
if (
Number.isNaN(date.getTime()) == false &&
checkYear == parsedYear &&
checkMonth == parsedMonth &&
checkDay == parsedDay &&
checkHours == parsedHours &&
checkMinutes == parsedMinutes &&
checkSeconds == parsedSeconds
) {
this.date = date;
} else {
this.date = null;
}
return this;
}
/**
* Format the date to a string.
*
* @param {string} format The format to return.
* @param {object} options (optional) Function options.
* @param {boolean} options.utc (optional) Format the date to be as UTC instead of local.
* @returns {string} The formatted date.
*/
public format(format: string, options: { utc?: boolean } = {}): string {
if (this.date == null) {
return "";
}
let result = format;
let year: string,
month: string,
date: string,
day: number,
hour: string,
min: string,
sec: string;
if (options.utc) {
const isoDate = this.date.toISOString();
year = isoDate.substring(0, 4);
month = isoDate.substring(5, 7);
date = isoDate.substring(8, 10);
day = new Date(isoDate).getUTCDay();
hour = isoDate.substring(11, 13);
min = isoDate.substring(14, 16);
sec = isoDate.substring(17, 18);
} else {
year = this.date.getFullYear().toString();
month = (this.date.getMonth() + 1).toString();
date = this.date.getDate().toString();
day = this.date.getDay();
hour = this.date.getHours().toString();
min = this.date.getMinutes().toString();
sec = this.date.getSeconds().toString();
}
const apm = parseInt(hour, 10) >= 12 ? "pm" : "am";
/* eslint-disable indent */
const apmhours = (
parseInt(hour, 10) > 12
? parseInt(hour, 10) - 12
: parseInt(hour, 10) == 0
? 12
: parseInt(hour, 10)
).toString();
/* eslint-enable indent */
// year
result = result.replace(/\byy\b/g, year.slice(-2));
result = result.replace(/\byyyy\b/g, year);
// month
result = result.replace(/\bM\b/g, month);
result = result.replace(/\bMM\b/g, (0 + month).slice(-2));
result = result.replace(
/\bMMM\b/g,
this.monthString[parseInt(month) - 1]
);
result = result.replace(
/\bMMMM\b/g,
this.fullMonthString[parseInt(month) - 1]
);
// day
result = result.replace(/\bd\b/g, date);
result = result.replace(/\bdd\b/g, (0 + date).slice(-2));
result = result.replace(/\bEEE\b/g, this.dayString[day]);
result = result.replace(/\bEEEE\b/g, this.fullDayString[day]);
// hour
result = result.replace(/\bH\b/g, hour);
result = result.replace(/\bHH\b/g, (0 + hour).slice(-2));
result = result.replace(/\bh\b/g, apmhours);
result = result.replace(/\bhh\b/g, (0 + apmhours).slice(-2));
// min
result = result.replace(/\bm\b/g, min);
result = result.replace(/\bmm\b/g, (0 + min).slice(-2));
// sec
result = result.replace(/\bs\b/g, sec);
result = result.replace(/\bss\b/g, (0 + sec).slice(-2));
// am/pm
result = result.replace(/\baa\b/g, apm);
return result;
}
/**
* Return a relative date string from now.
*
* @returns {string} A relative date string.
*/
public relative(): string {
if (this.date === null) {
return "";
}
const now = new Date();
const dif = Math.round((now.getTime() - this.date.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 (
this.monthString[this.date.getMonth()] +
" " +
this.date.getDate() +
", " +
this.date.getFullYear()
);
}
/**
* If the date is before the passed date.
*
* @param {Date|SMDate} d (optional) The date to check. If none, use now
* @returns {boolean} If the date is before the passed date.
*/
public isBefore(d: Date | SMDate = new SMDate("now")): boolean {
const otherDate = d instanceof SMDate ? d.date : d;
if (otherDate == null) {
return false;
}
if (this.date == null) {
return true;
}
return otherDate > this.date;
}
/**
* If the date is after the passed date.
*
* @param {Date|SMDate} d (optional) The date to check. If none, use now
* @returns {boolean} If the date is after the passed date.
*/
public isAfter(d: Date | SMDate = new SMDate("now")): boolean {
const otherDate = d instanceof SMDate ? d.date : d;
if (otherDate == null) {
return false;
}
if (this.date == null) {
return true;
}
return otherDate < this.date;
}
/**
* Return a month number from a string or a month number or month name
*
* @param {string} monthString The month string as number or name
* @returns {number} The month number
*/
private getMonthAsNumber(monthString: string): number {
const months = this.fullMonthString.map((month) => month.toLowerCase());
const shortMonths = months.map((month) => month.slice(0, 3));
const monthIndex = months.indexOf(monthString.toLowerCase());
if (monthIndex !== -1) {
return monthIndex + 1;
}
const shortMonthIndex = shortMonths.indexOf(monthString.toLowerCase());
if (shortMonthIndex !== -1) {
return shortMonthIndex + 1;
}
const monthNumber = parseInt(monthString, 10);
if (!isNaN(monthNumber) && monthNumber >= 1 && monthNumber <= 12) {
return monthNumber;
}
return 0;
}
/**
* Test if the current date is valid.
*
* @returns {boolean} If the current date is valid.
*/
public isValid(): boolean {
return this.date !== null;
}
/**
* Return a string with only the first occurrence of characters
*
* @param {string} str The string to modify.
* @param {string} characters The characters to use to test.
* @returns {string} A string that only contains the first occurrence of the characters.
*/
private onlyFirstOccurrence(
str: string,
characters: string = "dMy"
): string {
let findCharacters = characters.split("");
const replaceRegex = new RegExp("[^" + characters + "]", "g");
let result = "";
str = str.replace(replaceRegex, "");
if (str.length > 0) {
str.split("").forEach((strChar) => {
if (
findCharacters.length > 0 &&
findCharacters.includes(strChar)
) {
result += strChar;
const index = findCharacters.findIndex(
(findChar) => findChar === strChar
);
if (index !== -1) {
findCharacters = findCharacters
.slice(0, index)
.concat(findCharacters.slice(index + 1));
}
}
});
}
return result;
}
}

View File

@@ -0,0 +1,27 @@
type DebounceCallback = (...args: unknown[]) => void;
type DebounceResult = (...args: unknown[]) => void;
/**
* Call a function after a delay once.
*
* @param {Function} fn The function to call.
* @param {number} delay The delay before calling function.
* @returns {void}
*/
export const debounce = (
fn: DebounceCallback,
delay: number
): DebounceResult => {
let timeoutID: NodeJS.Timeout | null = null;
return (...args) => {
if (timeoutID != null) {
clearTimeout(timeoutID);
}
// eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this;
timeoutID = setTimeout(function () {
fn.apply(that, args);
}, delay);
};
};

View File

@@ -1,9 +0,0 @@
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { fas } from '@fortawesome/free-solid-svg-icons'
import { fab } from '@fortawesome/free-brands-svg-icons';
import { far } from '@fortawesome/free-regular-svg-icons';
library.add(far, fas, fab);
export default FontAwesomeIcon;

View File

@@ -0,0 +1,196 @@
import { ApiResponse } from "./api";
import {
createValidationResult,
defaultValidationResult,
ValidationObject,
ValidationResult,
} from "./validate";
type FormObjectValidateFunction = (item: string | null) => Promise<boolean>;
type FormObjectLoadingFunction = (state: boolean) => void;
type FormObjectMessageFunction = (
message?: string,
type?: string,
icon?: string
) => void;
type FormObjectErrorFunction = (message: string) => void;
type FormObjectApiErrorsFunction = (apiErrors: ApiResponse) => void;
export interface FormObject {
validate: FormObjectValidateFunction;
loading: FormObjectLoadingFunction;
message: FormObjectMessageFunction;
error: FormObjectErrorFunction;
apiErrors: FormObjectApiErrorsFunction;
_loading: boolean;
_message: string;
_messageType: string;
_messageIcon: string;
controls: { [key: string]: FormControlObject };
}
const defaultFormObject: FormObject = {
validate: async function (item = null) {
const keys = item ? [item] : Object.keys(this.controls);
let valid = true;
await Promise.all(
keys.map(async (key) => {
if (
typeof this.controls[key] == "object" &&
Object.keys(this.controls[key]).includes("validation")
) {
const validationResult = await this.controls[
key
].validation.validator.validate(this.controls[key].value);
this.controls[key].validation.result = validationResult;
if (!validationResult.valid) {
valid = false;
}
}
})
);
return valid;
},
loading: function (state = true) {
this._loading = state;
},
message: function (message = "", type = "", icon = "") {
this._message = message;
if (type.length > 0) {
this._messageType = type;
}
if (icon.length > 0) {
this._messageIcon = icon;
}
},
error: function (message = "") {
if (message == "") {
this.message("");
} else {
this.message(message, "error", "alert-circle-outline");
}
},
apiErrors: function (apiResponse: ApiResponse) {
let foundKeys = false;
if (
apiResponse.data &&
typeof apiResponse.data === "object" &&
apiResponse.data.errors
) {
const errors = apiResponse.data.errors as Record<string, string>;
Object.keys(errors).forEach((key) => {
if (
typeof this.controls[key] === "object" &&
Object.keys(this.controls[key]).includes("validation")
) {
foundKeys = true;
this.controls[key].validation.result =
createValidationResult(false, errors[key]);
}
});
}
if (foundKeys == false) {
this.error(
apiResponse?.json?.message ||
"An unknown server error occurred.\nPlease try again later."
);
}
},
controls: {},
_loading: false,
_message: "",
_messageType: "primary",
_messageIcon: "",
};
/**
* Create a new Form object.
*
* @param {Record<string, FormControlObject>} controls The controls included in the form.
* @returns {FormObject} Returns a form object.
*/
export const Form = (
controls: Record<string, FormControlObject>
): FormObject => {
const form = defaultFormObject;
form.controls = controls;
return form;
};
interface FormControlValidation {
validator: ValidationObject;
result: ValidationResult;
}
const defaultFormControlValidation: FormControlValidation = {
validator: {
validate: async (): Promise<ValidationResult> => {
return defaultValidationResult;
},
},
result: defaultValidationResult,
};
type FormControlClearValidations = () => void;
type FormControlSetValidation = (
valid: boolean,
message?: string | Array<string>
) => ValidationResult;
type FormControlIsValid = () => boolean;
export interface FormControlObject {
value: string;
validate: () => Promise<ValidationResult>;
validation: FormControlValidation;
clearValidations: FormControlClearValidations;
setValidationResult: FormControlSetValidation;
isValid: FormControlIsValid;
}
/**
* Create a new form control object.
*
* @param {string} value The control name.
* @param {ValidationObject | null} validator The control validation rules.
* @returns {FormControlObject} The form control object.
*/
export const FormControl = (
value: string = "",
validator: ValidationObject | null = null
): FormControlObject => {
return {
value: value,
validation:
validator == null
? defaultFormControlValidation
: {
validator: validator,
result: defaultValidationResult,
},
clearValidations: function () {
this.validation.result = defaultValidationResult;
},
setValidationResult: createValidationResult,
validate: async function () {
if (this.validation.validator) {
this.validation.result =
await this.validation.validator.validate(this.value);
return this.validation.result;
}
return defaultValidationResult;
},
isValid: function () {
return this.validation.result.valid;
},
};
};

View File

@@ -0,0 +1,14 @@
type ImageLoadCallback = (url: string) => void;
export const imageLoad = (
url: string,
callback: ImageLoadCallback,
postfix = "h=50"
) => {
callback(`${url}?${postfix}`);
const tmp = new Image();
tmp.onload = function () {
callback(url);
};
tmp.src = url;
};

View File

@@ -0,0 +1,29 @@
/**
* Sort a objects properties alphabetically
*
* @param {Record<string, unknown>} obj The object to sort
* @returns {Record<string, unknown>} The object sorted
*/
export const sortProperties = (
obj: Record<string, unknown>
): Record<string, unknown> => {
// convert object into array
const sortable: [string, unknown][] = [];
for (const key in obj)
if (Object.prototype.hasOwnProperty.call(obj, key))
sortable.push([key, obj[key]]); // each item is an array in format [key, value]
// sort items by value
sortable.sort(function (a, b) {
const x = String(a[1]).toLowerCase(),
y = String(b[1]).toLowerCase();
return x < y ? -1 : x > y ? 1 : 0;
});
const sortedObj: Record<string, unknown> = {};
sortable.forEach((item) => {
sortedObj[item[0]] = item[1];
});
return sortedObj; // array in format [ [ key1, val1 ], [ key2, val2 ], ... ]
};

View File

@@ -0,0 +1,81 @@
/**
* Transforms a string to title case.
*
* @param {string} str The string to transform.
* @returns {string} A string transformed to title case.
*/
export const toTitleCase = (str: string): string => {
return str.replace(/\w\S*/g, function (txt) {
return (
txt.charAt(0).toUpperCase() +
txt.substring(1).replace(/_/g, " ").toLowerCase()
);
});
};
/**
* Convert a string to a excerpt.
*
* @param {string} txt The text to convert.
* @param {number} maxLen (optional) The maximum length of the excerpt.
* @param {boolean} strip (optional) Strip HTML tags from the text.
* @param stripHtml
* @returns {string} The excerpt.
*/
export const excerpt = (
txt: string,
maxLen: number = 150,
stripHtml: boolean = true
): string => {
if (stripHtml) {
txt = stripHtmlTags(replaceHtmlEntites(txt));
}
const txtPieces = txt.split(" ");
const excerptPieces: string[] = [];
let curLen = 0;
txtPieces.every((itm) => {
if (curLen + itm.length >= maxLen) {
return false;
}
excerptPieces.push(itm);
curLen += itm.length + 1;
return true;
});
return excerptPieces.join(" ") + (curLen < txt.length ? "..." : "");
};
/**
* String HTML tags from text.
*
* @param {string} txt The text to strip tags.
* @returns {string} The stripped text.
*/
export const stripHtmlTags = (txt: string): string => {
txt = txt.replace(/<(p|br)([ /]*?>|[ /]+.*?>)/g, " ");
return txt.replace(/<[a-zA-Z/][^>]+(>|$)/g, "");
};
/**
* Replace HTML entities with real characters.
*
* @param {string} txt The text to transform.
* @returns {string} Transformed text
*/
export const replaceHtmlEntites = (txt: string): string => {
const translate_re = /&(nbsp|amp|quot|lt|gt);/g;
const translate = {
nbsp: " ",
amp: "&",
quot: '"',
lt: "<",
gt: ">",
};
return txt.replace(translate_re, function (match, entity) {
return translate[entity];
});
};

View File

@@ -0,0 +1,127 @@
import { Ref } from "vue";
/**
* Return the browser transiton end name.
*
* @returns {string} The browser transition end name.
*/
const transitionEndEventName = (): string => {
const el = document.createElement("div"),
transitions: Record<string, string> = {
transition: "transitionend",
OTransition: "otransitionend",
MozTransition: "transitionend",
WebkitTransition: "webkitTransitionEnd",
};
for (const i in transitions) {
if (
Object.prototype.hasOwnProperty.call(transitions, i) &&
el.style[i] !== undefined
) {
return transitions[i];
}
}
return "";
};
/**
* Wait for the element to render as Promise
*
* @param elem The
* @returns
*/
const waitForElementRender = (elem: Ref): Promise<HTMLElement> => {
return new Promise((resolve) => {
if (document.contains(elem.value)) {
return resolve(elem.value as HTMLElement);
}
/* eslint-disable @typescript-eslint/no-explicit-any */
const MutationObserver =
window.MutationObserver ||
(window as any).WebKitMutationObserver ||
(window as any).MozMutationObserver;
/* eslint-enable @typescript-eslint/no-explicit-any */
const observer = new MutationObserver(() => {
if (document.contains(elem.value)) {
resolve(elem.value);
observer.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
});
};
/**
* Run the enter transition on a element.
*
* @param {Ref} elem The element to run the enter transition.
* @param {string} transition The transition name.
* @returns {void}
*/
export const transitionEnter = (elem: Ref, transition: string): void => {
waitForElementRender(elem).then((e: HTMLElement) => {
window.setTimeout(() => {
e.classList.replace(
transition + "-enter-from",
transition + "-enter-active"
);
const transitionName = transitionEndEventName();
e.addEventListener(
transitionName,
() => {
e.classList.replace(
transition + "-enter-active",
transition + "-enter-to"
);
},
false
);
}, 1);
});
};
/**
* Run the exit transition on a element then call a callback.
*
* @param {Ref} elem The element to run the enter transition.
* @param {string} transition The transition name.
* @param {TransitionLeaveCallback|null} callback The callback to run after the transition finishes.
* @returns {void}
*/
type TransitionLeaveCallback = () => void;
export const transitionLeave = (
elem: Ref,
transition: string,
callback: TransitionLeaveCallback | null = null
): void => {
elem.value.classList.remove(transition + "-enter-to");
elem.value.classList.add(transition + "-leave-from");
window.setTimeout(() => {
elem.value.classList.replace(
transition + "-leave-from",
transition + "-leave-active"
);
const transitionName = transitionEndEventName();
elem.value.addEventListener(
transitionName,
() => {
elem.value.classList.replace(
transition + "-leave-active",
transition + "-leave-to"
);
if (callback) {
callback();
}
},
false
);
}, 1);
};

View File

@@ -0,0 +1,69 @@
/**
* Test if target is a boolean
*
* @param {unknown} target The varible to test
* @returns {boolean} If the varible is a boolean type
*/
export function isBool(target: unknown): boolean {
return typeof target === "boolean";
}
/**
* Test if target is a number
*
* @param {unknown} target The varible to test
* @returns {boolean} If the varible is a number type
*/
export function isNumber(target: unknown): boolean {
return typeof target === "number";
}
/**
* Test if target is an object
*
* @param {unknown} target The varible to test
* @returns {boolean} If the varible is a object type
*/
export function isObject(target: unknown): boolean {
return typeof target === "object" && target !== null;
}
/**
* Test if target is a string
*
* @param {unknown} target The varible to test
* @returns {boolean} If the varible is a string type
*/
export function isString(target: unknown): boolean {
return typeof target === "string" && target !== null;
}
/**
* Convert bytes to a human readable string.
*
* @param {number} bytes The bytes to convert.
* @returns {string} The bytes in human readable string.
*/
export const bytesReadable = (bytes: number): string => {
if (Number.isNaN(bytes)) {
return "0 Bytes";
}
if (Math.abs(bytes) < 1024) {
return bytes + " Bytes";
}
const units = ["KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
let u = -1;
const r = 10 ** 1;
do {
bytes /= 1000;
++u;
} while (
Math.round(Math.abs(bytes) * r) / r >= 1000 &&
u < units.length - 1
);
return bytes.toFixed(1) + " " + units[u];
};

View File

@@ -0,0 +1,64 @@
/**
* Tests if an object or string is empty.
*
* @param {object|string} objOrString The object or string.
* @returns {boolean} If the object or string is empty.
*/
export const isEmpty = (objOrString: unknown): boolean => {
if (objOrString == null) {
return true;
} else if (typeof objOrString === "string") {
return objOrString.length == 0;
} else if (
typeof objOrString == "object" &&
Object.keys(objOrString).length === 0
) {
return true;
}
return false;
};
/**
* Returns a url to a file type icon based on file name.
*
* @param {string} fileName The filename with extension.
* @returns {string} The url to the file type icon.
*/
export const getFileIconImagePath = (fileName: string): string => {
const ext = fileName.split(".").pop();
return `/img/fileicons/${ext}.png`;
};
/**
* Returns a url to a file preview icon based on file url.
*
* @param {string} url The url of the file.
* @returns {string} The url to the file preview icon.
*/
export const getFilePreview = (url: string): string => {
const ext = url.split(".").pop();
if (ext) {
if (/(gif|jpe?g|png)/i.test(ext)) {
return `${url}?w=200`;
}
return `/img/fileicons/${ext}.png`;
}
return "/img/fileicons/unknown.png";
};
/**
* Clamps a number between 2 numbers.
*
* @param {number} n The number to clamp.
* @param {number} min The minimum allowable number.
* @param {number} max The maximum allowable number.
* @returns {number} The clamped number.
*/
export const clamp = (n: number, min: number, max: number): number => {
if (n < min) return min;
if (n > max) return max;
return n;
};

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