add contact request
This commit is contained in:
@@ -85,6 +85,17 @@ export interface MediaItem {
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface ContactRequest {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
phone?: string
|
||||
category: string
|
||||
message: string
|
||||
status: 'new' | 'read' | 'archived'
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface SiteSettings {
|
||||
name?: string
|
||||
nameEn?: string
|
||||
@@ -296,6 +307,21 @@ export const api = {
|
||||
deleteMedia(id: string) {
|
||||
return request<void>(`/media/${id}`, { method: 'DELETE' })
|
||||
},
|
||||
|
||||
getContactRequests() {
|
||||
return request<ContactRequest[]>('/contact-requests')
|
||||
},
|
||||
|
||||
updateContactRequest(id: string, data: Pick<ContactRequest, 'status'>) {
|
||||
return request<ContactRequest>(`/contact-requests/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
|
||||
deleteContactRequest(id: string) {
|
||||
return request<void>(`/contact-requests/${id}`, { method: 'DELETE' })
|
||||
},
|
||||
}
|
||||
|
||||
export { ApiError }
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
<template>
|
||||
<div class="blog-editor overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div class="flex flex-wrap items-center gap-1 border-b border-slate-200 bg-slate-50 px-2 py-2">
|
||||
<div class="flex flex-wrap items-center gap-0.5">
|
||||
<button
|
||||
v-for="item in headingOptions"
|
||||
:key="item.level"
|
||||
type="button"
|
||||
class="editor-btn"
|
||||
:class="{ 'is-active': editor?.isActive('heading', { level: item.level }) }"
|
||||
:title="item.label"
|
||||
@click="setHeading(item.level)"
|
||||
>
|
||||
{{ item.short }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="editor-divider" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn"
|
||||
:class="{ 'is-active': editor?.isActive('bold') }"
|
||||
title="درشت"
|
||||
@click="editor?.chain().focus().toggleBold().run()"
|
||||
>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn"
|
||||
:class="{ 'is-active': editor?.isActive('italic') }"
|
||||
title="کج"
|
||||
@click="editor?.chain().focus().toggleItalic().run()"
|
||||
>
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn"
|
||||
:class="{ 'is-active': editor?.isActive('underline') }"
|
||||
title="زیرخط"
|
||||
@click="editor?.chain().focus().toggleUnderline().run()"
|
||||
>
|
||||
<span class="underline">U</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn"
|
||||
:class="{ 'is-active': editor?.isActive('strike') }"
|
||||
title="خطخورده"
|
||||
@click="editor?.chain().focus().toggleStrike().run()"
|
||||
>
|
||||
<span class="line-through">S</span>
|
||||
</button>
|
||||
|
||||
<span class="editor-divider" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn"
|
||||
:class="{ 'is-active': editor?.isActive({ textAlign: 'right' }) }"
|
||||
title="راستچین"
|
||||
@click="editor?.chain().focus().setTextAlign('right').run()"
|
||||
>
|
||||
≡
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn"
|
||||
:class="{ 'is-active': editor?.isActive({ textAlign: 'center' }) }"
|
||||
title="وسطچین"
|
||||
@click="editor?.chain().focus().setTextAlign('center').run()"
|
||||
>
|
||||
≡
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn"
|
||||
:class="{ 'is-active': editor?.isActive({ textAlign: 'left' }) }"
|
||||
title="چپچین"
|
||||
@click="editor?.chain().focus().setTextAlign('left').run()"
|
||||
>
|
||||
≡
|
||||
</button>
|
||||
|
||||
<span class="editor-divider" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn"
|
||||
:class="{ 'is-active': editor?.isActive('bulletList') }"
|
||||
title="لیست نقطهای"
|
||||
@click="editor?.chain().focus().toggleBulletList().run()"
|
||||
>
|
||||
•≡
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn"
|
||||
:class="{ 'is-active': editor?.isActive('orderedList') }"
|
||||
title="لیست شمارهدار"
|
||||
@click="editor?.chain().focus().toggleOrderedList().run()"
|
||||
>
|
||||
1.
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn"
|
||||
:class="{ 'is-active': editor?.isActive('blockquote') }"
|
||||
title="نقلقول"
|
||||
@click="editor?.chain().focus().toggleBlockquote().run()"
|
||||
>
|
||||
"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn"
|
||||
title="خط جداکننده"
|
||||
@click="editor?.chain().focus().setHorizontalRule().run()"
|
||||
>
|
||||
―
|
||||
</button>
|
||||
|
||||
<span class="editor-divider" />
|
||||
|
||||
<button type="button" class="editor-btn" title="لینک" @click="setLink">
|
||||
🔗
|
||||
</button>
|
||||
<button
|
||||
v-if="editor?.isActive('link')"
|
||||
type="button"
|
||||
class="editor-btn"
|
||||
title="حذف لینک"
|
||||
@click="editor?.chain().focus().unsetLink().run()"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn"
|
||||
:disabled="uploadingImage"
|
||||
title="درج تصویر در محل مکاننما"
|
||||
@click="triggerImageUpload('insert')"
|
||||
>
|
||||
{{ uploadingImage ? '...' : '🖼' }}
|
||||
</button>
|
||||
<template v-if="editor?.isActive('image')">
|
||||
<button type="button" class="editor-btn" title="ویرایش تصویر" @click="editSelectedImage">
|
||||
✎
|
||||
</button>
|
||||
<button type="button" class="editor-btn" title="جایگزینی تصویر" @click="triggerImageUpload('replace')">
|
||||
↻
|
||||
</button>
|
||||
<button type="button" class="editor-btn" title="حذف تصویر" @click="deleteSelectedImage">
|
||||
🗑
|
||||
</button>
|
||||
</template>
|
||||
<input
|
||||
ref="imageInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="onImageSelected"
|
||||
/>
|
||||
|
||||
<span class="editor-divider" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn"
|
||||
:disabled="!editor?.can().chain().focus().undo().run()"
|
||||
title="بازگشت"
|
||||
@click="editor?.chain().focus().undo().run()"
|
||||
>
|
||||
↶
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn"
|
||||
:disabled="!editor?.can().chain().focus().redo().run()"
|
||||
title="ازنو"
|
||||
@click="editor?.chain().focus().redo().run()"
|
||||
>
|
||||
↷
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-[220px_1fr]">
|
||||
<aside class="border-b border-slate-200 bg-slate-50/80 p-3 lg:border-b-0 lg:border-l">
|
||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
افزودن بلوک
|
||||
</p>
|
||||
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<button
|
||||
v-for="block in blockWidgets"
|
||||
:key="block.id"
|
||||
type="button"
|
||||
class="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-right text-sm text-slate-700 transition hover:border-brand/40 hover:bg-brand/5"
|
||||
@click="block.action()"
|
||||
>
|
||||
<span class="text-base" aria-hidden="true">{{ block.icon }}</span>
|
||||
<span>{{ block.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="relative">
|
||||
<EditorContent :editor="editor" class="blog-editor-content" />
|
||||
<p v-if="editor" class="border-t border-slate-100 px-4 py-2 text-xs text-slate-400">
|
||||
{{ wordCount }} کلمه · {{ characterCount }} کاراکتر
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { EditorContent, useEditor } from '@tiptap/vue-3'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import TextAlign from '@tiptap/extension-text-align'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import CharacterCount from '@tiptap/extension-character-count'
|
||||
import { BlogImage } from '@/components/blogImageExtension'
|
||||
import { api } from '@/api/client'
|
||||
import { normalizeEditorContent } from '@/utils/blogContent'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const imageInput = ref<HTMLInputElement | null>(null)
|
||||
const uploadingImage = ref(false)
|
||||
const imageUploadMode = ref<'insert' | 'replace'>('insert')
|
||||
|
||||
const headingOptions = [
|
||||
{ level: 2 as const, label: 'عنوان بخش', short: 'H2' },
|
||||
{ level: 3 as const, label: 'زیرعنوان', short: 'H3' },
|
||||
{ level: 4 as const, label: 'عنوان کوچک', short: 'H4' },
|
||||
]
|
||||
|
||||
const editor = useEditor({
|
||||
content: normalizeEditorContent(props.modelValue),
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [2, 3, 4] },
|
||||
}),
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { rel: 'noopener noreferrer' },
|
||||
}),
|
||||
BlogImage.configure({
|
||||
HTMLAttributes: { class: 'blog-editor-image' },
|
||||
inline: false,
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: 'محتوای مقاله را اینجا بنویسید یا از پنل کناری بلوک اضافه کنید...',
|
||||
}),
|
||||
CharacterCount,
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'blog-editor-prose',
|
||||
dir: 'rtl',
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: current }) => {
|
||||
emit('update:modelValue', current.getHTML())
|
||||
},
|
||||
})
|
||||
|
||||
const characterCount = computed(() => editor.value?.storage.characterCount.characters() ?? 0)
|
||||
const wordCount = computed(() => editor.value?.storage.characterCount.words() ?? 0)
|
||||
|
||||
const blockWidgets = computed(() => [
|
||||
{
|
||||
id: 'paragraph',
|
||||
icon: '¶',
|
||||
label: 'پاراگراف',
|
||||
action: () => editor.value?.chain().focus().setParagraph().run(),
|
||||
},
|
||||
{
|
||||
id: 'h2',
|
||||
icon: 'H',
|
||||
label: 'عنوان اصلی',
|
||||
action: () => editor.value?.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||
},
|
||||
{
|
||||
id: 'h3',
|
||||
icon: 'h',
|
||||
label: 'زیرعنوان',
|
||||
action: () => editor.value?.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||
},
|
||||
{
|
||||
id: 'list',
|
||||
icon: '•',
|
||||
label: 'لیست',
|
||||
action: () => editor.value?.chain().focus().toggleBulletList().run(),
|
||||
},
|
||||
{
|
||||
id: 'quote',
|
||||
icon: '❝',
|
||||
label: 'نقلقول',
|
||||
action: () => editor.value?.chain().focus().toggleBlockquote().run(),
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
icon: '🖼',
|
||||
label: 'تصویر',
|
||||
action: () => insertImageAtCursor(),
|
||||
},
|
||||
{
|
||||
id: 'divider',
|
||||
icon: '―',
|
||||
label: 'جداکننده',
|
||||
action: () => editor.value?.chain().focus().setHorizontalRule().run(),
|
||||
},
|
||||
])
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (!editor.value) return
|
||||
const current = editor.value.getHTML()
|
||||
const normalized = normalizeEditorContent(value)
|
||||
if (normalized !== current) {
|
||||
editor.value.commands.setContent(normalized, { emitUpdate: false })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function setHeading(level: 2 | 3 | 4) {
|
||||
editor.value?.chain().focus().toggleHeading({ level }).run()
|
||||
}
|
||||
|
||||
function setLink() {
|
||||
const previous = editor.value?.getAttributes('link').href as string | undefined
|
||||
const url = window.prompt('آدرس لینک را وارد کنید:', previous || 'https://')
|
||||
if (url === null) return
|
||||
if (!url.trim()) {
|
||||
editor.value?.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
return
|
||||
}
|
||||
editor.value?.chain().focus().extendMarkRange('link').setLink({ href: url.trim() }).run()
|
||||
}
|
||||
|
||||
function triggerImageUpload(mode: 'insert' | 'replace' = 'insert') {
|
||||
imageUploadMode.value = mode
|
||||
imageInput.value?.click()
|
||||
}
|
||||
|
||||
function insertImageAtCursor() {
|
||||
if (!editor.value) return
|
||||
editor.value.chain().focus().run()
|
||||
triggerImageUpload('insert')
|
||||
}
|
||||
|
||||
function deleteSelectedImage() {
|
||||
if (!editor.value?.isActive('image')) return
|
||||
if (!confirm('این تصویر از متن حذف شود؟')) return
|
||||
editor.value.chain().focus().deleteSelection().run()
|
||||
}
|
||||
|
||||
function editSelectedImage() {
|
||||
if (!editor.value?.isActive('image')) return
|
||||
const currentAlt = (editor.value.getAttributes('image').alt as string) || ''
|
||||
const alt = window.prompt('توضیح تصویر (زیرنویس):', currentAlt)
|
||||
if (alt === null) return
|
||||
editor.value.chain().focus().updateAttributes('image', { alt: alt.trim() }).run()
|
||||
}
|
||||
|
||||
async function onImageSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file || !editor.value) return
|
||||
|
||||
uploadingImage.value = true
|
||||
try {
|
||||
const uploaded = await api.uploadMedia(file)
|
||||
const currentAlt =
|
||||
imageUploadMode.value === 'replace'
|
||||
? ((editor.value.getAttributes('image').alt as string) || '')
|
||||
: ''
|
||||
const alt =
|
||||
window.prompt('توضیح تصویر (زیرنویس — اختیاری):', currentAlt || file.name) ?? currentAlt
|
||||
|
||||
if (imageUploadMode.value === 'replace' && editor.value.isActive('image')) {
|
||||
editor.value
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes('image', { src: uploaded.url, alt: alt.trim() })
|
||||
.run()
|
||||
} else {
|
||||
editor.value
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({ src: uploaded.url, alt: alt.trim() })
|
||||
.run()
|
||||
}
|
||||
} catch (err) {
|
||||
window.alert(err instanceof Error ? err.message : 'خطا در آپلود تصویر')
|
||||
} finally {
|
||||
uploadingImage.value = false
|
||||
input.value = ''
|
||||
imageUploadMode.value = 'insert'
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor.value?.destroy()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<NodeViewWrapper
|
||||
as="figure"
|
||||
class="blog-image-node"
|
||||
:class="{ 'is-selected': selected }"
|
||||
data-drag-handle
|
||||
>
|
||||
<div class="blog-image-node__frame">
|
||||
<img
|
||||
:src="node.attrs.src"
|
||||
:alt="node.attrs.alt || ''"
|
||||
:title="node.attrs.title || undefined"
|
||||
class="blog-editor-image"
|
||||
draggable="false"
|
||||
@click="selectImage"
|
||||
>
|
||||
|
||||
<div v-if="selected" class="blog-image-node__toolbar">
|
||||
<button type="button" class="blog-image-node__btn" @click.stop="toggleEdit">
|
||||
{{ editing ? 'بستن' : 'ویرایش' }}
|
||||
</button>
|
||||
<button type="button" class="blog-image-node__btn" @click.stop="replaceImage">
|
||||
جایگزین
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="blog-image-node__btn blog-image-node__btn--danger"
|
||||
@click.stop="removeImage"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<figcaption v-if="node.attrs.alt && !editing" class="blog-image-node__caption">
|
||||
{{ node.attrs.alt }}
|
||||
</figcaption>
|
||||
|
||||
<div v-if="selected && editing" class="blog-image-node__panel" @click.stop>
|
||||
<label class="blog-image-node__label">توضیح تصویر (زیرنویس)</label>
|
||||
<input
|
||||
v-model="altText"
|
||||
type="text"
|
||||
class="blog-image-node__input"
|
||||
placeholder="مثلاً: نمونه طراحی لوگو"
|
||||
@keydown.enter.prevent="saveAlt"
|
||||
>
|
||||
<label class="blog-image-node__label">آدرس تصویر</label>
|
||||
<input
|
||||
v-model="srcText"
|
||||
type="text"
|
||||
class="blog-image-node__input"
|
||||
dir="ltr"
|
||||
placeholder="/uploads/..."
|
||||
@keydown.enter.prevent="saveSrc"
|
||||
>
|
||||
<div class="blog-image-node__actions">
|
||||
<button type="button" class="blog-image-node__btn" @click="saveEdits">
|
||||
اعمال تغییرات
|
||||
</button>
|
||||
<button type="button" class="blog-image-node__btn" @click="replaceImage">
|
||||
آپلود تصویر جدید
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="localError" class="blog-image-node__error">{{ localError }}</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="onFileSelected"
|
||||
>
|
||||
</NodeViewWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
|
||||
import { api } from '@/api/client'
|
||||
|
||||
const props = defineProps(nodeViewProps)
|
||||
|
||||
const editing = ref(false)
|
||||
const altText = ref('')
|
||||
const srcText = ref('')
|
||||
const localError = ref('')
|
||||
const uploading = ref(false)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.selected,
|
||||
(isSelected) => {
|
||||
if (!isSelected) editing.value = false
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.node.attrs,
|
||||
(attrs) => {
|
||||
altText.value = attrs.alt || ''
|
||||
srcText.value = attrs.src || ''
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
function selectImage() {
|
||||
const pos = props.getPos()
|
||||
if (typeof pos !== 'number') return
|
||||
props.editor.chain().focus().setNodeSelection(pos).run()
|
||||
}
|
||||
|
||||
function toggleEdit() {
|
||||
editing.value = !editing.value
|
||||
if (editing.value) {
|
||||
altText.value = props.node.attrs.alt || ''
|
||||
srcText.value = props.node.attrs.src || ''
|
||||
}
|
||||
}
|
||||
|
||||
function saveEdits() {
|
||||
props.updateAttributes({
|
||||
alt: altText.value.trim(),
|
||||
src: srcText.value.trim() || props.node.attrs.src,
|
||||
})
|
||||
editing.value = false
|
||||
localError.value = ''
|
||||
}
|
||||
|
||||
function saveAlt() {
|
||||
saveEdits()
|
||||
}
|
||||
|
||||
function saveSrc() {
|
||||
saveEdits()
|
||||
}
|
||||
|
||||
function removeImage() {
|
||||
if (!confirm('این تصویر از متن حذف شود؟')) return
|
||||
props.deleteNode()
|
||||
}
|
||||
|
||||
function replaceImage() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function onFileSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
uploading.value = true
|
||||
localError.value = ''
|
||||
try {
|
||||
const uploaded = await api.uploadMedia(file)
|
||||
props.updateAttributes({ src: uploaded.url })
|
||||
srcText.value = uploaded.url
|
||||
editing.value = true
|
||||
} catch (err) {
|
||||
localError.value = err instanceof Error ? err.message : 'خطا در آپلود تصویر'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
import Image from '@tiptap/extension-image'
|
||||
import { VueNodeViewRenderer } from '@tiptap/vue-3'
|
||||
import BlogImageNode from './BlogImageNode.vue'
|
||||
|
||||
export const BlogImage = Image.extend({
|
||||
addNodeView() {
|
||||
return VueNodeViewRenderer(BlogImageNode)
|
||||
},
|
||||
})
|
||||
@@ -67,6 +67,7 @@ const navItems = [
|
||||
{ name: 'services', label: 'خدمات', icon: '💼' },
|
||||
{ name: 'clients', label: 'مشتریان', icon: '🤝' },
|
||||
{ name: 'blog', label: 'وبلاگ', icon: '📝' },
|
||||
{ name: 'contact-requests', label: 'درخواستها', icon: '📩' },
|
||||
{ name: 'media', label: 'رسانه', icon: '🖼️' },
|
||||
]
|
||||
|
||||
@@ -80,6 +81,7 @@ const titles: Record<string, string> = {
|
||||
clients: 'مشتریان',
|
||||
blog: 'وبلاگ',
|
||||
'blog-edit': 'ویرایش مطلب',
|
||||
'contact-requests': 'درخواستهای تماس',
|
||||
media: 'کتابخانه رسانه',
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,11 @@ const router = createRouter({
|
||||
name: 'media',
|
||||
component: () => import('@/views/MediaView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'contact-requests',
|
||||
name: 'contact-requests',
|
||||
component: () => import('@/views/ContactRequestsView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
+118
@@ -40,4 +40,122 @@
|
||||
.page-title {
|
||||
@apply text-2xl font-bold text-slate-900;
|
||||
}
|
||||
|
||||
.editor-btn {
|
||||
@apply inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm text-slate-600 transition hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-40;
|
||||
}
|
||||
|
||||
.editor-btn.is-active {
|
||||
@apply bg-brand/10 text-brand-700;
|
||||
}
|
||||
|
||||
.editor-divider {
|
||||
@apply mx-1 hidden h-6 w-px bg-slate-200 sm:inline-block;
|
||||
}
|
||||
|
||||
.blog-editor-content .ProseMirror {
|
||||
@apply min-h-[420px] px-5 py-4 text-base leading-8 text-slate-700 outline-none;
|
||||
}
|
||||
|
||||
.blog-editor-content .ProseMirror p.is-editor-empty:first-child::before {
|
||||
@apply pointer-events-none float-right h-0 text-slate-400;
|
||||
content: attr(data-placeholder);
|
||||
}
|
||||
|
||||
.blog-editor-prose h2 {
|
||||
@apply mb-3 mt-8 text-2xl font-bold text-slate-900;
|
||||
}
|
||||
|
||||
.blog-editor-prose h3 {
|
||||
@apply mb-2 mt-6 text-xl font-bold text-slate-900;
|
||||
}
|
||||
|
||||
.blog-editor-prose h4 {
|
||||
@apply mb-2 mt-4 text-lg font-semibold text-slate-900;
|
||||
}
|
||||
|
||||
.blog-editor-prose p {
|
||||
@apply my-3;
|
||||
}
|
||||
|
||||
.blog-editor-prose ul {
|
||||
@apply my-4 list-disc space-y-2 pr-6 marker:text-brand;
|
||||
}
|
||||
|
||||
.blog-editor-prose ol {
|
||||
@apply my-4 list-decimal space-y-2 pr-6 marker:text-brand;
|
||||
}
|
||||
|
||||
.blog-editor-prose blockquote {
|
||||
@apply my-6 border-r-4 border-brand/40 bg-slate-50 px-4 py-3 italic text-slate-600;
|
||||
}
|
||||
|
||||
.blog-editor-prose a {
|
||||
@apply text-brand underline decoration-brand/30 underline-offset-2;
|
||||
}
|
||||
|
||||
.blog-editor-prose hr {
|
||||
@apply my-8 border-slate-200;
|
||||
}
|
||||
|
||||
.blog-editor-prose img,
|
||||
.blog-editor-image {
|
||||
@apply my-6 w-full rounded-xl border border-slate-200 object-cover;
|
||||
}
|
||||
|
||||
.blog-editor-prose figure {
|
||||
@apply my-6 overflow-hidden rounded-xl border border-slate-200 bg-slate-50;
|
||||
}
|
||||
|
||||
.blog-editor-prose figcaption {
|
||||
@apply px-4 py-2 text-center text-sm text-slate-500;
|
||||
}
|
||||
|
||||
.blog-image-node {
|
||||
@apply my-6;
|
||||
}
|
||||
|
||||
.blog-image-node__frame {
|
||||
@apply relative overflow-hidden rounded-xl border-2 border-transparent transition;
|
||||
}
|
||||
|
||||
.blog-image-node.is-selected .blog-image-node__frame {
|
||||
@apply border-brand/50 shadow-md shadow-brand/10;
|
||||
}
|
||||
|
||||
.blog-image-node__toolbar {
|
||||
@apply absolute left-3 top-3 flex flex-wrap gap-1;
|
||||
}
|
||||
|
||||
.blog-image-node__btn {
|
||||
@apply rounded-md bg-white/95 px-2.5 py-1 text-xs font-medium text-slate-700 shadow-sm transition hover:bg-white;
|
||||
}
|
||||
|
||||
.blog-image-node__btn--danger {
|
||||
@apply text-red-600 hover:bg-red-50;
|
||||
}
|
||||
|
||||
.blog-image-node__caption {
|
||||
@apply mt-2 text-center text-sm text-slate-500;
|
||||
}
|
||||
|
||||
.blog-image-node__panel {
|
||||
@apply mt-3 space-y-2 rounded-lg border border-slate-200 bg-slate-50 p-3;
|
||||
}
|
||||
|
||||
.blog-image-node__label {
|
||||
@apply block text-xs font-medium text-slate-600;
|
||||
}
|
||||
|
||||
.blog-image-node__input {
|
||||
@apply w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800 focus:border-brand focus:outline-none focus:ring-2 focus:ring-brand/20;
|
||||
}
|
||||
|
||||
.blog-image-node__actions {
|
||||
@apply flex flex-wrap gap-2 pt-1;
|
||||
}
|
||||
|
||||
.blog-image-node__error {
|
||||
@apply text-xs text-red-600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
type LegacyBlock =
|
||||
| { type: 'h2'; text: string }
|
||||
| { type: 'h3'; text: string }
|
||||
| { type: 'h4'; text: string }
|
||||
| { type: 'p'; text: string }
|
||||
| { type: 'ul'; items: string[] }
|
||||
| { type: 'img'; src: string; alt: string }
|
||||
|
||||
const imagePattern = /^!\[(.*?)\]\((.*?)\)$/
|
||||
const headingPattern = /^(#{2,4})\s+(.*)$/
|
||||
|
||||
function escapeHtml(text: string) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function parseLegacyContent(content: string): LegacyBlock[] {
|
||||
const blocks: LegacyBlock[] = []
|
||||
const lines = content.replace(/\r\n/g, '\n').split('\n')
|
||||
let paragraph: string[] = []
|
||||
let listItems: string[] = []
|
||||
|
||||
const flushParagraph = () => {
|
||||
const text = paragraph.join('\n').trim()
|
||||
if (text) blocks.push({ type: 'p', text })
|
||||
paragraph = []
|
||||
}
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length) blocks.push({ type: 'ul', items: [...listItems] })
|
||||
listItems = []
|
||||
}
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const trimmed = rawLine.trimEnd().trim()
|
||||
if (!trimmed) {
|
||||
flushParagraph()
|
||||
flushList()
|
||||
continue
|
||||
}
|
||||
|
||||
const imageMatch = trimmed.match(imagePattern)
|
||||
if (imageMatch) {
|
||||
flushParagraph()
|
||||
flushList()
|
||||
blocks.push({
|
||||
type: 'img',
|
||||
alt: imageMatch[1] || '',
|
||||
src: imageMatch[2] || '',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const headingMatch = trimmed.match(headingPattern)
|
||||
if (headingMatch) {
|
||||
flushParagraph()
|
||||
flushList()
|
||||
const level = headingMatch[1].length
|
||||
const text = headingMatch[2].trim()
|
||||
if (level === 2) blocks.push({ type: 'h2', text })
|
||||
else if (level === 3) blocks.push({ type: 'h3', text })
|
||||
else blocks.push({ type: 'h4', text })
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('* ')) {
|
||||
flushParagraph()
|
||||
listItems.push(trimmed.slice(2).trim())
|
||||
continue
|
||||
}
|
||||
|
||||
flushList()
|
||||
paragraph.push(trimmed)
|
||||
}
|
||||
|
||||
flushParagraph()
|
||||
flushList()
|
||||
return blocks
|
||||
}
|
||||
|
||||
function legacyBlocksToHtml(blocks: LegacyBlock[]) {
|
||||
return blocks
|
||||
.map((block) => {
|
||||
switch (block.type) {
|
||||
case 'h2':
|
||||
return `<h2>${escapeHtml(block.text)}</h2>`
|
||||
case 'h3':
|
||||
return `<h3>${escapeHtml(block.text)}</h3>`
|
||||
case 'h4':
|
||||
return `<h4>${escapeHtml(block.text)}</h4>`
|
||||
case 'p':
|
||||
return `<p>${escapeHtml(block.text).replace(/\n/g, '<br>')}</p>`
|
||||
case 'ul':
|
||||
return `<ul>${block.items.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul>`
|
||||
case 'img':
|
||||
return `<figure><img src="${escapeHtml(block.src)}" alt="${escapeHtml(block.alt)}" />${block.alt ? `<figcaption>${escapeHtml(block.alt)}</figcaption>` : ''}</figure>`
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function isHtmlContent(content: string) {
|
||||
const trimmed = content.trim()
|
||||
return /^</.test(trimmed) || /<(?:p|h[1-6]|ul|ol|figure|img|blockquote)\b/i.test(trimmed)
|
||||
}
|
||||
|
||||
export function normalizeEditorContent(content: string) {
|
||||
if (!content?.trim()) return ''
|
||||
if (isHtmlContent(content)) return content
|
||||
return legacyBlocksToHtml(parseLegacyContent(content))
|
||||
}
|
||||
+79
-41
@@ -1,8 +1,16 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="page-title">{{ isNew ? 'مطلب جدید' : 'ویرایش مطلب' }}</h2>
|
||||
<RouterLink :to="{ name: 'blog' }" class="btn-secondary">بازگشت</RouterLink>
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="page-title">{{ isNew ? 'مطلب جدید' : 'ویرایش مطلب' }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">ویرایشگر بلوکی — شبیه المنتور</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<RouterLink :to="{ name: 'blog' }" class="btn-secondary">بازگشت</RouterLink>
|
||||
<button type="button" class="btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'در حال ذخیره...' : 'ذخیره مطلب' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="message" class="mb-4 rounded-lg bg-green-50 px-4 py-2 text-sm text-green-700">{{ message }}</p>
|
||||
@@ -10,55 +18,84 @@
|
||||
|
||||
<div v-if="loading" class="text-slate-500">در حال بارگذاری...</div>
|
||||
|
||||
<form v-else class="space-y-6" @submit.prevent="save">
|
||||
<section class="card space-y-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="sm:col-span-2">
|
||||
<label class="label">عنوان</label>
|
||||
<input v-model="form.title" class="input" required @input="maybeSlug" />
|
||||
<form v-else class="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_320px]" @submit.prevent="save">
|
||||
<div class="space-y-6">
|
||||
<section class="card space-y-4">
|
||||
<div>
|
||||
<label class="label">عنوان مقاله</label>
|
||||
<input
|
||||
v-model="form.title"
|
||||
class="input text-lg font-semibold"
|
||||
required
|
||||
placeholder="عنوان جذاب برای مقاله..."
|
||||
@input="maybeSlug"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">اسلاگ</label>
|
||||
<input v-model="form.slug" class="input" dir="ltr" required />
|
||||
<label class="label">خلاصه (نمایش در لیست وبلاگ)</label>
|
||||
<textarea
|
||||
v-model="form.excerpt"
|
||||
class="input min-h-[90px]"
|
||||
rows="3"
|
||||
placeholder="۲ تا ۳ جمله خلاصه مقاله..."
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pt-6">
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<label class="label mb-2">محتوای مقاله</label>
|
||||
<BlogEditor v-model="form.content" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-4 xl:sticky xl:top-6">
|
||||
<section class="card space-y-4">
|
||||
<h3 class="font-semibold text-slate-800">انتشار</h3>
|
||||
<div>
|
||||
<label class="label">اسلاگ (URL)</label>
|
||||
<input
|
||||
v-model="form.slug"
|
||||
class="input"
|
||||
dir="ltr"
|
||||
required
|
||||
@input="slugTouched = true"
|
||||
/>
|
||||
</div>
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
id="published"
|
||||
v-model="form.published"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-slate-300 text-brand focus:ring-brand"
|
||||
/>
|
||||
<label for="published" class="text-sm text-slate-700">منتشر شده</label>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="label">خلاصه</label>
|
||||
<textarea v-model="form.excerpt" class="input min-h-[80px]" rows="3" />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="label">محتوا</label>
|
||||
<textarea v-model="form.content" class="input min-h-[200px] font-mono text-sm" rows="12" />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<ImageField v-model="form.coverUrl" label="تصویر کاور مطلب" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<span class="text-sm text-slate-700">منتشر شده</span>
|
||||
</label>
|
||||
<button type="submit" class="btn-primary w-full" :disabled="saving">
|
||||
{{ saving ? 'در حال ذخیره...' : 'ذخیره مطلب' }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="card space-y-4">
|
||||
<h3 class="font-semibold text-slate-800">سئو</h3>
|
||||
<div>
|
||||
<label class="label">عنوان سئو (seoTitle)</label>
|
||||
<input v-model="form.seoTitle" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">توضیحات سئو (seoDescription)</label>
|
||||
<textarea v-model="form.seoDescription" class="input min-h-[80px]" rows="3" />
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<ImageField v-model="form.coverUrl" label="تصویر کاور" />
|
||||
</section>
|
||||
|
||||
<button type="submit" class="btn-primary" :disabled="saving">
|
||||
{{ saving ? 'در حال ذخیره...' : 'ذخیره' }}
|
||||
</button>
|
||||
<section class="card space-y-4">
|
||||
<h3 class="font-semibold text-slate-800">سئو</h3>
|
||||
<div>
|
||||
<label class="label">عنوان سئو</label>
|
||||
<input v-model="form.seoTitle" class="input" placeholder="در صورت خالی بودن، عنوان مقاله استفاده میشود" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">توضیحات سئو</label>
|
||||
<textarea
|
||||
v-model="form.seoDescription"
|
||||
class="input min-h-[90px]"
|
||||
rows="3"
|
||||
placeholder="توضیح کوتاه برای موتورهای جستجو..."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -67,6 +104,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api, type BlogPost } from '@/api/client'
|
||||
import BlogEditor from '@/components/BlogEditor.vue'
|
||||
import ImageField from '@/components/ImageField.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="page-title">درخواستهای تماس</h2>
|
||||
<button type="button" class="btn-secondary" @click="load">بروزرسانی</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="mb-4 rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600">{{ error }}</p>
|
||||
|
||||
<div v-if="loading" class="text-slate-500">در حال بارگذاری...</div>
|
||||
|
||||
<div v-else-if="requests.length === 0" class="card text-center text-slate-500">
|
||||
هنوز درخواستی ثبت نشده است.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="item in requests"
|
||||
:key="item.id"
|
||||
class="card"
|
||||
:class="item.status === 'new' ? 'border-r-4 border-brand' : ''"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="font-semibold text-slate-900">{{ item.name }}</h3>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs"
|
||||
:class="statusClass(item.status)"
|
||||
>
|
||||
{{ statusLabel(item.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ formatDate(item.createdAt) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-if="item.status === 'new'"
|
||||
type="button"
|
||||
class="btn-secondary text-sm"
|
||||
@click="setStatus(item, 'read')"
|
||||
>
|
||||
علامتگذاری خواندهشده
|
||||
</button>
|
||||
<button
|
||||
v-if="item.status !== 'archived'"
|
||||
type="button"
|
||||
class="btn-secondary text-sm"
|
||||
@click="setStatus(item, 'archived')"
|
||||
>
|
||||
بایگانی
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-red-500 hover:underline"
|
||||
@click="remove(item.id)"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<p class="text-xs text-slate-500">ایمیل</p>
|
||||
<a :href="`mailto:${item.email}`" class="text-sm text-brand hover:underline" dir="ltr">
|
||||
{{ item.email }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="item.phone">
|
||||
<p class="text-xs text-slate-500">تلفن</p>
|
||||
<a :href="`tel:${item.phone}`" class="text-sm text-slate-800" dir="ltr">
|
||||
{{ item.phone }}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-slate-500">نوع پروژه</p>
|
||||
<p class="text-sm text-slate-800">{{ categoryLabel(item.category) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-lg bg-slate-50 p-4">
|
||||
<p class="text-xs text-slate-500">توضیحات پروژه</p>
|
||||
<p class="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-slate-700">
|
||||
{{ item.message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api, type ContactRequest } from '@/api/client'
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const requests = ref<ContactRequest[]>([])
|
||||
const categories = ref<Record<string, string>>({})
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleString('fa-IR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
function statusLabel(status: ContactRequest['status']) {
|
||||
if (status === 'new') return 'جدید'
|
||||
if (status === 'read') return 'خواندهشده'
|
||||
return 'بایگانی'
|
||||
}
|
||||
|
||||
function statusClass(status: ContactRequest['status']) {
|
||||
if (status === 'new') return 'bg-brand/10 text-brand-700'
|
||||
if (status === 'read') return 'bg-blue-100 text-blue-700'
|
||||
return 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
|
||||
function categoryLabel(slug: string) {
|
||||
return categories.value[slug] || slug
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [items, cats] = await Promise.all([
|
||||
api.getContactRequests(),
|
||||
api.getPortfolioCategories(),
|
||||
])
|
||||
requests.value = items
|
||||
categories.value = Object.fromEntries(cats.map((c) => [c.slug, c.label]))
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setStatus(item: ContactRequest, status: ContactRequest['status']) {
|
||||
try {
|
||||
const updated = await api.updateContactRequest(item.id, { status })
|
||||
item.status = updated.status
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'خطا در بروزرسانی'
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!confirm('آیا از حذف این درخواست مطمئن هستید؟')) return
|
||||
try {
|
||||
await api.deleteContactRequest(id)
|
||||
requests.value = requests.value.filter((r) => r.id !== id)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'خطا در حذف'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<div v-if="loading" class="text-slate-500">در حال بارگذاری...</div>
|
||||
|
||||
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div v-for="stat in stats" :key="stat.label" class="card">
|
||||
<p class="text-3xl font-bold text-brand">{{ stat.value }}</p>
|
||||
<p class="mt-1 text-sm text-slate-600">{{ stat.label }}</p>
|
||||
@@ -18,6 +18,7 @@
|
||||
<RouterLink :to="{ name: 'site' }" class="btn-secondary">تنظیمات سایت</RouterLink>
|
||||
<RouterLink :to="{ name: 'portfolio-edit' }" class="btn-secondary">نمونهکار جدید</RouterLink>
|
||||
<RouterLink :to="{ name: 'blog-edit' }" class="btn-secondary">مطلب جدید</RouterLink>
|
||||
<RouterLink :to="{ name: 'contact-requests' }" class="btn-secondary">درخواستهای تماس</RouterLink>
|
||||
<RouterLink :to="{ name: 'media' }" class="btn-secondary">آپلود رسانه</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,24 +44,28 @@ const stats = ref([
|
||||
{ value: '—', label: 'خدمات' },
|
||||
{ value: '—', label: 'مشتری' },
|
||||
{ value: '—', label: 'مطلب وبلاگ' },
|
||||
{ value: '—', label: 'درخواست جدید' },
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [site, projects, services, clients, posts] = await Promise.all([
|
||||
const [site, projects, services, clients, posts, contactRequests] = await Promise.all([
|
||||
api.getSite(),
|
||||
api.getProjects(),
|
||||
api.getServices(),
|
||||
api.getClients(),
|
||||
api.getBlogPosts(),
|
||||
api.getContactRequests(),
|
||||
])
|
||||
siteName.value = site.name || 'استودیو نورو'
|
||||
siteTagline.value = site.tagline || ''
|
||||
const newRequests = contactRequests.filter((r) => r.status === 'new').length
|
||||
stats.value = [
|
||||
{ value: String(projects.length), label: 'نمونهکار' },
|
||||
{ value: String(services.length), label: 'خدمات' },
|
||||
{ value: String(clients.length), label: 'مشتری' },
|
||||
{ value: String(posts.length), label: 'مطلب وبلاگ' },
|
||||
{ value: String(newRequests), label: 'درخواست جدید' },
|
||||
]
|
||||
} catch {
|
||||
// stats stay as dashes
|
||||
|
||||
Reference in New Issue
Block a user