first commit

This commit is contained in:
Baghi330 2026-02-12 11:24:27 +03:30
commit cc647ffaba
104 changed files with 26810 additions and 0 deletions

7
.env Executable file
View File

@ -0,0 +1,7 @@
NUXT_PUBLIC_API_NAME=api/
IS_DEVLOP_MODE=1
NUXT_PUBLIC_SYSTEM=monir
NUXT_PUBLIC_BASE_URL=http://192.168.23.60/
NUXT_PUBLIC_BASE_URL2=https://hamfahmi.ir/

9
.env.db Executable file
View File

@ -0,0 +1,9 @@
# Active System
NUXT_PUBLIC_SYSTEM=monir
IS_DEVLOP_MODE=0
# (اختیاری اگر بعداً خواستی)
NUXT_PUBLIC_APP_NAME=Monir System
NUXT_PUBLIC_BASE_URL=https://asr.hamfahmi.ir/
# NUXT_PUBLIC_BASE_URL2=https://hamfahmi.ir/

9
.env.monir Executable file
View File

@ -0,0 +1,9 @@
# Active System
NUXT_PUBLIC_SYSTEM=monir
IS_DEVLOP_MODE=1
# (اختیاری اگر بعداً خواستی)
NUXT_PUBLIC_APP_NAME=Monir Hamfahmi
NUXT_PUBLIC_BASE_URL=http://192.168.23.60/
# NUXT_PUBLIC_BASE_URL2=https://hamfahmi.ir/

5
.env.tavasi Executable file
View File

@ -0,0 +1,5 @@
# Active System
NUXT_PUBLIC_SYSTEM=majles
# (اختیاری)
NUXT_PUBLIC_APP_NAME=Majles System

26
.gitignore vendored Executable file
View File

@ -0,0 +1,26 @@
# Nuxt dev/build outputs
.output
.data
.vscode
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
node_modules*
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
# .env
# .env.*
# !.env.example

248
README.md Executable file
View File

@ -0,0 +1,248 @@
# serve in windows
## install nvm , node
install nvm in windows :
- downlowd nvm-setup.exe : https://github.com/coreybutler/nvm-windows/releases
- install nvm-setup.exe
install node 24.0.0 :
nvm install 24.0.0
nvm use 24.0.0
## install node_modules
serve
```
git clone --recurse-submodules https://git2.tavasi.ir/nuxt_v4/front2.git
cd front2
npm install
npm run dev-monir
```
# build to ubuntu server
#build in local windows :
- ?? r&d ?? : #NUXT_PUBLIC_BASE_URL=http://192.168.23.160/
- npm run build-monir
-- r&d : زمان بیلد بسیار زیاد چرا ؟
sudo apt update
sudo apt install -y curl git
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm --version
nvm install 24.0.0
nvm use --lts
nvm alias default lts/*
npm install -g pm2
PORT=3000 pm2 start .output/server/index.mjs --name "nuxt-app"
pm2 save
pm2 startup
http://192.168.23.103:3000/dashboard/base
install nvm in ubuntu :
- ????
install node 24.0.0 :
- ???
run server :
1. npm install -g pm2
2. pm2 start .output/server/index.mjs --name "nuxt-app"
2. port : PORT=3003 pm2 start .output/server/index.mjs --name "nuxt-app"
3. pm2 save
4. pm2 startup
restart :
pm2 restart "???-app"
####
PORT=3000 HOST=0.0.0.0 pm2 start .output/server/index.mjs --name test-nuxt
pm2 list
pm2 delete test-nuxt
می‌تونم یک **ردمی کامل برای نصب، آپدیت و اجرای Nuxt روی اوبونتو** بر اساس توضیحات شما آماده کنم. این شامل نصب NVM، Node.js، PM2 و راه‌اندازی سرور است:
---
# راهنمای نصب و اجرا روی Ubuntu
### ۱. به‌روزرسانی سیستم و نصب پیش‌نیازها
```bash
sudo apt update
sudo apt install -y curl git build-essential
```
---
### ۲. نصب NVM (Node Version Manager)
```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm --version
```
> اگر بعد از `source ~/.bashrc` دستور `nvm` را نشناخت، ترمینال را ببندید و دوباره باز کنید.
---
### ۳. نصب Node.js 24 و انتخاب آن به عنوان پیش‌فرض
```bash
nvm install 24
nvm use 24
nvm alias default 24
node -v
npm -v
```
---
### ۴. نصب PM2 برای مدیریت فرآیند‌ها
```bash
npm install -g pm2
pm2 -v
```
---
### ۵. کلون پروژه Nuxt و نصب وابستگی‌ها
```bash
git clone --recurse-submodules https://git2.tavasi.ir/nuxt_v4/base_ui.git
cd base_ui
npm install
```
---
### ۶. اجرای محیط توسعه (Development)
```bash
npm run dev-monir
```
> سرور روی `http://localhost:3000` یا IP ماشین شما قابل دسترسی است.
---
### ۷. ساخت برنامه برای تولید (Build)
```bash
# اگر نیاز به تغییر متغیر محیطی دارید:
export NUXT_PUBLIC_BASE_URL=http://192.168.23.160/
npm run build-monir
```
> ⚠️ زمان بیلد طولانی می‌تواند به دلیل حجم پروژه یا سیستم باشد. برای کاهش زمان، از `pnpm` یا استفاده از Docker cache هم می‌توان کمک گرفت.
---
### ۸. اجرای برنامه روی سرور با PM2 (Production)
```bash
# نصب اگر قبلا انجام نشده:
npm install -g pm2
# اجرای برنامه با پورت مشخص
PORT=3000 HOST=0.0.0.0 pm2 start .output/server/index.mjs --name "nuxt-app"
# ذخیره وضعیت pm2 برای اجرای خودکار پس از ریبوت
pm2 save
pm2 startup
```
> بعد از اجرای `pm2 startup`، دستور نمایش داده شده را کپی و اجرا کنید تا PM2 روی بوت اوبونتو فعال شود.
---
### ۹. دسترسی
به مرورگر بروید و آدرس زیر را باز کنید:
```
http://<IP-Server>:3000/dashboard/base
```
---
💡 **نکات تکمیلی:**
* برای بروزرسانی Node.js:
```bash
nvm install 24 --reinstall-packages-from=24
nvm use 24
```
* اگر می‌خواهید تغییرات در کد بدون ریستارت دستی سرور اعمال شود، از `pm2 reload nuxt-app` استفاده کنید.
٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫ جایگذینی ورژن جدید ٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫٫
1.
npm run build {buid name project}
2.
نصب نرم افزار winscp و لاگین کردن در سرور و جایگذینی فایل output جدید
3.
با دستورات زیر در ترمینال جایگذاری انجام میگردد
ssh sabr@192.168.23.103
sabr@frant:~$ pm2 restart nuxt-app
# نکاتی برای استفاده از npm mirror
### سایتها
https://parswebserver.com/mirror-storages-for-pip/
https://mirror-npm.runflare.com/
https://mirror-pypi.runflare.com/
https://archive.ito.gov.ir/npm/
npm install --registry https://registry.npmmirror.com express
npm config set registry "https://archive.ito.gov.ir/npm/"
>> npm config list
; "project" config from H:\my_mindmap\my-nuxt-project\.npmrc
@baghi330:registry = "https://npm.pkg.github.com/"
//npm.pkg.github.com/:_authToken = (protected)
; node bin location = C:\Program Files\nodejs\node.exe
; node version = v22.13.1
; npm local prefix = H:\my_mindmap\my-nuxt-project
; npm version = 11.3.0
; cwd = H:\my_mindmap\my-nuxt-project
; HOME = C:\Users\user
; Run `npm config ls -l` to show all defaults.

20
app/app.config.ts Executable file
View File

@ -0,0 +1,20 @@
// app.config.ts
export default defineAppConfig({
ui: {
colors: {
primary: "primary",
secondary: "purple",
neutral: "zinc",
},
button: {
slots: {
base: ["cursor-pointer"],
},
defaultVariants: {
color: "primary",
variant: "solid",
size: "lg", // ← همه دکمه‌ها پیش‌فرض lg میشن
},
},
},
});

40
app/app.vue Executable file
View File

@ -0,0 +1,40 @@
<!-- app.vue -->
<template>
<UApp>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UApp>
<ConfirmModal />
</template>
<script setup lang="ts">
import { useHead } from "#imports";
import { onMounted } from "vue";
import { composSystemTheme } from "~/composables/composSystemTheme";
// تنظیم تم سیستم
useHead({
script: [
{
innerHTML: `
(function() {
var mode = localStorage.getItem('theme-mode');
if (mode === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
})();
`,
type: "text/javascript",
tagPriority: -1,
},
],
});
const { applyTheme } = composSystemTheme();
onMounted(() => {
applyTheme();
});
</script>
<style></style>

75
app/assets/css/main.css Executable file
View File

@ -0,0 +1,75 @@
/* app/assets/css/main.css */
@import "tailwindcss";
@import "@nuxt/ui";
@theme static {
/* light-colors */
--color-light-primary: #fff;
--color-text__orange: #e86c6b;
--color-link-color: #2563eb;
/* dark-colors */
--color-dark-primary: #111827;
--color-dark-primary-800: #1f2937;
--color-dark-primary-700: #374151;
--color-dark-primary-600: #4b5563;
--color-dark-primary-500: #6b7280;
--color-dark-primary-400: #9ca3af;
--color-dark-primary-300: #d1d5db;
--color-dark-primary-200: #e5e7eb;
--color-dark-primary-100: #f3f4f6;
--color-dark-primary-50: #f9fafb;
/* رنگ متن معمولی */
--color-white-normal: #ffffff; /* متن سفید معمولی */
--color-black-normal: #111827; /* متن مشکی معمولی */
/* متن سفید */
--color-white-bold: #ffffff; /* متن پررنگ سفید */
--color-white-light: rgba(255, 255, 255, 0.6); /* متن کم‌رنگ سفید */
--color-white-strong: #ffffff; /* متن بولد سفید */
/* متن خاکستری / دارک */
--color-dark-normal: #e5e7eb; /* متن خاکستری روشن */
--color-dark-bold: #111827; /* متن روشن‌تر و بولد */
--color-dark-light: #6b7280; /* متن کم‌رنگ خاکستری */
--color-dark-strong: #ffffff; /* متن بولد خاکستری روشن */
/* متن غیر فعال */
--color-text-disabled: #9ca3af;
/* رنگ های دلخواه دیگر */
--color-text-orange: #e86c6b;
--color-link-color: #2563eb;
}
/* تعریف رنگ دلخواه */
/* primary-colors */
/* --color-primary: #00b6e3; */
/* --color-primary-50: #e0f6fb;
--color-primary-100: #b3e9f7;
--color-primary-200: #80d8f3;
--color-primary-300: #4dc6ef;
--color-primary-400: #1ab4eb;
--color-primary-500: #00b6e3;
--color-primary-600: #0097b0;
--color-primary-700: #007580;
--color-primary-800: #005250;
--color-primary-900: #002f30;
--color-primary-950: #001519; */
* {
font-family: var(--app-font), sans-serif !important;
}
html,
body,
#__nuxt,
.root {
font-family: var(--app-font), sans-serif !important;
direction: rtl;
text-align: right;
}
button,a {
cursor: pointer;
}

37
app/assets/majles/theme.json Executable file
View File

@ -0,0 +1,37 @@
{
"name": "MajlesSystem",
"title": "قانون یار",
"subTitle": "مرجع رسمی قوانین و مقررات کشور",
"logo": {
"light": "/logo/majles/light_logo.png",
"dark": "/logo/majles/dark_logo.png"
},
"font": "sahel",
"fontFiles": [
{
"weight": "normal",
"style": "normal",
"src": "/fonts/sahel/Sahel-SemiBold.woff2"
},
{
"weight": "bold",
"style": "normal",
"src": "/fonts/sahel/Sahel-Bold.woff2"
}
],
"colors": {
"primary": {
"50": "#e6fafa",
"100": "#c8f5f5",
"200": "#a6f0f0",
"300": "#80ebeb",
"400": "#57e6e6",
"500": "#00baba",
"600": "#009b9b",
"700": "#007b7b",
"800": "#005c5c",
"900": "#003e3e",
"950": "#002020"
}
}
}

BIN
app/assets/monir/logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

42
app/assets/monir/theme.json Executable file
View File

@ -0,0 +1,42 @@
{
"name": "MonirSystem",
"title": "هم فهمی",
"subTitle": "زیست بوم پژوهشگران",
"logo": {
"light": "/logo/monir/logo.png",
"dark": "/logo/monir/logo.png"
},
"font": "Vazirmatn",
"fontFiles": [
{
"weight": "normal",
"style": "normal",
"src": "/fonts/vazir/Vazirmatn-Regular.woff2"
},
{
"weight": "bold",
"style": "normal",
"src": "/fonts/vazir/Vazirmatn-Bold.woff2"
},
{
"weight": "500",
"style": "normal",
"src": "/fonts/vazir/Vazirmatn-Medium.woff2"
}
],
"colors": {
"primary": {
"50": "#e6f8fd",
"100": "#cceffd",
"200": "#99e0fa",
"300": "#66d1f7",
"400": "#33c2f0",
"500": "#00b6e3",
"600": "#0092b8",
"700": "#006f8d",
"800": "#004c62",
"900": "#002937",
"950": "#00151d"
}
}
}

View File

@ -0,0 +1,207 @@
<template>
<UModal
v-model:open="isModalOpen"
:title="localModalSchema.title || ''"
:description="localModalSchema.description || ''"
:ui="modalUi"
:overlay="localModalSchema.overlay !== false"
:dismissible="localModalSchema.dismissible !== false"
:scrollable="localModalSchema.scrollable || false"
:fullscreen="localModalSchema.fullscreen || false"
>
<!-- Body -->
<template #body>
<slot name="modal-body-content" />
</template>
<!-- Footer -->
<template v-if="hasFooter" #footer>
<div class="flex w-full flex-wrap items-center justify-between gap-3">
<!-- right actions -->
<div class="flex flex-wrap gap-2">
<UButton
v-for="action in rightActions"
:key="action.key"
v-bind="resolveActionProps(action)"
@click="onActionClick(action)"
size="lg"
class="dark:text-white"
/>
</div>
<!-- left actions -->
<div class="flex flex-wrap items-center gap-2">
<UButton
v-for="action in leftActions"
:key="action.key"
v-bind="resolveActionProps(action)"
@click="onActionClick(action)"
size="lg"
class="dark:text-white"
/>
</div>
</div>
<div v-if="footerText" class="mt-2 text-sm text-gray-600">
{{ footerText }}
</div>
</template>
</UModal>
</template>
<script setup>
import { ref, computed, watch } from "vue";
/* ---------------- props & emits ---------------- */
const props = defineProps({
modalSchema: {
type: Object,
default: () => ({
title: "مدال",
description: "",
size: "lg",
footerDescription: "",
actions: {
left: [
{
key: "close",
label: "بستن",
variant: "outline",
closeOnClick: true,
},
{
key: "save",
label: "ذخیره",
color: "primary",
closeOnClick: false,
},
],
},
}),
},
isOpen: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["modal-action", "update-isOpen", "modal-close"]);
/* ---------------- state ---------------- */
const isModalOpen = ref(props.isOpen);
const footerText = ref("");
/* ---------------- computed ---------------- */
const localModalSchema = computed(() => props.modalSchema || {});
const actions = computed(() => localModalSchema.value.actions || {});
const leftActions = computed(() => {
const actionsList = actions.value.left || [];
return actionsList.filter((action) => action && action.key); // فیلتر کردن actions خالی
});
const rightActions = computed(() => {
const actionsList = actions.value.right || [];
return actionsList.filter((action) => action && action.key); // فیلتر کردن actions خالی
});
const hasFooter = computed(
() => leftActions.value.length > 0 || rightActions.value.length > 0,
);
/* -------- modal width by size -------- */
const modalWidthMap = {
sm: "max-w-[400px]",
md: "max-w-[600px]",
lg: "max-w-[900px]",
xl: "max-w-[1200px]",
};
const modalUi = computed(() => {
const size = localModalSchema.value.size || "md";
const widthClass = modalWidthMap[size] || modalWidthMap.md;
return {
...localModalSchema.value.ui,
content: [widthClass, localModalSchema.value.ui?.content]
.filter(Boolean)
.join(" "),
header: [
localModalSchema.value.ui?.header,
"bg-gray-50 dark:bg-dark-primary",
]
.filter(Boolean)
.join(" "),
footer: [
localModalSchema.value.ui?.footer,
"bg-gray-50 dark:bg-dark-primary",
]
.filter(Boolean)
.join(" "),
};
});
/* ---------------- watchers ---------------- */
/* sync parent → modal */
watch(
() => props.isOpen,
(val) => {
isModalOpen.value = val;
},
{ immediate: true },
);
/* sync modal → parent (❌ / ESC / overlay) */
watch(isModalOpen, (newVal) => {
emit("update-isOpen", newVal);
// اگر مودال بسته شد، event مخصوص close هم emit کن
if (newVal === false) {
emit("modal-close");
}
});
// /* وقتی overlay یا ESC بزند */
// function onOverlayClick() {
// emit("modal-action", {
// action: "overlay-close",
// });
// }
/* footer description */
watch(
() => localModalSchema.value.footerDescription,
(val) => {
footerText.value = val || "";
},
{ immediate: true },
);
/* ---------------- methods ---------------- */
function onActionClick(action) {
// همیشه event را emit کن (حتی برای close)
emit("modal-action", action.key);
// اگر دکمه باید مودال را ببندد
if (action.closeOnClick) {
isModalOpen.value = false;
}
}
function resolveActionProps(action) {
return {
label: action.label,
icon: action.icon,
color: action.color || "gray",
variant: action.variant || "solid",
size: action.size || "sm",
loading: action.loading || false,
disabled: action.disabled || false,
};
}
</script>

View File

@ -0,0 +1,521 @@
<template>
<div v-if="visible" class="block-menu" :style="menuStyle" @click.stop>
<!-- هدر بلوک -->
<div class="block-header">
<div class="block-type">
<span class="block-icon">{{ blockIcon }}</span>
<span class="block-name">{{ blockTypeName }}</span>
</div>
<button class="close-btn" @click="closeMenu">×</button>
</div>
<!-- تنظیمات بلوک -->
<div class="menu-section">
<div class="section-title">تنظیمات بلوک</div>
<!-- رنگ بلوک -->
<div class="block-color-picker">
<div class="color-label">رنگ:</div>
<div class="color-grid">
<button
v-for="color in blockColors"
:key="color.value"
class="color-option"
:class="{ selected: blockColor === color.value }"
:style="{ backgroundColor: color.value }"
@click="handleAction('color', color.value)"
:title="color.name"
></button>
</div>
</div>
<!-- ترازبندی -->
<div class="block-alignment">
<div class="alignment-label">تراز:</div>
<div class="alignment-buttons">
<button
v-for="align in alignments"
:key="align.value"
class="align-btn"
:class="{ selected: blockAlign === align.value }"
@click="handleAction('align', align.value)"
:title="align.name"
>
{{ align.icon }}
</button>
</div>
</div>
</div>
<!-- تبدیل نوع بلوک -->
<div class="menu-section">
<div class="section-title">تبدیل به</div>
<div class="block-types-grid">
<button
v-for="type in availableBlockTypes"
:key="type.id"
class="type-btn"
:class="{ current: blockType === type.id }"
@click="handleAction('convert', type.id)"
>
<span class="type-icon">{{ type.icon }}</span>
<span class="type-name">{{ type.name }}</span>
</button>
</div>
</div>
<!-- عملیات بلوک -->
<div class="menu-section">
<div class="section-title">عملیات</div>
<button class="menu-item" @click="handleAction('duplicate')">
<span class="item-icon">📋</span>
تکثیر بلوک
<span class="hint">ایجاد کپی</span>
</button>
<button class="menu-item" @click="handleAction('comment')">
<span class="item-icon">💬</span>
افزودن نظر
<span class="hint">کامنت</span>
</button>
<button class="menu-item" @click="handleAction('move-up')">
<span class="item-icon"></span>
انتقال به بالا
</button>
<button class="menu-item" @click="handleAction('move-down')">
<span class="item-icon"></span>
انتقال به پایین
</button>
<button
class="menu-item text-danger"
@click="handleAction('delete-block')"
>
<span class="item-icon">🗑</span>
حذف بلوک
<span class="hint danger">برای همیشه</span>
</button>
</div>
<!-- AI مخصوص بلوک -->
<div class="menu-section">
<div class="section-title">AI برای این بلوک</div>
<button class="menu-item ai-item" @click="handleAction('ai-rewrite')">
<span class="item-icon"></span>
بازنویسی هوشمند
<span class="ai-badge">AI</span>
</button>
<button class="menu-item ai-item" @click="handleAction('ai-expand')">
<span class="item-icon">🔍</span>
گسترش محتوا
<span class="ai-badge">AI</span>
</button>
<button class="menu-item ai-item" @click="handleAction('ai-simplify')">
<span class="item-icon">📖</span>
سادهسازی
<span class="ai-badge">AI</span>
</button>
</div>
<!-- تنظیمات پیشرفته -->
<div class="menu-section">
<button class="menu-item" @click="handleAction('settings')">
<span class="item-icon"></span>
تنظیمات پیشرفته
</button>
<button class="menu-item" @click="handleAction('export')">
<span class="item-icon">📤</span>
خروجی گرفتن
</button>
<button class="menu-item" @click="handleAction('history')">
<span class="item-icon">🕒</span>
مشاهده تاریخچه
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
const props = defineProps({
visible: Boolean,
position: {
type: Object,
default: () => ({ x: 0, y: 0 }),
},
blockType: {
type: String,
default: "paragraph",
},
blockData: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(["close", "action"]);
const menuStyle = computed(() => ({
top: `${props.position.y}px`,
left: `${props.position.x}px`,
display: props.visible ? "block" : "none",
}));
// دادههای بلوک
const blockColor = ref(props.blockData.color || "#ffffff");
const blockAlign = ref(props.blockData.align || "right");
// آیکون و نام بلوک
const blockIcon = computed(() => {
const icons = {
paragraph: "📝",
heading1: "H1",
heading2: "H2",
heading3: "H3",
todo: "✓",
bullet: "•",
number: "1.",
code: "{ }",
quote: '"',
image: "🖼️",
file: "📎",
table: "📊",
callout: "💡",
};
return icons[props.blockType] || "📝";
});
const blockTypeName = computed(() => {
const names = {
paragraph: "متن",
heading1: "عنوان ۱",
heading2: "عنوان ۲",
heading3: "عنوان ۳",
todo: "لیست کار",
bullet: "لیست نقطه‌ای",
number: "لیست شماره‌ای",
code: "کد",
quote: "نقل قول",
image: "تصویر",
file: "فایل",
table: "جدول",
callout: "کالاوت",
};
return names[props.blockType] || "بلوک";
});
// رنگهای بلوک
const blockColors = [
{ name: "پیش‌فرض", value: "#ffffff" },
{ name: "آبی روشن", value: "#dbeafe" },
{ name: "سبز روشن", value: "#dcfce7" },
{ name: "زرد روشن", value: "#fef3c7" },
{ name: "صورتی روشن", value: "#fce7f3" },
{ name: "بنفش روشن", value: "#f3e8ff" },
{ name: "خاکستری", value: "#f3f4f6" },
];
// ترازبندی
const alignments = [
{ name: "راست", value: "right", icon: "←" },
{ name: "وسط", value: "center", icon: "↔" },
{ name: "چپ", value: "left", icon: "→" },
];
// انواع بلوکهای قابل تبدیل
const availableBlockTypes = [
{ id: "paragraph", name: "متن", icon: "📝" },
{ id: "heading1", name: "عنوان ۱", icon: "H1" },
{ id: "heading2", name: "عنوان ۲", icon: "H2" },
{ id: "heading3", name: "عنوان ۳", icon: "H3" },
{ id: "todo", name: "لیست کار", icon: "✓" },
{ id: "bullet", name: "لیست نقطه‌ای", icon: "•" },
{ id: "number", name: "لیست شماره‌ای", icon: "1." },
{ id: "code", name: "کد", icon: "{ }" },
{ id: "quote", name: "نقل قول", icon: '"' },
{ id: "callout", name: "کالاوت", icon: "💡" },
];
const handleAction = (action, value = null) => {
emit("action", {
action,
value,
blockType: props.blockType,
blockData: props.blockData,
});
};
const closeMenu = () => {
emit("close");
};
// بستن منو با کلیک خارج
const handleClickOutside = (event) => {
if (!event.target.closest(".block-menu")) {
closeMenu();
}
};
onMounted(() => {
document.addEventListener("click", handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
});
</script>
<style scoped>
.block-menu {
position: fixed;
background: white;
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
width: 320px;
max-height: 600px;
overflow-y: auto;
z-index: 10001;
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* هدر بلوک */
.block-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #f3f4f6;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-radius: 12px 12px 0 0;
}
.block-type {
display: flex;
align-items: center;
gap: 12px;
}
.block-icon {
font-size: 24px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.block-name {
font-weight: 600;
color: #1f2937;
font-size: 16px;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #6b7280;
padding: 4px;
border-radius: 4px;
transition: all 0.2s;
}
.close-btn:hover {
background: #f3f4f6;
color: #374151;
}
/* تنظیمات بلوک */
.block-color-picker,
.block-alignment {
padding: 12px 16px;
}
.color-label,
.alignment-label {
font-size: 14px;
color: #6b7280;
margin-bottom: 8px;
display: block;
}
.color-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
}
.color-option {
width: 28px;
height: 28px;
border-radius: 6px;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.color-option:hover {
transform: scale(1.1);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.color-option.selected {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.alignment-buttons {
display: flex;
gap: 8px;
}
.align-btn {
flex: 1;
padding: 8px;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 6px;
cursor: pointer;
font-size: 18px;
transition: all 0.2s;
}
.align-btn:hover {
background: #e5e7eb;
}
.align-btn.selected {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
/* تبدیل نوع بلوک */
.block-types-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding: 0 16px;
}
.type-btn {
padding: 12px 8px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.type-btn:hover {
background: #f3f4f6;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.type-btn.current {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.type-icon {
font-size: 20px;
}
.type-name {
font-size: 12px;
}
/* آیتم‌های منو */
.menu-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px 16px;
border: none;
background: none;
text-align: right;
cursor: pointer;
transition: all 0.2s;
color: #374151;
font-size: 14px;
position: relative;
}
.menu-item:hover {
background-color: #f9fafb;
}
.item-icon {
font-size: 18px;
width: 24px;
text-align: center;
}
.hint {
font-size: 12px;
color: #9ca3af;
margin-right: auto;
}
.hint.danger {
color: #ef4444;
}
.ai-badge {
font-size: 10px;
padding: 2px 6px;
background: linear-gradient(135deg, #8b5cf6, #ec4899);
color: white;
border-radius: 12px;
margin-right: auto;
}
.ai-item {
background: linear-gradient(135deg, #f5f3ff 0%, #fdf2f8 100%);
}
.ai-item:hover {
background: linear-gradient(135deg, #ede9fe 0%, #fce7f3 100%);
}
.menu-item.text-danger {
color: #ef4444;
}
.menu-item.text-danger:hover {
background-color: #fef2f2;
}
/* ریسپانسیو */
@media (max-width: 768px) {
.block-menu {
width: 280px;
}
.block-types-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@ -0,0 +1,75 @@
<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";
const props = defineProps({
breadcrumbData: Array, // مسیرهای اصلی
tabs: {
type: Array,
default: () => [],
},
activeTabId: String, // تب فعال
});
const route = useRoute();
let breadcrumbTabs = props.tabs;
/**
* پیدا کردن مسیر breadcrumb از breadcrumbData
*/
function findBreadcrumbPath(path, items, parentPath = "") {
const result = [];
for (const item of items) {
const fullPath = item.to || parentPath;
if (path.startsWith(fullPath)) {
result.push({
label: item.label,
to: fullPath,
icon: item.icon,
children: item.children,
});
if (item.children) findBreadcrumbPath(item.children, fullPath);
}
}
return result;
}
const breadcrumbItems = computed(() => {
if (route.path === "/dashboard/base") return [];
const crumbs = findBreadcrumbPath(route.path, props.breadcrumbData);
if (breadcrumbTabs.length) {
let tabToShow = breadcrumbTabs[0]; // default tab
if (props.activeTabId) {
const activeTab = breadcrumbTabs.find((t) => t.id === props.activeTabId);
if (activeTab) tabToShow = activeTab;
}
crumbs.push({
label: tabToShow.label,
to: undefined,
icon: tabToShow.icon,
});
}
return crumbs;
});
</script>
<template>
<UBreadcrumb
v-if="breadcrumbItems.length"
:items="breadcrumbItems"
separator-icon="i-lucide-arrow-right"
>
<template #separator="{ ui }">
<span class="mx-2 text-muted">/</span>
</template>
<template #item-label="{ item, active }">
<span :class="active ? 'font-semibold text-primary' : ''">
{{ item.label }}
</span>
</template>
</UBreadcrumb>
</template>

View File

@ -0,0 +1,45 @@
<!-- ~/components/global/GlobalConfirm.vue -->
<template>
<Teleport to="body">
<div
v-if="state.isOpen"
class="fixed inset-0 z-[1001] flex items-center justify-center bg-black/40 pointer-events-auto"
@click="actions.ucoCancelConfirmModal"
>
<div
class="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-sm w-full mx-4 pointer-events-auto"
@click.stop
>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{{ state.title }}
</h3>
<p class="text-gray-600 dark:text-gray-300 mb-6">
{{ state.message }}
</p>
<div class="flex justify-end gap-3">
<button
type="button"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 transition pointer-events-auto"
@click="actions.ucoCancelConfirmModal"
>
انصراف
</button>
<button
type="button"
class="px-4 py-2 text-sm font-medium text-white bg-primary hover:bg-primary-700 rounded-md transition pointer-events-auto"
@click="actions.ucoConfirm"
>
تأیید
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { useConfirmState, useConfirmActions } from "~/composables/useConfirm";
const state = useConfirmState();
const actions = useConfirmActions();
</script>

View File

@ -0,0 +1,263 @@
<template>
<div v-if="visible" class="context-menu" :style="menuStyle" @click.stop>
<!-- بخش AI (مشابه Notion AI) -->
<div class="menu-section ai-section">
<div class="section-title">
<span class="ai-icon"></span>
هوش مصنوعی نوشن
</div>
<button class="menu-item" @click="handleAction('ai-summarize')">
<span class="item-icon">📝</span>
خلاصهسازی
<span class="shortcut">AI</span>
</button>
<button class="menu-item" @click="handleAction('ai-improve')">
<span class="item-icon"></span>
بهبود نوشتار
<span class="shortcut">AI</span>
</button>
<button class="menu-item" @click="handleAction('ai-translate')">
<span class="item-icon">🌍</span>
ترجمه
<span class="shortcut">AI</span>
</button>
<button class="menu-item" @click="handleAction('ai-explain')">
<span class="item-icon">💡</span>
توضیح
<span class="shortcut">AI</span>
</button>
</div>
<!-- بخش قالببندی -->
<div class="menu-section">
<div class="section-title">قالببندی</div>
<button class="menu-item" @click="handleAction('format-bold')">
<span class="item-icon">𝐁</span>
پررنگ
<span class="shortcut">Ctrl+B</span>
</button>
<button class="menu-item" @click="handleAction('format-italic')">
<span class="item-icon">𝐼</span>
کج
<span class="shortcut">Ctrl+I</span>
</button>
<button class="menu-item" @click="handleAction('format-code')">
<span class="item-icon">{ }</span>
کد
<span class="shortcut">Ctrl+E</span>
</button>
<button class="menu-item" @click="handleAction('format-link')">
<span class="item-icon">🔗</span>
لینک
<span class="shortcut">Ctrl+K</span>
</button>
</div>
<!-- بخش عملیات متنی -->
<div class="menu-section">
<div class="section-title">عملیات</div>
<button class="menu-item" @click="handleAction('copy')">
<span class="item-icon">📋</span>
کپی
<span class="shortcut">Ctrl+C</span>
</button>
<button class="menu-item" @click="handleAction('cut')">
<span class="item-icon"></span>
برش
<span class="shortcut">Ctrl+X</span>
</button>
<button class="menu-item" @click="handleAction('paste')">
<span class="item-icon">📝</span>
چسباندن
<span class="shortcut">Ctrl+V</span>
</button>
<button class="menu-item text-danger" @click="handleAction('delete')">
<span class="item-icon">🗑</span>
حذف
<span class="shortcut">Delete</span>
</button>
</div>
<!-- بخش جستجو -->
<div class="menu-section">
<button class="menu-item" @click="handleAction('search-web')">
<span class="item-icon">🔍</span>
جستجو در وب
</button>
<button class="menu-item" @click="handleAction('search-page')">
<span class="item-icon">📄</span>
جستجو در صفحه
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
const props = defineProps({
visible: Boolean,
position: {
type: Object,
default: () => ({ x: 0, y: 0 }),
},
selectedText: String,
});
const emit = defineEmits(["close", "action"]);
const menuStyle = computed(() => ({
top: `${props.position.y}px`,
left: `${props.position.x}px`,
display: props.visible ? "block" : "none",
}));
const handleAction = (action) => {
emit("action", {
action,
text: props.selectedText,
});
emit("close");
};
// بستن منو با کلیک خارج یا Escape
const closeMenu = () => {
if (props.visible) {
emit("close");
}
};
const handleEscape = (event) => {
if (event.key === "Escape") {
closeMenu();
}
};
const handleClickOutside = (event) => {
if (!event.target.closest(".context-menu")) {
closeMenu();
}
};
onMounted(() => {
document.addEventListener("click", handleClickOutside);
document.addEventListener("contextmenu", handleClickOutside);
document.addEventListener("keydown", handleEscape);
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
document.removeEventListener("contextmenu", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
});
</script>
<style scoped>
.context-menu {
position: fixed;
background: white;
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
width: 300px;
max-height: 500px;
overflow-y: auto;
z-index: 10000;
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.menu-section {
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
}
.menu-section:last-child {
border-bottom: none;
}
.ai-section {
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
border-radius: 12px 12px 0 0;
margin: -1px -1px 0 -1px;
}
.section-title {
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 8px;
}
.ai-icon {
color: #8b5cf6;
}
.menu-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 16px;
border: none;
background: none;
text-align: right;
cursor: pointer;
transition: all 0.2s;
color: #374151;
font-size: 14px;
position: relative;
}
.menu-item:hover {
background-color: #f9fafb;
}
.item-icon {
font-size: 18px;
width: 24px;
text-align: center;
opacity: 0.8;
}
.shortcut {
font-size: 12px;
color: #9ca3af;
font-family: monospace;
padding: 2px 6px;
background: #f3f4f6;
border-radius: 4px;
margin-right: auto;
}
.menu-item.text-danger {
color: #ef4444;
}
.menu-item.text-danger:hover {
background-color: #fef2f2;
}
/* ریسپانسیو */
@media (max-width: 768px) {
.context-menu {
width: 280px;
max-height: 400px;
}
}
</style>

View File

@ -0,0 +1,341 @@
<template>
<div :class="gridColumnClass">
<!-- Input Menu -->
<UInputMenu
v-if="selectSchema.selectType === 'input'"
:model-value="resolvedModelValue"
:items="normalizedItems"
:multiple="selectSchema.multiple"
:searchable="selectSchema.searchable"
:creatable="selectSchema.creatable"
:placeholder="selectSchema.placeholder"
:option-attribute="selectSchema.optionAttribute"
:value-attribute="selectSchema.valueAttribute"
:by="selectSchema.compareBy"
v-bind="selectSchema.additionalProps"
@update:model-value="onChange"
@update:open="emitEvent('open', $event)"
@update:search-term="handleSearchTerm"
@create="emitEvent('create', $event)"
:class="[gridColumnClass, 'cursor-pointer w-full']"
size="xl"
dir="rtl"
color="primary"
highlight
:ui="{ trailing: 'hidden' }"
>
<!-- Forward Slots -->
<template
v-for="(_, slotName) in $slots"
:key="slotName"
v-slot:[slotName]="slotProps"
>
<slot :name="slotName" v-bind="slotProps" />
</template>
<template #empty>
<slot name="empty">دادهای پیدا نشد</slot>
</template>
<template #loading>
<slot name="loading">در حال بارگذاری...</slot>
</template>
</UInputMenu>
<!-- Select Menu -->
<USelectMenu
v-if="selectSchema.selectType === 'select'"
:model-value="resolvedModelValue"
:items="normalizedItems"
:multiple="selectSchema.multiple"
:searchable="selectSchema.searchable"
:creatable="selectSchema.creatable"
:loading="isLoading"
:placeholder="selectSchema.placeholder"
:option-attribute="selectSchema.optionAttribute"
:value-attribute="selectSchema.valueAttribute"
:by="selectSchema.compareBy"
v-bind="selectSchema.additionalProps"
@update:model-value="onChange"
@update:open="emitEvent('open', $event)"
@update:search-term="handleSearchTerm"
@create="emitEvent('create', $event)"
:class="[gridColumnClass, 'cursor-pointer']"
:ui="{ wrapper: 'w-full', base: 'w-full' }"
dir="rtl"
color="primary"
size="xl"
highlight
>
<!-- Forward Slots -->
<template
v-for="(_, slotName) in $slots"
:key="slotName"
v-slot:[slotName]="slotProps"
>
<slot :name="slotName" v-bind="slotProps" />
</template>
<template #empty>
<slot name="empty">دادهای پیدا نشد</slot>
</template>
<template #loading>
<slot name="loading">در حال بارگذاری...</slot>
</template>
</USelectMenu>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import { debounce } from "lodash-es";
const props = defineProps({
dropdownSchema: { type: Object, required: true },
});
const emit = defineEmits(["dropdownSelectEvents"]);
const { $http: httpService } = useNuxtApp();
const isLoading = ref(false);
const apiItems = ref([]);
/* ---------------- SCHEMA BASE ---------------- */
const selectSchema = computed(() => {
const i = props.dropdownSchema || {};
return {
fieldId: i.key || i.name || i.id || null,
modelValue: i.modelValue ?? i.value ?? null,
selectType: i.selectType ?? "select",
items: Array.isArray(i.items)
? i.items
: Array.isArray(i.options)
? i.options
: [],
optionAttribute: i.optionAttribute ?? "label",
valueAttribute: i.valueAttribute ?? "value",
multiple:
i.multiple === true || i.multi_select === 1 || i.multi_select === true,
searchable: i.searchable ?? true,
creatable: i.creatable ?? i.allowCreate ?? false,
loading: i.loading ?? false,
placeholder: i.placeholder || "لطفاً انتخاب کنید...",
compareBy: i.compareBy,
additionalProps: i.additionalProps || i.extraProps || {},
columnSpan: resolveColSpan(i),
mode: i.mode || "default",
apiConfig: i.apiConfig || {},
};
});
/* ---------------- GRID ---------------- */
function resolveColSpan(input) {
let span = 12;
if (input.colSpan) span = input.colSpan;
if (input.colClass?.includes("col-")) {
const m = input.colClass.match(/col-(\d+)/);
if (m) span = Number(m[1]);
}
return Math.min(12, Math.max(1, span));
}
const gridColumnClass = computed(
() => `col-span-${selectSchema.value.columnSpan}`
);
/* ---------------- NORMALIZE ITEMS ---------------- */
const normalizedItems = computed(() => {
const { optionAttribute, valueAttribute, mode } = selectSchema.value;
const sourceItems =
mode === "api" ? apiItems.value : selectSchema.value.items;
return sourceItems.map((item) => {
if (typeof item !== "object") {
return {
[optionAttribute]: String(item),
[valueAttribute]: item,
raw: item,
};
}
return { ...item, raw: item };
});
});
/* ---------------- RESOLVED MODEL VALUE ---------------- */
const resolvedModelValue = computed(() => {
const { modelValue, multiple } = selectSchema.value;
if (multiple && Array.isArray(modelValue)) {
return modelValue.map(resolveSingleValue).filter(Boolean);
}
return resolveSingleValue(modelValue);
});
function resolveSingleValue(val) {
if (val === null || val === undefined) return null;
if (typeof val === "object") return val;
let item = normalizedItems.value.find(
(i) => i?.[selectSchema.value.valueAttribute] === val
);
// اگر پیدا نشد و mode API است آیتم موقت اضافه کن
if (!item && selectSchema.value.mode === "api") {
item = {
[selectSchema.value.optionAttribute]: val,
[selectSchema.value.valueAttribute]: val,
raw: val,
__temp: true,
};
apiItems.value.push(item);
}
return item || null;
}
/* ---------------- EMIT HANDLERS ---------------- */
function emitEvent(action, payload) {
emit("dropdownSelectEvents", {
action,
payload,
fieldId: selectSchema.value.fieldId,
timestamp: Date.now(),
});
}
function onChange(val) {
// اضافه کردن به apiItems اگر وجود ندارد
if (selectSchema.value.mode === "api") {
const values = Array.isArray(val) ? val : [val];
values.forEach((v) => {
const valueKey =
typeof v === "object" ? v[selectSchema.value.valueAttribute] : v;
if (
!apiItems.value.find(
(i) => i[selectSchema.value.valueAttribute] === valueKey
)
) {
apiItems.value.push(
typeof v === "object"
? v
: {
[selectSchema.value.optionAttribute]: valueKey,
[selectSchema.value.valueAttribute]: valueKey,
raw: v,
}
);
}
});
}
props.dropdownSchema.modelValue = val;
emitEvent("change", val);
}
watch(
() => normalizedItems.value.length,
(len) => {
if (!len) return;
const { multiple, modelValue, selectType } = selectSchema.value;
if (selectType !== "select") return;
if (
(multiple && Array.isArray(modelValue) && modelValue.length) ||
(!multiple && modelValue != null)
)
return;
onChange(multiple ? [normalizedItems.value[0]] : normalizedItems.value[0]);
},
{ immediate: true }
);
/* ---------------- SEARCH WITH DEBOUNCE ---------------- */
const debouncedFetch = debounce(
async (searchText, url, crud = "GET", payload = "") => {
if (!searchText) {
apiItems.value = [];
isLoading.value = false;
return;
}
const { searchField = "name" } = selectSchema.value.apiConfig;
url = url.replace("{{filter}}", searchText);
payload = payload?.replace("{{filter}}", searchText);
isLoading.value = true;
if (crud === "GET") {
httpService
.getRequest(url)
.then((res) => {
const mapped = res
.filter((item) =>
String(item?.[searchField] || "")
.toLowerCase()
.includes(searchText.toLowerCase())
)
.map((item) => ({
[selectSchema.value.optionAttribute]: item[searchField],
[selectSchema.value.valueAttribute]: item.id ?? item[searchField],
raw: item,
}));
// جایگزینی آیتم موقت با آیتم واقعی
apiItems.value = apiItems.value.map((existing) =>
existing.__temp
? mapped.find(
(m) =>
m[selectSchema.value.valueAttribute] ===
existing[selectSchema.value.valueAttribute]
) || existing
: existing
);
// اضافه کردن آیتمهای جدید
mapped.forEach((m) => {
if (
!apiItems.value.find(
(i) =>
i[selectSchema.value.valueAttribute] ===
m[selectSchema.value.valueAttribute]
)
) {
apiItems.value.push(m);
}
});
})
.catch((error) =>
console.error("Remote search API error:", crud, error)
)
.finally(() => (isLoading.value = false));
} else if (crud === "POST") {
let payload_obj = payload ? JSON.parse(payload) : {};
httpService
.postRequest(url, payload_obj)
.then((res) => {
apiItems.value = res.data;
})
.catch((error) =>
console.error("Remote search API error:", crud, error)
)
.finally(() => (isLoading.value = false));
}
},
300
);
function handleSearchTerm(searchText) {
emitEvent("search", searchText);
const { mode, apiConfig } = selectSchema.value;
if (mode === "api" && apiConfig?.url) {
debouncedFetch(
searchText,
apiConfig.url,
apiConfig.crud,
apiConfig.payload
);
}
}
</script>

View File

@ -0,0 +1,243 @@
<template>
<header
class="bg-gray-100 h-16 dark:bg-dark-primary border-gray-200 dark:border-dark-primary-800 px-6 grid grid-cols-12 items-center sticky top-0 z-40 gap-4"
>
<!-- سمت چپ هدر -->
<div class="col-span-3 lg:col-span-2 xl:col-span-3 hidden lg:block">
<template v-if="headerSchema.breadcrumb">
<Breadcrumb
:breadcrumbData="[]"
:tabs="tabs"
:activeTabId="activeTabModel"
/>
</template>
<template v-if="headerSchema.logo">
<nuxt-link :to="{ name: 'DashboardBasePage' }">
<div class="flex items-center gap-3">
<img :src="useSystemTheme.logo.value" alt="" class="h-9 w-9" />
<div v-if="useSystemTheme.currentTheme.value" class="flex flex-col">
<span class="font-bold text-gray-900 dark:text-light-primary">
{{ useSystemTheme.currentTheme.value.title || "" }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ useSystemTheme.currentTheme.value.subTitle || "" }}
</span>
</div>
</div>
</nuxt-link>
</template>
</div>
<div class="col-span-3 lg:col-span-2 xl:col-span-3 lg:hidden">
<button
@click="toggleSidebarMenu"
class="flex items-center justify-center w-8 h-8 rounded-md text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-dark-primary-800 transition-colors duration-200"
aria-label="باز کردن منو"
>
<UIcon name="i-heroicons-bars-3" class="w-5 h-5" />
</button>
</div>
<!-- وسط هدر -->
<div
class="col-span-6 lg:col-span-8 xl:col-span-6 flex justify-center pt-4"
>
<TabBar
:tabs="tabs"
v-model:active-tab="activeTabModel"
mode="soft"
:width="tabBarWidth"
@tab-change="emit('tab-change', $event)"
/>
</div>
<!-- راست هدر -->
<div
class="col-span-3 lg:col-span-2 xl:col-span-3 flex items-center justify-end gap-3"
>
<!-- Dark mode -->
<UButton
variant="ghost"
size="sm"
color="gray"
:icon="
useSystemTheme.isDark.value ? 'i-heroicons-sun' : 'i-heroicons-moon'
"
@click="useSystemTheme.toggleDarkMode"
/>
<!-- Language -->
<UDropdownMenu :items="headerItems.languages" dir="rtl">
<UButton
variant="ghost"
size="sm"
icon="i-heroicons-language"
label="فارسی"
/>
</UDropdownMenu>
<!-- Notifications -->
<UButton
variant="ghost"
size="sm"
icon="i-heroicons-bell"
:badge="unreadNotifications"
/>
<!-- User menu -->
<UDropdownMenu
v-if="isClient && userAvatar"
:items="userMenuItems"
dir="rtl"
>
<!-- <UAvatar
v-if="userInitial"
:label="userInitial"
size="sm"
class="cursor-pointer"
/> -->
<!-- <span v-if="userInitial" class="text-white text-lg font-bold">{{
userInitial
}}</span>
<UAvatar v-else :src="userAvatar" size="sm" class="cursor-pointer" /> -->
<!-- اگر avatar داشت -->
<UAvatar :src="userAvatar" size="sm" class="cursor-pointer" />
<!-- اگر avatar نداشت -->
<!-- <UAvatar
v-else
:label="userInitial"
size="sm"
class="cursor-pointer bg-primary text-white font-bold"
/> -->
</UDropdownMenu>
<UDropdownMenu v-else-if="isClient" :items="userMenuItems" dir="rtl">
<span class="text-white text-lg font-bold">{{ userInitial }}</span>
</UDropdownMenu>
</div>
</header>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import headerItems from "@/json/header/header.json";
import { composSystemTheme } from "@/composables/composSystemTheme";
import { useCommonStore } from "@/stores/commonStore";
import { useAuthStore } from "@/stores/authStore";
import { useRouter } from "vue-router";
const commonStore = useCommonStore();
const authStore = useAuthStore();
const router = useRouter();
/* ---------------- PROPS ---------------- */
const props = defineProps({
tabs: { type: Array, required: true, default: [] },
activeTab: { type: String, default: "" },
unreadNotifications: { type: Number, default: 0 },
tabBarWidth: { type: String, default: "40em" },
headerSchema: { type: Object, default: {} },
});
const emit = defineEmits([
"update:activeTab",
"tab-change",
"user-menu-select",
]);
/* ---------------- ACTIVE TAB ---------------- */
const activeTabModel = computed({
get: () => props.activeTab,
set: (val) => emit("update:activeTab", val),
});
/* ---------------- THEME ---------------- */
const useSystemTheme = composSystemTheme();
/* ---------------- USER MENU HANDLER ---------------- */
const handleUserMenu = async (action) => {
if (action.key === "logout") {
localStorage.clear();
sessionStorage.clear();
const cookies = document.cookie.split(";");
for (let cookie of cookies) {
const eqPos = cookie.indexOf("=");
const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/";
}
authStore.userReset();
navigateTo("/login");
await router.push("/login");
} else if (action.key === "profile") {
await router.push("/profile");
} else if (action.key === "settings") {
await router.push("/settings");
} else if (action.key === "developer") {
await router.push("/developer");
} else if (action.key === "admin") {
await router.push("/admin");
} else {
emit("user-menu-select", action);
}
};
/* ---------------- USER MENU ITEMS ---------------- */
const userMenuItems = computed(() =>
headerItems.userMenu.map((group) =>
group.map((item) => ({
...item,
onSelect: () => handleUserMenu(item),
})),
),
);
/* ---------------- AVATAR ---------------- */
// const userAvatar = "https://api.dicebear.com/7.x/avataaars/svg?seed=admin";
const isClient = ref(false);
onMounted(() => {
isClient.value = true;
});
const userAvatar = computed(() => {
if (!process.client) return null;
try {
const user = JSON.parse(localStorage.getItem("user") || "{}");
const avatar = user?.user_data?.avatar;
if (!avatar) return null;
const BASE_URL = "https://hamfahmi.ir/";
const url = BASE_URL + `api/media${avatar}`;
// console.log("avatar ==> ", url);
return url;
} catch (e) {
console.error(e);
return null;
}
});
const userInitial = computed(() => {
if (!process.client) return "؟";
try {
const user = JSON.parse(localStorage.getItem("user") || {});
const firstName = user?.user_data?.first_name?.trim() || "";
const lastName = user?.user_data?.last_name?.trim() || "";
const first = firstName[0] || "";
const second = lastName[0] || firstName[1] || "";
return first + second || "؟";
} catch (e) {
return "؟";
}
});
function toggleSidebarMenu() {
commonStore.isSidebarOpen();
}
</script>

View File

@ -0,0 +1,31 @@
<template>
<div class="flex flex-col items-center justify-center h-full py-8">
<div :class="['relative', sizeClass]" role="status" aria-label="در حال بارگذاری">
<!-- ساده و مینیمال اسپینر دایرهای با tail پرایمری (آبی) -->
<div class="w-16 h-16 rounded-full border-8 border-gray-200 dark:border-gray-700 border-t-primary-500 dark:border-t-primary-500 animate-spin"></div>
</div>
<p class="mt-6 text-lg font-medium text-gray-700 dark:text-gray-300">{{ loadingText }}</p>
<p class="mt-2 text-sm text-primary-600 dark:text-primary-400 animate-pulse">لطفاً صبر کنید...</p>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
loadingText: { type: String, default: 'در حال بارگذاری...' },
loadingSize: { type: String, default: 'md' }, // sm, md, lg
})
const sizeClass = computed(() => {
switch (props.loadingSize) {
case 'sm':
return 'scale-75'
case 'lg':
return 'scale-150'
default:
return ''
}
})
</script>

View File

@ -0,0 +1,171 @@
<!-- app/components/Sidebar.vue -->
<template>
<UDashboardSidebar
side="left"
:collapsed="collapsed"
collapsible
:min-size="5"
:default-size="10"
:max-size="15"
:ui="sidebarUI"
class="sidebar-gradient"
dir="rtl"
@update:collapsed="onCollapse"
>
<template #header>
<nuxt-link :to="{ name: 'DashboardBasePage' }">
<div class="flex items-center gap-3">
<img :src="useSystemTheme.logo.value" alt="" class="h-9 w-9" />
<div
v-if="useSystemTheme.currentTheme.value && !collapsed"
class="flex flex-col"
>
<span class="font-bold text-gray-900 dark:text-light-primary">
{{ useSystemTheme.currentTheme.value.title || "" }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ useSystemTheme.currentTheme.value.subTitle || "" }}
</span>
</div>
</div>
</nuxt-link>
</template>
<template #default>
<div class="">
<UNavigationMenu
dir="rtl"
:collapsed="collapsed"
:items="getSideBarSchema()?.topMenu || []"
orientation="vertical"
:ui="navigationUI"
/>
</div>
<div class="mt-auto pb-2 flex flex-col gap-2">
<UNavigationMenu
dir="rtl"
:collapsed="collapsed"
:items="getSideBarSchema()?.bottomMenu || []"
orientation="vertical"
:ui="navigationUI"
/>
</div>
</template>
<template #footer="{ collapsed }">
<div>
<UButton
:avatar="{ src: '' }"
:label="collapsed ? undefined : 'مدیرفنی سامانه'"
color="neutral"
variant="ghost"
class="w-full"
:block="collapsed"
/>
<!-- دکمههای باز/بسته کردن سایدبار -->
<div class="flex justify-center items-center gap-2 mt-2">
<!-- دکمه باز کردن -->
<UButton
v-if="collapsed"
icon="stash-chevron-double-left-solid"
color="neutral"
variant="ghost"
size="sm"
class="transition-all duration-300"
@click="toggleSidebar"
/>
<!-- دکمه بستن -->
<UButton
v-else
icon="stash-chevron-double-right-solid"
color="neutral"
variant="ghost"
size="sm"
class="transition-all duration-300 absolute left-0 -translate-y-1/2"
@click="toggleSidebar"
/>
</div>
</div>
</template>
</UDashboardSidebar>
</template>
<script setup>
import { ref} from "vue";
import { composSystemTheme } from "@/composables/composSystemTheme";
const useSystemTheme = composSystemTheme();
const props = defineProps({
sidebarItems: {
type: Object,
required: true,
default: () => ({
topMenu: [],
bottomMenu: [],
}),
},
});
const emit = defineEmits(["update:collapsed"]);
const collapsed = ref(false);
const onCollapse = (value) => {
collapsed.value = value;
emit("update:collapsed", value);
};
function getSideBarSchema(){
const config = useRuntimeConfig();
const IS_DEVLOP_MODE = config.public.IS_DEVLOP_MODE || 1;
let result = {}
result.topMenu = props.sidebarItems?.topMenu.filter((el) => (!el.develop || el.develop == IS_DEVLOP_MODE) )
result.bottomMenu = props.sidebarItems?.bottomMenu.filter((el) => (!el.develop || el.develop == IS_DEVLOP_MODE) )
// console.log("SideBar IS_DEVLOP_MODE ", IS_DEVLOP_MODE, result);
return result
}
// تابع برای باز/بسته کردن نرم سایدبار
const toggleSidebar = () => {
collapsed.value = !collapsed?.value;
emit("update:collapsed", collapsed?.value);
};
const sidebarUI = {
width: "w-72",
collapsed: { width: "w-16" },
wrapper:
"z-30 h-[calc(100vh-64px)] top-16 rounded-r-2xl border-r border-gray-200/50 dark:border-dark-primary-800/50 backdrop-blur-sm transition-all duration-300",
base: "backdrop-blur-sm transition-all duration-300 ease-out",
header: "border-b border-gray-200/30 dark:border-dark-primary-800/30",
footer: "border-t border-gray-200/30 dark:border-dark-primary-800/30",
};
const navigationUI = {
base: "space-y-1",
wrapper: (c) => (c ? "items-center" : ""),
inactive:
"text-dark-primary-700 dark:text-gray-300 hover:bg-gradient-to-r hover:from-blue-50/50 hover:to-transparent dark:hover:from-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400",
active:
"bg-gradient-to-r from-blue-100 to-blue-50/30 dark:from-blue-900/30 dark:to-blue-900/10 text-blue-600 dark:text-blue-400 font-semibold shadow-sm",
icon: { base: "transition-transform duration-300", active: "scale-110" },
};
</script>
<style scoped>
.sidebar-gradient {
background: linear-gradient(to bottom, #ffffff, #f9fafb);
transition: all 0.3s ease-in-out;
}
.dark .sidebar-gradient {
background: linear-gradient(to bottom, #111827, #1f2937);
}
</style>

View File

@ -0,0 +1,36 @@
<script setup>
defineProps({
items: Array,
command: Function,
});
</script>
<template>
<div class="slash-menu">
<button v-for="item in items" :key="item.title" @click="command(item)">
{{ item.title }}
</button>
</div>
</template>
<style scoped>
.slash-menu {
background: white;
border: 1px solid #eee;
border-radius: 8px;
padding: 4px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
.slash-menu button {
display: block;
width: 100%;
padding: 8px 12px;
text-align: right;
border-radius: 6px;
}
.slash-menu button:hover {
background: #f5f5f5;
}
</style>

View File

@ -0,0 +1,278 @@
<template>
<div class="relative flex items-center" :style="{ width: customWidth }">
<!-- نسخه دسکتاپ / تبلت: همان قبلی -->
<div v-if="!isMobile" class="w-full">
<UButton
v-if="hasOverflow"
icon="i-heroicons-chevron-left"
variant="ghost"
size="xs"
class="absolute -left-10 top-1/2 -translate-y-1/2 z-10 flex-shrink-0 bg-light-primary dark:bg-dark-primary-800 shadow-md border border-gray-200 dark:border-dark-primary-700"
@click="scrollLeft"
/>
<div
ref="tabsContainer"
class="flex overflow-x-auto scrollbar-hide w-full justify-start"
@wheel.prevent="handleWheelScroll"
>
<div class="flex items-center">
<button
v-for="tab in getListTabs()"
:key="tab.id"
@click="handleTabClick(tab)"
:class="getTabClass(activeTab === tab.id)"
:data-active="activeTab === tab.id"
>
<UIcon
v-if="tab.icon"
:name="tab.icon"
class="w-5 h-5 flex-shrink-0"
/>
<span class="text-sm font-medium">{{ tab.label }}</span>
</button>
</div>
</div>
<UButton
v-if="hasOverflow"
icon="i-heroicons-chevron-right"
variant="ghost"
size="xs"
class="absolute -right-10 top-1/2 -translate-y-1/2 z-10 flex-shrink-0 bg-light-primary dark:bg-dark-primary-800 shadow-md border border-gray-200 dark:border-dark-primary-700"
@click="scrollRight"
/>
</div>
<!-- نسخه موبایل -->
<div
v-else
class="fixed inset-x-0 bottom-0 z-50 bg-light-primary dark:bg-dark-primary-800 border-t border-gray-200 dark:border-dark-primary-700 shadow-lg pb-safe"
>
<!-- اگر ۵ یا کمتر justify-around و بدون اسکرول -->
<!-- اگر بیشتر از ۵ اسکرول افقی با flex-nowrap -->
<div
class="flex items-center"
:class="getListTabs().length <= 5 ? '' : 'flex-nowrap'"
>
<button
v-for="tab in getListTabs()"
:key="tab.id"
@click="handleTabClick(tab)"
class="flex flex-col items-center justify-center py-3 transition-colors relative mx-4 "
:class="[
getListTabs().length <= 5
? 'flex-1 min-w-0 min-h-[56px]'
: 'px-4 min-w-[80px] min-h-[56px] flex-shrink-0',
]"
>
<!-- آیکن -->
<UIcon
v-if="tab.icon"
:name="tab.icon"
class="w-6 h-6 mb-1"
:class="
activeTab === tab.id
? 'text-primary-600 dark:text-primary-400'
: 'text-gray-600 dark:text-gray-400'
"
/>
<!-- اگر آیکن نبود، فقط متن با فاصله مناسب -->
<div v-else class="h-7"></div>
<!-- متن -->
<span
class="text-xs font-medium whitespace-nowrap"
:class="
activeTab === tab.id
? 'text-primary-600 dark:text-primary-400'
: 'text-gray-600 dark:text-gray-400'
"
>
{{ tab.label }}
</span>
<!-- اندیکاتور فعال (خط زیر تب) -->
<div
v-if="activeTab === tab.id"
class="absolute bottom-0 w-12 h-1 bg-primary-600 dark:bg-primary-400 rounded-t-full"
/>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick, watch } from "vue";
const props = defineProps({
tabs: { type: Array, default: () => [] },
activeTab: { type: String, default: "" },
width: { type: String, default: "40em" },
mode: {
type: String,
default: "soft",
validator: (v) => ["soft", "chrome"].includes(v),
},
});
const emit = defineEmits(["tab-change", "update:activeTab"]);
const tabsContainer = ref(null);
const hasOverflow = ref(false);
const isMobile = ref(false);
const customWidth =
props.width.includes("px") ||
props.width.includes("em") ||
props.width.includes("%")
? props.width
: `${props.width}px`;
// بروزرسانی وضعیت موبایل (امن برای SSR)
const updateMobileStatus = () => {
if (typeof window === "undefined") return;
isMobile.value = window.innerWidth < 768;
};
function getListTabs(){
const config = useRuntimeConfig();
const IS_DEVLOP_MODE = config.public.IS_DEVLOP_MODE || 1;
let result = {}
result = props.tabs.filter((el) => (!el.develop || el.develop == IS_DEVLOP_MODE) )
// console.log("TabBar IS_DEVLOP_MODE ", IS_DEVLOP_MODE, result);
return result
}
const getTabClass = (isActive) => {
const base =
"flex items-center justify-center gap-2 px-6 py-3 cursor-pointer whitespace-nowrap transition-all duration-200 flex-shrink-0 min-w-[140px] w-[140px]";
if (!isActive) {
return `${base} border-transparent text-dark-primary-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-light-primary hover:bg-gray-50 dark:hover:bg-dark-primary-800`;
}
if (props.mode === "chrome") {
return `${base} py-4 text-gray-900 dark:text-light-primary bg-light-primary dark:bg-dark-primary-800 rounded-t-xl`;
}
return `${base} border-primary-500 text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/20 border-b-2`;
};
const handleTabClick = (tab) => {
emit("update:activeTab", tab.id);
emit("tab-change", tab);
if (!isMobile.value) {
nextTick(scrollToActiveTab);
}
};
// توابع دسکتاپ (همان قبلی)
const checkOverflow = () => {
if (!tabsContainer.value || isMobile.value) {
hasOverflow.value = false;
return;
}
const container = tabsContainer.value;
hasOverflow.value = container.scrollWidth > container.clientWidth + 1;
};
const scrollLeft = () => {
if (!tabsContainer.value) return;
tabsContainer.value.scrollLeft -= 200;
requestAnimationFrame(checkOverflow);
};
const scrollRight = () => {
if (!tabsContainer.value) return;
const container = tabsContainer.value;
const maxScroll = container.scrollWidth - container.clientWidth;
container.scrollLeft = Math.min(maxScroll, container.scrollLeft + 200);
requestAnimationFrame(checkOverflow);
};
const scrollToActiveTab = () => {
if (!tabsContainer.value || isMobile.value) return;
const container = tabsContainer.value;
const activeEl = container.querySelector('[data-active="true"]');
if (!activeEl) return;
const containerRect = container.getBoundingClientRect();
const elRect = activeEl.getBoundingClientRect();
if (elRect.left < containerRect.left) {
container.scrollLeft += elRect.left - containerRect.left - 16;
} else if (elRect.right > containerRect.right) {
container.scrollLeft += elRect.right - containerRect.right + 16;
}
};
const handleWheelScroll = (event) => {
if (!tabsContainer.value || isMobile.value) return;
tabsContainer.value.scrollLeft += event.deltaY;
requestAnimationFrame(checkOverflow);
};
const handleResize = () => {
updateMobileStatus();
nextTick(() => {
checkOverflow();
if (!isMobile.value) scrollToActiveTab();
});
};
watch(
() => props.activeTab,
() => {
if (!isMobile.value) nextTick(scrollToActiveTab);
}
);
watch(
() => props.tabs,
() => {
nextTick(checkOverflow);
}
);
onMounted(() => {
updateMobileStatus();
window.addEventListener("resize", handleResize);
if (tabsContainer.value) {
tabsContainer.value.addEventListener("scroll", checkOverflow);
}
requestAnimationFrame(() => {
checkOverflow();
if (!isMobile.value) scrollToActiveTab();
});
});
onUnmounted(() => {
window.removeEventListener("resize", handleResize);
if (tabsContainer.value) {
tabsContainer.value.removeEventListener("scroll", checkOverflow);
}
});
</script>
<style scoped>
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
scroll-behavior: smooth;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,270 @@
<!-- components/myPagination.vue -->
<template>
<div
class="jahat-pagination flex items-center flex-wrap border-t border-gray-200 p-2 rounded-none mt-2"
:class="pagination.alignment"
>
<!-- نمایش محدوده رکوردها و انتخاب تعداد سطر -->
<div v-if="showTotalRecords" class="flex items-center mb-0 mr-3 text-sm">
<div class="hidden md:block">
<span>{{ recordRange.start }}&nbsp;</span>
<span>-</span>
<span>&nbsp;{{ recordRange.end }}</span>
<span>&nbsp;از&nbsp;</span>
</div>
<span>{{ totalRecords }}</span>
<span>&nbsp;رکورد</span>
<label for="pagination-limit" class="ml-2 mr-4 mb-0 whitespace-nowrap">
<span class="hidden md:block"> تعداد سطرها </span>
<span class="md:hidden"> سطرها </span>
</label>
<select
:id="limitSelectId"
v-model.number="internalLimit"
@change="onLimitChange"
class="min-w-[3.5em] py-1 px-2 border dark:bg-dark-primary-800 border-gray-300 rounded-md bg-white text-sm"
>
<option v-for="opt in limitOptions" :key="opt" :value="opt">
{{ opt }}
</option>
</select>
</div>
<!-- ورودی عددی + UPagination -->
<div v-if="showPageSelection" class="flex items-center gap-2">
<!-- ورودی عددی (همیشه نمایش داده شود) -->
<div v-if="isMobile" class="flex gap-1 justify-center w-full">
<UButton
:disabled="internalPage <= 1"
size="sm"
color="gray"
@click="onPrevClick"
aria-label="صفحه قبلی"
>
<UIcon name="i-lucide-chevron-right" size="xl" class=""></UIcon>
</UButton>
<input
type="number"
dir="ltr"
v-model.number="internalPage"
class="py-1 px-2 border border-gray-300 rounded-md font-bold text-sm w-16 text-center no-spinner"
:id="pageInputId"
placeholder="00"
:min="1"
:max="totalPages"
@keyup="debouncedPageChange"
@keydown="clearDebounce"
aria-label="وارد کردن شماره صفحه"
/>
<!-- نمایش فقط دکمههای قبلی/بعدی در موبایل -->
<UButton
:disabled="internalPage >= totalPages"
size="sm"
color="gray"
@click="onNextClick"
aria-label="صفحه بعدی"
>
<UIcon name="i-lucide-chevron-left" size="xl" class=""></UIcon>
</UButton>
</div>
<!-- نمایش UPagination کامل در دسکتاپ -->
<UPagination
v-else
:page="internalPage"
:total="totalRecords"
:page-count="totalPages"
size="xl"
:max="3"
:sibling-count="1"
:show-edges="true"
@update:page="onPageClick"
:prev-button="{ label: 'قبلی', color: 'gray' }"
:next-button="{ label: 'بعدی', trailing: true, color: 'gray' }"
:active-button="{ variant: 'outline' }"
:inactive-button="{ color: 'gray' }"
/>
</div>
</div>
</template>
<script setup>
import { useMediaQuery } from "@vueuse/core";
import { ref, computed, watch } from "vue";
import { useDebounceFn } from "@vueuse/core";
import { useRoute, useRouter } from "#imports";
const props = defineProps({
/**
* اطلاعات پیجینیشن ورودی
* مثال: { pages: 1000, total: 10000, page: 1, offset: 0, limit: 10 }
*/
paginationInfo: {
type: Object,
required: true,
validator(value) {
return (
typeof value.total === "number" &&
typeof value.page === "number" &&
typeof value.limit === "number"
);
},
},
showTotalRecords: { type: Boolean, default: true },
showPageSelection: { type: Boolean, default: true },
limitOptions: {
type: Array,
default: () => [5, 10, 15, 20, 25, 50, 75, 100],
},
limitSelectId: { type: String, default: "pagination-limit" },
pageInputId: { type: String, default: "pagination-page" },
});
const emit = defineEmits([
"update:paginationInfo",
"pageChanged",
"limitChanged",
]);
// تشخیص موبایل (مثلاً عرض کمتر از 768px)
const isMobile = useMediaQuery("(max-width: 767px)");
const onPrevClick = () => {
const newPage = Math.max(1, internalPage.value - 1);
if (newPage !== internalPage.value) {
internalPage.value = newPage;
onPageClick(newPage);
}
};
const onNextClick = () => {
const newPage = Math.min(totalPages.value, internalPage.value + 1);
if (newPage !== internalPage.value) {
internalPage.value = newPage;
onPageClick(newPage);
}
};
// استخراج route و router برای بهروزرسانی URL
const route = useRoute();
const router = useRouter();
// --- State داخلی ---
const internalPage = ref(props.paginationInfo.page);
const internalLimit = ref(props.paginationInfo.limit);
// --- محاسبات کمکی ---
const totalPages = computed(
() =>
props.paginationInfo.pages ||
Math.ceil(props.paginationInfo.total / internalLimit.value) ||
1
);
const totalRecords = computed(() => props.paginationInfo.total);
const recordRange = computed(() => {
const start = (internalPage.value - 1) * internalLimit.value + 1;
const end = Math.min(
internalPage.value * internalLimit.value,
totalRecords.value
);
return { start, end };
});
const pagination = {
alignment: "justify-between",
};
// --- Debounce برای ورودی عددی ---
const debouncedEmit = useDebounceFn(() => {
const page = Math.max(1, Math.min(internalPage.value, totalPages.value));
internalPage.value = page; // normalize
emitPageChange(page, internalLimit.value);
}, 500);
const debouncedPageChange = () => {
debouncedEmit();
};
const clearDebounce = () => {
debouncedEmit.clear();
};
// --- هندلرهای اصلی ---
const onPageClick = (page) => {
internalPage.value = page;
emitPageChange(page, internalLimit.value);
// بهروزرسانی URL
router.replace({
query: { ...route.query, page: page.toString() },
});
};
const onLimitChange = () => {
internalPage.value = 1; // reset to first page
emitLimitChange(1, internalLimit.value);
// بهروزرسانی URL
router.replace({
query: { ...route.query, page: "1", limit: internalLimit.value.toString() },
});
};
// --- ارسال event و sync با والد ---
const emitPageChange = (page, limit) => {
const newPagination = {
page,
limit,
total: props.paginationInfo.total,
pages: totalPages.value,
offset: (page - 1) * limit,
};
emit("update:paginationInfo", newPagination);
emit("pageChanged", { pageNumber: page, limit });
};
const emitLimitChange = (page, limit) => {
const newPagination = {
page,
limit,
total: props.paginationInfo.total,
pages: Math.ceil(props.paginationInfo.total / limit),
offset: 0,
};
emit("update:paginationInfo", newPagination);
emit("limitChanged", { pageNumber: page, limit });
};
// --- همگامسازی با prop خارجی (در صورت تغییر از خارج) ---
watch(
() => props.paginationInfo,
(newVal) => {
internalPage.value = newVal.page;
internalLimit.value = newVal.limit;
},
{ deep: true }
);
</script>
<style scoped>
.jahat-pagination {
direction: rtl;
}
/* حذف اسپینر در این کامپوننت */
.no-spinner {
-moz-appearance: textfield; /* فایرفاکس */
}
.no-spinner::-webkit-inner-spin-button,
.no-spinner::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.no-spinner {
appearance: textfield;
}
</style>

View File

@ -0,0 +1,9 @@
<template>
<h1>main list</h1>
</template>
<script>
export default {};
</script>
<style></style>

View File

@ -0,0 +1,11 @@
<template>
<h1>RelationEdit</h1>
<TiptapEditor></TiptapEditor>
</template>
<script>
export default {};
</script>
<style></style>

View File

@ -0,0 +1,11 @@
<template>
<h1>RuleEdit</h1>
<TiptapEditor></TiptapEditor>
</template>
<script>
export default {};
</script>
<style></style>

View File

@ -0,0 +1,137 @@
// composables/composSystemTheme.ts
import type { Ref } from "vue";
import { ref, computed, watch, onMounted } from "vue";
import { useState } from "#imports";
type Theme = {
name: string;
title: string;
subTitle: string;
logo: {
light: string;
dark: string;
};
font: string;
fontFiles: Array<{ weight: string; style: string; src: string }>;
colors: {
primary: Record<
| "50"
| "100"
| "200"
| "300"
| "400"
| "500"
| "600"
| "700"
| "800"
| "900"
| "950",
string
>;
};
};
export function composSystemTheme() {
const currentTheme = useState<Theme | null>("system-theme", () => null);
const isDark = useState<boolean>("is-dark", () => false);
const isReady = ref(false);
onMounted(() => {
const saved = localStorage.getItem("theme-mode");
const shouldBeDark = saved === "dark";
if (isDark.value !== shouldBeDark) {
isDark.value = shouldBeDark;
}
isReady.value = true;
});
// فقط هنگامی که کاربر دستی تم را تغییر داد، ذخیره کن
watch(
() => isDark.value,
(val) => {
if (!isReady.value || process.server) return;
localStorage.setItem("theme-mode", val ? "dark" : "light");
const root = document.documentElement;
val ? root.classList.add("dark") : root.classList.remove("dark");
},
{ immediate: false }
);
const toggleDarkMode = () => {
isDark.value = !isDark.value;
};
const applyTheme = async () => {
const system = useRuntimeConfig().public.system as string;
if (!system) {
console.warn("No system specified in runtime config");
return;
}
try {
// 🔹 دقت: مسیر دقیقاً ~/assets/${system}/theme.json
const themeModule = await import(`~/assets/${system}/theme.json`);
const theme = themeModule.default || themeModule;
if (process.client) {
const root = document.documentElement;
// رنگ‌ها
Object.entries(theme.colors.primary).forEach(([key, value]) => {
root.style.setProperty(`--color-primary-${key}`, value);
});
// فونت
if (theme.font && theme.fontFiles) {
root.style.setProperty("--app-font", theme.font);
const fontId = `font-${theme.font}`;
if (!document.getElementById(fontId)) {
const style = document.createElement("style");
style.id = fontId;
let rules = "";
theme.fontFiles.forEach((file) => {
rules += `
@font-face {
font-family: "${theme.font}";
src: url("${file.src}") format("woff2");
font-weight: ${file.weight};
font-style: ${file.style};
font-display: swap;
}
`;
});
style.textContent = rules;
document.head.appendChild(style);
}
}
}
currentTheme.value = theme;
return theme;
} catch (error) {
console.error(`Failed to load theme for system: ${system}`, error);
}
};
const logo = computed(() => {
if (!currentTheme.value) return "";
return isDark.values
? currentTheme.value.logo.dark
: currentTheme.value.logo.light;
});
const primaryColor = computed(() => {
return currentTheme.value?.colors.primary["500"] || "#3b82f6";
});
return {
applyTheme,
currentTheme: currentTheme as Ref<Theme | null>,
isDark,
toggleDarkMode,
logo,
primaryColor,
};
}

45
app/composables/useConfirm.ts Executable file
View File

@ -0,0 +1,45 @@
// ~/composables/confirm.ts
import { reactive, readonly } from "vue";
// یک state مشترک برای تمام کامپوننت‌ها
const confirmState = reactive({
isOpen: false,
title: "تأیید عملیات",
message: "آیا از انجام این عملیات اطمینان دارید؟",
resolve: null as ((value: boolean) => void) | null,
});
export const useConfirmState = () => readonly(confirmState);
export const useConfirm = () => {
const ucoShowConfirmModal = (
options: { title?: string; message?: string } = {}
): Promise<boolean> => {
confirmState.title = options.title || "تأیید عملیات";
confirmState.message =
options.message || "آیا از انجام این عملیات اطمینان دارید؟";
confirmState.isOpen = true;
return new Promise((resolve) => {
confirmState.resolve = resolve;
});
};
return { ucoShowConfirmModal };
};
export const useConfirmActions = () => {
const ucoConfirm = () => {
confirmState.isOpen = false;
if (confirmState.resolve) confirmState.resolve(true);
confirmState.resolve = null;
};
const ucoCancelConfirmModal = () => {
confirmState.isOpen = false;
if (confirmState.resolve) confirmState.resolve(false);
confirmState.resolve = null;
};
return { ucoConfirm, ucoCancelConfirmModal };
};

441
app/json/EditorSchema.json Executable file
View File

@ -0,0 +1,441 @@
{
"toolbarButtons": [
{
"group": "textSettings",
"title": "تنظیمات متن",
"buttons": [
{
"action": "toggleHeading",
"level": 1,
"label": "عنوان ۱",
"title": "عنوان 1",
"class": "text-sm font-semibold"
},
{
"action": "toggleHeading",
"level": 2,
"label": "عنوان ۲",
"title": "عنوان 2",
"class": "text-sm font-semibold"
},
{
"action": "toggleHeading",
"level": 3,
"label": "عنوان ۳",
"title": "عنوان 3",
"class": "text-sm font-semibold"
}
]
},
{
"group": "textFormatting",
"title": "قالب‌بندی متن",
"buttons": [
{
"action": "toggleBold",
"label": "ب",
"title": "پررنگ (Ctrl+B)",
"class": "font-bold text-sm"
},
{
"action": "toggleItalic",
"label": "ک",
"title": "کج (Ctrl+I)",
"class": "italic text-sm"
},
{
"action": "toggleLink",
"label": "🔗",
"title": "لینک (Ctrl+K)",
"class": "text-sm"
}
]
},
{
"group": "lists",
"title": "لیست‌ها",
"buttons": [
{
"action": "toggleBulletList",
"label": "•",
"title": "لیست نقطه‌ای",
"class": "text-lg"
},
{
"action": "toggleOrderedList",
"label": "۱.",
"title": "لیست شماره‌ای",
"class": ""
},
{
"action": "toggleTaskList",
"label": "✓",
"title": "لیست کارها",
"class": "text-sm"
}
]
},
{
"group": "blocks",
"title": "بلوک‌ها",
"buttons": [
{
"action": "toggleCodeBlock",
"label": "{ }",
"title": "بلوک کد",
"class": "text-sm"
},
{
"action": "toggleBlockquote",
"label": "\"",
"title": "نقل قول",
"class": "text-lg"
},
{
"action": "setHorizontalRule",
"label": "―",
"title": "خط جداکننده",
"class": "text-lg"
}
]
},
{
"group": "alignment",
"title": "ترازبندی",
"buttons": [
{
"action": "setTextAlign",
"value": "right",
"label": "→|",
"title": "تراز راست",
"class": "text-sm"
},
{
"action": "setTextAlign",
"value": "center",
"label": "|→|",
"title": "تراز وسط",
"class": "text-sm"
},
{
"action": "setTextAlign",
"value": "left",
"label": "|←",
"title": "تراز چپ",
"class": "text-sm"
}
]
},
{
"group": "operations",
"title": "عملیات",
"buttons": [
{
"action": "undo",
"label": "↶",
"title": "بازگشت (Ctrl+Z)",
"class": "text-lg"
},
{
"action": "redo",
"label": "↷",
"title": "بازگشت به جلو (Ctrl+Shift+Z)",
"class": "text-lg"
}
]
}
],
"contextMenuItems": [
{
"section": "ai",
"label": "هوش مصنوعی",
"icon": "i-lucide-sparkles",
"items": [
[
{
"label": "بهبود نوشتار",
"icon": "i-lucide-pen-line",
"kbds": ["AI", "I"],
"action": "improve"
},
{
"label": "ادامه دادن",
"icon": "i-lucide-redo",
"kbds": ["AI", "I"],
"action": "continue"
},
{
"label": "تیتر گذاری",
"icon": "i-lucide-heading",
"kbds": ["AI", "I"],
"action": "title"
},
{
"label": "خلاصه‌سازی",
"icon": "i-lucide-file-text",
"kbds": ["AI", "S"],
"action": "summarize"
},
{
"label": "اصلاح املایی",
"icon": "i-lucide-eraser",
"kbds": ["AI", "I"],
"action": "spellcheck"
},
{
"label": "ساده‌سازی",
"icon": "i-lucide-spell-check",
"kbds": ["AI", "I"],
"action": "simplify"
}
],
[
{
"label": "ترجمه",
"icon": "i-lucide-languages",
"kbds": ["AI", "T"],
"action": "translate",
"children": [
[
{
"label": "انگلیسی",
"icon": "i-lucide-globe",
"action": "translate",
"lang": "انگلیسی"
},
{
"label": "فارسی",
"icon": "i-lucide-globe",
"action": "translate",
"lang": "فارسی"
},
{
"label": "عربی",
"icon": "i-lucide-globe",
"action": "translate",
"lang": "عربی"
}
]
]
},
{
"label": "توضیح",
"icon": "i-lucide-lightbulb",
"kbds": ["AI", "E"],
"action": "explain"
}
]
]
},
{
"section": "formatting",
"label": "قالب‌بندی",
"icon": "i-lucide-type",
"items": [
[
{
"label": "پررنگ",
"icon": "i-lucide-bold",
"kbds": ["ctrl", "B"],
"action": "toggleBold"
},
{
"label": "کج",
"icon": "i-lucide-italic",
"kbds": ["ctrl", "I"],
"action": "toggleItalic"
},
{
"label": "زیرخط",
"icon": "i-lucide-underline",
"kbds": ["ctrl", "U"],
"action": "toggleUnderline"
}
],
[
{
"label": "کد",
"icon": "i-lucide-code",
"kbds": ["ctrl", "E"],
"action": "toggleCode"
},
{
"label": "لینک",
"icon": "i-lucide-link",
"kbds": ["ctrl", "K"],
"action": "toggleLink"
}
]
]
},
{
"section": "operations",
"label": "عملیات",
"icon": "i-lucide-scissors",
"items": [
[
{
"label": "کپی",
"icon": "i-lucide-copy",
"kbds": ["ctrl", "C"],
"action": "copy"
},
{
"label": "برش",
"icon": "i-lucide-scissors",
"kbds": ["ctrl", "X"],
"action": "cut"
},
{
"label": "چسباندن",
"icon": "i-lucide-clipboard-paste",
"kbds": ["ctrl", "V"],
"action": "paste"
}
],
[
{
"label": "حذف",
"icon": "i-lucide-trash",
"kbds": ["Delete"],
"action": "delete",
"color": "error"
}
]
]
},
{
"section": "convert",
"label": "تبدیل به",
"icon": "i-lucide-shapes",
"items": [
[
{
"label": "عنوان ۱",
"icon": "i-lucide-heading-1",
"action": "convertToHeading1"
},
{
"label": "عنوان ۲",
"icon": "i-lucide-heading-2",
"action": "convertToHeading2"
},
{
"label": "عنوان ۳",
"icon": "i-lucide-heading-3",
"action": "convertToHeading3"
}
],
[
{
"label": "لیست نقطه‌ای",
"icon": "i-lucide-list",
"action": "convertToBulletList"
},
{
"label": "لیست شماره‌ای",
"icon": "i-lucide-list-ordered",
"action": "convertToOrderedList"
},
{
"label": "لیست کارها",
"icon": "i-lucide-check-square",
"action": "convertToTaskList"
}
],
[
{
"label": "کد",
"icon": "i-lucide-code",
"action": "convertToCodeBlock"
},
{
"label": "نقل قول",
"icon": "i-lucide-quote",
"action": "convertToBlockquote"
}
]
]
},
{
"section": "alignment",
"label": "ترازبندی",
"icon": "i-lucide-align-left",
"items": [
[
{
"label": "راست",
"icon": "i-lucide-align-right",
"action": "alignRight"
},
{
"label": "وسط",
"icon": "i-lucide-align-center",
"action": "alignCenter"
},
{
"label": "چپ",
"icon": "i-lucide-align-left",
"action": "alignLeft"
}
]
]
}
],
"blockIcons": {
"paragraph": "📝",
"heading1": "H1",
"heading2": "H2",
"heading3": "H3",
"todo": "✓",
"bullet": "•",
"number": "1.",
"code": "{ }",
"quote": "\"",
"image": "🖼️",
"file": "📎",
"table": "📊",
"callout": "💡"
},
"blockTypeNames": {
"paragraph": "متن",
"heading1": "عنوان ۱",
"heading2": "عنوان ۲",
"heading3": "عنوان ۳",
"todo": "لیست کار",
"bullet": "لیست نقطه‌ای",
"number": "لیست شماره‌ای",
"code": "کد",
"quote": "نقل قول",
"callout": "کالاوت"
},
"alignmentNames": {
"right": "راست",
"center": "وسط",
"left": "چپ"
},
"editorConfig": {
"placeholder": "شروع به نوشتن کنید...",
"emptyEditorClass": "is-editor-empty",
"attributes": {
"class": "notion-editor",
"dir": "rtl",
"spellcheck": "false"
},
"linkAttributes": {
"class": "text-blue-600 hover:text-blue-800 underline transition-colors",
"dir": "ltr",
"target": "_blank",
"rel": "noopener noreferrer"
},
"defaultAlignment": "right"
},
"welcomeContent": "<div style=\"text-align: right;\">\n <p>فلسفه اعم از همه علوم و معارف است، زيرا موضوع آن (موجود) عام ترين موضوعات و در برگيرنده همه چيزهاست. علوم كلًاّ از حيث ثبوت موضوع متوقف بر فلسفه اند، اما فلسفه در ثبوت موضوع خود بر هيچ يك از علوم مبتنى نيست</p> </div>"
}

18
app/json/header/header.json Executable file
View File

@ -0,0 +1,18 @@
{
"languages": [
{ "label": "فارسی", "icon": "i-flagpack-ir" },
{ "label": "English", "icon": "i-flagpack-gb-ukm" },
{ "label": "العربية", "icon": "i-flagpack-sa" }
],
"userMenu": [
[
{ "label": "پروفایل", "icon": "i-heroicons-user-circle","key":"profile" },
{ "label": "تنظیمات", "icon": "i-heroicons-cog-6-tooth" ,"key":"settings" },
{ "label": "برنامه‌نویس فنی", "icon": "i-heroicons-code-bracket" ,"key":"developer" },
{ "label": " پنل مدیریت", "icon": "i-heroicons-cog-6-tooth" ,"key":"admin" }
],
[
{ "label": "خروج", "icon": "i-heroicons-arrow-left-on-rectangle", "color": "red", "key": "logout" }
]
]
}

12
app/json/sidebar/dashboard.json Executable file
View File

@ -0,0 +1,12 @@
{
"topMenu": [
{
"label": "پیشخوان",
"icon": "i-lucide-home",
"to": "/dashboard/base",
"develop": 0,
"active": true
}
],
"bottomMenu": []
}

View File

@ -0,0 +1,50 @@
{
"tabs": [
{
"id": "dashboard",
"label": "داشبورد",
"icon": "i-heroicons-chart-bar"
},
{
"id": "dashboard1",
"label": "داشبورد",
"icon": "i-heroicons-chart-bar"
},
{
"id": "dashboard3",
"label": "داشبورد",
"icon": "i-heroicons-chart-bar"
},
{
"id": "dashboard5",
"label": "داشبورد",
"icon": "i-heroicons-chart-bar"
},
{
"id": "dashboard8",
"label": "داشبورد",
"icon": "i-heroicons-chart-bar"
},
{
"id": "dashboard7",
"label": "داشبورد",
"icon": "i-heroicons-chart-bar"
},
{
"id": "dashboard11",
"label": "داشبورد",
"icon": "i-heroicons-chart-bar"
},
{
"id": "users",
"label": "کاربران",
"icon": "i-heroicons-users"
},
{
"id": "users2",
"label": "کاربران",
"icon": "i-heroicons-users"
}
]
}

View File

@ -0,0 +1,26 @@
{
"tabs": [
{
"id": "MainList",
"key": "MainList",
"label": "فهرست",
"icon": "emojione-closed-book",
"develop": 0,
"active": true
},
{
"id": "RuleEdit",
"label": "احکام",
"key": "RuleEdit",
"develop": 0,
"icon": "i-mdi-library-outline"
},
{
"id": "RelationEdit",
"key": "RelationEdit",
"icon": "emojione-orange-book",
"develop": 0,
"label": "روابط"
}
]
}

View File

@ -0,0 +1,29 @@
{
"tabs": [
{
"id": "info",
"label": "اطلاعات پایه",
"key": "infoContent",
"develop": 0,
"icon": "i-mdi-database-outline"
},
{
"label": "آمار",
"id": "Statistics",
"develop": 1,
"icon": "i-lucide-bar-chart"
},
{
"label": "فعالیت کاربران",
"id": "UserReports",
"develop": 1,
"icon": "i-lucide-users"
},
{
"label": "گزارشات",
"to": "Reports",
"develop": 1,
"icon": "i-heroicons-chart-bar"
}
]
}

View File

@ -0,0 +1,325 @@
{
"items": [],
"tableActions": [
{
"key": "importInfo",
"icon": "i-lucide-info",
"title": "ورود اطلاعات"
},
{
"key": "manageFiles",
"icon": "i-lucide-upload",
"title": "بارگذاری فایل"
}
],
"tableColumns": [
{
"key": "branch",
"title": "دوره",
"isLink": true,
"width": "18%",
"contextmenu": true
},
{ "key": "title", "title": "عنوان", "width": "15%", "contextmenu": true },
{ "key": "subtitle", "title": "عنوان محتوایی", "width": "25%" },
{ "key": "meet_code", "title": "کد جلسه", "width": "12%" },
{
"key": "begin_date",
"title": "تاریخ",
"isLink": true,
"width": "12%",
"contextmenu": true
},
{ "key": "author", "title": "مولف", "width": "18%" }
],
"menuItems": [
[
{
"label": "ویرایش سریع",
"key": "quick-edit",
"icon": "heroicons:pencil-square"
},
{
"label": "کپی مقدار",
"key": "copy",
"icon": "heroicons:clipboard-document"
},
{
"label": "جزئیات",
"key": "details",
"icon": "heroicons:information-circle"
}
]
],
"schemaItems": {
"collapse_items": {
"key": "collapse",
"items": [
{
"key": "title",
"items": [
{
"key": "branch",
"source_key": "branch",
"label": "دوره :",
"style": "search-label",
"hilight_key": "branch",
"link_route": {
"id": "_id",
"name": "navigationView",
"key": "sanad",
"query": "listkey=branch"
}
},
{
"key": "author",
"source_key": "author",
"label": "",
"style": "search-label"
}
]
},
{
"key": "inner_hits",
"array_key": "inner_hits.by_collapse.hits.hits",
"items": [
{
"key": "format",
"source_key": "format",
"label": "",
"style": "search-tag"
},
{
"key": "meet_lid",
"source_key": "meet_lid",
"label": "کدداخلی :",
"style": "search-label"
},
{
"key": "meet_code",
"source_key": "meet_code",
"label": "کد جلسه :",
"style": "search-label"
},
{
"key": "begin_date",
"source_key": "begin_date",
"label": "تاریخ :",
"style": "search-label",
"process": "convert_date"
},
{
"key": "title",
"source_key": "title,subtitle",
"label": "",
"style": "search-title",
"link_route": {
"id": "_id",
"name": "navigationView",
"key": "sanad"
}
}
]
}
]
},
"items": [
{
"key": "title",
"items": [
{
"key": "format",
"source_key": "format",
"label": "",
"style": "search-tag"
},
{
"key": "title",
"source_key": "title,subtitle",
"label": "",
"style": "search-title",
"link_route": {
"id": "_id",
"name": "navigationView",
"key": "sanad"
}
}
]
},
{
"key": "subtitle",
"items": [
{
"key": "meet_lid",
"source_key": "meet_lid",
"label": "کدداخلی :",
"style": "search-label"
},
{
"key": "meet_code",
"source_key": "meet_code",
"label": "کد جلسه :",
"style": "search-label"
},
{
"key": "research_code",
"source_key": "research_code",
"label": "کد دوره :",
"style": "search-label"
},
{
"key": "id",
"source_key": "id",
"label": "کد پورتال :",
"style": "search-label"
},
{
"key": "begin_date",
"source_key": "begin_date",
"label": "تاریخ :",
"style": "search-label",
"process": "convert_date"
}
]
},
{
"key": "subtitle2",
"items": [
{
"key": "branch",
"source_key": "branch",
"label": "دوره :",
"style": "search-label",
"hilight_key": "branch",
"link_route": {
"id": "_id",
"name": "navigationView",
"key": "sanad",
"query": "listkey=branch"
}
},
{
"key": "author",
"source_key": "author",
"label": "",
"style": "search-label"
}
]
},
{
"key": "body",
"items": [
{
"key": "content",
"source_key": "content,mindex,mintro",
"label": "",
"hilight_key": "content",
"style": "search-body"
}
]
},
{
"key": "keywords",
"items": [
{
"key": "keywords",
"source_key": "keywords",
"label": "واژه‌گان :",
"style": "search-label"
}
]
},
{
"key": "media",
"items": [
{
"key": "films",
"source_key": "films",
"label": "فیلم :",
"style": "search-label"
},
{
"key": "voices",
"source_key": "voices",
"label": "صوت :",
"style": "search-label"
},
{
"key": "photos",
"source_key": "photos",
"label": "تصاویر :",
"style": "search-label"
}
]
},
{
"key": "subject",
"items": [
{
"key": "subject",
"isArray": 1,
"source_key": "subject",
"label": "موضوع :",
"style": "search-label"
}
]
}
],
"actions": [
{
"title": "علاقه مندی ها",
"key": "tbookmark",
"can": "favorite_create",
"type": "button",
"api_items": {
"data_type": "bookmark",
"ref_key": "sanad",
"id": "_id",
"title": "_source.title"
},
"toggle_icons": {
"icon1": "i-heroicons-bookmark-solid",
"icon2": "i-heroicons-bookmark"
}
},
{
"icon": "i-heroicons-information-circle",
"title": "مشخصات",
"key": "summary",
"can": "search_summary",
"type": "button"
},
{
"icon": "i-mdi-content-copy",
"title": "کپی لینک",
"key": "copy",
"type": "button",
"link_route": { "id": "_id", "name": "navigationView", "key": "sanad" }
}
]
},
"pagination": {
"pages": 0,
"total": 0,
"page": 1,
"offset": 0,
"limit": 10
},
"switchSchema": [
{ "key": "list", "label": "نمایش جدول", "icon": "i-ri:table-fill" },
{
"key": "table",
"label": "نمایش لیستی",
"icon": "i-lsicon:list-outline"
}
],
"viewMode": "table",
"contentKey": "sanad",
"textSearch": "",
"isSearchingState": true,
"height": "calc(-20em + 100vh)"
}

View File

@ -0,0 +1,333 @@
{
"items": [],
"tableActions": [
{
"key": "meta_infos",
"icon": "i-lucide-info",
"title": "ورود اطلاعات"
},
{
"key": "manageFiles",
"icon": "i-lucide-upload",
"title": "بارگذاری فایل"
},
{
"key": "hasMedia",
"icon": "lucide:list-checks",
"title": ""
}
],
"tableColumns": [
{
"key": "branch",
"title": "دوره",
"isLink": true,
"width": "18%",
"contextmenu": true
},
{ "key": "title", "title": "عنوان", "width": "15%", "contextmenu": true },
{ "key": "subtitle", "title": "عنوان محتوایی", "width": "25%" },
{ "key": "meet_code", "title": "کد جلسه", "width": "12%" },
{
"key": "begin_date",
"title": "تاریخ",
"isLink": true,
"width": "12%",
"contextmenu": true
},
{ "key": "video_count", "title": "ت فیلم", "width": "5%" },
{ "key": "photo_count", "title": "ت تصویر", "width": "5%" },
{ "key": "sound_count", "title": "ت صوت", "width": "5%" },
{ "key": "file_count", "title": "ت فایل", "width": "5%" }
],
"menuItems": [
[
{
"label": "ویرایش سریع",
"key": "quick-edit",
"icon": "heroicons:pencil-square"
},
{
"label": "کپی مقدار",
"key": "copy",
"icon": "heroicons:clipboard-document"
},
{
"label": "جزئیات",
"key": "details",
"icon": "heroicons:information-circle"
}
]
],
"schemaItems": {
"collapse_items": {
"key": "collapse",
"items": [
{
"key": "title",
"items": [
{
"key": "branch",
"source_key": "branch",
"label": "دوره :",
"style": "search-label",
"hilight_key": "branch",
"link_route": {
"id": "_id",
"name": "navigationView",
"key": "sanad",
"query": "listkey=branch"
}
},
{
"key": "author",
"source_key": "author",
"label": "",
"style": "search-label"
}
]
},
{
"key": "inner_hits",
"array_key": "inner_hits.by_collapse.hits.hits",
"items": [
{
"key": "format",
"source_key": "format",
"label": "",
"style": "search-tag"
},
{
"key": "meet_lid",
"source_key": "meet_lid",
"label": "کدداخلی :",
"style": "search-label"
},
{
"key": "meet_code",
"source_key": "meet_code",
"label": "کد جلسه :",
"style": "search-label"
},
{
"key": "begin_date",
"source_key": "begin_date",
"label": "تاریخ :",
"style": "search-label",
"process": "convert_date"
},
{
"key": "title",
"source_key": "title,subtitle",
"label": "",
"style": "search-title",
"link_route": {
"id": "_id",
"name": "navigationView",
"key": "sanad"
}
}
]
}
]
},
"items": [
{
"key": "title",
"items": [
{
"key": "format",
"source_key": "format",
"label": "",
"style": "search-tag"
},
{
"key": "title",
"source_key": "title,subtitle",
"label": "",
"style": "search-title",
"link_route": {
"id": "_id",
"name": "navigationView",
"key": "sanad"
}
}
]
},
{
"key": "subtitle",
"items": [
{
"key": "meet_lid",
"source_key": "meet_lid",
"label": "کدداخلی :",
"style": "search-label"
},
{
"key": "meet_code",
"source_key": "meet_code",
"label": "کد جلسه :",
"style": "search-label"
},
{
"key": "research_code",
"source_key": "research_code",
"label": "کد دوره :",
"style": "search-label"
},
{
"key": "id",
"source_key": "id",
"label": "کد پورتال :",
"style": "search-label"
},
{
"key": "begin_date",
"source_key": "begin_date",
"label": "تاریخ :",
"style": "search-label",
"process": "convert_date"
}
]
},
{
"key": "subtitle2",
"items": [
{
"key": "branch",
"source_key": "branch",
"label": "دوره :",
"style": "search-label",
"hilight_key": "branch",
"link_route": {
"id": "_id",
"name": "navigationView",
"key": "sanad",
"query": "listkey=branch"
}
},
{
"key": "author",
"source_key": "author",
"label": "",
"style": "search-label"
}
]
},
{
"key": "body",
"items": [
{
"key": "content",
"source_key": "content,mindex,mintro",
"label": "",
"hilight_key": "content",
"style": "search-body"
}
]
},
{
"key": "keywords",
"items": [
{
"key": "keywords",
"source_key": "keywords",
"label": "واژه‌گان :",
"style": "search-label"
}
]
},
{
"key": "media",
"items": [
{
"key": "films",
"source_key": "films",
"label": "فیلم :",
"style": "search-label"
},
{
"key": "voices",
"source_key": "voices",
"label": "صوت :",
"style": "search-label"
},
{
"key": "photos",
"source_key": "photos",
"label": "تصاویر :",
"style": "search-label"
}
]
},
{
"key": "subject",
"items": [
{
"key": "subject",
"isArray": 1,
"source_key": "subject",
"label": "موضوع :",
"style": "search-label"
}
]
}
],
"actions": [
{
"title": "علاقه مندی ها",
"key": "tbookmark",
"can": "favorite_create",
"type": "button",
"api_items": {
"data_type": "bookmark",
"ref_key": "sanad",
"id": "_id",
"title": "_source.title"
},
"toggle_icons": {
"icon1": "i-heroicons-bookmark-solid",
"icon2": "i-heroicons-bookmark"
}
},
{
"icon": "i-heroicons-information-circle",
"title": "مشخصات",
"key": "summary",
"can": "search_summary",
"type": "button"
},
{
"icon": "i-mdi-content-copy",
"title": "کپی لینک",
"key": "copy",
"type": "button",
"link_route": { "id": "_id", "name": "navigationView", "key": "sanad" }
}
]
},
"pagination": {
"pages": 0,
"total": 0,
"page": 1,
"offset": 0,
"limit": 10
},
"switchSchema": [
{ "key": "list", "label": "نمایش جدول", "icon": "i-ri:table-fill" },
{
"key": "table",
"label": "نمایش لیستی",
"icon": "i-lsicon:list-outline"
}
],
"viewMode": "table",
"contentKey": "sanad",
"textSearch": "",
"isSearchingState": true,
"height": "calc(-20em + 100vh)"
}

View File

@ -0,0 +1,23 @@
{
"tabs": [
{
"id": "monir",
"key": "monirContent",
"label": "استاد حسینی",
"icon": "emojione-closed-book",
"active": true
},
{
"id": "mirbagheri",
"key": "mirbagheriContent",
"icon": "emojione-orange-book",
"label": "استاد میرباقری"
},
{
"id": "farhangestan",
"label": "فرهنگستان",
"key": "farhangestanContent",
"icon": "i-mdi-library-outline"
}
]
}

64
app/layouts/dashboardLayout.vue Executable file
View File

@ -0,0 +1,64 @@
<!-- app/layouts/dashboard.vue -->
<template>
<div class="flex bg-light-primary dark:bg-dark-primary">
<Sidebar :sidebar-items="defaultSidebar" />
<div class="flex-1 flex flex-col">
<main
class="flex-1 bg-light-primary dark:bg-dark-primary"
>
<slot />
</main>
</div>
</div>
</template>
<script setup>
import { watch } from "vue";
import { useRoute } from "#imports";
// JSON فایلها
import defaultSidebar from "@/json/sidebar/dashboard.json";
// import tabBarData from "@/json/tab-bar/dashboard/dashboard.json"
const route = useRoute();
const onUserMenu = (action) => {
switch (action.key) {
case 'profile':
navigateTo('/profile')
break
case 'settings':
navigateTo('/settings')
break
case 'developer':
navigateTo('/developer')
break
case 'admin':
navigateTo('/admin')
break
break
case 'logout':
console.log('logout from parent')
break
}
}
// دادههای سایدبار
const sidebarData = route.meta.sidebarItems || defaultSidebar;
const headerSchema = ref({});
headerSchema.value = {
breadcrumb: false,
logo: true,
};
// اضافه کردن Route Meta برای سایدبار
watch(
() => route,
(newRoute) => {
sidebarData.value = newRoute.meta.sidebarItems || defaultSidebar;
},
{ immediate: true }
);
</script>

10
app/layouts/default.vue Executable file
View File

@ -0,0 +1,10 @@
<!-- app/layouts/default.vue -->
<template>
<div class="bg-light-primary dark:bg-dark-primary">
<main class="">
<slot />
</main>
<Footer />
</div>
</template>

34
app/middleware/route.global.ts Executable file
View File

@ -0,0 +1,34 @@
import { getUserPermission } from "@/stores/permissionStore";
import { useStorage } from "@vueuse/core";
export default defineNuxtRouteMiddleware(async (to, from) => {
let toRoute = useStorage("to_route", to.path);
const userPermissionStore = getUserPermission();
// let localStorageToRoute = toRoute.value;
userPermissionStore.fetchUserPermissions();
const publicRoutes = ["/403", "/login", from.path];
if (publicRoutes.includes(to.path)) return true;
if (
!userPermissionStore.permissions ||
!userPermissionStore.permissions.length
) {
try {
await userPermissionStore.fetchUserPermissions();
} catch (err) {
console.error("Error fetching permissions:", err);
return navigateTo("/403");
}
}
const canAccess = userPermissionStore.hasPagePermission(to.fullPath);
// console.log("Checking permission for route:", to.fullPath, canAccess);
if (!canAccess) {
return navigateTo("/403");
}
return true;
});

33
app/middleware/sidebar-items.js Executable file
View File

@ -0,0 +1,33 @@
export default defineNuxtRouteMiddleware((to, from) => {
// تعریف سایدبار پیش‌فرض
const defaultSidebar = {
topMenu: [
{
label: "پیشخوان",
icon: "i-lucide-home",
to: "/dashboard/base",
active: true,
},
{ label: "اعلانات", icon: "i-lucide-bell", to: "/", badge: "۳" },
{ label: "علاقه‌مندی‌ها", icon: "i-lucide-bookmark", to: "/" },
{ label: "تقویم", icon: "i-lucide-calendar", to: "/" },
],
bottomMenu: [
{ label: "تم", icon: "i-lucide-palette", to: "/" },
{ label: "زبان", icon: "i-lucide-languages", to: "/" },
{ label: "حساب کاربری", icon: "i-lucide-user", to: "/" },
{ label: "راهنما", icon: "i-lucide-help-circle", to: "/" },
],
};
// بررسی آیا route فعلی sidebarItems دارد یا نه
const routeSidebar = to.meta?.sidebarItems;
if (routeSidebar) {
// استفاده از سایدبار تعریف شده در route
useState("sidebar", () => routeSidebar);
} else {
// استفاده از پیش‌فرض
useState("sidebar", () => defaultSidebar);
}
});

124
app/pages/403.vue Executable file
View File

@ -0,0 +1,124 @@
<template>
<div
class="min-h-[calc(100dvh-4em)] flex items-center justify-center p-4 bg-gray-100 dark:bg-dark-primary"
>
<div
class="max-w-md w-full bg-white dark:bg-dark-primary-800 rounded-2xl shadow-xl p-8 text-center border border-gray-200 dark:border-dark-primary-600"
>
<!-- آیکون بزرگ -->
<div class="mb-10">
<div
class="w-40 h-40 mx-auto bg-red-50 dark:bg-dark-primary-700 rounded-full flex items-center justify-center shadow-lg"
>
<svg
class="w-32 h-32 text-red-600 dark:text-red-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
<!-- عنوان -->
<h2
class="text-2xl font-semibold text-gray-800 dark:text-dark-primary-100 mb-2"
>
دسترسی غیرمجاز
</h2>
<!-- متن -->
<div class="mb-6">
<p class="text-gray-600 dark:text-dark-primary-300 text-lg mb-2">
شما مجوز دسترسی به این صفحه را ندارید
</p>
<p class="text-gray-700 dark:text-dark-primary-200 font-medium">
لطفا برای ادامه، ارتقاء دسترسی دهید
</p>
</div>
<!-- دکمه اصلی -->
<!-- <div class="mb-8">
<button
@click="goToDashboard"
class="w-full bg-primary-600 hover:bg-primary-700 text-white font-medium py-4 rounded-xl shadow-lg hover:shadow-xl transition-all duration-200"
>
بازگشت به داشبورد
</button>
</div> -->
<div class="mb-8">
<button
@click="goBack"
class="w-full bg-primary-600 hover:bg-primary-700 text-white font-medium py-4 rounded-xl shadow-lg hover:shadow-xl transition-all duration-200"
>
بازگشت به صفحه قبل
</button>
</div>
<!-- لینکهای دیگر -->
<div class="space-y-3">
<!-- <a
href="/login"
class="block text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300 hover:bg-primary-50 dark:hover:bg-dark-primary-700 py-3 rounded-lg transition duration-200 font-medium"
>
ثبتنام
</a> -->
<a
@click.prevent="goToLogin"
class="block text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300 hover:bg-primary-50 dark:hover:bg-dark-primary-700 py-3 rounded-lg transition duration-200 font-medium"
>
ورود ، ثبتنام
</a>
<a
href="/contact"
class="block text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300 hover:bg-primary-50 dark:hover:bg-dark-primary-700 py-3 rounded-lg transition duration-200 font-medium"
>
تماس با ما
</a>
<a
href="/about"
class="block text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300 hover:bg-primary-50 dark:hover:bg-dark-primary-700 py-3 rounded-lg transition duration-200 font-medium"
>
درباره ما
</a>
</div>
<!-- پیام پایین -->
<div
class="mt-6 pt-6 border-t border-gray-200 dark:border-dark-primary-600"
>
<p class="text-gray-500 dark:text-dark-primary-400 text-sm">
کد خطا: 403 دسترسی ممنوع
</p>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({ layout: "login-layout" });
const router = useRouter();
const goBack = () => {
router.back();
};
// const goToDashboard = () => {
// navigateTo({
// name: "DashboardBasePage",
// });
// };
const goToLogin = () => {
navigateTo("/login");
};
</script>
<style scoped>
/* بدون استایل اضافی */
</style>

85
app/pages/index.vue Executable file
View File

@ -0,0 +1,85 @@
<template>
<div>
<Header
:tabs="headerTabs"
v-model:active-tab="activeTabKey"
:is-sidebar-collapsed="isSidebarCollapsed"
:unread-notifications="unreadNotifications"
@tab-change="handleTabChange"
:headerSchema="headerSchema"
/>
<component
:is="currentComponent"
v-if="currentComponent"
:activeTabKey="activeTabKey"
/>
</div>
</template>
<script setup>
definePageMeta({
name: "DashboardBasePage",
layout: "dashboard-layout",
});
import { ref, onMounted } from "vue";
import tabBarData from "@/json/tab-bar/data-entry/dataEntry.json";
import { defineAsyncComponent } from "vue";
const route = useRoute();
// Stateهای Header
const isSidebarCollapsed = ref(false);
const unreadNotifications = ref(5);
const headerTabs = ref([]);
headerTabs.value = tabBarData.tabs;
const activeTabKey = ref(tabBarData.tabs[0]?.key || "");
// const contentSchema = computed(() => ({
// items: items.value,
// }));
const tabComponents = {
MainList: defineAsyncComponent(
() => import("~/components/lazy-load/data-entry/MainList.vue"),
),
RelationEdit: defineAsyncComponent(
() => import("~/components/lazy-load/data-entry/RelationEdit.vue"),
),
RuleEdit: defineAsyncComponent(
() => import("~/components/lazy-load/data-entry/RuleEdit.vue"),
),
// "DataEntryFarhangestan": defineAsyncComponent(() => import("~/components/lazy-load/data-entry/DataEntryFarhangestan.vue")),
// "DataEntryMirbagheri": defineAsyncComponent(() => import("~/components/lazy-load/data-entry/DataEntryMirbagheri.vue")),
// ...
};
const currentComponent = computed(() => {
if (activeTabKey.value == "MainList") {
return tabComponents.MainList;
} else if (activeTabKey.value === "RelationEdit") {
return tabComponents.RelationEdit;
} else if (activeTabKey.value === "RuleEdit") {
return tabComponents.RuleEdit;
}
});
// تابع مدیریت تغییر تب
const handleTabChange = (tab) => {
// منطق تغییر مسیر یا لود داده
switch (tab.id) {
case "dashboard":
break;
case "analytics":
break;
case "users":
break;
case "settings":
break;
default:
}
};
const headerSchema = ref({});
headerSchema.value = {
breadcrumb: true,
logo: false,
};
onMounted(() => {
activeTabKey.value = headerTabs.value[0]?.id || "";
});
</script>

69
app/pages/login.vue Executable file
View File

@ -0,0 +1,69 @@
<template>
<div
class="min-h-[calc(100dvh-4em)] flex items-center justify-center p-4 bg-gray-100 dark:bg-dark-primary"
>
<div
class="max-w-md w-full bg-white dark:bg-dark-primary-800 rounded-2xl shadow-xl p-8 text-center border border-gray-200 dark:border-dark-primary-600"
>
<!-- Tabs -->
<ul class="flex border-b border-gray-200">
<li class="flex-1">
<button
class="w-full py-3.5 font-medium transition-all duration-200 cursor-pointer"
:class="tabClass('login')"
@click="setActive('login')"
>
ورود
</button>
</li>
<li class="flex-1">
<button
class="w-full py-3.5 font-medium transition-all duration-200 cursor-pointer"
:class="tabClass('register')"
@click="setActive('register')"
>
ثبتنام
</button>
</li>
</ul>
<!-- Content -->
<div class="mt-8">
<component :is="currentComponent" />
</div>
</div>
</div>
</template>
<script setup>
import { ref } from "vue";
definePageMeta({ layout: "login-layout" });
// --- Lazy load components ---
import LoginForm from "~/components/lazy-load/auth/LoginForm.vue";
import RegisterForm from "~/components/lazy-load/auth/RegisterForm.vue";
// --- State ---
const activeTab = ref("login");
const currentComponent = ref(LoginForm);
const componentsMap = {
login: LoginForm,
register: RegisterForm,
};
// --- Methods ---
function setActive(tab) {
activeTab.value = tab;
currentComponent.value = componentsMap[tab];
}
// --- Compute class for tabs ---
function tabClass(tab) {
if (activeTab.value === tab) {
return "text-primary border-b-1 border-primary font-semibold";
} else {
return "text-gray-500 hover:text-primary";
}
}
</script>
<style></style>

103
app/plugins/httpService.ts Executable file
View File

@ -0,0 +1,103 @@
import { useStorage } from "@vueuse/core";
export default defineNuxtPlugin((nuxtApp) => {
// ======================
// Runtime Config (Public)
// ======================
const config = useRuntimeConfig();
const BASE_URL = config.public.NUXT_PUBLIC_BASE_URL || "";
const API_NAME = config.public.NUXT_PUBLIC_API_NAME || "";
// ======================
// Safe URL Joiner
// ======================
const joinURL = (base: string, path: string) => {
return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
};
const baseURL = joinURL(BASE_URL, API_NAME);
// ======================
// Reactive Token
// ======================
const token = useStorage<string>("id_token", "GuestAccess");
// ======================
// Fetch Instance
// ======================
const api = $fetch.create({
baseURL,
// ======================
// Request Interceptor
// ======================
onRequest({ options }) {
const auth = token.value;
if (!auth) return;
const headers = (options.headers ||= {});
if (headers instanceof Headers) {
headers.set("Authorization", auth);
} else if (Array.isArray(headers)) {
headers.push(["Authorization", auth]);
} else {
headers.Authorization = auth;
}
},
// ======================
// Response Handler
// ======================
onResponse({ response }) {
return response._data;
},
// ======================
// Error Handler
// ======================
async onResponseError({ response }) {
const status = response?.status;
if (status === 401) {
token.value = null;
await nuxtApp.runWithContext(() => navigateTo("/login"));
}
throw {
status,
message:
response?._data?.message || response?.statusText || "خطای نامشخص",
data: response?._data || null,
};
},
});
// ======================
// HTTP Service (Public API)
// ======================
const http = {
getRequest: (url: string, options: any = {}) =>
api(url, { method: "GET", ...options }),
postRequest: (url: string, body: any, options: any = {}) =>
api(url, { method: "POST", body, ...options }),
putRequest: (url: string, body: any, options: any = {}) =>
api(url, { method: "PUT", body, ...options }),
patchRequest: (url: string, body: any, options: any = {}) =>
api(url, { method: "PATCH", body, ...options }),
deleteRequest: (url: string, options: any = {}) =>
api(url, { method: "DELETE", ...options }),
};
// ======================
// Provide to Nuxt
// ======================
return {
provide: {
http,
},
};
});

View File

@ -0,0 +1,5 @@
export default defineNuxtPlugin(async () => {
const { applyTheme } = composSystemTheme();
await applyTheme();
});

View File

@ -0,0 +1,60 @@
// themeLoader.client.ts
import majles from '~/assets/majles/theme.json'
import monir from '~/assets/monir/theme.json'
export const themes = {
majles,
monir
}
type ThemeKey = keyof typeof themes
type ThemeConfig = typeof themes[ThemeKey]
// تابع برای تبدیل HEX به RGB (بدون پرانتز و کاما)
function hexToRgb(hex: string): string {
// حذف # از ابتدا
hex = hex.replace('#', '');
// اگر کوتاه باشد (مثل #fff)
if (hex.length === 3) {
hex = hex.split('').map(char => char + char).join('');
}
// تبدیل به RGB
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `${r} ${g} ${b}`;
}
export default defineNuxtPlugin(() => {
const host = window.location.hostname.replace("www.", "");
let key: ThemeKey = "monir"; // default
if (host.includes("monir")) key = "monir";
else if (host.includes("majles")) key = "majles";
const theme: ThemeConfig = themes[key];
// ست کردن CSS Variables اصلی
Object.entries(theme).forEach(([key, value]) => {
if (typeof value === "string") {
document.documentElement.style.setProperty(`--theme-${key}`, value);
// تبدیل رنگ‌ها به RGB برای Tailwind
if (['primary', 'secondary', 'accent', 'background', 'text'].includes(key)) {
const rgb = hexToRgb(value);
document.documentElement.style.setProperty(`--color-${key}`, rgb);
}
}
});
return {
provide: {
themeKey: key,
theme,
},
};
});

139
app/server/api/ai/index.post.js Executable file
View File

@ -0,0 +1,139 @@
export default defineEventHandler(async (event) => {
try {
const body = await readBody(event);
const { action, text, language = "fa" } = body;
// بررسی پارامترهای ورودی
if (!action || !text) {
throw createError({
statusCode: 400,
statusMessage: "پارامترهای action و text الزامی هستند",
});
}
// API keyها از .env
const config = useRuntimeConfig();
const OPENAI_API_KEY = config.OPENAI_API_KEY;
const GEMINI_API_KEY = config.GEMINI_API_KEY;
// انتخاب سرویس AI
const useOpenAI = !!OPENAI_API_KEY;
const useGemini = !!GEMINI_API_KEY && !useOpenAI;
let aiResult = "";
if (useOpenAI) {
// OpenAI API
const response = await fetch(
"https://api.openai.com/v1/chat/completions",
{
method: "POST",
headers: {
Authorization: `Bearer ${OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-3.5-turbo",
messages: [
{
role: "system",
content:
"تو یک دستیار فارسی‌زبان هستی که به کاربران در نوشتن کمک می‌کنی.",
},
{
role: "user",
content: getPrompt(action, text, language),
},
],
temperature: 0.7,
max_tokens: 500,
}),
}
);
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status}`);
}
const data = await response.json();
aiResult = data.choices[0]?.message?.content || "خطا در پردازش";
} else if (useGemini) {
// Google Gemini API
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=${GEMINI_API_KEY}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
contents: [
{
parts: [
{
text: getPrompt(action, text, language),
},
],
},
],
}),
}
);
if (!response.ok) {
throw new Error(`Gemini API error: ${response.status}`);
}
const data = await response.json();
aiResult =
data.candidates?.[0]?.content?.parts?.[0]?.text || "خطا در پردازش";
} else {
// پاسخ تستی (اگر API key ندارید)
aiResult = getMockResponse(action, text, language);
}
return {
success: true,
action,
result: aiResult,
timestamp: new Date().toISOString(),
};
} catch (error) {
console.error("AI API Error:", error);
return {
success: false,
error: error.message,
mockResponse: getMockResponse(action, text, language),
};
}
});
// تولید prompt بر اساس action
function getPrompt(action, text, language) {
const prompts = {
summarize: `لطفاً این متن را به فارسی خلاصه کن و نکات اصلی آن را استخراج کن. متن:\n\n${text}\n\nخلاصه:`,
improve: `این متن را از نظر دستوری، نگارشی و سبک نوشتار بهبود بده و آن را حرفه‌ای‌تر کن. متن:\n\n${text}\n\nمتن بهبود یافته:`,
translate: `این متن فارسی را به انگلیسی ترجمه کن. متن:\n\n${text}\n\nترجمه:`,
explain: `این متن را به زبان ساده توضیح بده و مفاهیم آن را شفاف سازی کن. متن:\n\n${text}\n\nتوضیح:`,
continue: `بر اساس این متن، آن را ادامه بده و محتوای مرتبط اضافه کن. متن:\n\n${text}\n\nادامه متن:`,
};
return prompts[action] || `در مورد این متن نظر بده:\n\n${text}\n\nنظر:`;
}
// پاسخ‌های تستی
function getMockResponse(action, text, language) {
const responses = {
summarize: `📝 خلاصه متن: این یک خلاصه آزمایشی از متن شماست که نکات کلیدی را پوشش می‌دهد. متن اصلی حدود ${text.length} کاراکتر داشت و شامل موضوعاتی مانند نمونه‌گیری و تست است.`,
improve: `✨ متن بهبود یافته: این نسخه اصلاح شده متن شماست با رعایت اصول نگارشی و ساختار بهتر. جملات روان‌تر شده و از واژگان مناسب‌تری استفاده شده است.`,
translate: `🌍 Translation: This is a sample translation of your Persian text into English. The original text was about testing and demonstration purposes.`,
explain: `💡 توضیح: این مفهوم به زبان ساده توضیح داده می‌شود تا درک آن آسان‌تر شود. منظور از این متن نمایش قابلیت‌های سیستم هوش مصنوعی است.`,
continue: `↪️ ادامه متن: این بخش ادامه منطقی متن شماست که ایده‌های مطرح شده را توسعه می‌دهد. با توجه به محتوای قبلی، می‌توان به موضوعات مرتبط دیگری نیز پرداخت.`,
};
return (
responses[action] ||
"پردازش AI انجام شد. برای استفاده از قابلیت‌های واقعی، لطفاً API key مناسب را تنظیم کنید."
);
}

134
app/stores/authStore.ts Executable file
View File

@ -0,0 +1,134 @@
// stores/authStore.ts
import { defineStore } from "pinia";
import { useStorage } from "@vueuse/core";
export const useAuthStore = defineStore("authStore", () => {
// ===== state =====
const user = ref({});
const isAuthenticated = ref(false);
const isRealUser = ref(false);
const errors = ref(null);
// ===== actions =====
function setUser(response: any) {
const payload = response?.data ?? {}; // data اصلی از API
// userData از payload.user_data و user_id ساخته میشه
const userData = {
id: payload.user_id ?? null,
level: payload.user_level ?? null,
token: payload.token ?? null,
expire: payload.expire ?? null,
refresh_token: payload.refresh_token ?? null,
...payload.user_data, // first_name, last_name, avatar, username
};
isAuthenticated.value = true;
isRealUser.value = true;
user.value = userData;
errors.value = null;
// ذخیره توکن در localStorage — امن بنویس (هرگز null ننویس)
try {
const id_token = useStorage("id_token", "");
id_token.value = userData.token ?? "";
} catch (e) {
console.warn("Could not write id_token to storage:", e);
}
// ذخیره کامل user — مطمئن شو شیء است
try {
const userStorage = useStorage("user", {});
userStorage.value = userData ?? {};
} catch (e) {
console.warn("Could not write user to storage:", e);
}
// ذخیره user_id جداگانه برای دسترسی سریع
try {
const userIdStorage = useStorage("user_id", "");
userIdStorage.value = String(userData.id ?? "");
} catch (e) {
console.warn("Could not write user_id to storage:", e);
}
// --- محافظت از سایر storage هایی که ممکنه در برنامه استفاده شده باشند ---
// مثال: اگر یه key برای UI مثل 'sidebar' انتظار فیلد collapsed داره،
// اطمینان بده که مقدار آن null نیست:
try {
const maybeSidebar = useStorage("sidebar", { collapsed: false });
if (maybeSidebar.value == null) {
// اگر در localStorage قبلاً null ذخیره شده بود، مقدار پیش‌فرض را بازنویسی کن
maybeSidebar.value = { collapsed: false };
}
} catch (e) {
// اگر کلیدهای دیگری داری که انتظار فیلد collapsed دارند، مشابهشان را اضافه کن
}
}
async function getCaptcha() {
try {
const nuxtApp = useNuxtApp();
const baseUrl = import.meta.env.VITE_AUTH_BASE_URL;
const { $http: httpService } = nuxtApp;
const data: string = await httpService.getRequest("/auth/captcha", {
baseURL: baseUrl,
});
return data;
} catch (error) {
console.error("Captcha Error:", error);
return null;
}
}
async function register(credentials: any) {
try {
const nuxtApp = useNuxtApp();
const baseUrl = import.meta.env.VITE_AUTH_BASE_URL;
const { $http: httpService } = nuxtApp;
const response = await httpService.postRequest(
"/auth/register",
credentials,
{
baseURL: baseUrl,
},
);
setUser(response.data);
return response;
} catch (error: any) {
errors.value = error?.response?.data?.message || error.message;
return error;
}
}
function userReset() {
user.value = {};
isAuthenticated.value = false;
isRealUser.value = false;
errors.value = null;
// پاک کردن localStorage / sessionStorage
const id_token = useStorage("id_token", "");
id_token.value = "";
const userStorage = useStorage("user", {});
userStorage.value = {};
}
return {
// state
user,
isAuthenticated,
isRealUser,
errors,
// actions
setUser,
getCaptcha,
register,
userReset,
};
});

13
app/stores/commonStore.ts Executable file
View File

@ -0,0 +1,13 @@
import { ref } from "vue";
import { defineStore } from "pinia";
export const useCommonStore = defineStore("commonStore", () => {
let sidebarOpen = ref(false);
const isSidebarOpen = () => {
sidebarOpen.value = !sidebarOpen.value;
};
return {
isSidebarOpen,
sidebarOpen,
};
});

255
app/stores/notionStore.js Executable file
View File

@ -0,0 +1,255 @@
// stores/notionStore.js
import { defineStore } from "pinia";
export const useNotionStore = defineStore("notion", {
state: () => ({
blocks: [],
selectedBlockId: null,
draggedBlockId: null,
}),
getters: {
getBlockById: (state) => (id) => {
return state.blocks.find((block) => block.id === id);
},
getChildBlocks: (state) => (parentId) => {
return state.blocks.filter((block) => block.parentId === parentId);
},
selectedBlock: (state) => {
return state.blocks.find((block) => block.id === state.selectedBlockId);
},
// بلوک‌های سطح بالا (بدون والد)
topLevelBlocks: (state) => {
return state.blocks.filter((block) => !block.parentId);
},
},
actions: {
// ایجاد ID یکتا
generateId() {
return (
"block_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9)
);
},
// ایجاد بلوک جدید
addBlock({
type = "paragraph",
content = "",
parentId = null,
position = null,
}) {
const newBlock = {
id: this.generateId(),
type,
content,
parentId,
children: [],
properties: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
collapsed: false,
aiProcessing: false,
color: "default",
};
// اضافه کردن بلوک در موقعیت مشخص
if (position !== null && position >= 0 && position < this.blocks.length) {
this.blocks.splice(position, 0, newBlock);
} else {
this.blocks.push(newBlock);
}
// اگر والد دارد، به لیست فرزندانش اضافه شود
if (parentId) {
const parent = this.getBlockById(parentId);
if (parent) {
parent.children.push(newBlock.id);
}
}
this.selectedBlockId = newBlock.id;
this.saveToLocalStorage();
return newBlock;
},
// به‌روزرسانی بلوک
updateBlock(blockId, updates) {
const index = this.blocks.findIndex((block) => block.id === blockId);
if (index !== -1) {
this.blocks[index] = {
...this.blocks[index],
...updates,
updatedAt: new Date().toISOString(),
};
this.saveToLocalStorage();
}
},
// حذف بلوک
deleteBlock(blockId) {
const block = this.getBlockById(blockId);
// حذف فرزندان بلوک
if (block && block.children && block.children.length > 0) {
block.children.forEach((childId) => this.deleteBlock(childId));
}
// حذف از لیست فرزندان والد
if (block && block.parentId) {
const parent = this.getBlockById(block.parentId);
if (parent) {
parent.children = parent.children.filter((id) => id !== blockId);
}
}
// حذف بلوک اصلی
this.blocks = this.blocks.filter((block) => block.id !== blockId);
this.saveToLocalStorage();
},
// انتخاب بلوک
selectBlock(blockId) {
this.selectedBlockId = blockId;
},
// جابجایی بلوک
moveBlock(blockId, newParentId = null, newIndex = null) {
const block = this.getBlockById(blockId);
if (!block) return;
// حذف از والد قبلی
if (block.parentId) {
const oldParent = this.getBlockById(block.parentId);
if (oldParent) {
oldParent.children = oldParent.children.filter(
(id) => id !== blockId
);
}
}
// آپدیت والد جدید
block.parentId = newParentId;
// اضافه به لیست فرزندان والد جدید
if (newParentId) {
const newParent = this.getBlockById(newParentId);
if (newParent) {
if (newIndex !== null) {
newParent.children.splice(newIndex, 0, blockId);
} else {
newParent.children.push(blockId);
}
}
}
// جابجایی در آرایه اصلی اگر index مشخص شده
if (newIndex !== null) {
const currentIndex = this.blocks.findIndex((b) => b.id === blockId);
if (currentIndex !== -1) {
const [block] = this.blocks.splice(currentIndex, 1);
this.blocks.splice(newIndex, 0, block);
}
}
this.saveToLocalStorage();
},
// ذخیره در localStorage
saveToLocalStorage() {
if (typeof window !== "undefined") {
localStorage.setItem(
"notion-blocks",
JSON.stringify({
blocks: this.blocks,
version: "1.0.0",
lastSaved: new Date().toISOString(),
})
);
}
},
// بارگذاری از localStorage
loadFromLocalStorage() {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("notion-blocks");
if (saved) {
const data = JSON.parse(saved);
this.blocks = data.blocks || [];
} else {
// ایجاد بلوک‌های پیش‌فرض
this.initializeDefaultBlocks();
}
}
},
// بلوک‌های پیش‌فرض
initializeDefaultBlocks() {
this.blocks = [
{
id: this.generateId(),
type: "heading1",
content: "خوش آمدید به نوشن فارسی! 🎉",
parentId: null,
children: [],
properties: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
collapsed: false,
color: "default",
},
{
id: this.generateId(),
type: "paragraph",
content:
"روی هر بلوک کلیک کنید یا علامت + را بزنید. برای منوی بیشتر راست کلیک کنید.",
parentId: null,
children: [],
properties: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
collapsed: false,
color: "default",
},
{
id: this.generateId(),
type: "todo",
content: "اولین کار را انجام دهید",
parentId: null,
children: [],
properties: { checked: false },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
collapsed: false,
color: "default",
},
];
},
// پیدا کردن موقعیت بلوک
findBlockPosition(blockId) {
return this.blocks.findIndex((block) => block.id === blockId);
},
// پیدا کردن بلوک بعدی
findNextBlock(blockId) {
const index = this.findBlockPosition(blockId);
if (index < this.blocks.length - 1) {
return this.blocks[index + 1];
}
return null;
},
// پیدا کردن بلوک قبلی
findPreviousBlock(blockId) {
const index = this.findBlockPosition(blockId);
if (index > 0) {
return this.blocks[index - 1];
}
return null;
},
},
});

134
app/stores/page.js Executable file
View File

@ -0,0 +1,134 @@
// stores/page.js
import { defineStore } from "pinia";
import { tiptapToBlocks, blocksToTiptap } from "@/utils/tiptapMapper";
export const usePageStore = defineStore("page", {
state: () => ({
blocks: [],
currentPageId: null,
pages: [],
_hydrated: false,
}),
getters: {
getBlocks: (state) => state.blocks,
getPageById: (state) => (id) => {
return state.pages.find((page) => page.id === id);
},
isHydrated: (state) => state._hydrated,
},
actions: {
updateFromEditor(editor) {
this.blocks = tiptapToBlocks(editor.getJSON());
this.saveToLocalStorage();
},
setBlocks(blocks) {
this.blocks = blocks;
},
addBlock(block) {
this.blocks.push({
...block,
id: Date.now().toString(),
createdAt: new Date().toISOString(),
});
this.saveToLocalStorage();
},
removeBlock(blockId) {
this.blocks = this.blocks.filter((block) => block.id !== blockId);
this.saveToLocalStorage();
},
updateBlock(blockId, updates) {
const index = this.blocks.findIndex((block) => block.id === blockId);
if (index !== -1) {
this.blocks[index] = { ...this.blocks[index], ...updates };
this.saveToLocalStorage();
}
},
saveToLocalStorage() {
// فقط در کلاینت اجرا شود
if (typeof window !== "undefined" && window.localStorage) {
try {
localStorage.setItem(
"notion-pages",
JSON.stringify({
blocks: this.blocks,
pages: this.pages,
currentPageId: this.currentPageId,
lastSaved: new Date().toISOString(),
})
);
} catch (error) {
console.warn("خطا در ذخیره localStorage:", error);
}
}
},
loadFromLocalStorage() {
// فقط در کلاینت اجرا شود
if (typeof window !== "undefined" && window.localStorage) {
try {
const saved = localStorage.getItem("notion-pages");
if (saved) {
const data = JSON.parse(saved);
this.blocks = data.blocks || [];
this.pages = data.pages || [];
this.currentPageId = data.currentPageId || null;
this._hydrated = true;
}
} catch (error) {
console.warn("خطا در بارگذاری localStorage:", error);
}
}
},
createPage(title) {
const newPage = {
id: Date.now().toString(),
title: title || "صفحه جدید",
blocks: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
this.pages.push(newPage);
this.saveToLocalStorage();
return newPage;
},
switchPage(pageId) {
const page = this.getPageById(pageId);
if (page) {
this.currentPageId = pageId;
this.blocks = page.blocks;
this.saveToLocalStorage();
}
},
saveCurrentPage() {
if (this.currentPageId) {
const pageIndex = this.pages.findIndex(
(p) => p.id === this.currentPageId
);
if (pageIndex !== -1) {
this.pages[pageIndex].blocks = this.blocks;
this.pages[pageIndex].updatedAt = new Date().toISOString();
this.saveToLocalStorage();
}
}
},
clearAllData() {
this.blocks = [];
this.pages = [];
this.currentPageId = null;
if (typeof window !== "undefined" && window.localStorage) {
localStorage.removeItem("notion-pages");
}
},
},
});

89
app/stores/permissionStore.ts Executable file
View File

@ -0,0 +1,89 @@
// stores/permissionStore.ts
import { defineStore } from "pinia";
import { ref } from "vue";
import { useAuthStore } from "./authStore";
import { useStorage } from "@vueuse/core";
export const getUserPermission = defineStore("permissionStore", () => {
// State
let permissions = ref<any[]>([]);
const hasPagePermission = (to_fullPath: string) => {
// fullPath: "/data-entry/content"
// fullPath: "/data-entry/content?page=2"
let index = to_fullPath.indexOf("?");
if (index != -1) to_fullPath = to_fullPath.substring(0, index);
let items = to_fullPath.split("/");
let page_key = items[1] + "_" + items[2];
// console.log('hasPagePermission page_key ', to_fullPath, page_key);
return permissions.value.some((p) => p.section_tag === page_key);
};
const hasActionPermission = (to_fullPath: string, action_tag: string) => {
// fullPath: "/data-entry/content?page=2"
let index = to_fullPath.indexOf("?");
if (index != -1) to_fullPath = to_fullPath.substring(0, index);
let items = to_fullPath.split("/");
let page_key = items[1] + "_" + items[2];
return permissions.value.some(
(p) => p.section_tag === page_key && p.action_tag === action_tag
);
};
// const hasPermission = (action: string) => {
// return permissions.value.some((p) => p.action_tag === action);
// };
// const canAccessPage = (pageTag: string) => {
// return permissions.value.some((p) => p.section_tag === pageTag);
// };
// Actions
const fetchUserPermissions = async (projectId?: number) => {
const { $http: httpService } = useNuxtApp();
const authStore = useAuthStore();
if (!authStore.user && !localStorage.getItem("user").length) {
console.log("No authenticated user found");
return;
}
try {
const data = {
project_id: 50,
project_only: 1,
};
const response = await httpService.postRequest(
// permitApi.permissions.userPermissionTags,
data
);
permissions.value = response.data || [];
useStorage("permit", permissions.value);
// console.log(" fetchUserPermissions ", permissions.value);
} catch (error) {
console.error("❌ Failed to fetch user permissions:", error);
}
};
const reset = () => {
permissions.value = [];
};
return {
// State
permissions,
// Getters
hasPagePermission,
hasActionPermission,
// Actions
fetchUserPermissions,
reset,
};
});

34
app/types/blocks.js Executable file
View File

@ -0,0 +1,34 @@
/**
* @typedef {Object} BaseBlock
* @property {string} id
* @property {string} type
*/
/**
* @typedef {BaseBlock & {
* type: 'paragraph',
* text: string
* }} ParagraphBlock
*/
/**
* @typedef {BaseBlock & {
* type: 'heading',
* level: 1|2|3,
* text: string
* }} HeadingBlock
*/
/**
* @typedef {BaseBlock & {
* type: 'todo',
* text: string,
* checked: boolean
* }} TodoBlock
*/
/**
* @typedef {ParagraphBlock | HeadingBlock | TodoBlock} Block
*/
export {};

15
app/types/nuxt.d.ts vendored Executable file
View File

@ -0,0 +1,15 @@
import 'vue-i18n'
declare module '#app' {
interface NuxtApp {
$t: typeof import('vue-i18n')['createI18n']['prototype']['t']
}
}
declare module 'vue' {
interface ComponentCustomProperties {
$t: typeof import('vue-i18n')['createI18n']['prototype']['t']
}
}
export {}

196
app/utils/searchUtil.js Executable file
View File

@ -0,0 +1,196 @@
/* =====================================================
Helpers (ملحقات لازم)
===================================================== */
/**
* جایگزین globalMixin.myEncodeQuery
*/
// export function myEncodeQuery(text) {
// if (!text) return "";
// return encodeURIComponent(text);
// }
/**
* پاکسازی کاراکترهای غیرمجاز
*/
export function cleanTextUnpermittedChars(text) {
if (!text) return "";
// همان منطق قبلی (اصلاح‌شده برای JS صحیح)
text = text.replace(/([\x00-\x1F]|\x7F)/g, "");
return text;
}
/* =====================================================
Main: utilGetSearchRequest
===================================================== */
export function utilGetSearchRequest(
index_key,
textSearch,
searchTypeKey,
mode_url = "normal",
filterPanel = "",
filterExtended = "",
offset = 0,
limit = 10,
field_collapse = "normal",
sortKey = "normal",
domainKey = "all",
domainSchema = {},
user_synonyms = {},
mirror_type = ""
) {
let _payload = {};
let domain_label = "";
let query_string = cleanTextUnpermittedChars(textSearch);
/* ===== Base URL ===== */
let baseUrl = repoApi.search.queryNormal;
if (mode_url === "elp") {
baseUrl = elpApi.search.elp_base_search;
baseUrl = baseUrl.replace("{{index_key}}", index_key);
}
else if ( mode_url === "elp_db") {
baseUrl = elpApi.search.elp_db_search;
baseUrl = baseUrl.replace("{{index_key}}", index_key);
}
else if (mode_url === "elp_voice") {
baseUrl = elpApi.search.elp_voice_search;
baseUrl = baseUrl.replace("{{index_key}}", index_key);
} else if (mode_url === "mirror") {
baseUrl = repoApi.search.mirror_search;
baseUrl = baseUrl.replace("{{mirror_type}}", mirror_type);
}
/* =====================================================
Synonym / Vector / Normal
===================================================== */
let sysnonymSearchQuery = "";
if (!searchTypeKey) searchTypeKey = "normal";
if (searchTypeKey === "synonym") {
let newSynonym = {};
if (user_synonyms) {
Object.keys(user_synonyms).forEach((key) => {
if (
!("isStopWord" in user_synonyms[key] && user_synonyms[key].isStopWord)
) {
newSynonym[key] = user_synonyms[key].value;
sysnonymSearchQuery += " " + key;
}
});
}
if (textSearch === "" || textSearch === undefined)
textSearch = sysnonymSearchQuery;
if (Object.keys(newSynonym).length) {
_payload = {
synonym: newSynonym,
};
}
} else if (searchTypeKey === "vector") {
/* ===== Vector ===== */
_payload = {
text: query_string,
};
} else if (!(textSearch === "" || textSearch === undefined)) {
/* ===== Normal ===== */
query_string = query_string;
if (!domainKey) domainKey = "all";
if (domainKey === "all") {
if (domainSchema?.type_action) {
let isNumberInput = /^\d+$/.test(query_string);
if (isNumberInput && domainSchema.type_action.number) {
let tag = domainSchema.type_action.number;
query_string = encodeURIComponent("#") + tag + " " + query_string;
}
}
} else if (domainKey !== "advance") {
let domainItem = domainSchema?.domain?.find(
(item) => item.key === domainKey
);
if (domainItem) {
domain_label = domainItem.label;
query_string =
encodeURIComponent("#") + domainItem.tag + " " + query_string;
}
}
}
/* =====================================================
Filters
===================================================== */
let filterFull = "";
if (searchTypeKey !== "vector" && query_string) {
filterFull += "q=" + query_string;
}
filterFull += filterPanel + filterExtended;
if (!filterFull) filterFull = "none";
/* =====================================================
URL Replace
===================================================== */
let url = baseUrl;
const buildName = import.meta.env.VITE_BUILD_NAME;
url = url.replace("{{appname}}", buildName);
url = url.replace("{{index_key}}", index_key);
url = url.replace("{{search_type}}", searchTypeKey);
url = url.replace("{{sortKey}}", sortKey);
url = url.replace("{{field_collapse}}", field_collapse);
url = url.replace("{{offset}}", offset);
url = url.replace("{{limit}}", limit);
url = url.replace("{{filter}}", filterFull);
/* =====================================================
Payload
===================================================== */
let filters = (filterPanel + filterExtended)
.split("&")
.filter((e) => e.length);
let filters_obj = {};
filters.forEach((fl) => {
let fl_items = fl.split("=");
if (fl_items.length === 2) filters_obj[fl_items[0]] = fl_items[1];
});
let payload_full = {
track_total_hits: true,
query: query_string,
search_type: searchTypeKey,
filters: filters_obj,
sort: [sortKey],
from_: offset,
size: limit,
collapse_field: field_collapse,
};
if (_payload.synonym) {
payload_full.user_synonyms = _payload.synonym;
}
return {
url,
payload_full,
payload: _payload,
sysnonymSearchQuery,
domainLabel: domain_label,
};
}

141
app/utils/slashExtension.js Executable file
View File

@ -0,0 +1,141 @@
import { Extension } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
import { ref, h } from "vue";
export const SlashCommand = Extension.create({
name: "slash-command",
addOptions() {
return {
suggestion: {
char: "/",
startOfLine: true,
command: ({ editor, range, props }) => {
// props.type => 'paragraph' | 'heading' | 'todo'
if (props.type === "paragraph") {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "paragraph",
content: [{ type: "text", text: "" }],
})
.run();
}
if (props.type === "heading") {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 2 })
.run();
}
if (props.type === "todo") {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "paragraph",
content: [{ type: "text", text: "☑️ Todo " }],
})
.run();
}
},
items: ({ query }) => {
const all = [
{ title: "Text", type: "paragraph" },
{ title: "Heading", type: "heading" },
{ title: "Todo", type: "todo" },
];
return all.filter((item) =>
item.title.toLowerCase().startsWith(query.toLowerCase())
);
},
render: () => {
let component;
let popup;
return {
onStart: (props) => {
component = ref({
items: props.items,
command: props.command,
selected: 0,
});
popup = document.createElement("div");
popup.style.position = "absolute";
popup.style.background = "white";
popup.style.border = "1px solid #ddd";
popup.style.borderRadius = "6px";
popup.style.padding = "4px 0";
popup.style.zIndex = 100;
document.body.appendChild(popup);
update();
function update() {
popup.innerHTML = "";
component.value.items.forEach((item, i) => {
const div = document.createElement("div");
div.textContent = item.title;
div.style.padding = "4px 12px";
div.style.cursor = "pointer";
div.style.background =
i === component.value.selected ? "#f0f0f0" : "white";
div.onclick = () =>
component.value.command({
editor: props.editor,
range: props.range,
props: item,
});
popup.appendChild(div);
});
}
component.value.update = update;
},
onUpdate: (props) => {
component.value.items = props.items;
component.value.update();
},
onKeyDown: (props) => {
const event = props.event;
if (event.key === "ArrowDown") {
component.value.selected =
(component.value.selected + 1) % component.value.items.length;
component.value.update();
return true;
}
if (event.key === "ArrowUp") {
component.value.selected =
(component.value.selected -
1 +
component.value.items.length) %
component.value.items.length;
component.value.update();
return true;
}
if (event.key === "Enter") {
component.value.command({
editor: props.editor,
range: props.range,
props: component.value.items[component.value.selected],
});
return true;
}
return false;
},
onExit: () => {
popup.remove();
},
};
},
},
};
},
addProseMirrorPlugins() {
return [Suggestion(this.options.suggestion)];
},
});

363
app/utils/tiptapMapper.js Executable file
View File

@ -0,0 +1,363 @@
// utils/tiptapMapper.js
export function tiptapToBlocks(tiptapJSON) {
const blocks = [];
function processNode(node, parentId = null) {
if (!node) return null;
const blockId = generateId();
switch (node.type) {
case "doc":
if (node.content) {
node.content.forEach((child) => processNode(child, null));
}
return null;
case "paragraph":
const paragraphBlock = {
id: blockId,
type: "paragraph",
content: extractTextContent(node),
parentId: parentId,
createdAt: new Date().toISOString(),
children: [],
};
blocks.push(paragraphBlock);
// پردازش فرزندان
if (node.content) {
node.content.forEach((child) => {
if (child.type !== "text") {
const childBlock = processNode(child, blockId);
if (childBlock) {
paragraphBlock.children.push(childBlock.id);
}
}
});
}
return paragraphBlock;
case "heading":
const headingBlock = {
id: blockId,
type: "heading",
level: node.attrs?.level || 1,
content: extractTextContent(node),
parentId: parentId,
createdAt: new Date().toISOString(),
children: [],
};
blocks.push(headingBlock);
// پردازش فرمت‌های درون عنوان
if (node.content) {
node.content.forEach((child) => {
if (child.type !== "text") {
const childBlock = processNode(child, blockId);
if (childBlock) {
headingBlock.children.push(childBlock.id);
}
}
});
}
return headingBlock;
case "bulletList":
case "orderedList":
const isOrdered = node.type === "orderedList";
const listBlock = {
id: blockId,
type: isOrdered ? "numbered_list" : "bulleted_list",
content: "",
parentId: parentId,
createdAt: new Date().toISOString(),
children: [],
};
blocks.push(listBlock);
// پردازش آیتم‌های لیست
if (node.content) {
node.content.forEach((listItem, index) => {
if (listItem.type === "listItem") {
const itemContent = extractTextContent(listItem);
const itemBlock = {
id: generateId(),
type: "list_item",
content: itemContent,
parentId: blockId,
number: isOrdered ? index + 1 : null,
createdAt: new Date().toISOString(),
children: [],
};
blocks.push(itemBlock);
listBlock.children.push(itemBlock.id);
// پردازش محتوای درون آیتم لیست
if (listItem.content) {
listItem.content.forEach((child) => {
if (child.type !== "paragraph" && child.type !== "text") {
const childBlock = processNode(child, itemBlock.id);
if (childBlock) {
itemBlock.children.push(childBlock.id);
}
}
});
}
}
});
}
return listBlock;
case "codeBlock":
const codeBlock = {
id: blockId,
type: "code",
language: node.attrs?.language || "javascript",
content: extractTextContent(node),
parentId: parentId,
createdAt: new Date().toISOString(),
children: [],
};
blocks.push(codeBlock);
return codeBlock;
case "blockquote":
const quoteBlock = {
id: blockId,
type: "quote",
content: extractTextContent(node),
parentId: parentId,
createdAt: new Date().toISOString(),
children: [],
};
blocks.push(quoteBlock);
return quoteBlock;
case "text":
// متن ساده را به عنوان یک بلوک متنی برمی‌گردانیم
const textBlock = {
id: blockId,
type: "text",
content: node.text || "",
marks: node.marks || [],
parentId: parentId,
createdAt: new Date().toISOString(),
children: [],
};
// فقط اگر درون یک بلوک دیگر نباشد، آن را به عنوان بلوک مستقل اضافه می‌کنیم
if (!parentId) {
blocks.push(textBlock);
}
return textBlock;
case "hardBreak":
const breakBlock = {
id: blockId,
type: "break",
content: "\n",
parentId: parentId,
createdAt: new Date().toISOString(),
children: [],
};
if (!parentId) {
blocks.push(breakBlock);
}
return breakBlock;
default:
// برای انواع ناشناخته
if (node.content) {
const unknownBlock = {
id: blockId,
type: "unknown",
content: extractTextContent(node),
originalType: node.type,
parentId: parentId,
createdAt: new Date().toISOString(),
children: [],
};
blocks.push(unknownBlock);
// بازگشتی پردازش فرزندان
node.content.forEach((child) => {
if (child.type !== "text") {
const childBlock = processNode(child, blockId);
if (childBlock) {
unknownBlock.children.push(childBlock.id);
}
}
});
return unknownBlock;
}
return null;
}
}
function extractTextContent(node) {
if (!node.content) return node.text || "";
let text = "";
node.content.forEach((child) => {
if (child.type === "text") {
text += child.text;
} else if (child.content || child.text) {
text += extractTextContent(child);
}
});
return text;
}
function generateId() {
return (
"block_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9)
);
}
// شروع پردازش
if (tiptapJSON && tiptapJSON.content) {
tiptapJSON.content.forEach((child) => processNode(child, null));
}
return blocks;
}
// تابع معکوس برای تست (اختیاری)
export function blocksToTiptap(blocks) {
const content = [];
const blockMap = new Map();
// ایجاد نقشه برای دسترسی سریع
blocks.forEach((block) => {
blockMap.set(block.id, block);
});
// تابع بازگشتی برای ایجاد گره‌ها
function createNode(block) {
switch (block.type) {
case "paragraph":
return {
type: "paragraph",
content: [
{
type: "text",
text: block.content || "",
},
],
};
case "heading":
return {
type: "heading",
attrs: { level: block.level || 1 },
content: [
{
type: "text",
text: block.content || "",
},
],
};
case "bulleted_list":
case "numbered_list":
const listItems = [];
if (block.children && block.children.length > 0) {
block.children.forEach((childId) => {
const childBlock = blockMap.get(childId);
if (childBlock && childBlock.type === "list_item") {
listItems.push({
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: childBlock.content || "",
},
],
},
],
});
}
});
}
return {
type: block.type === "numbered_list" ? "orderedList" : "bulletList",
content: listItems,
};
case "code":
return {
type: "codeBlock",
attrs: { language: block.language || "javascript" },
content: [
{
type: "text",
text: block.content || "",
},
],
};
case "quote":
return {
type: "blockquote",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: block.content || "",
},
],
},
],
};
default:
return {
type: "paragraph",
content: [
{
type: "text",
text: block.content || "",
},
],
};
}
}
// فقط بلوک‌هایی که والد ندارند (بلوک‌های سطح بالا)
const topLevelBlocks = blocks.filter((block) => !block.parentId);
topLevelBlocks.forEach((block) => {
const node = createNode(block);
if (node) {
content.push(node);
}
});
return {
type: "doc",
content: content,
};
}

78
nuxt.config.ts Executable file
View File

@ -0,0 +1,78 @@
// nuxt.config.ts
import { fileURLToPath } from "node:url";
export default defineNuxtConfig({
compatibilityDate: "2025-07-15",
devtools: { enabled: true },
modules: ["@nuxt/ui", "@pinia/nuxt", "@nuxt/icon"],
css: ["~/assets/css/main.css"],
imports: {
dirs: ["stores", "composables", "apis"],
},
icon: {
serverBundle: {
collections: ["lucide", "system-uicons", "meteor-icons"],
},
},
ui: {
icons: {
collections: ["lucide", "system-uicons", "meteor-icons"],
},
},
components: [
{
path: "~/components/auto-import",
extensions: ["vue"],
pathPrefix: false,
},
],
runtimeConfig: {
public: {
...import.meta.env,
system: import.meta.env.NUXT_PUBLIC_SYSTEM,
},
},
alias: {
// مسیرهای اصلی با @
"@": fileURLToPath(new URL("./app", import.meta.url)), // srcDir
"@@": fileURLToPath(new URL(".", import.meta.url)), // rootDir
// پوشه‌های داخل app/
"@/components": fileURLToPath(new URL("./app/components", import.meta.url)),
"@/composables": fileURLToPath(
new URL("./app/composables", import.meta.url)
),
"@/layouts": fileURLToPath(new URL("./app/layouts", import.meta.url)),
"@/pages": fileURLToPath(new URL("./app/pages", import.meta.url)),
"@/types": fileURLToPath(new URL("./app/types", import.meta.url)),
"@/plugins": fileURLToPath(new URL("./app/plugins", import.meta.url)),
"@/middleware": fileURLToPath(new URL("./app/middleware", import.meta.url)),
"@/modules": fileURLToPath(new URL("./app/modules", import.meta.url)),
"@/json": fileURLToPath(new URL("./app/json", import.meta.url)),
"@/manuals": fileURLToPath(new URL("./app/manuals", import.meta.url)),
"@/stores": fileURLToPath(new URL("./app/stores", import.meta.url)),
// assets
"@/assets": fileURLToPath(new URL("./app/assets", import.meta.url)),
"@/images": fileURLToPath(new URL("./app/assets/images", import.meta.url)),
"@/styles": fileURLToPath(new URL("./app/assets/styles", import.meta.url)), // یا style
"@/fonts": fileURLToPath(new URL("./app/assets/fonts", import.meta.url)),
// public (در rootDir)
"@/public": fileURLToPath(new URL("./public", import.meta.url)),
// .nuxt (داخل rootDir)
"@/build": fileURLToPath(new URL("./.nuxt", import.meta.url)),
"@/internal/nuxt/paths": fileURLToPath(
new URL("./.nuxt/paths.mjs", import.meta.url)
),
// shared (اگر وجود داشته باشد — در rootDir)
"@/shared": fileURLToPath(new URL("./shared", import.meta.url)),
},
fonts: {
providers: false,
},
});

16456
package-lock.json generated Executable file

File diff suppressed because it is too large Load Diff

40
package.json Executable file
View File

@ -0,0 +1,40 @@
{
"name": "hamfahmi-front2",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev-tavasi": "env-cmd -f .env.tavasi nuxt dev --host --port 3007 --inspect",
"dev": "nuxt dev"
},
"dependencies": {
"@iconify-json/meteor-icons": "^1.2.1",
"@nuxt/icon": "^2.2.1",
"@nuxt/ui": "^4.3.0",
"@nuxtjs/i18n": "^10.2.1",
"@pinia/nuxt": "^0.11.3",
"@tiptap/extension-color": "^3.19.0",
"@tiptap/extension-drag-handle": "^3.19.0",
"@tiptap/extension-horizontal-rule": "^3.19.0",
"@tiptap/extension-link": "^3.19.0",
"@tiptap/extension-placeholder": "^3.19.0",
"@tiptap/extension-task-item": "^3.19.0",
"@tiptap/extension-task-list": "^3.19.0",
"@tiptap/extension-text-align": "^3.19.0",
"@tiptap/extension-text-style": "^3.19.0",
"@tiptap/starter-kit": "^3.18.0",
"@tiptap/vue-3": "^3.18.0",
"@vueuse/integrations": "^14.1.0",
"lodash-es": "^4.17.22",
"nuxt": "^4.2.1",
"pinia": "^3.0.4",
"vue": "^3.5.25",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.86",
"env-cmd": "^11.0.0",
"sass": "^1.95.0",
"vue-draggable-next": "^2.3.0"
}
}

BIN
public/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/fonts/sahel/Sahel-Bold.eot Executable file

Binary file not shown.

BIN
public/fonts/sahel/Sahel-Bold.ttf Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/fonts/sahel/Sahel-VF.ttf Executable file

Binary file not shown.

BIN
public/fonts/sahel/Sahel-VF.woff2 Executable file

Binary file not shown.

BIN
public/fonts/sahel/Sahel.eot Executable file

Binary file not shown.

BIN
public/fonts/sahel/Sahel.ttf Executable file

Binary file not shown.

BIN
public/fonts/sahel/Sahel.woff Executable file

Binary file not shown.

BIN
public/fonts/sahel/Sahel.woff2 Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/logo/majles/dark_logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
public/logo/majles/light_logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/logo/monir/logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

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