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:
Haqeem Solehan
2025-10-16 16:05:39 +08:00
commit b124ff8092
336 changed files with 94392 additions and 0 deletions

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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
View 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>

View 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
View 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
View 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>

View 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
View 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
View 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
View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,7 @@
<script setup>
</script>
<template>
<div></div>
</template>

View 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>

View 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>

View 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>

View 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>

View 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>