add contact request

This commit is contained in:
2026-07-14 11:19:41 +03:30
parent 493ee8105e
commit 259a725fba
15 changed files with 1863 additions and 69 deletions
+26
View File
@@ -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 }
+423
View File
@@ -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>
+167
View File
@@ -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>
+9
View File
@@ -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)
},
})
+2
View File
@@ -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: 'کتابخانه رسانه',
}
+5
View File
@@ -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
View File
@@ -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;
}
}
+116
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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
View File
@@ -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()
+163
View File
@@ -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>
+7 -2
View File
@@ -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