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:
88
components/layouts/Breadcrumb.vue
Normal file
88
components/layouts/Breadcrumb.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
const route = useRoute();
|
||||
|
||||
// Get breadcrumb from page meta
|
||||
const breadcrumb = computed(() => {
|
||||
let breadcrumb = null;
|
||||
const matched = route.matched;
|
||||
|
||||
if (matched[matched.length - 1].meta?.breadcrumb) {
|
||||
breadcrumb = matched[matched.length - 1].meta.breadcrumb;
|
||||
} else {
|
||||
// if no breadcrumb in page meta, get breadcrumb from route matched
|
||||
breadcrumb = matched.map((item) => {
|
||||
return {
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
};
|
||||
});
|
||||
|
||||
return breadcrumb;
|
||||
}
|
||||
|
||||
// if type current overwrite path to its own path
|
||||
if (breadcrumb) {
|
||||
breadcrumb.forEach((item) => {
|
||||
if (item.type == "current") {
|
||||
item.path = route.path;
|
||||
} else if (item.type == "parent") {
|
||||
item.path = route.path.split("/").slice(0, -item.parentNo).join("/");
|
||||
}
|
||||
});
|
||||
}
|
||||
return breadcrumb;
|
||||
});
|
||||
|
||||
// Get title from page meta
|
||||
const title = computed(() => {
|
||||
const matched = route.matched;
|
||||
const title = matched[matched.length - 1].name;
|
||||
return title;
|
||||
});
|
||||
|
||||
async function navigateMenu(path) {
|
||||
try {
|
||||
await navigateTo(path);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="breadcrumb" class="mb-6">
|
||||
<nav aria-label="Breadcrumb" class="mb-4">
|
||||
<ol class="flex items-center text-sm">
|
||||
<li class="flex items-center">
|
||||
<NuxtLink to="/" class="text-gray-500 hover:text-gray-700">
|
||||
<Icon name="mdi:home" size="16" />
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li v-for="(item, index) in breadcrumb" :key="index" class="flex items-center">
|
||||
<Icon
|
||||
name="mdi:chevron-right"
|
||||
size="16"
|
||||
class="mx-2 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<a
|
||||
@click="navigateMenu(item.path)"
|
||||
class="cursor-pointer capitalize"
|
||||
:class="{
|
||||
'text-gray-500 hover:text-gray-700': index !== breadcrumb.length - 1,
|
||||
'text-primary font-medium': index === breadcrumb.length - 1,
|
||||
}"
|
||||
:aria-current="index === breadcrumb.length - 1 ? 'page' : undefined"
|
||||
>
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- <div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-semibold text-gray-800">{{ title }}</h1>
|
||||
<slot name="right"></slot>
|
||||
</div> -->
|
||||
</div>
|
||||
</template>
|
||||
98
components/layouts/BreadcrumbV2.vue
Normal file
98
components/layouts/BreadcrumbV2.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script setup>
|
||||
const route = useRoute();
|
||||
|
||||
// Get breadcrumb from page meta
|
||||
const breadcrumb = computed(() => {
|
||||
let breadcrumb = null;
|
||||
const matched = route.matched;
|
||||
|
||||
if (matched[matched.length - 1].meta?.breadcrumb) {
|
||||
breadcrumb = matched[matched.length - 1].meta.breadcrumb;
|
||||
} else {
|
||||
// if no breadcrumb in page meta, get breadcrumb from route matched
|
||||
breadcrumb = matched.map((item) => {
|
||||
return {
|
||||
name: item.meta.title,
|
||||
path: item.path,
|
||||
};
|
||||
});
|
||||
|
||||
return breadcrumb;
|
||||
}
|
||||
|
||||
// if type current overwrite path to its own path
|
||||
if (breadcrumb) {
|
||||
breadcrumb.forEach((item) => {
|
||||
if (item.type == "current") {
|
||||
item.path = route.path;
|
||||
} else if (item.type == "parent") {
|
||||
item.path = route.path.split("/").slice(0, -item.parentNo).join("/");
|
||||
}
|
||||
});
|
||||
}
|
||||
return breadcrumb;
|
||||
});
|
||||
|
||||
// Get title from page meta
|
||||
const title = computed(() => {
|
||||
const matched = route.matched;
|
||||
const title = matched[matched.length - 1].meta.title;
|
||||
return title;
|
||||
});
|
||||
|
||||
async function navigateMenu(path) {
|
||||
try {
|
||||
await navigateTo(path);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="breadcrumb">
|
||||
<div class="flex flex-col md:flex-row items-stretch justify-between">
|
||||
<div
|
||||
class="flex items-center"
|
||||
v-if="breadcrumb && breadcrumb.length != 0"
|
||||
>
|
||||
<span
|
||||
v-for="(item, index) in breadcrumb"
|
||||
:key="index"
|
||||
class="flex items-center text-primary"
|
||||
>
|
||||
<Icon
|
||||
v-if="index != 0"
|
||||
name="ic:round-chevron-right"
|
||||
size="14"
|
||||
class="mr-1 text-gray-500"
|
||||
></Icon>
|
||||
<a
|
||||
@click="navigateMenu(item.path)"
|
||||
class="cursor-pointer hover:underline pr-1 font-normal text-sm text-gray-500"
|
||||
:class="{
|
||||
'!text-primary': breadcrumb.length - 1 == index,
|
||||
}"
|
||||
>{{ item.name }}</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<slot name="right"></slot>
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-col min-w-0 lg:mb-0 break-words w-full">
|
||||
<div class="rounded-t mb-5">
|
||||
<div class="flex flex-wrap items-center">
|
||||
<div>
|
||||
<div>
|
||||
<span class="text-xl font-semibold text-gray-600">
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
20
components/layouts/FormHeader.vue
Normal file
20
components/layouts/FormHeader.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup></script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="w-full h-14 z-20 bg-white dark:bg-slate-800 fixed top-0 right-0 px-5 py-3 duration-300 shadow-md shadow-slate-200 dark:shadow-slate-900"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-800 dark:text-white">
|
||||
<Icon
|
||||
name="material-symbols:chevron-left-rounded"
|
||||
class="inline-block w-6 h-6 mr-2"
|
||||
/>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
327
components/layouts/Header.vue
Normal file
327
components/layouts/Header.vue
Normal file
@@ -0,0 +1,327 @@
|
||||
<script setup>
|
||||
const isVertical = ref(true);
|
||||
const isDesktop = ref(true);
|
||||
|
||||
const emit = defineEmits(["toggleMenu"]);
|
||||
|
||||
// Use site settings composable
|
||||
const { siteSettings, setTheme, getCurrentTheme } = useSiteSettings();
|
||||
|
||||
// const { locale } = useI18n();
|
||||
// const colorMode = useColorMode();
|
||||
const langList = languageList();
|
||||
|
||||
const locale = ref("en");
|
||||
|
||||
const themes = themeList();
|
||||
const themes2 = themeList2();
|
||||
|
||||
function setThemeLocal(theme) {
|
||||
setTheme(theme); // Use the site settings setTheme function
|
||||
}
|
||||
|
||||
function rgbToHex(rgbString) {
|
||||
// Split the input string into an array of components
|
||||
const rgbArray = rgbString.split(",");
|
||||
|
||||
// Convert each component to its numeric value
|
||||
const r = parseInt(rgbArray[0].trim(), 10);
|
||||
const g = parseInt(rgbArray[1].trim(), 10);
|
||||
const b = parseInt(rgbArray[2].trim(), 10);
|
||||
|
||||
// Convert the numeric RGB values to hexadecimal
|
||||
const rHex = r.toString(16).padStart(2, "0");
|
||||
const gHex = g.toString(16).padStart(2, "0");
|
||||
const bHex = b.toString(16).padStart(2, "0");
|
||||
|
||||
// Concatenate the components and return the final hexadecimal color code
|
||||
return `#${rHex}${gHex}${bHex}`;
|
||||
}
|
||||
|
||||
// Toggle Open/Close menu
|
||||
const toggleMenu = (event) => {
|
||||
emit("toggleMenu", event);
|
||||
};
|
||||
|
||||
// Focus on search input
|
||||
function toggleSearch() {
|
||||
document.getElementById("header-search").value = "";
|
||||
document.getElementById("header-search").focus();
|
||||
}
|
||||
|
||||
// Change language
|
||||
const changeLanguage = (lang) => {
|
||||
locale.value = lang;
|
||||
};
|
||||
|
||||
const languageNow = computed(() => {
|
||||
return langList.find((lang) => lang.value == locale.value);
|
||||
});
|
||||
|
||||
// Get current theme icon
|
||||
const currentThemeIcon = computed(() => {
|
||||
const theme = getCurrentTheme();
|
||||
return theme === "dark" ? "ic:outline-dark-mode" : "ic:outline-light-mode";
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// If mobile toggleMenu
|
||||
if (window.innerWidth < 768) {
|
||||
emit("toggleMenu", true);
|
||||
}
|
||||
|
||||
// Load site settings on mount and ensure they're properly populated
|
||||
const { loadSiteSettings } = useSiteSettings();
|
||||
loadSiteSettings().then(() => {
|
||||
nextTick(() => {
|
||||
// console.log('[Header.vue] Site settings loaded. Name:', siteSettings.value?.siteName, 'ShowInHeader:', siteSettings.value?.showSiteNameInHeader, 'Logo:', siteSettings.value?.siteLogo);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Add computed to ensure logo reactivity
|
||||
const currentLogo = computed(() => {
|
||||
const logoUrl = siteSettings.value?.siteLogo;
|
||||
if (logoUrl && logoUrl.trim() !== "") {
|
||||
return logoUrl; // Use logo from settings if available and not empty
|
||||
}
|
||||
return "/img/logo/corradAF-logo.svg"; // Ultimate fallback
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-header">
|
||||
<div class="flex items-stretch justify-between">
|
||||
<div v-if="isVertical" class="flex">
|
||||
<span class="flex items-center justify-center">
|
||||
<button class="icon-btn h-10 w-10 rounded-full" @click="toggleMenu">
|
||||
<Icon name="ic:round-menu" class="" />
|
||||
</button>
|
||||
</span>
|
||||
<!-- Site logo and name for vertical layout - only show if explicitly enabled -->
|
||||
<div
|
||||
v-if="siteSettings?.value?.showSiteNameInHeader"
|
||||
class="flex items-center ml-4"
|
||||
>
|
||||
<img
|
||||
:src="currentLogo"
|
||||
:alt="siteSettings?.value?.siteName || 'Site Logo'"
|
||||
class="h-8 block"
|
||||
@error="$event.target.src = '/img/logo/corradAF-logo.svg'"
|
||||
/>
|
||||
<span
|
||||
v-if="siteSettings?.value?.siteName"
|
||||
class="text-lg font-semibold"
|
||||
:class="{ 'ml-3': siteSettings?.value?.siteLogo }"
|
||||
:style="{
|
||||
fontSize: (siteSettings?.value?.siteNameFontSize || 18) + 'px',
|
||||
}"
|
||||
>
|
||||
{{ siteSettings?.value?.siteName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex" v-else>
|
||||
<nuxt-link to="/">
|
||||
<div class="flex flex-auto gap-3 justify-center items-center">
|
||||
<img
|
||||
:src="currentLogo"
|
||||
:alt="siteSettings?.value?.siteName || 'Site Logo'"
|
||||
class="h-8 block"
|
||||
@error="$event.target.src = '/img/logo/corradAF-logo.svg'"
|
||||
/>
|
||||
<span
|
||||
v-if="
|
||||
siteSettings?.value?.siteName && siteSettings?.value?.showSiteNameInHeader
|
||||
"
|
||||
class="text-lg font-semibold"
|
||||
:style="{
|
||||
fontSize: (siteSettings?.value?.siteNameFontSize || 18) + 'px',
|
||||
}"
|
||||
>
|
||||
{{ siteSettings?.value?.siteName }}
|
||||
</span>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 item-center justify-items-end">
|
||||
<VoiceReader class="ml-4" />
|
||||
|
||||
<!-- New dropdown for themeList.js -->
|
||||
<VDropdown placement="bottom-end" distance="13" name="theme">
|
||||
<button class="icon-btn h-10 w-10 rounded-full">
|
||||
<Icon size="22px" name="ph:paint-brush-broad" />
|
||||
</button>
|
||||
<template #popper>
|
||||
<ul class="header-dropdown w-full md:w-52">
|
||||
<li v-for="(val, index) in themes" :key="index">
|
||||
<a
|
||||
@click="setThemeLocal(val.theme)"
|
||||
class="flex justify-between items-center cursor-pointer py-2 px-4 hover:bg-[rgb(var(--bg-1))]"
|
||||
>
|
||||
<span class="capitalize"> {{ val.theme }} </span>
|
||||
<div class="flex items-center gap-x-1">
|
||||
<div
|
||||
v-for="(color, colorIndex) in val.colors"
|
||||
:key="colorIndex"
|
||||
class="h-[25px] w-[10px] rounded-lg"
|
||||
:style="{
|
||||
backgroundColor: rgbToHex(color.value),
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</VDropdown>
|
||||
|
||||
<!-- New dropdown for themeList2.js -->
|
||||
<VDropdown placement="bottom-end" distance="13" name="theme2">
|
||||
<button class="icon-btn h-10 w-10 rounded-full">
|
||||
<Icon size="22px" name="ph:wheelchair" />
|
||||
</button>
|
||||
<template #popper>
|
||||
<ul class="header-dropdown w-full md:w-52">
|
||||
<li v-for="(val, index) in themes2" :key="index">
|
||||
<a
|
||||
@click="setThemeLocal(val.theme)"
|
||||
class="flex justify-between items-center cursor-pointer py-2 px-4 hover:bg-[rgb(var(--bg-1))]"
|
||||
>
|
||||
<span class="capitalize"> {{ val.theme }} </span>
|
||||
<div class="flex items-center gap-x-1">
|
||||
<div
|
||||
v-for="(color, colorIndex) in val.colors"
|
||||
:key="colorIndex"
|
||||
class="h-[25px] w-[10px] rounded-lg"
|
||||
:style="{
|
||||
backgroundColor: rgbToHex(color.value),
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</VDropdown>
|
||||
|
||||
<VDropdown placement="bottom-end" distance="13" name="notification">
|
||||
<button class="relative icon-btn h-10 w-10 rounded-full">
|
||||
<span class="w-3 h-3 absolute top-1 right-2 rounded-full bg-primary"></span>
|
||||
<Icon name="ic:round-notifications-none" class="" />
|
||||
</button>
|
||||
<template #popper>
|
||||
<ul class="header-dropdown w-full md:w-80 text-[#4B5563]">
|
||||
<li class="d-head flex items-center justify-between py-2 px-4">
|
||||
<span class="font-semibold">Notification</span>
|
||||
<div
|
||||
class="flex items-center text-primary cursor-pointer hover:underline"
|
||||
>
|
||||
<a class="ml-2">View All</a>
|
||||
</div>
|
||||
</li>
|
||||
<NuxtScrollbar>
|
||||
<li>
|
||||
<div class="bg-[rgb(var(--bg-1))] py-2 px-4">Today</div>
|
||||
<a class="py-2 px-4 block">
|
||||
<div class="flex items-center">
|
||||
<Icon name="ic:outline-circle" class="text-primary flex-none" />
|
||||
<span class="mx-2"
|
||||
>Terdapat Satu Pembayaran yang berlaku menggunakan bil Kuih Raya
|
||||
Cik Kiah</span
|
||||
>
|
||||
<div class="w-12 h-12 rounded-full ml-auto flex-none">
|
||||
<img class="rounded-full" src="@/assets/img/user/default.svg" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="py-2 px-4 block">
|
||||
<div class="flex items-center">
|
||||
<Icon name="ic:outline-circle" class="text-primary flex-none" />
|
||||
<span class="mx-2"
|
||||
>Terdapat Satu Pembayaran yang berlaku menggunakan bil
|
||||
Mercun</span
|
||||
>
|
||||
<div class="w-12 h-12 rounded-full ml-auto flex-none">
|
||||
<img
|
||||
class="rounded-full"
|
||||
src="@/assets/img/user/default.svg"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</NuxtScrollbar>
|
||||
</ul>
|
||||
</template>
|
||||
</VDropdown>
|
||||
|
||||
<VDropdown
|
||||
placement="bottom-end"
|
||||
distance="13"
|
||||
name="profile"
|
||||
class="flex justify-center item-center"
|
||||
>
|
||||
<button class="icon-btn profile px-2">
|
||||
<img
|
||||
class="w-8 h-8 object-cover rounded-full"
|
||||
src="@/assets/img/user/default.svg"
|
||||
/>
|
||||
<div v-if="isDesktop" class="grid grid-cols-1 text-left ml-3 flex-none">
|
||||
<p class="font-semibold text-sm truncate w-24 mb-0">Johan</p>
|
||||
</div>
|
||||
<Icon name="ic:outline-keyboard-arrow-down" class="ml-3" />
|
||||
</button>
|
||||
<template #popper>
|
||||
<ul class="header-dropdown w-full md:w-52">
|
||||
<li>
|
||||
<a
|
||||
href="/logout"
|
||||
class="flex items-center cursor-pointer py-2 px-4 hover:bg-[rgb(var(--bg-1))]"
|
||||
>
|
||||
<Icon name="ic:outline-logout" class="mr-2" />
|
||||
Logout
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Nav for Layout Vertical -->
|
||||
<div tabindex="0" class="w-header-search">
|
||||
<Icon name="ic:outline-search" class="mr-3" />
|
||||
<FormKit
|
||||
id="header-search"
|
||||
:classes="{
|
||||
outer: 'mb-0 flex-1',
|
||||
}"
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.popper) {
|
||||
background: #e92791;
|
||||
padding: 20px;
|
||||
border-radius: 20px;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
:deep(.popper #arrow::before) {
|
||||
background: #e92791;
|
||||
}
|
||||
|
||||
:deep(.popper:hover),
|
||||
:deep(.popper:hover > #arrow::before) {
|
||||
background: #e92791;
|
||||
}
|
||||
</style>
|
||||
241
components/layouts/configmenu/index.vue
Normal file
241
components/layouts/configmenu/index.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<script setup>
|
||||
import { useThemeStore } from "~/stores/theme";
|
||||
|
||||
const colorMode = useColorMode();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const hideConfigMenu = ref(true);
|
||||
const rgbColor = ref({});
|
||||
|
||||
const hexToRgb = (hex) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `${r}, ${g}, ${b}`;
|
||||
};
|
||||
|
||||
const setThemeColor = (type, color) => {
|
||||
themeStore.setThemeColor(type, color);
|
||||
|
||||
// Remove Color form type string
|
||||
const colorType = type.replace("Color", "").toLowerCase();
|
||||
document.documentElement.style.setProperty(
|
||||
`--color-${colorType}`,
|
||||
hexToRgb(color)
|
||||
);
|
||||
};
|
||||
|
||||
function setColorMode() {
|
||||
if (colorMode.preference == "light") {
|
||||
colorMode.preference = "dark";
|
||||
|
||||
document.documentElement.classList.remove("light");
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
colorMode.preference = "light";
|
||||
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.documentElement.classList.add("light");
|
||||
}
|
||||
}
|
||||
|
||||
const setThemeGlobal = () => {
|
||||
rgbColor.value.primary = themeStore.primaryColor;
|
||||
rgbColor.value.secondary = themeStore.secondaryColor;
|
||||
rgbColor.value.info = themeStore.infoColor;
|
||||
rgbColor.value.success = themeStore.successColor;
|
||||
rgbColor.value.warning = themeStore.warningColor;
|
||||
rgbColor.value.danger = themeStore.dangerColor;
|
||||
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-primary",
|
||||
hexToRgb(themeStore.primaryColor)
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-secondary",
|
||||
hexToRgb(themeStore.secondaryColor)
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-info",
|
||||
hexToRgb(themeStore.infoColor)
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-success",
|
||||
hexToRgb(themeStore.successColor)
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-warning",
|
||||
hexToRgb(themeStore.warningColor)
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-danger",
|
||||
hexToRgb(themeStore.dangerColor)
|
||||
);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setThemeGlobal();
|
||||
});
|
||||
|
||||
const resetTheme = () => {
|
||||
themeStore.resetThemeColor();
|
||||
setThemeGlobal();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
:class="{
|
||||
'right-[-300px]': hideConfigMenu,
|
||||
}"
|
||||
class="h-full w-[300px] bg-white dark:bg-slate-800 fixed top-0 right-0 z-20 shadow-md shadow-slate-200 dark:shadow-slate-900 duration-300"
|
||||
>
|
||||
<div
|
||||
@click="hideConfigMenu = !hideConfigMenu"
|
||||
class="py-3 px-4 bg-primary text-white rounded-l-lg absolute bottom-1 left-[-53px] cursor-pointer hover:bg-primary/90 hidden md:block"
|
||||
>
|
||||
<Icon name="fluent:paint-brush-16-regular" size="22px" />
|
||||
</div>
|
||||
<div class="p-4 flex font-normal border-b">
|
||||
<p class="flex-1">
|
||||
<span class="font-bold text-base text-gray-600 dark:text-white"
|
||||
>Theme Customizer</span
|
||||
>
|
||||
<br />
|
||||
<span class="text-sm text-gray-500 dark:text-gray-300">
|
||||
Customize Theme Real Time
|
||||
</span>
|
||||
</p>
|
||||
<div class="flex justify-center items-center">
|
||||
<Icon
|
||||
@click="hideConfigMenu = !hideConfigMenu"
|
||||
class="cursor-pointer"
|
||||
name="material-symbols:close-rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<p class="text-sm mb-3 font-bold">Color Mode</p>
|
||||
<FormKit
|
||||
v-model="colorMode.value"
|
||||
type="radio"
|
||||
label="Theme"
|
||||
:options="[
|
||||
{
|
||||
label: 'Light',
|
||||
value: 'light',
|
||||
},
|
||||
{
|
||||
label: 'Dark',
|
||||
value: 'dark',
|
||||
},
|
||||
]"
|
||||
:classes="{
|
||||
fieldset: 'border-0 !p-0',
|
||||
legend: '!font-medium !text-sm mb-0',
|
||||
options: '!flex !flex-row gap-4 mt-2',
|
||||
input: '!rounded-full',
|
||||
}"
|
||||
@change="setColorMode"
|
||||
/>
|
||||
<div class="flex justify-between">
|
||||
<p class="text-sm mb-3 font-bold">Theming</p>
|
||||
<a
|
||||
@click="resetTheme"
|
||||
class="underline text-blue-600 cursor-pointer hover:text-blue-400"
|
||||
>Reset</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2">
|
||||
<FormKit
|
||||
type="color"
|
||||
v-model="rgbColor.primary"
|
||||
label="Primary"
|
||||
:classes="{
|
||||
label: '!font-medium !text-sm mb-0',
|
||||
outer: '!mb-0',
|
||||
}"
|
||||
@input="setThemeColor('primaryColor', rgbColor.primary)"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="color"
|
||||
v-model="rgbColor.secondary"
|
||||
label="Secondary"
|
||||
:classes="{
|
||||
label: '!font-medium !text-sm mb-0',
|
||||
outer: '!mb-0',
|
||||
}"
|
||||
@input="setThemeColor('secondaryColor', rgbColor.secondary)"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="color"
|
||||
v-model="rgbColor.info"
|
||||
label="Info"
|
||||
:classes="{
|
||||
label: '!font-medium !text-sm mb-0',
|
||||
outer: '!mb-0',
|
||||
}"
|
||||
@input="setThemeColor('infoColor', rgbColor.info)"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="color"
|
||||
v-model="rgbColor.success"
|
||||
label="Success"
|
||||
:classes="{
|
||||
label: '!font-medium !text-sm mb-0',
|
||||
outer: '!mb-0',
|
||||
}"
|
||||
@input="setThemeColor('successColor', rgbColor.success)"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="color"
|
||||
v-model="rgbColor.warning"
|
||||
label="Warning"
|
||||
:classes="{
|
||||
label: '!font-medium !text-sm mb-0',
|
||||
outer: '!mb-0',
|
||||
}"
|
||||
@input="setThemeColor('warningColor', rgbColor.warning)"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="color"
|
||||
v-model="rgbColor.danger"
|
||||
label="Danger"
|
||||
:classes="{
|
||||
label: '!font-medium !text-sm mb-0',
|
||||
outer: '!mb-0',
|
||||
}"
|
||||
@input="setThemeColor('dangerColor', rgbColor.danger)"
|
||||
/>
|
||||
</div>
|
||||
<hr class="my-4" />
|
||||
<p class="text-sm mb-3 font-bold">Preview</p>
|
||||
<rs-button variant="primary" class="w-full mb-4 cursor-default">
|
||||
Primary Color
|
||||
</rs-button>
|
||||
<rs-button variant="secondary" class="w-full mb-4 cursor-default">
|
||||
Secondary Color
|
||||
</rs-button>
|
||||
<rs-button variant="info" class="w-full mb-4 cursor-default">
|
||||
Info Color
|
||||
</rs-button>
|
||||
<rs-button variant="success" class="w-full mb-4 cursor-default">
|
||||
Success Color
|
||||
</rs-button>
|
||||
<rs-button variant="warning" class="w-full mb-4 cursor-default">
|
||||
Warning Color
|
||||
</rs-button>
|
||||
<rs-button variant="danger" class="w-full mb-4 cursor-default">
|
||||
Danger Color
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
7
components/layouts/horizontal/index.vue
Normal file
7
components/layouts/horizontal/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
25
components/layouts/index.vue
Normal file
25
components/layouts/index.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import { useThemeStore } from "~/stores/theme";
|
||||
|
||||
// Import Layout Components
|
||||
import RSVertical from "~/components/layouts/vertical/index.vue";
|
||||
import RSHorizontal from "~/components/layouts/horizontal/index.vue";
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const layoutType = themeStore.layoutType;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-wrapper">
|
||||
<div v-if="layoutType === 'vertical'" class="v-layout h-100">
|
||||
<RSVertical>
|
||||
<slot />
|
||||
</RSVertical>
|
||||
</div>
|
||||
<div v-if="layoutType === 'horizontal'" class="h-layout h-100">
|
||||
<RSHorizontal>
|
||||
<slot />
|
||||
</RSHorizontal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
166
components/layouts/sidemenu/Item.vue
Normal file
166
components/layouts/sidemenu/Item.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<script setup>
|
||||
import { useLayoutStore } from "~/stores/layout";
|
||||
import { useWindowSize } from "vue-window-size";
|
||||
import RSChildItem from "~/components/layouts/sidemenu/ItemChild.vue";
|
||||
import { useUserStore } from "~/stores/user";
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
const mobileWidth = layoutStore.mobileWidth;
|
||||
const { width } = useWindowSize();
|
||||
|
||||
const user = useUserStore();
|
||||
const route = useRoute();
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const username = user.username;
|
||||
const roles = user.roles;
|
||||
|
||||
const menuItem = props.items ? props.items : [];
|
||||
|
||||
// validate userExist on meta.auth.user
|
||||
function userExist(item) {
|
||||
if (item.meta?.auth?.user) {
|
||||
if (item.meta?.auth?.user.some((e) => e === username)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// validate roleExist on meta.auth.role
|
||||
function roleExist(item) {
|
||||
if (item.meta?.auth?.role) {
|
||||
if (item.meta?.auth?.role.some((e) => roles?.includes(e))) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Toggle show and hide menu content
|
||||
function openMenu(event) {
|
||||
const target = event.currentTarget;
|
||||
try {
|
||||
target.querySelector("a").classList.toggle("nav-open");
|
||||
target.querySelector("ul").classList.toggle("hide");
|
||||
} catch (e) {
|
||||
// console.log(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Active menu
|
||||
function activeMenu(routePath) {
|
||||
return route.path == routePath
|
||||
? `bg-[rgb(var(--color-primary))] font-normal text-white active-menu`
|
||||
: `font-light text-white/90 md:transition-all md:duration-200 hover:md:ml-2`;
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
document.querySelector(".v-layout").classList.toggle("menu-hide");
|
||||
document.getElementsByClassName("menu-overlay")[0].classList.toggle("hide");
|
||||
}
|
||||
|
||||
function navigationPage(path, external) {
|
||||
if (width.value <= mobileWidth) toggleMenu();
|
||||
navigateTo(path, {
|
||||
external: external,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-for="(item, index) in menuItem" :key="index">
|
||||
<div
|
||||
v-if="
|
||||
!item.meta || !item.meta?.auth || (userExist(item) && roleExist(item))
|
||||
"
|
||||
class="navigation-wrapper"
|
||||
>
|
||||
<div
|
||||
v-if="item.header"
|
||||
class="text-left font-normal text-xs mx-6 mt-5 mb-2"
|
||||
>
|
||||
<span class="uppercase text-gray-400">
|
||||
{{ item.header ? item.header : "" }}
|
||||
</span>
|
||||
<p class="text-gray-400">
|
||||
{{ item.description ? item.description : "" }}
|
||||
</p>
|
||||
</div>
|
||||
<ul class="navigation-menu">
|
||||
<li
|
||||
class="navigation-item"
|
||||
v-for="(item2, index2) in item.child"
|
||||
:key="index2"
|
||||
@click.stop="
|
||||
item2.child !== undefined ||
|
||||
(item2.child && item2.child.length !== 0)
|
||||
? openMenu($event)
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
!item2.meta ||
|
||||
!item2.meta?.auth ||
|
||||
(userExist(item2) && roleExist(item2))
|
||||
"
|
||||
class="navigation-item-wrapper"
|
||||
>
|
||||
<NuxtLink
|
||||
v-if="
|
||||
item2.child === undefined ||
|
||||
(item2.child && item2.child.length === 0)
|
||||
"
|
||||
class="flex items-center px-6 py-3 cursor-pointer"
|
||||
@click="navigationPage(item2.path, item2.external)"
|
||||
:class="activeMenu(item2.path)"
|
||||
>
|
||||
<Icon v-if="item2.icon" :name="item2.icon" size="18"></Icon>
|
||||
<Icon v-else name="mdi:circle-slice-8" size="18"></Icon>
|
||||
<span class="mx-3 font-normal">{{ item2.title }}</span>
|
||||
<Icon
|
||||
v-if="item2.child && item2.child.length > 0"
|
||||
class="ml-auto side-menu-arrow"
|
||||
name="material-symbols:chevron-right-rounded"
|
||||
size="18"
|
||||
></Icon>
|
||||
</NuxtLink>
|
||||
<a
|
||||
v-else
|
||||
class="flex items-center px-6 py-3 rounded-lg cursor-pointer"
|
||||
:class="activeMenu(item2.path)"
|
||||
>
|
||||
<Icon v-if="item2.icon" :name="item2.icon" size="18"></Icon>
|
||||
<Icon v-else name="mdi:circle-slice-8" size="18"></Icon>
|
||||
<span class="mx-3 font-normal">{{ item2.title }}</span>
|
||||
<Icon
|
||||
v-if="item2.child && item2.child.length > 0"
|
||||
class="ml-auto side-menu-arrow"
|
||||
name="material-symbols:chevron-right-rounded"
|
||||
size="18"
|
||||
></Icon>
|
||||
</a>
|
||||
<RSChildItem
|
||||
v-if="item2.child"
|
||||
:items="item2.child"
|
||||
@openMenu="openMenu"
|
||||
@activeMenu="activeMenu"
|
||||
></RSChildItem>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
145
components/layouts/sidemenu/ItemChild.vue
Normal file
145
components/layouts/sidemenu/ItemChild.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup>
|
||||
import { useLayoutStore } from "~/stores/layout";
|
||||
import { useWindowSize } from "vue-window-size";
|
||||
import RSChildItem from "~/components/layouts/sidemenu/ItemChild.vue";
|
||||
import { useUserStore } from "~/stores/user";
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
const mobileWidth = layoutStore.mobileWidth;
|
||||
const { width } = useWindowSize();
|
||||
|
||||
const user = useUserStore();
|
||||
const route = useRoute();
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
indent: {
|
||||
type: Number,
|
||||
default: 0.5,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(["openMenu"]);
|
||||
|
||||
const indent = ref(props.indent);
|
||||
|
||||
const menuItem = props.items ? props.items : [];
|
||||
|
||||
const username = user.username;
|
||||
const roles = user.roles;
|
||||
|
||||
// validate userExist on meta.auth.user
|
||||
function userExist(item) {
|
||||
if (item.meta?.auth?.user) {
|
||||
if (item.meta?.auth?.user.includes(username)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// validate roleExist on meta.auth.role
|
||||
function roleExist(item) {
|
||||
if (item.meta?.auth?.role) {
|
||||
if (item.meta?.auth?.role.some((r) => roles.includes(r))) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Toggle Open/Close menu
|
||||
function openMenu(event) {
|
||||
emit("openMenu", event);
|
||||
}
|
||||
|
||||
// Active menu
|
||||
function activeMenu(routePath) {
|
||||
return route.path == routePath
|
||||
? `bg-[rgb(var(--color-primary))] font-normal text-white active-menu`
|
||||
: `font-light text-white/90 md:transition-all md:duration-200 hover:md:ml-2`;
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
document.querySelector(".v-layout").classList.toggle("menu-hide");
|
||||
document.getElementsByClassName("menu-overlay")[0].classList.toggle("hide");
|
||||
}
|
||||
|
||||
function navigationPage(path, external) {
|
||||
if (width.value <= mobileWidth) toggleMenu();
|
||||
navigateTo(path, {
|
||||
external: external,
|
||||
});
|
||||
}
|
||||
|
||||
const indentStyle = computed(() => {
|
||||
return { "background-color": `rgba(var(--sidebar-menu), ${indent.value})` };
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
class="menu-content hide transition-all duration-300"
|
||||
:style="indentStyle"
|
||||
>
|
||||
<li
|
||||
v-for="(item, index) in menuItem"
|
||||
:key="index"
|
||||
@click.stop="
|
||||
item.child !== undefined || (item.child && item.child.length !== 0)
|
||||
? openMenu($event)
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
!item.meta || !item.meta?.auth || (userExist(item) && roleExist(item))
|
||||
"
|
||||
class="navigation-item-wrapper"
|
||||
>
|
||||
<NuxtLink
|
||||
v-if="
|
||||
item.child === undefined || (item.child && item.child.length === 0)
|
||||
"
|
||||
class="flex items-center px-6 py-3 cursor-pointer"
|
||||
@click="navigationPage(item.path, item.external)"
|
||||
:class="activeMenu(item.path)"
|
||||
>
|
||||
<Icon v-if="item.icon" :name="item.icon" size="18"></Icon>
|
||||
<span class="mx-4 font-normal">{{ item.title }}</span>
|
||||
<Icon
|
||||
v-if="item.child && item.child.length > 0"
|
||||
class="ml-auto side-menu-arrow"
|
||||
name="material-symbols:chevron-right-rounded"
|
||||
size="18"
|
||||
></Icon>
|
||||
</NuxtLink>
|
||||
<a
|
||||
v-else
|
||||
class="flex items-center px-6 py-3 rounded-lg cursor-pointer"
|
||||
:class="activeMenu(item.path)"
|
||||
>
|
||||
<span class="mx-3 font-normal">{{ item.title }}</span>
|
||||
<Icon
|
||||
v-if="item.child && item.child.length > 0"
|
||||
class="ml-auto side-menu-arrow"
|
||||
name="material-symbols:chevron-right-rounded"
|
||||
size="18"
|
||||
></Icon>
|
||||
</a>
|
||||
<RSChildItem
|
||||
v-if="item.child"
|
||||
:items="item.child"
|
||||
:indent="indent + 0.1"
|
||||
@openMenu="openMenu"
|
||||
@activeMenu="activeMenu"
|
||||
></RSChildItem>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
72
components/layouts/sidemenu/index.vue
Normal file
72
components/layouts/sidemenu/index.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
import Menu from "~/navigation/index.js";
|
||||
import RSItem from "~/components/layouts/sidemenu/Item.vue";
|
||||
|
||||
// Use site settings composable
|
||||
const { siteSettings } = useSiteSettings();
|
||||
|
||||
// Add computed to ensure logo reactivity
|
||||
const logoToShow = computed(() => {
|
||||
// Always try to use the siteLogo from settings first
|
||||
if (siteSettings.value?.siteLogo && siteSettings.value.siteLogo.trim() !== "") {
|
||||
return siteSettings.value.siteLogo;
|
||||
}
|
||||
// Fallback to default logo if siteLogo is not set
|
||||
return "/img/logo/corradAF-logo.svg";
|
||||
});
|
||||
|
||||
const siteNameToShow = computed(() => {
|
||||
return siteSettings.value.siteName || "Jabatan Imigresen Malaysia";
|
||||
});
|
||||
|
||||
// const menuItem = Menu;
|
||||
|
||||
const props = defineProps({
|
||||
menuItem: {
|
||||
type: Array,
|
||||
default: () => Menu,
|
||||
required: false,
|
||||
},
|
||||
sidebarToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
const el = document.querySelector(".active-menu").closest(".menu-content");
|
||||
const elParent = el.parentElement.parentElement;
|
||||
|
||||
if (elParent) {
|
||||
elParent.classList.remove("hide");
|
||||
elParent.querySelector("a").classList.add("nav-open");
|
||||
}
|
||||
if (el) el.classList.remove("hide");
|
||||
} catch (e) {
|
||||
// console.log(e);
|
||||
return;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vertical-menu">
|
||||
<div class="py-2 px-4 bg-[rgb(var(--header))]">
|
||||
<nuxt-link to="/">
|
||||
<div class="flex flex-auto gap-3 justify-center items-center h-[48px]">
|
||||
<div
|
||||
class="app-logo text-center h-20 flex justify-center items-center gap-3 px-4"
|
||||
>
|
||||
<nuxt-link to="/" class="flex items-center justify-center">
|
||||
<img src="@/assets/img/logo/lzs-logo.png" class="h-12" alt="logo" />
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<NuxtScrollbar class="flex flex-col justify-between my-6" style="max-height: 82dvh">
|
||||
<RSItem :items="menuItem"></RSItem>
|
||||
</NuxtScrollbar>
|
||||
</div>
|
||||
</template>
|
||||
41
components/layouts/vertical/index.vue
Normal file
41
components/layouts/vertical/index.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import { useLayoutStore } from "~/stores/layout";
|
||||
|
||||
import RSHeader from "~/components/layouts/Header.vue";
|
||||
import RSSideMenu from "~~/components/layouts/sidemenu/index.vue";
|
||||
// import RSConfigMenu from "~~/components/layouts/configmenu/index.vue";
|
||||
// import RSFooter from "~/components/layouts/Footer.vue";
|
||||
import { useWindowSize } from "vue-window-size";
|
||||
|
||||
const { width } = useWindowSize();
|
||||
const layoutStore = useLayoutStore();
|
||||
const mobileWidth = layoutStore.mobileWidth;
|
||||
|
||||
// watch for window size changes
|
||||
watch(
|
||||
() => [width.value],
|
||||
([width]) => {
|
||||
if (width <= mobileWidth) {
|
||||
document.querySelector(".v-layout").classList.add("menu-hide");
|
||||
document.getElementsByClassName("menu-overlay")[0].classList.add("hide");
|
||||
} else document.querySelector(".v-layout").classList.remove("menu-hide");
|
||||
}
|
||||
);
|
||||
|
||||
function toggleMenu(event) {
|
||||
document.querySelector(".v-layout").classList.toggle("menu-hide");
|
||||
document.getElementsByClassName("menu-overlay")[0].classList.toggle("hide");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RSHeader @toggleMenu="toggleMenu" />
|
||||
<RSSideMenu />
|
||||
<div class="content-page duration-300">
|
||||
<slot />
|
||||
</div>
|
||||
<!-- <RSConfigMenu /> -->
|
||||
<div @click="toggleMenu" class="menu-overlay"></div>
|
||||
|
||||
<!-- <RSFooter /> -->
|
||||
</template>
|
||||
Reference in New Issue
Block a user