Update various configuration files, components, and assets; enhance notification system and API endpoints; improve documentation and styles across the application.
This commit is contained in:
78
components/FontSizeStepper.vue
Normal file
78
components/FontSizeStepper.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: 16
|
||||
},
|
||||
min: {
|
||||
type: Number,
|
||||
default: 12
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 32
|
||||
},
|
||||
step: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const increment = () => {
|
||||
if (props.modelValue < props.max && !props.disabled) {
|
||||
emit('update:modelValue', props.modelValue + props.step)
|
||||
}
|
||||
}
|
||||
|
||||
const decrement = () => {
|
||||
if (props.modelValue > props.min && !props.disabled) {
|
||||
emit('update:modelValue', props.modelValue - props.step)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInput = (event) => {
|
||||
const value = parseInt(event.target.value) || props.min
|
||||
const clampedValue = Math.max(props.min, Math.min(props.max, value))
|
||||
emit('update:modelValue', clampedValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="decrement"
|
||||
:disabled="modelValue <= min || disabled"
|
||||
class="w-8 h-8 flex items-center justify-center border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<Icon name="ic:round-remove" class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<input
|
||||
:value="modelValue"
|
||||
@input="handleInput"
|
||||
:disabled="disabled"
|
||||
type="number"
|
||||
:min="min"
|
||||
:max="max"
|
||||
class="w-16 px-2 py-1 text-center border border-gray-300 dark:border-gray-600 rounded focus:border-primary dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
|
||||
<button
|
||||
@click="increment"
|
||||
:disabled="modelValue >= max || disabled"
|
||||
class="w-8 h-8 flex items-center justify-center border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
>
|
||||
<Icon name="ic:round-add" class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<span class="text-sm text-gray-500">px</span>
|
||||
</div>
|
||||
</template>
|
||||
105
components/Loading.vue
Normal file
105
components/Loading.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup>
|
||||
const { siteSettings, loading: siteSettingsLoading } = useSiteSettings();
|
||||
|
||||
const showMessage = ref(false);
|
||||
|
||||
setTimeout(() => {
|
||||
showMessage.value = true;
|
||||
}, 2000);
|
||||
|
||||
const refreshPage = () => {
|
||||
// hard refresh
|
||||
window.location.reload(true);
|
||||
};
|
||||
|
||||
// Fast loading logo - fetch during SSR to prevent hydration flash
|
||||
const { data: quickLoadingData } = await useLazyFetch(
|
||||
"/api/devtool/config/loading-logo",
|
||||
{
|
||||
default: () => ({
|
||||
data: {
|
||||
siteLoadingLogo: "",
|
||||
siteName: "Loading...",
|
||||
},
|
||||
}),
|
||||
transform: (response) =>
|
||||
response.data || {
|
||||
siteLoadingLogo: "",
|
||||
siteName: "Loading...",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const loadingLogoSrc = computed(() => {
|
||||
// First priority: Quick loading data if available
|
||||
if (quickLoadingData.value?.siteLoadingLogo) {
|
||||
return quickLoadingData.value.siteLoadingLogo;
|
||||
}
|
||||
|
||||
// Second priority: Full site settings if loaded
|
||||
if (!siteSettingsLoading.value && siteSettings.value.siteLoadingLogo) {
|
||||
return siteSettings.value.siteLoadingLogo;
|
||||
}
|
||||
|
||||
// Fallback: Default logo
|
||||
return "/img/logo/corradAF-logo.svg";
|
||||
});
|
||||
|
||||
// Get site name with fallback
|
||||
const getSiteName = () => {
|
||||
// First priority: Quick loading data
|
||||
if (quickLoadingData.value?.siteName) {
|
||||
return quickLoadingData.value.siteName;
|
||||
}
|
||||
|
||||
// Second priority: Full site settings
|
||||
if (!siteSettingsLoading.value && siteSettings.value.siteName) {
|
||||
return siteSettings.value.siteName;
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return "Loading...";
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rs-loading bg-white absolute z-50 top-0 left-0 w-full h-full">
|
||||
<div class="flex justify-center text-center items-center h-screen">
|
||||
<div>
|
||||
<div class="img-container flex justify-center items-center mb-5">
|
||||
<!-- Use custom loading logo if available, otherwise show single default logo -->
|
||||
<img
|
||||
src="@/assets/img/logo/lzs-logo.png"
|
||||
class="max-w-[180px] max-h-[60px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex justify-center items-center"
|
||||
aria-label="Loading..."
|
||||
role="status"
|
||||
>
|
||||
<svg class="h-14 w-14 animate-spin" viewBox="3 3 18 18">
|
||||
<path
|
||||
class="fill-[#00A59A]/10"
|
||||
d="M12 5C8.13401 5 5 8.13401 5 12C5 15.866 8.13401 19 12 19C15.866 19 19 15.866 19 12C19 8.13401 15.866 5 12 5ZM3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12Z"
|
||||
></path>
|
||||
<path
|
||||
class="fill-[#00A59A]"
|
||||
d="M16.9497 7.05015C14.2161 4.31648 9.78392 4.31648 7.05025 7.05015C6.65973 7.44067 6.02656 7.44067 5.63604 7.05015C5.24551 6.65962 5.24551 6.02646 5.63604 5.63593C9.15076 2.12121 14.8492 2.12121 18.364 5.63593C18.7545 6.02646 18.7545 6.65962 18.364 7.05015C17.9734 7.44067 17.3403 7.44067 16.9497 7.05015Z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div v-if="showMessage" class="my-10 text-gray-500 font-medium">
|
||||
If loading takes too long,
|
||||
<br />
|
||||
please click
|
||||
<button @click="refreshPage">
|
||||
<span class="text-[#F3586A]">here</span>
|
||||
</button>
|
||||
or hard refresh your browser.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
243
components/RSCalendar.vue
Normal file
243
components/RSCalendar.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<script setup>
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
const props = defineProps({
|
||||
events: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const dateNow = ref(DateTime.now());
|
||||
const arrDate = ref([]);
|
||||
|
||||
const dayInWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
const monthInYear = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
const label = (date) => {
|
||||
// console.log(date.toString());
|
||||
return date.toFormat("d");
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
dateNow.value = DateTime.local(
|
||||
dateNow.value.year,
|
||||
dateNow.value.month,
|
||||
dateNow.value.day
|
||||
).plus({
|
||||
months: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const prevMonth = () => {
|
||||
dateNow.value = DateTime.local(
|
||||
dateNow.value.year,
|
||||
dateNow.value.month,
|
||||
dateNow.value.day
|
||||
).minus({
|
||||
months: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const getDateInMonth = () => {
|
||||
const date = DateTime.local(dateNow.value.year, dateNow.value.month);
|
||||
const dayInMonth = date.daysInMonth;
|
||||
|
||||
arrDate.value = [];
|
||||
|
||||
for (let i = 0; i < dayInMonth; i++) {
|
||||
const day = i + 1;
|
||||
const date = DateTime.local(dateNow.value.year, dateNow.value.month, day);
|
||||
const toDay = date.toFormat("ccc");
|
||||
// Start of the Date
|
||||
if (i == 0) {
|
||||
const toDayIndex = dayInWeek.indexOf(toDay);
|
||||
|
||||
for (let i = 0; i < toDayIndex; i++) {
|
||||
const dayBefore = toDayIndex - i;
|
||||
const dateBefore = date.minus({ days: dayBefore });
|
||||
|
||||
updateArrDate(dateBefore, false, false);
|
||||
}
|
||||
if (DateTime.now().toISODate() == date.toISODate())
|
||||
updateArrDate(date, true, true);
|
||||
else updateArrDate(date, true, false);
|
||||
}
|
||||
// End of the Date
|
||||
else if (i == dayInMonth - 1) {
|
||||
const toDayIndex = dayInWeek.length - dayInWeek.indexOf(toDay) - 1;
|
||||
|
||||
if (DateTime.now().toISODate() == date.toISODate())
|
||||
updateArrDate(date, true, true);
|
||||
else updateArrDate(date, true, false);
|
||||
|
||||
for (let i = 0; i < toDayIndex; i++) {
|
||||
const dayAfter = i + 1;
|
||||
const dateAfter = date.plus({ days: dayAfter });
|
||||
|
||||
updateArrDate(dateAfter, false, false);
|
||||
}
|
||||
} else {
|
||||
if (DateTime.now().toISODate() == date.toISODate())
|
||||
updateArrDate(date, true, true);
|
||||
else updateArrDate(date, true, false);
|
||||
}
|
||||
}
|
||||
|
||||
// console.log(arrDate.value);
|
||||
};
|
||||
|
||||
const updateArrDate = (date, currentMonth, isToday) => {
|
||||
arrDate.value.push({
|
||||
date: date,
|
||||
isCurrentMonth: currentMonth,
|
||||
isToday: isToday,
|
||||
event: checkEvent(date),
|
||||
});
|
||||
};
|
||||
|
||||
// Check props.event start and end date with date given
|
||||
const checkEvent = (date) => {
|
||||
let result = false;
|
||||
props.events.forEach((event) => {
|
||||
if (event.startDate) {
|
||||
let startDate = "";
|
||||
let endDate = "";
|
||||
startDate = DateTime.fromISO(event.startDate);
|
||||
if (event.endDate) {
|
||||
endDate = DateTime.fromISO(event.endDate);
|
||||
}
|
||||
if (date.toISODate() == startDate.toISODate())
|
||||
result = [
|
||||
{
|
||||
position: "start",
|
||||
title: event.title,
|
||||
},
|
||||
];
|
||||
else if (
|
||||
date.toISODate() > startDate.toISODate() &&
|
||||
date.toISODate() < endDate.toISODate()
|
||||
)
|
||||
result = [
|
||||
{
|
||||
position: "between",
|
||||
title: event.title,
|
||||
},
|
||||
];
|
||||
else if (date.toISODate() == endDate.toISODate())
|
||||
result = [
|
||||
{
|
||||
position: "end",
|
||||
title: event.title,
|
||||
},
|
||||
];
|
||||
else result = false;
|
||||
}
|
||||
});
|
||||
return result; // return result
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getDateInMonth();
|
||||
});
|
||||
|
||||
watch(dateNow, () => {
|
||||
getDateInMonth();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calendar">
|
||||
<div class="calendar-header">
|
||||
<!-- <div class="flex">1</div> -->
|
||||
<div class="flex justify-between items-center my-4">
|
||||
<h5>
|
||||
{{ date.toFormat("LLLL yyyy") }}
|
||||
</h5>
|
||||
<div class="flex gap-5">
|
||||
<button
|
||||
@click="prevMonth"
|
||||
class="flex items-center px-2 py-2 rounded-md shadow-md bg-white text-primary hover:bg-primary/80 hover:text-white"
|
||||
>
|
||||
<Icon size="20px" name="ic:round-keyboard-arrow-left"></Icon>
|
||||
</button>
|
||||
<button
|
||||
@click="nextMonth"
|
||||
class="flex items-center px-2 py-2 rounded-md shadow-md bg-white text-primary hover:bg-primary/80 hover:text-white"
|
||||
>
|
||||
<Icon size="20px" name="ic:round-keyboard-arrow-right"></Icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="calendar-body rounded-md border border-primary/20">
|
||||
<div class="calendar-body-header max-w-full">
|
||||
<ul
|
||||
class="grid grid-cols-7 list-none bg-primary/50 text-primary rounded-t-md"
|
||||
>
|
||||
<li
|
||||
class="flex justify-center items-center p-5"
|
||||
v-for="(val, index) in dayInWeek"
|
||||
:key="index"
|
||||
>
|
||||
<span class="font-semibold text-base">{{ val }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="calendar-body-content">
|
||||
<ul class="grid grid-cols-7 list-none">
|
||||
<li
|
||||
class="relative flex justify-center items-center h-30 border border-primary/10 whitespace-nowrap"
|
||||
:class="{
|
||||
'bg-primary/5': val.isToday,
|
||||
}"
|
||||
v-for="(val, index) in allDate"
|
||||
:key="index"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<label
|
||||
class="absolute top-2 right-3 font-semibold"
|
||||
:class="{
|
||||
'text-primary/20': !val.isCurrentMonth,
|
||||
'text-primary/70': val.isCurrentMonth,
|
||||
}"
|
||||
for="day"
|
||||
>{{ label(val.date) }}</label
|
||||
>
|
||||
<div class="event" v-if="val.event">
|
||||
<div
|
||||
class="font-semibold p-5 bg-primary text-white rounded-md"
|
||||
style="min-height: 5rem"
|
||||
:class="{
|
||||
'rounded-r-none ml-5': event.position === 'start',
|
||||
'rounded-r-none rounded-l-none':
|
||||
event.position === 'between',
|
||||
'rounded-l-none mr-5': event.position === 'end',
|
||||
}"
|
||||
v-for="(event, index) in val.event"
|
||||
:key="index"
|
||||
>
|
||||
{{ event.position == "start" ? event.title : " " }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
67
components/RsAlert.vue
Normal file
67
components/RsAlert.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: "primary",
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
dismissible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
autoDismiss: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
timer: {
|
||||
type: Number,
|
||||
default: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const showComponent = ref(props.show);
|
||||
|
||||
const autoDismiss = () => {
|
||||
setTimeout(() => {
|
||||
showComponent.value = false;
|
||||
}, props.timer);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autoDismiss) {
|
||||
autoDismiss();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="fade-up">
|
||||
<div
|
||||
v-if="showComponent"
|
||||
class="alert"
|
||||
:class="{
|
||||
'alert-primary': variant === 'primary',
|
||||
'alert-secondary': variant === 'secondary',
|
||||
'alert-info': variant === 'info',
|
||||
'alert-success': variant === 'success',
|
||||
'alert-warning': variant === 'warning',
|
||||
'alert-danger': variant === 'danger',
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon v-if="icon" :name="icon" />
|
||||
<slot />
|
||||
</div>
|
||||
<button @click="showComponent = false">
|
||||
<Icon name="ic:baseline-close" size="14"></Icon>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
145
components/RsApiTester.vue
Normal file
145
components/RsApiTester.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
url: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const url = ref(window.location.origin + props.url);
|
||||
const params = ref([
|
||||
{
|
||||
key: "",
|
||||
value: "",
|
||||
},
|
||||
]);
|
||||
|
||||
const method = ref("POST");
|
||||
const dropdownMethods = ref([
|
||||
{
|
||||
label: "GET",
|
||||
value: "GET",
|
||||
},
|
||||
{
|
||||
label: "POST",
|
||||
value: "POST",
|
||||
},
|
||||
{
|
||||
label: "PUT",
|
||||
value: "PUT",
|
||||
},
|
||||
{
|
||||
label: "DELETE",
|
||||
value: "DELETE",
|
||||
},
|
||||
]);
|
||||
|
||||
const bodyJson = ref("{}");
|
||||
|
||||
const response = ref("");
|
||||
|
||||
watch(
|
||||
params,
|
||||
() => {
|
||||
const urlParams = new URLSearchParams();
|
||||
params.value.forEach((param) => {
|
||||
if (param.key !== "") {
|
||||
urlParams.append(param.key, param.value);
|
||||
}
|
||||
});
|
||||
url.value = window.location.origin + props.url + "?" + urlParams.toString();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const addParam = () => {
|
||||
params.value.push({
|
||||
key: "",
|
||||
value: "",
|
||||
});
|
||||
};
|
||||
|
||||
const removeParam = (index) => {
|
||||
if (params.value.length === 1) return;
|
||||
params.value.splice(index, 1);
|
||||
};
|
||||
|
||||
const hitAPI = async () => {
|
||||
const res = await useFetch(url.value, {
|
||||
method: method.value,
|
||||
initialCache: false,
|
||||
body: JSON.stringify(bodyJson.value),
|
||||
});
|
||||
|
||||
response.value = JSON.stringify(res.data.value, null, 2);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<FormKit type="text" label="URL" v-model="url" disabled />
|
||||
<FormKit
|
||||
type="select"
|
||||
label="Method"
|
||||
:options="dropdownMethods"
|
||||
v-model="method"
|
||||
/>
|
||||
|
||||
<label
|
||||
class="formkit-label text-gray-700 dark:text-gray-200 mb-2 font-semibold text-sm formkit-invalid:text-red-500 dark:formkit-invalid:text-danger flex justify-between items-center"
|
||||
for="input_8"
|
||||
>Parameter
|
||||
<rs-button size="sm" @click="addParam()"> Add</rs-button>
|
||||
</label>
|
||||
<div class="dynamic-params grid grid-cols-3 gap-4">
|
||||
<div v-for="(val, index) in params">
|
||||
<FormKit type="text" placeholder="Key" v-model="val.key" />
|
||||
<FormKit type="text" placeholder="Value" v-model="val.value" />
|
||||
<rs-button @click="removeParam(index)"> Remove</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<label
|
||||
class="formkit-label text-gray-700 dark:text-gray-200 mb-2 font-semibold text-sm formkit-invalid:text-red-500 dark:formkit-invalid:text-danger flex justify-between items-center"
|
||||
for="input_8"
|
||||
>Body
|
||||
</label>
|
||||
|
||||
<ClientOnly>
|
||||
<rs-code-mirror v-model="bodyJson" mode="application/json" height="300px">
|
||||
</rs-code-mirror>
|
||||
</ClientOnly>
|
||||
|
||||
<br />
|
||||
<label
|
||||
class="formkit-label text-gray-700 dark:text-gray-200 mb-2 font-semibold text-sm formkit-invalid:text-red-500 dark:formkit-invalid:text-danger flex justify-between items-center"
|
||||
for="input_8"
|
||||
>Response
|
||||
</label>
|
||||
|
||||
<!-- <ClientOnly>
|
||||
<rs-code-mirror v-model="response" height="300px" readonly="nocursor">
|
||||
</rs-code-mirror>
|
||||
</ClientOnly> -->
|
||||
|
||||
<FormKit
|
||||
type="textarea"
|
||||
:classes="{
|
||||
input: '!bg-[#272822] text-white dark:bg-gray-800 dark:text-gray-200',
|
||||
}"
|
||||
rows="10"
|
||||
v-model="response"
|
||||
disabled
|
||||
></FormKit>
|
||||
|
||||
<br />
|
||||
|
||||
<div class="flex justify-end">
|
||||
<rs-button @click="hitAPI"> Test</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
34
components/RsBadge.vue
Normal file
34
components/RsBadge.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: "primary",
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
iconSize: {
|
||||
type: String,
|
||||
default: "18",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="badge capitalize"
|
||||
:class="{
|
||||
'badge-primary': variant === 'primary',
|
||||
'badge-secondary': variant === 'secondary',
|
||||
'badge-info': variant === 'info',
|
||||
'badge-success': variant === 'success',
|
||||
'badge-warning': variant === 'warning',
|
||||
'badge-danger': variant === 'danger',
|
||||
'badge-disabled': variant === 'disabled',
|
||||
}"
|
||||
>
|
||||
<Icon v-if="icon" :name="icon" :size="iconSize"></Icon>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
58
components/RsButton.vue
Normal file
58
components/RsButton.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: "fill",
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: "primary",
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "md",
|
||||
},
|
||||
btnType: {
|
||||
type: String,
|
||||
default: "button",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="button"
|
||||
:type="btnType"
|
||||
:class="{
|
||||
'button-sm': size === 'sm',
|
||||
'button-md': size === 'md',
|
||||
'button-lg': size === 'lg',
|
||||
|
||||
// Filled Button
|
||||
'button-primary': variant === 'primary',
|
||||
'button-secondary': variant === 'secondary',
|
||||
'button-info': variant === 'info',
|
||||
'button-success': variant === 'success',
|
||||
'button-warning': variant === 'warning',
|
||||
'button-danger': variant === 'danger',
|
||||
|
||||
// Outline Button
|
||||
'outline-primary': variant === 'primary-outline',
|
||||
'outline-secondary': variant === 'secondary-outline',
|
||||
'outline-info': variant === 'info-outline',
|
||||
'outline-success': variant === 'success-outline',
|
||||
'outline-warning': variant === 'warning-outline',
|
||||
'outline-danger': variant === 'danger-outline',
|
||||
|
||||
//Text Button
|
||||
'texts-primary': variant === 'primary-text',
|
||||
'texts-secondary': variant === 'secondary-text',
|
||||
'texts-info': variant === 'info-text',
|
||||
'texts-success': variant === 'success-text',
|
||||
'texts-warning': variant === 'warning-text',
|
||||
'texts-danger': variant === 'danger-text',
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
16
components/RsCard.vue
Normal file
16
components/RsCard.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup></script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<header v-if="!!$slots.header" class="card-header">
|
||||
<slot name="header" />
|
||||
</header>
|
||||
<main><slot></slot></main>
|
||||
<div v-if="!!$slots.body" class="card-body">
|
||||
<slot name="body" />
|
||||
</div>
|
||||
<footer v-if="!!$slots.footer" class="card-footer">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
223
components/RsCodeMirror.vue
Normal file
223
components/RsCodeMirror.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<script setup>
|
||||
import { useThemeStore } from "~/stores/theme";
|
||||
|
||||
import { vue } from "@codemirror/lang-vue";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { amy, ayuLight, barf, clouds, cobalt, dracula } from "thememirror";
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: "vue",
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: "70vh",
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
default: "oneDark",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(["update:modelValue"]);
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const editorTheme = ref(themeStore.codeTheme);
|
||||
|
||||
const dropdownThemes = ref([
|
||||
{
|
||||
label: "default",
|
||||
value: "clouds",
|
||||
},
|
||||
{
|
||||
label: "oneDark",
|
||||
value: "oneDark",
|
||||
},
|
||||
{
|
||||
label: "amy",
|
||||
value: "amy",
|
||||
},
|
||||
{
|
||||
label: "ayu",
|
||||
value: "ayuLight",
|
||||
},
|
||||
{
|
||||
label: "barf",
|
||||
value: "barf",
|
||||
},
|
||||
{
|
||||
label: "cobalt",
|
||||
value: "cobalt",
|
||||
},
|
||||
{
|
||||
label: "dracula",
|
||||
value: "dracula",
|
||||
},
|
||||
]);
|
||||
|
||||
const value = ref(props.modelValue);
|
||||
const extensions = ref([]);
|
||||
if (props.mode == "vue") {
|
||||
extensions.value = [vue(), oneDark];
|
||||
} else {
|
||||
extensions.value = [javascript(), oneDark];
|
||||
}
|
||||
|
||||
const totalLines = ref(0);
|
||||
const totalLength = ref(0);
|
||||
|
||||
// Codemirror EditorView instance ref
|
||||
const view = shallowRef();
|
||||
const handleReady = (payload) => {
|
||||
view.value = payload.view;
|
||||
totalLines.value = view.value.state.doc.lines;
|
||||
totalLength.value = view.value.state.doc.length;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => editorTheme.value,
|
||||
(themeVal) => {
|
||||
// themeStore.setCodeTheme(newValue.value);
|
||||
|
||||
if (props.mode == "vue") {
|
||||
extensions.value = [
|
||||
vue(),
|
||||
themeVal === "oneDark"
|
||||
? oneDark
|
||||
: themeVal === "amy"
|
||||
? amy
|
||||
: themeVal === "ayuLight"
|
||||
? ayuLight
|
||||
: themeVal === "barf"
|
||||
? barf
|
||||
: themeVal === "cobalt"
|
||||
? cobalt
|
||||
: themeVal === "dracula"
|
||||
? dracula
|
||||
: clouds,
|
||||
];
|
||||
} else {
|
||||
extensions.value = [
|
||||
javascript(),
|
||||
themeVal === "oneDark"
|
||||
? oneDark
|
||||
: themeVal === "amy"
|
||||
? amy
|
||||
: themeVal === "ayuLight"
|
||||
? ayuLight
|
||||
: themeVal === "barf"
|
||||
? barf
|
||||
: themeVal === "cobalt"
|
||||
? cobalt
|
||||
: themeVal === "dracula"
|
||||
? dracula
|
||||
: clouds,
|
||||
];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Status is available at all times via Codemirror EditorView
|
||||
const getCodemirrorStates = () => {
|
||||
const state = view.value.state;
|
||||
const ranges = state.selection.ranges;
|
||||
const selected = ranges.reduce((r, range) => r + range.to - range.from, 0);
|
||||
const cursor = ranges[0].anchor;
|
||||
const length = state.doc.length;
|
||||
const lines = state.doc.lines;
|
||||
|
||||
console.log("state", view.value.state);
|
||||
};
|
||||
|
||||
const onChange = (value) => {
|
||||
// console.log("onChange", value);
|
||||
emits("update:modelValue", value);
|
||||
totalLines.value = view.value.state.doc.lines;
|
||||
totalLength.value = view.value.state.doc.length;
|
||||
};
|
||||
|
||||
const onFocus = (value) => {
|
||||
// console.log("onFocus", value);
|
||||
};
|
||||
|
||||
const onBlur = (value) => {
|
||||
// console.log("onBlur", value);
|
||||
};
|
||||
|
||||
const onUpdate = (value) => {
|
||||
// console.log("onUpdate", value);
|
||||
};
|
||||
|
||||
function numberComma(x) {
|
||||
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-between items-center gap-2 p-2 bg-[#282C34] text-[#abb2bf]"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
Theme:
|
||||
<FormKit
|
||||
v-model="editorTheme"
|
||||
type="select"
|
||||
placeholder="Select Themes"
|
||||
:options="dropdownThemes"
|
||||
:classes="{
|
||||
input:
|
||||
'!bg-[#282C34] !text-[#abb2bf] !border-[#abb2bf] hover:cursor-pointer h-6 w-[100px]',
|
||||
inner: ' !rounded-none !mb-0',
|
||||
outer: '!mb-0',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<!-- <rs-button
|
||||
class="!p-2"
|
||||
variant="primary-outline"
|
||||
@click="getCodemirrorStates"
|
||||
>
|
||||
Get state</rs-button
|
||||
> -->
|
||||
</div>
|
||||
<client-only>
|
||||
<CodeMirror
|
||||
v-model="value"
|
||||
placeholder="Code goes here..."
|
||||
:style="{ height: height }"
|
||||
:autofocus="true"
|
||||
:indent-with-tab="true"
|
||||
:tab-size="2"
|
||||
:extensions="extensions"
|
||||
:disabled="disabled"
|
||||
@ready="handleReady"
|
||||
@change="onChange($event)"
|
||||
@focus="onFocus($event)"
|
||||
@blur="onBlur($event)"
|
||||
@update="onUpdate($event)"
|
||||
/>
|
||||
</client-only>
|
||||
<div
|
||||
class="footer flex justify-end items-center gap-2 p-2 bg-[#282C34] text-[#abb2bf]"
|
||||
>
|
||||
<span class="">Lines: {{ numberComma(totalLines) }}</span>
|
||||
<span class="">Length: {{ numberComma(totalLength) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
24
components/RsCollapse.vue
Normal file
24
components/RsCollapse.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
accordion: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: "default",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="accordion"
|
||||
:class="{
|
||||
'accordion-border': type === 'border',
|
||||
}"
|
||||
v-uid
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
94
components/RsCollapseItem.vue
Normal file
94
components/RsCollapseItem.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
const collapseGroup = ref(null);
|
||||
const parentID = ref(null);
|
||||
|
||||
const instance = getCurrentInstance();
|
||||
|
||||
const isAccordion = instance.parent.props.accordion;
|
||||
const type = ref(instance.parent.props.type);
|
||||
const height = ref(0);
|
||||
const maxHeight = ref(60);
|
||||
|
||||
//watch intance type
|
||||
watch(
|
||||
() => instance.parent.props.type,
|
||||
(newValue) => {
|
||||
type.value = newValue;
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const onClick = () => {
|
||||
const parentElement = document.querySelector(`#${collapseGroup.value.id}`);
|
||||
parentID.value = parentElement.parentElement.id;
|
||||
|
||||
const scrollHeight = parentElement.scrollHeight;
|
||||
const targetOpenCollapse = parentElement.classList.contains(
|
||||
"accordion-group--open"
|
||||
);
|
||||
const openCollapse = document.querySelector(
|
||||
`#${parentID.value} .accordion-group--open`
|
||||
);
|
||||
|
||||
if (isAccordion) {
|
||||
if (openCollapse) {
|
||||
const openCollapseHeader = document.querySelector(
|
||||
`#${parentID.value} .accordion-group--open .accordion-header`
|
||||
);
|
||||
openCollapse.style.maxHeight = `${openCollapseHeader.scrollHeight}px`;
|
||||
openCollapse.classList.remove("accordion-group--open");
|
||||
}
|
||||
}
|
||||
if (targetOpenCollapse) {
|
||||
parentElement.style.maxHeight = maxHeight.value + "px";
|
||||
parentElement.classList.remove("accordion-group--open");
|
||||
} else {
|
||||
parentElement.style.maxHeight = scrollHeight + "px";
|
||||
parentElement.classList.add("accordion-group--open");
|
||||
}
|
||||
};
|
||||
|
||||
// On mounted get height collapse header
|
||||
onMounted(() => {
|
||||
try {
|
||||
const parentElement = document.querySelector(
|
||||
`#${collapseGroup.value.id} .accordion-header`
|
||||
);
|
||||
|
||||
const scrollHeight = parentElement.scrollHeight;
|
||||
maxHeight.value = scrollHeight;
|
||||
height.value = scrollHeight;
|
||||
} catch (error) {
|
||||
// console.log(error);
|
||||
return;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-uid
|
||||
ref="collapseGroup"
|
||||
class="accordion-group"
|
||||
:class="{
|
||||
'accordion-default': type === 'default',
|
||||
'accordion-border': type === 'border',
|
||||
'accordion-card': type === 'card',
|
||||
}"
|
||||
:style="`max-height: ${maxHeight}px; transition-property: max-height`"
|
||||
>
|
||||
<div class="accordion-header" @click="onClick">
|
||||
<slot v-if="!!$slots.title" name="title"></slot>
|
||||
<span v-else> {{ title }}</span>
|
||||
</div>
|
||||
<div class="accordion-body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
222
components/RsDropdown.vue
Normal file
222
components/RsDropdown.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<script setup>
|
||||
import { directive as vClickAway } from "vue3-click-away";
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: "Default",
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: "primary",
|
||||
},
|
||||
position: {
|
||||
type: String,
|
||||
default: "bottom",
|
||||
},
|
||||
textAlign: {
|
||||
type: String,
|
||||
default: "left",
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "md",
|
||||
},
|
||||
itemSize: {
|
||||
type: String,
|
||||
default: "10rem",
|
||||
},
|
||||
});
|
||||
|
||||
const isOpen = ref(false);
|
||||
const dropdownRef = ref(null);
|
||||
|
||||
let originalPosition = null; // Store the original position of the dropdown
|
||||
let lastKnownPosition = null; // Store the last known position of the dropdown
|
||||
|
||||
const toggle = (event) => {
|
||||
isOpen.value = !isOpen.value;
|
||||
};
|
||||
|
||||
const closeMenu = (event) => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
// Add a watcher for isOpen to reposition the dropdown when it's open
|
||||
watch(isOpen, (newValue) => {
|
||||
if (newValue) {
|
||||
positionDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to position the dropdown relative to the viewport
|
||||
const positionDropdown = () => {
|
||||
const dropdownElement = dropdownRef.value;
|
||||
const dropdownSection = dropdownElement.querySelector(".dropdown-section");
|
||||
|
||||
if (!dropdownElement || !dropdownSection) return;
|
||||
|
||||
// Get the bounding rect of the dropdown and its section
|
||||
const dropdownRect = dropdownElement.getBoundingClientRect();
|
||||
const dropdownSectionRect = dropdownSection.getBoundingClientRect();
|
||||
|
||||
// Get the viewport dimensions
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Check if the dropdown overflows the right or left side of the viewport
|
||||
const rightOverflow =
|
||||
dropdownRect.right + dropdownSectionRect.width - viewportWidth;
|
||||
const leftOverflow = dropdownRect.left - dropdownSectionRect.width;
|
||||
|
||||
if (rightOverflow > 0) {
|
||||
dropdownSection.style.right = "0";
|
||||
dropdownSection.style.left = "unset";
|
||||
} else if (leftOverflow < 0) {
|
||||
dropdownSection.style.left = "0";
|
||||
dropdownSection.style.right = "unset";
|
||||
}
|
||||
|
||||
// Check if the dropdown overflows the bottom or top of the viewport
|
||||
const bottomOverflow =
|
||||
dropdownRect.bottom + dropdownSectionRect.height - viewportHeight;
|
||||
const topOverflow = dropdownRect.top - dropdownSectionRect.height;
|
||||
|
||||
if (bottomOverflow > 0) {
|
||||
dropdownSection.style.bottom = "100%";
|
||||
dropdownSection.style.top = "unset";
|
||||
} else if (topOverflow < 0) {
|
||||
dropdownSection.style.top = "100%";
|
||||
dropdownSection.style.bottom = "unset";
|
||||
}
|
||||
|
||||
// Check if the position changed and update the lastKnownPosition
|
||||
const newPosition = dropdownSection.getBoundingClientRect();
|
||||
if (
|
||||
!lastKnownPosition ||
|
||||
JSON.stringify(lastKnownPosition) !== JSON.stringify(newPosition)
|
||||
) {
|
||||
lastKnownPosition = newPosition;
|
||||
}
|
||||
|
||||
// Check if the dropdown is out of the viewport and reset its position to original
|
||||
if (
|
||||
isOpen.value &&
|
||||
originalPosition &&
|
||||
isOutOfViewport(dropdownSection, originalPosition)
|
||||
) {
|
||||
dropdownSection.style.top = originalPosition.top + "px";
|
||||
dropdownSection.style.left = originalPosition.left + "px";
|
||||
lastKnownPosition = originalPosition;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if the element is out of the viewport
|
||||
const isOutOfViewport = (element, position) => {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
return (
|
||||
position.left < 0 ||
|
||||
position.right > viewportWidth ||
|
||||
position.top < 0 ||
|
||||
position.bottom > viewportHeight
|
||||
);
|
||||
};
|
||||
|
||||
// Watch for viewport size changes (e.g., window resize) to reposition the dropdown
|
||||
const handleResize = () => {
|
||||
if (isOpen.value) {
|
||||
positionDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for scrolling to reposition the dropdown
|
||||
const handleScroll = () => {
|
||||
if (isOpen.value) {
|
||||
positionDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// Add a listener for window resize to reposition the dropdown
|
||||
window.addEventListener("resize", handleResize);
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// Remove the window resize listener when the component is unmounted
|
||||
window.removeEventListener("resize", handleResize);
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dropdown" ref="dropdownRef" v-click-away="closeMenu">
|
||||
<button
|
||||
@click="toggle"
|
||||
class="button"
|
||||
:class="{
|
||||
'button-sm': size === 'sm',
|
||||
'button-md': size === 'md',
|
||||
'button-lg': size === 'lg',
|
||||
|
||||
// Filled Button
|
||||
'button-primary': variant === 'primary',
|
||||
'button-secondary': variant === 'secondary',
|
||||
'button-info': variant === 'info',
|
||||
'button-success': variant === 'success',
|
||||
'button-warning': variant === 'warning',
|
||||
'button-danger': variant === 'danger',
|
||||
|
||||
// Outline Button
|
||||
'outline-primary': variant === 'primary-outline',
|
||||
'outline-secondary': variant === 'secondary-outline',
|
||||
'outline-info': variant === 'info-outline',
|
||||
'outline-success': variant === 'success-outline',
|
||||
'outline-warning': variant === 'warning-outline',
|
||||
'outline-danger': variant === 'danger-outline',
|
||||
|
||||
//Text Button
|
||||
'texts-primary': variant === 'primary-text',
|
||||
'texts-secondary': variant === 'secondary-text',
|
||||
'texts-info': variant === 'info-text',
|
||||
'texts-success': variant === 'success-text',
|
||||
'texts-warning': variant === 'warning-text',
|
||||
'texts-danger': variant === 'danger-text',
|
||||
}"
|
||||
type="button"
|
||||
>
|
||||
<slot v-if="$slots.title" name="title"></slot>
|
||||
<span v-else>{{ props.title }}</span>
|
||||
<Icon
|
||||
v-if="position === 'bottom'"
|
||||
name="ic:outline-keyboard-arrow-down"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="position === 'top'"
|
||||
name="ic:outline-keyboard-arrow-up"
|
||||
/>
|
||||
<Icon v-else-if="position === 'left'" name="ic:outline-chevron-left" />
|
||||
<Icon v-else-if="position === 'right'" name="ic:outline-chevron-right" />
|
||||
</button>
|
||||
<section
|
||||
class="dropdown-section"
|
||||
:class="{
|
||||
'list-bottom-sm': position == 'bottom' && size == 'sm',
|
||||
'list-bottom-md': position == 'bottom' && size == 'md',
|
||||
'list-bottom-lg': position == 'bottom' && size == 'lg',
|
||||
'list-top-sm': position == 'top' && size == 'sm',
|
||||
'list-top-md': position == 'top' && size == 'md',
|
||||
'list-top-lg': position == 'top' && size == 'lg',
|
||||
'list-left': position == 'left',
|
||||
'list-right': position == 'right',
|
||||
'list-align-right':
|
||||
(position == 'bottom' || position == 'top') && textAlign == 'right',
|
||||
}"
|
||||
:style="`min-width: ${itemSize}`"
|
||||
v-show="isOpen"
|
||||
>
|
||||
<slot></slot>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
21
components/RsDropdownItem.vue
Normal file
21
components/RsDropdownItem.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
const emits = defineEmits(["click"]);
|
||||
|
||||
const props = defineProps({
|
||||
divider: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const clickEvent = () => {
|
||||
emits("click");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<hr v-if="divider" />
|
||||
<div @click="clickEvent" class="dropdown-item">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
45
components/RsFieldset.vue
Normal file
45
components/RsFieldset.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
// const props = defineProps({
|
||||
// context: Object,
|
||||
// });
|
||||
|
||||
// const label = props.context.label || "";
|
||||
// const border = props.context.border || true;
|
||||
// const borderColour = props.context.borderColour || "gray";
|
||||
// const borderWidth = props.context.borderWidth || "1px";
|
||||
// const borderRadius = props.context.borderRadius || "0.375rem";
|
||||
|
||||
const props = defineProps({
|
||||
label: String,
|
||||
border: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
borderColour: {
|
||||
type: String,
|
||||
default: "rgb(226 232 240)",
|
||||
},
|
||||
borderWidth: {
|
||||
type: String,
|
||||
default: "1px",
|
||||
},
|
||||
borderRadius: {
|
||||
type: String,
|
||||
default: "0.375rem",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset
|
||||
class="p-3"
|
||||
:style="{
|
||||
border: border ? '1px solid ' + borderColour : 'none',
|
||||
borderRadius: borderRadius,
|
||||
borderWidth: borderWidth,
|
||||
}"
|
||||
>
|
||||
<legend class="font-bold text-sm">{{ label }}</legend>
|
||||
<slot></slot>
|
||||
</fieldset>
|
||||
</template>
|
||||
149
components/RsModal.vue
Normal file
149
components/RsModal.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup>
|
||||
const emits = defineEmits(["update:modelValue"]);
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "md",
|
||||
},
|
||||
dialogClass: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
position: {
|
||||
type: String,
|
||||
default: "top",
|
||||
},
|
||||
hideOverlay: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
okOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
okTitle: {
|
||||
type: String,
|
||||
default: "OK",
|
||||
},
|
||||
cancelOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
cancelTitle: {
|
||||
type: String,
|
||||
default: "Cancel",
|
||||
},
|
||||
okCallback: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
cancelCallback: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
hideFooter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
overlayClose: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: "70vh",
|
||||
},
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
emits("update:modelValue", false);
|
||||
};
|
||||
|
||||
const validateCancelCallback = () => {
|
||||
if (props.cancelCallback == "() => {}") closeModal();
|
||||
else props.cancelCallback();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val) document.body.style.overflow = "hidden";
|
||||
else document.body.style.overflow = "auto";
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<transition-group name="fade">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
@click.self="overlayClose ? closeModal() : ''"
|
||||
class="modal"
|
||||
style="z-index: 1000"
|
||||
:class="{
|
||||
'modal-top': position == 'top',
|
||||
'modal-center': position == 'center',
|
||||
'modal-end': position == 'bottom',
|
||||
'modal-hide-overlay': hideOverlay,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-show="modelValue"
|
||||
class="modal-dialog"
|
||||
:class="dialogClass"
|
||||
:style="{
|
||||
width: size == 'sm' ? '300px' : size == 'md' ? '500px' : '800px',
|
||||
}"
|
||||
>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 v-if="!$slots.header">
|
||||
{{ title }}
|
||||
</h4>
|
||||
<slot name="header"></slot>
|
||||
<Icon
|
||||
@click="closeModal"
|
||||
class="hover:text-gray-800 cursor-pointer"
|
||||
name="ic:round-close"
|
||||
></Icon>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<NuxtScrollbar
|
||||
:style="{
|
||||
'max-height': height,
|
||||
}"
|
||||
>
|
||||
<slot name="body"></slot>
|
||||
<slot v-if="!$slots.body"></slot>
|
||||
</NuxtScrollbar>
|
||||
</div>
|
||||
<div v-if="!hideFooter" class="modal-footer">
|
||||
<slot name="footer"></slot>
|
||||
<rs-button
|
||||
v-if="!$slots.footer && !okOnly"
|
||||
@click="validateCancelCallback"
|
||||
variant="primary-text"
|
||||
>
|
||||
{{ cancelTitle }}</rs-button
|
||||
>
|
||||
<rs-button
|
||||
v-if="!$slots.footer && !cancelOnly"
|
||||
@click="okCallback"
|
||||
>{{ okTitle }}</rs-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
</Teleport>
|
||||
</template>
|
||||
63
components/RsProgressBar.vue
Normal file
63
components/RsProgressBar.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
value: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 100,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: "primary",
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "md",
|
||||
},
|
||||
showValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="progress-wrapper">
|
||||
<div class="progress-label">{{ label }}</div>
|
||||
<div
|
||||
class="progress"
|
||||
:class="{
|
||||
'progress-sm': size === 'sm',
|
||||
'progress-md': size === 'md',
|
||||
'progress-lg': size === 'lg',
|
||||
'progress-primary': variant == 'primary',
|
||||
'progress-secondary': variant == 'secondary',
|
||||
'progress-success': variant == 'info',
|
||||
'progress-info': variant == 'success',
|
||||
'progress-warning': variant == 'warning',
|
||||
'progress-danger': variant == 'danger',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="progress-bar"
|
||||
:class="{
|
||||
primary: variant == 'primary',
|
||||
secondary: variant == 'secondary',
|
||||
info: variant == 'info',
|
||||
success: variant == 'success',
|
||||
warning: variant == 'warning',
|
||||
danger: variant == 'danger',
|
||||
}"
|
||||
:style="{ width: (value / max) * 100 + '%' }"
|
||||
>
|
||||
<span class="text-xs" v-if="showValue">{{ value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
230
components/RsTab.vue
Normal file
230
components/RsTab.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: "primary",
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: "default",
|
||||
},
|
||||
vertical: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
fill: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
justify: {
|
||||
type: String,
|
||||
default: "left",
|
||||
},
|
||||
});
|
||||
|
||||
// Slots
|
||||
const slots = useSlots();
|
||||
|
||||
const tabs = ref(slots.default()?.map((tab) => tab.props).filter(props => props && props.title) || []);
|
||||
const selectedTitle = ref(tabs.value.length > 0 ? tabs.value[0]["title"] : "");
|
||||
|
||||
tabs.value.forEach((tab) => {
|
||||
if (typeof tab.active !== "undefined" && tab.active) {
|
||||
selectedTitle.value = tab.title;
|
||||
}
|
||||
});
|
||||
|
||||
provide("selectedTitle", selectedTitle);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<client-only>
|
||||
<div
|
||||
class="tab"
|
||||
:class="{
|
||||
vertical: vertical,
|
||||
'tab-card': type === 'card' && !vertical,
|
||||
'card-vertical': type === 'card' && vertical,
|
||||
'card-primary': type === 'card' && variant === 'primary',
|
||||
'card-secondary': type === 'card' && variant === 'secondary',
|
||||
'card-success': type === 'card' && variant === 'success',
|
||||
'card-danger': type === 'card' && variant === 'danger',
|
||||
'card-warning': type === 'card' && variant === 'warning',
|
||||
'card-info': type === 'card' && variant === 'info',
|
||||
}"
|
||||
>
|
||||
<ul
|
||||
class="tab-nav"
|
||||
:class="{
|
||||
'tab-nav-card': type === 'card' && !vertical,
|
||||
'tab-nav-card card-vertical': type === 'card' && vertical,
|
||||
vertical: vertical,
|
||||
'vertical-fill': vertical && fill,
|
||||
}"
|
||||
>
|
||||
<li
|
||||
class="tab-item"
|
||||
:class="{
|
||||
fill: fill,
|
||||
border: type === 'border',
|
||||
'border-horizontal': type === 'border' && !vertical,
|
||||
'border-horizontal-active':
|
||||
selectedTitle === val.title && type === 'border' && !vertical,
|
||||
'border-vertical': type === 'border' && vertical,
|
||||
'border-vertical-active':
|
||||
selectedTitle === val.title && type === 'border' && vertical,
|
||||
|
||||
// Variant Color for Border Type
|
||||
'border-hover-primary': type === 'border' && variant == 'primary',
|
||||
'border-hover-secondary':
|
||||
type === 'border' && variant == 'secondary',
|
||||
'border-hover-info': type === 'border' && variant == 'info',
|
||||
'border-hover-success': type === 'border' && variant == 'success',
|
||||
'border-hover-warning': type === 'border' && variant == 'warning',
|
||||
'border-hover-danger': type === 'border' && variant == 'danger',
|
||||
|
||||
// Variant Color for Border Type Active
|
||||
'border-active-primary':
|
||||
selectedTitle === val.title &&
|
||||
type === 'border' &&
|
||||
variant == 'primary',
|
||||
'border-active-secondary':
|
||||
selectedTitle === val.title &&
|
||||
type === 'border' &&
|
||||
variant == 'secondary',
|
||||
'border-active-info':
|
||||
selectedTitle === val.title &&
|
||||
type === 'border' &&
|
||||
variant == 'info',
|
||||
'border-active-success':
|
||||
selectedTitle === val.title &&
|
||||
type === 'border' &&
|
||||
variant == 'success',
|
||||
'border-active-warning':
|
||||
selectedTitle === val.title &&
|
||||
type === 'border' &&
|
||||
variant == 'warning',
|
||||
'border-active-danger':
|
||||
selectedTitle === val.title &&
|
||||
type === 'border' &&
|
||||
variant == 'danger',
|
||||
}"
|
||||
role="presentation"
|
||||
v-for="(val, index) in tabs"
|
||||
:key="index"
|
||||
@click="selectedTitle = val.title"
|
||||
>
|
||||
<a
|
||||
class="tab-item-link"
|
||||
:class="{
|
||||
default: type === 'default' && !vertical,
|
||||
'default-vertical': type === 'default' && vertical,
|
||||
'default-active':
|
||||
selectedTitle === val.title && type === 'default' && !vertical,
|
||||
'default-vertical-active':
|
||||
selectedTitle === val.title && type === 'default' && vertical,
|
||||
|
||||
// Variant hover for default type
|
||||
'default-hover-primary':
|
||||
type === 'default' && variant == 'primary',
|
||||
'default-hover-secondary':
|
||||
type === 'default' && variant == 'secondary',
|
||||
'default-hover-info': type === 'default' && variant == 'info',
|
||||
'default-hover-success':
|
||||
type === 'default' && variant == 'success',
|
||||
'default-hover-warning':
|
||||
type === 'default' && variant == 'warning',
|
||||
'default-hover-danger': type === 'default' && variant == 'danger',
|
||||
|
||||
// Variant Color for default type Active
|
||||
'default-primary':
|
||||
selectedTitle === val.title &&
|
||||
type === 'default' &&
|
||||
variant == 'primary',
|
||||
'default-secondary':
|
||||
selectedTitle === val.title &&
|
||||
type === 'default' &&
|
||||
variant == 'secondary',
|
||||
'default-info':
|
||||
selectedTitle === val.title &&
|
||||
type === 'default' &&
|
||||
variant == 'info',
|
||||
'default-success':
|
||||
selectedTitle === val.title &&
|
||||
type === 'default' &&
|
||||
variant == 'success',
|
||||
'default-warning':
|
||||
selectedTitle === val.title &&
|
||||
type === 'default' &&
|
||||
variant == 'warning',
|
||||
'default-danger':
|
||||
selectedTitle === val.title &&
|
||||
type === 'default' &&
|
||||
variant == 'danger',
|
||||
|
||||
'link-card': type === 'card' && !vertical,
|
||||
'link-card-vertical': type === 'card' && vertical,
|
||||
|
||||
// Variant Color for card type
|
||||
'link-card-primary': type === 'card' && variant == 'primary',
|
||||
'link-card-secondary': type === 'card' && variant == 'secondary',
|
||||
'link-card-info': type === 'card' && variant == 'info',
|
||||
'link-card-success': type === 'card' && variant == 'success',
|
||||
'link-card-warning': type === 'card' && variant == 'warning',
|
||||
'link-card-danger': type === 'card' && variant == 'danger',
|
||||
|
||||
// Variant Color for card type Active
|
||||
'link-card-primary-active':
|
||||
selectedTitle === val.title &&
|
||||
type === 'card' &&
|
||||
variant == 'primary',
|
||||
'link-card-secondary-active':
|
||||
selectedTitle === val.title &&
|
||||
type === 'card' &&
|
||||
variant == 'secondary',
|
||||
'link-card-info-active':
|
||||
selectedTitle === val.title &&
|
||||
type === 'card' &&
|
||||
variant == 'info',
|
||||
'link-card-success-active':
|
||||
selectedTitle === val.title &&
|
||||
type === 'card' &&
|
||||
variant == 'success',
|
||||
'link-card-warning-active':
|
||||
selectedTitle === val.title &&
|
||||
type === 'card' &&
|
||||
variant == 'warning',
|
||||
'link-card-danger-active':
|
||||
selectedTitle === val.title &&
|
||||
type === 'card' &&
|
||||
variant == 'danger',
|
||||
|
||||
'link-justify-left': justify == 'left',
|
||||
'link-justify-center': justify == 'center',
|
||||
'link-justify-right': justify == 'right',
|
||||
}"
|
||||
>{{ val.title }}</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
class="tab-content"
|
||||
:class="{
|
||||
'content-vertical': vertical && !fill,
|
||||
'content-vertical-fill': vertical && fill,
|
||||
'content-border': type === 'border' && !vertical,
|
||||
'content-border-vertical': type === 'border' && vertical,
|
||||
'content-border-primary': type === 'border' && variant === 'primary',
|
||||
'content-border-secondary':
|
||||
type === 'border' && variant === 'secondary',
|
||||
'content-border-info': type === 'border' && variant === 'info',
|
||||
'content-border-success': type === 'border' && variant === 'success',
|
||||
'content-border-warning': type === 'border' && variant === 'warning',
|
||||
'content-border-danger': type === 'border' && variant === 'danger',
|
||||
}"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</client-only>
|
||||
</template>
|
||||
20
components/RsTabItem.vue
Normal file
20
components/RsTabItem.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedTitle = inject("selectedTitle");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tab-pane" v-if="selectedTitle === title">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
808
components/RsTable.vue
Normal file
808
components/RsTable.vue
Normal file
@@ -0,0 +1,808 @@
|
||||
<script setup>
|
||||
import { useLayoutStore } from "~/stores/layout";
|
||||
import { useWindowSize } from "vue-window-size";
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
const mobileWidth = layoutStore.mobileWidth;
|
||||
|
||||
const { width } = useWindowSize();
|
||||
const windowWidth = ref(width);
|
||||
|
||||
const props = defineProps({
|
||||
field: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
basic: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
advanced: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
variant: "default",
|
||||
striped: false,
|
||||
bordered: false,
|
||||
borderless: false,
|
||||
hover: false,
|
||||
}),
|
||||
},
|
||||
optionsAdvanced: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
responsive: false,
|
||||
outsideBorder: false,
|
||||
}),
|
||||
},
|
||||
grid: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
sort: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
column: "",
|
||||
direction: "asc",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Default varaiable
|
||||
const columnTitle = ref([]);
|
||||
const dataTable = ref([]);
|
||||
const dataTitle = ref([]);
|
||||
const dataLength = ref(0);
|
||||
|
||||
// Advanced Option Variable
|
||||
const currentSort = ref(0);
|
||||
const currentSortDir = ref("asc");
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(props.pageSize);
|
||||
const maxPageShown = ref(3);
|
||||
|
||||
// Searching Variable
|
||||
const keyword = ref("");
|
||||
|
||||
// Filtering Variable
|
||||
const filter = ref([]);
|
||||
const openFilter = ref(false);
|
||||
|
||||
const hideTable = ref(false);
|
||||
|
||||
// Other Variable
|
||||
const sortColumnFirstTime = ref(false);
|
||||
|
||||
const isDesktop = computed(() => {
|
||||
return windowWidth.value >= mobileWidth ? true : false;
|
||||
});
|
||||
|
||||
if (props.optionsAdvanced.responsive) {
|
||||
if (isDesktop.value) {
|
||||
hideTable.value = false;
|
||||
} else {
|
||||
hideTable.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
const camelCasetoTitle = (str, exclusions = []) => {
|
||||
if (exclusions.includes(str)) {
|
||||
return str.replace(/([A-Z])/g, " $1").trim();
|
||||
} else if (/\(.*\)/.test(str)) {
|
||||
return str; // if the string contains parentheses, return the original string
|
||||
} else {
|
||||
return str.replace(/([A-Z])/g, " $1").replace(/^./, (str) => {
|
||||
return str.toUpperCase();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const spacingCharactertoCamelCase = (array) => {
|
||||
// Loop array string and convert to camel case
|
||||
|
||||
let result = [];
|
||||
|
||||
array.forEach((element) => {
|
||||
// Handle both string elements and object elements with key property
|
||||
const stringElement = typeof element === 'string' ? element : element.key;
|
||||
|
||||
if (stringElement && stringElement.charAt(0) == stringElement.charAt(0).toUpperCase()) {
|
||||
// Camelcase the string and remove spacing
|
||||
// and if there is () in the string, do Uppercase inside the () and dont spacing it
|
||||
|
||||
let camelCase = stringElement
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.replace(/^./, (str) => {
|
||||
return str.toUpperCase();
|
||||
})
|
||||
.replace(/\s/g, "");
|
||||
|
||||
let resultCamelCase = camelCase.replace(/\(([^)]+)\)/, (str) => {
|
||||
return str.toUpperCase();
|
||||
});
|
||||
|
||||
result.push(resultCamelCase);
|
||||
} else {
|
||||
result.push(stringElement);
|
||||
}
|
||||
});
|
||||
|
||||
// console.log(result);
|
||||
return result;
|
||||
};
|
||||
|
||||
// watch props.data change and redo all the data
|
||||
watch(
|
||||
() => [props.data, props.field],
|
||||
() => {
|
||||
if (props.data && props.data.length > 0) {
|
||||
dataTable.value = props.data;
|
||||
dataLength.value = props.data.length;
|
||||
if (props.field && props.field.length > 0) {
|
||||
columnTitle.value = spacingCharactertoCamelCase(props.field);
|
||||
dataTitle.value = spacingCharactertoCamelCase(props.field);
|
||||
} else {
|
||||
columnTitle.value = Object.keys(dataTable.value[0]);
|
||||
dataTitle.value = Object.keys(dataTable.value[0]);
|
||||
}
|
||||
} else {
|
||||
dataTable.value = [];
|
||||
dataLength.value = 0;
|
||||
columnTitle.value = [];
|
||||
dataTitle.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const setColumnTitle = (data) => {
|
||||
try {
|
||||
if (props.field && props.field.length == 0) {
|
||||
columnTitle.value = Object.keys(data);
|
||||
} else {
|
||||
columnTitle.value = spacingCharactertoCamelCase(props.field);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredDatabyTitle = (data, title) => {
|
||||
let result = "";
|
||||
try {
|
||||
if (props.field && props.field.length == 0) {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (key === title) {
|
||||
result = value;
|
||||
return;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Get index title from columnTitle
|
||||
let index = columnTitle.value.indexOf(title);
|
||||
|
||||
// Convert data json to array
|
||||
let arr = Object.values(data);
|
||||
|
||||
result = arr[index];
|
||||
}
|
||||
if (result === "" || result === null) result = "-";
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return "-";
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (dataTable.value.length > 0) {
|
||||
setColumnTitle(dataTable.value[0]);
|
||||
}
|
||||
});
|
||||
|
||||
// Computed data
|
||||
const computedData = computed(() => {
|
||||
let result = [];
|
||||
let totalData = 0;
|
||||
result = dataTable.value
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
let modifier = 1;
|
||||
|
||||
columnTitle.value.forEach((title, index) => {
|
||||
// console.log(title, props.sort.column);
|
||||
// First sort by column title
|
||||
if (title === props.sort.column && !sortColumnFirstTime.value) {
|
||||
currentSort.value = index;
|
||||
currentSortDir.value = props.sort.direction;
|
||||
sortColumnFirstTime.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Check if column title is number or string and convert spacing to camelcase
|
||||
let a1 = filteredDatabyTitle(a, columnTitle.value[currentSort.value]);
|
||||
let b1 = filteredDatabyTitle(b, columnTitle.value[currentSort.value]);
|
||||
|
||||
if (typeof a1 === "string") a1 = a1.toLowerCase();
|
||||
if (typeof b1 === "string") b1 = b1.toLowerCase();
|
||||
|
||||
// Convert string to number if possible
|
||||
if (isNumeric(a1)) a1 = parseFloat(a1);
|
||||
if (isNumeric(b1)) b1 = parseFloat(b1);
|
||||
|
||||
if (currentSortDir.value === "desc") modifier = -1;
|
||||
if (a1 < b1) return -1 * modifier;
|
||||
if (a1 > b1) return 1 * modifier;
|
||||
return 0;
|
||||
})
|
||||
.filter((row) => {
|
||||
// Search all json object if keyword not equal null
|
||||
if (keyword.value === "") return true;
|
||||
let result = false;
|
||||
Object.entries(row).forEach(([key, value]) => {
|
||||
try {
|
||||
if (
|
||||
value.toString().toLowerCase().includes(keyword.value.toLowerCase())
|
||||
) {
|
||||
result = true;
|
||||
currentPage.value = 1;
|
||||
}
|
||||
} catch (error) {
|
||||
result = false;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
})
|
||||
.filter((_, index) => {
|
||||
let start = (currentPage.value - 1) * pageSize.value;
|
||||
let end = currentPage.value * pageSize.value;
|
||||
totalData++;
|
||||
if (index >= start && index < end) return true;
|
||||
});
|
||||
dataLength.value = totalData;
|
||||
return result;
|
||||
});
|
||||
|
||||
const isNumeric = (n) => {
|
||||
return !isNaN(parseFloat(n)) && isFinite(n);
|
||||
};
|
||||
|
||||
const totalEntries = computed(() => {
|
||||
return dataLength.value;
|
||||
});
|
||||
|
||||
const sort = (index) => {
|
||||
if (index === currentSort.value) {
|
||||
currentSortDir.value = currentSortDir.value === "asc" ? "desc" : "asc";
|
||||
} else if (index !== currentSort.value && currentSortDir.value == "desc") {
|
||||
currentSortDir.value = "asc";
|
||||
}
|
||||
currentSort.value = index;
|
||||
};
|
||||
|
||||
const pages = computed(() => {
|
||||
let totalPG = Math.ceil(dataLength.value / pageSize.value);
|
||||
const numShown = Math.min(maxPageShown.value, totalPG);
|
||||
let first = currentPage.value - Math.floor(numShown / 2);
|
||||
first = Math.max(first, 1);
|
||||
first = Math.min(first, totalPG - numShown + 1);
|
||||
return [...Array(numShown)].map((k, i) => i + first);
|
||||
});
|
||||
|
||||
const totalPage = computed(() => {
|
||||
return Math.ceil(dataLength.value / pageSize.value);
|
||||
});
|
||||
|
||||
const pageChange = (page) => {
|
||||
currentPage.value = page;
|
||||
};
|
||||
|
||||
const nextPage = () => {
|
||||
if (currentPage.value * pageSize.value < dataLength.value)
|
||||
currentPage.value++;
|
||||
};
|
||||
const prevPage = () => {
|
||||
if (currentPage.value > 1) currentPage.value--;
|
||||
};
|
||||
|
||||
const firstPage = () => {
|
||||
currentPage.value = 1;
|
||||
};
|
||||
|
||||
const lastPage = () => {
|
||||
currentPage.value = totalPage.value;
|
||||
};
|
||||
|
||||
const hideColumn = (key) => {
|
||||
if (!getFilter(key)) {
|
||||
// insert into filter variable to tell there is a change in filter
|
||||
setFilter(key, "hide", true);
|
||||
} else {
|
||||
// update filter variable to tell there is a change in filter
|
||||
setFilter(key, "hide", false);
|
||||
}
|
||||
};
|
||||
|
||||
const setFilter = (key, action, condition) => {
|
||||
// Check if key exist inside filter
|
||||
let index = filter.value.findIndex((item) => item.key === key);
|
||||
|
||||
if (index == -1) {
|
||||
// If key not exist, insert new filter
|
||||
filter.value.push({
|
||||
key: key,
|
||||
action: {
|
||||
[action]: condition,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// If key exist, update filter
|
||||
filter.value[index].action[action] = condition;
|
||||
// console.log(filter.value);
|
||||
}
|
||||
};
|
||||
|
||||
const getFilter = (key) => {
|
||||
let result = false;
|
||||
filter.value.forEach((item) => {
|
||||
if (item.key === key) {
|
||||
result = item.action.hide;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
// Watch filter.value
|
||||
watch(
|
||||
() => filter.value,
|
||||
() => {
|
||||
// console.log(filter.value);
|
||||
// Loop json object filter.value
|
||||
filter.value.forEach((item) => {
|
||||
// Hide Column
|
||||
if (item.action.hide) {
|
||||
// Get index title from columnTitle
|
||||
let index = columnTitle.value.indexOf(item.key);
|
||||
|
||||
if (index !== -1) {
|
||||
// Remove column from columnTitle
|
||||
columnTitle.value.splice(index, 1);
|
||||
}
|
||||
} else if (!item.action.hide) {
|
||||
// Get index title from dataTitle
|
||||
let indexData = dataTitle.value.indexOf(item.key);
|
||||
|
||||
if (!columnTitle.value.includes(item.key)) {
|
||||
// Add Column back to its original position
|
||||
columnTitle.value.splice(indexData, 0, item.key);
|
||||
|
||||
// Sort the columnTitle like dataTitle
|
||||
columnTitle.value.sort((a, b) => {
|
||||
let indexA = dataTitle.value.indexOf(a);
|
||||
let indexB = dataTitle.value.indexOf(b);
|
||||
return indexA - indexB;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const filterComputed = computed(() => {
|
||||
let result = [];
|
||||
let i = 0;
|
||||
filter.value.forEach((item) => {
|
||||
if (item.action.hide) {
|
||||
result.push({
|
||||
title: item.key,
|
||||
hide: item.action.hide,
|
||||
});
|
||||
}
|
||||
i++;
|
||||
});
|
||||
// console.log(result);
|
||||
return result;
|
||||
});
|
||||
|
||||
// watch pinia getter windowWidth
|
||||
watch(
|
||||
() => windowWidth.value,
|
||||
() => {
|
||||
if (props.optionsAdvanced.responsive) {
|
||||
if (windowWidth.value <= mobileWidth) {
|
||||
hideTable.value = true;
|
||||
} else {
|
||||
hideTable.value = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="dataTable && dataTable.length > 0"
|
||||
class="table-wrapper"
|
||||
:class="{
|
||||
'!border': advanced && !hideTable && optionsAdvanced.outsideBorder,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="table-header"
|
||||
:class="{
|
||||
open: openFilter,
|
||||
'!max-h-full': !optionsAdvanced.filterable,
|
||||
}"
|
||||
v-if="advanced"
|
||||
>
|
||||
<div
|
||||
class="table-header-filter"
|
||||
:class="{
|
||||
'!items-center !gap-3': !optionsAdvanced.filterable,
|
||||
}"
|
||||
>
|
||||
<div>
|
||||
<div class="flex gap-x-2">
|
||||
<FormKit
|
||||
v-model="keyword"
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
outer-class="mb-0"
|
||||
/>
|
||||
<rs-button
|
||||
v-if="optionsAdvanced.filterable"
|
||||
class="!px-3 sm:!px-6"
|
||||
@click="openFilter ? (openFilter = false) : (openFilter = true)"
|
||||
>
|
||||
<Icon
|
||||
name="ic:outline-filter-alt"
|
||||
class="mr-0 md:mr-1"
|
||||
size="1rem"
|
||||
/>
|
||||
<span class="hidden sm:block">Filter</span>
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center items-center gap-x-2">
|
||||
<span class="text-[rgb(var(--text-color))]">Result per page:</span>
|
||||
<FormKit
|
||||
type="select"
|
||||
v-model="pageSize"
|
||||
:options="[5, 10, 25, 100]"
|
||||
outer-class="mb-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-start gap-x-3"
|
||||
v-if="optionsAdvanced.filterable"
|
||||
>
|
||||
<rs-dropdown
|
||||
:title="camelCasetoTitle(val)"
|
||||
size="sm"
|
||||
class="mt-3"
|
||||
v-for="(val, index) in dataTitle"
|
||||
:key="index"
|
||||
>
|
||||
<rs-dropdown-item @click="hideColumn(val)">
|
||||
{{ getFilter(val) ? "Show Column" : "Hide Column" }}
|
||||
<Icon
|
||||
:name="getFilter(val) ? 'mdi:eye-outline' : 'mdi:eye-off-outline'"
|
||||
size="1rem"
|
||||
class="ml-auto"
|
||||
></Icon>
|
||||
</rs-dropdown-item>
|
||||
</rs-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="filterComputed.length > 0"
|
||||
class="table-header-filter-list w-full m-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-start gap-x-2">
|
||||
<div
|
||||
class="flex items-center justify-center gap-x-2 border border-primary text-primary rounded-lg py-1 px-2"
|
||||
v-for="(val, index) in filterComputed"
|
||||
:key="index"
|
||||
>
|
||||
{{ val ? camelCasetoTitle(val.title) : "" }}
|
||||
<Icon
|
||||
name="ic:round-close"
|
||||
class="mr-0 md:mr-1 hover:text-red-500 cursor-pointer"
|
||||
size="1rem"
|
||||
@click="hideColumn(val.title)"
|
||||
></Icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full overflow-x-auto">
|
||||
<client-only>
|
||||
<table
|
||||
v-if="!hideTable"
|
||||
class="table-content"
|
||||
:class="{
|
||||
'!border-y !border-0 border-[rgb(var(--bg-1))]': advanced,
|
||||
'table-fixed': options.fixed,
|
||||
'table-auto': !options.fixed,
|
||||
}"
|
||||
>
|
||||
<thead
|
||||
class="text-left border-[rgb(var(--border-color))]"
|
||||
:class="{
|
||||
'border-y': !options.borderless,
|
||||
'border-[rgb(var(--border-color))] bg-[rgb(var(--bg-2))]':
|
||||
options.variant === 'default',
|
||||
'border-primary/50 bg-primary text-white':
|
||||
options.variant === 'primary',
|
||||
'border-secondary/50 bg-secondary text-white':
|
||||
options.variant === 'secondary',
|
||||
'border-info/50 bg-info text-white ': options.variant === 'info',
|
||||
'border-success/50 bg-success text-white':
|
||||
options.variant === 'success',
|
||||
'border-warning/50 bg-warning text-white':
|
||||
options.variant === 'warning',
|
||||
'border-danger/50 bg-danger text-white':
|
||||
options.variant === 'danger',
|
||||
}"
|
||||
>
|
||||
<tr>
|
||||
<th
|
||||
class="relative py-3 pl-5 pr-8 whitespace-nowrap"
|
||||
:class="{
|
||||
'border-r last:border-l last:border-r-0':
|
||||
options.bordered && !options.borderless,
|
||||
'border-[rgb(var(--border-color))]':
|
||||
options.variant === 'default',
|
||||
'border-primary/80': options.variant === 'primary',
|
||||
'border-secondary/80': options.variant === 'secondary',
|
||||
'border-info/80': options.variant === 'info',
|
||||
'border-success/80': options.variant === 'success',
|
||||
'border-warning/80': options.variant === 'warning',
|
||||
'border-danger/80': options.variant === 'danger',
|
||||
'w-36': options.fixed,
|
||||
'cursor-pointer': optionsAdvanced.sortable && advanced,
|
||||
}"
|
||||
style="min-width: 100px"
|
||||
@click="
|
||||
optionsAdvanced.sortable && advanced ? sort(index) : null
|
||||
"
|
||||
v-for="(val, index) in columnTitle"
|
||||
:key="index"
|
||||
>
|
||||
{{ camelCasetoTitle(val) }}
|
||||
<div
|
||||
v-if="optionsAdvanced.sortable && advanced"
|
||||
class="sortable"
|
||||
>
|
||||
<Icon
|
||||
class="absolute top-3 right-2 opacity-20"
|
||||
size="1.25rem"
|
||||
name="carbon:chevron-sort"
|
||||
/>
|
||||
<Icon
|
||||
v-if="currentSort == index && currentSortDir == 'asc'"
|
||||
class="absolute top-3 right-2 opacity-50"
|
||||
size="1.25rem"
|
||||
name="carbon:chevron-sort-up"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="currentSort == index && currentSortDir == 'desc'"
|
||||
class="absolute top-3 right-2 opacity-50"
|
||||
size="1.25rem"
|
||||
name="carbon:chevron-sort-down"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
:class="{
|
||||
'border-y border-[rgb(var(--border-color))]':
|
||||
!options.bordered && !options.borderless,
|
||||
'border-b': options.bordered && !options.borderless,
|
||||
'border-b-0': options.borderless,
|
||||
'border-[rgb(var(--border-color))] odd:bg-[rgb(var(--bg-1))] even:bg-[rgb(var(--bg-2))]':
|
||||
options.variant === 'default' && options.striped,
|
||||
'border-primary/20 odd:bg-white even:bg-primary/5':
|
||||
options.variant === 'primary' && options.striped,
|
||||
'border-secondary/20 odd:bg-white even:bg-secondary/5':
|
||||
options.variant === 'secondary' && options.striped,
|
||||
'border-info/20 odd:bg-white even:bg-info/5':
|
||||
options.variant === 'info' && options.striped,
|
||||
'border-success/20 odd:bg-white even:bg-success/5':
|
||||
options.variant === 'success' && options.striped,
|
||||
'border-warning/20 odd:bg-white even:bg-warning/5':
|
||||
options.variant === 'warning' && options.striped,
|
||||
'border-danger/20 odd:bg-white even:bg-danger/5':
|
||||
options.variant === 'danger' && options.striped,
|
||||
'cursor-pointer hover:bg-slate-300':
|
||||
options.hover && options.variant === 'default',
|
||||
'cursor-pointer hover:bg-primary/5':
|
||||
options.hover && options.variant === 'primary',
|
||||
'cursor-pointer hover:bg-secondary/5':
|
||||
options.hover && options.variant === 'secondary',
|
||||
'cursor-pointer hover:bg-info/5':
|
||||
options.hover && options.variant === 'info',
|
||||
'cursor-pointer hover:bg-success/5':
|
||||
options.hover && options.variant === 'success',
|
||||
'cursor-pointer hover:bg-warning/5':
|
||||
options.hover && options.variant === 'warning',
|
||||
'cursor-pointer hover:bg-danger/5':
|
||||
options.hover && options.variant === 'danger',
|
||||
}"
|
||||
v-for="(val1, index1) in computedData"
|
||||
:key="index1"
|
||||
>
|
||||
<td
|
||||
class="p-4 pl-5 break-words"
|
||||
:class="{
|
||||
'border-r last:border-l last:border-r-0':
|
||||
options.bordered && !options.borderless,
|
||||
'border-[rgb(var(--border-color))]':
|
||||
options.variant === 'default',
|
||||
'border-primary/20': options.variant === 'primary',
|
||||
'border-secondary/20': options.variant === 'secondary',
|
||||
'border-info/20': options.variant === 'info',
|
||||
'border-success/20': options.variant === 'success',
|
||||
'border-warning/20': options.variant === 'warning',
|
||||
'border-danger/20': options.variant === 'danger',
|
||||
}"
|
||||
v-for="(val2, index2) in columnTitle"
|
||||
:key="index2"
|
||||
>
|
||||
<slot
|
||||
:name="val2"
|
||||
:text="filteredDatabyTitle(val1, val2)"
|
||||
:value="val1"
|
||||
>
|
||||
{{ filteredDatabyTitle(val1, val2) }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else>
|
||||
<rs-collapse v-if="computedData.length > 0" accordion>
|
||||
<rs-collapse-item v-for="(val, index) in computedData" :key="index">
|
||||
<template #title>
|
||||
<div class="grid grid-cols-2">
|
||||
<div class="flex flex-col col-span-1">
|
||||
<span class="font-semibold leading-tight">
|
||||
{{ Object.values(val)[0] }}
|
||||
</span>
|
||||
<span class="text-sm"> {{ Object.values(val)[1] }} </span>
|
||||
</div>
|
||||
<div class="flex justify-end items-center col-span-1">
|
||||
<div class="mr-4">
|
||||
{{ Object.values(val)[computedData.length] }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div
|
||||
class="flex justify-between items-center even:bg-inherit odd:bg-[rgb(var(--bg-1))] rounded-lg p-3"
|
||||
v-for="(val1, index1) in Object.entries(val).slice(
|
||||
2,
|
||||
Object.entries(val).length
|
||||
)"
|
||||
:key="index1"
|
||||
>
|
||||
<span>
|
||||
{{ camelCasetoTitle(val1[0]) }}
|
||||
</span>
|
||||
<span>
|
||||
{{ val1[1] }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</rs-collapse-item>
|
||||
</rs-collapse>
|
||||
</div>
|
||||
</client-only>
|
||||
</div>
|
||||
<div v-if="advanced" class="table-footer">
|
||||
<div class="flex justify-center items-center gap-x-2">
|
||||
<span class="text-sm text-[rgb(var(--text-color))] hidden md:block"
|
||||
>Showing {{ pageSize * currentPage - pageSize + 1 }} to
|
||||
{{ pageSize * currentPage }} of {{ totalEntries }} entries</span
|
||||
>
|
||||
</div>
|
||||
<div class="table-footer-page">
|
||||
<rs-button
|
||||
:variant="`${
|
||||
options.variant == 'default' ? 'primary' : options.variant
|
||||
}-outline`"
|
||||
class="!rounded-full !p-1 !w-8 !h-8"
|
||||
@click="firstPage"
|
||||
:disabled="currentPage == 1"
|
||||
>
|
||||
<Icon name="ic:round-keyboard-double-arrow-left" size="1rem"></Icon>
|
||||
</rs-button>
|
||||
<rs-button
|
||||
:variant="`${
|
||||
options.variant == 'default' ? 'primary' : options.variant
|
||||
}-outline`"
|
||||
class="!rounded-full !p-1 !w-8 !h-8"
|
||||
@click="prevPage"
|
||||
:disabled="currentPage == 1"
|
||||
>
|
||||
<Icon name="ic:round-keyboard-arrow-left" size="1rem"></Icon>
|
||||
</rs-button>
|
||||
<rs-button
|
||||
:variant="`${
|
||||
currentPage == val && options.variant != 'default'
|
||||
? options.variant
|
||||
: currentPage == val && options.variant == 'default'
|
||||
? 'primary'
|
||||
: options.variant == 'default'
|
||||
? 'primary-outline'
|
||||
: options.variant + '-outline'
|
||||
}`"
|
||||
class="!rounded-full !p-1 !w-8 !h-8"
|
||||
v-for="(val, index) in pages"
|
||||
:key="index"
|
||||
@click="pageChange(val)"
|
||||
>
|
||||
{{ val }}
|
||||
</rs-button>
|
||||
<rs-button
|
||||
:variant="`${
|
||||
options.variant == 'default' ? 'primary' : options.variant
|
||||
}-outline`"
|
||||
class="!rounded-full !p-1 !w-8 !h-8"
|
||||
@click="nextPage"
|
||||
:disabled="currentPage == totalPage"
|
||||
>
|
||||
<Icon name="ic:round-keyboard-arrow-right" size="1rem"></Icon>
|
||||
</rs-button>
|
||||
<rs-button
|
||||
:variant="`${
|
||||
options.variant == 'default' ? 'primary' : options.variant
|
||||
}-outline`"
|
||||
class="!rounded-full !p-1 !w-8 !h-8"
|
||||
@click="lastPage"
|
||||
:disabled="currentPage == totalPage"
|
||||
>
|
||||
<Icon name="ic:round-keyboard-double-arrow-right" size="1rem"></Icon>
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="table-wrapper p-4">
|
||||
<div
|
||||
class="border border-[rgb(var(--border-color))] rounded-lg overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="bg-[rgb(var(--bg-2))] p-4 border-b border-[rgb(var(--border-color))]"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-[rgb(var(--text-color))]"></h3>
|
||||
</div>
|
||||
<div class="p-8 text-center">
|
||||
<Icon name="mdi:table-off" class="text-gray-300 mb-4" size="48px" />
|
||||
<p class="text-[rgb(var(--text-color))] text-lg font-medium">
|
||||
Tiada data
|
||||
</p>
|
||||
<p class="text-gray-500 mt-2">
|
||||
Tiada entri untuk dipaparkan pada masa ini.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
228
components/RsWizard.vue
Normal file
228
components/RsWizard.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<script setup>
|
||||
import { getNode, createMessage } from "@formkit/core";
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: "top",
|
||||
},
|
||||
steps: {
|
||||
type: Array,
|
||||
default: () => ["Default"],
|
||||
},
|
||||
currentStep: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
form: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
formSubmit: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
formAction: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
formStepRequired: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
formStepBack: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
formNavigate: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
formErrorCounter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const step = reactive({});
|
||||
const activeStep = ref(props.currentStep);
|
||||
const stepNames = ref(props.steps);
|
||||
const visitedSteps = ref([]);
|
||||
|
||||
const toLowerCase = (str) => str.toLowerCase().replace(/\s/g, "");
|
||||
const stepIndex = (stepName) => stepNames.value.indexOf(stepName);
|
||||
|
||||
watch(activeStep, (newStep, oldStep) => {
|
||||
if (oldStep && !visitedSteps.value.includes(oldStep)) {
|
||||
visitedSteps.value.push(oldStep);
|
||||
}
|
||||
|
||||
// NEW: trigger showing validation on fields
|
||||
// within all visited steps
|
||||
visitedSteps.value.forEach((step) => {
|
||||
const node = getNode(step);
|
||||
if (node != undefined)
|
||||
node.walk((n) => {
|
||||
n.store.set(
|
||||
createMessage({
|
||||
key: "submitted",
|
||||
value: true,
|
||||
visible: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const nextStep = (stepName) => {
|
||||
const stepNames = Object.keys(step);
|
||||
const currentIndex = stepNames.indexOf(activeStep.value);
|
||||
const nextIndex = stepNames.indexOf(stepName);
|
||||
|
||||
if (props.formStepRequired) {
|
||||
if (!props.formStepBack) {
|
||||
if (nextIndex > currentIndex) {
|
||||
if (step[activeStep.value].valid) {
|
||||
activeStep.value = stepName;
|
||||
} else {
|
||||
const node = getNode(activeStep.value);
|
||||
if (node)
|
||||
node.walk((n) => {
|
||||
n.store.set(
|
||||
createMessage({
|
||||
key: "submitted",
|
||||
value: true,
|
||||
visible: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (step[activeStep.value].valid || currentIndex > nextIndex) {
|
||||
activeStep.value = stepName;
|
||||
} else {
|
||||
const node = getNode(activeStep.value);
|
||||
if (node)
|
||||
node.walk((n) => {
|
||||
n.store.set(
|
||||
createMessage({
|
||||
key: "submitted",
|
||||
value: true,
|
||||
visible: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
activeStep.value = stepName;
|
||||
}
|
||||
};
|
||||
|
||||
const setStep = (delta) => {
|
||||
const stepNames = Object.keys(step);
|
||||
const currentIndex = stepNames.indexOf(activeStep.value);
|
||||
nextStep(stepNames[currentIndex + delta]);
|
||||
};
|
||||
|
||||
const stepPlugin = (node) => {
|
||||
if (node.props.type == "group") {
|
||||
// builds an object of the top-level groups
|
||||
step[node.name] = step[node.name] || {};
|
||||
|
||||
node.on("created", () => {
|
||||
// use 'on created' to ensure context object is available
|
||||
step[node.name].valid = toRef(node.context.state, "valid");
|
||||
});
|
||||
|
||||
// listen for changes in error count and store it
|
||||
node.on("count:errors", ({ payload: count }) => {
|
||||
step[node.name].errorCount = count;
|
||||
});
|
||||
|
||||
// listen for changes in count of blocking validations messages
|
||||
node.on("count:blocking", ({ payload: count }) => {
|
||||
step[node.name].blockingCount = count;
|
||||
});
|
||||
|
||||
// set the active tab to the 1st tab
|
||||
if (activeStep.value === "") {
|
||||
activeStep.value = node.name;
|
||||
}
|
||||
|
||||
// Stop plugin inheritance to descendant nodes
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const checkStepValidity = (stepName) => {
|
||||
if (step[stepName]) {
|
||||
return (
|
||||
(step[stepName].errorCount > 0 || step[stepName].blockingCount > 0) &&
|
||||
visitedSteps.value.includes(stepName)
|
||||
);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormKit
|
||||
type="form"
|
||||
:plugins="[stepPlugin]"
|
||||
:actions="formAction ? true : false"
|
||||
@submit="formSubmit ? formSubmit() : ''"
|
||||
:form-class="{ 'top-form': type == 'top', 'left-form': type == 'left' }"
|
||||
>
|
||||
<ul :class="{ 'top-steps': type == 'top', 'left-steps': type == 'left' }">
|
||||
<li
|
||||
v-for="(stepName, index) in stepNames"
|
||||
:key="index"
|
||||
:class="['step', { 'has-errors': checkStepValidity(stepName) }]"
|
||||
@click="nextStep(stepName)"
|
||||
:data-step-active="activeStep === stepName"
|
||||
:data-step-completed="stepIndex(stepName) < stepIndex(activeStep)"
|
||||
>
|
||||
<div class="counter">{{ index + 1 }}.</div>
|
||||
{{ stepName }}
|
||||
<span
|
||||
v-show="formErrorCounter"
|
||||
v-if="checkStepValidity(stepName)"
|
||||
class="step--errors"
|
||||
v-text="step[stepName].errorCount + step[stepName].blockingCount"
|
||||
/>
|
||||
<div class="progress"></div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="form-wizard">
|
||||
<section
|
||||
v-for="(stepName, index) in stepNames"
|
||||
:key="index"
|
||||
v-show="activeStep === stepName"
|
||||
>
|
||||
<FormKit type="group" :id="stepName" :name="stepName">
|
||||
<slot
|
||||
:name="stepName === 'Default' ? 'default' : toLowerCase(stepName)"
|
||||
>
|
||||
</slot>
|
||||
</FormKit>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="formNavigate" class="flex justify-between">
|
||||
<FormKit
|
||||
type="button"
|
||||
:disabled="stepIndex(activeStep) == 0"
|
||||
@click="setStep(-1)"
|
||||
v-text="'Previous step'"
|
||||
/>
|
||||
<FormKit
|
||||
type="button"
|
||||
:disabled="stepIndex(activeStep) == stepNames.length - 1"
|
||||
class="next"
|
||||
@click="setStep(1)"
|
||||
v-text="'Next step'"
|
||||
/>
|
||||
</div>
|
||||
</FormKit>
|
||||
</template>
|
||||
35
components/VoiceReader.vue
Normal file
35
components/VoiceReader.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="voice-reader">
|
||||
<rs-button
|
||||
@click="toggleReading"
|
||||
:variant="isReading ? 'danger' : 'primary'"
|
||||
class="p-2 rounded-full"
|
||||
>
|
||||
<Icon
|
||||
:name="isReading ? 'ph:ear' : 'ph:ear-slash'"
|
||||
:class="['text-2xl', { 'animate-pulse text-white': isReading }]"
|
||||
/>
|
||||
</rs-button>
|
||||
<span ref="announceElement" class="sr-only" aria-live="polite"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useVoiceReader } from "~/composables/useVoiceReader";
|
||||
|
||||
const { isReading, toggleReading, announceElement } = useVoiceReader();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
</style>
|
||||
33
components/draggable/nested.vue
Normal file
33
components/draggable/nested.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import DraggableNested from "~/components/draggable/nested.vue";
|
||||
const props = defineProps({
|
||||
tasks: {
|
||||
required: true,
|
||||
type: Array,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<draggable
|
||||
class="dragArea"
|
||||
tag="ul"
|
||||
:list="tasks"
|
||||
:group="{ name: 'g1' }"
|
||||
item-key="name"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<li>
|
||||
<p>{{ element.name }}</p>
|
||||
<DraggableNested :tasks="element.tasks" />
|
||||
</li>
|
||||
</template>
|
||||
</draggable>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dragArea {
|
||||
min-height: 50px;
|
||||
outline: 1px dashed;
|
||||
}
|
||||
</style>
|
||||
533
components/draggable/sideMenuNested.vue
Normal file
533
components/draggable/sideMenuNested.vue
Normal file
@@ -0,0 +1,533 @@
|
||||
<script setup>
|
||||
import DraggableSideMenuNested from "~/components/draggable/sideMenuNested.vue";
|
||||
const props = defineProps({
|
||||
menus: {
|
||||
required: true,
|
||||
type: Array,
|
||||
},
|
||||
count: {
|
||||
required: false,
|
||||
default: 0,
|
||||
type: Number,
|
||||
},
|
||||
parentMenu: {
|
||||
required: false,
|
||||
default: [],
|
||||
type: Array,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(["changeSideMenu"]);
|
||||
|
||||
const showModal = ref(false);
|
||||
const type = ref(null);
|
||||
const formMenu = ref({
|
||||
index: null,
|
||||
name: null,
|
||||
title: null,
|
||||
path: null,
|
||||
icon: null,
|
||||
});
|
||||
|
||||
const formHeader = ref({
|
||||
index: null,
|
||||
header: null,
|
||||
description: null,
|
||||
});
|
||||
|
||||
const viewPermissionType = ref([
|
||||
{
|
||||
label: "All",
|
||||
value: "all",
|
||||
},
|
||||
{
|
||||
label: "User",
|
||||
value: "user",
|
||||
},
|
||||
{
|
||||
label: "Role",
|
||||
value: "role",
|
||||
},
|
||||
]);
|
||||
const viewPermissionTypeRadio = ref("");
|
||||
const roleList = ref([]);
|
||||
const userList = ref([]);
|
||||
|
||||
const selectListValue = ref([]);
|
||||
|
||||
const checkAll = ref(false);
|
||||
|
||||
// watch viewPermissionTypeRadio
|
||||
watch(
|
||||
viewPermissionTypeRadio,
|
||||
async (val) => {
|
||||
if (val == "") viewPermissionTypeRadio.value = "all";
|
||||
else if (val == "user") await getUserList();
|
||||
else if (val == "role") await getRoleList();
|
||||
|
||||
// Check if selectListValue doesnt match with user or role list then reset selectListValue
|
||||
if (val == "user") {
|
||||
selectListValue.value = selectListValue.value.filter((item) => {
|
||||
return userList.value.some((user) => user.value == item.value);
|
||||
});
|
||||
} else if (val == "role") {
|
||||
selectListValue.value = selectListValue.value.filter((item) => {
|
||||
return roleList.value.some((role) => role.value == item.value);
|
||||
});
|
||||
}
|
||||
|
||||
checkAll.value = false;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// watch checkAll
|
||||
watch(
|
||||
checkAll,
|
||||
(val) => {
|
||||
if (val) {
|
||||
if (viewPermissionTypeRadio.value == "user") {
|
||||
selectListValue.value = userList.value.map((user) => {
|
||||
return {
|
||||
label: user.label,
|
||||
value: user.value,
|
||||
};
|
||||
});
|
||||
} else if (viewPermissionTypeRadio.value == "role") {
|
||||
selectListValue.value = roleList.value.map((role) => {
|
||||
return {
|
||||
label: role.label,
|
||||
value: role.value,
|
||||
};
|
||||
});
|
||||
}
|
||||
} else {
|
||||
selectListValue.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const getUserList = async () => {
|
||||
const { data } = await useFetch("/api/devtool/menu/user-list");
|
||||
if (data.value?.statusCode === 200) {
|
||||
userList.value = data.value.data.map((user) => {
|
||||
return {
|
||||
label: user.userUsername,
|
||||
value: user.userUsername,
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleList = async () => {
|
||||
const { data } = await useFetch("/api/devtool/menu/role-list");
|
||||
|
||||
if (data.value?.statusCode === 200) {
|
||||
roleList.value = data.value.data.map((role) => {
|
||||
return {
|
||||
label: role.roleName,
|
||||
value: role.roleName,
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const clone = (obj) => {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
};
|
||||
|
||||
// Modal functions
|
||||
const openModal = () => {
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
const assignDataMenu = (menu) => {
|
||||
formMenu.value = {
|
||||
index: menu.index,
|
||||
name: menu.name,
|
||||
title: menu.title,
|
||||
path: menu.path,
|
||||
icon: menu.icon,
|
||||
};
|
||||
|
||||
if (menu.meta?.auth?.user) {
|
||||
viewPermissionTypeRadio.value = "user";
|
||||
selectListValue.value = menu.meta.auth.user.map((user) => {
|
||||
return {
|
||||
label: user,
|
||||
value: user,
|
||||
};
|
||||
});
|
||||
} else if (menu.meta?.auth?.role) {
|
||||
viewPermissionTypeRadio.value = "role";
|
||||
selectListValue.value = menu.meta.auth.role.map((role) => {
|
||||
return {
|
||||
label: role,
|
||||
value: role,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
viewPermissionTypeRadio.value = "all";
|
||||
}
|
||||
};
|
||||
|
||||
const assignDataHeader = (header) => {
|
||||
formHeader.value = {
|
||||
index: props.menus.indexOf(header),
|
||||
header: header.header,
|
||||
description: header.description,
|
||||
};
|
||||
|
||||
if (header.meta?.auth?.user) {
|
||||
viewPermissionTypeRadio.value = "user";
|
||||
selectListValue.value = header.meta.auth.user.map((user) => {
|
||||
return {
|
||||
label: user,
|
||||
value: user,
|
||||
};
|
||||
});
|
||||
} else if (header.meta?.auth?.role) {
|
||||
viewPermissionTypeRadio.value = "role";
|
||||
selectListValue.value = header.meta.auth.role.map((role) => {
|
||||
return {
|
||||
label: role,
|
||||
value: role,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
viewPermissionTypeRadio.value = "all";
|
||||
}
|
||||
};
|
||||
|
||||
const clickOK = () => {
|
||||
showModal.value = false;
|
||||
};
|
||||
|
||||
const clickCancel = () => {
|
||||
showModal.value = false;
|
||||
};
|
||||
|
||||
// Update the menus
|
||||
const updateMenus = (menus) => {
|
||||
emits("changeSideMenu", menus);
|
||||
};
|
||||
|
||||
// Save the menu
|
||||
const saveEditChanges = () => {
|
||||
let newMenu = props.menus;
|
||||
|
||||
if (type.value == "menu") {
|
||||
// Overwrite the props menus
|
||||
props.menus.map((menu) => {
|
||||
if (menu.path == formMenu.value.path) {
|
||||
menu.title = formMenu.value.title;
|
||||
menu.icon = formMenu.value.icon;
|
||||
menu.meta = {};
|
||||
|
||||
// Add the meta auth based on viewPermissionTypeRadio
|
||||
if (viewPermissionTypeRadio.value == "user") {
|
||||
menu.meta.auth = {
|
||||
user: selectListValue.value.map((user) => {
|
||||
return user.value;
|
||||
}),
|
||||
};
|
||||
} else if (viewPermissionTypeRadio.value == "role") {
|
||||
menu.meta.auth = {
|
||||
role: selectListValue.value.map((role) => {
|
||||
return role.value;
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
newMenu = props.parentMenu;
|
||||
} else if (type.value == "header") {
|
||||
// Overwrite the props menus
|
||||
newMenu = props.menus.map((header, index) => {
|
||||
if (index == formHeader.value.index) {
|
||||
header.header = formHeader.value.header;
|
||||
header.description = formHeader.value.description;
|
||||
header.meta = {};
|
||||
|
||||
// Add the meta auth based on viewPermissionTypeRadio
|
||||
if (viewPermissionTypeRadio.value == "user") {
|
||||
header.meta.auth = {
|
||||
user: selectListValue.value.map((user) => {
|
||||
return user.value;
|
||||
}),
|
||||
};
|
||||
} else if (viewPermissionTypeRadio.value == "role") {
|
||||
header.meta.auth = {
|
||||
role: selectListValue.value.map((role) => {
|
||||
return role.value;
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return header;
|
||||
});
|
||||
}
|
||||
|
||||
// Update the menus
|
||||
updateMenus(newMenu);
|
||||
showModal.value = false;
|
||||
};
|
||||
|
||||
const removeChild = (type, data) => {
|
||||
// console.log(data);
|
||||
// console.log(type);
|
||||
let newMenu = props.menus;
|
||||
if (type == "menu") {
|
||||
let parentMenu = props.parentMenu;
|
||||
|
||||
// Overwrite the props menus
|
||||
newMenu = props.menus.filter((menu) => {
|
||||
return menu.path == data;
|
||||
});
|
||||
|
||||
// Remove the newMenu from the parentMenu child
|
||||
parentMenu = parentMenu.filter((menu) => {
|
||||
// Level 1
|
||||
if (menu.child) {
|
||||
menu.child.forEach((el) => {
|
||||
// Level 2
|
||||
if (el.child) {
|
||||
el.child = el.child.filter((child) => {
|
||||
return child.path != data;
|
||||
});
|
||||
}
|
||||
|
||||
if (el.path == data) {
|
||||
menu.child.splice(menu.child.indexOf(el), 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
return menu;
|
||||
});
|
||||
|
||||
newMenu = parentMenu;
|
||||
} else if (type == "header") {
|
||||
// Remove the object array from the props menus
|
||||
newMenu = props.menus.filter((header, index) => {
|
||||
return index != data;
|
||||
});
|
||||
}
|
||||
|
||||
// Update the menus
|
||||
updateMenus(newMenu);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<draggable
|
||||
class="dragArea"
|
||||
tag="div"
|
||||
:list="menus"
|
||||
:group="{ name: 'menu', put: props.count == 0 ? false : true }"
|
||||
:clone="clone"
|
||||
item-key="id"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<rs-card
|
||||
class="p-4 !my-4 mx-0 mb-0 relative border-2 border-[rgb(var(--border-color))]"
|
||||
:class="{
|
||||
'py-6': count > 0,
|
||||
}"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-left font-normal text-xs mb-2">
|
||||
<span class="uppercase text-primary dark:text-primary">{{
|
||||
count == 0 && element.header
|
||||
? element.header
|
||||
: count === 0
|
||||
? "(No Header)"
|
||||
: ""
|
||||
}}</span>
|
||||
<p class="text-gray-500 dark:text-gray-500">
|
||||
{{
|
||||
count == 0 && element.description
|
||||
? element.description
|
||||
: count === 0
|
||||
? "There will be no header shown"
|
||||
: ""
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="count == 0">
|
||||
<Icon
|
||||
name="material-symbols:edit-outline-rounded"
|
||||
class="text-primary hover:text-primary/90 cursor-pointer"
|
||||
size="20"
|
||||
@click="
|
||||
type = 'header';
|
||||
assignDataHeader(element);
|
||||
openModal();
|
||||
"
|
||||
></Icon>
|
||||
<Icon
|
||||
name="material-symbols:close-rounded"
|
||||
class="text-primary hover:text-primary/90 cursor-pointer"
|
||||
size="20"
|
||||
@click="removeChild('header', menus.indexOf(element))"
|
||||
></Icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<p class="flex items-center gap-2">
|
||||
<Icon v-if="element.icon" :name="element.icon" size="22"></Icon>
|
||||
{{ element.title }}
|
||||
</p>
|
||||
<div v-if="count > 0">
|
||||
<Icon
|
||||
name="material-symbols:edit-outline-rounded"
|
||||
class="text-primary hover:text-primary/90 cursor-pointer"
|
||||
size="20"
|
||||
@click="
|
||||
type = 'menu';
|
||||
assignDataMenu(element);
|
||||
openModal();
|
||||
"
|
||||
></Icon>
|
||||
<Icon
|
||||
name="material-symbols:close-rounded"
|
||||
class="text-primary hover:text-primary/90 cursor-pointer"
|
||||
size="20"
|
||||
@click="removeChild('menu', element.path)"
|
||||
></Icon>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="element?.meta?.auth" class="authuser-wrapper mt-3">
|
||||
<div class="flex">
|
||||
<div v-for="(val, index) in element.meta.auth.user">
|
||||
<rs-badge
|
||||
v-if="index < 5"
|
||||
variant="danger"
|
||||
class="mr-1 text-sm"
|
||||
>
|
||||
{{ val }}
|
||||
</rs-badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<div v-for="(val, index) in element.meta.auth.role">
|
||||
<rs-badge
|
||||
v-if="index < 5"
|
||||
variant="warning"
|
||||
class="mr-1 text-sm"
|
||||
>
|
||||
{{ val }}
|
||||
</rs-badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DraggableSideMenuNested
|
||||
:menus="element?.child ? element.child : []"
|
||||
:count="count + 1"
|
||||
:parentMenu="
|
||||
props.parentMenu && props.parentMenu.length > 0
|
||||
? props.parentMenu
|
||||
: props.menus
|
||||
"
|
||||
@changeSideMenu="updateMenus"
|
||||
/>
|
||||
</rs-card>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<rs-modal
|
||||
:title="type == 'header' ? 'Edit Header' : 'Edit Menu'"
|
||||
v-model="showModal"
|
||||
ok-title="Confirm"
|
||||
:ok-callback="saveEditChanges"
|
||||
:cancel-callback="clickCancel"
|
||||
>
|
||||
<div v-if="type == 'header'">
|
||||
<FormKit
|
||||
type="hidden"
|
||||
label="Index"
|
||||
v-model="formHeader.index"
|
||||
></FormKit>
|
||||
<FormKit type="text" label="Name" v-model="formHeader.header"></FormKit>
|
||||
<FormKit
|
||||
type="text"
|
||||
label="Description"
|
||||
v-model="formHeader.description"
|
||||
></FormKit>
|
||||
</div>
|
||||
<div v-else-if="type == 'menu'">
|
||||
<FormKit type="text" label="Title" v-model="formMenu.title"></FormKit>
|
||||
<FormKit
|
||||
type="text"
|
||||
label="Path"
|
||||
v-model="formMenu.path"
|
||||
readonly
|
||||
></FormKit>
|
||||
<FormKit type="text" label="Icon" v-model="formMenu.icon"></FormKit>
|
||||
<div class="mb-4 text-sm">
|
||||
<p class="font-semibold mb-2">
|
||||
Preview Icon (<a
|
||||
href="https://icones.js.org/collection/all"
|
||||
class="text-primary hover:underline"
|
||||
target="_blank"
|
||||
>https://icones.js.org/collection/all</a
|
||||
>)
|
||||
</p>
|
||||
<Icon v-if="formMenu.icon" :name="formMenu.icon"></Icon>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="mb-4" />
|
||||
<h4 class="text-semibold mb-4">Menu Permission</h4>
|
||||
<FormKit
|
||||
type="radio"
|
||||
label="View Type"
|
||||
v-model="viewPermissionTypeRadio"
|
||||
:classes="{
|
||||
fieldset: 'border-0 !p-0',
|
||||
legend: '!font-semibold !text-sm mb-0',
|
||||
options: '!flex !flex-row gap-4 mt-3',
|
||||
}"
|
||||
:options="viewPermissionType"
|
||||
/>
|
||||
<div
|
||||
v-if="viewPermissionTypeRadio && viewPermissionTypeRadio != 'all'"
|
||||
class="form-wrapper"
|
||||
>
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<label
|
||||
class="formkit-label flex items-center gap-x-4 font-semibold text-gray-700 dark:text-gray-200 blockfont-semibold text-sm formkit-invalid:text-red-500 dark:formkit-invalid:text-danger"
|
||||
for="input_4"
|
||||
>
|
||||
{{ viewPermissionTypeRadio == "user" ? "User" : "Role" }}
|
||||
</label>
|
||||
</div>
|
||||
<v-select
|
||||
class="formkit-vselect"
|
||||
:options="
|
||||
viewPermissionTypeRadio == 'user'
|
||||
? userList
|
||||
: viewPermissionTypeRadio == 'role'
|
||||
? roleList
|
||||
: []
|
||||
"
|
||||
v-model="selectListValue"
|
||||
multiple
|
||||
></v-select>
|
||||
<FormKit
|
||||
type="checkbox"
|
||||
v-model="checkAll"
|
||||
:label="
|
||||
viewPermissionTypeRadio == 'user'
|
||||
? 'Check All User'
|
||||
: 'Check All Role'
|
||||
"
|
||||
input-class="icon-check"
|
||||
/>
|
||||
</div>
|
||||
</rs-modal>
|
||||
</div>
|
||||
</template>
|
||||
13
components/formkit/DateTimePicker.vue
Normal file
13
components/formkit/DateTimePicker.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
context: Object,
|
||||
});
|
||||
|
||||
function handleInput(e) {
|
||||
props.context.node.input(e.target.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input @input="handleInput" :value="props.context._value" />
|
||||
</template>
|
||||
139
components/formkit/FileDropzone.vue
Normal file
139
components/formkit/FileDropzone.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script setup>
|
||||
/* eslint-disable */
|
||||
import { useDropzone } from "vue3-dropzone";
|
||||
|
||||
const props = defineProps({
|
||||
context: Object,
|
||||
});
|
||||
|
||||
const fileBase64 = ref([]);
|
||||
const files = ref([]);
|
||||
let err = ref(false);
|
||||
let errmsg = ref("");
|
||||
|
||||
const accept = props.context.accept;
|
||||
const multiple = props.context.multiple;
|
||||
const maxSize = props.context.maxSize;
|
||||
const minSize = props.context.minSize;
|
||||
const maxFiles = props.context.maxFiles;
|
||||
const disabled = props.context.disabled;
|
||||
|
||||
const toBase64 = (file) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = (error) => reject(error);
|
||||
});
|
||||
|
||||
async function onDrop(fileList, fileError, event) {
|
||||
if (fileError.length == 0) {
|
||||
err.value = false;
|
||||
errmsg.value = "";
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
const base64 = await toBase64(fileList[i]);
|
||||
fileBase64.value.push({ data: fileList[i], base64 });
|
||||
files.value.push([fileList[i]]);
|
||||
}
|
||||
} else {
|
||||
err.value = true;
|
||||
errmsg.value = fileError[0].errors[0].message;
|
||||
}
|
||||
|
||||
updateNodeValue();
|
||||
}
|
||||
|
||||
async function removeFiles(index) {
|
||||
fileBase64.value.splice(index, 1);
|
||||
files.value.splice(index, 1);
|
||||
updateNodeValue();
|
||||
}
|
||||
|
||||
function updateNodeValue() {
|
||||
props.context.node.input(files.value);
|
||||
}
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept,
|
||||
multiple: multiple === "true" ? true : false,
|
||||
maxSize: maxSize ? parseInt(maxSize) : Infinity,
|
||||
minSize: minSize ? parseInt(minSize) : 0,
|
||||
maxFiles: maxFiles ? parseInt(maxFiles) : 0,
|
||||
disabled: disabled === "true" ? true : false,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- eslint-disable -->
|
||||
|
||||
<div :class="context.classes.dropzone">
|
||||
<div v-bind="getRootProps()" class="cursor-pointer">
|
||||
<input v-bind="getInputProps()" />
|
||||
<div class="flex items-center justify-center h-36">
|
||||
<div>
|
||||
<Icon
|
||||
class="!block m-auto mb-3"
|
||||
size="30px"
|
||||
name="ic:outline-upload-file"
|
||||
/>
|
||||
<p class="text-center" v-if="isDragActive">Drop the files here ...</p>
|
||||
<p v-else>Drop files or click here to upload files</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="fileList"
|
||||
class="grid sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4"
|
||||
v-auto-animate
|
||||
>
|
||||
<div
|
||||
v-for="(file, index) in fileBase64"
|
||||
class="relative overflow-hidden w-full h-20 md:h-36 rounded-lg border-2 border-[rgb(var(--border-color))]"
|
||||
v-auto-animate
|
||||
>
|
||||
<img
|
||||
v-if="file.data.type.includes('image')"
|
||||
:src="file.base64"
|
||||
class="w-full h-20 md:h-36 object-cover object-center rounded-lg"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="h-full flex items-center justify-center opacity-50 text-primary font-semibold uppercase text-xl whitespace-nowrap"
|
||||
>
|
||||
{{
|
||||
file.data.name.slice(
|
||||
((file.data.name.lastIndexOf(".") - 1) >>> 0) + 2
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<Icon
|
||||
name="ic:round-close"
|
||||
@click="removeFiles(index)"
|
||||
class="cursor-pointer absolute top-1 right-1 text-[rgb(var(--text-color))] bg-[rgb(var(--bg-2))] p-1 rounded-full"
|
||||
size="18"
|
||||
/>
|
||||
<div
|
||||
class="absolute bottom-1 right-1 bg-[rgb(var(--bg-2))] px-2 rounded-lg"
|
||||
>
|
||||
<span class="font-semibold text-xs text-[rgb(var(--text-color))]">
|
||||
{{ file.data.path }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
v-if="err"
|
||||
class="formkit-messages list-none p-0 mt-1 mb-0 relative -bottom-5 -left-2"
|
||||
aria-live="polite"
|
||||
>
|
||||
<li
|
||||
class="formkit-message text-red-500 mb-1 text-xs formkit-invalid:text-red-500 dark:formkit-invalid:text-danger"
|
||||
id="input_9-rule_required"
|
||||
data-message-type="validation"
|
||||
>
|
||||
{{ errmsg }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
83
components/formkit/OneTimePassword.vue
Normal file
83
components/formkit/OneTimePassword.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script setup>
|
||||
/* eslint-disable */
|
||||
|
||||
const props = defineProps({
|
||||
context: Object,
|
||||
});
|
||||
|
||||
const digits = Number(props.context.digits);
|
||||
const tmp = ref(props.context.value || "");
|
||||
|
||||
/**
|
||||
* Handle input, advancing or retreating focus.
|
||||
*/
|
||||
function handleInput(index, e) {
|
||||
const prev = tmp.value;
|
||||
|
||||
if (tmp.value.length <= index) {
|
||||
// If this is a new digit
|
||||
tmp.value = "" + tmp.value + e.target.value;
|
||||
} else {
|
||||
// If this digit is in the middle somewhere, cut the string into two
|
||||
// pieces at the index, and insert our new digit in.
|
||||
tmp.value =
|
||||
"" +
|
||||
tmp.value.substr(0, index) +
|
||||
e.target.value +
|
||||
tmp.value.substr(index + 1);
|
||||
}
|
||||
|
||||
// Get all the digit inputs
|
||||
const inputs = e.target.parentElement.querySelectorAll("input");
|
||||
|
||||
if (index < digits - 1 && tmp.value.length >= prev.length) {
|
||||
// If this is a new input and not at the end, focus the next input
|
||||
inputs.item(index + 1).focus();
|
||||
} else if (index > 0 && tmp.value.length < prev.length) {
|
||||
// in this case we deleted a value, focus backwards
|
||||
inputs.item(index - 1).focus();
|
||||
}
|
||||
|
||||
if (tmp.value.length === digits) {
|
||||
// If our input is complete, commit the value.
|
||||
props.context.node.input(tmp.value);
|
||||
} else if (tmp.value.length < digits && props.context.value !== "") {
|
||||
// If our input is incomplete, it should have no value.
|
||||
props.context.node.input("");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On focus, select the text in our input.
|
||||
*/
|
||||
function handleFocus(e) {
|
||||
e.target.select();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the paste event.
|
||||
*/
|
||||
function handlePaste(e) {
|
||||
const paste = e.clipboardData.getData("text");
|
||||
if (typeof paste === "string") {
|
||||
// If it is the right length, paste it.
|
||||
tmp.value = paste.substr(0, digits);
|
||||
const inputs = e.target.parentElement.querySelectorAll("input");
|
||||
// Focus on the last character
|
||||
inputs.item(tmp.value.length - 1).focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- eslint-disable -->
|
||||
<input
|
||||
v-for="index in digits"
|
||||
maxlength="1"
|
||||
:class="context.classes.digit"
|
||||
:value="tmp[index - 1] || ''"
|
||||
@input="handleInput(index - 1, $event)"
|
||||
@focus="handleFocus"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
</template>
|
||||
23
components/formkit/TextMask.vue
Normal file
23
components/formkit/TextMask.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
/* eslint-disable */
|
||||
const props = defineProps({
|
||||
context: Object,
|
||||
});
|
||||
|
||||
const mask = String(props.context.mask);
|
||||
// console.log(props.context);
|
||||
|
||||
function handleInput(e) {
|
||||
props.context.node.input(e.target.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
@input="handleInput"
|
||||
:class="context.classes.input"
|
||||
:value="props.context._value"
|
||||
:placeholder="props.context.attrs.placeholder"
|
||||
v-maska="mask"
|
||||
/>
|
||||
</template>
|
||||
36
components/formkit/Toggle.vue
Normal file
36
components/formkit/Toggle.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
context: Object,
|
||||
});
|
||||
|
||||
function handleChange(event) {
|
||||
props.context.node.input(event.target.checked);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label
|
||||
:class="context.classes.toggle"
|
||||
class="inline-flex items-center mb-5 cursor-pointer mt-1"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="context.value"
|
||||
:disabled="context.disabled"
|
||||
class="sr-only peer"
|
||||
@change="handleChange"
|
||||
/>
|
||||
<div
|
||||
class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:w-5 after:h-5 after:transition-all peer-checked:bg-blue-600"
|
||||
></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900">
|
||||
{{
|
||||
context.onLabel || context.offLabel
|
||||
? context.value
|
||||
? context.onLabel
|
||||
: context.offLabel
|
||||
: context.label
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
</template>
|
||||
88
components/layouts/Breadcrumb.vue
Normal file
88
components/layouts/Breadcrumb.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
const route = useRoute();
|
||||
|
||||
// Get breadcrumb from page meta
|
||||
const breadcrumb = computed(() => {
|
||||
let breadcrumb = null;
|
||||
const matched = route.matched;
|
||||
|
||||
if (matched[matched.length - 1].meta?.breadcrumb) {
|
||||
breadcrumb = matched[matched.length - 1].meta.breadcrumb;
|
||||
} else {
|
||||
// if no breadcrumb in page meta, get breadcrumb from route matched
|
||||
breadcrumb = matched.map((item) => {
|
||||
return {
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
};
|
||||
});
|
||||
|
||||
return breadcrumb;
|
||||
}
|
||||
|
||||
// if type current overwrite path to its own path
|
||||
if (breadcrumb) {
|
||||
breadcrumb.forEach((item) => {
|
||||
if (item.type == "current") {
|
||||
item.path = route.path;
|
||||
} else if (item.type == "parent") {
|
||||
item.path = route.path.split("/").slice(0, -item.parentNo).join("/");
|
||||
}
|
||||
});
|
||||
}
|
||||
return breadcrumb;
|
||||
});
|
||||
|
||||
// Get title from page meta
|
||||
const title = computed(() => {
|
||||
const matched = route.matched;
|
||||
const title = matched[matched.length - 1].name;
|
||||
return title;
|
||||
});
|
||||
|
||||
async function navigateMenu(path) {
|
||||
try {
|
||||
await navigateTo(path);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="breadcrumb" class="mb-6">
|
||||
<nav aria-label="Breadcrumb" class="mb-4">
|
||||
<ol class="flex items-center text-sm">
|
||||
<li class="flex items-center">
|
||||
<NuxtLink to="/" class="text-gray-500 hover:text-gray-700">
|
||||
<Icon name="mdi:home" size="16" />
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li v-for="(item, index) in breadcrumb" :key="index" class="flex items-center">
|
||||
<Icon
|
||||
name="mdi:chevron-right"
|
||||
size="16"
|
||||
class="mx-2 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<a
|
||||
@click="navigateMenu(item.path)"
|
||||
class="cursor-pointer capitalize"
|
||||
:class="{
|
||||
'text-gray-500 hover:text-gray-700': index !== breadcrumb.length - 1,
|
||||
'text-primary font-medium': index === breadcrumb.length - 1,
|
||||
}"
|
||||
:aria-current="index === breadcrumb.length - 1 ? 'page' : undefined"
|
||||
>
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- <div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-semibold text-gray-800">{{ title }}</h1>
|
||||
<slot name="right"></slot>
|
||||
</div> -->
|
||||
</div>
|
||||
</template>
|
||||
98
components/layouts/BreadcrumbV2.vue
Normal file
98
components/layouts/BreadcrumbV2.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script setup>
|
||||
const route = useRoute();
|
||||
|
||||
// Get breadcrumb from page meta
|
||||
const breadcrumb = computed(() => {
|
||||
let breadcrumb = null;
|
||||
const matched = route.matched;
|
||||
|
||||
if (matched[matched.length - 1].meta?.breadcrumb) {
|
||||
breadcrumb = matched[matched.length - 1].meta.breadcrumb;
|
||||
} else {
|
||||
// if no breadcrumb in page meta, get breadcrumb from route matched
|
||||
breadcrumb = matched.map((item) => {
|
||||
return {
|
||||
name: item.meta.title,
|
||||
path: item.path,
|
||||
};
|
||||
});
|
||||
|
||||
return breadcrumb;
|
||||
}
|
||||
|
||||
// if type current overwrite path to its own path
|
||||
if (breadcrumb) {
|
||||
breadcrumb.forEach((item) => {
|
||||
if (item.type == "current") {
|
||||
item.path = route.path;
|
||||
} else if (item.type == "parent") {
|
||||
item.path = route.path.split("/").slice(0, -item.parentNo).join("/");
|
||||
}
|
||||
});
|
||||
}
|
||||
return breadcrumb;
|
||||
});
|
||||
|
||||
// Get title from page meta
|
||||
const title = computed(() => {
|
||||
const matched = route.matched;
|
||||
const title = matched[matched.length - 1].meta.title;
|
||||
return title;
|
||||
});
|
||||
|
||||
async function navigateMenu(path) {
|
||||
try {
|
||||
await navigateTo(path);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="breadcrumb">
|
||||
<div class="flex flex-col md:flex-row items-stretch justify-between">
|
||||
<div
|
||||
class="flex items-center"
|
||||
v-if="breadcrumb && breadcrumb.length != 0"
|
||||
>
|
||||
<span
|
||||
v-for="(item, index) in breadcrumb"
|
||||
:key="index"
|
||||
class="flex items-center text-primary"
|
||||
>
|
||||
<Icon
|
||||
v-if="index != 0"
|
||||
name="ic:round-chevron-right"
|
||||
size="14"
|
||||
class="mr-1 text-gray-500"
|
||||
></Icon>
|
||||
<a
|
||||
@click="navigateMenu(item.path)"
|
||||
class="cursor-pointer hover:underline pr-1 font-normal text-sm text-gray-500"
|
||||
:class="{
|
||||
'!text-primary': breadcrumb.length - 1 == index,
|
||||
}"
|
||||
>{{ item.name }}</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<slot name="right"></slot>
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-col min-w-0 lg:mb-0 break-words w-full">
|
||||
<div class="rounded-t mb-5">
|
||||
<div class="flex flex-wrap items-center">
|
||||
<div>
|
||||
<div>
|
||||
<span class="text-xl font-semibold text-gray-600">
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
20
components/layouts/FormHeader.vue
Normal file
20
components/layouts/FormHeader.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup></script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="w-full h-14 z-20 bg-white dark:bg-slate-800 fixed top-0 right-0 px-5 py-3 duration-300 shadow-md shadow-slate-200 dark:shadow-slate-900"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-800 dark:text-white">
|
||||
<Icon
|
||||
name="material-symbols:chevron-left-rounded"
|
||||
class="inline-block w-6 h-6 mr-2"
|
||||
/>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
327
components/layouts/Header.vue
Normal file
327
components/layouts/Header.vue
Normal file
@@ -0,0 +1,327 @@
|
||||
<script setup>
|
||||
const isVertical = ref(true);
|
||||
const isDesktop = ref(true);
|
||||
|
||||
const emit = defineEmits(["toggleMenu"]);
|
||||
|
||||
// Use site settings composable
|
||||
const { siteSettings, setTheme, getCurrentTheme } = useSiteSettings();
|
||||
|
||||
// const { locale } = useI18n();
|
||||
// const colorMode = useColorMode();
|
||||
const langList = languageList();
|
||||
|
||||
const locale = ref("en");
|
||||
|
||||
const themes = themeList();
|
||||
const themes2 = themeList2();
|
||||
|
||||
function setThemeLocal(theme) {
|
||||
setTheme(theme); // Use the site settings setTheme function
|
||||
}
|
||||
|
||||
function rgbToHex(rgbString) {
|
||||
// Split the input string into an array of components
|
||||
const rgbArray = rgbString.split(",");
|
||||
|
||||
// Convert each component to its numeric value
|
||||
const r = parseInt(rgbArray[0].trim(), 10);
|
||||
const g = parseInt(rgbArray[1].trim(), 10);
|
||||
const b = parseInt(rgbArray[2].trim(), 10);
|
||||
|
||||
// Convert the numeric RGB values to hexadecimal
|
||||
const rHex = r.toString(16).padStart(2, "0");
|
||||
const gHex = g.toString(16).padStart(2, "0");
|
||||
const bHex = b.toString(16).padStart(2, "0");
|
||||
|
||||
// Concatenate the components and return the final hexadecimal color code
|
||||
return `#${rHex}${gHex}${bHex}`;
|
||||
}
|
||||
|
||||
// Toggle Open/Close menu
|
||||
const toggleMenu = (event) => {
|
||||
emit("toggleMenu", event);
|
||||
};
|
||||
|
||||
// Focus on search input
|
||||
function toggleSearch() {
|
||||
document.getElementById("header-search").value = "";
|
||||
document.getElementById("header-search").focus();
|
||||
}
|
||||
|
||||
// Change language
|
||||
const changeLanguage = (lang) => {
|
||||
locale.value = lang;
|
||||
};
|
||||
|
||||
const languageNow = computed(() => {
|
||||
return langList.find((lang) => lang.value == locale.value);
|
||||
});
|
||||
|
||||
// Get current theme icon
|
||||
const currentThemeIcon = computed(() => {
|
||||
const theme = getCurrentTheme();
|
||||
return theme === "dark" ? "ic:outline-dark-mode" : "ic:outline-light-mode";
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// If mobile toggleMenu
|
||||
if (window.innerWidth < 768) {
|
||||
emit("toggleMenu", true);
|
||||
}
|
||||
|
||||
// Load site settings on mount and ensure they're properly populated
|
||||
const { loadSiteSettings } = useSiteSettings();
|
||||
loadSiteSettings().then(() => {
|
||||
nextTick(() => {
|
||||
// console.log('[Header.vue] Site settings loaded. Name:', siteSettings.value?.siteName, 'ShowInHeader:', siteSettings.value?.showSiteNameInHeader, 'Logo:', siteSettings.value?.siteLogo);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Add computed to ensure logo reactivity
|
||||
const currentLogo = computed(() => {
|
||||
const logoUrl = siteSettings.value?.siteLogo;
|
||||
if (logoUrl && logoUrl.trim() !== "") {
|
||||
return logoUrl; // Use logo from settings if available and not empty
|
||||
}
|
||||
return "/img/logo/corradAF-logo.svg"; // Ultimate fallback
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-header">
|
||||
<div class="flex items-stretch justify-between">
|
||||
<div v-if="isVertical" class="flex">
|
||||
<span class="flex items-center justify-center">
|
||||
<button class="icon-btn h-10 w-10 rounded-full" @click="toggleMenu">
|
||||
<Icon name="ic:round-menu" class="" />
|
||||
</button>
|
||||
</span>
|
||||
<!-- Site logo and name for vertical layout - only show if explicitly enabled -->
|
||||
<div
|
||||
v-if="siteSettings?.value?.showSiteNameInHeader"
|
||||
class="flex items-center ml-4"
|
||||
>
|
||||
<img
|
||||
:src="currentLogo"
|
||||
:alt="siteSettings?.value?.siteName || 'Site Logo'"
|
||||
class="h-8 block"
|
||||
@error="$event.target.src = '/img/logo/corradAF-logo.svg'"
|
||||
/>
|
||||
<span
|
||||
v-if="siteSettings?.value?.siteName"
|
||||
class="text-lg font-semibold"
|
||||
:class="{ 'ml-3': siteSettings?.value?.siteLogo }"
|
||||
:style="{
|
||||
fontSize: (siteSettings?.value?.siteNameFontSize || 18) + 'px',
|
||||
}"
|
||||
>
|
||||
{{ siteSettings?.value?.siteName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex" v-else>
|
||||
<nuxt-link to="/">
|
||||
<div class="flex flex-auto gap-3 justify-center items-center">
|
||||
<img
|
||||
:src="currentLogo"
|
||||
:alt="siteSettings?.value?.siteName || 'Site Logo'"
|
||||
class="h-8 block"
|
||||
@error="$event.target.src = '/img/logo/corradAF-logo.svg'"
|
||||
/>
|
||||
<span
|
||||
v-if="
|
||||
siteSettings?.value?.siteName && siteSettings?.value?.showSiteNameInHeader
|
||||
"
|
||||
class="text-lg font-semibold"
|
||||
:style="{
|
||||
fontSize: (siteSettings?.value?.siteNameFontSize || 18) + 'px',
|
||||
}"
|
||||
>
|
||||
{{ siteSettings?.value?.siteName }}
|
||||
</span>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 item-center justify-items-end">
|
||||
<VoiceReader class="ml-4" />
|
||||
|
||||
<!-- New dropdown for themeList.js -->
|
||||
<VDropdown placement="bottom-end" distance="13" name="theme">
|
||||
<button class="icon-btn h-10 w-10 rounded-full">
|
||||
<Icon size="22px" name="ph:paint-brush-broad" />
|
||||
</button>
|
||||
<template #popper>
|
||||
<ul class="header-dropdown w-full md:w-52">
|
||||
<li v-for="(val, index) in themes" :key="index">
|
||||
<a
|
||||
@click="setThemeLocal(val.theme)"
|
||||
class="flex justify-between items-center cursor-pointer py-2 px-4 hover:bg-[rgb(var(--bg-1))]"
|
||||
>
|
||||
<span class="capitalize"> {{ val.theme }} </span>
|
||||
<div class="flex items-center gap-x-1">
|
||||
<div
|
||||
v-for="(color, colorIndex) in val.colors"
|
||||
:key="colorIndex"
|
||||
class="h-[25px] w-[10px] rounded-lg"
|
||||
:style="{
|
||||
backgroundColor: rgbToHex(color.value),
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</VDropdown>
|
||||
|
||||
<!-- New dropdown for themeList2.js -->
|
||||
<VDropdown placement="bottom-end" distance="13" name="theme2">
|
||||
<button class="icon-btn h-10 w-10 rounded-full">
|
||||
<Icon size="22px" name="ph:wheelchair" />
|
||||
</button>
|
||||
<template #popper>
|
||||
<ul class="header-dropdown w-full md:w-52">
|
||||
<li v-for="(val, index) in themes2" :key="index">
|
||||
<a
|
||||
@click="setThemeLocal(val.theme)"
|
||||
class="flex justify-between items-center cursor-pointer py-2 px-4 hover:bg-[rgb(var(--bg-1))]"
|
||||
>
|
||||
<span class="capitalize"> {{ val.theme }} </span>
|
||||
<div class="flex items-center gap-x-1">
|
||||
<div
|
||||
v-for="(color, colorIndex) in val.colors"
|
||||
:key="colorIndex"
|
||||
class="h-[25px] w-[10px] rounded-lg"
|
||||
:style="{
|
||||
backgroundColor: rgbToHex(color.value),
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</VDropdown>
|
||||
|
||||
<VDropdown placement="bottom-end" distance="13" name="notification">
|
||||
<button class="relative icon-btn h-10 w-10 rounded-full">
|
||||
<span class="w-3 h-3 absolute top-1 right-2 rounded-full bg-primary"></span>
|
||||
<Icon name="ic:round-notifications-none" class="" />
|
||||
</button>
|
||||
<template #popper>
|
||||
<ul class="header-dropdown w-full md:w-80 text-[#4B5563]">
|
||||
<li class="d-head flex items-center justify-between py-2 px-4">
|
||||
<span class="font-semibold">Notification</span>
|
||||
<div
|
||||
class="flex items-center text-primary cursor-pointer hover:underline"
|
||||
>
|
||||
<a class="ml-2">View All</a>
|
||||
</div>
|
||||
</li>
|
||||
<NuxtScrollbar>
|
||||
<li>
|
||||
<div class="bg-[rgb(var(--bg-1))] py-2 px-4">Today</div>
|
||||
<a class="py-2 px-4 block">
|
||||
<div class="flex items-center">
|
||||
<Icon name="ic:outline-circle" class="text-primary flex-none" />
|
||||
<span class="mx-2"
|
||||
>Terdapat Satu Pembayaran yang berlaku menggunakan bil Kuih Raya
|
||||
Cik Kiah</span
|
||||
>
|
||||
<div class="w-12 h-12 rounded-full ml-auto flex-none">
|
||||
<img class="rounded-full" src="@/assets/img/user/default.svg" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="py-2 px-4 block">
|
||||
<div class="flex items-center">
|
||||
<Icon name="ic:outline-circle" class="text-primary flex-none" />
|
||||
<span class="mx-2"
|
||||
>Terdapat Satu Pembayaran yang berlaku menggunakan bil
|
||||
Mercun</span
|
||||
>
|
||||
<div class="w-12 h-12 rounded-full ml-auto flex-none">
|
||||
<img
|
||||
class="rounded-full"
|
||||
src="@/assets/img/user/default.svg"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</NuxtScrollbar>
|
||||
</ul>
|
||||
</template>
|
||||
</VDropdown>
|
||||
|
||||
<VDropdown
|
||||
placement="bottom-end"
|
||||
distance="13"
|
||||
name="profile"
|
||||
class="flex justify-center item-center"
|
||||
>
|
||||
<button class="icon-btn profile px-2">
|
||||
<img
|
||||
class="w-8 h-8 object-cover rounded-full"
|
||||
src="@/assets/img/user/default.svg"
|
||||
/>
|
||||
<div v-if="isDesktop" class="grid grid-cols-1 text-left ml-3 flex-none">
|
||||
<p class="font-semibold text-sm truncate w-24 mb-0">Johan</p>
|
||||
</div>
|
||||
<Icon name="ic:outline-keyboard-arrow-down" class="ml-3" />
|
||||
</button>
|
||||
<template #popper>
|
||||
<ul class="header-dropdown w-full md:w-52">
|
||||
<li>
|
||||
<a
|
||||
href="/logout"
|
||||
class="flex items-center cursor-pointer py-2 px-4 hover:bg-[rgb(var(--bg-1))]"
|
||||
>
|
||||
<Icon name="ic:outline-logout" class="mr-2" />
|
||||
Logout
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Nav for Layout Vertical -->
|
||||
<div tabindex="0" class="w-header-search">
|
||||
<Icon name="ic:outline-search" class="mr-3" />
|
||||
<FormKit
|
||||
id="header-search"
|
||||
:classes="{
|
||||
outer: 'mb-0 flex-1',
|
||||
}"
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.popper) {
|
||||
background: #e92791;
|
||||
padding: 20px;
|
||||
border-radius: 20px;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
:deep(.popper #arrow::before) {
|
||||
background: #e92791;
|
||||
}
|
||||
|
||||
:deep(.popper:hover),
|
||||
:deep(.popper:hover > #arrow::before) {
|
||||
background: #e92791;
|
||||
}
|
||||
</style>
|
||||
241
components/layouts/configmenu/index.vue
Normal file
241
components/layouts/configmenu/index.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<script setup>
|
||||
import { useThemeStore } from "~/stores/theme";
|
||||
|
||||
const colorMode = useColorMode();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const hideConfigMenu = ref(true);
|
||||
const rgbColor = ref({});
|
||||
|
||||
const hexToRgb = (hex) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `${r}, ${g}, ${b}`;
|
||||
};
|
||||
|
||||
const setThemeColor = (type, color) => {
|
||||
themeStore.setThemeColor(type, color);
|
||||
|
||||
// Remove Color form type string
|
||||
const colorType = type.replace("Color", "").toLowerCase();
|
||||
document.documentElement.style.setProperty(
|
||||
`--color-${colorType}`,
|
||||
hexToRgb(color)
|
||||
);
|
||||
};
|
||||
|
||||
function setColorMode() {
|
||||
if (colorMode.preference == "light") {
|
||||
colorMode.preference = "dark";
|
||||
|
||||
document.documentElement.classList.remove("light");
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
colorMode.preference = "light";
|
||||
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.documentElement.classList.add("light");
|
||||
}
|
||||
}
|
||||
|
||||
const setThemeGlobal = () => {
|
||||
rgbColor.value.primary = themeStore.primaryColor;
|
||||
rgbColor.value.secondary = themeStore.secondaryColor;
|
||||
rgbColor.value.info = themeStore.infoColor;
|
||||
rgbColor.value.success = themeStore.successColor;
|
||||
rgbColor.value.warning = themeStore.warningColor;
|
||||
rgbColor.value.danger = themeStore.dangerColor;
|
||||
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-primary",
|
||||
hexToRgb(themeStore.primaryColor)
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-secondary",
|
||||
hexToRgb(themeStore.secondaryColor)
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-info",
|
||||
hexToRgb(themeStore.infoColor)
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-success",
|
||||
hexToRgb(themeStore.successColor)
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-warning",
|
||||
hexToRgb(themeStore.warningColor)
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-danger",
|
||||
hexToRgb(themeStore.dangerColor)
|
||||
);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setThemeGlobal();
|
||||
});
|
||||
|
||||
const resetTheme = () => {
|
||||
themeStore.resetThemeColor();
|
||||
setThemeGlobal();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
:class="{
|
||||
'right-[-300px]': hideConfigMenu,
|
||||
}"
|
||||
class="h-full w-[300px] bg-white dark:bg-slate-800 fixed top-0 right-0 z-20 shadow-md shadow-slate-200 dark:shadow-slate-900 duration-300"
|
||||
>
|
||||
<div
|
||||
@click="hideConfigMenu = !hideConfigMenu"
|
||||
class="py-3 px-4 bg-primary text-white rounded-l-lg absolute bottom-1 left-[-53px] cursor-pointer hover:bg-primary/90 hidden md:block"
|
||||
>
|
||||
<Icon name="fluent:paint-brush-16-regular" size="22px" />
|
||||
</div>
|
||||
<div class="p-4 flex font-normal border-b">
|
||||
<p class="flex-1">
|
||||
<span class="font-bold text-base text-gray-600 dark:text-white"
|
||||
>Theme Customizer</span
|
||||
>
|
||||
<br />
|
||||
<span class="text-sm text-gray-500 dark:text-gray-300">
|
||||
Customize Theme Real Time
|
||||
</span>
|
||||
</p>
|
||||
<div class="flex justify-center items-center">
|
||||
<Icon
|
||||
@click="hideConfigMenu = !hideConfigMenu"
|
||||
class="cursor-pointer"
|
||||
name="material-symbols:close-rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<p class="text-sm mb-3 font-bold">Color Mode</p>
|
||||
<FormKit
|
||||
v-model="colorMode.value"
|
||||
type="radio"
|
||||
label="Theme"
|
||||
:options="[
|
||||
{
|
||||
label: 'Light',
|
||||
value: 'light',
|
||||
},
|
||||
{
|
||||
label: 'Dark',
|
||||
value: 'dark',
|
||||
},
|
||||
]"
|
||||
:classes="{
|
||||
fieldset: 'border-0 !p-0',
|
||||
legend: '!font-medium !text-sm mb-0',
|
||||
options: '!flex !flex-row gap-4 mt-2',
|
||||
input: '!rounded-full',
|
||||
}"
|
||||
@change="setColorMode"
|
||||
/>
|
||||
<div class="flex justify-between">
|
||||
<p class="text-sm mb-3 font-bold">Theming</p>
|
||||
<a
|
||||
@click="resetTheme"
|
||||
class="underline text-blue-600 cursor-pointer hover:text-blue-400"
|
||||
>Reset</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2">
|
||||
<FormKit
|
||||
type="color"
|
||||
v-model="rgbColor.primary"
|
||||
label="Primary"
|
||||
:classes="{
|
||||
label: '!font-medium !text-sm mb-0',
|
||||
outer: '!mb-0',
|
||||
}"
|
||||
@input="setThemeColor('primaryColor', rgbColor.primary)"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="color"
|
||||
v-model="rgbColor.secondary"
|
||||
label="Secondary"
|
||||
:classes="{
|
||||
label: '!font-medium !text-sm mb-0',
|
||||
outer: '!mb-0',
|
||||
}"
|
||||
@input="setThemeColor('secondaryColor', rgbColor.secondary)"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="color"
|
||||
v-model="rgbColor.info"
|
||||
label="Info"
|
||||
:classes="{
|
||||
label: '!font-medium !text-sm mb-0',
|
||||
outer: '!mb-0',
|
||||
}"
|
||||
@input="setThemeColor('infoColor', rgbColor.info)"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="color"
|
||||
v-model="rgbColor.success"
|
||||
label="Success"
|
||||
:classes="{
|
||||
label: '!font-medium !text-sm mb-0',
|
||||
outer: '!mb-0',
|
||||
}"
|
||||
@input="setThemeColor('successColor', rgbColor.success)"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="color"
|
||||
v-model="rgbColor.warning"
|
||||
label="Warning"
|
||||
:classes="{
|
||||
label: '!font-medium !text-sm mb-0',
|
||||
outer: '!mb-0',
|
||||
}"
|
||||
@input="setThemeColor('warningColor', rgbColor.warning)"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="color"
|
||||
v-model="rgbColor.danger"
|
||||
label="Danger"
|
||||
:classes="{
|
||||
label: '!font-medium !text-sm mb-0',
|
||||
outer: '!mb-0',
|
||||
}"
|
||||
@input="setThemeColor('dangerColor', rgbColor.danger)"
|
||||
/>
|
||||
</div>
|
||||
<hr class="my-4" />
|
||||
<p class="text-sm mb-3 font-bold">Preview</p>
|
||||
<rs-button variant="primary" class="w-full mb-4 cursor-default">
|
||||
Primary Color
|
||||
</rs-button>
|
||||
<rs-button variant="secondary" class="w-full mb-4 cursor-default">
|
||||
Secondary Color
|
||||
</rs-button>
|
||||
<rs-button variant="info" class="w-full mb-4 cursor-default">
|
||||
Info Color
|
||||
</rs-button>
|
||||
<rs-button variant="success" class="w-full mb-4 cursor-default">
|
||||
Success Color
|
||||
</rs-button>
|
||||
<rs-button variant="warning" class="w-full mb-4 cursor-default">
|
||||
Warning Color
|
||||
</rs-button>
|
||||
<rs-button variant="danger" class="w-full mb-4 cursor-default">
|
||||
Danger Color
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
7
components/layouts/horizontal/index.vue
Normal file
7
components/layouts/horizontal/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
25
components/layouts/index.vue
Normal file
25
components/layouts/index.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import { useThemeStore } from "~/stores/theme";
|
||||
|
||||
// Import Layout Components
|
||||
import RSVertical from "~/components/layouts/vertical/index.vue";
|
||||
import RSHorizontal from "~/components/layouts/horizontal/index.vue";
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const layoutType = themeStore.layoutType;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-wrapper">
|
||||
<div v-if="layoutType === 'vertical'" class="v-layout h-100">
|
||||
<RSVertical>
|
||||
<slot />
|
||||
</RSVertical>
|
||||
</div>
|
||||
<div v-if="layoutType === 'horizontal'" class="h-layout h-100">
|
||||
<RSHorizontal>
|
||||
<slot />
|
||||
</RSHorizontal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
166
components/layouts/sidemenu/Item.vue
Normal file
166
components/layouts/sidemenu/Item.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<script setup>
|
||||
import { useLayoutStore } from "~/stores/layout";
|
||||
import { useWindowSize } from "vue-window-size";
|
||||
import RSChildItem from "~/components/layouts/sidemenu/ItemChild.vue";
|
||||
import { useUserStore } from "~/stores/user";
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
const mobileWidth = layoutStore.mobileWidth;
|
||||
const { width } = useWindowSize();
|
||||
|
||||
const user = useUserStore();
|
||||
const route = useRoute();
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const username = user.username;
|
||||
const roles = user.roles;
|
||||
|
||||
const menuItem = props.items ? props.items : [];
|
||||
|
||||
// validate userExist on meta.auth.user
|
||||
function userExist(item) {
|
||||
if (item.meta?.auth?.user) {
|
||||
if (item.meta?.auth?.user.some((e) => e === username)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// validate roleExist on meta.auth.role
|
||||
function roleExist(item) {
|
||||
if (item.meta?.auth?.role) {
|
||||
if (item.meta?.auth?.role.some((e) => roles?.includes(e))) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Toggle show and hide menu content
|
||||
function openMenu(event) {
|
||||
const target = event.currentTarget;
|
||||
try {
|
||||
target.querySelector("a").classList.toggle("nav-open");
|
||||
target.querySelector("ul").classList.toggle("hide");
|
||||
} catch (e) {
|
||||
// console.log(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Active menu
|
||||
function activeMenu(routePath) {
|
||||
return route.path == routePath
|
||||
? `bg-[rgb(var(--color-primary))] font-normal text-white active-menu`
|
||||
: `font-light text-white/90 md:transition-all md:duration-200 hover:md:ml-2`;
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
document.querySelector(".v-layout").classList.toggle("menu-hide");
|
||||
document.getElementsByClassName("menu-overlay")[0].classList.toggle("hide");
|
||||
}
|
||||
|
||||
function navigationPage(path, external) {
|
||||
if (width.value <= mobileWidth) toggleMenu();
|
||||
navigateTo(path, {
|
||||
external: external,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-for="(item, index) in menuItem" :key="index">
|
||||
<div
|
||||
v-if="
|
||||
!item.meta || !item.meta?.auth || (userExist(item) && roleExist(item))
|
||||
"
|
||||
class="navigation-wrapper"
|
||||
>
|
||||
<div
|
||||
v-if="item.header"
|
||||
class="text-left font-normal text-xs mx-6 mt-5 mb-2"
|
||||
>
|
||||
<span class="uppercase text-gray-400">
|
||||
{{ item.header ? item.header : "" }}
|
||||
</span>
|
||||
<p class="text-gray-400">
|
||||
{{ item.description ? item.description : "" }}
|
||||
</p>
|
||||
</div>
|
||||
<ul class="navigation-menu">
|
||||
<li
|
||||
class="navigation-item"
|
||||
v-for="(item2, index2) in item.child"
|
||||
:key="index2"
|
||||
@click.stop="
|
||||
item2.child !== undefined ||
|
||||
(item2.child && item2.child.length !== 0)
|
||||
? openMenu($event)
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
!item2.meta ||
|
||||
!item2.meta?.auth ||
|
||||
(userExist(item2) && roleExist(item2))
|
||||
"
|
||||
class="navigation-item-wrapper"
|
||||
>
|
||||
<NuxtLink
|
||||
v-if="
|
||||
item2.child === undefined ||
|
||||
(item2.child && item2.child.length === 0)
|
||||
"
|
||||
class="flex items-center px-6 py-3 cursor-pointer"
|
||||
@click="navigationPage(item2.path, item2.external)"
|
||||
:class="activeMenu(item2.path)"
|
||||
>
|
||||
<Icon v-if="item2.icon" :name="item2.icon" size="18"></Icon>
|
||||
<Icon v-else name="mdi:circle-slice-8" size="18"></Icon>
|
||||
<span class="mx-3 font-normal">{{ item2.title }}</span>
|
||||
<Icon
|
||||
v-if="item2.child && item2.child.length > 0"
|
||||
class="ml-auto side-menu-arrow"
|
||||
name="material-symbols:chevron-right-rounded"
|
||||
size="18"
|
||||
></Icon>
|
||||
</NuxtLink>
|
||||
<a
|
||||
v-else
|
||||
class="flex items-center px-6 py-3 rounded-lg cursor-pointer"
|
||||
:class="activeMenu(item2.path)"
|
||||
>
|
||||
<Icon v-if="item2.icon" :name="item2.icon" size="18"></Icon>
|
||||
<Icon v-else name="mdi:circle-slice-8" size="18"></Icon>
|
||||
<span class="mx-3 font-normal">{{ item2.title }}</span>
|
||||
<Icon
|
||||
v-if="item2.child && item2.child.length > 0"
|
||||
class="ml-auto side-menu-arrow"
|
||||
name="material-symbols:chevron-right-rounded"
|
||||
size="18"
|
||||
></Icon>
|
||||
</a>
|
||||
<RSChildItem
|
||||
v-if="item2.child"
|
||||
:items="item2.child"
|
||||
@openMenu="openMenu"
|
||||
@activeMenu="activeMenu"
|
||||
></RSChildItem>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
145
components/layouts/sidemenu/ItemChild.vue
Normal file
145
components/layouts/sidemenu/ItemChild.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup>
|
||||
import { useLayoutStore } from "~/stores/layout";
|
||||
import { useWindowSize } from "vue-window-size";
|
||||
import RSChildItem from "~/components/layouts/sidemenu/ItemChild.vue";
|
||||
import { useUserStore } from "~/stores/user";
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
const mobileWidth = layoutStore.mobileWidth;
|
||||
const { width } = useWindowSize();
|
||||
|
||||
const user = useUserStore();
|
||||
const route = useRoute();
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
indent: {
|
||||
type: Number,
|
||||
default: 0.5,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(["openMenu"]);
|
||||
|
||||
const indent = ref(props.indent);
|
||||
|
||||
const menuItem = props.items ? props.items : [];
|
||||
|
||||
const username = user.username;
|
||||
const roles = user.roles;
|
||||
|
||||
// validate userExist on meta.auth.user
|
||||
function userExist(item) {
|
||||
if (item.meta?.auth?.user) {
|
||||
if (item.meta?.auth?.user.includes(username)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// validate roleExist on meta.auth.role
|
||||
function roleExist(item) {
|
||||
if (item.meta?.auth?.role) {
|
||||
if (item.meta?.auth?.role.some((r) => roles.includes(r))) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Toggle Open/Close menu
|
||||
function openMenu(event) {
|
||||
emit("openMenu", event);
|
||||
}
|
||||
|
||||
// Active menu
|
||||
function activeMenu(routePath) {
|
||||
return route.path == routePath
|
||||
? `bg-[rgb(var(--color-primary))] font-normal text-white active-menu`
|
||||
: `font-light text-white/90 md:transition-all md:duration-200 hover:md:ml-2`;
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
document.querySelector(".v-layout").classList.toggle("menu-hide");
|
||||
document.getElementsByClassName("menu-overlay")[0].classList.toggle("hide");
|
||||
}
|
||||
|
||||
function navigationPage(path, external) {
|
||||
if (width.value <= mobileWidth) toggleMenu();
|
||||
navigateTo(path, {
|
||||
external: external,
|
||||
});
|
||||
}
|
||||
|
||||
const indentStyle = computed(() => {
|
||||
return { "background-color": `rgba(var(--sidebar-menu), ${indent.value})` };
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
class="menu-content hide transition-all duration-300"
|
||||
:style="indentStyle"
|
||||
>
|
||||
<li
|
||||
v-for="(item, index) in menuItem"
|
||||
:key="index"
|
||||
@click.stop="
|
||||
item.child !== undefined || (item.child && item.child.length !== 0)
|
||||
? openMenu($event)
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
!item.meta || !item.meta?.auth || (userExist(item) && roleExist(item))
|
||||
"
|
||||
class="navigation-item-wrapper"
|
||||
>
|
||||
<NuxtLink
|
||||
v-if="
|
||||
item.child === undefined || (item.child && item.child.length === 0)
|
||||
"
|
||||
class="flex items-center px-6 py-3 cursor-pointer"
|
||||
@click="navigationPage(item.path, item.external)"
|
||||
:class="activeMenu(item.path)"
|
||||
>
|
||||
<Icon v-if="item.icon" :name="item.icon" size="18"></Icon>
|
||||
<span class="mx-4 font-normal">{{ item.title }}</span>
|
||||
<Icon
|
||||
v-if="item.child && item.child.length > 0"
|
||||
class="ml-auto side-menu-arrow"
|
||||
name="material-symbols:chevron-right-rounded"
|
||||
size="18"
|
||||
></Icon>
|
||||
</NuxtLink>
|
||||
<a
|
||||
v-else
|
||||
class="flex items-center px-6 py-3 rounded-lg cursor-pointer"
|
||||
:class="activeMenu(item.path)"
|
||||
>
|
||||
<span class="mx-3 font-normal">{{ item.title }}</span>
|
||||
<Icon
|
||||
v-if="item.child && item.child.length > 0"
|
||||
class="ml-auto side-menu-arrow"
|
||||
name="material-symbols:chevron-right-rounded"
|
||||
size="18"
|
||||
></Icon>
|
||||
</a>
|
||||
<RSChildItem
|
||||
v-if="item.child"
|
||||
:items="item.child"
|
||||
:indent="indent + 0.1"
|
||||
@openMenu="openMenu"
|
||||
@activeMenu="activeMenu"
|
||||
></RSChildItem>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
72
components/layouts/sidemenu/index.vue
Normal file
72
components/layouts/sidemenu/index.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
import Menu from "~/navigation/index.js";
|
||||
import RSItem from "~/components/layouts/sidemenu/Item.vue";
|
||||
|
||||
// Use site settings composable
|
||||
const { siteSettings } = useSiteSettings();
|
||||
|
||||
// Add computed to ensure logo reactivity
|
||||
const logoToShow = computed(() => {
|
||||
// Always try to use the siteLogo from settings first
|
||||
if (siteSettings.value?.siteLogo && siteSettings.value.siteLogo.trim() !== "") {
|
||||
return siteSettings.value.siteLogo;
|
||||
}
|
||||
// Fallback to default logo if siteLogo is not set
|
||||
return "/img/logo/corradAF-logo.svg";
|
||||
});
|
||||
|
||||
const siteNameToShow = computed(() => {
|
||||
return siteSettings.value.siteName || "Jabatan Imigresen Malaysia";
|
||||
});
|
||||
|
||||
// const menuItem = Menu;
|
||||
|
||||
const props = defineProps({
|
||||
menuItem: {
|
||||
type: Array,
|
||||
default: () => Menu,
|
||||
required: false,
|
||||
},
|
||||
sidebarToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
const el = document.querySelector(".active-menu").closest(".menu-content");
|
||||
const elParent = el.parentElement.parentElement;
|
||||
|
||||
if (elParent) {
|
||||
elParent.classList.remove("hide");
|
||||
elParent.querySelector("a").classList.add("nav-open");
|
||||
}
|
||||
if (el) el.classList.remove("hide");
|
||||
} catch (e) {
|
||||
// console.log(e);
|
||||
return;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vertical-menu">
|
||||
<div class="py-2 px-4 bg-[rgb(var(--header))]">
|
||||
<nuxt-link to="/">
|
||||
<div class="flex flex-auto gap-3 justify-center items-center h-[48px]">
|
||||
<div
|
||||
class="app-logo text-center h-20 flex justify-center items-center gap-3 px-4"
|
||||
>
|
||||
<nuxt-link to="/" class="flex items-center justify-center">
|
||||
<img src="@/assets/img/logo/lzs-logo.png" class="h-12" alt="logo" />
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<NuxtScrollbar class="flex flex-col justify-between my-6" style="max-height: 82dvh">
|
||||
<RSItem :items="menuItem"></RSItem>
|
||||
</NuxtScrollbar>
|
||||
</div>
|
||||
</template>
|
||||
41
components/layouts/vertical/index.vue
Normal file
41
components/layouts/vertical/index.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import { useLayoutStore } from "~/stores/layout";
|
||||
|
||||
import RSHeader from "~/components/layouts/Header.vue";
|
||||
import RSSideMenu from "~~/components/layouts/sidemenu/index.vue";
|
||||
// import RSConfigMenu from "~~/components/layouts/configmenu/index.vue";
|
||||
// import RSFooter from "~/components/layouts/Footer.vue";
|
||||
import { useWindowSize } from "vue-window-size";
|
||||
|
||||
const { width } = useWindowSize();
|
||||
const layoutStore = useLayoutStore();
|
||||
const mobileWidth = layoutStore.mobileWidth;
|
||||
|
||||
// watch for window size changes
|
||||
watch(
|
||||
() => [width.value],
|
||||
([width]) => {
|
||||
if (width <= mobileWidth) {
|
||||
document.querySelector(".v-layout").classList.add("menu-hide");
|
||||
document.getElementsByClassName("menu-overlay")[0].classList.add("hide");
|
||||
} else document.querySelector(".v-layout").classList.remove("menu-hide");
|
||||
}
|
||||
);
|
||||
|
||||
function toggleMenu(event) {
|
||||
document.querySelector(".v-layout").classList.toggle("menu-hide");
|
||||
document.getElementsByClassName("menu-overlay")[0].classList.toggle("hide");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RSHeader @toggleMenu="toggleMenu" />
|
||||
<RSSideMenu />
|
||||
<div class="content-page duration-300">
|
||||
<slot />
|
||||
</div>
|
||||
<!-- <RSConfigMenu /> -->
|
||||
<div @click="toggleMenu" class="menu-overlay"></div>
|
||||
|
||||
<!-- <RSFooter /> -->
|
||||
</template>
|
||||
Reference in New Issue
Block a user