first commit
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
@@ -0,0 +1,95 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
es2021: true,
|
||||
},
|
||||
plugins: ['prettier', 'unicorn', 'import'],
|
||||
ignorePatterns: ['node_modules/', 'dist/', 'build/', '*.min.js'],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2021,
|
||||
sourceType: 'module',
|
||||
},
|
||||
extends: [
|
||||
'plugin:unicorn/recommended',
|
||||
'eslint:recommended',
|
||||
'plugin:prettier/recommended',
|
||||
'eslint-config-prettier',
|
||||
'plugin:vue/vue3-essential',
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.vue'],
|
||||
parser: 'vue-eslint-parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2021,
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.cjs'],
|
||||
parserOptions: {
|
||||
sourceType: 'script',
|
||||
},
|
||||
},
|
||||
],
|
||||
rules: {
|
||||
'no-alert': 'error',
|
||||
'no-shadow': 'off',
|
||||
'no-console': 'off',
|
||||
'import/order': 'off',
|
||||
'dot-notation': 'error',
|
||||
'no-else-return': 'off',
|
||||
'unicorn/no-null': 'off',
|
||||
semi: ['error', 'never'],
|
||||
'import/namespace': 'off',
|
||||
'no-implied-eval': 'error',
|
||||
'no-return-await': 'error',
|
||||
'no-nested-ternary': 'off',
|
||||
'no-param-reassign': 'off',
|
||||
'no-throw-literal': 'error',
|
||||
'consistent-return': 'off',
|
||||
'no-useless-catch': 'off',
|
||||
'prefer-destructuring': 'off',
|
||||
'import/no-unresolved': 'off',
|
||||
'no-underscore-dangle': 'off',
|
||||
'unicorn/prefer-spread': 'off',
|
||||
'unicorn/prefer-module': 'off',
|
||||
'unicorn/filename-case': 'off',
|
||||
'unicorn/no-array-reduce': 'off',
|
||||
'no-constant-condition': 'error',
|
||||
'unicorn/no-array-for-each': 'off',
|
||||
'unicorn/prefer-node-protocol': 'off',
|
||||
'import/prefer-default-export': 'off',
|
||||
'unicorn/prevent-abbreviations': 'off',
|
||||
'unicorn/prefer-string-replace-all': 'off',
|
||||
'unicorn/consistent-function-scoping': 'off',
|
||||
'unicorn/no-thenable': 'off',
|
||||
'unicorn/no-static-only-class': 'off',
|
||||
'unicorn/prefer-dom-node-text-content': 'off',
|
||||
'unicorn/prefer-logical-operator-over-ternary': 'off',
|
||||
'unicorn/prefer-number-properties': 'off',
|
||||
'unicorn/prefer-ternary': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'no-irregular-whitespace': 'off',
|
||||
'no-empty': 'off',
|
||||
'import/extensions': 'off',
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/component-name-in-template-casing': 'off',
|
||||
'vue/html-self-closing': 'off',
|
||||
'vue/max-attributes-per-line': 'off',
|
||||
'vue/html-indent': 'off',
|
||||
'vue/no-unused-components': 'off',
|
||||
'vue/no-reserved-component-names': 'off',
|
||||
'vue/no-unused-vars': 'off',
|
||||
'vue/require-v-for-key': 'off',
|
||||
'vue/require-toggle-inside-transition': 'off',
|
||||
'vue/require-valid-default-prop': 'off',
|
||||
'vue/no-deprecated-props-default-this': 'off',
|
||||
'vue/no-duplicate-attributes': 'off',
|
||||
'vue/no-side-effects-in-computed-properties': 'off',
|
||||
'vue/valid-v-on': 'off',
|
||||
'padding-line-between-statements': 'off',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.*.local
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
*.min.js
|
||||
package-lock.json
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": false,
|
||||
"arrowParens": "always",
|
||||
"proseWrap": "never",
|
||||
"htmlWhitespaceSensitivity": "ignore",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
*.min.css
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"extends": ["stylelint-config-standard-scss", "stylelint-config-html"],
|
||||
"plugins": ["stylelint-declaration-block-no-ignored-properties"],
|
||||
"rules": {
|
||||
"media-feature-range-notation": "prefix",
|
||||
"property-no-vendor-prefix": null,
|
||||
"color-function-notation": "legacy",
|
||||
"declaration-block-no-redundant-longhand-properties": [
|
||||
true,
|
||||
{
|
||||
"ignoreShorthands": ["inset", "place-content"]
|
||||
}
|
||||
],
|
||||
"selector-pseudo-class-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignorePseudoClasses": ["global", "deep", "slotted"]
|
||||
}
|
||||
],
|
||||
"at-rule-no-unknown": null,
|
||||
"scss/at-rule-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignoreAtRules": ["use", "forward"]
|
||||
}
|
||||
],
|
||||
"font-family-no-missing-generic-family-keyword": null,
|
||||
"no-descending-specificity": null,
|
||||
"plugin/declaration-block-no-ignored-properties": true,
|
||||
"selector-class-pattern": [
|
||||
"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:__[a-z0-9]+(?:-[a-z0-9]+)*)?(?:--[a-z0-9]+(?:-[a-z0-9]+)*)?$",
|
||||
{
|
||||
"message": "Expected class selector to follow kebab-case BEM (block__element--modifier)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.vue", "**/*.vue"],
|
||||
"customSyntax": "postcss-html",
|
||||
"rules": {
|
||||
"selector-pseudo-element-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignorePseudoElements": ["v-deep", "v-global", "v-slotted", "deep"]
|
||||
}
|
||||
],
|
||||
"selector-pseudo-class-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignorePseudoClasses": ["deep", "global", "slotted"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignoreFiles": ["**/*.js", "**/*.ts", "node_modules/**", "dist/**", "build/**"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
{
|
||||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"editorconfig.editorconfig",
|
||||
"stylelint.vscode-stylelint",
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"streetsidesoftware.code-spell-checker-persian"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Banu</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "banu-front",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .js,.vue --max-warnings=0",
|
||||
"lint:fix": "eslint . --ext .js,.vue --fix --max-warnings=0",
|
||||
"lint:css:check": "stylelint \"src/**/*.{css,scss,vue}\" --max-warnings=0",
|
||||
"lint:css:fix": "stylelint \"src/**/*.{css,scss,vue}\" --fix --max-warnings=0",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.6",
|
||||
"@tanstack/vue-query": "^5.62.2",
|
||||
"@tanstack/vue-query-devtools": "^5.62.2",
|
||||
"axios": "^1.12.2",
|
||||
"lodash": "^4.18.1",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.21",
|
||||
"vue-advanced-cropper": "^2.8.9",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue3-persian-datetime-picker": "^1.2.2",
|
||||
"vue3-toastify": "^0.2.5",
|
||||
"yup": "^1.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^8.10.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-unicorn": "^55.0.0",
|
||||
"eslint-plugin-vue": "^9.15.1",
|
||||
"postcss-html": "^1.8.0",
|
||||
"prettier": "2.8.8",
|
||||
"sass": "^1.83.0",
|
||||
"stylelint": "^16.0.0",
|
||||
"stylelint-config-html": "^1.1.0",
|
||||
"stylelint-config-standard-scss": "^12.0.0",
|
||||
"stylelint-declaration-block-no-ignored-properties": "^2.8.0",
|
||||
"vite": "^7.1.6",
|
||||
"vite-svg-loader": "^5.1.0",
|
||||
"vue-eslint-parser": "^10.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<component :is="currentLayout" />
|
||||
<ConfirmModal />
|
||||
<VueQueryDevtools />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
|
||||
|
||||
import ConfirmModal from '@/components/ConfirmModal.vue'
|
||||
import PublicLayout from '@/layouts/PublicLayout.vue'
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue'
|
||||
import StudentLayout from '@/layouts/StudentLayout.vue'
|
||||
import { useModalStore } from '@/store/modal'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const layouts = {
|
||||
public: PublicLayout,
|
||||
admin: AdminLayout,
|
||||
student: StudentLayout,
|
||||
}
|
||||
|
||||
const currentLayout = computed(() => layouts[route.meta?.layout] || PublicLayout)
|
||||
|
||||
const { modals } = storeToRefs(useModalStore())
|
||||
watch(
|
||||
() => modals.value.length,
|
||||
(count) => {
|
||||
document.body.style.overflow = count > 0 ? 'hidden' : ''
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z"/></svg>
|
||||
|
After Width: | Height: | Size: 249 B |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 11 11" fill="none">
|
||||
<g clip-path="url(#clip0_864_4288)">
|
||||
<path d="M6.75391 3.85938H8.68359V1.92969" stroke="currentColor" stroke-width="0.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.68294 3.86055L7.54604 2.72364C6.88762 2.06525 5.9962 1.69298 5.0651 1.68756C4.13399 1.68214 3.2383 2.04401 2.57227 2.69469" stroke="currentColor" stroke-width="0.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3.53711 6.43359H1.60742V8.36328" stroke="currentColor" stroke-width="0.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M1.60742 6.43359L2.74433 7.5705C3.40274 8.22889 4.29417 8.60116 5.22527 8.60658C6.15637 8.612 7.05207 8.25013 7.7181 7.59945" stroke="currentColor" stroke-width="0.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_864_4288">
|
||||
<rect width="10.2917" height="10.2917" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1012 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="36" height="35" viewBox="0 0 36 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M25.1332 22.2004C25.1332 24.2438 24.4217 25.9842 22.9987 27.4214C21.5755 28.8584 19.8222 29.577 17.7388 29.577C15.6551 29.577 13.8942 28.8581 12.4561 27.4203C11.0179 25.9828 10.2988 24.24 10.2988 22.1918V10.0032C10.2988 8.59544 10.79 7.39693 11.7724 6.40769C12.7545 5.41845 13.9646 4.92383 15.4027 4.92383C16.8348 4.92383 18.0434 5.42072 19.0287 6.41451C20.014 7.40831 20.5066 8.60646 20.5066 10.009V21.5449C20.5066 22.2996 20.2431 22.9495 19.7161 23.4945C19.1893 24.0396 18.5375 24.3121 17.7608 24.3121C16.9843 24.3121 16.3207 24.0458 15.7699 23.5132C15.2191 22.9806 14.9438 22.3245 14.9438 21.5449V10.6379C14.9438 10.521 14.9871 10.4169 15.0737 10.3256C15.1601 10.2343 15.2703 10.1887 15.4042 10.1887C15.5379 10.1887 15.6506 10.2343 15.7424 10.3256C15.8342 10.4169 15.8801 10.521 15.8801 10.6379V21.5449C15.8801 22.06 16.0583 22.4973 16.4147 22.8566C16.7709 23.216 17.2071 23.3957 17.7234 23.3957C18.2448 23.3957 18.6829 23.2143 19.0379 22.8516C19.3928 22.4889 19.5703 22.0533 19.5703 21.5449V9.97305C19.5703 8.82305 19.1704 7.84674 18.3707 7.04414C17.5707 6.24154 16.5814 5.84023 15.4027 5.84023C14.2358 5.84023 13.2494 6.24753 12.4436 7.06211C11.638 7.87669 11.2352 8.85898 11.2352 10.009V22.1839C11.2352 23.9741 11.8655 25.501 13.1262 26.7648C14.3868 28.0286 15.9281 28.6605 17.7498 28.6605C19.5456 28.6605 21.0721 28.0286 22.3294 26.7648C23.5866 25.501 24.2091 23.9767 24.1969 22.1918V10.6379C24.1969 10.521 24.2433 10.4169 24.336 10.3256C24.4286 10.2343 24.5387 10.1887 24.6665 10.1887C24.794 10.1887 24.9037 10.2343 24.9955 10.3256C25.0873 10.4169 25.1332 10.521 25.1332 10.6379V22.2004Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M221.8,175.94C216.25,166.38,208,139.33,208,104a80,80,0,1,0-160,0c0,35.34-8.26,62.38-13.81,71.94A16,16,0,0,0,48,200H88.81a40,40,0,0,0,78.38,0H208a16,16,0,0,0,13.8-24.06ZM128,216a24,24,0,0,1-22.62-16h45.24A24,24,0,0,1,128,216ZM48,184c7.7-13.24,16-43.92,16-80a64,64,0,1,1,128,0c0,36.05,8.28,66.73,16,80Z"/></svg>
|
||||
|
After Width: | Height: | Size: 401 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M208,24H72A32,32,0,0,0,40,56V224a8,8,0,0,0,8,8H192a8,8,0,0,0,0-16H56a16,16,0,0,1,16-16H208a8,8,0,0,0,8-8V32A8,8,0,0,0,208,24Zm-8,160H72a31.81,31.81,0,0,0-16,4.29V56A16,16,0,0,1,72,40H200Z"/></svg>
|
||||
|
After Width: | Height: | Size: 288 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M208,32H184V24a8,8,0,0,0-16,0v8H88V24a8,8,0,0,0-16,0v8H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM72,48v8a8,8,0,0,0,16,0V48h80v8a8,8,0,0,0,16,0V48h24V80H48V48ZM208,208H48V96H208V208Z"/></svg>
|
||||
|
After Width: | Height: | Size: 328 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z"/></svg>
|
||||
|
After Width: | Height: | Size: 215 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M165.66,202.34a8,8,0,0,1-11.32,11.32l-80-80a8,8,0,0,1,0-11.32l80-80a8,8,0,0,1,11.32,11.32L91.31,128Z"/></svg>
|
||||
|
After Width: | Height: | Size: 201 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z"/></svg>
|
||||
|
After Width: | Height: | Size: 220 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M132,24A100.11,100.11,0,0,0,32,124v84a16,16,0,0,0,16,16h84a100,100,0,0,0,0-200Zm0,184H48V124a84,84,0,1,1,84,84Zm12-80a12,12,0,1,1-12-12A12,12,0,0,1,144,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,100,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,188,128Z"/></svg>
|
||||
|
After Width: | Height: | Size: 346 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M208,32H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32Zm-37.66,75.66-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32Z"/></svg>
|
||||
|
After Width: | Height: | Size: 308 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z"/></svg>
|
||||
|
After Width: | Height: | Size: 216 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></svg>
|
||||
|
After Width: | Height: | Size: 285 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"/></svg>
|
||||
|
After Width: | Height: | Size: 280 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M53.92,34.62A8,8,0,1,0,42.08,45.38L61.32,66.55C25,88.84,9.38,123.2,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208a127.11,127.11,0,0,0,52.07-10.83l21.85,24a8,8,0,1,0,11.84-10.76Zm47.33,75.84,41.67,45.85a32,32,0,0,1-41.67-45.85ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.16,133.16,0,0,1,25,128c4.69-8.79,19.66-33.39,47.35-49.38l18,19.75a48,48,0,0,0,63.66,70l14.73,16.2A112,112,0,0,1,128,192Zm6-95.43a8,8,0,0,1,3-15.72,48.16,48.16,0,0,1,38.77,42.64,8,8,0,0,1-7.22,8.71,6.39,6.39,0,0,1-.75,0,8,8,0,0,1-8-7.26A32.09,32.09,0,0,0,134,96.57Zm113.28,34.69c-.42.94-10.55,23.37-33.36,43.8a8,8,0,1,1-10.67-11.92A132.77,132.77,0,0,0,231.05,128a133.15,133.15,0,0,0-23.12-30.77C185.67,75.19,158.78,64,128,64a118.37,118.37,0,0,0-19.36,1.57A8,8,0,1,1,106,49.79,134,134,0,0,1,128,48c34.88,0,66.57,13.26,91.66,38.35,18.83,18.83,27.3,37.62,27.65,38.41A8,8,0,0,1,247.31,131.26Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1001 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.47,133.47,0,0,1,25,128,133.33,133.33,0,0,1,48.07,97.25C70.33,75.19,97.22,64,128,64s57.67,11.19,79.93,33.25A133.46,133.46,0,0,1,231.05,128C223.84,141.46,192.43,192,128,192Zm0-112a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z"/></svg>
|
||||
|
After Width: | Height: | Size: 702 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216ZM168,136H88a8,8,0,0,0,0,16h80a8,8,0,0,0,0-16Zm0,32H88a8,8,0,0,0,0,16h80a8,8,0,0,0,0-16Z"/></svg>
|
||||
|
After Width: | Height: | Size: 384 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M230.6,49.53A15.81,15.81,0,0,0,216,40H40A16,16,0,0,0,28.19,66.76l.08.09L96,139.17V216a16,16,0,0,0,24.87,13.32l32-21.34A16,16,0,0,0,160,194.66V139.17l67.74-72.32.08-.09A15.8,15.8,0,0,0,230.6,49.53ZM40,56h0Zm106.18,74.58A8,8,0,0,0,144,136v58.66L112,216V136a8,8,0,0,0-2.16-5.47L40,56H216Z"/></svg>
|
||||
|
After Width: | Height: | Size: 386 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M128,80a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160ZM176,24H80A56.06,56.06,0,0,0,24,80v96a56.06,56.06,0,0,0,56,56h96a56.06,56.06,0,0,0,56-56V80A56.06,56.06,0,0,0,176,24Zm40,152a40,40,0,0,1-40,40H80a40,40,0,0,1-40-40V80A40,40,0,0,1,80,40h96a40,40,0,0,1,40,40ZM192,76a12,12,0,1,1-12-12A12,12,0,0,1,192,76Z"/></svg>
|
||||
|
After Width: | Height: | Size: 447 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M88,64a8,8,0,0,1,8-8H216a8,8,0,0,1,0,16H96A8,8,0,0,1,88,64Zm128,56H96a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Zm0,64H96a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16ZM44,52A12,12,0,1,0,56,64,12,12,0,0,0,44,52Zm0,64a12,12,0,1,0,12,12A12,12,0,0,0,44,116Zm0,64a12,12,0,1,0,12,12A12,12,0,0,0,44,180Z"/></svg>
|
||||
|
After Width: | Height: | Size: 377 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/></svg>
|
||||
|
After Width: | Height: | Size: 254 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="36" height="35" viewBox="0 0 36 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22.3488 15.0762C22.7483 15.0762 23.0857 14.9392 23.3611 14.6654C23.6365 14.3918 23.7742 14.0594 23.7742 13.6681C23.7742 13.2771 23.6343 12.9469 23.3545 12.6773C23.075 12.4078 22.7353 12.273 22.3356 12.273C21.9361 12.273 21.5986 12.41 21.3232 12.6838C21.0479 12.9574 20.9102 13.2898 20.9102 13.6811C20.9102 14.0721 21.0501 14.4023 21.3299 14.6719C21.6094 14.9414 21.9491 15.0762 22.3488 15.0762ZM12.9121 15.0762C13.3116 15.0762 13.649 14.9392 13.9244 14.6654C14.1998 14.3918 14.3375 14.0594 14.3375 13.6681C14.3375 13.2771 14.1976 12.9469 13.9178 12.6773C13.6383 12.4078 13.2986 12.273 12.8989 12.273C12.4994 12.273 12.1619 12.41 11.8865 12.6838C11.6111 12.9574 11.4734 13.2898 11.4734 13.6811C11.4734 14.0721 11.6133 14.4023 11.8931 14.6719C12.1727 14.9414 12.5123 15.0762 12.9121 15.0762ZM17.6326 29.577C15.8909 29.577 14.2524 29.2533 12.7171 28.6059C11.1818 27.9583 9.84618 27.0797 8.71035 25.9699C7.57452 24.8599 6.67711 23.5562 6.01813 22.0588C5.35891 20.5614 5.0293 18.9615 5.0293 17.259C5.0293 15.5544 5.36001 13.9507 6.02144 12.4481C6.68311 10.9454 7.58088 9.63823 8.71476 8.52656C9.84888 7.4149 11.1809 6.53658 12.7109 5.89162C14.2408 5.24643 15.8755 4.92383 17.615 4.92383C19.3567 4.92383 20.9952 5.24751 22.5306 5.89486C24.0659 6.54245 25.4015 7.42112 26.5373 8.53087C27.6731 9.64086 28.5705 10.9446 29.2295 12.442C29.8887 13.9393 30.2184 15.5393 30.2184 17.2418C30.2184 18.9464 29.8876 20.5501 29.2262 22.0527C28.5645 23.5554 27.6668 24.8626 26.5329 25.9742C25.3988 27.0859 24.0667 27.9642 22.5368 28.6092C21.0068 29.2544 19.3721 29.577 17.6326 29.577ZM17.6227 28.6605C20.867 28.6605 23.6212 27.5528 25.8855 25.3374C28.1499 23.1222 29.282 20.4269 29.282 17.2515C29.282 14.0763 28.1502 11.3806 25.8866 9.16445C23.6233 6.94831 20.8694 5.84023 17.6249 5.84023C14.3807 5.84023 11.6264 6.94795 9.36211 9.16337C7.09779 11.3786 5.96562 14.0739 5.96562 17.2493C5.96562 20.4245 7.09742 23.1202 9.36101 25.3363C11.6244 27.5525 14.3783 28.6605 17.6227 28.6605ZM17.6238 23.8988C18.755 23.8988 19.7995 23.6475 20.7574 23.1449C21.715 22.642 22.5105 21.959 23.1438 21.0961C23.2703 20.8802 23.2794 20.6615 23.1709 20.4398C23.0627 20.2182 22.8757 20.1074 22.6099 20.1074H12.6521C12.3804 20.1074 12.1895 20.2182 12.0793 20.4398C11.9691 20.6615 11.9835 20.8765 12.1223 21.0849C12.7423 21.9546 13.5364 22.641 14.5046 23.1441C15.4727 23.6473 16.5125 23.8988 17.6238 23.8988Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1279_3967)">
|
||||
<path d="M12.2344 10.875H6.79688" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.11956 18.8005C4.07177 18.931 4.06515 19.073 4.10058 19.2074C4.13601 19.3418 4.2118 19.4621 4.31772 19.5521C4.42364 19.6421 4.55461 19.6975 4.69297 19.7108C4.83133 19.724 4.97043 19.6945 5.09152 19.6263L19.365 11.4624C19.4712 11.4036 19.5597 11.3175 19.6214 11.2129C19.6831 11.1083 19.7156 10.9891 19.7156 10.8677C19.7156 10.7463 19.6831 10.6271 19.6214 10.5225C19.5597 10.4179 19.4712 10.3317 19.365 10.273L5.09152 2.12945C4.97079 2.06193 4.83234 2.03285 4.69466 2.04609C4.55697 2.05934 4.4266 2.11428 4.32096 2.20357C4.21533 2.29286 4.13945 2.41226 4.10346 2.54582C4.06747 2.67938 4.07309 2.82074 4.11956 2.95102L6.79668 10.8753L4.11956 18.8005Z" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1279_3967">
|
||||
<rect width="21.75" height="21.75" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M239.84,141.66l-92,75A8,8,0,0,1,134.27,210L156,160H80a8,8,0,0,1-5.85-13.45l112-119.81A8,8,0,0,1,200,32V96l40-1.66a8,8,0,0,1,5,14.32l-5.16,32.99ZM68,144H40a8,8,0,0,1-7.59-10.53l24-72A8,8,0,0,1,64,56h44.31a8,8,0,0,1,5.85,13.45L52.83,144H68Z"/></svg>
|
||||
|
After Width: | Height: | Size: 339 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M227.32,73.37,182.63,28.69a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.32,96A16,16,0,0,0,227.32,73.37ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.69,147.32,64l24-24L216,84.69Z"/></svg>
|
||||
|
After Width: | Height: | Size: 354 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M222.37,158.46l-47.11-21.11-.13-.06a16,16,0,0,0-15.17,1.4,8.12,8.12,0,0,0-.75.56L134.87,160c-15.42-7.49-31.34-23.29-38.83-38.51l20.78-24.71c.2-.25.39-.5.57-.77a16,16,0,0,0,1.32-15.06l0-.12L97.54,33.64a16,16,0,0,0-16.62-9.52A56.26,56.26,0,0,0,32,80c0,79.4,64.6,144,144,144a56.26,56.26,0,0,0,55.88-48.92A16,16,0,0,0,222.37,158.46ZM176,208A128.14,128.14,0,0,1,48,80,40.2,40.2,0,0,1,82.87,40a.61.61,0,0,0,0,.12l21,47L83.2,111.86a6.13,6.13,0,0,0-.57.77,16,16,0,0,0-1,15.7c9.06,18.53,27.73,37.06,46.46,46.11a16,16,0,0,0,15.75-1.14,8.44,8.44,0,0,0,.74-.56L168.89,152l47,21.05h.11A40.21,40.21,0,0,1,176,208Z"/></svg>
|
||||
|
After Width: | Height: | Size: 700 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z"/></svg>
|
||||
|
After Width: | Height: | Size: 213 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M12.75 3V6C12.75 6.19891 12.671 6.38968 12.5303 6.53033C12.3897 6.67098 12.1989 6.75 12 6.75C11.8011 6.75 11.6103 6.67098 11.4697 6.53033C11.329 6.38968 11.25 6.19891 11.25 6V3C11.25 2.80109 11.329 2.61032 11.4697 2.46967C11.6103 2.32902 11.8011 2.25 12 2.25C12.1989 2.25 12.3897 2.32902 12.5303 2.46967C12.671 2.61032 12.75 2.80109 12.75 3ZM21 11.25H18C17.8011 11.25 17.6103 11.329 17.4697 11.4697C17.329 11.6103 17.25 11.8011 17.25 12C17.25 12.1989 17.329 12.3897 17.4697 12.5303C17.6103 12.671 17.8011 12.75 18 12.75H21C21.1989 12.75 21.3897 12.671 21.5303 12.5303C21.671 12.3897 21.75 12.1989 21.75 12C21.75 11.8011 21.671 11.6103 21.5303 11.4697C21.3897 11.329 21.1989 11.25 21 11.25ZM16.7728 15.7125C16.631 15.5778 16.4422 15.5038 16.2466 15.5063C16.0511 15.5088 15.8642 15.5876 15.7259 15.7259C15.5876 15.8642 15.5088 16.0511 15.5063 16.2466C15.5038 16.4422 15.5778 16.631 15.7125 16.7728L17.8331 18.8944C17.9739 19.0351 18.1647 19.1142 18.3638 19.1142C18.5628 19.1142 18.7536 19.0351 18.8944 18.8944C19.0351 18.7536 19.1142 18.5628 19.1142 18.3638C19.1142 18.1647 19.0351 17.9739 18.8944 17.8331L16.7728 15.7125ZM12 17.25C11.8011 17.25 11.6103 17.329 11.4697 17.4697C11.329 17.6103 11.25 17.8011 11.25 18V21C11.25 21.1989 11.329 21.3897 11.4697 21.5303C11.6103 21.671 11.8011 21.75 12 21.75C12.1989 21.75 12.3897 21.671 12.5303 21.5303C12.671 21.3897 12.75 21.1989 12.75 21V18C12.75 17.8011 12.671 17.6103 12.5303 17.4697C12.3897 17.329 12.1989 17.25 12 17.25ZM7.22719 15.7125L5.10562 17.8331C4.96489 17.9739 4.88583 18.1647 4.88583 18.3638C4.88583 18.5628 4.96489 18.7536 5.10562 18.8944C5.24636 19.0351 5.43723 19.1142 5.63625 19.1142C5.83527 19.1142 6.02614 19.0351 6.16687 18.8944L8.2875 16.7728C8.42221 16.631 8.49621 16.4422 8.4937 16.2466C8.4912 16.0511 8.4124 15.8642 8.2741 15.7259C8.1358 15.5876 7.94894 15.5088 7.75337 15.5063C7.5578 15.5038 7.36898 15.5778 7.22719 15.7125ZM6.75 12C6.75 11.8011 6.67098 11.6103 6.53033 11.4697C6.38968 11.329 6.19891 11.25 6 11.25H3C2.80109 11.25 2.61032 11.329 2.46967 11.4697C2.32902 11.6103 2.25 11.8011 2.25 12C2.25 12.1989 2.32902 12.3897 2.46967 12.5303C2.61032 12.671 2.80109 12.75 3 12.75H6C6.19891 12.75 6.38968 12.671 6.53033 12.5303C6.67098 12.3897 6.75 12.1989 6.75 12ZM6.16687 5.10562C6.02614 4.96489 5.83527 4.88583 5.63625 4.88583C5.43723 4.88583 5.24636 4.96489 5.10562 5.10562C4.96489 5.24636 4.88583 5.43723 4.88583 5.63625C4.88583 5.83527 4.96489 6.02614 5.10562 6.16687L7.22719 8.2875C7.36898 8.42221 7.5578 8.49621 7.75337 8.4937C7.94894 8.4912 8.1358 8.4124 8.2741 8.2741C8.4124 8.1358 8.4912 7.94894 8.4937 7.75337C8.49621 7.5578 8.42221 7.36898 8.2875 7.22719L6.16687 5.10562Z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M208,32H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32Zm0,176H48V48H208V208Z"/></svg>
|
||||
|
After Width: | Height: | Size: 217 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M236.88,26.19a9,9,0,0,0-9.16-1.57L25.06,103.93a14.22,14.22,0,0,0,2.43,27.21L80,141.45V200a15.92,15.92,0,0,0,10,14.83,15.91,15.91,0,0,0,17.51-3.73l25.32-26.26L173,220a15.88,15.88,0,0,0,10.51,4,16.3,16.3,0,0,0,5-.79,15.85,15.85,0,0,0,10.67-11.63L239.77,35A9,9,0,0,0,236.88,26.19ZM182.42,144.5l-31.61-27.88a8,8,0,0,0-10.55,12L171.66,156,96,232V141.45a8,8,0,0,0-1.49-4.65L41.41,93.18l175.32-67.06Z"/></svg>
|
||||
|
After Width: | Height: | Size: 494 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 392 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M240,136v64a16,16,0,0,1-16,16H32a16,16,0,0,1-16-16V136a16,16,0,0,1,16-16H80a8,8,0,0,1,0,16H32v64H224V136H176a8,8,0,0,1,0-16h48A16,16,0,0,1,240,136ZM85.66,69.66,120,35.31V128a8,8,0,0,0,16,0V35.31l34.34,34.35a8,8,0,0,0,11.32-11.32l-48-48a8,8,0,0,0-11.32,0l-48,48A8,8,0,0,0,85.66,69.66ZM200,168a12,12,0,1,0-12,12A12,12,0,0,0,200,168Z"/></svg>
|
||||
|
After Width: | Height: | Size: 431 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M230.92,212c-15.23-26.33-38.7-45.21-66.09-54.16a72,72,0,1,0-73.66,0C63.78,166.78,40.31,185.66,25.08,212a8,8,0,1,0,13.85,8c18.84-32.56,52.14-52,89.07-52s70.23,19.44,89.07,52a8,8,0,1,0,13.85-8ZM72,96a56,56,0,1,1,56,56A56.06,56.06,0,0,1,72,96Z"/></svg>
|
||||
|
After Width: | Height: | Size: 341 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M244.8,150.4a8,8,0,0,1-11.2-1.6A51.6,51.6,0,0,0,192,128a8,8,0,0,1-7.37-4.89,8,8,0,0,1,0-6.22A8,8,0,0,1,192,112a24,24,0,1,0-23.24-30,8,8,0,1,1-15.5-4A40,40,0,1,1,219,117.51a67.94,67.94,0,0,1,27.43,21.68A8,8,0,0,1,244.8,150.4ZM190.92,212a8,8,0,1,1-13.84,8,57,57,0,0,0-98.16,0,8,8,0,1,1-13.84-8,72.06,72.06,0,0,1,33.74-29.92,48,48,0,1,1,58.36,0A72.06,72.06,0,0,1,190.92,212ZM128,176a32,32,0,1,0-32-32A32,32,0,0,0,128,176ZM72,120a8,8,0,0,0-8-8A24,24,0,1,1,87.24,82a8,8,0,1,0,15.5-4A40,40,0,1,0,37,117.51,67.94,67.94,0,0,0,9.6,139.19a8,8,0,1,0,12.8,9.61A51.6,51.6,0,0,1,64,128,8,8,0,0,0,72,120Z"/></svg>
|
||||
|
After Width: | Height: | Size: 690 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M117.25,157.92a60,60,0,1,0-66.5,0A95.83,95.83,0,0,0,3.53,195.63a8,8,0,1,0,13.4,8.74,80,80,0,0,1,134.14,0,8,8,0,0,0,13.4-8.74A95.83,95.83,0,0,0,117.25,157.92ZM40,108a44,44,0,1,1,44,44A44.05,44.05,0,0,1,40,108Zm210.14,98.7a8,8,0,0,1-11.07-2.33A79.83,79.83,0,0,0,172,168a8,8,0,0,1,0-16,44,44,0,1,0-16.34-84.87,8,8,0,1,1-5.94-14.85,60,60,0,0,1,55.53,105.64,95.83,95.83,0,0,1,47.22,37.71A8,8,0,0,1,250.14,206.7Z"/></svg>
|
||||
|
After Width: | Height: | Size: 507 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm-8-80V80a8,8,0,0,1,16,0v56a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,172Z"/></svg>
|
||||
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 1024 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,7 @@
|
||||
@use './styles/colors';
|
||||
@use './styles/variables';
|
||||
@use './styles/reset';
|
||||
@use './styles/typography';
|
||||
@use './styles/forms';
|
||||
@use './styles/modals';
|
||||
@use './styles/datepicker';
|
||||
@@ -0,0 +1,25 @@
|
||||
$primary: #f36675;
|
||||
$secondary: #007074;
|
||||
$error: #cc2831;
|
||||
$validate: #a2eaa8;
|
||||
$prim-gray: #818181;
|
||||
$sec-gray: #5d5d5d;
|
||||
$thd-gray: #ddd;
|
||||
$prim-text: #000;
|
||||
$body-text: #5f5f5f;
|
||||
$body-bg: #fff;
|
||||
$scrollbar-thumb: #d1d5db;
|
||||
$scrollbar-thumb-hover: #9ca3af;
|
||||
|
||||
:root {
|
||||
--color-primary: #{$primary};
|
||||
--color-secondary: #{$secondary};
|
||||
--color-error: #{$error};
|
||||
--color-validate: #{$validate};
|
||||
--color-prim-gray: #{$prim-gray};
|
||||
--color-sec-gray: #{$sec-gray};
|
||||
--color-thd-gray: #{$thd-gray};
|
||||
--color-prim-text: #{$prim-text};
|
||||
--text-color: #{$body-text};
|
||||
--bg-color: #{$body-bg};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
.date-picker-dropdown {
|
||||
.vpd-main .vpd-input-group {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-container {
|
||||
margin: 0 !important;
|
||||
font-family: iran-yekan;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-content {
|
||||
width: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-header .vpd-date {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-header {
|
||||
background-color: transparent !important;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-header .vpd-year-label {
|
||||
color: #5f5f5f;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-controls button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-clearfix.vpd-week {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-clearfix.vpd-week .vpd-weekday {
|
||||
width: auto;
|
||||
float: none;
|
||||
clear: both;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
.vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-clearfix.vpd-week,
|
||||
.vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-days {
|
||||
padding: 0 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-days .direction-prev .vpd-clearfix {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-days {
|
||||
height: fit-content !important;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-days .vpd-day {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
float: right;
|
||||
line-height: 35px;
|
||||
}
|
||||
|
||||
.vpd-wrapper
|
||||
.vpd-body
|
||||
.vpd-clearfix.vpd-month
|
||||
.vpd-days
|
||||
.vpd-day:not([disabled='true']).vpd-selected
|
||||
.vpd-day-effect,
|
||||
.vpd-wrapper
|
||||
.vpd-body
|
||||
.vpd-clearfix.vpd-month
|
||||
.vpd-days
|
||||
.vpd-day:not([disabled='true']):hover
|
||||
.vpd-day-effect {
|
||||
background-color: #f36675 !important;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-days .vpd-day .vpd-day-effect {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
top: -2px;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-close-addon {
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-month-label {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
20%,
|
||||
80% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
40%,
|
||||
60% {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shake {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.slide-drawer-enter-active,
|
||||
.slide-drawer-leave-active {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-drawer-enter-from,
|
||||
.slide-drawer-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.slide-drawer-enter-to,
|
||||
.slide-drawer-leave-from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
font-family: var(--font-family-primary), sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.dir-rtl {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.scrollbar-thin {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #d1d5db transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
@font-face {
|
||||
font-family: montserrat;
|
||||
src: url('../fonts/montserrat/Montserrat-ExtraLight.ttf') format('truetype');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: montserrat;
|
||||
src: url('../fonts/montserrat/Montserrat-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: aria;
|
||||
src: url('../fonts/aria/AriaWebFaNum-Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: aria;
|
||||
src: url('../fonts/aria/AriaWebFaNum-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: aria;
|
||||
src: url('../fonts/aria/AriaWebFaNum-Medium.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: aria;
|
||||
src: url('../fonts/aria/AriaWebFaNum-SemiBold.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: aria;
|
||||
src: url('../fonts/aria/AriaWeb-Normal.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: iran-yekan;
|
||||
src: url('../fonts/iran-yekan/iranyekanwebregularfanum.woff') format('woff');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: iran-yekan;
|
||||
src: url('../fonts/iran-yekan/iranyekanwebmediumfanum.woff') format('woff');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: iran-yekan;
|
||||
src: url('../fonts/iran-yekan/iranyekanwebboldfanum.woff') format('woff');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: iran-yekan;
|
||||
src: url('../fonts/iran-yekan/iranyekanweblightfanum.woff') format('woff');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
$font-fa: 'aria', 'Tahoma', sans-serif;
|
||||
$font-number-fa: 'iran-yekan', 'aria', sans-serif;
|
||||
$font-en: 'montserrat', 'Tahoma', sans-serif;
|
||||
$font-size-en-xs: 0.625rem;
|
||||
$font-size-2xs: 0.6875rem;
|
||||
$font-size-xxs: 0.75rem;
|
||||
$font-size-label: 0.8125rem;
|
||||
$font-size-input: 0.9375rem;
|
||||
$font-size-xxl: 2.0625rem;
|
||||
$input-height: 2.5rem;
|
||||
$input-height-lg: 3.125rem;
|
||||
$icon-xs: 1.0625rem;
|
||||
$icon-sm: 1.375rem;
|
||||
$icon-lg: 1.875rem;
|
||||
$icon-xl: 2.125rem;
|
||||
$icon-2xl: 3.375rem;
|
||||
|
||||
:root {
|
||||
--font-family-primary: 'aria';
|
||||
--font-family-fa: #{$font-fa};
|
||||
--font-family-number-fa: #{$font-number-fa};
|
||||
--font-family-en: #{$font-en};
|
||||
--font-size-en-xs: #{$font-size-en-xs};
|
||||
--font-size-2xs: #{$font-size-2xs};
|
||||
--font-size-xxs: #{$font-size-xxs};
|
||||
--font-size-label: #{$font-size-label};
|
||||
--font-size-input: #{$font-size-input};
|
||||
--font-size-xxl: #{$font-size-xxl};
|
||||
--input-height: #{$input-height};
|
||||
--input-height-lg: #{$input-height-lg};
|
||||
--icon-xs: #{$icon-xs};
|
||||
--icon-sm: #{$icon-sm};
|
||||
--icon-lg: #{$icon-lg};
|
||||
--icon-xl: #{$icon-xl};
|
||||
--icon-2xl: #{$icon-2xl};
|
||||
}
|
||||
@@ -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>
|
||||