Add image upload fields, media edit/delete, blog seeds
This commit is contained in:
@@ -285,6 +285,17 @@ export const api = {
|
||||
body: form,
|
||||
})
|
||||
},
|
||||
|
||||
updateMedia(id: string, data: Pick<MediaItem, 'filename'>) {
|
||||
return request<MediaItem>(`/media/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
|
||||
deleteMedia(id: string) {
|
||||
return request<void>(`/media/${id}`, { method: 'DELETE' })
|
||||
},
|
||||
}
|
||||
|
||||
export { ApiError }
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="space-y-3 rounded-xl border border-slate-100 bg-slate-50/50 p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<label v-if="label" class="label mb-0">{{ label }}</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label class="btn-secondary cursor-pointer text-xs">
|
||||
{{ uploading ? 'در حال آپلود...' : modelValue ? 'جایگزین' : 'آپلود' }}
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
:accept="accept"
|
||||
:disabled="uploading"
|
||||
@change="onUpload"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
v-if="modelValue"
|
||||
type="button"
|
||||
class="btn-secondary text-xs"
|
||||
@click="editingUrl = !editingUrl"
|
||||
>
|
||||
{{ editingUrl ? 'بستن' : 'ویرایش آدرس' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="modelValue"
|
||||
type="button"
|
||||
class="btn-danger text-xs"
|
||||
@click="remove"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="localError" class="text-xs text-red-600">{{ localError }}</p>
|
||||
|
||||
<div v-if="modelValue" class="overflow-hidden rounded-lg border border-slate-200 bg-white">
|
||||
<img :src="modelValue" :alt="label || 'تصویر'" :class="previewClass" />
|
||||
</div>
|
||||
<p v-else class="text-sm text-slate-500">تصویری انتخاب نشده است.</p>
|
||||
|
||||
<div v-if="editingUrl || !modelValue">
|
||||
<label class="label">آدرس تصویر</label>
|
||||
<input
|
||||
:value="modelValue"
|
||||
class="input"
|
||||
dir="ltr"
|
||||
placeholder="/uploads/..."
|
||||
@input="onUrlInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/api/client'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
modelValue?: string
|
||||
accept?: string
|
||||
previewClass?: string
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
accept: 'image/*',
|
||||
previewClass: 'aspect-video w-full max-w-md object-cover',
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const uploading = ref(false)
|
||||
const editingUrl = ref(false)
|
||||
const localError = ref('')
|
||||
|
||||
async function onUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
uploading.value = true
|
||||
localError.value = ''
|
||||
try {
|
||||
const uploaded = await api.uploadMedia(file)
|
||||
emit('update:modelValue', uploaded.url)
|
||||
editingUrl.value = false
|
||||
} catch (err) {
|
||||
localError.value = err instanceof Error ? err.message : 'خطا در آپلود'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function onUrlInput(e: Event) {
|
||||
emit('update:modelValue', (e.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
function remove() {
|
||||
if (!confirm('آیا از حذف این تصویر مطمئن هستید؟')) return
|
||||
emit('update:modelValue', '')
|
||||
editingUrl.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 v-if="title" class="font-semibold text-slate-800">{{ title }}</h3>
|
||||
<p v-if="description" class="mt-1 text-sm text-slate-500">{{ description }}</p>
|
||||
</div>
|
||||
<label class="btn-secondary cursor-pointer text-xs">
|
||||
{{ uploading ? 'در حال آپلود...' : 'آپلود تصویر' }}
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept="image/*"
|
||||
multiple
|
||||
:disabled="uploading"
|
||||
@change="onUpload"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="localError" class="text-sm text-red-600">{{ localError }}</p>
|
||||
|
||||
<div
|
||||
v-if="modelValue.length === 0"
|
||||
class="rounded-lg border border-dashed border-slate-200 px-4 py-8 text-center text-sm text-slate-500"
|
||||
>
|
||||
هنوز تصویری در گالری نیست.
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div
|
||||
v-for="(url, index) in modelValue"
|
||||
:key="`${url}-${index}`"
|
||||
class="overflow-hidden rounded-xl border border-slate-200 bg-white"
|
||||
>
|
||||
<img :src="url" :alt="`${title || 'گالری'} ${index + 1}`" class="aspect-[4/3] w-full object-cover" />
|
||||
<div class="flex flex-wrap gap-2 p-3">
|
||||
<label class="cursor-pointer text-xs text-brand hover:underline">
|
||||
{{ uploadingIndex === index ? '...' : 'جایگزین' }}
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept="image/*"
|
||||
:disabled="uploadingIndex === index"
|
||||
@change="onReplace($event, index)"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
v-if="showSetCover"
|
||||
type="button"
|
||||
class="text-xs text-brand hover:underline"
|
||||
@click="emit('set-cover', url)"
|
||||
>
|
||||
کاور
|
||||
</button>
|
||||
<button type="button" class="text-xs text-red-500 hover:underline" @click="remove(index)">
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/api/client'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string[]
|
||||
title?: string
|
||||
description?: string
|
||||
showSetCover?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: () => [],
|
||||
showSetCover: false,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]]
|
||||
'set-cover': [url: string]
|
||||
}>()
|
||||
|
||||
const uploading = ref(false)
|
||||
const uploadingIndex = ref<number | null>(null)
|
||||
const localError = ref('')
|
||||
|
||||
async function uploadFile(file: File) {
|
||||
const uploaded = await api.uploadMedia(file)
|
||||
return uploaded.url
|
||||
}
|
||||
|
||||
async function onUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
if (files.length === 0) return
|
||||
|
||||
uploading.value = true
|
||||
localError.value = ''
|
||||
try {
|
||||
const urls = [...props.modelValue]
|
||||
for (const file of files) {
|
||||
urls.push(await uploadFile(file))
|
||||
}
|
||||
emit('update:modelValue', urls)
|
||||
} catch (err) {
|
||||
localError.value = err instanceof Error ? err.message : 'خطا در آپلود'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function onReplace(e: Event, index: number) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
uploadingIndex.value = index
|
||||
localError.value = ''
|
||||
try {
|
||||
const url = await uploadFile(file)
|
||||
const urls = [...props.modelValue]
|
||||
urls[index] = url
|
||||
emit('update:modelValue', urls)
|
||||
} catch (err) {
|
||||
localError.value = err instanceof Error ? err.message : 'خطا در جایگزینی'
|
||||
} finally {
|
||||
uploadingIndex.value = null
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
if (!confirm('آیا از حذف این تصویر از گالری مطمئن هستید؟')) return
|
||||
emit(
|
||||
'update:modelValue',
|
||||
props.modelValue.filter((_, i) => i !== index)
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -39,8 +39,7 @@
|
||||
<textarea v-model="form.content" class="input min-h-[200px] font-mono text-sm" rows="12" />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="label">آدرس تصویر کاور</label>
|
||||
<input v-model="form.coverUrl" class="input" dir="ltr" placeholder="/uploads/..." />
|
||||
<ImageField v-model="form.coverUrl" label="تصویر کاور مطلب" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -68,6 +67,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api, type BlogPost } from '@/api/client'
|
||||
import ImageField from '@/components/ImageField.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -26,19 +26,12 @@
|
||||
<label class="label">نام</label>
|
||||
<input v-model="client.name" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">آدرس لوگو</label>
|
||||
<input v-model="client.logoUrl" class="input" dir="ltr" />
|
||||
<div class="sm:col-span-2">
|
||||
<ImageField v-model="client.logoUrl" :label="`لوگو ${client.name || 'مشتری'}`" preview-class="h-16 w-auto max-w-full object-contain p-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-4">
|
||||
<img
|
||||
v-if="client.logoUrl"
|
||||
:src="client.logoUrl"
|
||||
:alt="client.name"
|
||||
class="h-10 w-auto object-contain"
|
||||
/>
|
||||
<button type="button" class="text-sm text-red-500" @click="clients.splice(i, 1)">حذف</button>
|
||||
<button type="button" class="text-sm text-red-500" @click="clients.splice(i, 1)">حذف مشتری</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,6 +41,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api, type Client } from '@/api/client'
|
||||
import ImageField from '@/components/ImageField.vue'
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
+88
-9
@@ -28,16 +28,42 @@
|
||||
/>
|
||||
<span v-else class="text-4xl text-slate-400">📄</span>
|
||||
</div>
|
||||
|
||||
<div class="p-3">
|
||||
<p class="truncate text-sm font-medium text-slate-800">{{ item.filename }}</p>
|
||||
<p class="mt-1 truncate text-xs text-slate-500" dir="ltr">{{ item.url }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 text-xs text-brand hover:underline"
|
||||
@click="copyUrl(item.url)"
|
||||
>
|
||||
کپی آدرس
|
||||
</button>
|
||||
<template v-if="editingId === item.id">
|
||||
<label class="label" :for="`filename-${item.id}`">نام فایل</label>
|
||||
<input
|
||||
:id="`filename-${item.id}`"
|
||||
v-model="editFilename"
|
||||
type="text"
|
||||
class="input"
|
||||
@keyup.enter="saveEdit(item.id)"
|
||||
/>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button type="button" class="btn-primary flex-1 text-xs" :disabled="saving" @click="saveEdit(item.id)">
|
||||
ذخیره
|
||||
</button>
|
||||
<button type="button" class="btn-secondary flex-1 text-xs" @click="cancelEdit">
|
||||
انصراف
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="truncate text-sm font-medium text-slate-800">{{ item.filename }}</p>
|
||||
<p class="mt-1 truncate text-xs text-slate-500" dir="ltr">{{ item.url }}</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button type="button" class="text-xs text-brand hover:underline" @click="copyUrl(item.url)">
|
||||
کپی آدرس
|
||||
</button>
|
||||
<button type="button" class="text-xs text-slate-600 hover:underline" @click="startEdit(item)">
|
||||
ویرایش
|
||||
</button>
|
||||
<button type="button" class="text-xs text-red-500 hover:underline" @click="remove(item)">
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,9 +76,12 @@ import { api, type MediaItem } from '@/api/client'
|
||||
|
||||
const loading = ref(true)
|
||||
const uploading = ref(false)
|
||||
const saving = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
const items = ref<MediaItem[]>([])
|
||||
const editingId = ref<string | null>(null)
|
||||
const editFilename = ref('')
|
||||
|
||||
function isImage(item: MediaItem) {
|
||||
const mime = item.mimeType || ''
|
||||
@@ -92,6 +121,56 @@ async function onUpload(e: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(item: MediaItem) {
|
||||
editingId.value = item.id
|
||||
editFilename.value = item.filename
|
||||
message.value = ''
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = null
|
||||
editFilename.value = ''
|
||||
}
|
||||
|
||||
async function saveEdit(id: string) {
|
||||
const filename = editFilename.value.trim()
|
||||
if (!filename) {
|
||||
error.value = 'نام فایل نمیتواند خالی باشد.'
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
message.value = ''
|
||||
error.value = ''
|
||||
try {
|
||||
const updated = await api.updateMedia(id, { filename })
|
||||
const index = items.value.findIndex((item) => item.id === id)
|
||||
if (index !== -1) items.value[index] = updated
|
||||
cancelEdit()
|
||||
message.value = 'نام فایل ذخیره شد.'
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'خطا در ذخیره'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(item: MediaItem) {
|
||||
if (!confirm(`آیا از حذف «${item.filename}» مطمئن هستید؟`)) return
|
||||
|
||||
message.value = ''
|
||||
error.value = ''
|
||||
try {
|
||||
await api.deleteMedia(item.id)
|
||||
items.value = items.value.filter((entry) => entry.id !== item.id)
|
||||
if (editingId.value === item.id) cancelEdit()
|
||||
message.value = 'فایل حذف شد.'
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'خطا در حذف'
|
||||
}
|
||||
}
|
||||
|
||||
async function copyUrl(url: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
|
||||
@@ -44,10 +44,6 @@
|
||||
<label class="label">توضیح کامل</label>
|
||||
<textarea v-model="form.longDescription" class="input min-h-[120px]" rows="5" />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="label">آدرس تصویر کاور</label>
|
||||
<input v-model="form.imageUrl" class="input" dir="ltr" placeholder="/uploads/..." />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="label">تحویلیها (هر خط یک مورد)</label>
|
||||
<textarea v-model="deliverablesText" class="input min-h-[80px]" rows="3" />
|
||||
@@ -55,6 +51,20 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<ImageField v-model="form.imageUrl" label="تصویر کاور" />
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<ImageGalleryField
|
||||
v-model="galleryUrls"
|
||||
title="گالری تصاویر"
|
||||
description="تصاویر صفحه جزئیات نمونهکار"
|
||||
show-set-cover
|
||||
@set-cover="setCover"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="card space-y-4">
|
||||
<h3 class="font-semibold text-slate-800">سئو</h3>
|
||||
<div>
|
||||
@@ -78,6 +88,8 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api, type PortfolioCategory, type Project } from '@/api/client'
|
||||
import ImageField from '@/components/ImageField.vue'
|
||||
import ImageGalleryField from '@/components/ImageGalleryField.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -100,11 +112,19 @@ const form = reactive<Partial<Project>>({
|
||||
client: '',
|
||||
featured: false,
|
||||
imageUrl: '',
|
||||
galleryUrls: [],
|
||||
seoTitle: '',
|
||||
seoDescription: '',
|
||||
deliverables: [],
|
||||
})
|
||||
|
||||
const galleryUrls = computed({
|
||||
get: () => form.galleryUrls || [],
|
||||
set: (urls: string[]) => {
|
||||
form.galleryUrls = urls
|
||||
},
|
||||
})
|
||||
|
||||
const deliverablesText = computed({
|
||||
get: () => (form.deliverables || []).join('\n'),
|
||||
set: (v: string) => {
|
||||
@@ -117,7 +137,10 @@ onMounted(async () => {
|
||||
categories.value = await api.getPortfolioCategories()
|
||||
if (id.value) {
|
||||
const project = await api.getProject(id.value)
|
||||
Object.assign(form, project)
|
||||
Object.assign(form, {
|
||||
...project,
|
||||
galleryUrls: project.galleryUrls?.length ? [...project.galleryUrls] : [],
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
|
||||
@@ -126,16 +149,22 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
function setCover(url: string) {
|
||||
form.imageUrl = url
|
||||
message.value = 'تصویر کاور بهروزرسانی شد.'
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
message.value = ''
|
||||
error.value = ''
|
||||
try {
|
||||
const payload = { ...form, galleryUrls: galleryUrls.value }
|
||||
if (isNew.value) {
|
||||
const created = await api.createProject({ ...form })
|
||||
const created = await api.createProject(payload)
|
||||
router.replace({ name: 'portfolio-edit', params: { id: created.id } })
|
||||
} else {
|
||||
await api.updateProject(id.value!, { ...form })
|
||||
await api.updateProject(id.value!, payload)
|
||||
}
|
||||
message.value = 'نمونهکار ذخیره شد.'
|
||||
} catch (e) {
|
||||
|
||||
@@ -29,13 +29,11 @@
|
||||
<label class="label">مقدمه</label>
|
||||
<textarea v-model="form.intro" class="input min-h-[100px]" rows="4" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">آدرس آیکون</label>
|
||||
<input v-model="form.iconUrl" class="input" dir="ltr" />
|
||||
<div class="sm:col-span-2">
|
||||
<ImageField v-model="form.iconUrl" label="آیکون خدمت" preview-class="h-24 w-24 object-contain" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">آدرس پسزمینه</label>
|
||||
<input v-model="form.backgroundUrl" class="input" dir="ltr" />
|
||||
<div class="sm:col-span-2">
|
||||
<ImageField v-model="form.backgroundUrl" label="تصویر پسزمینه" />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="label">شامل میشود (هر خط یک مورد)</label>
|
||||
@@ -77,6 +75,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api, type Service } from '@/api/client'
|
||||
import ImageField from '@/components/ImageField.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const serviceId = route.params.id as string
|
||||
|
||||
@@ -57,6 +57,33 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card space-y-4">
|
||||
<h3 class="font-semibold text-slate-800">تصاویر Hero</h3>
|
||||
<ImageField v-model="hero.darkImageUrl" label="پسزمینه تم تیره" />
|
||||
<ImageField v-model="hero.lightImageUrl" label="پسزمینه تم روشن" />
|
||||
</section>
|
||||
|
||||
<section class="card space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-semibold text-slate-800">پیامرسانها</h3>
|
||||
<button type="button" class="btn-secondary text-xs" @click="addMessenger">افزودن</button>
|
||||
</div>
|
||||
<div v-for="(messenger, i) in messengers" :key="messenger.id || i" class="space-y-4 rounded-xl border border-slate-100 p-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="label">نام</label>
|
||||
<input v-model="messenger.name" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">لینک</label>
|
||||
<input v-model="messenger.url" class="input" dir="ltr" />
|
||||
</div>
|
||||
</div>
|
||||
<ImageField v-model="messenger.iconUrl" :label="`آیکون ${messenger.name || 'پیامرسان'}`" preview-class="h-12 w-12 object-contain p-1" />
|
||||
<button type="button" class="text-sm text-red-500" @click="messengers.splice(i, 1)">حذف پیامرسان</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card space-y-4">
|
||||
<h3 class="font-semibold text-slate-800">سئو پیشفرض</h3>
|
||||
<div class="grid gap-4">
|
||||
@@ -92,6 +119,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { api, type NavItem, type SiteSettings } from '@/api/client'
|
||||
import ImageField from '@/components/ImageField.vue'
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
@@ -102,6 +130,8 @@ const error = ref('')
|
||||
const site = reactive<SiteSettings>({})
|
||||
const seo = reactive({ defaultTitle: '', defaultDescription: '' })
|
||||
const navigation = ref<NavItem[]>([])
|
||||
const hero = reactive({ darkImageUrl: '', lightImageUrl: '' })
|
||||
const messengers = ref<Array<{ id: string; name: string; url: string; iconUrl?: string }>>([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -110,6 +140,11 @@ onMounted(async () => {
|
||||
seo.defaultTitle = siteData.seo?.defaultTitle || ''
|
||||
seo.defaultDescription = siteData.seo?.defaultDescription || ''
|
||||
navigation.value = [...nav]
|
||||
hero.darkImageUrl = siteData.hero?.darkImageUrl || ''
|
||||
hero.lightImageUrl = siteData.hero?.lightImageUrl || ''
|
||||
messengers.value = siteData.messengers?.length
|
||||
? siteData.messengers.map((m) => ({ ...m }))
|
||||
: []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
|
||||
} finally {
|
||||
@@ -121,12 +156,23 @@ function addNavItem() {
|
||||
navigation.value.push({ label: '', to: '' })
|
||||
}
|
||||
|
||||
function addMessenger() {
|
||||
messengers.value.push({
|
||||
id: `messenger-${Date.now()}`,
|
||||
name: '',
|
||||
url: '',
|
||||
iconUrl: '',
|
||||
})
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
message.value = ''
|
||||
error.value = ''
|
||||
try {
|
||||
site.seo = { defaultTitle: seo.defaultTitle, defaultDescription: seo.defaultDescription }
|
||||
site.hero = { darkImageUrl: hero.darkImageUrl, lightImageUrl: hero.lightImageUrl }
|
||||
site.messengers = messengers.value
|
||||
await api.putSite({ ...site })
|
||||
message.value = 'تنظیمات سایت ذخیره شد.'
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user