This commit is contained in:
2023-04-21 07:11:00 +10:00
parent 5ae6e02ce8
commit 7a2f263061
29 changed files with 775 additions and 387 deletions

View File

@@ -259,14 +259,14 @@ class Conductor
// Transform and Includes // Transform and Includes
$includes = $conductor->includes; $includes = $conductor->includes;
if(count($limitFields) > 0) { if (count($limitFields) > 0) {
$includes = array_intersect($limitFields, $conductor->includes); $includes = array_intersect($limitFields, $conductor->includes);
} }
$conductor->collection = $conductor->collection->map(function ($model) use ($conductor, $includes, $limitFields) { $conductor->collection = $conductor->collection->map(function ($model) use ($conductor, $includes, $limitFields) {
$conductor->applyIncludes($model, $includes); $conductor->applyIncludes($model, $includes);
if(count($limitFields) > 0) { if (count($limitFields) > 0) {
$model->setAppends(array_intersect($model->getAppends(), $limitFields)); $model->setAppends(array_intersect($model->getAppends(), $limitFields));
} }
@@ -316,10 +316,10 @@ class Conductor
$requestFields = $request->input('fields'); $requestFields = $request->input('fields');
if ($requestFields !== null) { if ($requestFields !== null) {
$requestFields = explode(',', $requestFields); $requestFields = explode(',', $requestFields);
if(in_array($key, $requestFields) === false) { if (in_array($key, $requestFields) === false) {
foreach($requestFields as $field) { foreach ($requestFields as $field) {
if(strpos($field, $key . '.') === 0) { if (strpos($field, $key . '.') === 0) {
$fields[] = substr($field, strlen($key) + 1); $fields[] = substr($field, (strlen($key) + 1));
} }
} }
} }
@@ -332,8 +332,8 @@ class Conductor
/** /**
* Run the conductor on a Model with the data stored in a Request. * Run the conductor on a Model with the data stored in a Request.
* *
* @param mixed $fields The fields to show. * @param mixed $fields The fields to show.
* @param Model|null $model The model. * @param Model|null $model The model.
* @return array The processed and transformed model data. * @return array The processed and transformed model data.
*/ */
final public static function model(mixed $fields, mixed $model) final public static function model(mixed $fields, mixed $model)
@@ -349,21 +349,21 @@ class Conductor
// Limit fields // Limit fields
$limitFields = $modelFields; $limitFields = $modelFields;
if($fields instanceof Request) { if ($fields instanceof Request) {
if ($fields !== null && $fields->has('fields') === true) { if ($fields !== null && $fields->has('fields') === true) {
$requestFields = $fields->input('fields'); $requestFields = $fields->input('fields');
if ($requestFields !== null) { if ($requestFields !== null) {
$limitFields = array_intersect(explode(',', $requestFields), $modelFields); $limitFields = array_intersect(explode(',', $requestFields), $modelFields);
} }
} }
} else if(is_array($fields) && count($fields) > 0) { } elseif (is_array($fields) && count($fields) > 0) {
$limitFields = array_intersect($fields, $modelFields); $limitFields = array_intersect($fields, $modelFields);
} }
if (empty($limitFields) === false) { if (empty($limitFields) === false) {
$modelAppends = $model->getAppends(); $modelAppends = $model->getAppends();
foreach(array_diff($modelFields, $limitFields) as $attribute) { foreach (array_diff($modelFields, $limitFields) as $attribute) {
$key = array_search($attribute, $modelAppends); $key = array_search($attribute, $modelAppends);
if ($key !== false) { if ($key !== false) {
unset($modelAppends[$key]); unset($modelAppends[$key]);
@@ -476,8 +476,8 @@ class Conductor
/** /**
* Paginate the conductor collection. * Paginate the conductor collection.
* *
* @param integer $page The current page to return. * @param integer $page The current page to return.
* @param integer $limit The limit of items to include or use default. * @param integer $limit The limit of items to include or use default.
* @param integer $offset Offset the page count after this count of rows. * @param integer $offset Offset the page count after this count of rows.
* @return void * @return void
*/ */
@@ -687,8 +687,8 @@ class Conductor
/** /**
* Return an array of model fields visible to the current user. * Return an array of model fields visible to the current user.
* *
* @param Model $model The model in question. * @param Model $model The model in question.
* @param bool $includes Include the includes in the result. * @param boolean $includes Include the includes in the result.
* @return array The array of field names. * @return array The array of field names.
*/ */
public function fields(Model $model) public function fields(Model $model)
@@ -727,7 +727,7 @@ class Conductor
$result[$key] = $this->$transformFunction($value); $result[$key] = $this->$transformFunction($value);
} }
} }
$result = $this->transformFinal($result); $result = $this->transformFinal($result);
return $result; return $result;
} }
@@ -743,7 +743,7 @@ class Conductor
$result = $model->toArray(); $result = $model->toArray();
$fields = $this->fields($model); $fields = $this->fields($model);
if (is_array($fields) === true) { if (is_array($fields) === true) {
$result = array_intersect_key($result, array_flip($fields)); $result = array_intersect_key($result, array_flip($fields));
} }

View File

@@ -4,6 +4,7 @@ namespace App\Conductors;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\User;
class MediaConductor extends Conductor class MediaConductor extends Conductor
{ {
@@ -19,6 +20,13 @@ class MediaConductor extends Conductor
*/ */
protected $sort = 'created_at'; protected $sort = 'created_at';
/**
* The included fields
*
* @var string[]
*/
protected $includes = ['user'];
/** /**
* Return an array of model fields visible to the current user. * Return an array of model fields visible to the current user.
@@ -106,4 +114,27 @@ class MediaConductor extends Conductor
$user = auth()->user(); $user = auth()->user();
return ($user !== null && ($model->user_id === $user->id || $user->hasPermission('admin/media') === true)); return ($user !== null && ($model->user_id === $user->id || $user->hasPermission('admin/media') === true));
} }
/**
* Transform the final model data
*
* @param array $data The model data to transform.
* @return array The transformed model.
*/
public function transformFinal(array $data)
{
unset($data['user_id']);
return $data;
}
/**
* Include User Field.
*
* @param Model $model Them model.
* @return mixed The model result.
*/
public function includeUser(Model $model)
{
return UserConductor::includeModel(request(), 'user', User::find($model['user_id']));
}
} }

View File

@@ -29,11 +29,12 @@ class PostConductor extends Conductor
/** /**
* The included fields * The included fields
* *
* @var string[] * @var string[]
*/ */
protected $includes = ['attachments', 'user']; protected $includes = ['attachments', 'user'];
/** /**
* Run a scope query on the collection before anything else. * Run a scope query on the collection before anything else.
* *
@@ -105,7 +106,7 @@ class PostConductor extends Conductor
/** /**
* Transform the final model data * Transform the final model data
* *
* @param Array $data The model data to transform. * @param array $data The model data to transform.
* @return array The transformed model. * @return array The transformed model.
*/ */
public function transformFinal(array $data) public function transformFinal(array $data)
@@ -116,7 +117,7 @@ class PostConductor extends Conductor
/** /**
* Include Attachments Field. * Include Attachments Field.
* *
* @param Model $model Them model. * @param Model $model Them model.
* @return mixed The model result. * @return mixed The model result.
*/ */
@@ -129,7 +130,7 @@ class PostConductor extends Conductor
/** /**
* Include User Field. * Include User Field.
* *
* @param Model $model Them model. * @param Model $model Them model.
* @return mixed The model result. * @return mixed The model result.
*/ */
@@ -140,7 +141,7 @@ class PostConductor extends Conductor
/** /**
* Transform the Hero field. * Transform the Hero field.
* *
* @param mixed $value The current value. * @param mixed $value The current value.
* @return array The new value. * @return array The new value.
*/ */

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Console\Commands;
use App\Jobs\StoreUploadedFileJob;
use Illuminate\Console\Command;
use App\Models\Media;
use File;
class MigrateUploads extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'uploads:migrate';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Migrate the uploads folder to the CDN';
/**
* Execute the console command.
*
* @return void
*/
public function handle()
{
$files = File::allFiles(public_path('uploads'));
foreach ($files as $file) {
$filename = pathinfo($file, PATHINFO_BASENAME);
// echo $filename . "\n";
$medium = Media::where('name', $filename)->first();
if ($medium !== null) {
$medium->update(['status' => 'Processing media']);
StoreUploadedFileJob::dispatch($medium, $file)->onQueue('media');
} else {
unlink($file);
}
}
}
}

View File

@@ -137,6 +137,7 @@ class ApiController extends Controller
protected function respondAsResource( protected function respondAsResource(
mixed $data, mixed $data,
array $options = [], array $options = [],
$validationFn = null
) { ) {
$isCollection = $options['isCollection'] ?? false; $isCollection = $options['isCollection'] ?? false;
$appendData = $options['appendData'] ?? null; $appendData = $options['appendData'] ?? null;
@@ -144,7 +145,14 @@ class ApiController extends Controller
$respondCode = ($options['respondCode'] ?? HttpResponseCodes::HTTP_OK); $respondCode = ($options['respondCode'] ?? HttpResponseCodes::HTTP_OK);
if ($data === null || ($data instanceof Collection && $data->count() === 0)) { if ($data === null || ($data instanceof Collection && $data->count() === 0)) {
return $this->respondNotFound(); $validationData = [];
if (array_key_exists('appendData', $options) === true) {
$validationData = $options['appendData'];
}
if ($validationFn === null || $validationFn($validationData) === true) {
return $this->respondNotFound();
}
} }
if (is_null($resourceName) === true || empty($resourceName) === true) { if (is_null($resourceName) === true || empty($resourceName) === true) {

View File

@@ -35,7 +35,10 @@ class MediaController extends ApiController
$collection, $collection,
['isCollection' => true, ['isCollection' => true,
'appendData' => ['total' => $total] 'appendData' => ['total' => $total]
] ],
function ($options) {
return $options['total'] === 0;
}
); );
} }

View File

@@ -14,6 +14,9 @@ return new class extends Migration
*/ */
public function up() public function up()
{ {
DB::table('media')->whereNull('mime')->update(['mime' => '']);
DB::table('media')->whereNull('permission')->update(['permission' => '']);
Schema::table('media', function (Blueprint $table) { Schema::table('media', function (Blueprint $table) {
$table->string('storage')->default("cdn"); $table->string('storage')->default("cdn");
$table->string('description')->default(""); $table->string('description')->default("");
@@ -21,18 +24,11 @@ return new class extends Migration
$table->string('dimensions')->default(""); $table->string('dimensions')->default("");
$table->text('variants'); $table->text('variants');
// Update null 'mime' values to empty strings
DB::table('media')->whereNull('mime')->update(['mime' => '']);
// // Update null 'permission' values to empty strings
DB::table('media')->whereNull('permission')->update(['permission' => '']);
$table->bigInteger('size')->default(0)->change(); $table->bigInteger('size')->default(0)->change();
$table->string('permission')->default("")->nullable(false)->change(); $table->string('permission')->default("")->nullable(false)->change();
$table->string('mime')->default("")->nullable(false)->change(); $table->string('mime')->default("")->nullable(false)->change();
// $table->renameColumn('mime', 'mime_type'); $table->renameColumn('mime', 'mime_type');
$table->string('mime_type');
}); });
} }

View File

@@ -9,8 +9,8 @@
<td class="attachment-file-icon"> <td class="attachment-file-icon">
<img <img
:src="getFileIconImagePath(file.name || file.title)" :src="getFileIconImagePath(file.name || file.title)"
height="48" height="40"
width="48" /> width="40" />
</td> </td>
<td class="attachment-file-name"> <td class="attachment-file-name">
<a :href="file.url">{{ file.title || file.name }}</a> <a :href="file.url">{{ file.title || file.name }}</a>
@@ -57,15 +57,16 @@ const props = defineProps({
<style lang="scss"> <style lang="scss">
.attachment-list { .attachment-list {
border: 1px solid $secondary-color;
border-collapse: collapse; border-collapse: collapse;
table-layout: fixed; table-layout: fixed;
width: 100%; width: 100%;
max-width: 580px; // max-width: 580px;
margin-top: 12px; margin-top: 12px;
background-color: var(--base-color-light);
.attachment-row { .attachment-row {
td { td {
border-bottom: 1px solid $secondary-background-color;
padding: 8px 0; padding: 8px 0;
} }
@@ -74,7 +75,8 @@ const props = defineProps({
} }
.attachment-file-icon { .attachment-file-icon {
width: 64px; width: 56px;
padding-left: 8px;
img { img {
display: block; display: block;
@@ -82,9 +84,18 @@ const props = defineProps({
} }
.attachment-file-name { .attachment-file-name {
font-size: 80%;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
} }
.attachment-download { .attachment-download {
@@ -109,11 +120,12 @@ const props = defineProps({
} }
.attachment-file-size { .attachment-file-size {
width: 64px; width: 80px;
font-size: 75%; font-size: 75%;
color: $secondary-color-dark; color: $secondary-color-dark;
white-space: nowrap; white-space: nowrap;
text-align: right; text-align: right;
padding-right: 8px;
} }
} }
} }

View File

@@ -80,7 +80,7 @@ import SMLoadingIcon from "./SMLoadingIcon.vue";
const props = defineProps({ const props = defineProps({
label: { type: String, default: "Button", required: false }, label: { type: String, default: "Button", required: false },
type: { type: String, default: "secondary", required: false }, type: { type: String, default: "", required: false },
icon: { icon: {
type: String, type: String,
default: "", default: "",
@@ -193,6 +193,7 @@ const handleClickItem = (item: string) => {
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
user-select: none; user-select: none;
white-space: nowrap;
.button-label { .button-label {
display: inline-block; display: inline-block;
@@ -254,6 +255,10 @@ const handleClickItem = (item: string) => {
cursor: pointer; cursor: pointer;
} }
&:hover:not(:disabled):not(.button-dropdown) {
filter: brightness(115%);
}
&:hover:disabled { &:hover:disabled {
cursor: not-allowed; cursor: not-allowed;
} }
@@ -285,5 +290,9 @@ const handleClickItem = (item: string) => {
background-color: var(--primary-color); background-color: var(--primary-color);
color: var(--base-color); color: var(--base-color);
} }
&.secondary {
background-color: #ccc;
}
} }
</style> </style>

View File

@@ -0,0 +1,188 @@
<template>
<SMControl class="control-type-checkbox">
<div :class="['control-item', { disabled: disabled }]">
<label class="control-label" v-bind="{ for: id }"
><input
type="checkbox"
class="checkbox-control"
:checked="props.modelValue"
@blur="handleBlur"
@input="handleInput" />
<span class="checkbox-control-box">
<span class="checkbox-control-tick"></span>
</span>
{{ label }}</label
>
</div>
{{ id }}
<template v-if="slots.help" #help><slot name="help"></slot></template>
</SMControl>
</template>
<script setup lang="ts">
import { inject, watch, ref, useSlots } from "vue";
import { isEmpty } from "../helpers/utils";
import { toTitleCase } from "../helpers/string";
import SMControl from "./SMControl.vue";
const emits = defineEmits(["update:modelValue", "blur"]);
const props = defineProps({
form: {
type: Object,
default: undefined,
required: false,
},
control: {
type: [String, Object],
default: "",
},
label: {
type: String,
default: undefined,
required: false,
},
modelValue: {
type: Boolean,
default: false,
required: true,
},
id: {
type: String,
default: undefined,
required: false,
},
disabled: {
type: Boolean,
default: false,
required: false,
},
});
const slots = useSlots();
const form = inject("form", props.form);
const control =
typeof props.control === "object"
? props.control
: !isEmpty(form) &&
typeof props.control === "string" &&
props.control !== "" &&
Object.prototype.hasOwnProperty.call(form.controls, props.control)
? form.controls[props.control]
: null;
const label = ref(
props.label != undefined
? props.label
: typeof props.control == "string"
? toTitleCase(props.control)
: ""
);
const value = ref(
props.modelValue != undefined
? props.modelValue
: control != null
? control.value
: ""
);
const id = ref(
props.id != undefined
? props.id
: typeof props.control == "string" && props.control.length > 0
? props.control
: null
);
const disabled = ref(props.disabled);
if (typeof control === "object" && control !== null) {
watch(
() => control.value,
(newValue) => {
value.value = newValue;
},
{ deep: true }
);
}
if (form) {
watch(
() => form.loading(),
(newValue) => {
disabled.value = newValue;
}
);
}
const handleBlur = async () => {
emits("blur");
};
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement;
value.value = target.checked;
emits("update:modelValue", target.checked);
if (control) {
control.value = target.checked;
}
};
</script>
<style lang="scss">
.control-group.control-type-checkbox {
.control-row {
.control-item {
.control-label {
display: flex;
align-items: center;
height: 24px;
padding-left: 32px;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
.checkbox-control {
opacity: 0;
width: 0;
height: 0;
&:checked + .checkbox-control-box {
.checkbox-control-tick {
display: block;
}
}
}
.checkbox-control-box {
position: absolute;
top: 0;
left: 0;
width: 24px;
height: 24px;
border: 1px solid var(--base-color-darker);
border-radius: 2px;
background-color: var(--base-color-light);
.checkbox-control-tick {
position: absolute;
display: none;
border-right: 3px solid var(--base-color-text);
border-bottom: 3px solid var(--base-color-text);
top: 1px;
left: 7px;
width: 8px;
height: 16px;
transform: rotate(45deg);
}
}
}
&.disabled label {
cursor: not-allowed;
}
}
}
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<div :class="['control-group', { 'control-invalid': invalid.length > 0 }]">
<div class="control-row">
<slot></slot>
</div>
<div v-if="slots.help || invalid" class="control-help">
<span v-if="invalid" class="control-feedback">
{{ invalid }}
</span>
<span v-if="slots.help"><slot name="help"></slot></span>
</div>
</div>
</template>
<script setup lang="ts">
import { watch, ref, useSlots } from "vue";
const props = defineProps({
invalid: {
type: String,
default: "",
required: false,
},
});
const slots = useSlots();
const invalid = ref(props.invalid);
watch(
() => props.invalid,
(newValue) => {
invalid.value = newValue;
}
);
</script>
<style lang="scss">
.control-group {
margin-bottom: 16px;
width: 100%;
.control-row {
display: flex;
align-items: center;
margin-bottom: 8px;
.control-item {
display: flex;
flex: 1;
position: relative;
align-items: center;
.control-label {
// position: absolute;
// display: block;
// transform-origin: top left;
// transform: translate(16px, 16px) scale(1);
// transition: all 0.1s ease-in-out;
// color: var(--base-color-darker);
// pointer-events: none;
}
}
}
.control-help {
display: block;
font-size: 70%;
margin-bottom: 8px;
.control-feedback {
color: var(--danger-color);
}
span + span:before {
content: "-";
margin: 0 6px;
}
}
}
</style>

View File

@@ -110,5 +110,12 @@ export function openDialog<C extends Component>(
wrapper, wrapper,
resolve, resolve,
}); });
window.setTimeout(() => {
const autofocusElement = document.querySelector("[autofocus]");
if (autofocusElement) {
autofocusElement.focus();
}
}, 10);
}); });
} }

View File

@@ -609,8 +609,6 @@ const imageBrowser = (callback, value, meta, gallery = false) => {
} }
}; };
console.log(selected);
const rightBtn = document.createElement("div"); const rightBtn = document.createElement("div");
rightBtn.classList.add("image-gallery-content-item-right"); rightBtn.classList.add("image-gallery-content-item-right");
rightBtn.onclick = function () { rightBtn.onclick = function () {

View File

@@ -48,6 +48,10 @@ const slots = useSlots();
max-width: 960px; max-width: 960px;
} }
.body .row {
margin: 0 auto;
}
.footer { .footer {
margin-top: 32px; margin-top: 32px;
display: flex; display: flex;

View File

@@ -1,55 +1,45 @@
<template> <template>
<div <SMControl
:class="[ :class="['control-type-input', { 'input-active': active }]"
'input-control-group', :invalid="feedbackInvalid">
{ 'input-active': active, 'input-invalid': feedbackInvalid }, <div v-if="slots.prepend" class="input-control-prepend">
]"> <slot name="prepend"></slot>
<div class="input-control-row">
<div v-if="slots.prepend" class="input-control-prepend">
<slot name="prepend"></slot>
</div>
<div class="input-control-item">
<label class="input-label" v-bind="{ for: id }">{{
label
}}</label>
<ion-icon
class="invalid-icon"
name="alert-circle-outline"></ion-icon>
<ion-icon
v-if="
props.showClear && value?.length > 0 && !feedbackInvalid
"
class="clear-icon"
name="close-outline"
@click.stop="handleClear"></ion-icon>
<input
:type="props.type"
class="input-control"
:disabled="disabled"
v-bind="{ id: id, autofocus: props.autofocus }"
v-model="value"
@focus="handleFocus"
@blur="handleBlur"
@input="handleInput"
@keyup="handleKeyup" />
</div>
<div v-if="slots.append" class="input-control-append">
<slot name="append"></slot>
</div>
</div> </div>
<div v-if="slots.default || feedbackInvalid" class="input-help"> <div class="control-item">
<span v-if="feedbackInvalid" class="input-invalid"> <label class="control-label" v-bind="{ for: id }">{{
{{ feedbackInvalid }} label
</span> }}</label>
<span v-if="slots.default"><slot></slot></span> <ion-icon
class="invalid-icon"
name="alert-circle-outline"></ion-icon>
<ion-icon
v-if="props.showClear && value?.length > 0 && !feedbackInvalid"
class="clear-icon"
name="close-outline"
@click.stop="handleClear"></ion-icon>
<input
:type="props.type"
class="input-control"
:disabled="disabled"
v-bind="{ id: id, autofocus: props.autofocus }"
v-model="value"
@focus="handleFocus"
@blur="handleBlur"
@input="handleInput"
@keyup="handleKeyup" />
</div> </div>
</div> <div v-if="slots.append" class="input-control-append">
<slot name="append"></slot>
</div>
<template v-if="slots.help" #help><slot name="help"></slot></template>
</SMControl>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { inject, watch, ref, useSlots } from "vue"; import { inject, watch, ref, useSlots } from "vue";
import { isEmpty } from "../helpers/utils"; import { isEmpty } from "../helpers/utils";
import { toTitleCase } from "../helpers/string"; import { toTitleCase } from "../helpers/string";
import SMControl from "./SMControl.vue";
const emits = defineEmits(["update:modelValue", "blur", "keyup"]); const emits = defineEmits(["update:modelValue", "blur", "keyup"]);
const props = defineProps({ const props = defineProps({
@@ -225,19 +215,16 @@ const handleKeyup = (event: Event) => {
const handleClear = () => { const handleClear = () => {
value.value = ""; value.value = "";
emits("update:modelValue", ""); emits("update:modelValue", "");
emits("change"); // emits("change");
}; };
</script> </script>
<style lang="scss"> <style lang="scss">
.input-control-group { .control-group.control-type-input {
margin-bottom: 16px; .control-row {
width: 100%; .control-item {
align-items: start;
.input-control-row { }
display: flex;
align-items: center;
margin-bottom: 8px;
.input-control-prepend { .input-control-prepend {
p { p {
@@ -258,7 +245,7 @@ const handleClear = () => {
border-radius: 8px 0 0 8px; border-radius: 8px 0 0 8px;
} }
& + .input-control-item .input-control { & + .control-item .input-control {
border-top-left-radius: 0; border-top-left-radius: 0;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
} }
@@ -284,18 +271,15 @@ const handleClear = () => {
} }
} }
&:has(.input-control-item + .input-control-append) &:has(.control-item + .input-control-append)
> .input-control-item > .control-item
.input-control { .input-control {
border-top-right-radius: 0; border-top-right-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
} }
.input-control-item { .control-item {
flex: 1; .control-label {
position: relative;
.input-label {
position: absolute; position: absolute;
display: block; display: block;
transform-origin: top left; transform-origin: top left;
@@ -345,29 +329,14 @@ const handleClear = () => {
} }
} }
.input-help {
display: block;
font-size: 70%;
margin-bottom: 8px;
.input-invalid {
color: var(--danger-color);
}
span + span:before {
content: "-";
margin: 0 6px;
}
}
&.input-active { &.input-active {
.input-control-item .input-label { .control-item .control-label {
transform: translate(16px, 6px) scale(0.7); transform: translate(16px, 6px) scale(0.7);
} }
} }
&.input-invalid { &.control-invalid {
.input-control-row .input-control-item { .control-row .control-item {
.invalid-icon { .invalid-icon {
display: block; display: block;
} }

View File

@@ -1,7 +1,6 @@
<template> <template>
<div class="pagination"> <div class="pagination">
<div <div
v-if="props.modelValue > 1"
:class="[ :class="[
'item', 'item',
'previous', 'previous',
@@ -9,17 +8,16 @@
]" ]"
@click="handleClickPrev"> @click="handleClickPrev">
<ion-icon name="chevron-back-outline" /> <ion-icon name="chevron-back-outline" />
Previous Prev
</div> </div>
<div <div
:class="['item', { active: page == props.modelValue }]" :class="['item', 'page', { active: page == props.modelValue }]"
v-for="(page, idx) of computedPages" v-for="(page, idx) of computedPages"
:key="idx" :key="idx"
@click="handleClickPage(page)"> @click="handleClickPage(page)">
{{ page }} {{ page }}
</div> </div>
<div <div
v-if="(props.modelValue + 3) * props.perPage <= props.total"
:class="['item', 'next', { disabled: computedDisableNextButton }]" :class="['item', 'next', { disabled: computedDisableNextButton }]"
@click="handleClickNext"> @click="handleClickNext">
Next Next
@@ -29,6 +27,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { unwatchFile } from "fs";
import { computed } from "vue"; import { computed } from "vue";
const props = defineProps({ const props = defineProps({
@@ -54,22 +53,27 @@ const emits = defineEmits(["update:modelValue"]);
const computedPages = computed(() => { const computedPages = computed(() => {
let pages = []; let pages = [];
if (props.modelValue - 2 > 0) { let pagesRemaining =
pages.push(props.modelValue - 2); Math.ceil(props.total / props.perPage) - props.modelValue;
let pagesBefore = Math.max(0, props.modelValue - 1);
if (pagesRemaining + pagesBefore > 4) {
if (pagesRemaining < 2) {
pagesBefore = Math.min(pagesBefore, 4 - pagesRemaining);
} else if (pagesBefore < 2) {
pagesRemaining = Math.min(pagesRemaining, 4 - pagesBefore);
} else {
pagesRemaining = 2;
pagesBefore = 2;
}
} }
if (props.modelValue - 1 > 0) { for (; pagesBefore > 0; pagesBefore--) {
pages.push(props.modelValue - 1); pages.push(props.modelValue - pagesBefore);
} }
pages.push(props.modelValue); pages.push(props.modelValue);
for (let i = 1; i <= pagesRemaining; i++) {
if (props.perPage * (props.modelValue + 1) <= props.total) { pages.push(props.modelValue + i);
pages.push(props.modelValue + 1);
}
if (props.perPage * (props.modelValue + 2) <= props.total) {
pages.push(props.modelValue + 2);
} }
return pages; return pages;
@@ -100,14 +104,18 @@ const computedDisableNextButton = computed(() => {
* Handle click on previous button * Handle click on previous button
*/ */
const handleClickPrev = (): void => { const handleClickPrev = (): void => {
emits("update:modelValue", props.modelValue - 1); if (computedDisablePrevButton.value == false) {
emits("update:modelValue", props.modelValue - 1);
}
}; };
/** /**
* Handle click on next button * Handle click on next button
*/ */
const handleClickNext = (): void => { const handleClickNext = (): void => {
emits("update:modelValue", props.modelValue + 1); if (computedDisableNextButton.value == false) {
emits("update:modelValue", props.modelValue + 1);
}
}; };
/** /**
@@ -118,6 +126,15 @@ const handleClickNext = (): void => {
const handleClickPage = (page: number): void => { const handleClickPage = (page: number): void => {
emits("update:modelValue", page); emits("update:modelValue", page);
}; };
if (props.modelValue < 1) {
emits("update:modelValue", 1);
} else {
const totalPages = computedTotalPages.value;
if (totalPages < props.modelValue) {
emits("update:modelValue", totalPages);
}
}
</script> </script>
<style lang="scss"> <style lang="scss">
@@ -138,6 +155,11 @@ const handleClickPage = (page: number): void => {
padding: 12px 16px; padding: 12px 16px;
border-right: 1px solid rgba(0, 0, 0, 0.1); border-right: 1px solid rgba(0, 0, 0, 0.1);
&.page {
width: 44px;
justify-content: center;
}
&.active { &.active {
background-color: var(--primary-color); background-color: var(--primary-color);
} }
@@ -158,9 +180,15 @@ const handleClickPage = (page: number): void => {
padding-left: 12px; padding-left: 12px;
} }
&:hover:not(.active) { &:hover:not(.active):not(.disabled) {
background-color: var(--primary-color-hover); background-color: var(--primary-color-hover);
} }
&.disabled {
cursor: not-allowed;
color: var(--base-color-darker);
background-color: var(--base-color);
}
} }
} }

View File

@@ -24,7 +24,7 @@ defineProps({
.row { .row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
margin: 0 auto; margin: 8px auto;
align-items: top; align-items: top;
width: 100%; width: 100%;
max-width: 1200px; max-width: 1200px;

View File

@@ -22,7 +22,13 @@
v-bind="item as any"> v-bind="item as any">
</slot> </slot>
</template> </template>
<template v-else>{{ item[header["value"]] }}</template> <template v-else>
{{
header["value"]
.split(".")
.reduce((item, key) => item[key], item)
}}
</template>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -69,6 +75,7 @@ const handleRowClick = (item) => {
th { th {
font-size: 90%; font-size: 90%;
white-space: nowrap;
} }
td { td {

View File

@@ -9,7 +9,7 @@
display: flex; display: flex;
width: 100%; width: 100%;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: flex-start;
gap: 20px; gap: 20px;
} }
</style> </style>

View File

@@ -1,22 +1,23 @@
<template> <template>
<SMFormCard :loading="dialogLoading"> <SMForm :model-value="form" @submit="handleSubmit">
<h1>Change Password</h1> <SMFormCard :loading="dialogLoading">
<p class="text-center">Enter your new password below</p> <template #header>
<SMForm :model-value="form" @submit="handleSubmit"> <h3>Change Password</h3>
<SMInput control="password" type="password" label="New Password" /> <p>Enter your new password below</p>
<SMFormFooter> </template>
<template #left> <template #body>
<SMButton <SMInput
type="secondary" control="password"
label="Cancel" type="password"
@click="handleClickCancel" /> label="New Password"
</template> autofocus />
<template #right> </template>
<SMButton type="submit" label="Update" /> <template #footer-space-between>
</template> <SMButton label="Cancel" @click="handleClickCancel" />
</SMFormFooter> <SMButton type="submit" label="Update" />
</SMForm> </template>
</SMFormCard> </SMFormCard>
</SMForm>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,3 +1,5 @@
import { Router } from "vue-router";
export const urlStripAttributes = (url: string): string => { export const urlStripAttributes = (url: string): string => {
const urlObject = new URL(url); const urlObject = new URL(url);
urlObject.search = ""; urlObject.search = "";
@@ -73,3 +75,23 @@ export const urlMatches = (
} }
} }
}; };
interface Params {
[key: string]: string;
}
export const updateRouterParams = (router: Router, params: Params): void => {
const query = { ...router.currentRoute.value.query };
Object.entries(params).forEach(([key, value]) => {
if (value === "") {
if (key in params) {
delete query[key];
}
} else {
query[key] = value;
}
});
router.push({ query });
};

View File

@@ -456,7 +456,10 @@ router.beforeEach(async (to, from, next) => {
// } // }
if (to.meta.middleware == "authenticated" && !userStore.id) { if (to.meta.middleware == "authenticated" && !userStore.id) {
next({ name: "login", query: { redirect: to.fullPath } }); next({
name: "login",
query: { redirect: encodeURIComponent(to.fullPath) },
});
} else { } else {
next(); next();
} }

View File

@@ -25,17 +25,19 @@ export interface UserState {
export const useUserStore = defineStore({ export const useUserStore = defineStore({
id: "user", id: "user",
state: (): UserState => ({ state: (): UserState => {
id: "", return {
token: "", id: "",
username: "", token: "",
firstName: "", username: "",
lastName: "", firstName: "",
displayName: "", lastName: "",
email: "", displayName: "",
phone: "", email: "",
permissions: [], phone: "",
}), permissions: [],
};
},
actions: { actions: {
async setUserDetails(user: UserDetails) { async setUserDetails(user: UserDetails) {
@@ -63,9 +65,6 @@ export const useUserStore = defineStore({
this.$state.email = null; this.$state.email = null;
this.$state.phone = null; this.$state.phone = null;
this.$state.permissions = []; this.$state.permissions = [];
this.$reset();
localStorage.removeItem(this.$id);
}, },
}, },

View File

@@ -236,7 +236,7 @@ import SMHero from "../components/SMHero.vue";
} }
} }
@media only screen and (max-width: 896px) { @media only screen and (max-width: 1000px) {
.page-home { .page-home {
.support { .support {
img { img {
@@ -245,7 +245,7 @@ import SMHero from "../components/SMHero.vue";
} }
.button-row { .button-row {
flex-direction: column; flex-direction: column;
gap: 20px; gap: 15px;
.button { .button {
text-align: center; text-align: center;

View File

@@ -83,7 +83,7 @@ const handleSubmit = async () => {
? redirectQuery[0] ? redirectQuery[0]
: redirectQuery; : redirectQuery;
router.push({ path: redirect }); router.push(decodeURIComponent(redirect));
} else { } else {
router.push({ name: "dashboard" }); router.push({ name: "dashboard" });
} }

View File

@@ -11,7 +11,7 @@
:to="{ name: 'dashboard-post-list' }" :to="{ name: 'dashboard-post-list' }"
class="admin-card posts"> class="admin-card posts">
<ion-icon name="newspaper-outline" /> <ion-icon name="newspaper-outline" />
<h3>Posts</h3> <h3>Articles</h3>
</router-link> </router-link>
<router-link <router-link
v-if="userStore.permissions.includes('admin/users')" v-if="userStore.permissions.includes('admin/users')"

View File

@@ -3,180 +3,137 @@
title="Media" title="Media"
:back-link="{ name: 'dashboard' }" :back-link="{ name: 'dashboard' }"
back-title="Return to Dashboard" /> back-title="Return to Dashboard" />
<SMMessage <SMContainer class="flex-grow-1">
v-if="formMessage.message"
:type="formMessage.type"
:message="formMessage.message"
:icon="formMessage.icon" />
<SMContainer>
<SMToolbar> <SMToolbar>
<template #left> <SMButton
<SMButton :to="{ name: 'workshops' }"
:to="{ name: 'workshops' }" type="primary"
type="primary" label="Upload Media" />
label="Upload Media" /> <SMInput
</template> v-model="search"
<template #right> label="Search"
<SMInput style="max-width: 350px"
v-model="search" @keyup.enter="handleClickSearch">
label="Search" <template #append>
:small="true" <SMButton
style="max-width: 350px" type="primary"
:show-clear="true"> label="Search"
<template #append> icon="search-outline"
<SMButton @click="handleClickSearch" />
type="primary" </template>
label="Search" </SMInput>
icon="search-outline"
@click="handleClickSearch" />
</template>
</SMInput>
</template>
</SMToolbar> </SMToolbar>
<SMLoading large v-if="pageLoading" />
<SMPagination <template v-else>
:model-value="pageValue" <SMPagination
:total="total" v-if="items.length < totalFound"
:per-page="perPage" /> v-model="page"
<SMTable :headers="headers" :items="items" @row-click="handleRowClick"> :total="totalFound"
<template #item-size="item"> :per-page="perPage" />
{{ bytesReadable(item.size) }} <SMNoItems v-if="items.length == 0" text="No Media Found" />
</template> <SMTable
<template #item-actions="item"> v-else
<SMButton :headers="headers"
label="Edit" :items="items"
:dropdown="{ @row-click="handleEdit">
download: 'Download', <template #item-size="item">
delete: 'Delete', {{ bytesReadable(item.size) }}
}" </template>
size="medium" <template #item-actions="item">
@click="handleClick(item, $event)"></SMButton> <SMButton
</template> label="Edit"
</SMTable> :dropdown="{
download: 'Download',
delete: 'Delete',
}"
size="medium"
@click="handleClick(item, $event)"></SMButton>
</template>
</SMTable>
</template>
</SMContainer> </SMContainer>
<!-- @click-row="handleClickRow" -->
<!-- <EasyDataTable
v-model:server-options="serverOptions"
:server-items-length="serverItemsLength"
:loading="formLoading"
:headers="headers"
:items="items"
:search-value="search">
<template #loading>
<SMLoadingIcon />
</template>
<template #item-size="item">
{{ bytesReadable(item.size) }}
</template>
<template #item-actions="item">
<div class="action-wrapper">
<SMButton
label="Edit"
:dropdown="{
download: 'Download',
delete: 'Delete',
}"
size="medium"
@click="handleClick(item, $event)"></SMButton>
</div>
</template>
</EasyDataTable> -->
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, watch } from "vue"; import { ref, watch } from "vue";
import { useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import EasyDataTable from "vue3-easy-data-table";
import { openDialog } from "../../components/SMDialog"; import { openDialog } from "../../components/SMDialog";
import SMDialogConfirm from "../../components/dialogs/SMDialogConfirm.vue"; import SMDialogConfirm from "../../components/dialogs/SMDialogConfirm.vue";
import SMButton from "../../components/SMButton.vue"; import SMButton from "../../components/SMButton.vue";
import SMFileLink from "../../components/SMFileLink.vue";
import SMLoadingIcon from "../../components/SMLoadingIcon.vue";
import SMMessage from "../../components/SMMessage.vue";
import SMToolbar from "../../components/SMToolbar.vue"; import SMToolbar from "../../components/SMToolbar.vue";
import { api } from "../../helpers/api"; import { api } from "../../helpers/api";
import { Media, UserResponse } from "../../helpers/api.types"; import { Media } from "../../helpers/api.types";
import { SMDate } from "../../helpers/datetime"; import { SMDate } from "../../helpers/datetime";
import { debounce } from "../../helpers/debounce";
import { bytesReadable } from "../../helpers/types"; import { bytesReadable } from "../../helpers/types";
import { useUserStore } from "../../store/UserStore";
import { useToastStore } from "../../store/ToastStore"; import { useToastStore } from "../../store/ToastStore";
import SMInput from "../../components/SMInput.vue"; import SMInput from "../../components/SMInput.vue";
import SMMastHead from "../../components/SMMastHead.vue"; import SMMastHead from "../../components/SMMastHead.vue";
import SMTable from "../../components/SMTable.vue"; import SMTable from "../../components/SMTable.vue";
import SMPagination from "../../components/SMPagination.vue"; import SMPagination from "../../components/SMPagination.vue";
import SMNoItems from "../../components/SMNoItems.vue";
import SMLoading from "../../components/SMLoading.vue";
import { updateRouterParams } from "../../helpers/url";
const route = useRoute();
const router = useRouter(); const router = useRouter();
const search = ref("");
const userStore = useUserStore();
const toastStore = useToastStore(); const toastStore = useToastStore();
const pageLoading = ref(true);
const search = ref(route.query.search || "");
const items = ref([]);
const totalFound = ref(0);
const perPage = 25;
const page = ref(parseInt((route.query.page as string) || "1"));
const headers = [ const headers = [
{ text: "Name", value: "title", sortable: true }, { text: "Name", value: "title", sortable: true },
{ text: "Size", value: "size", sortable: true }, { text: "Size", value: "size", sortable: true },
// { text: "Permission", value: "permission", sortable: true }, { text: "Uploaded By", value: "user.display_name", sortable: true },
{ text: "Uploaded By", value: "username", sortable: true },
{ text: "Created", value: "created_at", sortable: true },
// { text: "Updated", value: "updated_at", sortable: true },
{ text: "Actions", value: "actions" }, { text: "Actions", value: "actions" },
]; ];
const items = ref([]); const handleClickSearch = () => {
let users = {}; page.value = 1;
const formLoading = ref(false); handleLoad();
const formMessage = reactive({
message: "",
type: "error",
icon: "",
});
const serverItemsLength = ref(0);
const serverOptions = ref({
page: 1,
rowsPerPage: 25,
sortBy: null,
sortType: null,
});
const total = 108;
const perPage = 25;
const pageValue = ref(1);
const handleRowClick = (item) => {
alert(JSON.stringify(item));
}; };
const handleClick = (item, extra: string): void => { const handleClick = (item, extra: string): void => {
if (extra.length == 0) { if (extra.length == 0) {
handleEdit(item); handleEdit(item);
} else if (extra.toLowerCase() == "download") {
handleDownload(item);
} else if (extra.toLowerCase() == "delete") { } else if (extra.toLowerCase() == "delete") {
handleDelete(item); handleDelete(item);
} }
}; };
const loadFromServer = async () => { /**
formLoading.value = true; * Watch if page number changes.
formMessage.type = "error"; */
formMessage.icon = "alert-circle-outline"; watch(page, () => {
formMessage.message = ""; handleLoad();
});
const handleLoad = async () => {
pageLoading.value = true;
items.value = [];
totalFound.value = 0;
let routerParams = {
search: search.value as string,
page: page.value == 1 ? "" : page.value.toString(),
};
updateRouterParams(router, routerParams);
try { try {
let params = {}; let params = {
if (serverOptions.value.sortBy) { page: page.value,
params["sort"] = serverOptions.value.sortBy; limit: perPage,
if ( };
serverOptions.value.sortType &&
serverOptions.value.sortType === "desc"
) {
params["sort"] = "-" + params["sort"];
}
}
params["page"] = serverOptions.value.page;
params["limit"] = serverOptions.value.rowsPerPage;
if (search.value.length > 0) { if (search.value.length > 0) {
params["title"] = search.value; params[
"filter"
] = `title:${search.value},OR,name:${search.value},OR,description:${search.value}`;
} }
let res = await api.get({ let res = await api.get({
@@ -190,27 +147,6 @@ const loadFromServer = async () => {
items.value = []; items.value = [];
res.data.media.forEach(async (row) => { res.data.media.forEach(async (row) => {
if (Object.keys(users).includes(row.user_id) === false) {
try {
const userResult = await api.get({
url: "/users/{id}",
params: {
id: row.user_id,
},
});
const data = userResult.data as UserResponse;
users[row.user_id] = data.user.username;
} catch (error) {
users[row.user_id] = "Unknown";
}
}
if (Object.keys(users).includes(row.user_id)) {
row["username"] = users[row.user_id];
} else {
row["username"] = "--";
}
if (row.created_at !== "undefined") { if (row.created_at !== "undefined") {
row.created_at = new SMDate(row.created_at, { row.created_at = new SMDate(row.created_at, {
format: "ymd", format: "ymd",
@@ -227,32 +163,22 @@ const loadFromServer = async () => {
items.value.push(row); items.value.push(row);
}); });
serverItemsLength.value = res.data.total; totalFound.value = res.data.total;
} catch (err) { } catch (error) {
// formMessage.message = parseErrorTyp(err); if (error.status != 404) {
toastStore.addToast({
title: "Server Error",
content: error.message,
//"An error occurred retrieving the list from the server.",
type: "danger",
});
}
} finally { } finally {
formLoading.value = false; pageLoading.value = false;
} }
}; };
loadFromServer(); handleLoad();
watch(
serverOptions,
(value) => {
loadFromServer();
},
{ deep: true }
);
const debouncedFilter = debounce(loadFromServer, 1000);
watch(search, (value) => {
debouncedFilter();
});
const handleClickRow = (item) => {
router.push({ name: "dashboard-media-edit", params: { id: item.id } });
};
const handleEdit = (item) => { const handleEdit = (item) => {
router.push({ name: "dashboard-media-edit", params: { id: item.id } }); router.push({ name: "dashboard-media-edit", params: { id: item.id } });
@@ -291,7 +217,7 @@ const handleDelete = async (item: Media) => {
content: `The file ${item.title} has been deleted.`, content: `The file ${item.title} has been deleted.`,
type: "success", type: "success",
}); });
loadFromServer(); handleLoad();
} catch (error) { } catch (error) {
toastStore.addToast({ toastStore.addToast({
title: "Error Deleting File", title: "Error Deleting File",
@@ -305,18 +231,19 @@ const handleDelete = async (item: Media) => {
}; };
const handleDownload = (item) => { const handleDownload = (item) => {
window.open(item.url, "_blank"); window.open(`${item.url}?download=1`, "_blank");
}; };
</script> </script>
<style lang="scss"> <style lang="scss">
body[data-route-name="page-dashboard-media-list"] { .page-dashboard-media-list {
.table tr td:last-of-type { .table tr {
padding-left: 0; td:first-of-type {
text-align: center; word-break: break-all;
}
&:before { td:not(:first-of-type) {
display: none; white-space: nowrap;
} }
} }
} }

View File

@@ -1,8 +1,12 @@
<template> <template>
<SMMastHead <SMMastHead
:title="pageHeading" :title="pageHeading"
:back-link="{ name: 'dashboard' }" :back-link="
back-title="Back to Dashboard" /> route.params.id
? { name: 'dashboard-user-list' }
: { name: 'dashboard' }
"
:back-title="route.params.id ? 'Back to Users' : 'Back to Dashboard'" />
<SMContainer> <SMContainer>
<SMForm :model-value="form" @submit="handleSubmit"> <SMForm :model-value="form" @submit="handleSubmit">
<SMRow> <SMRow>
@@ -16,9 +20,35 @@
<SMRow> <SMRow>
<SMColumn><SMInput control="email" /></SMColumn> <SMColumn><SMInput control="email" /></SMColumn>
<SMColumn <SMColumn
><SMInput control="phone">This field is optional</SMInput> ><SMInput control="phone"
><template #help
>This field is optional</template
></SMInput
>
</SMColumn> </SMColumn>
</SMRow> </SMRow>
<template v-if="userStore.permissions.includes('admin/users')">
<SMRow
><SMColumn><h3>Permissions</h3></SMColumn></SMRow
>
<SMRow>
<SMColumn
><SMCheckbox
label="Edit Users"
v-model="permissions.users"
/></SMColumn>
<SMColumn
><SMCheckbox
label="Edit Posts"
v-model="permissions.users"
/></SMColumn>
<SMColumn
><SMCheckbox
label="Edit Events"
v-model="permissions.users"
/></SMColumn>
</SMRow>
</template>
<SMRow> <SMRow>
<SMColumn> <SMColumn>
<SMFormFooter> <SMFormFooter>
@@ -37,7 +67,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive } from "vue"; import { computed, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { openDialog } from "../../components/SMDialog"; import { openDialog } from "../../components/SMDialog";
import SMDialogChangePassword from "../../components/dialogs/SMDialogChangePassword.vue"; import SMDialogChangePassword from "../../components/dialogs/SMDialogChangePassword.vue";
@@ -52,6 +82,7 @@ import { And, Email, Phone, Required } from "../../helpers/validate";
import { useUserStore } from "../../store/UserStore"; import { useUserStore } from "../../store/UserStore";
import SMMastHead from "../../components/SMMastHead.vue"; import SMMastHead from "../../components/SMMastHead.vue";
import { useToastStore } from "../../store/ToastStore"; import { useToastStore } from "../../store/ToastStore";
import SMCheckbox from "../../components/SMCheckbox.vue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -68,6 +99,10 @@ let form = reactive(
}) })
); );
const permissions = ref({
users: false,
});
/** /**
* Load the page data. * Load the page data.
*/ */
@@ -163,7 +198,10 @@ loadData();
</script> </script>
<style lang="scss"> <style lang="scss">
.sm-page-user-edit { .page-dashboard-account-details {
background-color: #f8f8f8; h3 {
margin-top: 0;
margin-bottom: 16px;
}
} }
</style> </style>

View File

@@ -1,41 +1,44 @@
<template> <template>
<SMPage permission="admin/users"> <SMPage permission="admin/users">
<SMHeading heading="Users" /> <SMMastHead
title="Users"
:back-link="{ name: 'dashboard' }"
back-title="Return to Dashboard" />
<SMMessage <SMMessage
v-if="formMessage.message" v-if="formMessage.message"
:icon="formMessage.icon" :icon="formMessage.icon"
:type="formMessage.type" :type="formMessage.type"
:message="formMessage.message" /> :message="formMessage.message" />
<EasyDataTable
v-model:server-options="serverOptions" <SMContainer>
:server-items-length="serverItemsLength" <SMTable
:loading="formLoading" :headers="headers"
:headers="headers" :items="items"
:items="items" @row-click="handleRowClick">
:search-value="searchValue" <template #item-actions="item">
:header-item-class-name="headerItemClassNameFunction" <SMButton
:body-item-class-name="bodyItemClassNameFunction"> label="Edit"
<template #loading> :dropdown="{
<SMLoadingIcon /> download: 'Download',
</template> delete: 'Delete',
<template #item-actions="item"> }"
<div class="action-wrapper"></div> size="medium" />
</template> </template>
</EasyDataTable> </SMTable>
</SMContainer>
</SMPage> </SMPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, watch } from "vue"; import { reactive, ref, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import EasyDataTable from "vue3-easy-data-table";
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 SMHeading from "../../components/SMHeading.vue";
import SMLoadingIcon from "../../components/SMLoadingIcon.vue";
import SMMessage from "../../components/SMMessage.vue"; import SMMessage from "../../components/SMMessage.vue";
import { api } from "../../helpers/api"; import { api } from "../../helpers/api";
import { SMDate } from "../../helpers/datetime"; import { SMDate } from "../../helpers/datetime";
import SMTable from "../../components/SMTable.vue";
import SMMastHead from "../../components/SMMastHead.vue";
const router = useRouter(); const router = useRouter();
const searchValue = ref(""); const searchValue = ref("");
@@ -67,6 +70,10 @@ const serverOptions = ref({
sortType: null, sortType: null,
}); });
const handleRowClick = (item) => {
router.push({ name: "dashboard-user-edit", params: { id: item.id } });
};
const loadFromServer = async () => { const loadFromServer = async () => {
formLoading.value = true; formLoading.value = true;
formMessage.type = "error"; formMessage.type = "error";