first commit
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<RouterLink v-if="to" :to="to" :class="rootClass">
|
||||
<slot name="prependIcon" />
|
||||
<span :class="['base-button__text', textClass]">{{ text }}</span>
|
||||
<slot name="appendIcon" />
|
||||
</RouterLink>
|
||||
<button
|
||||
v-else
|
||||
:class="rootClass"
|
||||
:type="type"
|
||||
:disabled="disabled || loading"
|
||||
@click="handleClick"
|
||||
>
|
||||
<template v-if="loading">
|
||||
<SvgIcon
|
||||
name="spinner"
|
||||
:size="24"
|
||||
:color="spinnerFill"
|
||||
class="base-button__spinner animate-spin"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot name="prependIcon" />
|
||||
<span :class="['base-button__text', textClass]">{{ text }}</span>
|
||||
<slot name="appendIcon" />
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
to: { type: [String, Object], default: null },
|
||||
type: { type: String, default: 'button' },
|
||||
variant: { type: String, default: 'primary' },
|
||||
text: { type: String, default: 'تایید' },
|
||||
textClass: { type: String, default: '' },
|
||||
customClass: { type: String, default: 'base-button--full' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const rootClass = computed(() => [
|
||||
'base-button',
|
||||
`base-button--${props.variant}`,
|
||||
props.customClass,
|
||||
{ 'base-button--disabled': props.disabled, 'base-button--loading': props.loading },
|
||||
])
|
||||
|
||||
const spinnerFill = computed(() => (props.variant === 'primary' ? '#fff' : '#2B2B2B'))
|
||||
|
||||
const handleClick = (event) => {
|
||||
if (props.type !== 'submit' && (props.disabled || props.loading)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
if (props.type === 'submit') return
|
||||
emit('click', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.base-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 2.35rem;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease, background-color 0.2s ease;
|
||||
font-family: var(--font-family-fa);
|
||||
text-decoration: none;
|
||||
padding: 0 1rem;
|
||||
|
||||
&__text {
|
||||
line-height: 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
&--full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background-color: var(--color-primary);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(.base-button--disabled, .base-button--loading) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background-color: var(--color-thd-gray);
|
||||
color: var(--color-sec-gray);
|
||||
|
||||
&:hover:not(.base-button--disabled, .base-button--loading) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
&--transparent {
|
||||
background-color: transparent;
|
||||
color: var(--color-sec-gray);
|
||||
}
|
||||
|
||||
&--disabled,
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade-scale" @after-enter="onOpened" @after-leave="onClosed">
|
||||
<div v-if="isOpen" class="basic-modal" @click="onBackdropClick">
|
||||
<div class="basic-modal__container" :style="containerStyle" @click.stop>
|
||||
<div class="basic-modal__card scrollbar-thin">
|
||||
<header v-if="hasHeader" class="basic-modal__header">
|
||||
<slot name="header">
|
||||
<LineTitleBlock v-if="title || titleEn" :title="title" :title-en="titleEn" />
|
||||
<span v-else />
|
||||
</slot>
|
||||
<button
|
||||
v-if="showCloseButton"
|
||||
type="button"
|
||||
class="basic-modal__close"
|
||||
aria-label="بستن"
|
||||
@click="close"
|
||||
>
|
||||
<SvgIcon name="close" :size="36" color="#B1B1B1" />
|
||||
</button>
|
||||
</header>
|
||||
<slot :data="modalData" :close="close" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, onBeforeUnmount, useSlots, watch } from 'vue'
|
||||
|
||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import { useModalStore } from '@/store/modal'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, default: '' },
|
||||
title: { type: String, default: '' },
|
||||
titleEn: { type: String, default: '' },
|
||||
closable: { type: Boolean, default: true },
|
||||
closeOnEsc: { type: Boolean, default: true },
|
||||
showCloseButton: { type: Boolean, default: true },
|
||||
width: { type: String, default: '' },
|
||||
maxWidth: { type: String, default: '98%' },
|
||||
minWidth: { type: String, default: '85%' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['open', 'close'])
|
||||
|
||||
const instance = getCurrentInstance()
|
||||
const slots = useSlots()
|
||||
const { isModal, getModal, closeModal } = useModalStore()
|
||||
|
||||
const modalName = computed(() => props.name || instance?.parent?.type?.name || '')
|
||||
|
||||
const isOpen = computed(() => !!modalName.value && isModal(modalName.value))
|
||||
const modalData = computed(() => getModal(modalName.value)?.data)
|
||||
|
||||
const hasHeader = computed(
|
||||
() => props.showCloseButton || !!slots.header || !!props.title || !!props.titleEn
|
||||
)
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
const style = { maxWidth: props.maxWidth, minWidth: props.minWidth }
|
||||
if (props.width) style.width = props.width
|
||||
return style
|
||||
})
|
||||
|
||||
const close = () => closeModal(modalName.value)
|
||||
|
||||
const onBackdropClick = () => {
|
||||
if (props.closable) close()
|
||||
}
|
||||
|
||||
const onOpened = () => emit('open', modalData.value)
|
||||
const onClosed = () => emit('close')
|
||||
|
||||
const onKeydown = (e) => {
|
||||
if (e.key === 'Escape' && props.closeOnEsc && props.closable && isOpen.value) {
|
||||
e.preventDefault()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
isOpen,
|
||||
(open) => {
|
||||
if (open) {
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
} else {
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.basic-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 5%);
|
||||
backdrop-filter: blur(12px);
|
||||
|
||||
&__container {
|
||||
background: transparent;
|
||||
border-radius: 1.5rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
max-height: calc(100dvh - 10%);
|
||||
}
|
||||
|
||||
&__card {
|
||||
width: 100%;
|
||||
padding: 1rem 1.25rem 1.5rem;
|
||||
background: #fff;
|
||||
border-radius: 1.5rem;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&__close {
|
||||
height: 2.75rem;
|
||||
width: 2.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fade-scale-enter-active,
|
||||
.fade-scale-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.fade-scale-enter-from,
|
||||
.fade-scale-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div
|
||||
class="circle-button-wrap"
|
||||
@mouseenter="onShow"
|
||||
@mouseleave="onHide"
|
||||
@focusin="onShow"
|
||||
@focusout="onHide"
|
||||
>
|
||||
<button
|
||||
ref="triggerRef"
|
||||
:type="type"
|
||||
class="circle-button"
|
||||
:style="buttonStyle"
|
||||
:disabled="disabled || loading"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot name="icon" />
|
||||
</button>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="tooltip-fade">
|
||||
<div
|
||||
v-if="showTooltip && tooltip"
|
||||
ref="tooltipRef"
|
||||
class="circle-button__tooltip"
|
||||
:class="`circle-button__tooltip--${actualPlacement}`"
|
||||
:style="tooltipStyle"
|
||||
role="tooltip"
|
||||
>
|
||||
{{ tooltip }}
|
||||
<span
|
||||
class="circle-button__tooltip-arrow"
|
||||
:style="arrowStyle"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { arrow, autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
|
||||
|
||||
const props = defineProps({
|
||||
type: { type: String, default: 'button' },
|
||||
tooltip: { type: String, default: '' },
|
||||
tooltipPosition: {
|
||||
type: String,
|
||||
default: 'top',
|
||||
validator: (v) => ['top', 'bottom'].includes(v),
|
||||
},
|
||||
bgColor: { type: String, default: '#fff' },
|
||||
size: { type: String, default: '2.75rem' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const showTooltip = ref(false)
|
||||
const triggerRef = ref(null)
|
||||
const tooltipRef = ref(null)
|
||||
const arrowEl = ref(null)
|
||||
const tooltipStyle = ref({ position: 'fixed', top: '0', left: '0' })
|
||||
const arrowStyle = ref({})
|
||||
const actualPlacement = ref(props.tooltipPosition)
|
||||
|
||||
const buttonStyle = computed(() => ({
|
||||
width: props.size,
|
||||
height: props.size,
|
||||
backgroundColor: props.bgColor,
|
||||
}))
|
||||
|
||||
let cleanupAutoUpdate = null
|
||||
|
||||
const updatePosition = async () => {
|
||||
if (!triggerRef.value || !tooltipRef.value) return
|
||||
arrowEl.value = tooltipRef.value.querySelector('.circle-button__tooltip-arrow')
|
||||
const middleware = [offset(6), flip(), shift({ padding: 8 })]
|
||||
if (arrowEl.value) middleware.push(arrow({ element: arrowEl.value }))
|
||||
|
||||
const result = await computePosition(triggerRef.value, tooltipRef.value, {
|
||||
placement: props.tooltipPosition,
|
||||
strategy: 'fixed',
|
||||
middleware,
|
||||
})
|
||||
|
||||
tooltipStyle.value = {
|
||||
position: 'fixed',
|
||||
top: `${result.y}px`,
|
||||
left: `${result.x}px`,
|
||||
}
|
||||
actualPlacement.value = result.placement
|
||||
|
||||
const arrowData = result.middlewareData.arrow
|
||||
if (arrowEl.value && arrowData) {
|
||||
const side = result.placement.split('-')[0]
|
||||
const oppositeSide = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }[side]
|
||||
arrowStyle.value = {
|
||||
left: arrowData.x != null ? `${arrowData.x}px` : '',
|
||||
top: arrowData.y != null ? `${arrowData.y}px` : '',
|
||||
[oppositeSide]: '-0.25rem',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startAutoUpdate = () => {
|
||||
if (!triggerRef.value || !tooltipRef.value) return
|
||||
cleanupAutoUpdate?.()
|
||||
cleanupAutoUpdate = autoUpdate(triggerRef.value, tooltipRef.value, updatePosition)
|
||||
}
|
||||
|
||||
const stopAutoUpdate = () => {
|
||||
cleanupAutoUpdate?.()
|
||||
cleanupAutoUpdate = null
|
||||
}
|
||||
|
||||
watch(showTooltip, async (open) => {
|
||||
if (open) {
|
||||
await nextTick()
|
||||
startAutoUpdate()
|
||||
} else {
|
||||
stopAutoUpdate()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(stopAutoUpdate)
|
||||
|
||||
const onShow = () => {
|
||||
if (!props.tooltip) return
|
||||
showTooltip.value = true
|
||||
}
|
||||
const onHide = () => {
|
||||
showTooltip.value = false
|
||||
}
|
||||
|
||||
const handleClick = (event) => {
|
||||
if (props.disabled || props.loading) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
emit('click', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.circle-button-wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.circle-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
z-index: 20;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.circle-button__tooltip {
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background-color: #000;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 5%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.circle-button__tooltip-arrow {
|
||||
position: absolute;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
background-color: #000;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.tooltip-fade-enter-active,
|
||||
.tooltip-fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.tooltip-fade-enter-from,
|
||||
.tooltip-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<BasicModal min-width="0" max-width="640px" width="95%" :show-close-button="false">
|
||||
<template #default="{ data }">
|
||||
<div class="confirm-modal">
|
||||
<h3 class="confirm-modal__title">{{ data?.title || 'تأیید حذف' }}</h3>
|
||||
|
||||
<div class="confirm-modal__message">
|
||||
<span v-if="data?.message" v-html="data.message" />
|
||||
<span v-else>این عمل غیرقابل بازگشت است. آیا از حذف اطمینان دارید؟</span>
|
||||
</div>
|
||||
|
||||
<div class="confirm-modal__actions">
|
||||
<BaseButton
|
||||
variant="transparent"
|
||||
text="منصرف شدم"
|
||||
text-class=""
|
||||
custom-class="confirm-modal__btn confirm-modal__btn--cancel"
|
||||
@click="cancel(data)"
|
||||
/>
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
text="تایید"
|
||||
:loading="!!data?.confirmLoading"
|
||||
custom-class="confirm-modal__btn confirm-modal__btn--confirm"
|
||||
@click="confirm(data)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BasicModal from '@/components/BasicModal.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import { useModalStore } from '@/store/modal'
|
||||
|
||||
defineOptions({ name: 'ConfirmModal' })
|
||||
|
||||
const { closeModal } = useModalStore()
|
||||
|
||||
const confirm = (data) => {
|
||||
data?.onConfirm?.()
|
||||
if (!data?.keepOpenOnConfirm) closeModal('ConfirmModal')
|
||||
}
|
||||
|
||||
const cancel = (data) => {
|
||||
data?.onCancel?.()
|
||||
closeModal('ConfirmModal')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.confirm-modal {
|
||||
padding: 1.5rem;
|
||||
|
||||
&__title {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
color: #1f2937;
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&__message {
|
||||
margin-bottom: 1.5rem;
|
||||
font-family: var(--font-family-fa);
|
||||
text-align: center;
|
||||
|
||||
:deep(p) {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
min-width: 100px;
|
||||
padding: 0.5rem 1.5rem;
|
||||
|
||||
&--confirm {
|
||||
padding-inline: 1.5rem 2.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="copyright">
|
||||
<div class="copyright__text">
|
||||
<a href="https://mugweb.ir/" target="_blank" rel="noopener">
|
||||
<p class="copyright__en">All Rights Reserved. 2026</p>
|
||||
<p class="copyright__fa">تمام حقوق مادی و معنوی این وبسایت محفوظ هست.</p>
|
||||
</a>
|
||||
</div>
|
||||
<div class="copyright__social">
|
||||
<ul>
|
||||
<li v-for="item in social" :key="item.title">
|
||||
<a :href="item.link" target="_blank" rel="noopener">
|
||||
<SvgIcon :name="item.icon" :size="24" color="var(--color-thd-gray)" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
const social = [
|
||||
{
|
||||
title: 'اینستاگرام',
|
||||
link: 'https://www.instagram.com/mugweb.ir?igsh=MWprOHF6eWZub3k4bA==',
|
||||
icon: 'instagram',
|
||||
},
|
||||
{
|
||||
title: 'تلگرام',
|
||||
link: '#',
|
||||
icon: 'telegram',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.copyright {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&__text {
|
||||
padding: 0 0.25rem;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__en {
|
||||
font-family: var(--font-family-en);
|
||||
font-size: 0.65rem;
|
||||
color: #9ca3af;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__fa {
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&__social {
|
||||
border-right: 1px solid var(--color-thd-gray);
|
||||
padding: 0 0.5rem;
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline-flex;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="dropdown">
|
||||
<div v-if="modelValue" ref="floatingEl" class="dropdown-menu" :style="floatingStyle">
|
||||
<ul class="dropdown-menu__list">
|
||||
<li
|
||||
v-for="(item, i) in items"
|
||||
:key="item.key ?? item.label ?? i"
|
||||
class="dropdown-menu__item-wrap"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-menu__item"
|
||||
:class="{ 'dropdown-menu__item--danger': item.danger }"
|
||||
:disabled="item.disabled"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<SvgIcon v-if="item.icon" :name="item.icon" :size="16" class="dropdown-menu__icon" />
|
||||
<span class="dropdown-menu__label">{{ item.label }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset as offsetMiddleware,
|
||||
shift,
|
||||
} from '@floating-ui/dom'
|
||||
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
reference: { type: [Object, null], default: null },
|
||||
items: { type: Array, default: () => [] },
|
||||
placement: { type: String, default: 'bottom-end' },
|
||||
offset: { type: Number, default: 8 },
|
||||
minWidth: { type: String, default: '12rem' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'select'])
|
||||
|
||||
const floatingEl = ref(null)
|
||||
const floatingStyle = ref({ position: 'fixed', top: '0', left: '0', minWidth: props.minWidth })
|
||||
let cleanup = null
|
||||
|
||||
const close = () => emit('update:modelValue', false)
|
||||
|
||||
const mountFloating = async () => {
|
||||
await Promise.resolve()
|
||||
const referenceEl = props.reference
|
||||
const floating = floatingEl.value
|
||||
if (!referenceEl || !floating) return
|
||||
|
||||
cleanup = autoUpdate(referenceEl, floating, () => {
|
||||
computePosition(referenceEl, floating, {
|
||||
placement: props.placement,
|
||||
strategy: 'fixed',
|
||||
middleware: [offsetMiddleware(props.offset), flip(), shift({ padding: 8 })],
|
||||
}).then(({ x, y }) => {
|
||||
floatingStyle.value = {
|
||||
position: 'fixed',
|
||||
top: `${y}px`,
|
||||
left: `${x}px`,
|
||||
minWidth: props.minWidth,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
document.addEventListener('mousedown', handleOutsideClick)
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
}
|
||||
|
||||
const unmountFloating = () => {
|
||||
cleanup?.()
|
||||
cleanup = null
|
||||
document.removeEventListener('mousedown', handleOutsideClick)
|
||||
document.removeEventListener('keydown', handleEsc)
|
||||
}
|
||||
|
||||
const handleOutsideClick = (event) => {
|
||||
const referenceEl = props.reference
|
||||
const floating = floatingEl.value
|
||||
if (referenceEl?.contains(event.target)) return
|
||||
if (floating?.contains(event.target)) return
|
||||
close()
|
||||
}
|
||||
|
||||
const handleEsc = (event) => {
|
||||
if (event.key === 'Escape') close()
|
||||
}
|
||||
|
||||
const handleClick = (item) => {
|
||||
if (item.disabled) return
|
||||
emit('select', item)
|
||||
item.onClick?.(item)
|
||||
close()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
setTimeout(mountFloating, 0)
|
||||
} else {
|
||||
unmountFloating()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(unmountFloating)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dropdown-menu {
|
||||
z-index: 9999;
|
||||
background: #fff;
|
||||
border-radius: 0.875rem;
|
||||
box-shadow: 0 10px 25px -8px rgba(0, 0, 0, 12%), 0 4px 10px -4px rgba(0, 0, 0, 8%);
|
||||
padding: 0.25rem;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
&__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__item-wrap {
|
||||
& + & {
|
||||
border-top: 1px solid #ececec;
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 0.625rem;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-sec-gray, #363636);
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="header-block">
|
||||
<div class="header-block__row">
|
||||
<div class="header-block__menu">
|
||||
<button type="button" class="header-block__menu-btn" @click="ui.toggleSidebar">
|
||||
<SvgIcon name="menu" :size="24" color="#9a9a9a" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="header-block__logo">
|
||||
<img :src="logo" alt="logo" />
|
||||
</div>
|
||||
<div class="header-block__title">
|
||||
<slot>
|
||||
<div v-if="!hasSlotContent && pageTitle">{{ pageTitle }}</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="header-block__icons">
|
||||
<ul>
|
||||
<li v-for="item in iconList" :key="item.icon">
|
||||
<button type="button" class="header-block__icon-btn" @click="item.action">
|
||||
<SvgIcon :name="item.icon" :size="24" color="#9a9a9a" />
|
||||
<span v-if="item.count" class="header-block__icon-badge">{{ item.count }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-block__title-mobile">
|
||||
<slot>
|
||||
<div v-if="!hasSlotContent && pageTitle">{{ pageTitle }}</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import { gallery } from '@/utils/gallery'
|
||||
import { useUIStore } from '@/store/ui'
|
||||
|
||||
defineProps({
|
||||
pageTitle: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
const hasSlotContent = computed(() => !!slots.default)
|
||||
|
||||
const ui = useUIStore()
|
||||
|
||||
const logo = gallery.authLogo
|
||||
|
||||
const iconList = [
|
||||
{ icon: 'chat', action: () => {} },
|
||||
{ icon: 'bell', action: () => {}, count: 5 },
|
||||
{ icon: 'user', action: () => {} },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.header-block {
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
&__menu {
|
||||
flex-basis: 33.33%;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__menu-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 9999px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__logo {
|
||||
flex-basis: 33.33%;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
img {
|
||||
margin: 0 auto;
|
||||
height: 3.5rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: none;
|
||||
text-align: right;
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__title-mobile {
|
||||
margin-top: 2.5rem;
|
||||
flex: 1 1 auto;
|
||||
text-align: right;
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__icons {
|
||||
flex-basis: 33.33%;
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
justify-content: flex-end;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__icon-btn {
|
||||
position: relative;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 9999px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__icon-badge {
|
||||
position: absolute;
|
||||
top: -0.125rem;
|
||||
right: -0.125rem;
|
||||
padding-top: 0.125rem;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-family: var(--font-family-number-fa);
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="line-title">
|
||||
<span class="line-title__bar" />
|
||||
<div class="line-title__text">
|
||||
<p class="line-title__fa">{{ title }}</p>
|
||||
<p v-if="titleEn" class="line-title__en">{{ titleEn }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
titleEn: { type: String, default: '' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.line-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 0;
|
||||
|
||||
&__bar {
|
||||
background-color: var(--color-primary);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 5%);
|
||||
border-radius: 1rem;
|
||||
height: 1.9rem;
|
||||
width: 0.375rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__text {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&__fa {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__en {
|
||||
font-family: var(--font-family-en);
|
||||
font-weight: 300;
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="boxed-icon-title">
|
||||
<div class="boxed-icon-title__icon-wrap">
|
||||
<span class="boxed-icon-title__icon-glow" />
|
||||
<slot name="icon" />
|
||||
</div>
|
||||
<div class="boxed-icon-title__text">
|
||||
<p class="boxed-icon-title__title">{{ title }}</p>
|
||||
<p class="boxed-icon-title__desc">{{ desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, default: 'عنوان' },
|
||||
desc: { type: String, default: 'توضیحات' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.boxed-icon-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
&__icon-wrap {
|
||||
background: #fff;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 3rem;
|
||||
width: 3rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__icon-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
background: var(--color-primary);
|
||||
filter: blur(4px);
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
opacity: 0.15;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
text-align: right;
|
||||
font-family: var(--font-family-fa);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: #9ca3af;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="line-info">
|
||||
<span class="line-info__bar" />
|
||||
<p class="line-info__title">{{ title }}</p>
|
||||
<div v-if="numericDesc !== '' || desc" class="line-info__value">
|
||||
<span v-if="numericDesc !== ''" class="line-info__numeric">{{ numericDesc }}</span>
|
||||
<span v-if="desc" class="line-info__desc">{{ desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
desc: { type: String, default: '' },
|
||||
numericDesc: { type: [String, Number], default: '' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.line-info {
|
||||
position: relative;
|
||||
padding: 0.75rem;
|
||||
text-align: start;
|
||||
|
||||
&__bar {
|
||||
position: absolute;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-end: 0;
|
||||
transform: translateY(-50%);
|
||||
background: #efefef;
|
||||
border-radius: 1rem;
|
||||
height: 60%;
|
||||
min-height: 1.9rem;
|
||||
width: 0.25rem;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 400;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5rem;
|
||||
color: #b7b7b7;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__value {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
&__numeric {
|
||||
font-family: var(--font-family-en);
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-family: var(--font-family-fa);
|
||||
text-align: justify;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="no-items">
|
||||
<div class="no-items__inner">
|
||||
<SvgIcon name="warning" :size="120" color="#cbd5e1" />
|
||||
<p class="no-items__title">{{ title }}</p>
|
||||
<p class="no-items__desc">{{ desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
desc: { type: String, default: '' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.no-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 0.75rem;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 20rem;
|
||||
|
||||
&__inner {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 300;
|
||||
font-size: 1.25rem;
|
||||
color: #999;
|
||||
margin: 1rem 0 0.625rem;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
color: #3c3c3c;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div v-if="visiblePages.length > 1" class="pagination">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isFirstPage"
|
||||
class="pagination__nav"
|
||||
:class="{ 'pagination__nav--disabled': isFirstPage }"
|
||||
@click="changePage(currentPage - 1)"
|
||||
>
|
||||
<SvgIcon name="caret-left" :size="12" />
|
||||
قبلی
|
||||
</button>
|
||||
<ul class="pagination__list">
|
||||
<li v-for="page in visiblePages" :key="page">
|
||||
<button
|
||||
type="button"
|
||||
class="pagination__page"
|
||||
:class="{ 'pagination__page--active': page === currentPage }"
|
||||
@click="changePage(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isLastPage"
|
||||
class="pagination__nav"
|
||||
:class="{ 'pagination__nav--disabled': isLastPage }"
|
||||
@click="changePage(currentPage + 1)"
|
||||
>
|
||||
بعدی
|
||||
<SvgIcon name="caret-right" :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
pagination: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:page', 'change'])
|
||||
|
||||
const currentPage = computed(() => props.pagination.page || 1)
|
||||
const totalPages = computed(() => props.pagination.lastPage || props.pagination.last_page || 1)
|
||||
|
||||
const isFirstPage = computed(() => currentPage.value === 1)
|
||||
const isLastPage = computed(() => currentPage.value === totalPages.value)
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const total = totalPages.value
|
||||
const current = currentPage.value
|
||||
const range = 2
|
||||
let start = Math.max(1, current - range)
|
||||
let end = Math.min(total, current + range)
|
||||
if (start > 1) start = start === 2 ? 1 : start
|
||||
if (end < total) end = end === total - 1 ? total : end
|
||||
const pages = []
|
||||
for (let i = start; i <= end; i += 1) pages.push(i)
|
||||
return pages
|
||||
})
|
||||
|
||||
const changePage = (page) => {
|
||||
if (page < 1 || page > totalPages.value) return
|
||||
emit('update:page', page)
|
||||
emit('change', page)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
direction: ltr;
|
||||
|
||||
&__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: none;
|
||||
background: #e9e9e9;
|
||||
border-radius: 0.375rem;
|
||||
font-family: var(--font-family-fa);
|
||||
cursor: pointer;
|
||||
|
||||
&--disabled {
|
||||
background: transparent;
|
||||
color: #d1d5db;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
&__page {
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
line-height: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0.375rem;
|
||||
font-family: var(--font-family-number-fa);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
|
||||
&--active {
|
||||
background: #e9e9e9;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="simple-title">
|
||||
<slot name="header-icon" />
|
||||
<p class="simple-title__text">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.simple-title {
|
||||
background: rgba(255, 255, 255, 80%);
|
||||
box-shadow: 0 10px 15px -3px rgba(241, 241, 241, 100%);
|
||||
padding: 0.875rem;
|
||||
margin-bottom: 0.625rem;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
|
||||
&__text {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="skeleton-loader" :class="customClass">
|
||||
<div v-for="n in rows" :key="n" class="skeleton-loader__row">
|
||||
<div
|
||||
v-for="i in colsPerRow"
|
||||
:key="i"
|
||||
class="skeleton-loader__cell"
|
||||
:style="{ background: bgColor }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
rows: { type: Number, default: 5 },
|
||||
colsPerRow: { type: Number, default: 2 },
|
||||
customClass: { type: String, default: '' },
|
||||
bgColor: { type: String, default: '#f9f9f9' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.skeleton-loader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
background: #f9f9f9;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
&__cell {
|
||||
flex: 1 1 0;
|
||||
min-height: 4rem;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="tabs-block">
|
||||
<div class="tabs-block__nav">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.name"
|
||||
type="button"
|
||||
class="tabs-block__btn"
|
||||
:class="{ 'tabs-block__btn--active': activeTab === tab.name }"
|
||||
@click="onChange(tab.name)"
|
||||
>
|
||||
<SvgIcon
|
||||
v-if="tab.icon"
|
||||
:name="tab.icon"
|
||||
:size="16"
|
||||
:color="activeTab === tab.name ? 'var(--color-primary)' : '#bcbcbc'"
|
||||
/>
|
||||
<span>{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tabs-block__content">
|
||||
<slot :name="activeTab" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
tabs: { type: Array, required: true },
|
||||
defaultTab: { type: String, default: '' },
|
||||
modelValue: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change-tab'])
|
||||
|
||||
const activeTab = ref(props.modelValue || props.defaultTab || props.tabs[0]?.name || '')
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val) activeTab.value = val
|
||||
}
|
||||
)
|
||||
|
||||
const onChange = (name) => {
|
||||
activeTab.value = name
|
||||
emit('update:modelValue', name)
|
||||
emit('change-tab', name)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tabs-block {
|
||||
&__nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
border-block-end: 1px solid #eee;
|
||||
margin-bottom: 1rem;
|
||||
overflow: auto hidden;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.875rem;
|
||||
color: #848484;
|
||||
border-block-end: 2px solid transparent;
|
||||
margin-block-end: -1px;
|
||||
white-space: nowrap;
|
||||
|
||||
&--active {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding-block: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<SelectField v-bind="$attrs" :searchable="true" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SelectField from '@/components/form/SelectField.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
</script>
|
||||
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<div class="datepicker-field">
|
||||
<label v-if="label" :for="inputId" class="datepicker-field__label">{{ label }}</label>
|
||||
|
||||
<div
|
||||
:id="inputId"
|
||||
ref="referenceEl"
|
||||
tabindex="0"
|
||||
class="datepicker-field__trigger"
|
||||
:class="{
|
||||
'datepicker-field__trigger--error': error,
|
||||
'datepicker-field__trigger--disabled': disabled,
|
||||
}"
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="datepicker-field__value">{{ displayValue }}</span>
|
||||
<SvgIcon name="calendar" :size="20" class="datepicker-field__icon" />
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="datepicker-field__error" :class="{ 'animate-shake': vibration }">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-show="isOpen"
|
||||
ref="floatingEl"
|
||||
class="date-picker-dropdown datepicker-field__dropdown"
|
||||
:style="floatingStyles"
|
||||
>
|
||||
<DatePicker
|
||||
v-model="internalValue"
|
||||
locale="fa"
|
||||
inline
|
||||
editable
|
||||
:min="min"
|
||||
:max="max"
|
||||
:type="type"
|
||||
:simple="simple"
|
||||
:auto-submit="type === 'date'"
|
||||
:format="type === 'datetime' ? 'jYYYY/jMM/jDD HH:mm' : 'jYYYY/jMM/jDD'"
|
||||
:display-format="type === 'datetime' ? 'jYYYY/jMM/jDD HH:mm' : 'jYYYY/jMM/jDD'"
|
||||
@update:model-value="onSelect"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import DatePicker from 'vue3-persian-datetime-picker'
|
||||
import { autoUpdate, computePosition, flip, offset, shift, size } from '@floating-ui/dom'
|
||||
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import {
|
||||
formatJalaaliDate,
|
||||
formatJalaaliDateTime,
|
||||
jalaaliStringToIsoDate,
|
||||
jalaaliStringToIsoDateTime,
|
||||
} from '@/utils/date-utils'
|
||||
|
||||
let uid = 0
|
||||
const nextUid = () => `datepicker-field-${++uid}`
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
name: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
placeholder: { type: String, default: 'یک تاریخ را انتخاب کنید' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
error: { type: String, default: '' },
|
||||
vibration: { type: Boolean, default: false },
|
||||
min: { type: String, default: undefined },
|
||||
max: { type: String, default: undefined },
|
||||
type: { type: String, default: 'date' },
|
||||
simple: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const inputId = computed(() => props.name || nextUid())
|
||||
const referenceEl = ref(null)
|
||||
const floatingEl = ref(null)
|
||||
const isOpen = ref(false)
|
||||
const internalValue = ref(null)
|
||||
const floatingStyles = ref({ position: 'absolute', top: '0px', left: '0px' })
|
||||
let cleanup = null
|
||||
|
||||
const displayValue = computed(() => internalValue.value || props.placeholder)
|
||||
|
||||
const syncFromProp = () => {
|
||||
const v = props.modelValue
|
||||
if (v && typeof v === 'string') {
|
||||
internalValue.value =
|
||||
props.type === 'datetime' ? formatJalaaliDateTime(v) : formatJalaaliDate(v)
|
||||
} else {
|
||||
internalValue.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, syncFromProp, { immediate: true })
|
||||
|
||||
const onSelect = (jalaliValue) => {
|
||||
if (!jalaliValue) return
|
||||
internalValue.value = jalaliValue
|
||||
const gregorian =
|
||||
props.type === 'datetime'
|
||||
? jalaaliStringToIsoDateTime(jalaliValue)
|
||||
: jalaaliStringToIsoDate(jalaliValue)
|
||||
emit('update:modelValue', gregorian)
|
||||
emit('change', gregorian)
|
||||
if (props.type === 'date') close()
|
||||
}
|
||||
|
||||
const mountFloating = () => {
|
||||
const reference = referenceEl.value
|
||||
const floating = floatingEl.value
|
||||
if (!reference || !floating) return
|
||||
cleanup = autoUpdate(reference, floating, () => {
|
||||
computePosition(reference, floating, {
|
||||
placement: 'bottom-start',
|
||||
middleware: [
|
||||
offset(6),
|
||||
flip(),
|
||||
shift({ padding: 8 }),
|
||||
size({
|
||||
apply({ rects }) {
|
||||
Object.assign(floating.style, { minWidth: `${rects.reference.width}px` })
|
||||
},
|
||||
}),
|
||||
],
|
||||
}).then(({ x, y }) => {
|
||||
floatingStyles.value = { position: 'absolute', left: `${x}px`, top: `${y}px` }
|
||||
})
|
||||
})
|
||||
document.addEventListener('mousedown', handleOutsideClick)
|
||||
}
|
||||
|
||||
const unmountFloating = () => {
|
||||
cleanup?.()
|
||||
cleanup = null
|
||||
document.removeEventListener('mousedown', handleOutsideClick)
|
||||
}
|
||||
|
||||
const handleOutsideClick = (event) => {
|
||||
const reference = referenceEl.value
|
||||
const floating = floatingEl.value
|
||||
if (
|
||||
reference &&
|
||||
!reference.contains(event.target) &&
|
||||
floating &&
|
||||
!floating.contains(event.target)
|
||||
) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
isOpen.value = true
|
||||
setTimeout(mountFloating, 0)
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false
|
||||
unmountFloating()
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
if (props.disabled) return
|
||||
isOpen.value ? close() : open()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => unmountFloating())
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.datepicker-field {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
&__label {
|
||||
display: flex;
|
||||
margin-bottom: 0.25rem;
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 300;
|
||||
line-height: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-prim-gray);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 2.35rem;
|
||||
padding: 0 1.25rem;
|
||||
border: 1px solid var(--color-thd-gray);
|
||||
border-radius: 1.5rem;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-family-number-fa);
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 1px var(--color-validate);
|
||||
}
|
||||
|
||||
&--error {
|
||||
border-color: var(--color-error);
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 1px var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__value {
|
||||
flex: 1;
|
||||
text-align: start;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: var(--color-thd-gray);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin-top: 0.25rem;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
&__dropdown {
|
||||
z-index: 50;
|
||||
border: 1px solid var(--color-thd-gray);
|
||||
border-radius: 0.75rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 10%), 0 4px 6px -4px rgba(0, 0, 0, 10%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div class="file-uploader">
|
||||
<label class="file-uploader__dropzone-wrap">
|
||||
<div
|
||||
class="file-uploader__dropzone"
|
||||
:class="{ 'file-uploader__dropzone--active': isDragging }"
|
||||
@dragover.prevent
|
||||
@dragenter="isDragging = true"
|
||||
@dragleave="isDragging = false"
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<SvgIcon name="upload" :size="32" class="file-uploader__upload-icon" />
|
||||
<span class="file-uploader__upload-text">{{ uploadText }}</span>
|
||||
<input
|
||||
type="file"
|
||||
class="file-uploader__input"
|
||||
:accept="accept"
|
||||
:multiple="multiple"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div v-if="modelValue.length > 0" class="file-uploader__list">
|
||||
<div v-for="(file, index) in modelValue" :key="file.id ?? index" class="file-uploader__item">
|
||||
<div class="file-uploader__item-info">
|
||||
<SvgIcon name="file" :size="24" class="file-uploader__item-icon" />
|
||||
<div class="file-uploader__item-meta">
|
||||
<p class="file-uploader__item-name">{{ file.name }}</p>
|
||||
<p class="file-uploader__item-size">{{ formatFileSize(file.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-uploader__item-actions">
|
||||
<img
|
||||
v-if="file.url && isImage(file)"
|
||||
:src="file.url"
|
||||
class="file-uploader__item-thumb"
|
||||
alt="preview"
|
||||
/>
|
||||
<a v-else-if="file.url" :href="file.url" target="_blank" class="file-uploader__item-link">
|
||||
دانلود
|
||||
</a>
|
||||
|
||||
<CircleButton
|
||||
tooltip="حذف"
|
||||
bg-color="transparent"
|
||||
size="2rem"
|
||||
@click="removeFile(index, file)"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="close" :size="16" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="file-uploader__empty">هیچ فایلی آپلود نشده است</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import CircleButton from '@/components/CircleButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
accept: { type: String, default: '*' },
|
||||
multiple: { type: Boolean, default: true },
|
||||
maxFiles: { type: Number, default: 10 },
|
||||
maxSize: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'select', 'remove', 'error'])
|
||||
|
||||
const isDragging = ref(false)
|
||||
|
||||
const uploadText = computed(() =>
|
||||
props.modelValue.length === 0 ? 'فایل جدید اضافه کنید' : 'فایل دیگری اضافه کنید'
|
||||
)
|
||||
|
||||
const handleFileSelect = (event) => {
|
||||
const files = [...event.target.files]
|
||||
emitFiles(files)
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
const handleDrop = (event) => {
|
||||
event.preventDefault()
|
||||
isDragging.value = false
|
||||
const files = [...event.dataTransfer.files]
|
||||
emitFiles(files)
|
||||
}
|
||||
|
||||
const emitFiles = (files) => {
|
||||
if (props.modelValue.length + files.length > props.maxFiles) {
|
||||
emit('error', `حداکثر تعداد فایل مجاز ${props.maxFiles} عدد است.`)
|
||||
return
|
||||
}
|
||||
if (props.maxSize > 0) {
|
||||
const tooBig = files.find((f) => f.size > props.maxSize)
|
||||
if (tooBig) {
|
||||
emit('error', `حجم فایل "${tooBig.name}" بیش از حد مجاز است.`)
|
||||
return
|
||||
}
|
||||
}
|
||||
emit('select', files)
|
||||
}
|
||||
|
||||
const removeFile = (index, file) => {
|
||||
const next = [...props.modelValue]
|
||||
next.splice(index, 1)
|
||||
emit('update:modelValue', next)
|
||||
emit('remove', { index, file })
|
||||
}
|
||||
|
||||
const isImage = (file) =>
|
||||
file.type === 'image' || /\.(jpe?g|png|gif|webp|svg)$/i.test(file.name || '')
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.file-uploader {
|
||||
width: 100%;
|
||||
|
||||
&__dropzone-wrap {
|
||||
display: block;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
&__dropzone {
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
transition: border-color 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
&:hover,
|
||||
&--active {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__upload-icon {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
&__upload-text {
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
&__input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
background-color: #eee;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
&__item-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__item-icon {
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__item-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__item-name {
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__item-size {
|
||||
font-family: var(--font-family-en);
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__item-thumb {
|
||||
width: 4rem;
|
||||
height: 3rem;
|
||||
object-fit: cover;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
&__item-link {
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
&__empty {
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
font-family: var(--font-family-fa);
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,357 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="image-cropper" :style="{ backgroundColor: bgColor }">
|
||||
<button
|
||||
v-if="!isCropping && !previewUrl"
|
||||
type="button"
|
||||
class="image-cropper__upload-btn"
|
||||
@click="triggerUpload"
|
||||
>
|
||||
<SvgIcon name="upload" :size="64" />
|
||||
</button>
|
||||
|
||||
<div v-if="isCropping && image" class="image-cropper__crop-stage">
|
||||
<div class="image-cropper__crop-frame">
|
||||
<Cropper
|
||||
:src="image"
|
||||
:stencil-component="circleStencil"
|
||||
:stencil-props="{ aspectRatio: 1 }"
|
||||
image-restriction="stencil"
|
||||
:auto-zoom="true"
|
||||
class="image-cropper__cropper"
|
||||
@change="onCrop"
|
||||
/>
|
||||
</div>
|
||||
<div class="image-cropper__actions">
|
||||
<CircleButton
|
||||
tooltip="تایید"
|
||||
tooltip-position="top"
|
||||
bg-color="var(--color-primary)"
|
||||
size="2.5rem"
|
||||
@click.stop="confirmCrop"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="check" :size="20" color="#fff" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
tooltip="انصراف"
|
||||
tooltip-position="top"
|
||||
bg-color="#fff"
|
||||
size="2.5rem"
|
||||
@click="cancelCrop"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="close" :size="20" color="var(--color-thd-gray)" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="previewUrl && !isCropping" class="image-cropper__preview-wrap">
|
||||
<div class="image-cropper__preview-frame">
|
||||
<img :src="previewUrl" alt="" class="image-cropper__preview-img" />
|
||||
<div class="image-cropper__preview-mask" />
|
||||
</div>
|
||||
<div class="image-cropper__preview-action">
|
||||
<CircleButton
|
||||
tooltip="بارگذاری"
|
||||
tooltip-position="top"
|
||||
bg-color="#fff"
|
||||
size="2.5rem"
|
||||
@click="triggerUpload"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="upload" :size="20" color="var(--color-thd-gray)" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="image-cropper__hint">
|
||||
<SvgIcon name="warning" class="image-cropper__hint-icon" />
|
||||
<p>{{ helperText }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input ref="fileInput" type="file" accept="image/*" hidden @change="onFileChange" />
|
||||
|
||||
<p v-if="error" class="image-cropper__error" :class="{ 'animate-shake': vibration }">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { markRaw, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { Cropper, CircleStencil } from 'vue-advanced-cropper'
|
||||
import 'vue-advanced-cropper/dist/style.css'
|
||||
|
||||
import CircleButton from '@/components/CircleButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Object, String], default: null },
|
||||
name: { type: String, default: '' },
|
||||
vibration: { type: Boolean, default: false },
|
||||
bgColor: { type: String, default: '#f3f4f6' },
|
||||
maxSize: { type: Number, default: 250 * 1024 },
|
||||
helperText: {
|
||||
type: String,
|
||||
default: 'سایز تصویر رسمی 3x4 و حجم تصویر بیشتر از 250 کیلو بایت نباشد.',
|
||||
},
|
||||
error: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['crop', 'update:modelValue', 'error'])
|
||||
|
||||
const fileInput = ref(null)
|
||||
const image = ref(null)
|
||||
const cropResult = ref(null)
|
||||
const isCropping = ref(false)
|
||||
const internalPreviewUrl = ref(null)
|
||||
const originalFileName = ref(null)
|
||||
const previewUrl = ref(null)
|
||||
|
||||
const circleStencil = markRaw(CircleStencil)
|
||||
|
||||
const cleanup = () => {
|
||||
if (internalPreviewUrl.value) {
|
||||
URL.revokeObjectURL(internalPreviewUrl.value)
|
||||
internalPreviewUrl.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val && typeof val === 'object' && val.url) {
|
||||
previewUrl.value = val.url
|
||||
} else if (typeof val === 'string') {
|
||||
previewUrl.value = val
|
||||
} else {
|
||||
previewUrl.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const triggerUpload = () => fileInput.value?.click()
|
||||
|
||||
const onFileChange = (event) => {
|
||||
cleanup()
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (props.maxSize > 0 && file.size > props.maxSize) {
|
||||
emit('error', 'حجم تصویر نباید بیشتر از ۲۵۰ کیلوبایت باشد.')
|
||||
event.target.value = null
|
||||
return
|
||||
}
|
||||
|
||||
originalFileName.value = file.name
|
||||
internalPreviewUrl.value = URL.createObjectURL(file)
|
||||
image.value = internalPreviewUrl.value
|
||||
isCropping.value = true
|
||||
event.target.value = null
|
||||
}
|
||||
|
||||
const onCrop = ({ canvas }) => {
|
||||
cropResult.value = canvas
|
||||
}
|
||||
|
||||
const confirmCrop = async () => {
|
||||
if (!cropResult.value) return
|
||||
const blob = await new Promise((resolve) => cropResult.value.toBlob(resolve, 'image/png'))
|
||||
const croppedFile = new File([blob], originalFileName.value || 'avatar.png', {
|
||||
type: 'image/png',
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
emit('crop', croppedFile)
|
||||
isCropping.value = false
|
||||
image.value = null
|
||||
cropResult.value = null
|
||||
}
|
||||
|
||||
const cancelCrop = () => {
|
||||
isCropping.value = false
|
||||
image.value = null
|
||||
cleanup()
|
||||
}
|
||||
|
||||
onBeforeUnmount(cleanup)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.image-cropper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
border-radius: 1.5rem;
|
||||
text-align: center;
|
||||
padding: 0.75rem 0.5rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
min-height: 20rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
min-height: 23rem;
|
||||
}
|
||||
|
||||
&__upload-btn {
|
||||
height: 8.15rem;
|
||||
width: 8.15rem;
|
||||
border-radius: 9999px;
|
||||
background: #fff;
|
||||
border: none;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--color-thd-gray);
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
height: 10.95rem;
|
||||
width: 10.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__crop-stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__crop-frame {
|
||||
height: 8.15rem;
|
||||
width: 8.15rem;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #d1d5db;
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
height: 10.95rem;
|
||||
width: 10.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__cropper {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 0.75rem;
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__preview-wrap {
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
&__preview-frame {
|
||||
position: relative;
|
||||
border-radius: 9999px;
|
||||
height: 8.15rem;
|
||||
width: 8.15rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
height: 10.95rem;
|
||||
width: 10.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__preview-img {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
&__preview-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 9999px;
|
||||
border: 1rem solid #000;
|
||||
opacity: 0.2;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
&__preview-action {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
bottom: -0.25rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
background: #fff;
|
||||
border-radius: 1rem;
|
||||
margin-top: 1.25rem;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
p {
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.5;
|
||||
text-align: right;
|
||||
margin: 0;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin-top: 0.25rem;
|
||||
text-align: right;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-error);
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__hint-icon {
|
||||
color: var(--color-prim-gray);
|
||||
min-height: 1.25rem;
|
||||
min-width: 1.25rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
min-height: 2rem;
|
||||
min-width: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<TextField
|
||||
v-model="model"
|
||||
:name="name"
|
||||
:label="label"
|
||||
:placeholder="placeholder"
|
||||
:error="error"
|
||||
:vibration="vibration"
|
||||
:disabled="disabled"
|
||||
:type="visible ? 'text' : 'password'"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<button
|
||||
type="button"
|
||||
class="password-field__toggle"
|
||||
:aria-label="visible ? 'پنهان کردن رمز' : 'نمایش رمز'"
|
||||
@click="visible = !visible"
|
||||
>
|
||||
<SvgIcon :name="visible ? 'eye-slash' : 'eye'" :size="22" color="var(--color-thd-gray)" />
|
||||
</button>
|
||||
</template>
|
||||
</TextField>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import TextField from '@/components/form/TextField.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
name: { type: String, default: 'password' },
|
||||
label: { type: String, default: 'رمز عبور' },
|
||||
placeholder: { type: String, default: '' },
|
||||
error: { type: String, default: '' },
|
||||
vibration: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const visible = ref(false)
|
||||
const model = ref(props.modelValue)
|
||||
|
||||
watch(model, (v) => emit('update:modelValue', v))
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
if (v !== model.value) model.value = v
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.password-field__toggle {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,377 @@
|
||||
<template>
|
||||
<div class="select-field">
|
||||
<label v-if="label" :for="inputId" class="select-field__label">{{ label }}</label>
|
||||
|
||||
<div
|
||||
:id="inputId"
|
||||
ref="referenceEl"
|
||||
tabindex="0"
|
||||
class="select-field__trigger"
|
||||
:class="{
|
||||
'select-field__trigger--error': error,
|
||||
'select-field__trigger--disabled': disabled,
|
||||
'select-field__trigger--open': isOpen,
|
||||
}"
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="select-field__value">{{ selectedLabel }}</span>
|
||||
<SvgIcon
|
||||
name="caret-down"
|
||||
:size="20"
|
||||
class="select-field__caret"
|
||||
:class="{ 'select-field__caret--open': isOpen }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="select-field__error" :class="{ 'animate-shake': vibration }">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="floatingEl"
|
||||
class="select-field__dropdown scrollbar-thin"
|
||||
:style="{ width: dropdownWidth }"
|
||||
>
|
||||
<div v-if="searchable" class="select-field__search">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="جستجو..."
|
||||
class="select-field__search-input"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="options.length === 0" class="select-field__empty">موردی یافت نشد</div>
|
||||
|
||||
<ul v-else class="select-field__list">
|
||||
<li v-for="(opt, i) in options" :key="i">
|
||||
<button
|
||||
type="button"
|
||||
class="select-field__option"
|
||||
:class="{ 'select-field__option--active': isSelected(opt) }"
|
||||
@click="select(opt)"
|
||||
>
|
||||
<template v-if="multiple">
|
||||
<SvgIcon
|
||||
:name="isSelected(opt) ? 'check-square' : 'square'"
|
||||
:size="16"
|
||||
class="select-field__check"
|
||||
/>
|
||||
</template>
|
||||
<span class="select-field__option-text">{{ opt[optionLabel] }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
|
||||
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
let uid = 0
|
||||
const nextUid = () => `select-field-${++uid}`
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number, Array, Boolean, Object], default: undefined },
|
||||
name: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
placeholder: { type: String, default: 'انتخاب کنید' },
|
||||
options: { type: Array, required: true },
|
||||
optionLabel: { type: String, default: 'name' },
|
||||
optionValue: { type: String, default: 'id' },
|
||||
multiple: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
error: { type: String, default: '' },
|
||||
vibration: { type: Boolean, default: false },
|
||||
searchable: { type: Boolean, default: false },
|
||||
onSearch: { type: Function, default: null },
|
||||
searchDebounce: { type: Number, default: 400 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const inputId = computed(() => props.name || nextUid())
|
||||
const referenceEl = ref(null)
|
||||
const floatingEl = ref(null)
|
||||
const isOpen = ref(false)
|
||||
const dropdownWidth = ref('auto')
|
||||
const searchQuery = ref('')
|
||||
let cleanup = null
|
||||
let debounceTimer = null
|
||||
|
||||
const isSelected = (opt) => {
|
||||
const value = opt[props.optionValue]
|
||||
return props.multiple
|
||||
? Array.isArray(props.modelValue) && props.modelValue.includes(value)
|
||||
: props.modelValue === value
|
||||
}
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
const value = props.modelValue
|
||||
if (value === undefined || value === null || value === '') return props.placeholder
|
||||
if (props.multiple && Array.isArray(value)) {
|
||||
const selected = props.options.filter((o) => value.includes(o[props.optionValue]))
|
||||
if (selected.length === 0) return props.placeholder
|
||||
if (selected.length <= 2) return selected.map((s) => s[props.optionLabel]).join('، ')
|
||||
return `${selected.length} مورد انتخاب شده`
|
||||
}
|
||||
const found = props.options.find((o) => o[props.optionValue] === value)
|
||||
return found ? found[props.optionLabel] : props.placeholder
|
||||
})
|
||||
|
||||
const select = (opt) => {
|
||||
if (props.disabled) return
|
||||
const value = opt[props.optionValue]
|
||||
if (props.multiple) {
|
||||
let current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||
if (current.includes(value)) {
|
||||
current = current.filter((v) => v !== value)
|
||||
} else {
|
||||
current.push(value)
|
||||
}
|
||||
emit('update:modelValue', current)
|
||||
emit('change', current)
|
||||
} else {
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
const mountFloating = async () => {
|
||||
await Promise.resolve()
|
||||
const reference = referenceEl.value
|
||||
const floating = floatingEl.value
|
||||
if (!reference || !floating) return
|
||||
dropdownWidth.value = `${reference.offsetWidth}px`
|
||||
cleanup = autoUpdate(reference, floating, () => {
|
||||
computePosition(reference, floating, {
|
||||
placement: 'bottom-start',
|
||||
middleware: [offset(4), flip(), shift({ padding: 8 })],
|
||||
}).then(({ x, y }) => {
|
||||
Object.assign(floating.style, { left: `${x}px`, top: `${y}px`, position: 'absolute' })
|
||||
})
|
||||
})
|
||||
document.addEventListener('mousedown', handleOutsideClick)
|
||||
}
|
||||
|
||||
const unmountFloating = () => {
|
||||
cleanup?.()
|
||||
cleanup = null
|
||||
document.removeEventListener('mousedown', handleOutsideClick)
|
||||
}
|
||||
|
||||
const handleOutsideClick = (event) => {
|
||||
const reference = referenceEl.value
|
||||
const floating = floatingEl.value
|
||||
if (
|
||||
reference &&
|
||||
!reference.contains(event.target) &&
|
||||
floating &&
|
||||
!floating.contains(event.target)
|
||||
) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
isOpen.value = true
|
||||
setTimeout(mountFloating, 0)
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
unmountFloating()
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
if (props.disabled) return
|
||||
isOpen.value ? close() : open()
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
if (!props.onSearch) return
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => props.onSearch(searchQuery.value), props.searchDebounce)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => unmountFloating())
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.select-field {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
&__label {
|
||||
display: flex;
|
||||
margin-bottom: 0.25rem;
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 300;
|
||||
line-height: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-prim-gray);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 2.35rem;
|
||||
padding: 0 1.25rem;
|
||||
border: 1px solid var(--color-thd-gray);
|
||||
border-radius: 1.5rem;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-family-number-fa);
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 1px var(--color-validate);
|
||||
}
|
||||
|
||||
&--error {
|
||||
border-color: var(--color-error);
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 1px var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__value {
|
||||
flex: 1;
|
||||
text-align: start;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__caret {
|
||||
color: var(--color-thd-gray);
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin-top: 0.25rem;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
&__dropdown {
|
||||
position: absolute;
|
||||
z-index: 9999;
|
||||
max-height: 15rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-thd-gray);
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 10%), 0 4px 6px -4px rgba(0, 0, 0, 10%);
|
||||
}
|
||||
|
||||
&__search {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
&__search-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid var(--color-thd-gray);
|
||||
border-radius: 0.75rem;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 1px var(--color-validate);
|
||||
}
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: 0.75rem;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-prim-gray);
|
||||
}
|
||||
|
||||
&__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.25rem;
|
||||
|
||||
> li + li {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 1.5rem;
|
||||
background: transparent;
|
||||
text-align: right;
|
||||
font-family: var(--font-family-fa);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
&__check {
|
||||
color: var(--color-thd-gray);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__option-text {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div class="text-field">
|
||||
<label v-if="label" :for="inputId" class="text-field__label">
|
||||
{{ label }}
|
||||
</label>
|
||||
<div
|
||||
class="text-field__control"
|
||||
:class="{
|
||||
'text-field__control--error': error,
|
||||
'text-field__control--disabled': disabled,
|
||||
}"
|
||||
>
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="inputEl"
|
||||
:type="type"
|
||||
:name="name"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:value="modelValue"
|
||||
:inputmode="inputmode"
|
||||
class="text-field__input"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
<div v-if="$slots.appendIcon" class="text-field__icon">
|
||||
<slot name="appendIcon" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="error" class="text-field__error" :class="{ 'animate-shake': vibration }">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { convertToEnNumber } from '@/utils/convert-check-digits'
|
||||
|
||||
let uid = 0
|
||||
const nextUid = () => `text-field-${++uid}`
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
name: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '' },
|
||||
type: { type: String, default: 'text' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
error: { type: String, default: '' },
|
||||
vibration: { type: Boolean, default: false },
|
||||
convertDigits: { type: Boolean, default: false },
|
||||
inputmode: { type: String, default: undefined },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'blur', 'focus', 'input'])
|
||||
|
||||
const inputEl = ref(null)
|
||||
const inputId = computed(() => props.name || nextUid())
|
||||
|
||||
const onInput = (event) => {
|
||||
const raw = event.target.value
|
||||
const value = props.convertDigits ? convertToEnNumber(raw) : raw
|
||||
if (props.convertDigits && value !== raw) {
|
||||
event.target.value = value
|
||||
}
|
||||
emit('update:modelValue', value)
|
||||
emit('input', value)
|
||||
}
|
||||
|
||||
const onBlur = (event) => emit('blur', event)
|
||||
const onFocus = (event) => emit('focus', event)
|
||||
|
||||
defineExpose({ focus: () => inputEl.value?.focus() })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.text-field {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
&__label {
|
||||
display: flex;
|
||||
margin-bottom: 0.25rem;
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 300;
|
||||
line-height: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-prim-gray);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__control {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
height: 2.35rem;
|
||||
padding: 0 2rem;
|
||||
background: transparent;
|
||||
font-family: var(--font-family-number-fa);
|
||||
font-weight: 300;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid var(--color-thd-gray);
|
||||
border-radius: 1.5rem;
|
||||
transition: all 0.3s;
|
||||
outline: none;
|
||||
color: inherit;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-prim-gray);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-thd-gray);
|
||||
box-shadow: 0 0 0 1px var(--color-validate);
|
||||
}
|
||||
}
|
||||
|
||||
&__control--error &__input {
|
||||
border-color: var(--color-error);
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 1px var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
&__control--disabled &__input {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
position: absolute;
|
||||
inset-inline-start: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin-top: 0.25rem;
|
||||
text-align: right;
|
||||
font-family: var(--font-family-fa);
|
||||
letter-spacing: 0.025em;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-error);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<div class="textarea-field">
|
||||
<label v-if="label" :for="inputId" class="textarea-field__label">
|
||||
{{ label }}
|
||||
</label>
|
||||
<div
|
||||
class="textarea-field__control"
|
||||
:class="{
|
||||
'textarea-field__control--error': error,
|
||||
'textarea-field__control--disabled': disabled,
|
||||
'textarea-field__control--has-icon': $slots.appendIcon,
|
||||
}"
|
||||
>
|
||||
<textarea
|
||||
:id="inputId"
|
||||
ref="textareaEl"
|
||||
:name="name"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:rows="row"
|
||||
:value="modelValue"
|
||||
:style="textareaStyle"
|
||||
class="textarea-field__input"
|
||||
:class="{ 'textarea-field__input--no-resize': !autoResize }"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
<div v-if="$slots.appendIcon" class="textarea-field__icon">
|
||||
<slot name="appendIcon" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="error" class="textarea-field__error" :class="{ 'animate-shake': vibration }">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
|
||||
let uid = 0
|
||||
const nextUid = () => `textarea-field-${++uid}`
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
name: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '' },
|
||||
row: { type: Number, default: 3 },
|
||||
disabled: { type: Boolean, default: false },
|
||||
error: { type: String, default: '' },
|
||||
vibration: { type: Boolean, default: false },
|
||||
autoResize: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'blur', 'focus', 'input'])
|
||||
|
||||
const textareaEl = ref(null)
|
||||
const inputId = computed(() => props.name || nextUid())
|
||||
const textareaHeight = ref('auto')
|
||||
|
||||
const textareaStyle = computed(() => {
|
||||
if (!props.autoResize) return {}
|
||||
return {
|
||||
height: textareaHeight.value,
|
||||
minHeight: '40px',
|
||||
resize: 'none',
|
||||
overflowY: 'hidden',
|
||||
}
|
||||
})
|
||||
|
||||
const resize = () => {
|
||||
const el = textareaEl.value
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
const newHeight = Math.max(el.scrollHeight, 40)
|
||||
el.style.height = `${newHeight}px`
|
||||
textareaHeight.value = `${newHeight}px`
|
||||
}
|
||||
|
||||
const onInput = (event) => {
|
||||
emit('update:modelValue', event.target.value)
|
||||
emit('input', event.target.value)
|
||||
if (props.autoResize) nextTick(resize)
|
||||
}
|
||||
|
||||
const onBlur = (event) => emit('blur', event)
|
||||
const onFocus = (event) => emit('focus', event)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
if (props.autoResize) nextTick(resize)
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autoResize) nextTick(resize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.textarea-field {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
&__label {
|
||||
display: flex;
|
||||
margin-bottom: 0.25rem;
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 300;
|
||||
line-height: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-prim-gray);
|
||||
text-align: justify;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__control {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
min-height: 37px;
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: transparent;
|
||||
font-family: var(--font-family-number-fa);
|
||||
font-weight: 300;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid var(--color-thd-gray);
|
||||
border-radius: 1.5rem;
|
||||
transition: all 0.3s;
|
||||
outline: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-prim-gray);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-thd-gray);
|
||||
box-shadow: 0 0 0 1px var(--color-validate);
|
||||
}
|
||||
|
||||
&--no-resize {
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__control--error &__input {
|
||||
border-color: var(--color-error);
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 1px var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
&__control--disabled &__input {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&__control--has-icon &__input {
|
||||
padding-inline-start: 3rem;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
position: absolute;
|
||||
inset-inline-start: 0.75rem;
|
||||
top: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin-top: 0.25rem;
|
||||
text-align: right;
|
||||
font-family: var(--font-family-fa);
|
||||
letter-spacing: 0.025em;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-error);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="toggle-switch">
|
||||
<label class="toggle-switch__label">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
class="toggle-switch__track"
|
||||
@click="!disabled && toggle()"
|
||||
>
|
||||
<span
|
||||
class="toggle-switch__thumb"
|
||||
:class="{
|
||||
'toggle-switch__thumb--on': modelValue,
|
||||
'toggle-switch__thumb--off': !modelValue,
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
<span class="toggle-switch__text">{{ label }}</span>
|
||||
</label>
|
||||
<div v-if="error" class="toggle-switch__error dir-rtl" :class="{ 'animate-shake': vibration }">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
name: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
error: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
vibration: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const toggle = () => emit('update:modelValue', !props.modelValue)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
|
||||
&__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&__track {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 2rem;
|
||||
width: 3.75rem;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background-color: rgba(104, 104, 104, 5%);
|
||||
transition: background-color 0.3s;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__thumb {
|
||||
display: inline-block;
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
border-radius: 9999px;
|
||||
transition: transform 0.3s;
|
||||
|
||||
&--on {
|
||||
background-color: var(--color-primary);
|
||||
transform: translateX(-1.75rem);
|
||||
}
|
||||
|
||||
&--off {
|
||||
background-color: #fff;
|
||||
transform: translateX(-0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 400;
|
||||
font-size: 0.95rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin-top: 0.625rem;
|
||||
padding: 0 0.5rem;
|
||||
text-align: right;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-error);
|
||||
transition: transform 0.075s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<component
|
||||
:is="component"
|
||||
v-if="component"
|
||||
class="svg-icon"
|
||||
:style="iconStyle"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { iconRegistry } from '@/components/icons/registry'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
size: { type: [String, Number], default: '1.25rem' },
|
||||
color: { type: String, default: 'currentColor' },
|
||||
})
|
||||
|
||||
const component = computed(() => {
|
||||
const c = iconRegistry[props.name]
|
||||
if (!c && import.meta.env.DEV) {
|
||||
console.warn(`[SvgIcon] unknown icon "${props.name}"`)
|
||||
}
|
||||
return c
|
||||
})
|
||||
|
||||
const sizeValue = computed(() => (typeof props.size === 'number' ? `${props.size}px` : props.size))
|
||||
|
||||
const iconStyle = computed(() => ({
|
||||
width: sizeValue.value,
|
||||
height: sizeValue.value,
|
||||
color: props.color,
|
||||
flexShrink: 0,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.svg-icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
fill: currentcolor;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
const modules = import.meta.glob('@/assets/icons/*.svg', { eager: true })
|
||||
|
||||
export const iconRegistry = Object.fromEntries(
|
||||
Object.entries(modules).map(([path, mod]) => {
|
||||
const name = path.split('/').pop().replace('.svg', '')
|
||||
return [name, mod.default]
|
||||
})
|
||||
)
|
||||
|
||||
export const iconNames = Object.keys(iconRegistry)
|
||||
Reference in New Issue
Block a user