Initial commit

This commit is contained in:
2026-06-24 23:47:55 -07:00
commit d134b480a0
297 changed files with 30726 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
<template>
<v-app>
<router-view v-if="isUnauthenticatedRoute"></router-view>
<!--
NOTE: current user will always be defined when the authenticated router view loads.
-->
<template v-else-if="isReady || isErrored">
<router-view />
</template>
<PageLoader
v-else-if="isLoadingAuth0 || isLoadingCurrentUser"
message="ALPHANE"
/>
<PageLoader
v-else-if="!isReady"
message="Loading"
/>
<PageLoader
v-else
message="Initializing"
/>
<AppSnackbar />
</v-app>
</template>
<script lang="ts" setup>
import { computed, onErrorCaptured, onMounted, ref, watch } from "vue"
import { useDisplay } from "vuetify"
import { useRoute, useRouter } from "vue-router"
import { useAuth0 } from "@auth0/auth0-vue"
import ApiError from "@/api/api-error"
import useCurrentUser from "@/use/use-current-user"
import useInterface from "@/use/use-interface"
import PageLoader from "@/components/common/PageLoader.vue"
import AppSnackbar from "@/components/common/AppSnackbar.vue"
const route = useRoute()
const isUnauthenticatedRoute = computed(() => route.meta.requiresAuth === false)
const { isLoading: isLoadingAuth0, isAuthenticated } = useAuth0()
const isReadyAuth0 = computed(() => !isLoadingAuth0.value && isAuthenticated.value)
const { isReady: isReadyCurrentUser, isLoading: isLoadingCurrentUser, fetch } = useCurrentUser()
const isReady = computed(() => isReadyAuth0.value && isReadyCurrentUser.value)
const isErrored = ref(false)
const router = useRouter()
const { mdAndDown } = useDisplay()
const { setSidebarDrawer, setSidebarMini } = useInterface()
onMounted(() => {
if (mdAndDown.value === false) {
setSidebarDrawer(true)
setSidebarMini(false)
}
})
watch(
[() => isReadyAuth0.value, isUnauthenticatedRoute],
async ([newIsReadyAuth0, newIsUnauthenticatedRoute]) => {
// Don't bother attempting to load current user for unauthenticated routes
if (newIsUnauthenticatedRoute) return
if (newIsReadyAuth0 === true && !isReadyCurrentUser.value) {
try {
await fetch()
} catch (error) {
console.log("Failed to load current user:", error)
isErrored.value = true
await router.isReady()
await router.push({ name: "SignInPage" })
}
}
},
{ immediate: true }
)
onErrorCaptured((error: Error) => {
if (error instanceof ApiError) {
redirectToAppropriateErrorPage(error.status)
} else {
console.error(`Unhandled error: ${error}`, { error })
}
})
function redirectToAppropriateErrorPage(httpErrorCode: number) {
if (httpErrorCode === 400 || httpErrorCode === 422) return
switch (httpErrorCode) {
case 401:
return router.replace({
name: "errors/UnauthorizedPage",
})
case 403:
return router.replace({
name: "errors/ForbiddenPage",
})
case 404:
return router.replace({
name: "errors/NotFoundPage",
})
default:
return router.replace({
name: "errors/InternalServerErrorPage",
})
}
}
</script>
+12
View File
@@ -0,0 +1,12 @@
export class ApiError extends Error {
public readonly name = "ApiError"
constructor(
message: string,
public readonly status: number
) {
super(message)
}
}
export default ApiError
+44
View File
@@ -0,0 +1,44 @@
/** Keep in sync with api/src/controllers/base-controller.ts#ModelOrder */
export type ModelOrder =
| [string, string]
| [string, string, string]
| [string, string, string, string]
| [string, string, string, string, string]
| [string, string, string, string, string, string]
export type Policy = {
show: boolean
create: boolean
update: boolean
destroy: boolean
}
export type WhereOptions<Model, Attributes extends keyof Model> = {
[K in Attributes]?: Model[K] | Model[K][]
}
export type FiltersOptions<Options> = Partial<Options>
export type QueryOptions<WhereOptions, FiltersOptions> = Partial<{
where: WhereOptions
filters: FiltersOptions
order: ModelOrder[]
page: number
perPage: number
}>
// Keep in sync with api/src/controllers/base-controller.ts
export const MAX_PER_PAGE = 1000
export type ApiResponseError = { field?: string; text: string }
export type ApiResponseLegacy<T> = {
data: T
errors: ApiResponseError[]
messages?: string[]
}
export type ApiResponse<TPayload = object> = {
errors: ApiResponseError[]
messages?: string[]
} & TPayload
+23
View File
@@ -0,0 +1,23 @@
import http from "@/api/http-client"
import { type Policy } from "@/api/base-api"
import { UserRoles, type User } from "@/api/users-api"
export { UserRoles }
export type UserAsShow = Pick<
User,
"id" | "email" | "firstName" | "lastName" | "displayName" | "roles" | "createdAt" | "updatedAt"
>
export const currentUserApi = {
async get(): Promise<{
user: UserAsShow
policy: Policy
}> {
const { data } = await http.get(`/api/current-user`)
return data
},
}
export default currentUserApi
+46
View File
@@ -0,0 +1,46 @@
import qs from "qs"
import axios from "axios"
import { API_BASE_URL } from "@/config"
import auth0 from "@/plugins/auth0-plugin"
import ApiError from "@/api/api-error"
export const httpClient = axios.create({
baseURL: API_BASE_URL,
headers: {
"Content-Type": "application/json",
},
paramsSerializer: {
serialize: (params) => {
return qs.stringify(params, {
arrayFormat: "indices",
strictNullHandling: true,
})
},
},
})
httpClient.interceptors.request.use(async (config) => {
// Only add the Authorization header to requests that start with "/api"
if (config.url?.startsWith("/api")) {
const accessToken = await auth0.getAccessTokenSilently()
config.headers["Authorization"] = `Bearer ${accessToken}`
}
return config
})
// Any status codes that falls outside the range of 2xx causes this function to trigger
httpClient.interceptors.response.use(null, async (error) => {
if (error?.error === "login_required") {
throw new ApiError("You must be logged in to access this endpoint", 401)
} else if (error?.response?.data?.message) {
throw new ApiError(error.response.data.message, error.response.status)
} else if (error.message) {
throw new ApiError(error.message, error.response?.status || 500)
} else {
throw new ApiError("An unknown error occurred", error.response?.status || 500)
}
})
export default httpClient
+18
View File
@@ -0,0 +1,18 @@
import http from "@/api/http-client"
export type Status = {
RELEASE_TAG: string
GIT_COMMIT_HASH: string
}
export const statusApi = {
/**
* Note: This is a public API route, and not protected by authentication
*/
async get(): Promise<Status> {
const { data } = await http.get("/_status")
return data
},
}
export default statusApi
+81
View File
@@ -0,0 +1,81 @@
import http from "@/api/http-client"
import {
type FiltersOptions,
type ModelOrder,
type Policy,
type WhereOptions,
} from "@/api/base-api"
/** Keep in sync with api/src/models/user.ts */
export enum UserRoles {
SYSTEM_ADMIN = "system_admin",
USER = "user",
}
export type User = {
id: number
email: string
firstName: string
lastName: string
displayName: string
roles: UserRoles[]
createdAt: string
updatedAt: string
}
export type UserAsShow = Omit<User, "organizations"> & {}
export type UserWhereOptions = WhereOptions<User, "email" | "firstName" | "lastName">
export type UserFiltersOptions = FiltersOptions<{
search: string | string[]
}>
export type UserQueryOptions = {
where?: UserWhereOptions
filters?: UserFiltersOptions
order?: ModelOrder[]
page?: number
perPage?: number
}
export const usersApi = {
UserRoles,
async list(params: UserQueryOptions = {}): Promise<{
users: User[]
totalCount: number
}> {
const { data } = await http.get("/api/users", {
params,
})
return data
},
async get(userId: number): Promise<{
user: User
policy: Policy
}> {
const { data } = await http.get(`/api/users/${userId}`)
return data
},
async create(attributes: Partial<User>): Promise<{
user: User
}> {
const { data } = await http.post("/api/users", attributes)
return data
},
async update(
userId: number,
attributes: Partial<User>
): Promise<{
user: User
}> {
const { data } = await http.patch(`/api/users/${userId}`, attributes)
return data
},
async delete(userId: number): Promise<void> {
const { data } = await http.delete(`/api/users/${userId}`)
return data
},
}
export default usersApi
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,55 @@
<!-- Special module scope required for exports -->
<script lang="ts">
import { RouteLocationRaw } from "vue-router"
type Breadcrumb = {
title: string
to: RouteLocationRaw
}
export { type Breadcrumb }
</script>
<script setup lang="ts">
const props = defineProps<{
title: string
breadcrumbs: Breadcrumb[]
}>()
</script>
<template>
<div class="mt-3 mb-6">
<div class="d-flex justify-space-between">
<div class="d-flex py-0 align-center">
<div>
<h2 class="text-h3 mb-2">{{ props.title }}</h2>
<v-breadcrumbs
:items="props.breadcrumbs"
class="text-h6 font-weight-regular pa-0 ml-n1"
>
<template
v-if="props.breadcrumbs"
#divider
>
<v-icon>mdi-chevron-right</v-icon>
</template>
<template #title="{ item }">
<h6 class="text-medium-emphasis text-subtitle-1">{{ item.title }}</h6>
</template>
</v-breadcrumbs>
</div>
</div>
<div class="d-flex align-center">
<slot name="append" />
</div>
</div>
</div>
</template>
<style lang="scss">
.page-breadcrumb {
.v-toolbar {
background: transparent;
}
}
</style>
+46
View File
@@ -0,0 +1,46 @@
<template>
<v-card
class="mb-5 app-card"
:to="to"
>
<v-progress-linear
v-if="mainColor && !isNil(progressValue)"
:model-value="progressValue"
:color="mainColor"
height="16"
>
<span style="font-size: 12px"> {{ progressValue }}% Complete</span>
</v-progress-linear>
<v-card-title
v-if="title"
class="d-flex"
:class="{ 'mb-n3': subtitle }"
>
{{ title }}
<v-spacer />
<slot name="append-title"></slot>
</v-card-title>
<v-card-subtitle
v-if="subtitle"
:class="{ 'mt-3': !title }"
style="font-weight: 500 !important"
>
{{ subtitle }}
</v-card-subtitle>
<v-card-text :class="{ 'mt-n5': subtitle }">
<slot></slot>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import { isNil } from "lodash"
defineProps<{
title?: string
subtitle?: string
to?: string | { name: string; params?: Record<string, string | number> }
mainColor?: string
progressValue?: number
}>()
</script>
+50
View File
@@ -0,0 +1,50 @@
<template>
<div class="logo">
<RouterLink
to="/dashboard"
class="d-flex"
>
<img
class="ml-0 mt-1"
style="height: 36px; transform: rotate(-12deg)"
:src="AppLogo"
/>
<div v-if="sidebarMini || mdAndDown"></div>
<div
v-else
class="d-flex"
style="width: 200px"
>
<div
class="mt-1 ml-3 rotyr-font"
style="font-size: 26px; color: #505682"
>
ALPHANE
</div>
</div>
</RouterLink>
</div>
</template>
<script setup>
import AppLogo from "@/assets/app_logo_small.png"
import { useDisplay } from "vuetify"
import useInterface from "@/use/use-interface"
import { watch } from "vue"
const { sidebarMini, setSidebarMini } = useInterface()
const { mdAndDown } = useDisplay()
watch(mdAndDown, (newVal) => {
if (newVal === true) setSidebarMini(false)
})
</script>
<style>
.logo a {
text-decoration: none !important;
color: #fff;
font-weight: 500;
}
</style>
+82
View File
@@ -0,0 +1,82 @@
<template>
<v-snackbar
v-model="showSnackbar"
v-bind="{
multiLine: true,
timeout: defaultTimeout,
...options,
}"
>
<span :class="`text-${constrastingColor}`">
{{ message }}
</span>
<template #actions>
<v-btn
:color="constrastingColor"
variant="text"
@click="close"
>
Close
</v-btn>
</template>
</v-snackbar>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from "vue"
import { useSnack } from "@/use/use-snack"
import { isEmpty } from "lodash"
const { message, options, reset } = useSnack()
const showSnackbar = ref(false)
const defaultTimeout = 4000
const constrastingColor = computed(() => getContrastingColor(options.value.color))
watch(
() => [message.value, options.value],
() => {
if (isEmpty(message.value)) return
show()
},
{ deep: true, immediate: true }
)
watch(
() => showSnackbar.value,
(newShowSnackbar) => {
if (newShowSnackbar === false) {
reset()
}
}
)
function close() {
showSnackbar.value = false
}
function show() {
showSnackbar.value = true
}
function getContrastingColor(color: string | undefined) {
if (color === undefined) return "white"
const colorMap: {
[key: string]: "white" | "black" | undefined
} = {
primary: "white",
secondary: "black",
accent: "black",
error: "white",
info: "white",
success: "white",
warning: "black",
}
return colorMap[color] || "white"
}
</script>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { ref, useSlots } from "vue"
const props = defineProps<{
title: string
}>()
const collapsed = ref(false)
const slots = useSlots()
function toggleCollapse() {
collapsed.value = !collapsed.value
}
</script>
<template>
<v-card class="mb-5 savable-card">
<v-card-text>
<div class="d-flex">
<div
class="d-flex cursor-pointer"
@click="toggleCollapse"
>
<v-icon
:icon="collapsed ? 'mdi-chevron-down' : 'mdi-chevron-up'"
color="primary"
class="mr-4 mt-1"
/>
<h2 class="text-h5 title mb-1">{{ props.title }}</h2>
</div>
<v-spacer v-if="slots.rightpart && !collapsed" />
<div
v-if="slots.rightpart && !collapsed"
class="float-right"
>
<!---Toggle Button For mobile-->
<slot name="rightpart"></slot>
</div>
</div>
<v-slide-y-transition>
<div v-show="!collapsed">
<v-divider class="my-3" />
<slot></slot>
</div>
</v-slide-y-transition>
</v-card-text>
</v-card>
</template>
@@ -0,0 +1,77 @@
<template>
<v-dialog
v-model="internalDialog"
max-width="500"
persistent
>
<v-card>
<v-card-title class="text-h6">
{{ title }}
</v-card-title>
<v-card-text>
<slot>{{ message }}</slot>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
variant="text"
@click="cancel"
>
{{ cancelText }}
</v-btn>
<v-btn
:color="confirmColor"
variant="flat"
@click="confirm"
>
{{ confirmText }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts" setup>
import { computed } from "vue"
const props = withDefaults(
defineProps<{
modelValue: boolean
title?: string
message?: string
confirmText?: string
cancelText?: string
confirmColor?: string
}>(),
{
title: "Confirm",
message: "Are you sure?",
confirmText: "Confirm",
cancelText: "Cancel",
confirmColor: "primary",
}
)
const emit = defineEmits<{
"update:modelValue": [value: boolean]
confirm: []
cancel: []
}>()
const internalDialog = computed({
get: () => props.modelValue,
set: (value: boolean) => emit("update:modelValue", value),
})
function confirm() {
emit("confirm")
internalDialog.value = false
}
function cancel() {
emit("cancel")
internalDialog.value = false
}
</script>
@@ -0,0 +1,76 @@
<template>
<!-- Vertical layout -->
<div v-if="vertical">
<dt class="d-flex align-center font-weight-medium">
<v-icon
v-if="icon"
size="18"
class="mr-2"
aria-hidden="true"
>
{{ icon }}
</v-icon>
<span>{{ label }}</span>
</dt>
<dd class="mt-2">
<v-progress-circular
v-if="loading"
indeterminate
size="16"
width="1"
/>
<slot>{{ modelValue }}</slot>
</dd>
</div>
<!-- Horizontal layout -->
<div
v-else
class="d-flex align-center gap-2"
>
<dt class="d-flex align-center font-weight-medium">
<v-icon
v-if="icon"
size="18"
class="mr-2"
aria-hidden="true"
>
{{ icon }}
</v-icon>
<span>{{ label }}:</span>
</dt>
<dd>
<v-progress-circular
v-if="loading"
indeterminate
size="16"
width="1"
/>
<slot>{{ modelValue }}</slot>
</dd>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
label: string
icon?: string
modelValue?: string | number | boolean | null | undefined
vertical?: boolean
loading?: boolean
}>(),
{
icon: "",
modelValue: null,
vertical: false,
loading: false,
}
)
</script>
<style scoped>
.gap-2 {
gap: 0.5rem; /* 8px */
}
</style>
@@ -0,0 +1,73 @@
<template>
<v-card :elevation="elevation">
<HeaderActionsCardBody
:title="title"
:header-tag="headerTag"
:header-class="headerClass"
:divider-class="dividerClass"
:hide-body="hideBody"
:body-class="bodyClass"
:header-icon="headerIcon"
>
<template #header>
<slot name="header"></slot>
</template>
<template #header-actions>
<slot name="header-actions"></slot>
</template>
<template #default>
<slot></slot>
</template>
<template
v-if="$slots.actions"
#actions
>
<slot name="actions"></slot>
</template>
<!--
TODO: replace current #actions slot with #actions-next code.
-->
<template
v-if="$slots['actions-next']"
#actions-next
>
<slot name="actions-next"></slot>
</template>
</HeaderActionsCardBody>
<slot name="content"></slot>
</v-card>
</template>
<script setup lang="ts">
import { type VCard } from "vuetify/components"
import { type VueHtmlClass } from "@/utils/utility-types"
import HeaderActionsCardBody from "@/components/common/HeaderActionsCardBody.vue"
/**
* Keep in sync with web/src/components/common/HeaderActionsCardBody.vue
*/
withDefaults(
defineProps<{
title?: string
headerTag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p"
headerClass?: VueHtmlClass
dividerClass?: VueHtmlClass
elevation?: VCard["elevation"]
hideBody?: boolean
bodyClass?: VueHtmlClass
headerIcon?: string
}>(),
{
title: "",
headerTag: "h3",
headerClass: "text-h5",
dividerClass: "mb-3",
elevation: 0,
hideBody: false,
bodyClass: "",
headerIcon: "",
}
)
</script>
@@ -0,0 +1,74 @@
<template>
<v-card-title class="d-flex flex-sm-row justify-sm-space-between align-sm-end">
<slot name="header">
<component
:is="headerTag"
v-if="!isEmpty(title)"
:class="headerClass"
class="py-2"
>
<v-icon
v-if="headerIcon"
class="mr-2"
>{{ headerIcon }}</v-icon
>
{{ title }}
</component>
</slot>
<v-spacer class="mt-4 mt-md-0" />
<slot name="header-actions"></slot>
</v-card-title>
<v-divider
v-if="!isEmpty(title)"
:class="dividerClass"
/>
<v-card-text
v-if="!hideBody"
:class="bodyClass"
>
<slot></slot>
<div
v-if="$slots.actions"
class="mt-4"
>
<slot name="actions"></slot>
</div>
</v-card-text>
<!--
TODO: replace current #actions slot with #actions-next code.
-->
<v-card-actions
v-if="$slots['actions-next']"
class="d-flex flex-column flex-md-row"
>
<slot name="actions-next"></slot>
</v-card-actions>
</template>
<script setup lang="ts">
import { isEmpty } from "lodash"
import { type VueHtmlClass } from "@/utils/utility-types"
withDefaults(
defineProps<{
title?: string
headerTag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p"
headerClass?: VueHtmlClass
dividerClass?: VueHtmlClass
hideBody?: boolean
bodyClass?: VueHtmlClass
headerIcon?: string
}>(),
{
title: "",
headerTag: "h3",
headerClass: "text-h5",
dividerClass: "mb-3",
hideBody: false,
bodyClass: "",
headerIcon: "",
}
)
</script>
@@ -0,0 +1,101 @@
<template>
<v-card :elevation="elevation">
<v-form
ref="form"
v-model="isValid"
@submit="emit('submit', $event)"
>
<HeaderActionsCardBody
:title="title"
:header-tag="headerTag"
:header-class="headerClass"
:divider-class="dividerClass"
:body-class="bodyClass"
>
<template #header>
<slot name="header"></slot>
</template>
<template #header-actions>
<slot name="header-actions"></slot>
</template>
<template #default>
<slot></slot>
</template>
<template
v-if="$slots.actions"
#actions
>
<slot name="actions"></slot>
</template>
<!--
TODO: replace current #actions slot with #actions-next code.
-->
<template
v-if="$slots['actions-next']"
#actions-next
>
<slot name="actions-next"></slot>
</template>
</HeaderActionsCardBody>
</v-form>
</v-card>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { isNil } from "lodash"
import { type VCard, type VForm } from "vuetify/components"
import { type SubmitEventPromise } from "vuetify/lib/composables/form"
import { type VueHtmlClass } from "@/utils/utility-types"
import HeaderActionsCardBody from "@/components/common/HeaderActionsCardBody.vue"
const isValid = defineModel<boolean | null>({ default: null })
/**
* Keep in sync with web/src/components/common/HeaderActionsCardBody.vue
*/
withDefaults(
defineProps<{
title?: string
headerTag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
headerClass?: VueHtmlClass
dividerClass?: VueHtmlClass
bodyClass?: VueHtmlClass
elevation?: VCard["elevation"]
}>(),
{
title: "",
headerTag: "h3",
headerClass: "text-h5",
dividerClass: "mb-3",
bodyClass: "",
elevation: 0,
}
)
const emit = defineEmits<{
(e: "submit", value: SubmitEventPromise): void
}>()
const form = ref<InstanceType<typeof VForm> | null>(null)
async function validate() {
if (isNil(form.value)) throw new Error("form component not loaded")
return form.value?.validate()
}
async function resetValidation() {
if (isNil(form.value)) throw new Error("form component not loaded")
return form.value?.resetValidation()
}
defineExpose({
validate,
resetValidation,
})
</script>
+17
View File
@@ -0,0 +1,17 @@
<template>
<div class="d-flex justify-center align-center h-screen">
<div class="d-flex flex-column align-center">
<v-progress-circular
indeterminate
size="64"
class="mb-5"
color="#505682"
/>
<h1 class="text-center">{{ message }}</h1>
</div>
</div>
</template>
<script lang="ts" setup>
withDefaults(defineProps<{ message: string }>(), { message: "ALPAHNE" })
</script>
@@ -0,0 +1,85 @@
<template>
<v-text-field
:model-value="modelValue"
:label="label"
:rules="combinedRules"
:disabled="disabled"
:readonly="readonly"
:clearable="clearable"
:hint="hint"
:persistent-hint="persistentHint"
type="number"
suffix="%"
min="0"
max="100"
step="0.01"
@update:model-value="updateModelValue"
/>
</template>
<script setup lang="ts">
import { computed } from "vue"
import { VTextField } from "vuetify/components"
const props = withDefaults(
defineProps<{
modelValue: number | null | undefined
label?: string
rules?: VTextField["rules"]
disabled?: boolean
readonly?: boolean
clearable?: boolean
hint?: string
persistentHint?: boolean
}>(),
{
label: "Percentage",
rules: () => [],
disabled: false,
readonly: false,
clearable: false,
persistentHint: false,
hint: undefined,
}
)
const emit = defineEmits<{
"update:modelValue": [value: number | null]
}>()
const combinedRules = computed(() => {
const baseRules = [
(value: number | null | undefined) => {
if (value === null || value === undefined) return true
return true
},
(value: number | null | undefined) => {
if (value === null || value === undefined) return true
const numValue = Number(value)
if (isNaN(numValue)) return "Must be a valid number"
if (numValue < 0) return "Percentage must be at least 0"
if (numValue > 100) return "Percentage must be at most 100"
return true
},
]
return [...baseRules, ...(props.rules || [])]
})
function updateModelValue(value: string | number | null) {
if (value === null || value === undefined || value === "") {
emit("update:modelValue", null)
return
}
const numValue = Number(value)
if (isNaN(numValue)) {
emit("update:modelValue", null)
return
}
// Clamp value between 0 and 100
const clampedValue = Math.min(Math.max(numValue, 0), 100)
emit("update:modelValue", clampedValue)
}
</script>
@@ -0,0 +1,83 @@
<template>
<v-dialog
v-model="internalDialog"
:max-width="mobile ? undefined : maxWidth"
:fullscreen="mobile"
:persistent="persistent"
scrollable
>
<HeaderActionsCard>
<template #header>
<div
class="d-flex align-start ga-2 w-100"
:class="{ 'pl-4': mobile, 'pt-2': mobile }"
style="white-space: normal"
>
<slot name="title">
<h3
class="text-h5 py-2 flex-grow-1 text-break"
style="min-width: 0; overflow-wrap: anywhere; white-space: normal"
>
<v-icon
v-if="titleIcon"
class="mr-2"
>{{ titleIcon }}</v-icon
>
{{ title }}
</h3>
</slot>
<v-btn
v-if="!hideClose"
icon="mdi-close"
:variant="mobile ? 'tonal' : 'text'"
density="comfortable"
class="flex-shrink-0"
@click="close"
/>
</div>
</template>
<slot />
</HeaderActionsCard>
</v-dialog>
</template>
<script setup lang="ts">
import { computed } from "vue"
import { useDisplay } from "vuetify"
import HeaderActionsCard from "@/components/shared/cards/HeaderActionsCard.vue"
const props = withDefaults(
defineProps<{
modelValue: boolean
title?: string
titleIcon?: string
maxWidth?: number | string
persistent?: boolean
hideClose?: boolean
}>(),
{
title: "",
titleIcon: "",
maxWidth: 700,
persistent: false,
hideClose: false,
}
)
const emit = defineEmits<{
"update:modelValue": [value: boolean]
}>()
const { mobile } = useDisplay()
const internalDialog = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
})
function close() {
internalDialog.value = false
}
</script>
@@ -0,0 +1,70 @@
<template>
<v-date-input
v-model="date"
v-bind="$attrs"
@update:model-value="emitStringResult"
/>
</template>
<script setup lang="ts">
import { ref, watch } from "vue"
import { DateTime } from "luxon"
import { isNil } from "lodash"
/**
* A date input accepts and returns a string.
*/
const props = withDefaults(
defineProps<{
modelValue: string | null | undefined
returnFormat?: string
}>(),
{
returnFormat: "yyyy-MM-dd",
}
)
const emit = defineEmits<{
"update:modelValue": [value: string | null]
}>()
const date = ref<Date | null>(null)
watch(
() => props.modelValue,
(newValue) => {
if (isNil(newValue)) {
date.value = null
} else {
const dateTime = DateTime.fromISO(newValue)
if (dateTime.isValid) {
date.value = dateTime.toJSDate()
} else {
date.value = null
}
}
},
{
immediate: true,
}
)
/**
* NOTE: v-date-input returns Date | string, rather than just "string" like it's types imply.
*/
function emitStringResult(value: unknown) {
if (value instanceof Date) {
const dateTime = DateTime.fromJSDate(value)
if (dateTime.isValid) {
const stringValue = dateTime.toFormat(props.returnFormat)
emit("update:modelValue", stringValue)
} else {
emit("update:modelValue", null)
}
} else if (typeof value === "string") {
emit("update:modelValue", value)
} else {
emit("update:modelValue", null)
}
}
</script>
@@ -0,0 +1,40 @@
<template>
<v-app-bar
id="top"
elevation="10"
height="60"
class="main-head pl-2"
>
<div class="mr-3">
<AppLogo />
</div>
<v-spacer />
<v-btn
v-if="isSystemAdmin"
icon
variant="text"
class="custom-hover-primary mr-2"
size="small"
title="AI Assistant"
@click="$emit('toggle-ai-panel')"
>
<v-icon size="28">mdi-robot-outline</v-icon>
</v-btn>
<ProfileMenu />
</v-app-bar>
</template>
<script setup lang="ts">
import useCurrentUser from "@/use/use-current-user"
import AppLogo from "@/components/common/AppLogo.vue"
import ProfileMenu from "@/components/layout/ProfileMenu.vue"
defineEmits<{
"toggle-ai-panel": []
}>()
const { isSystemAdmin } = useCurrentUser<true>()
</script>
@@ -0,0 +1,70 @@
<template>
<v-navigation-drawer
v-model="sidebarDrawer"
left
elevation="0"
rail-width="75"
app
class="leftSidebar"
:rail="sidebarMini"
expand-on-hover
width="256"
>
<div class="pa-2">
<v-card
v-if="currentUser"
variant="outlined"
style="overflow: hidden"
>
<v-card-text
v-if="!sidebarMini"
class="pa-2"
>
<div class="font-weight-bold">{{ currentUser.displayName }}</div>
</v-card-text>
</v-card>
</div>
<v-list class="d-none py-3 px-4">
<v-list-subheader
class="text-uppercase text-13 font-weight-semibold textPrimary d-flex align-items-center"
>
<span class="mini-icon">
<IconDotsCircleHorizontal
size="16"
stroke-width="1.5"
class="iconClass"
/>
</span>
<span class="mini-text">Aircraft</span>
</v-list-subheader>
<v-list-item
v-scroll-to="{ el: '#top' }"
:to="{}"
rounded="pill"
class="mb-1 px-3"
>
<template #prepend>
<IconPointFilled
size="24"
class="dot mini-icon"
/>
</template>
<span class="mini-text"> Y-CCAA</span>
</v-list-item>
</v-list>
</v-navigation-drawer>
</template>
<script setup lang="ts">
import { IconDotsCircleHorizontal, IconPointFilled } from "@tabler/icons-vue"
import useCurrentUser from "@/use/use-current-user"
import useInterface from "@/use/use-interface"
const { sidebarDrawer, sidebarMini } = useInterface()
const { currentUser } = useCurrentUser()
</script>
@@ -0,0 +1,69 @@
<template>
<v-breadcrumbs
class="flex-wrap"
:items="breadcrumbsWithExactTrueByDefault"
color=""
>
<template #title="{ item }">
<transition
name="breadcrumb-title-fade"
mode="out-in"
>
<v-progress-circular
v-if="isEmpty(item.title)"
key="title-loader"
size="16"
color="secondary"
width="1"
indeterminate
/>
<span
v-else
key="title-text"
>
{{ item.title }}
</span>
</transition>
</template>
<template #divider>
<v-icon>mdi-chevron-right</v-icon>
</template>
</v-breadcrumbs>
</template>
<script lang="ts">
export { type Breadcrumb } from "@/use/use-breadcrumbs"
</script>
<script lang="ts" setup>
import { computed } from "vue"
import { isEmpty } from "lodash"
import { type Breadcrumb } from "@/use/use-breadcrumbs"
const props = defineProps<{
items: Breadcrumb[]
}>()
// Changes https://vuetifyjs.com/en/components/breadcrumbs/ default behavior.
// By default v-breadcrumbs will disable all crumbs up to the current page in a nested paths.
// You can prevent this behavior by using exact: true on each applicable breadcrumb in the items array.
const breadcrumbsWithExactTrueByDefault = computed(() =>
props.items.map((item) => ({
...item,
title: item.title ?? "",
exact: item.exact ?? true,
}))
)
</script>
<style scoped>
.breadcrumb-title-fade-enter-active,
.breadcrumb-title-fade-leave-active {
transition: opacity 0.18s ease;
}
.breadcrumb-title-fade-enter-from,
.breadcrumb-title-fade-leave-to {
opacity: 0;
}
</style>
+160
View File
@@ -0,0 +1,160 @@
<template>
<v-menu
v-model="showMenu"
:close-on-content-click="false"
class="profile_popup"
>
<template #activator="{ props }">
<v-btn
class="ml-2"
variant="tonal"
v-bind="props"
icon
:text="currentUserInitials"
@click="showMenu = true"
/>
</template>
<v-sheet
rounded="md"
width="360"
elevation="11"
>
<div class="px-8 pt-3">
<div class="d-flex align-center mt-4 pb-6">
<div>
<h6 class="text-h6 mb-n1">{{ currentUser.displayName }}</h6>
<div class="d-flex align-center mt-2">
<IconMail
:size="18"
:stroke-width="1.5"
/>
<span class="text-subtitle-1 font-weight-regular textSecondary ml-2">{{
currentUser.email
}}</span>
</div>
</div>
</div>
<v-divider></v-divider>
</div>
<v-list
class="py-0 theme-list"
lines="two"
>
<v-list-item
class="py-4 px-8 custom-text-primary"
:to="{
name: 'ProfilePage',
}"
@click="closeMenu"
>
<template #prepend>
<v-avatar color="info">
<IconUserCircle></IconUserCircle>
</v-avatar>
</template>
<div>
<h6 class="text-subtitle-1 font-weight-semibold mb-2 custom-title">My Profile</h6>
</div>
<p class="text-subtitle-1 font-weight-regular textSecondary">Manage your information</p>
</v-list-item>
<v-list-item
v-if="isSystemAdmin"
class="py-4 px-8 custom-text-primary"
:to="{
name: 'administration/AdministrationDashboardPage',
}"
@click="closeMenu"
>
<template #prepend>
<v-avatar color="warning">
<IconSettings></IconSettings>
</v-avatar>
</template>
<div>
<h6 class="text-subtitle-1 font-weight-semibold mb-2 custom-title">
ROTYR System Administration
</h6>
</div>
<p class="text-subtitle-1 font-weight-regular textSecondary">Manage this application</p>
</v-list-item>
<v-list-item
v-if="isSystemAdmin"
class="py-4 px-8 custom-text-primary"
:to="{
name: 'StatusPage',
}"
@click="closeMenu"
>
<template #prepend>
<v-avatar color="secondary">
<IconClock />
</v-avatar>
</template>
<div>
<h6 class="text-subtitle-1 font-weight-semibold mb-2 custom-title">Version</h6>
</div>
<p class="text-subtitle-1 font-weight-regular textSecondary">
{{ releaseTag || "2024.08.29" }}
</p>
</v-list-item>
</v-list>
<div class="pt-4 pb-6 px-8 text-center">
<v-btn
variant="outlined"
block
@click="signOut"
>Logout</v-btn
>
</div>
</v-sheet>
</v-menu>
</template>
<script setup lang="ts">
import { computed, ref } from "vue"
import { IconClock, IconMail, IconSettings, IconUserCircle } from "@tabler/icons-vue"
import { useAuth0 } from "@auth0/auth0-vue"
import { isEmpty, isNil } from "lodash"
import useCurrentUser from "@/use/use-current-user"
import useStatus from "@/use/use-status"
const { logout } = useAuth0()
const { currentUser, isSystemAdmin, reset: resetCurrentUser } = useCurrentUser<true>()
const showMenu = ref(false)
const { releaseTag } = useStatus()
const currentUserInitials = computed(() => {
if (isNil(currentUser.value)) {
return ""
}
const { firstName, lastName, email } = currentUser.value
if (!isNil(firstName) && !isEmpty(firstName) && !isNil(lastName) && !isEmpty(lastName)) {
const initials = [firstName, lastName].map((name) => name[0]?.toUpperCase()).join("")
return initials
}
const initials = email
.split(".")
.map((part) => part[0]?.toUpperCase())
.join("")
return initials
})
function closeMenu() {
showMenu.value = false
}
function signOut() {
resetCurrentUser()
const returnTo = encodeURI(window.location.origin)
return logout({ logoutParams: { returnTo } })
}
</script>
+59
View File
@@ -0,0 +1,59 @@
<template>
<v-menu
v-model="showMenu"
:close-on-content-click="false"
class="search_popup"
>
<template #activator="{ props }">
<v-btn
icon
variant="text"
class="custom-hover-primary mr-2"
size="small"
v-bind="props"
>
<IconSearch :size="26" />
</v-btn>
</template>
<v-sheet
width="360"
elevation="11"
rounded="md"
>
<v-form class="d-flex flex-column pa-5">
<v-text-field
v-model="search"
placeholder="Search"
color="primary"
density="compact"
variant="outlined"
hide-details
clearable
@click:clear="search = ''"
/>
<v-btn
class="mt-2"
color="primary"
type="submit"
block
>
<v-icon start>mdi-magnify</v-icon> Search
</v-btn>
</v-form>
<v-divider />
<h5 class="text-h5 mt-4 px-5 pb-4">Recently Viewed</h5>
<v-divider />
</v-sheet>
</v-menu>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { IconSearch } from "@tabler/icons-vue"
const showMenu = ref(false)
const search = ref("")
</script>
@@ -0,0 +1,196 @@
<template>
<v-skeleton-loader
v-if="isNil(user)"
type="card"
/>
<HeaderActionsFormCard
v-else
ref="headerActionsFormCard"
title="User Details"
elevation="10"
@submit.prevent="saveWrapper"
>
<template #header-actions> </template>
<v-row>
<v-col
cols="12"
md="6"
>
<v-label class="mb-2">First name *</v-label>
<v-text-field
v-model="user.firstName"
hide-details="auto"
:rules="[required]"
required
/>
</v-col>
<v-col
cols="12"
md="6"
>
<v-label class="mb-2">Last name *</v-label>
<v-text-field
v-model="user.lastName"
hide-details="auto"
:rules="[required]"
required
/>
</v-col>
<v-col
cols="12"
md="6"
>
<v-label class="mb-2">Display name *</v-label>
<v-text-field
v-model="user.displayName"
hide-details="auto"
:rules="[required]"
required
/>
</v-col>
<v-col
cols="12"
md="6"
>
<v-label class="mb-2">Email</v-label>
<v-text-field
v-model="user.email"
type="email"
hide-details="auto"
:rules="[required, email]"
/>
</v-col>
<v-col
cols="12"
md="6"
>
<v-label class="mb-2">Roles</v-label>
<UserRoleSelect
v-model="user.roles"
hide-details="auto"
:rules="[required]"
/>
</v-col>
</v-row>
<template #actions>
<div class="d-flex mt-8">
<v-btn
:loading="isLoading"
color="primary"
type="submit"
>
Save User
</v-btn>
<v-spacer />
<v-btn
color="warning"
class="ml-4"
variant="outlined"
:loading="isLoading"
:to="{ name: 'administration/UsersPage' }"
>
Cancel
</v-btn>
<v-btn
v-if="canDelete"
color="error"
class="ml-4"
variant="outlined"
:loading="isDeleting"
@click="showDeleteDialog = true"
>
Delete
</v-btn>
</div>
</template>
</HeaderActionsFormCard>
<ConfirmDialog
v-model="showDeleteDialog"
title="Delete user?"
:message="`Permanently delete ${user?.displayName || user?.email}? This removes the user from all organizations and roles and cannot be undone.`"
confirm-text="Delete"
confirm-color="error"
@confirm="handleDelete"
/>
</template>
<script setup lang="ts">
import { computed, ref, toRefs } from "vue"
import { RouteLocationRaw, useRouter } from "vue-router"
import { isNil } from "lodash"
import { email, required } from "@/utils/validators"
import usersApi from "@/api/users-api"
import useCurrentUser from "@/use/use-current-user"
import useSnack from "@/use/use-snack"
import useUser from "@/use/use-user"
import ConfirmDialog from "@/components/common/ConfirmDialog.vue"
import HeaderActionsFormCard from "@/components/common/HeaderActionsFormCard.vue"
import UserRoleSelect from "@/components/users/UserRoleSelect.vue"
const props = withDefaults(
defineProps<{
userId: number
returnTo?: RouteLocationRaw
}>(),
{
returnTo: undefined,
}
)
const emit = defineEmits<{
saved: [userId: number]
}>()
const { userId } = toRefs(props)
const { user, policy, isLoading, save } = useUser(userId)
const { currentUser } = useCurrentUser()
const router = useRouter()
const headerActionsFormCard = ref<InstanceType<typeof HeaderActionsFormCard> | null>(null)
const snack = useSnack()
const showDeleteDialog = ref(false)
const isDeleting = ref(false)
const canDelete = computed(() => {
if (!policy.value?.destroy) return false
if (currentUser.value?.id === userId.value) return false
return true
})
async function handleDelete() {
if (isNil(user.value)) return
isDeleting.value = true
try {
await usersApi.delete(user.value.id)
snack.success("User deleted.")
await router.push({ name: "administration/UsersPage" })
} catch (error) {
snack.error(`Failed to delete user: ${error}`)
} finally {
isDeleting.value = false
}
}
async function saveWrapper() {
if (isNil(user.value)) return
if (headerActionsFormCard.value === null) return
const { valid } = await headerActionsFormCard.value.validate()
if (!valid) return
try {
await save()
snack.success("User saved!")
emit("saved", user.value.id)
} catch (error) {
snack.error(`Failed to save user: ${error}`)
}
}
</script>
@@ -0,0 +1,86 @@
<template>
<v-skeleton-loader
v-if="isNil(user)"
type="card"
/>
<HeaderActionsCard
v-else
title="User Details"
elevation="10"
>
<template #header-actions>
<v-btn
:to="{
name: 'profile/ProfileEditPage',
}"
color="primary"
>
Edit
</v-btn>
</template>
<v-row>
<v-col
cols="12"
md="4"
>
<DescriptionElement
label="First name"
:model-value="user.firstName || '<blank>'"
vertical
/>
</v-col>
<v-col
cols="12"
md="4"
>
<DescriptionElement
label="Last name"
:model-value="user.lastName || '<blank>'"
vertical
/>
</v-col>
<v-col
cols="12"
md="4"
>
<DescriptionElement
label="Email"
:model-value="user.email"
vertical
/>
</v-col>
</v-row>
<v-row>
<v-col
cols="12"
md="4"
>
<DescriptionElement
label="Display name"
:model-value="user.displayName || '<blank>'"
vertical
/>
</v-col>
</v-row>
<v-divider class="my-4" />
</HeaderActionsCard>
</template>
<script setup lang="ts">
import { toRefs } from "vue"
import { isNil } from "lodash"
import useUser from "@/use/use-user"
import HeaderActionsCard from "@/components/common/HeaderActionsCard.vue"
import DescriptionElement from "@/components/common/DescriptionElement.vue"
const props = defineProps<{
userId: number
}>()
const { userId } = toRefs(props)
const { user } = useUser(userId)
</script>
@@ -0,0 +1,30 @@
<template>
<v-select
v-model="selectedRoles"
:items="roleItems"
label="Roles"
chips
multiple
closable-chips
v-bind="$attrs"
></v-select>
</template>
<script lang="ts" setup>
import { useI18n } from "vue-i18n"
import { UserRoles } from "@/api/users-api"
const selectedRoles = defineModel<UserRoles[]>({
default: [],
})
const { t } = useI18n()
const ORDERED_ROLES = [UserRoles.USER, UserRoles.SYSTEM_ADMIN]
const roleItems = Object.values(ORDERED_ROLES).map((value) => ({
title: t(`user.roles.${value}`),
value,
}))
</script>
@@ -0,0 +1,107 @@
<template>
<v-data-table-server
v-model:items-per-page="perPage"
:page="page"
:headers="headers"
:items="users"
:items-length="totalCount"
:loading="isLoading"
@click:row="rowClicked"
@update:page="updatePage"
>
<template #item.roles="{ item }"> {{ item.roles?.join(", ") }} affaafafa </template>
<template #item.updatedAt="{ value }">{{ formatDate(value) }}</template>
<template #item.createdAt="{ value }">{{ formatDate(value) }}</template>
<template
v-for="(_, name) in $slots"
:key="name"
#[name]="slotProps"
>
<slot
:name="name"
v-bind="slotProps"
></slot>
</template>
</v-data-table-server>
</template>
<script lang="ts">
export const DEFAULT_HEADERS = [
{
title: "Name",
key: "displayName",
},
{
title: "Email",
key: "email",
},
{
title: "Roles",
key: "roles",
},
]
</script>
<script setup lang="ts">
import { computed } from "vue"
import { formatDate } from "@/utils/formatters"
import useUsers, {
User,
UserFiltersOptions,
UserQueryOptions,
UserWhereOptions,
} from "@/use/use-users"
import useRouteQueryPagination from "@/use/utils/use-route-query-pagination"
const props = withDefaults(
defineProps<{
headers?: { title: string; key: string }[]
filters?: UserFiltersOptions
where?: UserWhereOptions
waiting?: boolean
routeQuerySuffix?: string
}>(),
{
headers: () => DEFAULT_HEADERS,
filters: () => ({}),
where: () => ({}),
waiting: false,
routeQuerySuffix: "Users",
}
)
const { page, perPage } = useRouteQueryPagination({ routeQuerySuffix: "Users", perPage: 100 })
const userQueryOptions = computed<UserQueryOptions>(() => {
return {
where: props.where,
filters: props.filters,
perPage: perPage.value,
page: page.value,
}
})
const { users, totalCount, isLoading, refresh } = useUsers(userQueryOptions, {
skipWatchIf: () => props.waiting,
})
type UserTableRow = {
item: User
}
const emit = defineEmits<{ clicked: [userId: User] }>()
function rowClicked(_event: unknown, row: UserTableRow) {
emit("clicked", row.item)
}
function updatePage(newPage: number) {
if (isLoading.value || props.waiting) return
page.value = newPage
}
defineExpose({ refresh })
</script>
+47
View File
@@ -0,0 +1,47 @@
import { stripTrailingSlash } from "@/utils/strip-trailing-slash"
export const ENVIRONMENT = import.meta.env.MODE
const prodConfig = {
domain: "https://dev-7mdjzcgwirhocfwm.ca.auth0.com",
clientId: "TRlKzdNBynpo9tU1RSmnF0p8d3IEam4J",
audience: "alphane-api",
apiBaseUrl: "",
webSocketBaseUrl: "",
applicationName: "ALPHANE",
}
const devConfig = {
domain: "https://dev-7mdjzcgwirhocfwm.ca.auth0.com",
clientId: "TRlKzdNBynpo9tU1RSmnF0p8d3IEam4J",
audience: "alphane-api",
apiBaseUrl: "http://localhost:3000",
webSocketBaseUrl: "ws://localhost:3000",
applicationName: "ALPHANE",
}
const localProductionConfig = {
domain: "https://dev-7mdjzcgwirhocfwm.ca.auth0.com",
clientId: "TRlKzdNBynpo9tU1RSmnF0p8d3IEam4J",
audience: "alphane-api",
apiBaseUrl: "http://localhost:8080",
webSocketBaseUrl: "ws://localhost:8080",
applicationName: "ALPHANE (production)",
}
let config = prodConfig
if (ENVIRONMENT === "production" && window.location.host === "localhost:8080") {
config = localProductionConfig
} else if (window.location.host === "localhost:8080") {
config = devConfig
}
export const APPLICATION_NAME = config.applicationName
export const API_BASE_URL = config.apiBaseUrl
export const WEB_SOCKET_BASE_URL = config.webSocketBaseUrl
export const AUTH0_DOMAIN = stripTrailingSlash(config.domain)
export const AUTH0_AUDIENCE = config.audience
export const AUTH0_CLIENT_ID = config.clientId
+5
View File
@@ -0,0 +1,5 @@
import { visible } from "./visible"
export default {
visible,
}
+27
View File
@@ -0,0 +1,27 @@
import { DirectiveBinding } from "vue"
/**
* Shows or hides an element using visibility property based on a boolean value.
*
* This makes helps loaders avoid downshift flickering when they trigger.
*
* See https://vuejs.org/guide/reusability/custom-directives.html#function-shorthand
*
* @usage
* ```html
* <v-progress-linear
* v-visible="isAnyDashboardLoading"
* indeterminate
* height="2"
* />
* ```
*/
export function visible(el: HTMLElement, binding: DirectiveBinding) {
if (binding.value) {
el.style.visibility = "visible"
} else {
el.style.visibility = "hidden"
}
}
export default visible
+49
View File
@@ -0,0 +1,49 @@
<template>
<v-layout>
<DefaultAppBar @toggle-ai-panel="showAiPanel = !showAiPanel" />
<!--
<LeftSidebarNavigationDrawer /> -->
<v-main>
<!-- Provides the application the proper gutter -->
<v-container
fluid
class="h-100"
>
<h2 class="text-h3 mt-3">{{ title }}</h2>
<ExactingBreadcrumbs
class="pl-0 pt-2 mb-1"
:items="breadcrumbs"
/>
<router-view />
</v-container>
</v-main>
<AiChatPanel
v-if="showAiPanel"
v-model="showAiPanel"
/>
</v-layout>
</template>
<script setup lang="ts">
import { ref } from "vue"
import useBreadcrumbs from "@/use/use-breadcrumbs"
import DefaultAppBar from "@/components/layout/DefaultAppBar.vue"
import ExactingBreadcrumbs from "@/components/layout/ExactingBreadcrumbs.vue"
//import LeftSidebarNavigationDrawer from "@/components/administration-layout/LeftSidebarNavigationDrawer.vue"
const { title, breadcrumbs } = useBreadcrumbs(undefined, undefined, {
baseCrumb: {
title: "Administration Dashboard",
to: {
name: "administration/AdministrationDashboardPage",
},
},
})
const showAiPanel = ref(false)
</script>
+22
View File
@@ -0,0 +1,22 @@
<template>
<v-app>
<DefaultAppBar />
<v-main>
<v-container
fluid
:class="mobile ? 'pa-2' : 'pa-4'"
>
<router-view />
</v-container>
</v-main>
</v-app>
</template>
<script setup lang="ts">
import { useDisplay } from "vuetify"
import DefaultAppBar from "@/components/layout/DefaultAppBar.vue"
const { mobile } = useDisplay()
</script>
+69
View File
@@ -0,0 +1,69 @@
<template>
<v-row>
<v-col style="position: relative">
<div class="d-flex align-start justify-space-between">
<div class="flex-grow-1">
<h2 class="text-h3 mb-n2">
{{ title }}
</h2>
<ExactingBreadcrumbs
class="pl-0 pt-2 mb-1"
:items="breadcrumbs"
/>
</div>
<v-btn
v-if="showBackButton && !mobile"
variant="tonal"
prepend-icon="mdi-arrow-left"
size="small"
class="ml-2 mt-4"
color="warning"
@click="router.back()"
>
Back
</v-btn>
</div>
<div
v-if="showBackButton && mobile"
class="mt-n5"
>
<a
class="d-inline-flex align-center text-primary text-decoration-none"
href="#"
@click.prevent="router.back()"
>
<v-icon
size="small"
class="mr-1"
>
mdi-arrow-left
</v-icon>
Back
</a>
</div>
</v-col>
</v-row>
<router-view />
</template>
<script lang="ts" setup>
import ExactingBreadcrumbs from "@/components/layout/ExactingBreadcrumbs.vue"
import useBreadcrumbs from "@/use/use-breadcrumbs"
import { useDisplay } from "vuetify"
import { useRouter } from "vue-router"
const { mobile } = useDisplay()
const router = useRouter()
const { title, breadcrumbs, showBackButton } = useBreadcrumbs(undefined, undefined, {
baseCrumb: {
title: "Dashboard",
to: {
name: "DashboardPage",
},
},
})
</script>
+33
View File
@@ -0,0 +1,33 @@
<template>
<v-row>
<v-col>
<UserProfileCard :user-id="currentUser.id" />
</v-col>
</v-row>
<v-row>
<v-col>
<router-view />
</v-col>
</v-row>
</template>
<script lang="ts" setup>
import useBreadcrumbs from "@/use/use-breadcrumbs"
import useCurrentUser from "@/use/use-current-user"
import UserProfileCard from "@/components/users/UserProfileCard.vue"
const { currentUser } = useCurrentUser<true>()
useBreadcrumbs("My Profile", [
{
title: "My Profile",
to: {
name: "ProfilePage",
},
},
])
</script>
<style scoped></style>
+50
View File
@@ -0,0 +1,50 @@
<template>
<div class="logo">
<RouterLink
to="/dashboard"
class="d-flex"
>
<img
class="ml-0 mt-1"
style="height: 36px; transform: rotate(-12deg)"
:src="AlphaneLogo"
/>
<div v-if="sidebarMini || mdAndDown"></div>
<div
v-else
class="d-flex"
style="width: 200px"
>
<div
class="mt-1 ml-3"
style="font-size: 26px; color: #505682"
>
ALPHANE
</div>
</div>
</RouterLink>
</div>
</template>
<script setup>
import AlphaneLogo from "@/assets/alphane_logo_small.png"
import { useDisplay } from "vuetify"
import useInterface from "@/use/use-interface"
import { watch } from "vue"
const { sidebarMini, setSidebarMini } = useInterface()
const { mdAndDown } = useDisplay()
watch(mdAndDown, (newVal) => {
if (newVal === true) setSidebarMini(false)
})
</script>
<style>
.logo a {
text-decoration: none !important;
color: #fff;
font-weight: 500;
}
</style>
+8
View File
@@ -0,0 +1,8 @@
export default {
user: {
roles: {
system_admin: "System Admin",
user: "User",
},
},
}
+25
View File
@@ -0,0 +1,25 @@
import { createApp } from "vue"
// Plugins
import vuetify from "@/plugins/vuetify-plugin"
import auth0 from "@/plugins/auth0-plugin"
import vueI18nPlugin from "@/plugins/vue-i18n-plugin"
import "@/scss/style.scss"
import VueScrollTo from "vue-scrollto"
import directives from "@/directives"
import router from "@/router"
import App from "@/App.vue"
const app = createApp(App)
app.use(router).use(vuetify).use(auth0).use(vueI18nPlugin)
app.directive("visible", directives.visible)
app.mount("#app")
app.use(VueScrollTo, {
duration: 1000,
easing: "ease",
})
+23
View File
@@ -0,0 +1,23 @@
<template>
<PageLoader message="ALPHANE" />
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from "vue"
import { useRouter } from "vue-router"
import PageLoader from "@/components/common/PageLoader.vue"
const router = useRouter()
const timeout = ref<number>()
onMounted(() => {
timeout.value = setTimeout(() => {
router.push({ name: "DashboardPage" })
}, 5000)
})
onUnmounted(() => {
clearTimeout(timeout.value)
})
</script>
+16
View File
@@ -0,0 +1,16 @@
<template>
<div v-if="isSystemAdmin">
<AppCard :to="{ name: 'administration/DashboardPage' }">You are a system admin</AppCard>
</div>
</template>
<script lang="ts" setup>
import useBreadcrumbs from "@/use/use-breadcrumbs"
import useCurrentUser from "@/use/use-current-user"
import AppCard from "@/components/common/AppCard.vue"
const { isSystemAdmin } = useCurrentUser<true>()
useBreadcrumbs()
</script>
+27
View File
@@ -0,0 +1,27 @@
<template>
<v-row>
<v-col>
<UserProfileCard :user-id="currentUser.id" />
</v-col>
</v-row>
</template>
<script lang="ts" setup>
import useBreadcrumbs from "@/use/use-breadcrumbs"
import useCurrentUser from "@/use/use-current-user"
import UserProfileCard from "@/components/users/UserProfileCard.vue"
const { currentUser } = useCurrentUser<true>()
useBreadcrumbs("My Profile", [
{
title: "My Profile",
to: {
name: "ProfilePage",
},
},
])
</script>
<style scoped></style>
+115
View File
@@ -0,0 +1,115 @@
<template>
<div class="pa-3">
<v-row class="h-100vh auth">
<v-col
cols="12"
lg="5"
xl="4"
class="d-flex align-center justify-center bg-surface"
>
<div class="mt-xl-0 mt-5 mw-100 text-center">
<div
class="px-8"
style="max-width: 500px"
>
<img
class="d-inline-block d-lg-none"
src="@/assets/app_logo_small.png"
style="height: 66px; transform: rotate(-12deg)"
/>
<h2
class="text-h1 textPrimary font-weight-semibold mb-0"
style="font-size: 2.5rem !important"
>
ALPHANE
</h2>
<div
class="card-subtitle mb-6"
style="font-size: 1.2rem"
>
Helicopter Maintenance Tracking Platform
</div>
<p>
This application is will streamline maintenance control decision making and unlock
operational constraints.
</p>
<h6 class="text-h6 text-medium-emphasis d-flex align-center mt-6 font-weight-medium">
<v-btn
block
color="primary"
@click="doLogin"
>Sign in</v-btn
>
</h6>
<div class="my-6 text-medium-emphasis">
If you don't have an account, create one to get started using ROTYR. It's free to sign
up and only takes a few seconds.
</div>
<!-- <v-btn
block
variant="outlined"
color="primary"
class="mt-3"
:to="{ name: 'SignUpPage' }"
text="Sign Up"
/> -->
<div
v-if="isAuthenticated"
class="mt-5 text-center"
>
<v-btn
color="warning"
variant="text"
@click="logoutWrapper"
>Sign out</v-btn
>
</div>
</div>
</div>
</v-col>
<v-col
cols="12"
lg="7"
xl="8"
class="d-none d-lg-flex align-center justify-center authentication position-relative"
>
<div class="text-center">
<img
src="@/assets/app_logo_splash.png"
class="position-relative"
style="opacity: 0.15; width: 80%"
/>
</div>
</v-col>
</v-row>
</div>
</template>
<script setup lang="ts">
import { onMounted } from "vue"
import { useAuth0 } from "@auth0/auth0-vue"
import useCurrentUser from "@/use/use-current-user"
const { reset: resetCurrentUser } = useCurrentUser()
const { isAuthenticated, loginWithRedirect, logout } = useAuth0()
onMounted(() => {
resetCurrentUser()
})
function doLogin() {
loginWithRedirect({
appState: { target: "/dashboard" },
})
}
async function logoutWrapper() {
await logout({
logoutParams: {
returnTo: window.location.origin,
},
})
}
</script>
+93
View File
@@ -0,0 +1,93 @@
<template>
<v-app>
<v-container>
Return to <router-link :to="{ name: returnTo.name }">{{ returnTo.title }}</router-link>
<v-row class="mt-5">
<v-col cols="12">
<v-card
outlined
class="pa-3"
:loading="isLoading"
>
<v-card-title
>Environment Information
<v-btn
class="ma-0 ml-1"
icon
size="small"
color="success"
title="refresh"
@click="refresh"
>
<v-icon>mdi-cached</v-icon>
</v-btn>
</v-card-title>
<v-list dense>
<v-list-item> Release Tag: {{ environment.releaseTag }} </v-list-item>
<v-list-item> Git Commit Hash: {{ environment.gitCommitHash }} </v-list-item>
</v-list>
</v-card>
</v-col>
</v-row>
</v-container>
</v-app>
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from "vue"
import { useAuth0 } from "@auth0/auth0-vue"
import http from "@/api/http-client"
const { isAuthenticated } = useAuth0()
const returnTo = computed<{ name: string; title: string }>(() => {
if (isAuthenticated.value) {
return {
name: "DashboardPage",
title: "Dashboard",
}
}
return {
name: "SignInPage",
title: "Sign In",
}
})
const environment = reactive({
releaseTag: "not-set",
gitCommitHash: "not-set",
})
const isLoading = ref(true)
onMounted(async () => {
await refresh()
})
async function fetchVersion() {
return http
.get("/_status")
.then(({ data }) => {
environment.releaseTag = data.RELEASE_TAG
environment.gitCommitHash = data.GIT_COMMIT_HASH
return data
})
.catch((error: unknown) => {
console.error(`Error fetching version: ${error}`)
})
}
async function refresh() {
isLoading.value = true
try {
await fetchVersion()
} catch (error) {
console.error(`Error fetching version: ${error}`)
} finally {
isLoading.value = false
}
}
</script>
@@ -0,0 +1,118 @@
<template>
<div>
<v-row>
<!-- Users Card -->
<v-col
v-if="isSystemAdmin"
cols="12"
md="6"
lg="4"
>
<v-card
elevation="10"
:to="{ name: 'administration/UsersPage' }"
hover
>
<v-card-item>
<div class="d-flex align-center">
<v-avatar
color="success"
size="56"
class="mr-4"
>
<v-icon
size="32"
color="white"
>
mdi-account-group
</v-icon>
</v-avatar>
<div>
<v-card-title class="text-h5">Users</v-card-title>
<v-card-subtitle>Manage users and permissions</v-card-subtitle>
</div>
</div>
</v-card-item>
<v-divider />
<v-card-text>
<div class="d-flex align-center justify-space-between">
<span class="text-h3 font-weight-bold">{{ usersCount }}</span>
<v-icon
size="large"
color="success"
>
mdi-chevron-right
</v-icon>
</div>
<div class="text-caption text-medium-emphasis mt-1">Total Users</div>
</v-card-text>
</v-card>
</v-col>
<!-- Settings Card -->
<v-col
v-if="isSystemAdmin"
cols="12"
md="6"
lg="4"
>
<v-card
elevation="10"
:to="{ name: 'administration/SettingsPage' }"
hover
>
<v-card-item>
<div class="d-flex align-center">
<v-avatar
color="info"
size="56"
class="mr-4"
>
<v-icon
size="32"
color="white"
>
mdi-cog
</v-icon>
</v-avatar>
<div>
<v-card-title class="text-h5">Settings</v-card-title>
<v-card-subtitle>System configuration</v-card-subtitle>
</div>
</div>
</v-card-item>
<v-divider />
<v-card-text>
<div class="d-flex align-center justify-space-between">
<span class="text-h3 font-weight-bold">&nbsp;</span>
<v-icon
size="large"
color="info"
>
mdi-chevron-right
</v-icon>
</div>
<div class="text-caption text-medium-emphasis mt-1">Configure System</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
</template>
<script setup lang="ts">
import useBreadcrumbs from "@/use/use-breadcrumbs"
import useUsers from "@/use/use-users"
import useCurrentUser from "@/use/use-current-user"
const { isSystemAdmin } = useCurrentUser()
const { totalCount: usersCount } = useUsers()
useBreadcrumbs("Administration Dashboard", [])
</script>
@@ -0,0 +1,14 @@
<template><div></div></template>
<script setup lang="ts">
import useBreadcrumbs from "@/use/use-breadcrumbs"
useBreadcrumbs("Settings", [
{
title: "Settings",
to: {
name: "administration/SettingsPage",
},
},
])
</script>
@@ -0,0 +1,46 @@
<template>
<HeaderActionsCard
title="Users"
elevation="10"
>
<template
v-if="isSystemAdmin"
#header-actions
>
<v-btn
color="secondary"
:to="{
name: 'administration/users/UserNewPage',
}"
>
<v-icon class="mr-3">mdi-plus</v-icon>
Add User
</v-btn>
</template>
<UsersDataTableServer />
</HeaderActionsCard>
</template>
<script setup lang="ts">
import { computed } from "vue"
import useBreadcrumbs from "@/use/use-breadcrumbs"
import useCurrentUser from "@/use/use-current-user"
import HeaderActionsCard from "@/components/common/HeaderActionsCard.vue"
import UsersDataTableServer from "@/components/users/UsersDataTableServer.vue"
const { isSystemAdmin } = useCurrentUser()
const breadcrumbs = computed(() => [
{
title: "Users",
to: {
name: "administration/UsersPage",
},
},
])
useBreadcrumbs("Users", breadcrumbs)
</script>
@@ -0,0 +1,70 @@
<template>
<v-row>
<v-col>
<UserEditCardForm
:user-id="userId"
:return-to="{
name: 'administration/users/UserPage',
params: {
userId,
},
}"
@saved="refreshUserOrCurrentUser"
/>
</v-col>
</v-row>
</template>
<script lang="ts" setup>
import { computed } from "vue"
import useBreadcrumbs from "@/use/use-breadcrumbs"
import useCurrentUser from "@/use/use-current-user"
import useUser from "@/use/use-user"
import useSnack from "@/use/use-snack"
import UserEditCardForm from "@/components/users/UserEditCardForm.vue"
const props = defineProps<{
userId: string
}>()
const userId = computed(() => parseInt(props.userId))
const { user, refresh } = useUser(userId)
const { currentUser, refresh: refreshCurrentUser } = useCurrentUser<true>()
const snack = useSnack()
async function refreshUserOrCurrentUser() {
if (userId.value === currentUser.value.id) {
await refreshCurrentUser()
snack.info("Logged-in user updated. App refreshed.")
} else {
await refresh()
}
}
const breadcrumbs = computed(() => {
return [
{
title: "Users",
to: {
name: "administration/UsersPage",
},
},
{
title: user.value?.displayName || user.value?.email,
to: {
name: "administration/users/UserPage",
params: {
userId: props.userId,
},
},
},
]
})
useBreadcrumbs("User Details", breadcrumbs)
</script>
<style scoped></style>
@@ -0,0 +1,353 @@
<template>
<HeaderActionsFormCard
ref="headerActionsFormCard"
title="New User"
@submit.prevent="createUser"
>
<v-row>
<v-col
cols="12"
md="4"
>
<v-text-field
v-model="userAttributes.firstName"
label="First name *"
required
:rules="[required]"
/>
</v-col>
<v-col
cols="12"
md="4"
>
<v-text-field
v-model="userAttributes.lastName"
label="Last name *"
required
:rules="[required]"
@update:focused="autoFillDependentFields"
/>
</v-col>
<v-col
cols="12"
md="4"
>
<v-text-field
v-model="userAttributes.displayName"
label="Display name *"
required
:rules="[required]"
/>
</v-col>
</v-row>
<v-row>
<v-col
cols="12"
md="6"
>
<UserEmailUniqueTextField
v-model="userAttributes.email"
label="Email *"
:rules="[required, minimum(2), email]"
required
validate-on="blur"
/>
</v-col>
<v-col
cols="12"
md="6"
>
<v-text-field
v-model="userAttributes.title"
label="Title"
clearable
/>
</v-col>
</v-row>
<v-row>
<v-col
cols="12"
md="6"
>
<v-text-field
v-model="userAttributes.pilotLicense"
label="Pilot License"
placeholder="e.g., CPL-123456"
clearable
/>
</v-col>
<v-col
cols="12"
md="6"
>
<v-text-field
v-model="userAttributes.ameLicense"
label="AME License"
placeholder="e.g., AME-789012"
clearable
/>
</v-col>
</v-row>
<h4 class="mt-3">Images</h4>
<v-divider class="mt-1 mb-2" />
<v-row>
<v-col
cols="12"
md="6"
>
<v-file-input
v-model="profileImageFile"
accept="image/*"
prepend-icon="mdi-camera"
label="Upload Profile Image"
clearable
@change="handleProfileImageUpload"
/>
<div
v-if="profileImagePreview"
class="mt-4 d-flex justify-center"
>
<v-img
:src="profileImagePreview"
max-width="200"
max-height="200"
class="rounded elevation-2"
cover
/>
</div>
</v-col>
<v-col
cols="12"
md="6"
>
<v-file-input
v-model="signatureImageFile"
accept="image/*"
prepend-icon="mdi-draw"
label="Upload Signature"
clearable
@change="handleSignatureImageUpload"
/>
<div
v-if="signatureImagePreview"
class="mt-4 d-flex justify-center"
>
<v-img
:src="signatureImagePreview"
max-width="300"
max-height="150"
class="rounded elevation-2"
contain
/>
</div>
</v-col>
</v-row>
<h4 class="mt-3">Notification Details</h4>
<v-divider class="mt-1 mb-2" />
<v-col
cols="12"
md="6"
>
<v-switch
v-model="userAttributes.emailNotificationsEnabled"
label="Email notifications enabled?"
inset
center-affix
/>
</v-col>
<template #actions>
<div class="d-flex">
<v-btn
:loading="isLoading"
type="submit"
>
Create User
</v-btn>
<v-spacer />
<v-btn
color="warning"
class="ml-4"
variant="outlined"
:loading="isLoading"
:to="{
name: 'administration/UsersPage',
}"
>
Cancel
</v-btn>
</div>
</template>
</HeaderActionsFormCard>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { useRouter } from "vue-router"
import { isEmpty, isNil } from "lodash"
import { required, minimum, email } from "@/utils/validators"
import { resizeToStandard } from "@/utils/image-resizer"
import usersApi, { User } from "@/api/users-api"
import useBreadcrumbs from "@/use/use-breadcrumbs"
import useSnack from "@/use/use-snack"
import HeaderActionsFormCard from "@/components/shared/cards/HeaderActionsFormCard.vue"
import UserEmailUniqueTextField from "@/components/users/UserEmailUniqueTextField.vue"
const userAttributes = ref<Partial<User>>({
email: "",
displayName: "",
firstName: "",
lastName: "",
title: null,
pilotLicense: null,
ameLicense: null,
emailNotificationsEnabled: true,
})
// Image handling
const profileImageFile = ref<File[] | File | null>(null)
const signatureImageFile = ref<File[] | File | null>(null)
const profileImagePreview = ref<string | null>(null)
const signatureImagePreview = ref<string | null>(null)
async function handleProfileImageUpload() {
if (!profileImageFile.value) {
profileImagePreview.value = null
userAttributes.value.profileImage = null
return
}
// Handle both array and single file
const file = Array.isArray(profileImageFile.value)
? profileImageFile.value[0]
: profileImageFile.value
if (!file) {
profileImagePreview.value = null
userAttributes.value.profileImage = null
return
}
// Ensure we have a valid File object
if (!(file instanceof File)) {
console.error("Invalid file type:", file)
return
}
try {
// Resize image to 512x512 before upload
const base64DataUrl = await resizeToStandard(file)
profileImagePreview.value = base64DataUrl
userAttributes.value.profileImage = base64DataUrl
} catch (error) {
console.error("Error resizing profile image:", error)
snack.error("Failed to process profile image")
}
}
async function handleSignatureImageUpload() {
if (!signatureImageFile.value) {
signatureImagePreview.value = null
userAttributes.value.signatureImage = null
return
}
// Handle both array and single file
const file = Array.isArray(signatureImageFile.value)
? signatureImageFile.value[0]
: signatureImageFile.value
if (!file) {
signatureImagePreview.value = null
userAttributes.value.signatureImage = null
return
}
// Ensure we have a valid File object
if (!(file instanceof File)) {
console.error("Invalid file type:", file)
return
}
try {
// Resize image to 512x512 before upload
const base64DataUrl = await resizeToStandard(file)
signatureImagePreview.value = base64DataUrl
userAttributes.value.signatureImage = base64DataUrl
} catch (error) {
console.error("Error resizing signature image:", error)
snack.error("Failed to process signature image")
}
}
function autoFillDependentFields(focused: boolean) {
if (focused) return
const { email, displayName, firstName, lastName } = userAttributes.value
if ((isNil(firstName) || isEmpty(firstName)) && (isNil(lastName) || isEmpty(lastName))) return
if (isNil(displayName) || isEmpty(displayName)) {
userAttributes.value.displayName = [firstName, lastName].filter(Boolean).join(" ")
}
if (isNil(email) || isEmpty(email)) {
userAttributes.value.email = `${firstName}.${lastName}@unknown.ca`
}
}
const headerActionsFormCard = ref<InstanceType<typeof HeaderActionsFormCard> | null>(null)
const isLoading = ref(false)
const snack = useSnack()
const router = useRouter()
async function createUser() {
if (headerActionsFormCard.value === null) return
const { valid } = await headerActionsFormCard.value.validate()
if (!valid) return
const userAttributesWithAuthSubject = {
...userAttributes.value,
authSubject: userAttributes.value.email,
}
isLoading.value = true
try {
const { user } = await usersApi.create(userAttributesWithAuthSubject)
snack.success("User created!")
return router.push({
name: "administration/users/UserPage",
params: {
userId: user.id,
},
})
} catch (error) {
console.error(error)
snack.error(`Failed to create user: ${error}`)
} finally {
isLoading.value = false
}
}
useBreadcrumbs("User Creation", [
{
title: "Users",
to: {
name: "administration/UsersPage",
},
},
{
title: "New User",
to: {
name: "administration/users/UserNewPage",
},
},
])
</script>
+79
View File
@@ -0,0 +1,79 @@
<template>
<v-empty-state
headline="Whoops, 403"
title="Access Forbidden"
text="You do not have permission to access that page"
:image="SplashImage"
>
<v-row>
<v-col class="d-flex justify-center">
<v-btn
color="primary"
width="200"
variant="outlined"
@click="goBack"
>Back</v-btn
>
</v-col>
</v-row>
<v-row>
<v-col class="d-flex justify-center">
<!-- href="/" performs a more aggressive refresh than using to="xxx" -->
<v-btn
href="/dashboard"
color="primary"
width="200"
variant="outlined"
>Home</v-btn
>
</v-col>
</v-row>
<v-row>
<v-col class="d-flex justify-center">
<v-btn
width="200"
@click="signOut"
>Logout</v-btn
>
</v-col>
</v-row>
<v-divider
class="mt-10 mb-6"
thickness="1"
/>
<p>Site: {{ APPLICATION_NAME }}</p>
<p>Version: {{ releaseTag }}</p>
<p>Commit Hash: {{ gitCommitHash }}</p>
</v-empty-state>
</template>
<script lang="ts" setup>
import SplashImage from "@/assets/SplashImage.png"
import { useAuth0 } from "@auth0/auth0-vue"
import { APPLICATION_NAME } from "@/config"
import useCurrentUser from "@/use/use-current-user"
import useStatus from "@/use/use-status"
const { logout } = useAuth0()
const { reset: resetCurrentUser } = useCurrentUser()
const { releaseTag, gitCommitHash } = useStatus()
function goBack() {
window.history.back()
}
async function signOut() {
resetCurrentUser()
const returnTo = encodeURI(window.location.origin + "/sign-in")
return logout({
logoutParams: {
returnTo,
},
})
}
</script>
<style scoped></style>
@@ -0,0 +1,79 @@
<template>
<v-empty-state
headline="Whoops, 500"
title="Internal Server Error"
:text="'Oops! The server encountered an unexpected error. Please\u00a0contact\u00a0support.'"
:image="SplashImage"
>
<v-row>
<v-col class="d-flex justify-center">
<v-btn
color="primary"
width="200"
variant="outlined"
@click="goBack"
>Back</v-btn
>
</v-col>
</v-row>
<v-row>
<v-col class="d-flex justify-center">
<!-- href="/" performs a more aggressive refresh than using to="xxx" -->
<v-btn
href="/dashboard"
color="primary"
width="200"
variant="outlined"
>Home</v-btn
>
</v-col>
</v-row>
<v-row>
<v-col class="d-flex justify-center">
<v-btn
width="200"
@click="signOut"
>Logout</v-btn
>
</v-col>
</v-row>
<v-divider
class="mt-10 mb-6"
thickness="1"
/>
<p>Site: {{ APPLICATION_NAME }}</p>
<p>Version: {{ releaseTag }}</p>
<p>Commit Hash: {{ gitCommitHash }}</p>
</v-empty-state>
</template>
<script lang="ts" setup>
import SplashImage from "@/assets/SplashImage.png"
import { useAuth0 } from "@auth0/auth0-vue"
import { APPLICATION_NAME } from "@/config"
import useCurrentUser from "@/use/use-current-user"
import useStatus from "@/use/use-status"
const { logout } = useAuth0()
const { reset: resetCurrentUser } = useCurrentUser()
const { releaseTag, gitCommitHash } = useStatus()
function goBack() {
window.history.back()
}
async function signOut() {
resetCurrentUser()
const returnTo = encodeURI(window.location.origin + "/sign-in")
return logout({
logoutParams: {
returnTo,
},
})
}
</script>
<style scoped></style>
+79
View File
@@ -0,0 +1,79 @@
<template>
<v-empty-state
headline="Whoops, 404"
title="Page Not Found"
text="Oops! The page you're looking doesn't exist."
:image="SplashImage"
>
<v-row>
<v-col class="d-flex justify-center">
<v-btn
color="primary"
width="200"
variant="outlined"
@click="goBack"
>Back</v-btn
>
</v-col>
</v-row>
<v-row>
<v-col class="d-flex justify-center">
<!-- href="/" performs a more aggressive refresh than using to="xxx" -->
<v-btn
href="/dashboard"
color="primary"
width="200"
variant="outlined"
>Home</v-btn
>
</v-col>
</v-row>
<v-row>
<v-col class="d-flex justify-center">
<v-btn
width="200"
@click="signOut"
>Logout</v-btn
>
</v-col>
</v-row>
<v-divider
class="mt-10 mb-6"
thickness="1"
/>
<p>Site: {{ APPLICATION_NAME }}</p>
<p>Version: {{ releaseTag }}</p>
<p>Commit Hash: {{ gitCommitHash }}</p>
</v-empty-state>
</template>
<script lang="ts" setup>
import SplashImage from "@/assets/SplashImage.png"
import { useAuth0 } from "@auth0/auth0-vue"
import { APPLICATION_NAME } from "@/config"
import useCurrentUser from "@/use/use-current-user"
import useStatus from "@/use/use-status"
const { logout } = useAuth0()
const { reset: resetCurrentUser } = useCurrentUser()
const { releaseTag, gitCommitHash } = useStatus()
function goBack() {
window.history.back()
}
async function signOut() {
resetCurrentUser()
const returnTo = encodeURI(window.location.origin + "/sign-in")
return logout({
logoutParams: {
returnTo,
},
})
}
</script>
<style scoped></style>
+79
View File
@@ -0,0 +1,79 @@
<template>
<v-empty-state
headline="Whoops, 401"
title="Unauthorized"
text="If you think this is an error, please contact support. Alternatively, try logging out and signing back in."
:image="SplashImage"
>
<v-row>
<v-col class="d-flex justify-center">
<v-btn
color="primary"
width="200"
variant="outlined"
@click="goBack"
>Back</v-btn
>
</v-col>
</v-row>
<v-row>
<v-col class="d-flex justify-center">
<!-- href="/" performs a more aggressive refresh than using to="xxx" -->
<v-btn
href="/dashboard"
color="primary"
width="200"
variant="outlined"
>Home</v-btn
>
</v-col>
</v-row>
<v-row>
<v-col class="d-flex justify-center">
<v-btn
width="200"
@click="signOut"
>Logout</v-btn
>
</v-col>
</v-row>
<v-divider
class="mt-10 mb-6"
thickness="1"
/>
<p>Site: {{ APPLICATION_NAME }}</p>
<p>Version: {{ releaseTag }}</p>
<p>Commit Hash: {{ gitCommitHash }}</p>
</v-empty-state>
</template>
<script lang="ts" setup>
import SplashImage from "@/assets/SplashImage.png"
import { useAuth0 } from "@auth0/auth0-vue"
import { APPLICATION_NAME } from "@/config"
import useCurrentUser from "@/use/use-current-user"
import useStatus from "@/use/use-status"
const { logout } = useAuth0()
const { reset: resetCurrentUser } = useCurrentUser()
const { releaseTag, gitCommitHash } = useStatus()
function goBack() {
window.history.back()
}
async function signOut() {
resetCurrentUser()
const returnTo = encodeURI(window.location.origin + "/sign-in")
return logout({
logoutParams: {
returnTo,
},
})
}
</script>
<style scoped></style>
+46
View File
@@ -0,0 +1,46 @@
<template>
<v-row>
<v-col>
<UserEditCardForm
:user-id="currentUser.id"
:return-to="{
name: 'ProfilePage',
}"
@saved="refreshWrapper"
/>
</v-col>
</v-row>
</template>
<script lang="ts" setup>
import useBreadcrumbs from "@/use/use-breadcrumbs"
import useCurrentUser from "@/use/use-current-user"
import useSnack from "@/use/use-snack"
import UserEditCardForm from "@/components/users/UserEditCardForm.vue"
const { currentUser, refresh } = useCurrentUser<true>()
const snack = useSnack()
async function refreshWrapper() {
await refresh()
snack.info("Logged-in user updated. App refreshed.")
}
useBreadcrumbs("Edit My Profile", [
{
title: "My Profile",
to: {
name: "ProfilePage",
},
},
{
title: "Edit",
to: {
name: "profile/ProfileEditPage",
},
},
])
</script>
<style scoped></style>
+14
View File
@@ -0,0 +1,14 @@
import { createAuth0 } from "@auth0/auth0-vue"
import { AUTH0_AUDIENCE, AUTH0_CLIENT_ID, AUTH0_DOMAIN, ENVIRONMENT } from "@/config"
// See https://auth0.github.io/auth0-vue/#md:add-login-to-your-application
export default createAuth0({
domain: AUTH0_DOMAIN,
clientId: AUTH0_CLIENT_ID,
authorizationParams: {
audience: AUTH0_AUDIENCE,
redirect_uri: `${window.location.origin}/callback`,
},
cacheLocation: ENVIRONMENT === "development" ? "localstorage" : "memory",
})
+13
View File
@@ -0,0 +1,13 @@
import { createI18n } from "vue-i18n"
// I'd prefer to use yaml, or even json, but I can't get them to import at the moment
// This might be a TypeScript issue, or I might need a yaml plugin.
import en from "@/locales/en.js"
export default createI18n({
legacy: false, // support composition api
locale: "en",
messages: {
en,
},
})
+86
View File
@@ -0,0 +1,86 @@
/**
* plugins/vuetify.js
*
* Framework documentation: https://vuetifyjs.com`
*/
// Styles
import "@mdi/font/css/materialdesignicons.css"
import "vuetify/styles"
// ComposablesF
import { createVuetify } from "vuetify"
import * as components from "vuetify/components"
import * as directives from "vuetify/directives"
import * as labsComponents from "vuetify/labs/components"
import {
DARK_BLUE_THEME,
DARK_AQUA_THEME,
DARK_ORANGE_THEME,
DARK_PURPLE_THEME,
DARK_GREEN_THEME,
DARK_CYAN_THEME,
} from "@/theme/DarkTheme"
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({
components: {
...components,
...labsComponents,
},
directives,
theme: {
defaultTheme: "DARK_AQUA_THEME",
themes: {
DARK_BLUE_THEME,
DARK_AQUA_THEME,
DARK_ORANGE_THEME,
DARK_PURPLE_THEME,
DARK_GREEN_THEME,
DARK_CYAN_THEME,
},
},
defaults: {
VCard: {
rounded: "md",
},
VTextField: {
variant: "outlined",
density: "comfortable",
color: "primary",
},
VTextarea: {
variant: "outlined",
density: "comfortable",
color: "primary",
},
VFileInput: {
variant: "outlined",
density: "comfortable",
color: "primary",
prependIcon: null,
},
VSelect: {
variant: "outlined",
density: "comfortable",
color: "primary",
},
VCombobox: {
variant: "outlined",
density: "comfortable",
color: "primary",
},
VAutocomplete: {
variant: "outlined",
density: "comfortable",
color: "primary",
},
VListItem: {
minHeight: "45px",
},
VTooltip: {
location: "top",
},
},
})
+152
View File
@@ -0,0 +1,152 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router"
import { authGuard } from "@auth0/auth0-vue"
import { APPLICATION_NAME } from "@/config"
import administrationRoutes from "@/routes/administration-routes"
import { authorizationGuard } from "@/utils/authorization-guards"
const routes: RouteRecordRaw[] = [
{
path: "/",
name: "SignInPage",
component: () => import("@/pages/SignInPage.vue"),
meta: { requiresAuth: false },
},
{
path: "/callback",
name: "CallbackPage",
component: () => import("@/pages/CallbackPage.vue"),
meta: { requiresAuth: false },
},
{
path: "/",
component: () => import("@/layouts/DefaultLayout.vue"),
children: [
{
path: "",
redirect: "sign-in",
},
{
name: "DashboardPage",
path: "dashboard",
component: () => import("@/pages/DashboardPage.vue"),
meta: {
title: "Dashboard",
},
},
{
path: "",
component: () => import("@/layouts/LayoutWithBreadcrumbs.vue"),
children: [
{
path: "profile",
component: () => import("@/layouts/ProfileLayout.vue"),
meta: {
title: "Profile",
},
children: [
{
path: "",
name: "ProfilePage",
redirect: { name: "profile/ProfileEditPage" },
},
],
},
{
path: "profile/edit",
name: "profile/ProfileEditPage",
component: () => import("@/pages/profile/ProfileEditPage.vue"),
meta: {
title: "Edit Profile",
},
},
],
},
],
},
...administrationRoutes,
{
name: "StatusPage",
path: "/status",
component: () => import("@/pages/StatusPage.vue"),
meta: {
title: "Status",
requiresAuth: false,
},
},
{
path: "/errors/unauthorized",
name: "errors/UnauthorizedPage",
component: () => import("@/pages/errors/UnauthorizedPage.vue"),
meta: {
title: "Unauthorized",
requiresAuth: false,
},
},
{
path: "/errors/forbidden",
name: "errors/ForbiddenPage",
component: () => import("@/pages/errors/ForbiddenPage.vue"),
meta: {
title: "Forbidden",
requiresAuth: false,
},
},
{
path: "/errors/internal-server-error",
name: "errors/InternalServerErrorPage",
component: () => import("@/pages/errors/InternalServerErrorPage.vue"),
meta: {
title: "Internal Server Error",
requiresAuth: false,
},
},
{
path: "/errors/not-found",
name: "errors/NotFoundPage",
component: () => import("@/pages/errors/NotFoundPage.vue"),
meta: {
title: "Not Found",
requiresAuth: false,
},
},
{
path: "/:pathMatch(.*)*",
redirect: "/errors/not-found",
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.onError((error, to) => {
if (
error.message.includes("Failed to fetch dynamically imported module") ||
error.message.includes("Importing a module script failed")
) {
window.location.href = to.fullPath
}
})
router.beforeEach(async (to) => {
if (to.meta && to.meta.title) {
document.title = `${APPLICATION_NAME} - ${to.meta.title}`
} else {
document.title = APPLICATION_NAME
}
if (to.meta.requiresAuth === false) return true
const isAuthenticated = await authGuard(to)
if (!isAuthenticated) return false
const isAuthorized = await authorizationGuard(to)
if (!isAuthorized) return "/errors/forbidden"
return true
})
export default router
+50
View File
@@ -0,0 +1,50 @@
import { RouteRecordRaw } from "vue-router"
import { isSystemAdmin } from "@/utils/authorization-guards"
export const administrationRoutes: Readonly<RouteRecordRaw[]> = [
{
path: "/administration",
component: () => import("@/layouts/AdministrationLayout.vue"),
meta: {
guards: [isSystemAdmin],
},
children: [
{
path: "",
name: "administration/AdministrationDashboardPage",
component: () => import("@/pages/administration/AdministrationDashboardPage.vue"),
},
{
path: "users",
name: "administration/UsersPage",
component: () => import("@/pages/administration/UsersPage.vue"),
},
{
path: "users/new",
name: "administration/users/UserNewPage",
component: () => import("@/pages/administration/users/UserNewPage.vue"),
props: true,
},
{
path: "users/:userId",
name: "administration/users/UserPage",
component: () => import("@/pages/administration/users/UserEditPage.vue"),
props: true,
},
{
path: "users/:userId/edit",
name: "administration/users/UserEditPage",
component: () => import("@/pages/administration/users/UserEditPage.vue"),
props: true,
},
{
path: "settings",
name: "administration/SettingsPage",
component: () => import("@/pages/administration/SettingsPage.vue"),
},
],
},
]
export default administrationRoutes
+204
View File
@@ -0,0 +1,204 @@
@use "./variables" as *;
html {
.bg-success {
color: $white !important;
}
.bg-primary {
color: $white !important;
}
.bg-secondary {
color: $white !important;
}
.bg-warning {
color: $white !important;
}
.bg-secondary-gradient {
background: linear-gradient(287deg, rgb(var(--v-theme-primary)) .54%, #1bcaff 100.84%);
}
}
.border,
.v-divider {
border-color: rgba(var(--v-border-color)) !important;
}
.avtar-border {
border: 2px solid rgb(var(--v-theme-surface)) !important;
}
.subtext {
font-size: $font-size-root;
line-height: 1.75rem;
}
.v-dialog {
&.dialog-mw {
max-width: 800px;
}
}
.round-40 {
height: 40px;
width: 40px;
}
.round-56 {
height: 56px;
width: 56px;
}
.round-48 {
height: 48px;
width: 48px;
}
.round-30 {
height: 30px;
width: 30px;
}
.lh-0 {
line-height: 0 !important;
}
.lh-28 {
line-height: 28px !important;
}
.lh-32 {
line-height: 32px !important;
}
.space-p-96 {
padding: 96px 0 !important;
}
.ps-96 {
padding-inline-start: 96px !important;
}
.pt-96 {
padding-top: 96px !important;
}
.end-0 {
inset-inline-end: 0;
}
.top-0 {
top: 0;
}
.no-scrollbar {
height: calc(100vh - 350px);
}
.msg-chat-height {
height: calc(-500px + 100vh);
}
@media screen and (max-width:991px) {
.overflow-x-reposive {
overflow-x: scroll;
overflow-y: hidden;
}
.border-m-none {
border: 0 !important
}
}
@media screen and (max-height:767px) {
.msg-chat-height {
height: calc(-315px + 100vh);
}
}
.max-h-600 {
max-height: 600px;
height: calc(100vh - 100px);
}
.custom-hover-primary {
.iconify {
color: rgb(255, 255, 255) !important;
@media screen and (max-width:991px) {
color: rgba(var(--v-theme-textPrimary), 0.8) !important
}
}
&:hover {
background-color: rgba(var(--v-theme-lightprimary), 0.1);
.iconify {
@media screen and (max-width:991px) {
color: rgb(var(--v-theme-primary)) !important
}
}
}
}
.custom-hover-primary-white {
.iconify {
color: rgb(255, 255, 255) !important;
}
&:hover {
background-color: rgba(var(--v-theme-lightprimary), 0.1);
}
}
.no-icon {
.v-input__prepend,
.v-input__append {
display: none !important;
}
}
.bg-white {
background-color: rgb(255, 255, 255) !important;
}
.v-badge {
&.x-small-badge {
.v-badge__badge {
height: 6px !important;
width: 6px !important;
}
}
}
.one-line {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.two-line {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.z-1 {
z-index: 1;
}
+150
View File
@@ -0,0 +1,150 @@
@use "sass:math";
@use "sass:map";
@use "sass:meta";
@use "vuetify/lib/styles/tools/functions" as *;
// Custom Variables
// colors
$white: #fff !default;
// cards
$card-title-size: 18px !default;
$body-font-family: "Overpass", sans-serif !default;
$border-radius-root: 10px;
$btn-font-weight: 400 !default;
$btn-letter-spacing: 0 !default;
// Global Shadow
$box-shadow:
rgba(145 158 171 / 30%) 0px 0px 2px 0px,
rgba(145 158 171 / 12%) 0px 12px 24px -4px;
// Global Radius as per breakeven point
@forward "vuetify/settings" with (
$color-pack: false !default,
// Global font size and border radius
$font-size-root: 1rem,
$border-radius-root: $border-radius-root,
$body-font-family: $body-font-family,
$heading-font-family: $body-font-family !default,
$button-height: 40px,
// 👉 Typography
$typography: (
"h1": (
"size": 2.25rem,
"weight": 600,
"line-height": 2.75rem,
"font-family": inherit,
),
"h2": (
"size": 1.875rem,
"weight": 500,
"line-height": 2.25rem,
"font-family": inherit,
),
"h3": (
"size": 1.5rem,
"weight": 500,
"line-height": 2rem,
"font-family": inherit,
),
"h4": (
"size": 1.3125rem,
"weight": 500,
"line-height": 1.6rem,
"font-family": inherit,
),
"h5": (
"size": 1.125rem,
"weight": 500,
"line-height": 1.6rem,
"font-family": inherit,
),
"h6": (
"size": 1rem,
"weight": 500,
"line-height": 1.2rem,
"font-family": inherit,
),
"subtitle-1": (
"size": 0.875rem,
"weight": 400,
"line-height": 1.1rem,
"font-family": inherit,
),
"subtitle-2": (
"size": 0.75rem,
"weight": 400,
"line-height": 1rem,
"font-family": inherit,
),
"body-1": (
"size": 0.875rem,
"weight": 400,
"font-family": inherit,
),
"body-2": (
"size": 0.75rem,
"weight": 400,
"font-family": inherit,
),
"button": (
"size": 0.875rem,
"weight": 500,
"font-family": inherit,
"text-transform": capitalize,
),
"caption": (
"size": 0.75rem,
"weight": 400,
"font-family": inherit,
),
"overline": (
"size": 0.75rem,
"weight": 500,
"font-family": inherit,
"text-transform": uppercase,
),
)
!default,
// 👉 Button
$button-border-radius: $border-radius-root !default,
$button-text-letter-spacing: 0 !default,
$button-text-transform: capitalize,
$button-elevation: (
"default": 0,
"hover": 4,
"active": 8,
)
!default,
// 👉 Tooltip
$tooltip-background-color: #212121 !default,
$tooltip-text-color: rgb(var(--v-theme-on-primary)) !default,
$tooltip-font-size: 0.75rem !default,
$tooltip-border-radius: 4px !default,
$tooltip-padding: 4px 8px !default,
// 👉 Rounded
$rounded: (
0: 0,
"sm": $border-radius-root * 0.5,
null: $border-radius-root,
"md": $border-radius-root * 1,
"lg": $border-radius-root * 2,
"xl": $border-radius-root * 6,
"pill": 9999px,
"circle": 50%,
),
// 👉 Card
// $card-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !default,
$card-elevation: 10 !default,
$card-title-line-height: 1.6 !default,
$card-text-padding: 24px !default,
$card-item-padding: 30px 30px 24px !default,
$card-actions-padding: 10px 24px 24px !default,
$card-subtitle-opacity: 1 !default // $card-border-color $border-color-root
);
+24
View File
@@ -0,0 +1,24 @@
.single-line-alert {
.v-alert__close,
.v-alert__prepend {
align-self: center !important;
}
}
@media (max-width: 500px) {
.single-line-alert {
display: flex;
flex-wrap: wrap;
.v-alert__append {
margin-inline-start: 0px;
}
.v-alert__close {
margin-left: auto;
}
.v-alert__content {
width: 100%;
margin-top: 5px;
}
}
}
@@ -0,0 +1,9 @@
.v-breadcrumbs{
.v-breadcrumbs-divider{
padding: 0 0 !important;
}
.v-breadcrumbs-item--link{
text-decoration: none;
}
}
+22
View File
@@ -0,0 +1,22 @@
.v-btn-group .v-btn {
height: inherit !important;
}
.v-btn-group {
border-color: rgb(var(--v-theme-borderColor)) !important;
}
.v-btn{
text-transform: capitalize;
letter-spacing: 0;
border-radius: 30px;
&.v-btn--variant-elevated{
box-shadow: none !important;
}
.v-btn--slim{
padding: 0 15px;
}
}
.v-btn--elevated:hover{
box-shadow: none;
}
+69
View File
@@ -0,0 +1,69 @@
// Outline Card
.v-card--variant-outlined {
border-color: rgba(var(--v-theme-borderColor)) !important;
}
.v-card--variant-elevated,
.v-card--variant-flat {
color: rgb(var(--v-theme-textPrimary));
}
.card-hover {
transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
&:hover {
scale: 1.01;
transition: all 0.1s ease-in 0s;
}
}
.v-card {
width: 100%;
overflow: visible;
.color-inherits {
color: inherit;
}
.feature-card {
.v-responsive__content {
height: 100%;
}
}
.v-timeline-divider__before,.v-timeline-divider__after {
background: rgba(var(--v-border-color), 1);
}
.v-card-text{
padding: 24px 24px;
}
.v-card-item{
padding: 24px 24px;
}
}
// Theme cards
.cardBordered {
.v-card {
box-shadow: none !important;
border: 1px solid rgb(var(--v-theme-borderColor));
}
}
.elevation-o-card{
.v-card-item{
padding: 0.625rem 1rem;
}
}
.card-title{
font-size: 18px;
font-weight: 500;
color:rgb(var(--v-theme-textPrimary));
}
.card-subtitle{
font-size: 14px;
font-weight: 400;
color:rgb(var(--v-theme-textSecondary));
}
.dark-card-title{
font-size: 18px;
font-weight: 500;
color:rgb(var(--v-theme-textPrimary));
}
+3
View File
@@ -0,0 +1,3 @@
.theme-carousel .v-carousel__progress {
position: absolute;
}
+3
View File
@@ -0,0 +1,3 @@
.v-pagination__item--is-active .v-btn__overlay {
opacity: 0.15 !important;
}
+99
View File
@@ -0,0 +1,99 @@
.v-table {
&.datatabels {
&.productlist {
.v-data-table-header__content span {
color: rgb(var(--v-theme-textPrimary));
}
.v-toolbar {
.v-input__control {
max-width: 300px;
}
.v-toolbar__content {
height: auto !important;
}
}
thead tr th:first-child {
padding-left: 0px !important;
}
tbody tr td:first-child {
padding-left: 0px !important;
}
tbody tr td{
padding: 15px;
}
}
.v-selection-control--dirty .v-selection-control__input>.v-icon {
color: rgb(var(--v-theme-primary));
}
}
}
@media screen and (max-width:1368px) {
.v-table {
&.datatabels {
&.productlist {
.v-data-table-header__content span {
color: rgb(var(--v-theme-textPrimary));
}
table {
tbody {
tr {
td {
padding: 14px 5px !important;
&:first-child {
padding-left: 15px !important;
}
}
}
}
thead {
tr {
th {
padding: 14px 5px !important;
&:first-child {
padding-left: 15px !important;
}
}
}
}
}
}
}
}
}
.v-pagination {
.v-pagination__list {
.v-pagination__item--is-active {
.v-btn {
.v-btn__overlay {
opacity: 0;
}
background-color: rgb(var(--v-theme-grey100)) !important;
}
}
}
}
@@ -0,0 +1,6 @@
.v-expansion-panel-title__overlay{
background: rgba(var(--v-theme-primary));
}
.v-expansion-panel:not(:first-child)::after {
border-color: transparent !important;
}
+29
View File
@@ -0,0 +1,29 @@
@use "../variables" as *;
.v-field--variant-outlined .v-field__outline__start.v-locale--is-ltr,
.v-locale--is-ltr .v-field--variant-outlined .v-field__outline__start {
border-radius: $border-radius-root 0 0 $border-radius-root;
}
.v-field--variant-outlined .v-field__outline__end.v-locale--is-ltr,
.v-locale--is-ltr .v-field--variant-outlined .v-field__outline__end {
border-radius: 0 $border-radius-root $border-radius-root 0;
}
.v-field {
font-size: 14px;
color: rgba(var(--v-theme-textPrimary));
}
// select outlined
.v-field--variant-outlined .v-field__outline__start,
.v-field--variant-outlined .v-field__outline__notch::before,
.v-field--variant-outlined .v-field__outline__notch::after,
.v-field--variant-outlined .v-field__outline__end {
opacity: 1;
}
.v-field--active .v-label.v-field-label{
color: rgb(var(--v-theme-textPrimary));
}
+35
View File
@@ -0,0 +1,35 @@
// variant
.v-input--density-default,
.v-field--variant-solo,
.v-field--variant-filled {
--v-input-control-height: 51px;
--v-input-padding-top: 14px;
}
// comfortable
.v-input--density-comfortable {
--v-input-control-height: 44px;
}
// compact
.v-input--density-compact {
--v-input-padding-top: 10px;
}
.v-label {
font-size: 14px;
opacity: 0.7;
font-weight: 500 !important;
}
.v-switch .v-label,
.v-checkbox .v-label {
opacity: 1;
}
.v-text-field__suffix {
opacity: 1;
padding-left: 20px;
}
.shadow-none .v-field--variant-solo {
box-shadow: none !important;
}
+18
View File
@@ -0,0 +1,18 @@
.v-time-picker-clock{
background: rgb(var(--v-theme-grey100)) ;
}
.v-time-picker-controls__ampm__btn.v-btn.v-btn--density-default{
border: 0 !important;
}
.v-stepper-header,.v-stepper.v-sheet{
box-shadow: none !important;
}
.v-time-picker-controls__time__btn.v-btn--density-default.v-btn {
width: 55px !important;
height: 55px !important;
font-size: 30px;
}
.v-time-picker-controls__time__separator {
font-size: 36px !important;
}
+34
View File
@@ -0,0 +1,34 @@
.v-list.theme-list {
.v-list-item:hover > .v-list-item__overlay {
opacity: 1;
z-index: 1;
}
.v-list-item--variant-text {
.v-list-item__overlay {
background: rgb(var(--v-theme-hoverColor));
}
}
.v-list-item__prepend,
.v-list-item__content {
z-index: 2;
}
.v-list-item__overlay {
background-color: rgb(var(--v-theme-hoverColor));
}
.v-list-item.v-list-item--active{
.v-list-item__overlay{
opacity: 1;
}
}
.mail-items{
min-height: 40px !important;
margin-bottom: 5px !important;
}
}
.v-list-item-title{
font-size: 14px;
}
@@ -0,0 +1,3 @@
.v-navigation-drawer__scrim.fade-transition-leave-to {
display: none;
}
@@ -0,0 +1,6 @@
// For checkbox & radios
.v-selection-control__input > .v-icon.mdi-checkbox-blank-outline,
.v-selection-control__input > .v-icon.mdi-radiobox-blank {
color: rgb(var(--v-theme-inputBorder));
opacity: 1;
}
+33
View File
@@ -0,0 +1,33 @@
@use "../variables" as *;
.elevation-9 {
box-shadow: rgb(0 0 0 / 5%) 0px 9px 17.5px !important;
}
.elevation-10 {
box-shadow: $box-shadow !important;
}
.elevation-1 {
box-shadow:0px 12px 30px -2px rgba(58,75,116,0.14) !important
}
.elevation-2 {
box-shadow:0px 24px 24px -12px rgba(0, 0, 0, .05) !important
}
.elevation-3 {
box-shadow: rgba(145,158,171,0.2) 0px 0px 2px 0px, rgba(145,158,171,0.12) 0px 12px 24px -4px !important;
}
.elevation-4{
box-shadow: 0px 12px 12px -6px rgba(0,0,0,0.15) !important;
}
.elevation-5
{
box-shadow: 1px 0 7px rgba(0, 0, 0, .05)!important;
}
.primary-shadow {
box-shadow: rgba(var(--v-theme-primary), 0.30) 0px 12px 14px 0px;
&:hover {
box-shadow: none;
}
}
+8
View File
@@ -0,0 +1,8 @@
.v-stepper-item--selected .v-stepper-item__avatar.v-avatar, .v-stepper-item--complete .v-stepper-item__avatar.v-avatar {
background: rgb(var(--v-theme-primary)) !important;
}
.v-stepper-item__avatar.v-avatar {
background: rgba(var(--v-theme-primary), var(--v-medium-emphasis-opacity)) !important;
color: rgb(var(--v-theme-on-primary)) !important;
}
+48
View File
@@ -0,0 +1,48 @@
.v-selection-control.v-selection-control--density-default {
.v-switch__track,
.v-switch__thumb {
background-color: rgb(var(--v-theme-grey200));
}
&.v-selection-control--dirty {
.v-selection-control__wrapper.text-primary {
.v-switch__track {
background-color: rgba(var(--v-theme-primary), 0.6);
}
.v-switch__thumb {
background-color: rgb(var(--v-theme-primary));
}
}
.v-selection-control__wrapper.text-secondary {
.v-switch__track {
background-color: rgba(var(--v-theme-secondary), 0.6);
}
.v-switch__thumb {
background-color: rgb(var(--v-theme-secondary));
}
}
.v-selection-control__wrapper.text-warning {
.v-switch__track {
background-color: rgba(var(--v-theme-warning), 0.6);
}
.v-switch__thumb {
background-color: rgb(var(--v-theme-warning));
}
}
.v-selection-control__wrapper.text-error {
.v-switch__track {
background-color: rgba(var(--v-theme-error), 0.6);
}
.v-switch__thumb {
background-color: rgb(var(--v-theme-error));
}
}
.v-selection-control__wrapper.text-success {
.v-switch__track {
background-color: rgba(var(--v-theme-success), 0.6);
}
.v-switch__thumb {
background-color: rgb(var(--v-theme-success));
}
}
}
}
+99
View File
@@ -0,0 +1,99 @@
.v-table .v-table__wrapper > table > tbody > tr:not(:last-child) > td,
.v-table .v-table__wrapper > table > tbody > tr:not(:last-child) > th,
.v-table .v-table__wrapper > table > thead > tr:last-child > th {
border-bottom: thin solid rgba(var(--v-border-color)) !important;
}
.v-data-table{
th.v-data-table__th{
font-size:16px;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
}
td.v-data-table__td{
font-size: 14px;
text-wrap: nowrap;
}
.v-data-table-footer{
padding: 15px 8px;
}
.v-data-table-header__sort-badge{
background-color:rgb(var(--v-theme-borderColor)) !important;
}
.tdhead{
font-size:16px;
}
}
@media screen and (max-width:767px) {
.v-data-table-footer{
justify-content: center;
}
}
.v-table {
&.ticket-table {
table {
thead {
th {
font-weight: 600 !important;
}
}
tbody {
tr {
td {
padding: 16px 16px !important;
}
}
}
}
}
&.invoice-table {
.v-table__wrapper {
table {
thead {
th {
font-weight: 600 !important;
padding: 0px 24px !important;
&:first-child {
padding-left: 0 !important;
}
&:last-child {
padding-right: 0 !important;
}
}
}
tbody {
tr {
td {
padding: 8px 24px !important;
&:first-child {
padding-left: 0 !important;
}
&:last-child {
padding-right: 0 !important;
}
}
}
}
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
@use "../variables" as *;
.theme-tab {
&.v-tabs {
.v-tab {
border-radius: $border-radius-root !important;
min-width: auto !important;
&.v-slide-group-item--active {
background: rgb(var(--v-theme-primary));
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
.v-text-field input {
font-size: 0.875rem;
}
.v-field__outline {
color: rgb(var(--v-theme-inputBorder));
--v-field-border-opacity: 1 !important;
}
.input {
.v-field--variant-outlined {
background-color: rgba(0, 0, 0, 0.025);
}
}
+7
View File
@@ -0,0 +1,7 @@
.v-textarea input {
font-size: 0.875rem;
font-weight: 500;
&::placeholder {
color: rgba(0, 0, 0, 0.38);
}
}
+597
View File
@@ -0,0 +1,597 @@
@use "../variables" as *;
@keyframes slideup {
0% {
transform: translate3d(0, 0, 0);
}
100% {
transform: translate3d(0px, -100%, 0px);
}
}
.animateDown {
animation: 35s linear 0s infinite normal none running slideDown;
}
@keyframes slideDown {
0% {
transform: translate3d(0, -100%, 0);
}
100% {
transform: translate3d(0px, 0, 0px);
}
}
// OfferBar
.offerbar {
position: relative;
top: 0;
width: 100%;
z-index: 999;
.white-btn {
background-color: rgba(var(--v-theme-surface), 0.15);
font-weight: 700;
height: 25px;
}
&:before {
background-repeat: no-repeat;
content: '';
position: absolute;
background-image: url('@/assets/images/front-pages/background/left-shape.png');
bottom: 0;
height: 40px;
left: 0;
width: 325px;
}
&:after {
background-repeat: no-repeat;
content: '';
position: absolute;
background-image: url('@/assets/images/front-pages/background/right-shape.png');
bottom: 0;
right: 17%;
width: 325px;
top: 0;
background-size: contain;
}
}
//
// frameworks
//
.slider-group {
animation: slide 45s linear infinite;
}
.marquee1-group {
animation: marquee 45s linear infinite;
}
.marquee2-group {
animation: marquee2 45s linear infinite;
}
@keyframes slide {
0% {
transform: translate3d(0, 0, 0);
}
100% {
transform: translate3d(-100%, 0, 0);
}
}
@keyframes marquee {
0% {
transform: translate3d(0, 0, 0);
}
100% {
transform: translate3d(-2086px, 0, 0);
}
}
@keyframes marquee2 {
0% {
transform: translate3d(-2086px, 0, 0)
}
100% {
transform: translate3d(0, 0, 0)
}
}
.front-wraper {
overflow: hidden;
.underline-link {
text-underline-offset: 4px;
}
.underline-link-6 {
text-underline-offset: 6px;
text-decoration-thickness: 2px;
}
.main-banner {
min-width: 1300px;
overflow: hidden;
max-height: 700px;
height: calc(100vh - 100px);
img {
max-width: 100%;
height: auto;
}
}
.team {
&:hover {
.intro {
opacity: 1;
}
}
.intro {
opacity: 0;
bottom: 16px;
inset-inline-start: .75rem;
inset-inline-end: .75rem;
transition: 0.5s;
}
}
// Revenue Products
.feature-tabs {
.v-slide-group__content {
gap: 16px;
padding-bottom: 56px;
}
.v-btn {
background-color: rgba(var(--v-theme-surface));
padding: 16px 24px;
border-radius: 12px !important;
font-size: 16px;
box-shadow: 0px 24px 24px -12px rgba(0, 0, 0, .05);
}
.v-tab--selected {
background-color: rgba(var(--v-theme-primary));
box-shadow: 0px 24px 24px -12px rgba(99, 91, 255, .15);
.v-btn__content {
color: #fff;
.v-tab__slider {
display: none;
}
}
}
}
.v-container {
&.max-width-1218 {
max-width: 1218px !important;
}
}
.v-container {
&.max-width-800 {
max-width: 800px !important;
}
&.max-width-1000 {
max-width: 1000px !important;
}
}
.max-w-600 {
max-width: 600px !important;
}
.template {
.left-widget {
position: absolute;
top: 96px;
inset-inline-start: -40px;
max-height: 400px;
width: auto;
}
.right-widget {
position: absolute;
top: 96px;
inset-inline-end: -40px;
max-height: 400px;
width: auto;
}
}
.feature-tabs {
.v-tab__slider {
top: 0;
bottom: unset;
}
&.v-tabs--density-default {
--v-tabs-height: auto;
}
}
.feature-tabs-expansion {
.v-expansion-panel-text__wrapper {
padding: 0px 0px 16px;
}
.v-expansion-panel-title {
padding: 16px 0px;
}
.v-expansion-panel {
background-color: transparent !important;
}
.v-expansion-panel--active:not(:first-child),
.v-expansion-panel--active+.v-expansion-panel {
margin-top: 0;
}
}
.leader-slider {
.carousel__slide {
padding: 0 15px;
}
.carousel__viewport {
margin: 0 -15px;
padding-bottom: 30px;
}
.carousel__prev,
.carousel__next {
top: -85px;
width: 100%;
justify-content: end;
margin: 0;
transform: none;
display: flex;
justify-content: center
}
.carousel__next {
height: 48px;
width: 48px;
border-radius: 50%;
background: rgb(var(--v-theme-lightprimary));
}
.carousel__prev {
height: 48px;
width: 48px;
border-radius: 50%;
background: rgb(var(--v-theme-lightprimary));
right: 65px;
left: unset;
}
}
.our-template {
.carousel__slide {
padding: 0 15px 40px;
}
.carousel__viewport {
margin: 0 -106px;
}
}
.testimonials {
.carousel__prev,
.carousel__next {
width: 100%;
justify-content: end;
margin: 0;
transform: translateY(75px);
display: flex;
justify-content: center;
bottom: 0;
}
.carousel__next {
height: 32px;
width: 32px;
border-radius: 50%;
background: rgb(var(--v-theme-background));
left: -60%;
}
.carousel__prev {
height: 32px;
width: 32px;
border-radius: 50%;
background: rgb(var(--v-theme-background));
right: 65px;
left: -74%;
}
.carousel {
padding-bottom: 25px;
}
.slide-counter {
position: relative;
bottom: -4px;
left: 50px;
z-index: 2;
font-size: 15px;
opacity: 0.7;
}
}
.social-icon {
svg {
path {
fill: rgb(255, 255, 255);
&:hover {
fill: rgb(var(--v-theme-primary));
}
}
}
}
.package {
.v-list-item {
min-height: 35px !important;
}
}
.lp-faq {
.v-expansion-panel-title__icon {
.v-icon {
font-size: 20px;
opacity: 0.5
}
}
.v-expansion-panels:not(.v-expansion-panels--variant-accordion)> :first-child:not(:last-child):not(.v-expansion-panel--active):not(.v-expansion-panel--before-active) {
border-bottom-left-radius: 8px !important;
border-bottom-right-radius: 8px !important;
}
.v-expansion-panels:not(.v-expansion-panels--variant-accordion)> :not(:first-child):not(:last-child):not(.v-expansion-panel--active):not(.v-expansion-panel--after-active) {
border-top-left-radius: 8px !important;
border-top-right-radius: 8px !important;
}
.v-expansion-panels:not(.v-expansion-panels--variant-accordion)> :not(:first-child):not(:last-child):not(.v-expansion-panel--active):not(.v-expansion-panel--before-active) {
border-bottom-left-radius: 8px !important;
border-bottom-right-radius: 8px !important;
}
.v-expansion-panel--active:not(:first-child),
.v-expansion-panel--active+.v-expansion-panel {
margin-top: 0px !important;
}
}
.animted-img {
position: absolute;
z-index: 9;
top: 0%;
animation: mover 5s infinite alternate;
}
.animted-img-2 {
position: absolute;
z-index: 9;
top: -35px;
inset-inline-end: 15px;
animation: mover 5s infinite alternate;
}
@keyframes mover {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-10px);
}
}
.carousel__pagination {
.carousel__pagination-button {
padding: 6px;
&::after {
height: 8px;
width: 8px;
border-radius: 50%;
background-color: transparent;
background-color: rgb(var(--v-theme-textPrimary));
opacity: 0.25;
}
&:hover {
&::after {
background-color: #000;
opacity: 1;
}
}
}
.carousel__pagination-button--active {
&::after {
background-color: #000;
opacity: 1;
}
}
}
.carousel {
z-index: 2;
}
}
.v-btn--size-default {
&.nav-links {
font-size: $font-size-root !important;
.v-btn__overlay {
display: none;
}
&:hover {
color: rgb(var(--v-theme-primary)) !important;
}
}
}
.light-primary {
background-color: rgb(var(--v-theme-primary), 0.1);
}
.announce-close {
position: absolute;
right: 15px;
}
.text-align-start{
text-align: start;
}
@media screen and (max-width:1199px) {
.ps-96 {
padding-inline-start: 60px !important;
}
.space-p-96 {
padding: 55px 0 !important;
}
.pt-96 {
padding-top: 55px !important;
}
.offerbar {
&:after {
background-image: none;
}
}
.offerbar:after,
.offerbar:before {
display: none;
}
}
@media screen and (max-width:1024px) {
.front-wraper .testimonials .slide-counter {
left: 67px;
}
.space-p-96 {
padding: 40px 0 !important;
}
.pt-96 {
padding-top: 40px !important;
}
.front-wraper .bg-collection {
background-image: none;
}
.front-wraper .our-template .carousel__viewport {
margin: 0 0px;
}
.ps-96 {
padding-inline-start: 20px !important;
padding-inline-end: 20px !important;
}
}
@media screen and (max-width:991px) {
.text-align-start{
text-align: center;
}
}
@media screen and (max-width:767px) {
.technology {
.round-54 {
height: 45px;
width: 45px;
img {
height: 22px;
}
}
}
.front-wraper {
.display-2 {
font-size: 32px;
line-height: normal;
}
.display-1 {
font-size: 32px;
line-height: normal;
}
}
.front-wraper .leader-slider .carousel__viewport {
margin: 0 0px;
}
.announce-close {
bottom: 8px;
}
.text-48 {
font-size: 30px !important;
line-height: 40px !important;
}
.text-56 {
font-size: 35px !important;
line-height: 40px !important;
}
.team {
.intro {
opacity: 1 !important;
}
}
}
+104
View File
@@ -0,0 +1,104 @@
@use "../variables" as *;
.front-lp-header {
.v-toolbar{
background: rgb(var(--v-theme-surface));
}
&.v-app-bar .v-toolbar__content {
padding: 0;
}
.v-toolbar__content {
background: transparent !important;
box-shadow: none !important;
}
&.v-toolbar {
background: transparent !important;
top: 0 !important;
}
.v-toolbar {
background: transparent !important;
}
&.sticky-header {
position: fixed !important;
top: 0 !important;
transition: 0.5s;
background-color: rgba(var(--v-theme-surface)) !important;
box-shadow: 0 4px 29px -11px #3a4b7424 !important;
}
}
//
// mega menu
//
.white-btn{
background-color: #769CFF;
}
.front_wrapper {
&.v-menu .v-overlay__content {
margin: 0 auto;
left: 0 !important;
right: 0;
}
.megamenu {
&::before {
content: '';
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 96%;
background-color: rgba(55, 114, 255, 0.2);
border-radius: 7px;
opacity: 0;
}
.v-btn {
top: 50%;
transform: translateY(-50%);
z-index: 1;
left: 0;
right: 0;
min-width: 100px;
opacity: 0;
font-size: 13px;
}
&:hover {
&::before,
.v-btn {
opacity: 1;
}
}
}
}
.lp-drawer {
&.v-navigation-drawer {
top: 0 !important;
height: 100% !important;
z-index: 1007 !important;
}
}
.lp-mobile-sidebar {
.v-list {
.v-list-item__content {
overflow: inherit;
}
}
.v-list-group__items .v-list-item {
padding-inline-start: 25px !important;
}
}
.v-btn--size-default {
&.nav-links {
font-size: $font-size-root !important;
}
}
+40
View File
@@ -0,0 +1,40 @@
html {
overflow-y: auto;
}
.v-main{
background:rgb(var(--v-theme-background)) !important; ;
}
@media (max-width: 1279px) {
.v-main {
margin: 0 10px;
}
}
.cursor-pointer {
cursor: pointer;
}
.page-wrapper {
min-height: calc(100vh - 100px);
padding: 24px;
// border-radius: $border-radius-root;
@media screen and (max-width: 767px) {
padding: 20px 10px;
}
}
.maxWidth {
max-width: 1200px;
margin: 0 auto;
}
.fixed-width {
max-width: 1300px;
}
.right-pos-img {
position: absolute;
right: 0;
top: 0;
height: 100%;
}
+93
View File
@@ -0,0 +1,93 @@
.v-btn.customizer-btn {
position: fixed;
bottom: 30px;
right: 30px;
border-radius: 50%;
// .icon-tabler-settings {
// animation: progress-circular-rotate 1.4s linear infinite;
// transform-origin: center center;
// transition: all 0.2s ease-in-out;
// }
}
.btn-group-custom {
&.v-btn-group {
height: 66px !important;
overflow: unset !important;
.v-btn {
height: 66px !important;
padding: 0 20px;
border: 1px solid rgb(var(--v-theme-borderColor), 0.7) !important;
transition: all 0.1s ease-in 0s;
&:hover {
transform: scale(1.05);
}
&.text-primary {
.v-btn__overlay {
background: transparent !important;
}
.icon {
color: rgb(var(--v-theme-primary)) !important;
fill: rgb(var(--v-theme-primary), 0.2);
}
color: rgb(var(--v-theme-primary)) !important;
}
}
}
}
.hover-btns {
transition: all 0.1s ease-in 0s;
&:hover {
transform: scale(1.05);
}
}
// all theme colors
.v-avatar.themeBlue,
.v-avatar.themeDarkBlue {
background: #1e88e5;
}
.v-avatar.themeAqua,
.v-avatar.themeDarkAqua {
background: #0074ba;
}
.v-avatar.themePurple,
.v-avatar.themeDarkPurple {
background: #763ebd;
}
.v-avatar.themeGreen,
.v-avatar.themeDarkGreen {
background: #0a7ea4;
}
.v-avatar.themeCyan,
.v-avatar.themeDarkCyan {
background: #01c0c8;
}
.v-avatar.themeOrange,
.v-avatar.themeDarkOrange {
background: #fa896b;
}
.DARK_BLUE_THEME, .DARK_AQUA_THEME, .DARK_ORANGE_THEME, .DARK_PURPLE_THEME, .DARK_GREEN_THEME, .DARK_CYAN_THEME {
.togglethemeBlue {
display: block !important;
}
.togglethemeDarkBlue {
display: none !important;
}
}
.BLUE_THEME, .AQUA_THEME, .ORANGE_THEME, .PURPLE_THEME, .GREEN_THEME, .CYAN_THEME {
.togglethemeDarkBlue {
display: block !important;
}
.togglethemeBlue {
display: none !important;
}
}
+81
View File
@@ -0,0 +1,81 @@
// theme : dark
div[class*='v-theme--DARK_'] {
.smallCap {
color: rgb(var(--v-theme-textSecondary));
}
.elevation-10 {
box-shadow: rgb(145 158 171 / 30%) 0px 0px 2px 0px, rgb(145 158 171 / 2%) 0px 12px 24px -4px !important;
}
.v-field__outline{
--v-field-border-opacity: 0.38 !important;
}
.front-wraper{
.bg-background{
background-color: rgb(var(--v-theme-hoverColor)) !important;
}
.front-dark{
&.bg-textPrimary{
background-color: rgb(var(--v-theme-surface)) !important;
}
}
.bg-textPrimary{
background-color: rgb(var(--v-theme-textSecondary)) !important;
}
}
#vector-map .dxm-layers path {
fill: #7C8FAC !important;
}
.svgMap-map-wrapper {
.svgMap-country {
stroke: #878585;
fill: #1A2537 !important;
&#svgMap-map-country-IN {
fill: rgb(var(--v-theme-secondary)) !important;
}
&#svgMap-map-country-AF {
fill: rgb(var(--v-theme-purple)) !important;
}
&#svgMap-map-country-US {
fill: rgb(var(--v-theme-primary)) !important;
}
}
.svgMap-map-controls-zoom {
background: #c9d6de !important;
}
.svgMap-control-button{
background-color: rgb(var(--v-theme-textSecondary)) !important;
}
}
.dark-card-title{
color:rgb(var(--v-theme-surface));
}
.fc{
.fc-button-primary:not(:disabled).fc-button-active {
background-color: rgb(var(--v-theme-grey100));
}
.fc-button-group {
>.fc-button {
&:hover,&:focus{
background-color: rgba(var(--v-theme-grey100));
color: #fff;
.fc-icon{
color: #fff;
}
}
}
}
}
}
+231
View File
@@ -0,0 +1,231 @@
@use "../variables" as *;
.horizontalLayout {
.v-main {
margin: 0 16px !important;
@media screen and (max-width: 767px) {
margin: 0 10px !important;
}
}
}
.horizontal-header {
&.v-app-bar .v-toolbar__content {
padding: 0;
display: flex;
justify-content: space-between;
}
.maxWidth {
@media screen and (max-width: 1199px) {
padding: 0 8px !important;
}
}
}
.ddMenu {
&.ddLevel-1 {
.navItem {
.navItemLink {
.dot {
height: 6px;
width: 6px;
background-color: rgb(var(--v-theme-textSecondary));
border-radius: 50%;
margin-inline-end: 8px !important;
}
}
&:hover {
.dot {
background-color: rgb(var(--v-theme-secondary));
}
}
}
}
&.ddLevel-2 {
.navItem {
.navItemLink {
.dot {
height: 6px;
width: 6px;
background-color: rgb(var(--v-theme-textSecondary));
border-radius: 50%;
margin-inline-end: 8px !important;
}
}
&:hover {
.dot {
background-color: rgb(var(--v-theme-secondary));
}
}
}
}
}
.horizontalMenu {
.v-toolbar__content {
max-width: 1270px;
margin: 0 auto;
}
.navItem:has(.ddMenu.ddLevel-1 li a.router-link-active) {
background-color: rgb(var(--v-theme-secondary)) !important;
border-radius: 9999px;
.navcollapse {
color: rgba(255, 255, 255);
}
}
}
.mobile-menu {
.v-navigation-drawer {
margin-top: -70px !important;
height: 100vh !important;
z-index: 2000 !important;
}
}
@media (min-width: 960px) {
.horizontalMenu {
margin-top: 65px;
margin-bottom: -70px;
.maxWidth {
.horizontal-navbar {
max-width: 1160px;
}
}
}
.horizontal-navbar {
padding: 16px 0;
margin: 0px auto;
align-items: center;
display: flex;
z-index: 11;
font-size: 0.875rem;
position: relative;
ul {
padding: 0px;
margin: 0px;
}
.ddMenu {
li {
a {
color: rgb(var(--v-theme-textPrimary)) !important;
}
}
}
li {
list-style: none;
a {
text-decoration: none;
display: flex;
align-items: center;
padding: 10px 13px;
height: 40px;
.navIcon {
margin-right: 10px;
display: flex;
}
.ddIcon {
margin-top: 2px;
}
&.router-link-exact-active {
background-color: transparent;
color: rgba(var(--v-theme-secondary)) !important;
.dot {
background-color: rgb(var(--v-theme-secondary)) !important;
}
}
}
}
.navItem {
position: relative;
.single-link {
&:hover {
color: rgb(var(--v-theme-secondary)) !important;
}
}
.ddMenu {
.navItem {
.navcollapse {
&:hover {
color: rgb(var(--v-theme-secondary)) !important;
}
}
}
}
}
.ddMenu {
position: absolute;
width: 230px;
display: none;
top: 40px;
padding: 10px;
z-index: 1;
background-color: rgb(var(--v-theme-surface));
box-shadow: $box-shadow;
border-radius: $border-radius-root;
li {
margin-bottom: 3px;
}
}
.ddLevel-2,
.ddLevel-3 {
top: -5px;
left: 212px;
}
.navItem:hover {
>.ddMenu {
display: block;
}
}
>li:hover {
background-color: rgb(var(--v-theme-lightprimary));
border-radius: 9999px;
>.navItemLink {
color: rgb(var(--v-theme-secondary));
opacity: 1;
}
}
.router-link-exact-active {
color: rgb(var(--v-theme-secondary));
font-weight: 500;
background-color: rgb(var(--v-theme-lightprimary));
border-radius: $border-radius-root;
}
}
}
+109
View File
@@ -0,0 +1,109 @@
.h-100 {
height: 100%;
}
.w-100 {
width: 100%;
}
.h-100vh {
height: 100vh;
}
.gap-2 {
gap: 8px;
}
.gap-3 {
gap: 16px;
}
.gap-4 {
gap: 24px;
}
.text-white {
color: rgb(255, 255, 255) !important;
}
// border
.border-bottom {
border-bottom: 1px solid rgba(0, 0, 0, .05);
}
.opacity-1 {
opacity: 1 !important;
}
.opacity-50 {
opacity: 0.5;
}
.z-auto.v-card {
z-index: auto;
}
.obj-cover {
object-fit: cover;
}
.cursor-move {
cursor: move;
}
body {
cursor: default;
}
input:not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="file"]):not([type="range"]):not([type="color"]),
textarea,
[contenteditable="true"] {
cursor: text;
}
//Date time picker
input[type="date"],
input[type="time"] {
display: block !important;
}
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator {
display: block !important;
}
.ProseMirror {
min-height: 150px;
}
.upload-btn-wrapper {
width: 150px;
height: 140px;
margin: 0 auto;
box-shadow: 0 0.5rem 1.5rem 0.5rem rgba(0, 0, 0, 0.075);
input[type=file] {
position: absolute;
left: 0;
top: 0;
opacity: 0;
height: 100%;
width: 100%;
}
}
.bg-transparent {
background-color: transparent !important;
}
.bg-dark{
background-color: rgba(0, 0, 0, .08);
}
.bg-white-opacity{
background-color: rgba(255, 255, 255, 0.2);
}
@media screen and (max-width:1368px) and (min-width:1200px) {
.space-20 {
padding: 30px 20px !important;
}
}
+341
View File
@@ -0,0 +1,341 @@
.v-locale--is-rtl {
.customizer-btn {
left: 30px;
right: unset;
}
.horizontal-navbar .icon-box {
margin-left: 12px;
}
.bg-img-1 {
position: absolute;
bottom: 0;
left: 0;
right: unset !important;
transform: scaleX(-1);
}
.ml-1 {
margin-left: unset !important;
margin-right: 4px;
}
.ml-2 {
margin-left: unset !important;
margin-right: 8px;
}
.mr-1 {
margin-right: unset !important;
margin-left: 4px;
}
.mr-2 {
margin-right: unset !important;
margin-left: 8px;
}
.mr-sm-2 {
margin-right: unset !important;
margin-left: 8px;
}
.mr-3 {
margin-right: unset !important;
margin-left: 12px !important;
}
.mr-4 {
margin-right: unset !important;
margin-left: 16px !important;
}
.ml-3 {
margin-left: unset !important;
margin-right: 12px !important;
}
.mr-auto {
margin-left: auto !important;
margin-right: unset !important;
}
.ml-4 {
margin-left: unset !important;
margin-right: 16px;
}
.ml-sm-4 {
margin-left: unset !important;
margin-right: 16px;
}
.ml-md-4 {
margin-left: unset !important;
margin-right: 16px;
}
.ml-sm-3 {
margin-left: unset !important;
margin-right: 12px;
}
.ml-5 {
margin-left: unset !important;
margin-right: 20px;
}
.ml-6 {
margin-left: unset !important;
margin-right: 24px;
}
.ml-10 {
margin-left: unset !important;
margin-right: 40px;
}
.pl-1 {
padding-left: unset !important;
padding-right: 4px !important;
}
.pl-2 {
padding-left: unset !important;
padding-right: 8px !important;
}
.pr-2 {
padding-left: 8px !important;
}
.pr-4 {
padding-left: 16px !important;
padding-right: unset !important;
}
.pl-4 {
padding-left: unset !important;
padding-right: 16px !important;
}
.right-pos-img {
right: unset;
left: 0;
transform: scaleX(-1);
top: 0;
}
.badg-dotDetail {
left: 0;
right: -8px;
}
.text-right {
text-align: left !important;
}
.text-sm-right,
.text-md-right {
text-align: left !important;
}
.text-sm-left {
text-align: right !important;
}
.text-left {
text-align: right !important;
}
.ml-auto,
.ml-sm-auto {
margin-left: unset !important;
margin-right: auto !important;
}
.justify-start {
justify-content: flex-end !important;
}
.vertical-table .v-table>.v-table__wrapper>table>tbody>tr>th {
border-left: thin solid rgba(var(--v-border-color), 1) !important;
}
.authentication .auth-header {
left: unset;
right: 0;
}
.horizontal-navbar li a {
padding: 10px 13px;
}
.horizontal-navbar {
li {
margin-right: 0;
margin-left: 15px;
}
}
// &.v-menu .v-overlay__content,
// &.search_popup .v-overlay__content,
// &.language_dropdown .v-overlay__content,
// &.notification_popup .v-overlay__content,
// &.profile_popup .v-overlay__content {
// left: inherit;
// }
.related-Product {
.carousel__prev.navarrow {
top: 0;
right: unset !important;
left: 0;
}
.carousel__next.navarrow {
top: 0;
right: unset !important;
left: 45px;
}
}
//RTL mode minisidebar hover to active scrollbar
.ps--active-y>.ps__rail-y {
right: unset !important;
left: 0;
}
//RTL mode sidebar scrollbar on right side
.left-customizer {
.ps__rail-y {
right: 0 !important;
}
}
.horizontal-navbar .ddMenu {
padding: 10px 15px 10px 0px;
}
.v-list-group__items {
.iconClass {
position: relative;
left: unset;
right: -2px;
}
}
@media (min-width: 960px) {
.horizontal-navbar .ddLevel-2,
.horizontal-navbar .ddLevel-3 {
top: -5px;
right: 212px;
}
.horizontal-navbar li a .navIcon {
margin-right: 0;
margin-left: 10px;
}
}
.leftSidebar .profile-name h5 {
direction: ltr;
}
.horizontal-navbar {
.ddMenu {
.navItemLink {
padding-right: 15px;
}
}
}
@media screen and (max-width:1279px) {
.mini-sidebar {
.v-navigation-drawer.v-navigation-drawer--right {
width: 270px !important;
}
.v-navigation-drawer.v-navigation-drawer--left {
width: 320px !important;
}
}
}
.rtlImg {
transform: scaleX(-1);
}
.marquee1-group {
animation: marquee-rtl 45s linear infinite;
}
.marquee2-group {
animation: marquee2-rtl 45s linear infinite;
}
@keyframes marquee-rtl {
0% {
transform: translate3d(0, 0, 0);
}
100% {
transform: translate3d(2086px, 0, 0);
}
}
@keyframes marquee2-rtl {
0% {
transform: translate3d(2086px, 0, 0);
}
100% {
transform: translate3d(0, 0, 0);
}
}
.front-wraper {
.testimonials {
.slide-counter {
left: -50px;
}
.carousel__prev {
right: -74%;
left: unset;
.rtlnav {
transform: scaleX(-1);
}
}
.carousel__next {
right: -60%;
left: unset;
.rtlnav {
transform: scaleX(-1);
}
}
}
}
//For Rtl Chart
.rtl-me-n7 {
margin-inline-start: -28px !important;
margin-inline-end: unset !important;
}
.profile-img::before {
left: unset;
right: 14px;
}
}
+374
View File
@@ -0,0 +1,374 @@
@use "../variables" as *;
/*This is for the logo*/
.leftSidebar {
box-shadow: 0 3px 4px 0 rgba(0, 0, 0, .03), 0 0 1px 0 rgba(0, 0, 0, .1);
.logo {
padding-left: 7px;
}
.mini-icon {
display: none;
}
.mini-text {
display: block;
}
.profile {
background: url("@/assets/images/backgrounds/user-info.jpg") no-repeat;
}
.profile-name {
background: rgba(0, 0, 0, 0.5);
margin-top: -6px;
height: 35px;
h5 {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
.v-list--density-default .v-list-subheader {
padding-inline-start: 0 !important;
}
}
.verticalLayout {
.logo {
width: 250px;
@media screen and (max-width:1024px) {
width: auto;
}
}
}
/*This is for the Vertical sidebar*/
.scrollnavbar {
height: 100%;
.userbottom {
position: fixed;
bottom: 0px;
width: 100%;
}
.smallCap {
padding: 3px 12px 12px 0px !important;
font-size: 0.875rem;
font-weight: 500;
margin-top: 24px;
color: rgb(var(--v-theme-textPrimary));
&:first-child {
margin-top: 0 !important;
}
}
/*General Menu css*/
.v-list-group__items .v-list-item,
.v-list-item {
border-radius: $border-radius-root;
padding-inline-start: calc(14px + var(--indent-padding) / 10) !important;
margin: 0 0 2px;
&:hover {
color: rgb(var(--v-theme-secondary));
}
.v-list-item__prepend {
margin-inline-end: 13px;
}
.v-list-item__append {
font-size: 0.875rem;
.v-icon {
margin-inline-start: 13px;
}
}
.v-list-item-title {
font-size: 0.875rem;
}
}
.v-list-group__items {
.v-list-item {
min-height: 35px !important;
padding-inline-start: calc(12px + var(--indent-padding) / 10) !important;
.v-list-item__prepend .dot {
height: 6px;
width: 6px;
background-color: rgb(var(--v-theme-textSecondary));
border-radius: 50%;
margin-inline-end: 8px !important;
opacity: 0;
}
.v-list-item-title {
font-size: 14px !important;
}
&:hover {
color: rgb(var(--v-theme-secondary));
.v-list-item__prepend .dot {
background-color: rgb(var(--v-theme-secondary));
}
}
&.v-list-item--active {
.v-list-item__prepend .dot {
background-color: rgb(var(--v-theme-secondary));
}
}
}
}
/*This is for the dropdown*/
.v-list {
color: rgb(var(--v-theme-textPrimary));
>.v-list-item.v-list-item--active,
.v-list-item--active>.v-list-item__overlay {
background: rgb(var(--v-theme-secondary));
color: white;
}
>.v-list-group {
position: relative;
>.v-list-item--active,
>.v-list-item--active:hover {
background: rgb(var(--v-theme-secondary));
color: white;
}
.v-list-group__items .v-list-item.v-list-item--active,
.v-list-group__items .v-list-item.v-list-item--active>.v-list-item__overlay {
background: transparent;
color: rgb(var(--v-theme-secondary));
}
}
}
}
.v-navigation-drawer--rail {
.scrollnavbar .v-list .v-list-group__items,
.hide-menu {
opacity: 1;
}
.leftPadding {
margin-left: 0px;
}
}
@media only screen and (min-width: 1170px) {
.mini-sidebar {
.logo {
width: 40px;
overflow: hidden;
padding-left: 0;
}
.profile-logout {
opacity: 0;
width: 0;
}
.scrollnavbar {
.smallCap {
padding: 3px 12px 12px 12px !important;
}
}
.leftSidebar .v-list--density-default .v-list-subheader {
padding-inline-start: 15px !important;
}
.mini-icon {
display: block;
}
.sidebarchip.hide-menu {
opacity: 0;
}
.mini-text {
display: none;
}
.v-list {
padding: 14px !important;
}
.v-list-group__items {
.iconClass {
position: relative;
left: -2px;
}
}
.leftSidebar:hover {
box-shadow: $box-shadow !important;
.mini-icon {
display: none;
}
.sidebarchip.hide-menu {
opacity: 1;
}
.mini-text {
display: block;
}
.profile-logout {
opacity: 1;
width: auto;
}
.scrollnavbar {
.smallCap {
padding: 3px 12px 12px 0px !important;
}
}
.v-list-group__items {
.iconClass {
position: relative;
left: 0px;
}
}
.v-list--density-default .v-list-subheader {
padding-inline-start: 0px !important;
}
.v-list-group__items {
.v-list-item {
.v-list-item__prepend .dot {
opacity:0;
}
}
}
}
.v-navigation-drawer--expand-on-hover:hover {
.logo {
width: 100%;
}
.v-list .v-list-group__items,
.hide-menu {
opacity: 1;
}
}
.profile-img {
margin-left: 0;
&::before {
left: 12px;
}
}
.menu-toggle {
margin-left: 24px;
}
.v-list-group__items {
.v-list-item {
.v-list-item__prepend .dot {
opacity: 1;
}
}
}
}
}
// scrollbar
.ps__rail-y {
z-index: 9;
}
.profile-img {
margin-left: 14px;
&::before {
-webkit-animation: 2.5s blow 0s linear infinite;
animation: 2.5s blow 0s linear infinite;
position: absolute;
content: "";
width: 50px;
height: 50px;
top: 40px;
border-radius: 50%;
z-index: 0;
left: 26px;
}
@-webkit-keyframes blow {
0% {
box-shadow: 0 0 0 0px rgba(0, 0, 0, 0.1);
opacity: 1;
-webkit-transform: scale3d(1, 1, 0.5);
transform: scale3d(1, 1, 0.5);
}
50% {
box-shadow: 0 0 0 10px rgba(0, 0, 0, 0.1);
opacity: 1;
-webkit-transform: scale3d(1, 1, 0.5);
transform: scale3d(1, 1, 0.5);
}
100% {
box-shadow: 0 0 0 20px rgba(0, 0, 0, 0.1);
opacity: 0;
-webkit-transform: scale3d(1, 1, 0.5);
transform: scale3d(1, 1, 0.5);
}
}
@keyframes blow {
0% {
box-shadow: 0 0 0 0px rgba(0, 0, 0, 0.1);
opacity: 1;
-webkit-transform: scale3d(1, 1, 0.5);
transform: scale3d(1, 1, 0.5);
}
50% {
box-shadow: 0 0 0 10px rgba(0, 0, 0, 0.1);
opacity: 1;
-webkit-transform: scale3d(1, 1, 0.5);
transform: scale3d(1, 1, 0.5);
}
100% {
box-shadow: 0 0 0 20px rgba(0, 0, 0, 0.1);
opacity: 0;
-webkit-transform: scale3d(1, 1, 0.5);
transform: scale3d(1, 1, 0.5);
}
}
}
+98
View File
@@ -0,0 +1,98 @@
$sizes: (
'display-1': 44px,
'display-2': 40px,
'display-3': 30px,
'h1': 36px,
'h2': 30px,
'h3': 21px,
'h4': 18px,
'h5': 16px,
'h6': 14px,
'text-10': 10px,
'text-12': 12px,
'text-13': 13px,
'text-14': 14px,
'text-15': 15px,
'text-16': 16px,
'text-17': 17px,
'text-18': 18px,
'text-20': 20px,
'text-22': 22px,
'text-24': 24px,
'text-28': 28px,
'text-34': 34px,
'text-40': 40px,
'text-44': 44px,
'text-48': 48px,
'text-50': 50px,
'text-52': 52px,
'text-56': 56px,
'text-64': 64px,
'body-text-1': 10px
);
@each $pixel, $size in $sizes {
.#{$pixel} {
font-size: $size;
line-height: $size + 10;
}
}
$height: (
'h-10': 10px,
'h-12': 12px,
'h-15': 15px,
);
@each $pixel, $size in $height {
.#{$pixel} {
height: $size;
width: $size ;
}
}
.textSecondary {
color: rgb(var(--v-theme-textSecondary)) !important;
}
.textPrimary {
color: rgb(var(--v-theme-textPrimary)) !important;
}
// line height
.lh-md {
line-height: 1.57;
}
.lh-normal{
line-height: normal;
}
.font-weight-semibold {
font-weight: 600;
}
// hover text
.text-hover-primary {
color: rgb(var(--v-theme-textPrimary));
&:hover {
color: rgb(var(--v-theme-primary));
}
}
.link {
color: rgb(var(--v-theme-textSecondary));
text-decoration: none;
&:hover {
color: rgb(var(--v-theme-primary));
}
}
.hover-primary {
&:hover {
color: rgb(var(--v-theme-primary)) !important;
opacity: 1;
}
}
+110
View File
@@ -0,0 +1,110 @@
.v-app-bar {
.v-toolbar__content {
padding: 0 15px;
> .v-btn:first-child {
margin-inline-start: 0;
}
.v-btn {
color: rgba(var(--v-theme-textsurface)) !important;
}
}
}
.custom-text-primary {
&.v-list-item:hover > .v-list-item__overlay {
display: none;
}
.custom-title {
color: rgb(var(--v-theme-textPrimary)) !important;
}
&:hover {
.custom-title {
color: rgb(var(--v-theme-primary)) !important;
}
}
}
@media screen and (max-width:1279px) {
.mini-sidebar {
.v-navigation-drawer.v-navigation-drawer--left {
width: 270px !important;
}
}
}
.notify {
position: relative;
top: -20px;
right: -8px;
.heartbit {
position: absolute;
top: -5px;
right: -2px;
height: 18px;
width: 18px;
z-index: 10;
border: 2px solid rgb(var(--v-theme-error));
border-radius: 70px;
animation: heartbit 1s ease-out;
-moz-animation: heartbit 1s ease-out;
-moz-animation-iteration-count: infinite;
-o-animation: heartbit 1s ease-out;
-o-animation-iteration-count: infinite;
-webkit-animation: heartbit 1s ease-out;
-webkit-animation-iteration-count: infinite;
animation-iteration-count: infinite;
}
.point {
width: 4px;
height: 4px;
border-radius: 30px;
position: absolute;
right: 5px;
top: 2px;
background-color: rgb(var(--v-theme-error));
position: absolute;
}
}
@keyframes heartbit {
0% {
transform: scale(0);
opacity: 0;
}
25% {
transform: scale(0.1);
opacity: 0.1;
}
50% {
transform: scale(0.5);
opacity: 0.3;
}
75% {
transform: scale(0.8);
opacity: 0.5;
}
100% {
transform: scale(1);
opacity: 0;
}
}
.v-menu.mobile_popup .v-overlay__content {
width: 100%;
}
@media (max-width: 1199px) {
.main-head{
&.v-app-bar .v-toolbar__content{
width: 100%;
justify-content: space-between;
padding: 0 10px;
}
}
}
+240
View File
@@ -0,0 +1,240 @@
//
// common
//
.inside-left-sidebar {
.left-part {
width: 240px;
}
}
//
//Full Calendar
.fc {
.fc-button-group {
>.fc-button {
display: flex;
align-items: center;
padding: 7px 10px;
border: 0;
font-size: 14px;
background-color: rgba(var(--v-theme-grey200));
color: rgba(var(--v-theme-textPrimary));
.fc-icon{
color: rgba(var(--v-theme-textPrimary));
font-size: 20px;
}
&:hover,&:focus{
background-color: rgba(var(--v-theme-textPrimary));
color: #fff;
.fc-icon{
color: #fff;
}
}
}
.fc-button-primary:not(:disabled):active{
background-color: rgba(var(--v-theme-textPrimary));
}
.fc-button-primary:not(:disabled).fc-button-active {
background-color: rgb(var(--v-theme-textPrimary));
}
}
.fc-today-button{
padding: 7px 20px;
border: 0;
background-color: rgba(var(--v-theme-primary));
}
.fc-prev-button{
border-radius: 30px 0 0 30px;
}
.fc-toolbar-title{
font-weight: 600;
}
.fc-event-title{
font-size: 14px;
padding: 3px 6px;
}
.fc-button {
font-size: 14px;
font-weight: 500;
text-transform: capitalize;
.fc-icon {
font-size: 1.5em;
vertical-align: unset;
}
}
.fc-daygrid-day-number{
color: rgba(var(--v-theme-textSecondary));
font-size: 15px;
}
.fc-button-primary {
background: rgb(var(--v-theme-primary));
border-color: rgb(var(--v-theme-primary));
color: #fff;
&:hover {
background-color: rgb(var(--v-theme-primary));
border-color: rgb(var(--v-theme-primary));
}
&:not(:disabled).fc-button-active {
background-color: rgb(var(--v-theme-primary));
border-color: rgb(var(--v-theme-primary));
&:focus {
box-shadow: none;
}
}
&:not(:disabled) {
&:active {
background-color: rgb(var(--v-theme-primary));
border-color: rgb(var(--v-theme-primary));
&:focus {
box-shadow: none;
}
}
}
&:disabled {
background-color: rgb(var(--v-theme-primary));
border-color: rgb(var(--v-theme-primary));
opacity: 1;
}
}
.fc-col-header-cell-cushion {
display: inline-block;
padding: 10px 5px;
font-size: 14px;
font-weight: 600;
}
.fc-button-primary:not(:disabled).fc-button-active{
background-color: rgb(var(--v-theme-textPrimary));
}
}
.fc-theme-standard {
td {
border: 1px solid rgba(var(--v-border-color), 1) !important;
}
th {
border: 1px solid rgba(var(--v-border-color), 1) !important;
border-bottom: 0 !important;
background-color: rgba(var(--v-theme-grey200));
height: 56px;
vertical-align: middle;
color: rgba(var(--v-theme-textSecondary));
}
.fc-scrollgrid {
border: 0 !important;
}
}
.fc-h-event {
background-color: rgb(var(--v-theme-primary));
border: 0;
display: block;
}
.fc-direction-ltr {
.fc-button-group {
>.fc-button {
&:not(:last-child) {
border-bottom-left-radius: 30px;
border-top-left-radius: 30px;
}
&:not(:first-child) {
border-bottom-right-radius: 30px;
border-top-right-radius: 30px;
margin-left: -1px;
}
}
}
}
.fc-button-group {
.fc-dayGridMonth-button {
border-bottom-right-radius: 0px !important;
border-top-right-radius: 0px !important;
padding: 7px 20px !important;
}
.fc-timeGridDay-button {
border-bottom-left-radius: 0px !important;
border-top-left-radius: 0px !important;
padding: 7px 20px !important;
}
.fc-timeGridWeek-button {
border-radius: 0 !important;
padding: 7px 20px !important;
}
}
.fc-today-button {
border-radius: 30px !important;
font-size: 14px;
}
@media screen and (max-width:600px) {
.fc {
.fc-toolbar {
display: block;
text-align: center;
}
}
.fc-toolbar-chunk {
.fc-toolbar-title {
margin: 15px 0;
}
}
}
.customTab {
.v-btn {
&.v-tab-item--selected {
background-color: rgb(var(--v-theme-lightprimary)) !important;
.icon {
background-color: rgb(var(--v-theme-primary)) !important;
color: #fff !important;
}
}
}
}
.email-items {
padding: 0px 24px 12px;
&.selected-email {
.email-title {
color: rgb(var(--v-theme-primary)) !important;
}
}
&:hover {
background-color: rgb(var(--v-theme-hoverColor));
.email-title {
color: rgb(var(--v-theme-primary)) !important;
}
}
}
.email-content {
p {
margin: 10px 0;
}
}
+90
View File
@@ -0,0 +1,90 @@
.authentication{
&::before{
content: "";
position: absolute;
height: 100%;
width: 100%;
opacity: 0.3;
left: 0;
top: 0;
bottom: 0;
background: radial-gradient(rgb(210, 241, 223), rgb(211, 215, 250), rgb(186, 216, 244)) 0% 0% / 400% 400%;
}
@media screen and (max-width:1280px){
.auth-header{
position: unset;
}
}
}
.mw-450{
max-width: 450px;
width: 100%;;
}
.auth-header{
position: absolute;
top: 0;
left: 0;
}
.verification{
.v-field__input{
text-align: center;
}
}
.auth-divider{
span{
z-index: 1;
}
&::before{
position: absolute;
width: 100%;
border-top: thin solid rgb(var(--v-theme-borderColor));
top: 50%;
content: "";
transform: translateY(50%);
left: 0;
}
&::after
{
position: absolute;
width: 100%;
border-top: thin solid rgb(var(--v-theme-borderColor));
top: 50%;
content: "";
transform: translateY(50%);
right: 0;
}
}
@media (min-width: 1536px){
.auth{
.v-col-lg-7{
flex: 0 0 66.66%;
max-width: 66.66%;
}
.v-col-lg-5{
flex: 0 0 33.33%;
max-width: 33.33%;
}
}
}
@media screen and (max-width:1280px){
.mh-100{
height: 100% !important;
}
}
@media screen and (max-width:600px){
.mw-100{
width: 100%;
padding: 0 15px;
}
}
.auth{
.v-switch .v-label, .v-checkbox .v-label {
opacity: 0.7;
font-weight: 400 !important;
}
}
+227
View File
@@ -0,0 +1,227 @@
.month-table {
&.custom-px-0 {
thead {
tr {
th:first-child {
padding-left: 0 !important;
}
th:last-child {
padding-right: 0 !important;
}
}
}
tr.month-item {
td:first-child {
padding-left: 0 !important;
}
td:last-child {
padding-right: 0 !important;
}
}
}
tr.month-item {
td {
padding-top: 16px !important;
padding-bottom: 16px !important;
}
&:hover {
background: transparent !important;
}
}
tr.month-item-0 {
td {
padding-top: 12px !important;
padding-bottom: 12px !important;
}
&:hover {
background: transparent !important;
}
}
tr.month-item-hover {
td {
padding-top: 12px !important;
padding-bottom: 12px !important;
}
border-left: 4px solid transparent;
&:hover {
border-left: 4px solid rgba(var(--v-theme-primary)) !important;
}
}
}
.no-line {
.v-table .v-table__wrapper>table>tbody>tr:not(:last-child)>td {
border-bottom: 0 !important;
}
.v-table .v-table__wrapper>table>tbody>tr>td {
padding: 12px;
}
}
.recent-transaction {
.line {
width: 2px;
height: 35px;
}
}
.chip-label {
width: 80px;
justify-content: center;
}
//
// Apex Chart
//
body {
.apexcharts-tooltip {
border-radius: 16px;
}
.apexcharts-tooltip-marker {
border-radius: 4px;
width: 12px;
height: 4px;
}
.apexcharts-tooltip.apexcharts-theme-dark {
background: rgba(17, 28, 45, 0.8);
.apexcharts-tooltip-title {
border-bottom: 0;
background: rgba(17, 28, 45, 0.7);
}
}
.apexcharts-tooltip-series-group {
padding: 0 14px;
}
.apexcharts-tooltip-title {
padding: 10px 14px;
}
}
.profile-activity {
.v-tab {
&.v-btn {
border: 1px dashed rgba(var(--v-theme-borderColor));
border-radius: 8px;
min-width: 90px;
overflow: hidden;
padding: 15px 20px;
overflow: hidden;
.v-btn__content {
display: block;
}
&.v-tab--selected{
border: 1px solid rgba(var(--v-theme-borderColor));
border-bottom: 2px solid;
}
.v-tab__slider{
opacity: 0;
}
}
}
.v-slide-group__content {
gap: 16px;
}
}
.comment-box {
border-bottom: 1px solid rgba(var(--v-theme-borderColor));
padding-bottom: 20px;
margin-bottom: 20px;
cursor: pointer;
&:last-child {
border-bottom: 0;
padding-bottom: 0px;
margin-bottom: 0px;
}
.comment-action {
opacity: 0;
transition: 0.5s;
}
&:hover {
.comment-action {
opacity: 1;
}
}
}
.todo-list {
border-bottom: 1px solid rgba(var(--v-theme-borderColor));
padding-bottom: 25px;
margin-bottom: 25px;
cursor: pointer;
&:last-child {
border-bottom: 0;
padding-bottom: 20px;
margin-bottom: 20px;
}
}
.progress-cards {
border-inline-end: 1px solid rgba(var(--v-theme-borderColor));
&:last-child {
border: 0;
}
@media screen and (max-width:991px) {
border-inline-end: 0 !important
}
}
.notification {
border-bottom: 1px solid rgba(var(--v-theme-borderColor));
&:last-child {
border: 0;
}
}
.earning-cards {
@media screen and (max-width:991px){
.w-25{
width: 100% !important;
&.border-e{
border: 0 !important;
}
.mobile-border{
border-bottom:1px solid rgba(var(--v-theme-borderColor));
padding-bottom: 8px !important;
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More