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
+31 -2
View File
@@ -2,8 +2,8 @@ import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import multer from 'multer'
import { existsSync, mkdirSync } from 'node:fs'
import { dirname, extname, join } from 'node:path'
import { existsSync, mkdirSync, unlinkSync } from 'node:fs'
import { basename, dirname, extname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { randomUUID } from 'node:crypto'
import { authMiddleware, loginHandler, meHandler } from './auth.js'
@@ -227,6 +227,35 @@ app.post('/api/media/upload', authMiddleware, upload.single('file'), (req, res)
res.status(201).json(item)
})
app.put('/api/media/:id', authMiddleware, (req, res) => {
const { filename } = req.body || {}
if (!filename?.trim()) return res.status(400).json({ error: 'نام فایل الزامی است' })
let updated = null
updateDb((db) => {
const i = db.media.findIndex((m) => m.id === req.params.id)
if (i === -1) return
db.media[i] = { ...db.media[i], filename: filename.trim() }
updated = db.media[i]
})
if (!updated) return res.status(404).json({ error: 'فایل یافت نشد' })
res.json(updated)
})
app.delete('/api/media/:id', authMiddleware, (req, res) => {
let removed = null
updateDb((db) => {
const i = db.media.findIndex((m) => m.id === req.params.id)
if (i === -1) return
removed = db.media.splice(i, 1)[0]
})
if (!removed) return res.status(404).json({ error: 'فایل یافت نشد' })
const filePath = join(UPLOADS_DIR, basename(removed.url))
if (existsSync(filePath)) unlinkSync(filePath)
res.status(204).end()
})
app.listen(PORT, () => {
console.log(`Noro CMS API running at http://localhost:${PORT}`)
})
+290
View File
@@ -0,0 +1,290 @@
import { randomUUID } from 'node:crypto'
import { readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const dbPath = join(__dirname, '..', 'data', 'content.json')
const content = `## مقدمه
لوگو یکی از مهم‌ترین عناصر هویت بصری هر کسب‌وکار است. در دنیای رقابتی امروز، داشتن یک لوگوی حرفه‌ای می‌تواند نقش مهمی در برندسازی، افزایش اعتماد مشتریان و ماندگاری نام برند در ذهن مخاطب داشته باشد. با این حال، بسیاری از صاحبان کسب‌وکارها هنگام سفارش طراحی لوگو با این سؤال مواجه می‌شوند که چه نوع لوگویی برای برند آن‌ها مناسب‌تر است.
در این مقاله از نورو استودیو قصد داریم انواع لوگو را به‌صورت کامل معرفی کنیم و مزایا، کاربردها و ویژگی‌های هر کدام را بررسی نماییم تا بتوانید بهترین انتخاب را برای کسب‌وکار خود داشته باشید.
## لوگو چیست؟
لوگو یا نشان تجاری، یک نماد گرافیکی است که برای معرفی یک برند، شرکت، سازمان یا کسب‌وکار مورد استفاده قرار می‌گیرد. طراحی لوگو به گونه‌ای انجام می‌شود که هویت، ارزش‌ها و شخصیت برند را در کوتاه‌ترین زمان ممکن به مخاطب منتقل کند.
یک لوگوی موفق باید ساده، ماندگار، قابل تشخیص و متناسب با حوزه فعالیت برند باشد.
## انواع لوگو
به طور کلی لوگوها در چند دسته اصلی قرار می‌گیرند که هر کدام ویژگی‌ها و کاربردهای خاص خود را دارند.
![اینفوگرافیک ۷ نوع لوگو](/uploads/blog-logo-types-overview.png)
### 1. لوگوتایپ (Wordmark)
لوگوتایپ یا لوگوی نوشتاری یکی از محبوب‌ترین انواع لوگو است. در این سبک، نام برند با استفاده از تایپوگرافی اختصاصی طراحی می‌شود و عنصر اصلی لوگو را متن تشکیل می‌دهد.
#### ویژگی‌های لوگوتایپ
* تمرکز کامل روی نام برند
* سادگی و خوانایی بالا
* مناسب برای برندهای تازه‌تأسیس
* قابلیت ماندگاری زیاد در ذهن مخاطب
#### نمونه برندهای معروف
* Google
* Coca-Cola
* Visa
#### مزایای لوگوتایپ
* افزایش شناخت نام برند
* طراحی ساده و حرفه‌ای
* کاربرد آسان در رسانه‌های مختلف
### 2. مونوگرام یا لوگوی حرفی (Lettermark)
در این نوع لوگو به جای استفاده از نام کامل برند، از حروف اول نام شرکت یا کسب‌وکار استفاده می‌شود.
#### ویژگی‌های مونوگرام
* مناسب برای برندهایی با نام طولانی
* طراحی مینیمال و مدرن
* قابلیت استفاده در ابعاد کوچک
#### نمونه‌های مشهور
* IBM
* NASA
* HBO
#### مزایای لوگوی حرفی
* سادگی در اجرا
* افزایش قابلیت تشخیص برند
* مناسب برای شبکه‌های اجتماعی و اپلیکیشن‌ها
### 3. لوگوی نمادین (Brandmark)
لوگوی نمادین از یک آیکون، شکل یا نماد گرافیکی تشکیل می‌شود و معمولاً نام برند در آن وجود ندارد.
#### ویژگی‌های لوگوی نمادین
* انتقال سریع مفهوم برند
* جذابیت بصری بالا
* قابلیت جهانی شدن
#### نمونه برندهای معروف
* Apple
* Twitter
* Target
#### مزایا
* به‌یادماندنی بودن
* قابلیت استفاده در فضاهای مختلف
* قدرت بالای برندسازی
البته این نوع لوگو معمولاً برای برندهای شناخته‌شده مناسب‌تر است؛ زیرا مخاطب باید بتواند نماد را به برند مرتبط کند.
### 4. لوگوی ترکیبی (Combination Mark)
لوگوی ترکیبی از متن و نماد گرافیکی تشکیل شده است. این سبک یکی از پرکاربردترین انواع لوگو در طراحی حرفه‌ای محسوب می‌شود.
#### ویژگی‌ها
* ترکیب نام برند با نماد اختصاصی
* انعطاف‌پذیری بالا
* افزایش هویت بصری برند
#### نمونه‌های معروف
* Adidas
* Burger King
* Doritos
#### مزایا
* تقویت شناخت برند
* امکان استفاده جداگانه از متن یا نماد
* مناسب برای اکثر کسب‌وکارها
بسیاری از طراحان حرفه‌ای، لوگوی ترکیبی را بهترین انتخاب برای برندهای جدید می‌دانند.
### 5. لوگوی امبلم (Emblem)
در لوگوی امبلم، متن داخل یک نشان، مهر یا قاب قرار می‌گیرد. این سبک ظاهری رسمی و کلاسیک دارد.
#### ویژگی‌ها
* حس اعتبار و اصالت
* مناسب سازمان‌ها و نهادهای رسمی
* طراحی ساختاریافته
#### نمونه‌ها
* Starbucks
* Harley-Davidson
* برخی دانشگاه‌ها و سازمان‌های دولتی
#### مزایا
* ایجاد حس اعتماد
* ماندگاری بالا
* مناسب برندهای سنتی و لوکس
### 6. لوگوی مسکات (Mascot)
لوگوی مسکات از یک شخصیت کارتونی یا تصویرسازی اختصاصی تشکیل می‌شود که نماینده برند است.
#### ویژگی‌ها
* ایجاد ارتباط عاطفی با مخاطب
* مناسب برندهای خانوادگی و سرگرمی
* جذاب برای کودکان و نوجوانان
#### نمونه‌های معروف
* KFC
* Pringles
* Michelin
#### مزایا
* افزایش تعامل با مخاطب
* قابلیت استفاده در کمپین‌های تبلیغاتی
* شخصیت‌سازی برای برند
### 7. لوگوی انتزاعی (Abstract Logo)
این نوع لوگو از اشکال هندسی یا فرم‌های خلاقانه تشکیل شده است و معمولاً مفهوم برند را به شکل غیرمستقیم منتقل می‌کند.
#### ویژگی‌ها
* طراحی منحصربه‌فرد
* قابلیت ثبت و تمایز بالا
* مناسب برندهای مدرن
#### نمونه‌ها
* Pepsi
* Nike
* Airbnb
#### مزایا
* هویت بصری اختصاصی
* ماندگاری در ذهن مخاطب
* انعطاف‌پذیری بالا در طراحی
## چگونه بهترین نوع لوگو را انتخاب کنیم؟
انتخاب نوع لوگو به عوامل مختلفی بستگی دارد:
### نوع کسب‌وکار
برندهای رسمی معمولاً از امبلم یا لوگوتایپ استفاده می‌کنند، در حالی که استارتاپ‌ها بیشتر به سراغ لوگوهای ترکیبی یا انتزاعی می‌روند.
### مخاطبان هدف
شناخت دقیق مخاطب نقش مهمی در انتخاب سبک لوگو دارد. برای مثال برندهای کودکانه معمولاً از لوگوهای مسکات بهره می‌برند.
### اهداف برندسازی
اگر هدف شما افزایش آگاهی از نام برند است، لوگوتایپ گزینه مناسبی خواهد بود. اگر به دنبال ساخت یک هویت بصری منحصربه‌فرد هستید، لوگوی نمادین یا انتزاعی انتخاب بهتری است.
### قابلیت توسعه برند
لوگو باید به گونه‌ای طراحی شود که در آینده نیز پاسخگوی نیازهای برند باشد و در رسانه‌های مختلف کیفیت خود را حفظ کند.
![جدول مقایسه انواع لوگو](/uploads/blog-logo-types-comparison.png)
## ویژگی‌های یک لوگوی حرفه‌ای
صرف‌نظر از نوع لوگو، یک طراحی موفق باید دارای ویژگی‌های زیر باشد:
* سادگی
* ماندگاری
* خوانایی
* مقیاس‌پذیری
* ارتباط با هویت برند
* قابلیت استفاده در چاپ و فضای دیجیتال
* تمایز نسبت به رقبا
## اشتباهات رایج در طراحی لوگو
برخی اشتباهات می‌توانند اثربخشی لوگو را کاهش دهند:
* استفاده از جزئیات بیش از حد
* کپی‌برداری از برندهای دیگر
* انتخاب رنگ‌های نامناسب
* عدم توجه به مخاطب هدف
* پیروی از ترندهای زودگذر
* طراحی بدون استراتژی برندینگ
## نتیجه‌گیری
شناخت انواع لوگو اولین قدم برای ساخت یک هویت بصری قدرتمند است. لوگوتایپ، مونوگرام، لوگوی نمادین، ترکیبی، امبلم، مسکات و انتزاعی هر کدام مزایا و کاربردهای خاص خود را دارند. انتخاب بهترین نوع لوگو باید بر اساس ماهیت کسب‌وکار، مخاطبان هدف و استراتژی برند انجام شود.
در نورو استودیو، طراحی لوگو تنها به خلق یک نماد زیبا محدود نمی‌شود؛ بلکه با بررسی دقیق هویت برند، بازار هدف و اصول حرفه‌ای برندینگ، لوگویی طراحی می‌شود که بتواند سال‌ها نماینده موفق کسب‌وکار شما باشد.`
const post = {
id: randomUUID(),
title: 'انواع لوگو؛ آشنایی کامل با سبک‌های مختلف طراحی لوگو',
slug: 'anvae-logo',
excerpt:
'در این راهنمای جامع با ۷ نوع لوگو — از لوگوتایپ تا انتزاعی — آشنا شوید و بهترین سبک طراحی لوگو را برای برند خود انتخاب کنید.',
content,
coverUrl: '/uploads/blog-logo-types-intro.png',
published: true,
publishedAt: new Date().toISOString(),
seoTitle: 'انواع لوگو؛ راهنمای کامل ۷ سبک طراحی لوگو | استودیو نورو',
seoDescription:
'با ۷ نوع لوگو آشنا شوید: لوگوتایپ، مونوگرام، نمادین، ترکیبی، امبلم، مسکات و انتزاعی. راهنمای انتخاب بهترین لوگو برای برند شما در استودیو نورو.',
}
const mediaItems = [
{
id: randomUUID(),
filename: 'blog-logo-types-intro.png',
url: '/uploads/blog-logo-types-intro.png',
mimeType: 'image/png',
size: 58962,
createdAt: new Date().toISOString(),
},
{
id: randomUUID(),
filename: 'blog-logo-types-overview.png',
url: '/uploads/blog-logo-types-overview.png',
mimeType: 'image/png',
size: 52046,
createdAt: new Date().toISOString(),
},
{
id: randomUUID(),
filename: 'blog-logo-types-comparison.png',
url: '/uploads/blog-logo-types-comparison.png',
mimeType: 'image/png',
size: 72005,
createdAt: new Date().toISOString(),
},
]
const db = JSON.parse(readFileSync(dbPath, 'utf-8'))
db.blogPosts = db.blogPosts.filter((item) => item.slug !== post.slug)
db.blogPosts.unshift(post)
for (const item of mediaItems) {
if (!db.media.some((m) => m.url === item.url)) {
db.media.unshift(item)
}
}
writeFileSync(dbPath, JSON.stringify(db, null, 2), 'utf-8')
console.log('Blog post seeded:', post.slug)
+219
View File
@@ -0,0 +1,219 @@
import { randomUUID } from 'node:crypto'
import { readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const dbPath = join(__dirname, '..', 'data', 'content.json')
const testPosts = [
{
title: 'هویت بصری چیست و چرا برای برند شما حیاتی است؟',
slug: 'hoviat-basari',
excerpt:
'هویت بصری فراتر از لوگو است؛ در این مقاله با اجزای هویت بصری و نقش آن در اعتمادسازی و رشد برند آشنا می‌شوید.',
coverUrl: '/uploads/bg-brand-identity.png',
seoTitle: 'هویت بصری چیست؟ | راهنمای کامل برندینگ بصری — استودیو نورو',
seoDescription:
'هویت بصری شامل لوگو، رنگ، تایپوگرافی و لحن بصری برند است. بیاموزید چطور هویت بصری حرفه‌ای بسازید.',
daysAgo: 5,
content: `## مقدمه
هویت بصری مجموعه‌ای از عناصر گرافیکی است که برند شما را در ذهن مخاطب متمایز می‌کند. بسیاری از کسب‌وکارها هویت بصری را فقط به لوگو محدود می‌کنند، در حالی که موفق‌ترین برندها از یک سیستم یکپارچه استفاده می‌کنند.
## هویت بصری شامل چه چیزهایی است؟
* لوگو و نشان تجاری
* پالت رنگ سازمانی
* تایپوگرافی و فونت‌های برند
* الگوها، آیکون‌ها و عناصر گرافیکی
* لحن بصری در عکس‌ها و تصاویر
## مزایای هویت بصری حرفه‌ای
* افزایش اعتماد مشتری
* تمایز از رقبا
* یکپارچگی در همه کانال‌ها
* کاهش هزینه طراحی در بلندمدت
## نتیجه‌گیری
سرمایه‌گذاری روی هویت بصری، سرمایه‌گذاری روی آینده برند شماست. در استودیو نورو، هویت بصری را بر اساس استراتژی برند و مخاطب هدف طراحی می‌کنیم.`,
},
{
title: '۱۰ اصل طلایی طراحی UI/UX برای وب‌سایت‌های حرفه‌ای',
slug: 'ui-ux-principles',
excerpt:
'طراحی رابط و تجربه کاربری خوب فقط زیبایی نیست؛ با ۱۰ اصل کلیدی UI/UX وب‌سایت خود را کاربرپسند کنید.',
coverUrl: '/uploads/bg-ui.png',
seoTitle: 'اصول طراحی UI/UX | ۱۰ نکته برای وب‌سایت بهتر — استودیو نورو',
seoDescription:
'از سادگی و سلسله‌مراتب بصری تا دسترس‌پذیری؛ ۱۰ اصل طراحی UI/UX که تبدیل بازدیدکننده به مشتری را آسان‌تر می‌کند.',
daysAgo: 12,
content: `## مقدمه
وب‌سایت شما اغلب اولین نقطه تماس مشتری با برند است. طراحی UI/UX ضعیف می‌تواند حتی با محصول عالی، فروش را کاهش دهد.
## ۱۰ اصل کلیدی
### 1. سادگی
هر عنصر باید هدف مشخصی داشته باشد. از شلوغی بپرهیزید.
### 2. سلسله‌مراتب بصری
مهم‌ترین پیام باید بیشترین توجه را بگیرد.
### 3. یکپارچگی
دکمه‌ها، فونت‌ها و فاصله‌ها در همه صفحات یکسان باشند.
### 4. خوانایی
تضاد کافی بین متن و پس‌زمینه ضروری است.
### 5. واکنش‌گرایی
سایت باید روی موبایل بی‌نقص کار کند.
### 6. سرعت بارگذاری
هر ثانیه تأخیر، نرخ تبدیل را کاهش می‌دهد.
### 7. دسترس‌پذیری
طراحی برای همه کاربران، از جمله افراد دارای محدودیت بینایی.
### 8. بازخورد بصری
کاربر باید بداند کلیک یا ارسال فرم موفق بوده است.
### 9. مسیر کاربری روشن
کاربر در ۳ کلیک به هدف اصلی برسد.
### 10. تست با کاربر واقعی
قبل از لانچ، با مخاطب هدف تست کنید.
## نتیجه‌گیری
UI/UX خوب نامرئی است؛ کاربر فقط نتیجه را حس می‌کند: سادگی، سرعت و اعتماد.`,
},
{
title: 'راهنمای طراحی پست و قالب اینستاگرام برای برندها',
slug: 'social-media-design',
excerpt:
'سوشال مدیا ویترین برند شماست. نکات طراحی قالب پست، استوری و هایلایت برای اینستاگرام حرفه‌ای.',
coverUrl: '/uploads/bg-social.png',
seoTitle: 'طراحی سوشال مدیا | قالب اینستاگرام حرفه‌ای — استودیو نورو',
seoDescription:
'چطور قالب اینستاگرام یکپارچه بسازیم؟ راهنمای رنگ، تایپوگرافی و چیدمان پست برای برندهای ایرانی.',
daysAgo: 20,
content: `## مقدمه
اینستاگرام برای بسیاری از برندهای ایرانی، مهم‌ترین کانال ارتباطی با مشتری است. طراحی نامنظم پست‌ها اعتماد را کاهش می‌دهد.
## عناصر یک فید حرفه‌ای
* پالت رنگ ثابت (۲ تا ۳ رنگ اصلی)
* فونت مشخص برای تیتر و متن
* قالب‌های تکرارشونده برای انواع محتوا
* فضای سفید کافی
* تصاویر با کیفیت و یکدست
## انواع قالب‌های پرکاربرد
### قالب معرفی محصول
تصویر بزرگ + نام محصول + CTA کوتاه
### قالب آموزشی
تیتر bold + ۳ تا ۵ bullet point
### قالب نقل‌قول
متن برجسته + لوگو یا آیکون برند
## اشتباهات رایج
* استفاده از فونت‌های مختلف در هر پست
* رنگ‌های ناهماهنگ
* متن بیش از حد روی تصویر
* کیفیت پایین تصاویر
## نتیجه‌گیری
فید اینستاگرام یکپارچه یعنی برند حرفه‌ای‌تر. استودیو نورو قالب‌های اختصاصی سوشال مدیا طراحی می‌کند.`,
},
{
title: '۵ اشتباه رایج در طراحی لوگو که باید از آن‌ها دوری کنید',
slug: 'logo-design-mistakes',
excerpt:
'از کپی‌برداری تا جزئیات اضافه؛ ۵ اشتباه رایج در طراحی لوگو که می‌تواند برند شما را ضعیف کند.',
coverUrl: '/uploads/blog-logo-types-comparison.png',
seoTitle: 'اشتباهات طراحی لوگو | ۵ خطای رایج — استودیو نورو',
seoDescription:
'۵ اشتباه رایج در طراحی لوگو: پیچیدگی، ترند زودگذر، رنگ نامناسب و کپی از رقبا. راهکارهای عملی برای اجتناب.',
daysAgo: 8,
content: `## مقدمه
طراحی لوگو یک فرآیند استراتژیک است، نه فقط انتخاب یک شکل زیبا. متأسفانه بسیاری از کسب‌وکارها در همین مرحله اشتباهات پرهزینه‌ای مرتکب می‌شوند.
![اینفوگرافیک انواع لوگو](/uploads/blog-logo-types-overview.png)
## ۱. پیچیدگی بیش از حد
لوگوی شلوغ در سایز کوچک (فاویکون، استوری) ناخوانا می‌شود.
## ۲. پیروی از ترندهای زودگذر
ترند امسال، قدیمی فردا. لوگو باید ماندگار باشد.
## ۳. کپی از برندهای دیگر
لوگوی مشابه یعنی از دست دادن تمایز و ریسک حقوقی.
## ۴. نادیده گرفتن مخاطب هدف
لوگوی لوکس برای برند کودکانه یا برعکس، اشتباه است.
## ۵. طراحی بدون استراتژی
بدون تحقیق برند، لوگو فقط یک تصویر تزئینی می‌شود.
## راهکار
قبل از طراحی: مخاطب، رقبا، ارزش‌های برند و کاربردهای آینده لوگو را مشخص کنید.
## نتیجه‌گیری
لوگوی موفق ساده، مرتبط و ماندگار است. برای انتخاب نوع مناسب لوگو، مقاله «انواع لوگو» ما را هم بخوانید.`,
},
]
const db = JSON.parse(readFileSync(dbPath, 'utf-8'))
for (const template of testPosts) {
db.blogPosts = db.blogPosts.filter((p) => p.slug !== template.slug)
const publishedAt = new Date()
publishedAt.setDate(publishedAt.getDate() - template.daysAgo)
db.blogPosts.push({
id: randomUUID(),
title: template.title,
slug: template.slug,
excerpt: template.excerpt,
content: template.content,
coverUrl: template.coverUrl,
published: true,
publishedAt: publishedAt.toISOString(),
seoTitle: template.seoTitle,
seoDescription: template.seoDescription,
})
}
writeFileSync(dbPath, JSON.stringify(db, null, 2), 'utf-8')
console.log(`Seeded ${testPosts.length} test posts. Total blog posts: ${db.blogPosts.length}`)
+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) {