This commit is contained in:
2023-04-22 21:18:07 +10:00
parent 84bfd3cda2
commit a663e2bd56
22 changed files with 384 additions and 143 deletions

View File

@@ -8,6 +8,7 @@ use App\Http\Requests\MediaRequest;
use App\Models\Media; use App\Models\Media;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Laravel\Sanctum\PersonalAccessToken; use Laravel\Sanctum\PersonalAccessToken;
class MediaController extends ApiController class MediaController extends ApiController
@@ -119,19 +120,36 @@ class MediaController extends ApiController
if (MediaConductor::updatable($medium) === true) { if (MediaConductor::updatable($medium) === true) {
$file = $request->file('file'); $file = $request->file('file');
if ($file !== null) { if ($file !== null) {
if ($file->getSize() > Media::maxUploadSize()) { if ($file->isValid() !== true) {
return $this->respondTooLarge(); switch ($file->getError()) {
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
return $this->respondTooLarge();
case UPLOAD_ERR_PARTIAL:
return $this->respondWithErrors(['file' => 'The file upload was interrupted.']);
default:
return $this->respondWithErrors(['file' => 'An error occurred uploading the file to the server.']);
}
} }
if ($medium->updateFile($file) === false) { if ($file->getSize() > Media::getMaxUploadSize()) {
return $this->respondTooLarge();
}
}
$medium->update($request->all());
if ($file !== null) {
try {
$medium->updateWithUploadedFile($file);
} catch (\Exception $e) {
return $this->respondWithErrors( return $this->respondWithErrors(
['file' => 'The file could not be stored on the server'], ['file' => $e->getMessage()],
HttpResponseCodes::HTTP_INTERNAL_SERVER_ERROR HttpResponseCodes::HTTP_INTERNAL_SERVER_ERROR
); );
} }
}//end if }
$medium->update($request->all());
return $this->respondAsResource(MediaConductor::model($request, $medium)); return $this->respondAsResource(MediaConductor::model($request, $medium));
}//end if }//end if

View File

@@ -44,11 +44,12 @@ class StoreUploadedFileJob implements ShouldQueue
*/ */
protected $replaceExisting; protected $replaceExisting;
/** /**
* Create a new job instance. * Create a new job instance.
* *
* @param Media $media The media model. * @param Media $media The media model.
* @param string $filePath The uploaded file. * @param string $filePath The uploaded file.
* @param boolean $replaceExisting Replace existing files. * @param boolean $replaceExisting Replace existing files.
* @return void * @return void
*/ */
@@ -74,31 +75,30 @@ class StoreUploadedFileJob implements ShouldQueue
$this->media->save(); $this->media->save();
if (strlen($this->uploadedFilePath) > 0) { if (strlen($this->uploadedFilePath) > 0) {
if (Storage::disk($storageDisk)->exists($fileName) == false || $this->replaceExisting == true) { if (Storage::disk($storageDisk)->exists($fileName) === false || $this->replaceExisting === true) {
Storage::disk($storageDisk)->putFileAs('/', new SplFileInfo($this->uploadedFilePath), $fileName); Storage::disk($storageDisk)->putFileAs('/', new SplFileInfo($this->uploadedFilePath), $fileName);
Log::info("uploading file {$storageDisk} / {$fileName} / {$this->uploadedFilePath}"); Log::info("uploading file {$storageDisk} / {$fileName} / {$this->uploadedFilePath}");
} else { } else {
Log::info("file {$fileName} already exists in {$storageDisk} / {$this->uploadedFilePath}. Not replacing file and using local {$fileName} for variants."); Log::info("file {$fileName} already exists in {$storageDisk} / {$this->uploadedFilePath}. Not replacing file and using local {$fileName} for variants.");
} }
} else { } else {
if (Storage::disk($storageDisk)->exists($fileName) == true) { if (Storage::disk($storageDisk)->exists($fileName) === true) {
Log::info("file {$fileName} already exists in {$storageDisk} / {$this->uploadedFilePath}. No local {$fileName} for variants, downloading from CDN."); Log::info("file {$fileName} already exists in {$storageDisk} / {$this->uploadedFilePath}. No local {$fileName} for variants, downloading from CDN.");
$readStream = Storage::disk($storageDisk)->readStream($fileName); $readStream = Storage::disk($storageDisk)->readStream($fileName);
$tempFilePath = tempnam(sys_get_temp_dir(), 'download-'); $tempFilePath = tempnam(sys_get_temp_dir(), 'download-');
$writeStream = fopen($tempFilePath, 'w'); $writeStream = fopen($tempFilePath, 'w');
while (!feof($readStream)) { while (feof($readStream) !== true) {
fwrite($writeStream, fread($readStream, 8192)); fwrite($writeStream, fread($readStream, 8192));
} }
fclose($readStream); fclose($readStream);
fclose($writeStream); fclose($writeStream);
$this->uploadedFilePath = $tempFilePath; $this->uploadedFilePath = $tempFilePath;
} else { } else {
$errorStr = "cannot upload file {$storageDisk} / {$fileName} / {$this->uploadedFilePath} as temp file is empty"; $errorStr = "cannot upload file {$storageDisk} / {$fileName} / {$this->uploadedFilePath} as temp file is empty";
Log::info($errorStr); Log::info($errorStr);
throw new \Exception($errorStr); throw new \Exception($errorStr);
} }
} }//end if
if (strpos($this->media->mime_type, 'image/') === 0) { if (strpos($this->media->mime_type, 'image/') === 0) {
$this->media->status = "Optimizing image"; $this->media->status = "Optimizing image";
@@ -160,7 +160,7 @@ class StoreUploadedFileJob implements ShouldQueue
}//end if }//end if
} else { } else {
Log::info("variant {$variantName} already exists for file {$fileName}"); Log::info("variant {$variantName} already exists for file {$fileName}");
} }//end if
}//end foreach }//end foreach
// Set missing variants to the largest available variant // Set missing variants to the largest available variant

View File

@@ -206,7 +206,7 @@ class Media extends Model
*/ */
public function getUrlAttribute() public function getUrlAttribute()
{ {
if(isset($this->attributes['name'])) { if (isset($this->attributes['name']) === true) {
$url = config("filesystems.disks.$this->storage.url"); $url = config("filesystems.disks.$this->storage.url");
return "$url/$this->name"; return "$url/$this->name";
} }
@@ -247,6 +247,28 @@ class Media extends Model
* @return null|Media The result or null if not successful. * @return null|Media The result or null if not successful.
*/ */
public static function createFromUploadedFile(Request $request, UploadedFile $file) public static function createFromUploadedFile(Request $request, UploadedFile $file)
{
$request->merge([
'title' => $request->get('title', ''),
'name' => '',
'size' => 0,
'mime_type' => '',
'status' => '',
]);
$mediaItem = $request->user()->media()->create($request->all());
$mediaItem->updateWithUploadedFile($file);
return $mediaItem;
}
/**
* Update Media with UploadedFile data.
*
* @param Illuminate\Http\UploadedFile $file The file.
* @return null|Media The media item.
*/
public function updateWithUploadedFile(UploadedFile $file)
{ {
if ($file === null || $file->isValid() !== true) { if ($file === null || $file->isValid() !== true) {
throw new \Exception('The file is invalid.', self::INVALID_FILE_ERROR); throw new \Exception('The file is invalid.', self::INVALID_FILE_ERROR);
@@ -261,34 +283,40 @@ class Media extends Model
throw new \Exception('The file name already exists in storage.', self::FILE_NAME_EXISTS_ERROR); throw new \Exception('The file name already exists in storage.', self::FILE_NAME_EXISTS_ERROR);
} }
$request->merge([ // remove file if there is an existing entry in this medium item
'title' => $request->get('title', $name), if (strlen($this->name) > 0 && strlen($this->storage) > 0) {
'name' => $name, Storage::disk($this->storage)->delete($this->name);
'size' => $file->getSize(), foreach ($this->variants as $variantName => $fileName) {
'mime_type' => $file->getMimeType(), Storage::disk($this->storage)->delete($fileName);
'status' => 'Processing media', }
]);
$mediaItem = $request->user()->media()->create($request->all()); $this->name = '';
$this->variants = [];
try {
$temporaryFilePath = tempnam(sys_get_temp_dir(), 'upload');
$temporaryDirectoryPath = dirname($temporaryFilePath);
$file->move($temporaryDirectoryPath, basename($temporaryFilePath));
} catch (\Exception $e) {
throw new \Exception('Could not temporarily store file. ' . $e->getMessage(), self::TEMP_FILE_ERROR);
} }
if (strlen($this->title) === 0) {
$this->title = $name;
}
$this->name = $name;
$this->size = $file->getSize();
$this->mime_type = $file->getMimeType();
$this->status = 'Processing media';
$this->save();
$temporaryFilePath = tempnam(sys_get_temp_dir(), 'upload');
copy($file->path(), $temporaryFilePath);
try { try {
StoreUploadedFileJob::dispatch($mediaItem, $temporaryFilePath)->onQueue('media'); StoreUploadedFileJob::dispatch($this, $temporaryFilePath)->onQueue('media');
} catch (\Exception $e) { } catch (\Exception $e) {
$mediaItem->delete(); $this->status = 'Error';
$mediaItem = null; $this->save();
throw $e; throw $e;
}//end try }//end try
return $mediaItem; return $this;
} }
/** /**

14
composer.lock generated
View File

@@ -62,16 +62,16 @@
}, },
{ {
"name": "aws/aws-sdk-php", "name": "aws/aws-sdk-php",
"version": "3.263.14", "version": "3.268.16",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/aws/aws-sdk-php.git", "url": "https://github.com/aws/aws-sdk-php.git",
"reference": "7a6a43fad8899e3be3c46471fa3802331620e36b" "reference": "b59134c9ca64dcb9de6f7dbbcb9d5a75ed665a98"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/7a6a43fad8899e3be3c46471fa3802331620e36b", "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b59134c9ca64dcb9de6f7dbbcb9d5a75ed665a98",
"reference": "7a6a43fad8899e3be3c46471fa3802331620e36b", "reference": "b59134c9ca64dcb9de6f7dbbcb9d5a75ed665a98",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -151,9 +151,9 @@
"support": { "support": {
"forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80",
"issues": "https://github.com/aws/aws-sdk-php/issues", "issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.263.14" "source": "https://github.com/aws/aws-sdk-php/tree/3.268.16"
}, },
"time": "2023-04-20T18:21:44+00:00" "time": "2023-04-21T21:37:05+00:00"
}, },
{ {
"name": "brick/math", "name": "brick/math",
@@ -9508,5 +9508,5 @@
"php": "^8.0.2" "php": "^8.0.2"
}, },
"platform-dev": [], "platform-dev": [],
"plugin-api-version": "2.1.0" "plugin-api-version": "2.3.0"
} }

View File

@@ -31,10 +31,7 @@ return [
'disks' => [ 'disks' => [
'local' => [ 'local' => [
'driver' => 'local', 'driver' => 'local',
'root' => storage_path('app/uploads'), 'root' => storage_path('app'),
'throw' => false,
'url' => env('STORAGE_LOCAL_URL'),
'public' => true,
], ],
'cdn' => [ 'cdn' => [

40
package-lock.json generated
View File

@@ -475,9 +475,9 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "8.38.0", "version": "8.39.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.38.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.39.0.tgz",
"integrity": "sha512-IoD2MfUnOV58ghIHCiil01PcohxjbYR/qCxsoC+xNgUwh1EY8jOOrYmu3d3a71+tJJ23uscEV4X2HJWMsPJu4g==", "integrity": "sha512-kf9RB0Fg7NZfap83B3QOqOGg9QmD9yBudqQXzzOtn3i4y7ZUXe5ONeW34Gwi+TxhH4mvj72R1Zc300KUMa9Bng==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -729,9 +729,9 @@
"integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==" "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ=="
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "18.15.12", "version": "18.15.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.12.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.13.tgz",
"integrity": "sha512-Wha1UwsB3CYdqUm2PPzh/1gujGCNtWVUYF0mB00fJFoR4gTyWTDPjSm+zBF787Ahw8vSGgBja90MkgFwvB86Dg==" "integrity": "sha512-N+0kuo9KgrUQ1Sn/ifDXsvg0TTleP7rIy4zOBGECxAljqvqfqpTfzx0Q1NUedOixRMBfe2Whhb056a42cWs26Q=="
}, },
"node_modules/@types/semver": { "node_modules/@types/semver": {
"version": "7.3.13", "version": "7.3.13",
@@ -1607,9 +1607,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001480", "version": "1.0.30001481",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001480.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001481.tgz",
"integrity": "sha512-q7cpoPPvZYgtyC4VaBSN0Bt+PJ4c4EYRf0DrduInOz2SkFpHD5p3LnvEpqBp7UnJn+8x1Ogl1s38saUxe+ihQQ==", "integrity": "sha512-KCqHwRnaa1InZBtqXzP98LPg0ajCVujMKjqKDhZEthIpAsJl/YEIa3YvXjGXPVqzZVguccuu7ga9KOE1J9rKPQ==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -1936,9 +1936,9 @@
"dev": true "dev": true
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.4.368", "version": "1.4.369",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.368.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.369.tgz",
"integrity": "sha512-e2aeCAixCj9M7nJxdB/wDjO6mbYX+lJJxSJCXDzlr5YPGYVofuJwGN9nKg2o6wWInjX6XmxRinn3AeJMK81ltw==", "integrity": "sha512-LfxbHXdA/S+qyoTEA4EbhxGjrxx7WK2h6yb5K2v0UCOufUKX+VZaHbl3svlzZfv9sGseym/g3Ne4DpsgRULmqg==",
"peer": true "peer": true
}, },
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
@@ -2032,15 +2032,15 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "8.38.0", "version": "8.39.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.38.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.39.0.tgz",
"integrity": "sha512-pIdsD2jwlUGf/U38Jv97t8lq6HpaU/G9NKbYmpWpZGw3LdTNhZLbJePqxOXGB5+JEKfOPU/XLxYxFh03nr1KTg==", "integrity": "sha512-mwiok6cy7KTW7rBpo05k6+p4YVZByLNjAZ/ACB9DRCu4YDRwjXI01tWHp6KAUWelsBetTxKK/2sHB0vdS8Z2Og==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.4.0", "@eslint-community/regexpp": "^4.4.0",
"@eslint/eslintrc": "^2.0.2", "@eslint/eslintrc": "^2.0.2",
"@eslint/js": "8.38.0", "@eslint/js": "8.39.0",
"@humanwhocodes/config-array": "^0.11.8", "@humanwhocodes/config-array": "^0.11.8",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
"@nodelib/fs.walk": "^1.2.8", "@nodelib/fs.walk": "^1.2.8",
@@ -2050,7 +2050,7 @@
"debug": "^4.3.2", "debug": "^4.3.2",
"doctrine": "^3.0.0", "doctrine": "^3.0.0",
"escape-string-regexp": "^4.0.0", "escape-string-regexp": "^4.0.0",
"eslint-scope": "^7.1.1", "eslint-scope": "^7.2.0",
"eslint-visitor-keys": "^3.4.0", "eslint-visitor-keys": "^3.4.0",
"espree": "^9.5.1", "espree": "^9.5.1",
"esquery": "^1.4.2", "esquery": "^1.4.2",
@@ -3473,9 +3473,9 @@
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "3.20.6", "version": "3.20.7",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.20.6.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.20.7.tgz",
"integrity": "sha512-2yEB3nQXp/tBQDN0hJScJQheXdvU2wFhh6ld7K/aiZ1vYcak6N/BKjY1QrU6BvO2JWYS8bEs14FRaxXosxy2zw==", "integrity": "sha512-P7E2zezKSLhWnTz46XxjSmInrbOCiul1yf+kJccMxT56vxjHwCbDfoLbiqFgu+WQoo9ij2PkraYaBstgB2prBA==",
"bin": { "bin": {
"rollup": "dist/bin/rollup" "rollup": "dist/bin/rollup"
}, },

BIN
public/uploadabiwAz Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -114,7 +114,7 @@
} }
.flex-row-reverse { .flex-row-reverse {
flex-direction: row-reverse; flex-direction: row-reverse !important;
} }
.flex-column { .flex-column {

View File

@@ -8,6 +8,7 @@
props.size, props.size,
{ 'button-block': block }, { 'button-block': block },
{ 'button-dropdown': dropdown }, { 'button-dropdown': dropdown },
{ 'button-loading': loading },
]" ]"
ref="buttonRef" ref="buttonRef"
:style="{ minWidth: minWidth }" :style="{ minWidth: minWidth }"
@@ -146,8 +147,10 @@ if (props.form !== undefined) {
watch( watch(
() => props.form.loading(), () => props.form.loading(),
(newValue) => { (newValue) => {
loading.value = newValue;
disabled.value = newValue; disabled.value = newValue;
if (buttonType === "submit") {
loading.value = newValue;
}
} }
); );
} }
@@ -265,7 +268,7 @@ const handleClickItem = (item: string) => {
&:disabled, &:disabled,
&.primary:disabled { &.primary:disabled {
background-color: var(--base-color-dark); background-color: var(--base-color-dark) !important;
box-shadow: none; box-shadow: none;
} }

View File

@@ -0,0 +1,58 @@
<template>
<div class="image">
<SMLoading v-if="imgLoaded == false && imgError == false" />
<img
v-if="imgError == false"
:src="src"
@load="imgLoaded = true"
@error="imgError = true" />
<div v-if="imgError == true" class="image-error">
<ion-icon name="alert-circle-outline"></ion-icon>
<p>Error loading image</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import SMLoading from "./SMLoading.vue";
defineProps({
src: {
type: String,
required: true,
},
});
const imgLoaded = ref(false);
const imgError = ref(false);
</script>
<style lang="scss">
.image {
display: flex;
flex-basis: 300px;
img {
max-height: 100%;
max-width: 100%;
object-fit: contain;
border-radius: 8px;
}
.image-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
ion-icon {
font-size: 300%;
}
p {
margin: 0;
}
}
}
</style>

View File

@@ -1,6 +1,9 @@
<template> <template>
<div class="loading-container"> <div :class="['loading-background', { overlay: props.overlay }]">
<SMLoadingIcon v-bind="{ large: props.large }" /> <div :class="{ 'loading-box': props.overlay }">
<SMLoadingIcon v-bind="{ large: props.large }" />
<p v-if="props.text" class="loading-text">{{ props.text }}</p>
</div>
</div> </div>
</template> </template>
@@ -13,14 +16,54 @@ const props = defineProps({
default: false, default: false,
required: false, required: false,
}, },
text: {
type: String,
default: "",
required: false,
},
overlay: {
type: Boolean,
default: false,
required: false,
},
}); });
</script> </script>
<style lang="scss"> <style lang="scss">
.loading-container { .loading-background {
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
&.overlay {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
z-index: 10000;
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
div {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.loading-box {
background-color: #fff;
padding: 48px 48px 16px 48px;
border-radius: 10px;
box-shadow: var(--base-shadow);
.loading-text {
font-size: 150%;
}
}
} }
</style> </style>

View File

@@ -17,17 +17,11 @@
:data-title="header['text']" :data-title="header['text']"
:key="`item-row-${index}-${header['value']}`"> :key="`item-row-${index}-${header['value']}`">
<template v-if="slots[`item-${header['value']}`]"> <template v-if="slots[`item-${header['value']}`]">
<slot <slot :name="`item-${header['value']}`" v-bind="item">
:name="`item-${header['value']}`"
v-bind="item as any">
</slot> </slot>
</template> </template>
<template v-else> <template v-else>
{{ {{ getItemValue(item, header["value"]) }}
header["value"]
.split(".")
.reduce((item, key) => item[key], item)
}}
</template> </template>
</td> </td>
</tr> </tr>
@@ -57,6 +51,22 @@ const slots = useSlots();
const handleRowClick = (item) => { const handleRowClick = (item) => {
emits("rowClick", item); emits("rowClick", item);
}; };
const getItemValue = (data: unknown, key: string): string => {
if (typeof data === "object" && data !== null) {
return key.split(".").reduce((item, key) => item[key], data);
}
return "";
};
const hasClassLong = (text: unknown): boolean => {
if (typeof text == "string") {
return text.length >= 35;
}
return false;
};
</script> </script>
<style lang="scss"> <style lang="scss">
@@ -81,6 +91,10 @@ const handleRowClick = (item) => {
td { td {
font-size: 85%; font-size: 85%;
background-color: #fff; background-color: #fff;
&.long {
font-size: 75%;
}
} }
tbody { tbody {
@@ -127,16 +141,19 @@ const handleRowClick = (item) => {
border: none; border: none;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
position: relative; position: relative;
padding: 8px 12px 8px 50%; padding: 8px 12px 8px 40%;
white-space: normal; white-space: normal;
text-align: left; text-align: left;
&:before { &:before {
position: absolute; position: absolute;
padding: 8px 12px; display: flex;
align-items: center;
padding-left: 12px;
top: 0; top: 0;
bottom: 0;
left: 0; left: 0;
width: 45%; width: 35%;
white-space: nowrap; white-space: nowrap;
text-align: left; text-align: left;
font-weight: 600; font-weight: 600;

View File

@@ -122,11 +122,11 @@ onMounted(() => {
} }
} }
&.success .sm-toast-inner { &.success .toast-inner {
border-left-color: var(--success-color); border-left-color: var(--success-color);
} }
&.danger .sm-toast-inner { &.danger .toast-inner {
border-left-color: var(--danger-color); border-left-color: var(--danger-color);
} }
} }

View File

@@ -1,6 +1,6 @@
<template> <template>
<SMFormCard> <SMFormCard>
<h1>{{ props.title }}</h1> <h3>{{ props.title }}</h3>
<p v-html="computedSanitizedText"></p> <p v-html="computedSanitizedText"></p>
<SMFormFooter> <SMFormFooter>
<template #left> <template #left>

View File

@@ -71,6 +71,8 @@ export const api = {
options.headers["Authorization"] = `Bearer ${userStore.token}`; options.headers["Authorization"] = `Bearer ${userStore.token}`;
} }
options.method = options.method.toUpperCase() || "GET";
if (options.body && typeof options.body === "object") { if (options.body && typeof options.body === "object") {
if (options.body instanceof FormData) { if (options.body instanceof FormData) {
if ( if (
@@ -82,6 +84,11 @@ export const api = {
// remove the "Content-Type" key from the headers object // remove the "Content-Type" key from the headers object
delete options.headers["Content-Type"]; delete options.headers["Content-Type"];
} }
if (options.method != "POST") {
options.body.append("_method", options.method);
options.method = "POST";
}
} else if ( } else if (
options.body instanceof Blob || options.body instanceof Blob ||
options.body instanceof ArrayBuffer options.body instanceof ArrayBuffer
@@ -94,7 +101,9 @@ export const api = {
} }
if ( if (
(options.method.toUpperCase() || "GET") == "POST" && (options.method == "POST" ||
options.method == "PUT" ||
options.method == "PATCH") &&
options.progress options.progress
) { ) {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();

View File

@@ -167,7 +167,7 @@ type FormControlSetValidation = (
type FormControlIsValid = () => boolean; type FormControlIsValid = () => boolean;
export interface FormControlObject { export interface FormControlObject {
value: string; value: unknown;
validate: () => Promise<ValidationResult>; validate: () => Promise<ValidationResult>;
validation: FormControlValidation; validation: FormControlValidation;
clearValidations: FormControlClearValidations; clearValidations: FormControlClearValidations;

View File

@@ -3,19 +3,23 @@ import { extractFileNameFromUrl } from "./url";
/** /**
* Tests if an object or string is empty. * Tests if an object or string is empty.
* *
* @param {object|string} objOrString The object or string. * @param {unknown} value The object or string.
* @returns {boolean} If the object or string is empty. * @returns {boolean} If the object or string is empty.
*/ */
export const isEmpty = (objOrString: unknown): boolean => { export const isEmpty = (value: unknown): boolean => {
if (objOrString == null) { if (typeof value === "string") {
return true; return value.trim().length === 0;
} else if (typeof objOrString === "string") {
return objOrString.length == 0;
} else if ( } else if (
typeof objOrString == "object" && value instanceof File ||
Object.keys(objOrString).length === 0 value instanceof Blob ||
value instanceof Map ||
value instanceof Set
) { ) {
return true; return value.size === 0;
} else if (value instanceof FormData) {
return [...value.entries()].length === 0;
} else if (typeof value === "object") {
return !value || Object.keys(value).length === 0;
} }
return false; return false;

View File

@@ -1,8 +1,9 @@
import { bytesReadable } from "../helpers/types"; import { bytesReadable } from "../helpers/types";
import { SMDate } from "./datetime"; import { SMDate } from "./datetime";
import { isEmpty } from "../helpers/utils";
export interface ValidationObject { export interface ValidationObject {
validate: (value: any) => Promise<ValidationResult>; validate: (value: unknown) => Promise<ValidationResult>;
} }
export interface ValidationResult { export interface ValidationResult {
@@ -744,9 +745,9 @@ export function Required(
return { return {
...options, ...options,
validate: function (value: string): Promise<ValidationResult> { validate: function (value: unknown): Promise<ValidationResult> {
return Promise.resolve({ return Promise.resolve({
valid: value.length > 0, valid: !isEmpty(value),
invalidMessages: [ invalidMessages: [
typeof this.invalidMessage === "string" typeof this.invalidMessage === "string"
? this.invalidMessage ? this.invalidMessage
@@ -831,8 +832,11 @@ export function FileSize(
return { return {
...options, ...options,
validate: function (value: File): Promise<ValidationResult> { validate: function (value: File): Promise<ValidationResult> {
const isValid =
value instanceof File ? value.size < options.size : true;
return Promise.resolve({ return Promise.resolve({
valid: value.size < options.size, valid: isValid,
invalidMessages: [ invalidMessages: [
typeof this.invalidMessage === "string" typeof this.invalidMessage === "string"
? this.invalidMessage ? this.invalidMessage

View File

@@ -40,7 +40,10 @@
<div <div
class="thumbnail" class="thumbnail"
:style="{ :style="{
backgroundImage: `url('${event.hero.url}')`, backgroundImage: `url('${mediaGetVariantUrl(
event.hero,
'medium'
)}')`,
}"> }">
<div :class="['banner', event['bannerType']]"> <div :class="['banner', event['bannerType']]">
{{ event["banner"] }} {{ event["banner"] }}
@@ -87,6 +90,7 @@ import SMToolbar from "../components/SMToolbar.vue";
import { api } from "../helpers/api"; import { api } from "../helpers/api";
import { Event, EventCollection } from "../helpers/api.types"; import { Event, EventCollection } from "../helpers/api.types";
import { SMDate } from "../helpers/datetime"; import { SMDate } from "../helpers/datetime";
import { mediaGetVariantUrl } from "../helpers/media";
import SMMastHead from "../components/SMMastHead.vue"; import SMMastHead from "../components/SMMastHead.vue";
import SMContainer from "../components/SMContainer.vue"; import SMContainer from "../components/SMContainer.vue";
import SMNoItems from "../components/SMNoItems.vue"; import SMNoItems from "../components/SMNoItems.vue";

View File

@@ -7,6 +7,13 @@
<SMContainer class="flex-grow-1"> <SMContainer class="flex-grow-1">
<SMLoading v-if="pageLoading" large /> <SMLoading v-if="pageLoading" large />
<SMForm v-else :model-value="form" @submit="handleSubmit"> <SMForm v-else :model-value="form" @submit="handleSubmit">
<SMRow>
<SMColumn class="media-container">
<!-- <div class="media-container"> -->
<SMImage :src="imageUrl" />
<!-- </div> -->
</SMColumn>
</SMRow>
<SMRow> <SMRow>
<SMColumn> <SMColumn>
<SMInput control="file" type="file" /> <SMInput control="file" type="file" />
@@ -61,12 +68,15 @@
<SMInput type="textarea" control="description" /> <SMInput type="textarea" control="description" />
</SMColumn> </SMColumn>
</SMRow> </SMRow>
<SMRow class="px-2 justify-content-space-between"> <SMRow
class="px-2 flex-row-reverse justify-content-space-between">
<SMButton type="submit" label="Save" :form="form" />
<SMButton <SMButton
:form="form"
v-if="route.params.id"
type="danger" type="danger"
label="Delete" label="Delete"
@click="handleDelete" /> @click="handleDelete" />
<SMButton type="submit" label="Save" />
</SMRow> </SMRow>
</SMForm> </SMForm>
</SMContainer> </SMContainer>
@@ -74,13 +84,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref } from "vue"; import { computed, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { api } from "../../helpers/api"; import { api } from "../../helpers/api";
import { Form, FormControl } from "../../helpers/form"; import { Form, FormControl } from "../../helpers/form";
import { bytesReadable } from "../../helpers/types"; import { bytesReadable } from "../../helpers/types";
import { And, FileSize, Required } from "../../helpers/validate"; import { And, FileSize, Required } from "../../helpers/validate";
import { Media, MediaResponse } from "../../helpers/api.types"; import { MediaResponse } from "../../helpers/api.types";
import { openDialog } from "../../components/SMDialog"; import { openDialog } from "../../components/SMDialog";
import DialogConfirm from "../../components/dialogs/SMDialogConfirm.vue"; import DialogConfirm from "../../components/dialogs/SMDialogConfirm.vue";
import SMButton from "../../components/SMButton.vue"; import SMButton from "../../components/SMButton.vue";
@@ -89,6 +99,9 @@ import SMInput from "../../components/SMInput.vue";
import SMMastHead from "../../components/SMMastHead.vue"; import SMMastHead from "../../components/SMMastHead.vue";
import SMLoading from "../../components/SMLoading.vue"; import SMLoading from "../../components/SMLoading.vue";
import { toTitleCase } from "../../helpers/string"; import { toTitleCase } from "../../helpers/string";
import { useToastStore } from "../../store/ToastStore";
import SMColumn from "../../components/SMColumn.vue";
import SMImage from "../../components/SMImage.vue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -116,6 +129,8 @@ const fileData = reactive({
user: {}, user: {},
}); });
const imageUrl = ref("");
const handleLoad = async () => { const handleLoad = async () => {
if (route.params.id) { if (route.params.id) {
try { try {
@@ -142,6 +157,8 @@ const handleLoad = async () => {
: toTitleCase(data.medium.status); : toTitleCase(data.medium.status);
fileData.dimensions = data.medium.dimensions; fileData.dimensions = data.medium.dimensions;
imageUrl.value = fileData.url;
} catch (err) { } catch (err) {
pageError.value = err.status; pageError.value = err.status;
} }
@@ -152,51 +169,74 @@ const handleLoad = async () => {
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
let res = null; form.loading(true);
// let data = { let submitData = new FormData();
// title: formData.title.value,
// slug: formData.slug.value,
// user_id: formData.user_id.value,
// content: formData.content.value
// }
// if(route.params.id) { // add file if there is one
// res = await axios.put(`posts/${route.params.id}`, data); if (form.controls.file.value instanceof File) {
// } else { submitData.append("file", form.controls.file.value);
// res = await axios.post(`posts`, data);
// }
let submitFormData = new FormData();
if (form.file.value instanceof File) {
submitFormData.append("file", form.file.value);
} }
submitFormData.append("permission", form.permission.value); submitData.append("title", form.controls.title.value as string);
submitData.append(
"permission",
form.controls.permission.value as string
);
submitData.append(
"description",
form.controls.description.value as string
);
await api.post({ if (route.params.id) {
url: "/media", await api.put({
body: submitFormData, url: "/media/{id}",
headers: { params: {
"Content-Type": "multipart/form-data", id: route.params.id,
}, },
progress: (progressEvent) => body: submitData,
(formLoadingMessage.value = `Uploading Files ${Math.floor( headers: {
(progressEvent.loaded / progressEvent.total) * 100 "Content-Type": "multipart/form-data",
)}%`), },
});
} else {
await api.post({
url: "/media",
body: submitData,
headers: {
"Content-Type": "multipart/form-data",
},
// progress: (progressEvent) =>
// (formLoadingMessage.value = `Uploading Files ${Math.floor(
// (progressEvent.loaded / progressEvent.total) * 100
// )}%`),
});
}
useToastStore().addToast({
title: route.params.id ? "Media Updated" : "Media Created",
content: route.params.id
? "The media item has been updated."
: "The media item been created.",
type: "success",
}); });
form.message("Your details have been updated", "success"); router.push({ name: "dashboard-media-list" });
} catch (err) { } catch (error) {
form.apiErrors(err); console.log(error);
useToastStore().addToast({
title: "Server error",
content: "An error occurred saving the media.",
type: "danger",
});
} finally {
form.loading(false);
} }
form.loading(false);
}; };
const handleDelete = async (item: Media) => { const handleDelete = async () => {
let result = await openDialog(DialogConfirm, { let result = await openDialog(DialogConfirm, {
title: "Delete File?", title: "Delete File?",
text: `Are you sure you want to delete the file <strong>${item.title}</strong>?`, text: `Are you sure you want to delete the file <strong>${form.controls.title.value}</strong>?`,
cancel: { cancel: {
type: "secondary", type: "secondary",
label: "Cancel", label: "Cancel",
@@ -209,7 +249,7 @@ const handleDelete = async (item: Media) => {
if (result) { if (result) {
try { try {
await api.delete(`media/${item.id}`); await api.delete(`media/${route.params.id}`);
router.push({ name: "media" }); router.push({ name: "media" });
} catch (error) { } catch (error) {
pageError.value = error.status; pageError.value = error.status;
@@ -223,3 +263,14 @@ const computedFileSize = computed(() => {
handleLoad(); handleLoad();
</script> </script>
<style lang="scss">
.page-dashboard-media-edit {
.media-container {
max-height: 300px;
display: flex;
justify-content: center;
align-items: center;
}
}
</style>

View File

@@ -39,6 +39,11 @@
<template #item-size="item"> <template #item-size="item">
{{ bytesReadable(item.size) }} {{ bytesReadable(item.size) }}
</template> </template>
<template #item-title="item"
>{{ item.title }}<br /><span class="small"
>({{ item.name }})</span
></template
>
<template #item-actions="item"> <template #item-actions="item">
<SMButton <SMButton
label="Edit" label="Edit"
@@ -86,7 +91,7 @@ const itemsPerPage = 25;
const itemsPage = ref(parseInt((route.query.page as string) || "1")); const itemsPage = ref(parseInt((route.query.page as string) || "1"));
const headers = [ const headers = [
{ text: "Name", value: "title", sortable: true }, { text: "Title (Name)", value: "title", sortable: true },
{ text: "Size", value: "size", sortable: true }, { text: "Size", value: "size", sortable: true },
{ text: "Uploaded By", value: "user.display_name", sortable: true }, { text: "Uploaded By", value: "user.display_name", sortable: true },
{ text: "Actions", value: "actions" }, { text: "Actions", value: "actions" },
@@ -257,7 +262,8 @@ handleLoad();
<style lang="scss"> <style lang="scss">
.page-dashboard-media-list { .page-dashboard-media-list {
.table tr { .table tr {
td:first-of-type { td:first-of-type,
td:nth-of-type(2) {
word-break: break-all; word-break: break-all;
} }

View File

@@ -241,7 +241,6 @@ const handleSubmit = async () => {
router.push({ name: "dashboard-post-list" }); router.push({ name: "dashboard-post-list" });
} catch (error) { } catch (error) {
console.log(error);
form.apiErrors(error); form.apiErrors(error);
} }
}; };