first commit

This commit is contained in:
sajjadtalkhabi
2026-05-13 01:43:05 +03:30
commit d2a577f254
297 changed files with 36274 additions and 0 deletions
+125
View File
@@ -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>
+171
View File
@@ -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>
+201
View File
@@ -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>
+91
View File
@@ -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>
+93
View File
@@ -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>
+196
View File
@@ -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>
+165
View File
@@ -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>
+53
View File
@@ -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>
+69
View File
@@ -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>
+49
View File
@@ -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>
+129
View File
@@ -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>
+91
View File
@@ -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>
+257
View File
@@ -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>
+256
View File
@@ -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>
+357
View File
@@ -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>
+68
View File
@@ -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>
+377
View File
@@ -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>
+155
View File
@@ -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>
+189
View File
@@ -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>
+107
View File
@@ -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>
+47
View File
@@ -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>
+10
View File
@@ -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)