Add image upload fields, media edit/delete, blog seeds

This commit is contained in:
Noro Studio
2026-06-30 21:00:44 +03:30
parent 79491cd8ef
commit 493ee8105e
12 changed files with 985 additions and 36 deletions
+11
View File
@@ -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 }
+109
View File
@@ -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>
+144
View File
@@ -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>
+2 -2
View File
@@ -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()
+4 -10
View File
@@ -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
View File
@@ -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)
+36 -7
View File
@@ -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) {
+5 -6
View File
@@ -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
+46
View File
@@ -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) {