Dependency refactor #17
@@ -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",
|
||||
|
||||
1
.github/workflows/laravel.yml
vendored
@@ -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
@@ -237,4 +237,11 @@ dist/
|
||||
### This Project ###
|
||||
/public/uploads
|
||||
/public/build
|
||||
*.key
|
||||
/public/tinymce
|
||||
*.key
|
||||
|
||||
### Synk ###
|
||||
.dccache
|
||||
|
||||
### TempCodeRunner ###
|
||||
tempCodeRunnerFile.*
|
||||
3
.vscode/settings.json
vendored
@@ -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]": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
84
app/Http/Controllers/Api/AttachmentController.php
Normal 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)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
698
app/Services/AnimatedGifService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use ImageIntervention;
|
||||
|
||||
class ImageService
|
||||
{
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,6 @@
|
||||
export interface ImportMetaExtras extends ImportMeta {
|
||||
env: {
|
||||
APP_URL: string;
|
||||
[key: string]: string;
|
||||
};
|
||||
}
|
||||
1240
package-lock.json
generated
23
package.json
@@ -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",
|
||||
|
||||
@@ -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
|
After Width: | Height: | Size: 55 KiB |
BIN
public/img/dashboard-bg.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
3
public/img/fileicons/.htaccess
Normal file
@@ -0,0 +1,3 @@
|
||||
RewriteEngine on
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule \.png$ unknown.png [L]
|
||||
BIN
public/img/fileicons/doc.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/img/fileicons/docx.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/img/fileicons/file-icons.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/img/fileicons/jpeg.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/img/fileicons/jpg.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/img/fileicons/pdf.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/img/fileicons/png.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/img/fileicons/svg.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/img/fileicons/unknown.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
public/img/fileicons/xls.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/img/fileicons/xml.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/img/fileicons/zip.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
60
public/media.php
Normal 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;
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
3
resources/css/tinymce.scss
Normal 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";
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
34
resources/js/bootstrap.js
vendored
@@ -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'],
|
||||
// });
|
||||
|
||||
@@ -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>
|
||||
68
resources/js/components/SMAttachments.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
42
resources/js/components/SMFileLink.vue
Normal 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>
|
||||
@@ -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 © 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;
|
||||
}
|
||||
|
||||
37
resources/js/components/SMForm.vue
Normal 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>
|
||||
@@ -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)};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
198
resources/js/components/SMInputAttachments.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
68
resources/js/components/SMLoadingIcon.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="modal">
|
||||
<div class="sm-modal">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
103
resources/js/components/SMPage.vue
Normal 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>
|
||||
@@ -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>
|
||||
135
resources/js/components/SMPagination.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
|
||||
86
resources/js/components/SMProgress.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
134
resources/js/components/SMToast.vue
Normal 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>
|
||||
31
resources/js/components/SMToastList.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
};
|
||||
82
resources/js/helpers/api.types.ts
Normal 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;
|
||||
}
|
||||
24
resources/js/helpers/array.ts
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
487
resources/js/helpers/datetime.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
27
resources/js/helpers/debounce.ts
Normal 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);
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
196
resources/js/helpers/form.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
};
|
||||
14
resources/js/helpers/image.ts
Normal 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;
|
||||
};
|
||||
29
resources/js/helpers/object.ts
Normal 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 ], ... ]
|
||||
};
|
||||
81
resources/js/helpers/string.ts
Normal 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];
|
||||
});
|
||||
};
|
||||
127
resources/js/helpers/transition.ts
Normal 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);
|
||||
};
|
||||
69
resources/js/helpers/types.ts
Normal 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];
|
||||
};
|
||||
64
resources/js/helpers/utils.ts
Normal 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;
|
||||
};
|
||||