Initial commit: Admin panel

This commit is contained in:
Noro Studio
2026-06-30 00:50:13 +03:30
commit 79491cd8ef
36 changed files with 6448 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
PORT=4000
JWT_SECRET=change-this-secret-in-production
ADMIN_EMAIL=admin@norostudio.ir
ADMIN_PASSWORD=admin123
API_PUBLIC_URL=http://localhost:4000
CORS_ORIGIN=http://localhost:5173,http://localhost:3000
+10
View File
@@ -0,0 +1,10 @@
node_modules/
dist/
.env
.env.*
!.env.example
uploads/
data/
*.log
.DS_Store
.idea
+61
View File
@@ -0,0 +1,61 @@
# Noro Studio Admin Panel
پنل مدیریت محتوای استودیو نورو — Vue 3 + Vite + Express API
## پیش‌نیاز
- Node.js 18+
- پروژه سایت اصلی در `../nuxt-tailwind-app`
## راه‌اندازی
```bash
cd C:\Users\SMB\Projects\admin
npm install
cp .env.example .env # در ویندوز: copy .env.example .env
npm run seed # بارگذاری اولیه داده از سایت
npm run dev # API روی :4000 و پنل روی :5173
```
## ورود پیش‌فرض
- ایمیل: `admin@norostudio.ir`
- رمز: `admin123`
## اتصال به سایت
سایت Nuxt باید با API در حال اجرا باشد. در `.env` سایت:
```
NUXT_PUBLIC_CMS_API_URL=http://localhost:4000
```
سپس هر دو را همزمان اجرا کنید:
```bash
# ترمینال ۱ — ادمین
cd C:\Users\SMB\Projects\admin && npm run dev
# ترمینال ۲ — سایت
cd C:\Users\SMB\Projects\nuxt-tailwind-app && npm run dev
```
## امکانات
- تنظیمات سایت (متن، تماس، هیرو، سئو)
- منوی ناوبری
- نمونه‌کارها (CRUD + گالری + سئو)
- خدمات
- مشتریان و لوگو
- وبلاگ با فیلدهای سئو
- آپلود رسانه
## API
- `GET /api/site` — عمومی
- `GET /api/portfolio/projects` — عمومی
- `GET /api/blog/posts` — فقط مطالب منتشرشده
- `POST /api/auth/login` — ورود ادمین
فایل دیتابیس: `data/content.json`
آپلودها: `uploads/`
+1
View File
@@ -0,0 +1 @@
admin
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>پنل مدیریت | استودیو نورو</title>
</head>
<body class="bg-slate-50 text-slate-800 antialiased">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+3830
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "noro-admin",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:server": "node server/index.js",
"dev:client": "vite",
"build": "vite build",
"preview": "vite preview",
"seed": "node server/seed.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"uuid": "^10.0.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.2",
"autoprefixer": "^10.4.20",
"concurrently": "^8.2.2",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.4",
"vite": "^5.4.2",
"vue": "^3.5.0",
"vue-router": "^4.4.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#39c0c4"/>
<text x="16" y="22" text-anchor="middle" fill="white" font-size="16" font-family="sans-serif" font-weight="bold">F</text>
</svg>

After

Width:  |  Height:  |  Size: 259 B

+35
View File
@@ -0,0 +1,35 @@
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { readDb } from './db.js'
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret'
export function authMiddleware(req, res, next) {
const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized' })
}
try {
req.user = jwt.verify(header.slice(7), JWT_SECRET)
next()
} catch {
return res.status(401).json({ error: 'Invalid token' })
}
}
export function loginHandler(req, res) {
const { email, password } = req.body || {}
const adminEmail = process.env.ADMIN_EMAIL || 'admin@norostudio.ir'
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'
if (email !== adminEmail || password !== adminPassword) {
return res.status(401).json({ error: 'ایمیل یا رمز عبور نادرست است' })
}
const token = jwt.sign({ email, role: 'admin' }, JWT_SECRET, { expiresIn: '7d' })
res.json({ token, user: { email, role: 'admin' } })
}
export function meHandler(req, res) {
res.json({ user: req.user })
}
+46
View File
@@ -0,0 +1,46 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const DATA_DIR = join(__dirname, '..', 'data')
const DB_PATH = join(DATA_DIR, 'content.json')
const defaultDb = () => ({
site: {},
navigation: [],
portfolioCategories: [],
projects: [],
services: [],
serviceProjectCategories: {},
clients: [],
stats: [],
blogPosts: [],
media: []
})
function ensureDataDir() {
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true })
}
export function readDb() {
ensureDataDir()
if (!existsSync(DB_PATH)) {
const db = defaultDb()
writeDb(db)
return db
}
return JSON.parse(readFileSync(DB_PATH, 'utf-8'))
}
export function writeDb(db) {
ensureDataDir()
writeFileSync(DB_PATH, JSON.stringify(db, null, 2), 'utf-8')
}
export function updateDb(mutator) {
const db = readDb()
mutator(db)
writeDb(db)
return db
}
+232
View File
@@ -0,0 +1,232 @@
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 { fileURLToPath } from 'node:url'
import { randomUUID } from 'node:crypto'
import { authMiddleware, loginHandler, meHandler } from './auth.js'
import { readDb, updateDb } from './db.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const UPLOADS_DIR = join(__dirname, '..', 'uploads')
const PORT = Number(process.env.PORT) || 4000
if (!existsSync(UPLOADS_DIR)) mkdirSync(UPLOADS_DIR, { recursive: true })
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, UPLOADS_DIR),
filename: (_req, file, cb) => {
const ext = extname(file.originalname)
cb(null, `${randomUUID()}${ext}`)
},
})
const upload = multer({ storage })
const app = express()
const corsOrigin = process.env.CORS_ORIGIN?.split(',') || ['http://localhost:5173']
app.use(cors({ origin: corsOrigin, credentials: true }))
app.use(express.json({ limit: '10mb' }))
app.use('/uploads', express.static(UPLOADS_DIR))
app.post('/api/auth/login', loginHandler)
app.get('/api/auth/me', authMiddleware, meHandler)
app.get('/api/site', (_req, res) => {
res.json(readDb().site)
})
app.put('/api/site', authMiddleware, (req, res) => {
const site = updateDb((db) => {
db.site = req.body
}).site
res.json(site)
})
app.get('/api/navigation', (_req, res) => {
res.json(readDb().navigation)
})
app.put('/api/navigation', authMiddleware, (req, res) => {
const navigation = updateDb((db) => {
db.navigation = req.body
}).navigation
res.json(navigation)
})
app.get('/api/portfolio/categories', (_req, res) => {
res.json(readDb().portfolioCategories)
})
app.get('/api/portfolio/projects', (_req, res) => {
res.json(readDb().projects)
})
app.get('/api/portfolio/projects/:id', (req, res) => {
const project = readDb().projects.find((p) => p.id === req.params.id)
if (!project) return res.status(404).json({ error: 'پروژه یافت نشد' })
res.json(project)
})
app.post('/api/portfolio/projects', authMiddleware, (req, res) => {
const project = { id: randomUUID(), ...req.body }
updateDb((db) => {
db.projects.push(project)
})
res.status(201).json(project)
})
app.put('/api/portfolio/projects/:id', authMiddleware, (req, res) => {
let updated = null
updateDb((db) => {
const i = db.projects.findIndex((p) => p.id === req.params.id)
if (i === -1) return
db.projects[i] = { ...db.projects[i], ...req.body, id: req.params.id }
updated = db.projects[i]
})
if (!updated) return res.status(404).json({ error: 'پروژه یافت نشد' })
res.json(updated)
})
app.delete('/api/portfolio/projects/:id', authMiddleware, (req, res) => {
let found = false
updateDb((db) => {
const i = db.projects.findIndex((p) => p.id === req.params.id)
if (i !== -1) {
db.projects.splice(i, 1)
found = true
}
})
if (!found) return res.status(404).json({ error: 'پروژه یافت نشد' })
res.status(204).end()
})
app.get('/api/services', (_req, res) => {
res.json(readDb().services)
})
app.put('/api/services', authMiddleware, (req, res) => {
const services = updateDb((db) => {
db.services = req.body
}).services
res.json(services)
})
app.get('/api/services/:id', (req, res) => {
const service = readDb().services.find((s) => s.id === req.params.id)
if (!service) return res.status(404).json({ error: 'خدمت یافت نشد' })
res.json(service)
})
app.put('/api/services/:id', authMiddleware, (req, res) => {
let updated = null
updateDb((db) => {
const i = db.services.findIndex((s) => s.id === req.params.id)
if (i === -1) return
db.services[i] = { ...db.services[i], ...req.body, id: req.params.id }
updated = db.services[i]
})
if (!updated) return res.status(404).json({ error: 'خدمت یافت نشد' })
res.json(updated)
})
app.get('/api/clients', (_req, res) => {
res.json(readDb().clients)
})
app.put('/api/clients', authMiddleware, (req, res) => {
const clients = updateDb((db) => {
db.clients = req.body
}).clients
res.json(clients)
})
app.get('/api/stats', (_req, res) => {
res.json(readDb().stats || [])
})
app.get('/api/blog/posts', (_req, res) => {
res.json(readDb().blogPosts.filter((p) => p.published))
})
app.get('/api/blog/posts/all', authMiddleware, (_req, res) => {
res.json(readDb().blogPosts)
})
app.get('/api/blog/posts/slug/:slug', (req, res) => {
const post = readDb().blogPosts.find(
(p) => p.slug === req.params.slug && p.published
)
if (!post) return res.status(404).json({ error: 'مطلب یافت نشد' })
res.json(post)
})
app.get('/api/blog/posts/:id', (req, res) => {
const post = readDb().blogPosts.find((p) => p.id === req.params.id)
if (!post) return res.status(404).json({ error: 'مطلب یافت نشد' })
res.json(post)
})
app.post('/api/blog/posts', authMiddleware, (req, res) => {
const post = {
id: randomUUID(),
published: false,
publishedAt: new Date().toISOString(),
...req.body,
}
updateDb((db) => {
db.blogPosts.push(post)
})
res.status(201).json(post)
})
app.put('/api/blog/posts/:id', authMiddleware, (req, res) => {
let updated = null
updateDb((db) => {
const i = db.blogPosts.findIndex((p) => p.id === req.params.id)
if (i === -1) return
db.blogPosts[i] = { ...db.blogPosts[i], ...req.body, id: req.params.id }
updated = db.blogPosts[i]
})
if (!updated) return res.status(404).json({ error: 'مطلب یافت نشد' })
res.json(updated)
})
app.delete('/api/blog/posts/:id', authMiddleware, (req, res) => {
let found = false
updateDb((db) => {
const i = db.blogPosts.findIndex((p) => p.id === req.params.id)
if (i !== -1) {
db.blogPosts.splice(i, 1)
found = true
}
})
if (!found) return res.status(404).json({ error: 'مطلب یافت نشد' })
res.status(204).end()
})
app.get('/api/media', authMiddleware, (_req, res) => {
res.json(readDb().media)
})
app.post('/api/media/upload', authMiddleware, upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'فایلی ارسال نشده' })
const item = {
id: randomUUID(),
filename: req.file.originalname,
url: `/uploads/${req.file.filename}`,
mimeType: req.file.mimetype,
size: req.file.size,
createdAt: new Date().toISOString(),
}
updateDb((db) => {
db.media.unshift(item)
})
res.status(201).json(item)
})
app.listen(PORT, () => {
console.log(`Noro CMS API running at http://localhost:${PORT}`)
})
+123
View File
@@ -0,0 +1,123 @@
import { existsSync, mkdirSync, copyFileSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { writeDb } from './db.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const UPLOADS = join(__dirname, '..', 'uploads')
const NUXT = join(__dirname, '..', '..', 'nuxt-tailwind-app')
function asset(rel, name) {
const src = join(NUXT, 'src', 'assets', 'images', rel)
if (!existsSync(UPLOADS)) mkdirSync(UPLOADS, { recursive: true })
if (existsSync(src)) {
copyFileSync(src, join(UPLOADS, name))
return `/uploads/${name}`
}
return ''
}
export function runSeed() {
const cover = asset('portfolio/madar-biscuit-poster.png', 'portfolio-cover.png')
const services = JSON.parse(
readFileSync(join(__dirname, 'services.seed.json'), 'utf-8')
).map((s) => ({
...s,
iconUrl: asset(`services/${s.id === 'ui' ? 'ui' : s.id === 'graphic-design' ? 'graphic-design' : s.id === 'brand-identity' ? 'brand-identity' : s.id === 'social' ? 'social' : s.id === 'photography' ? 'photography' : 'video'}.png`, `icon-${s.id}.png`),
backgroundUrl: asset(`services/backgrounds/${s.id === 'ui' ? 'ui' : s.id === 'graphic-design' ? 'graphic-design' : s.id === 'brand-identity' ? 'brand-identity' : s.id === 'social' ? 'social' : s.id === 'photography' ? 'photography' : 'video'}.png`, `bg-${s.id}.png`),
portfolioCategorySlugs:
{
ui: ['uiux'],
'graphic-design': ['graphic'],
'brand-identity': ['logo', 'identity'],
social: ['social'],
photography: [],
video: ['motion']
}[s.id] || []
}))
writeDb({
site: {
name: 'استودیو نورو',
nameEn: 'Noro Studio',
tagline: 'استودیوی طراحی دیجیتال',
description:
'استودیو نورو با تمرکز بر هویت بصری، UI/UX، طراحی سوشال مدیا و محتوای دیجیتال، برند شما را به تجربه‌ای مدرن و حرفه‌ای تبدیل می‌کند.',
email: 'hello@norostudio.ir',
website: 'noro.vision',
phone: '۰۹۳۳ ۲۰۶ ۱۶۶۷',
phoneTel: '+989332061667',
location: 'اصفهان، ایران',
messengerHandle: '@noro_vision',
messengersLabel: 'پیام‌رسان‌ها و شبکه‌های اجتماعی',
messengers: [
{ id: 'telegram', name: 'تلگرام', url: 'https://t.me/noro_vision', iconUrl: asset('messengers/telegram.png', 'tg.png') },
{ id: 'bale', name: 'بله', url: 'https://ble.ir/noro_vision', iconUrl: asset('messengers/bale.png', 'bale.png') },
{ id: 'rubika', name: 'روبیکا', url: 'https://rubika.ir/noro_vision', iconUrl: asset('messengers/rubika.png', 'rubika.png') },
{ id: 'instagram', name: 'اینستاگرام', url: 'https://www.instagram.com/noro.vision?igsh=MXF1NGE2NGMxOGxncA==', iconUrl: asset('messengers/instagram.svg', 'ig.svg') }
],
seo: {
defaultTitle: 'استودیو نورو | Noro Studio',
defaultDescription: 'استودیو نورو — طراحی هویت بصری، UI/UX، سوشال مدیا و محتوای دیجیتال'
},
hero: {
darkImageUrl: asset('hero/studio-background.png', 'hero-dark.png'),
lightImageUrl: asset('hero/studio-background-light.png', 'hero-light.png')
}
},
navigation: [
{ label: 'خانه', to: '/' },
{ label: 'نمونه‌کارها', to: '/portfolio' },
{ label: 'خدمات', to: '/services' },
{ label: 'وبلاگ', to: '/blog' },
{ label: 'تماس', to: '/contact' }
],
portfolioCategories: [
{ slug: 'graphic', label: 'طراحی گرافیک' },
{ slug: 'logo', label: 'طراحی لوگو' },
{ slug: 'identity', label: 'هویت بصری' },
{ slug: 'uiux', label: 'UI/UX' },
{ slug: 'social', label: 'طراحی سوشال مدیا' },
{ slug: 'motion', label: 'تدوین و موشن گرافیک' }
],
projects: [
{ id: '1', title: 'طراحی پوستر تبلیغاتی بیسکویت مادر', category: 'graphic', description: 'این پوستر به سفارش شرکت بیسکویت مادر طراحی شده است.', longDescription: 'پروژه پوستر تبلیغاتی بیسکویت مادر با هدف معرفی خواص غذایی و ارزش‌های برند طراحی شد.', year: 'خرداد ۱۴۰۴', featured: true, imageUrl: cover, galleryUrls: [cover, cover], client: 'بیسکویت مادر', deliverables: ['پوستر تبلیغاتی'], seoTitle: '', seoDescription: '' },
{ id: '2', title: 'اپلیکیشن فین‌تک', category: 'uiux', description: 'طراحی رابط و تجربه کاربری اپ موبایل مالی', longDescription: 'طراحی کامل UI/UX اپلیکیشن موبایل فین‌تک.', year: '۱۴۰۴', featured: true, imageUrl: cover, galleryUrls: [cover], client: 'استارتاپ فین‌تک', deliverables: ['طراحی UI'], seoTitle: '', seoDescription: '' },
{ id: '3', title: 'لوگو استارتاپ نوا', category: 'logo', description: 'طراحی لوگوتایپ مینیمال', longDescription: 'لوگوتایپ استارتاپ نوا با رویکرد مینیمال طراحی شد.', year: '۱۴۰۳', featured: true, imageUrl: cover, galleryUrls: [cover], client: 'استارتاپ نوا', deliverables: ['لوگوتایپ'], seoTitle: '', seoDescription: '' },
{ id: '4', title: 'کمپین اینستاگرام', category: 'social', description: 'طراحی قالب و محتوای بصری', longDescription: 'کمپین بصری اینستاگرام برای فروش فصلی.', year: '۱۴۰۳', featured: false, imageUrl: cover, galleryUrls: [cover], client: 'برند خرده‌فروشی', deliverables: ['قالب پست'], seoTitle: '', seoDescription: '' },
{ id: '5', title: 'موشن معرفی محصول', category: 'motion', description: 'تدوین و موشن گرافیک', longDescription: 'ویدیوی موشن گرافیک ۶۰ ثانیه‌ای.', year: '۱۴۰۳', featured: false, imageUrl: cover, galleryUrls: [cover], client: 'برند لوازم خانگی', deliverables: ['انیمیشن'], seoTitle: '', seoDescription: '' },
{ id: '6', title: 'پنل مدیریت SaaS', category: 'uiux', description: 'طراحی داشبورد SaaS', longDescription: 'طراحی داشبورد مدیریتی SaaS.', year: '۱۴۰۲', featured: false, imageUrl: cover, galleryUrls: [cover], client: 'پلتفرم SaaS', deliverables: ['داشبورد'], seoTitle: '', seoDescription: '' }
],
services,
serviceProjectCategories: {
ui: ['uiux'],
'graphic-design': ['graphic'],
'brand-identity': ['logo', 'identity'],
social: ['social'],
photography: [],
video: ['motion']
},
clients: [
{ id: 'chehelsotoun-souleh', name: 'چهلستون سوله اصفهان', logoUrl: asset('clients/chehelsotoun-souleh.png', 'c1.png') },
{ id: 'montzar', name: 'منتظر', logoUrl: asset('clients/montzar.png', 'c2.png') },
{ id: 'madar', name: 'مادر', logoUrl: asset('clients/madar.png', 'c3.png') },
{ id: 'azad-khorasgan', name: 'دانشگاه آزاد اسلامی — واحد خوراسگان', logoUrl: asset('clients/azad-khorasgan.png', 'c4.png') },
{ id: 'isfahan', name: 'اصفهان', logoUrl: asset('clients/isfahan.png', 'c5.png') },
{ id: 'montech', name: 'منتک', logoUrl: asset('clients/montech.png', 'c6.png') },
{ id: 'farshchin', name: 'فرشچین', logoUrl: asset('clients/farshchin.png', 'c7.png') },
{ id: 'rouyesh', name: 'رویش — گروه توسعه نرم‌افزاری', logoUrl: asset('clients/rouyesh.png', 'c8.png') }
],
stats: [
{ value: '۵۰+', label: 'پروژه انجام‌شده' },
{ value: '۳۰+', label: 'مشتری راضی' },
{ value: '۶', label: 'حوزه تخصصی' }
],
blogPosts: [],
media: []
})
console.log('Seed completed.')
}
runSeed()
+260
View File
@@ -0,0 +1,260 @@
[
{
"id": "ui",
"title": "طراحی رابط کاربری (UI/UX)",
"subtitle": "طراحی رابط کاربری حرفه‌ای برای وب‌سایت و اپلیکیشن",
"description": "طراحی رابط کاربری برای وب، موبایل و محصولات دیجیتال با تمرکز بر تجربه کاربری.",
"intro": "ما رابط‌های کاربری زیبا، کاربردی و کاربرمحور طراحی می‌کنیم تا کاربران تجربه‌ای روان و لذت‌بخش از تعامل با محصول شما داشته باشند. هدف ما تنها زیبایی نیست؛ بلکه ایجاد تجربه‌ای است که به افزایش تعامل کاربران و رشد کسب‌وکار شما کمک کند.",
"includes": [
"تحقیق و تحلیل کاربران",
"طراحی User Flow",
"طراحی Wireframe",
"طراحی رابط کاربری صفحات",
"طراحی نسخه موبایل و دسکتاپ",
"طراحی سیستم طراحی (Design System)",
"پروتوتایپ تعاملی"
],
"benefits": [
"افزایش نرخ تبدیل کاربران",
"بهبود تجربه کاربری",
"کاهش سردرگمی کاربران",
"افزایش اعتماد به برند",
"آماده‌سازی محصول برای توسعه حرفه‌ای"
],
"process": [
{ "title": "شناخت پروژه", "description": "بررسی اهداف، مخاطبان و نیازهای محصول." },
{ "title": "طراحی ساختار", "description": "طراحی مسیرهای کاربری و وایرفریم صفحات." },
{ "title": "طراحی بصری", "description": "طراحی رابط کاربری بر اساس هویت برند." },
{ "title": "بازبینی و اصلاح", "description": "دریافت بازخورد و اعمال تغییرات." },
{ "title": "تحویل نهایی", "description": "ارائه فایل‌های طراحی و مستندات لازم." }
],
"deliverables": [
"طراحی صفحات وب‌سایت",
"داشبوردهای مدیریتی",
"اپلیکیشن‌های موبایل",
"لندینگ پیج‌ها",
"سیستم‌های نرم‌افزاری"
],
"faq": [
{
"question": "آیا طراحی برای موبایل هم انجام می‌شود؟",
"answer": "بله، تمامی پروژه‌ها به صورت Responsive طراحی می‌شوند."
},
{
"question": "فایل نهایی در چه فرمتی تحویل داده می‌شود؟",
"answer": "فایل‌های Figma و تمامی Assetهای مورد نیاز تحویل داده می‌شوند."
}
]
},
{
"id": "graphic-design",
"title": "طراحی گرافیک",
"subtitle": "خلق هویت بصری جذاب برای برندها",
"description": "خلق اثرهای بصری حرفه‌ای برای چاپ، تبلیغات، بسته‌بندی و کمپین‌های برند.",
"intro": "طراحی گرافیک تنها ساخت تصاویر زیبا نیست؛ بلکه ابزاری برای انتقال پیام برند و ایجاد ارتباط مؤثر با مخاطبان است.",
"includes": [
"طراحی پوستر",
"طراحی بنر تبلیغاتی",
"طراحی بسته‌بندی",
"طراحی کاتالوگ و بروشور",
"طراحی استوری و پست شبکه‌های اجتماعی",
"طراحی اقلام چاپی"
],
"benefits": [
"افزایش جذابیت برند",
"انتقال بهتر پیام",
"ایجاد تمایز در بازار",
"افزایش اثربخشی کمپین‌های تبلیغاتی"
],
"process": [
{ "title": "دریافت نیازمندی‌ها", "description": "جمع‌آوری اطلاعات و اهداف پروژه." },
{ "title": "تحقیق و ایده‌پردازی", "description": "بررسی روندها و تولید مفاهیم اولیه." },
{ "title": "طراحی اولیه", "description": "ارائه طرح‌های اولیه برای بازبینی." },
{ "title": "اصلاح و بهینه‌سازی", "description": "اعمال بازخورد و بهبود طرح." },
{ "title": "تحویل نهایی", "description": "تحویل فایل‌های نهایی آماده استفاده." }
],
"deliverables": ["پوستر", "بنر", "کاتالوگ", "بسته‌بندی", "طراحی کمپین تبلیغاتی"],
"faq": [
{
"question": "آیا فایل قابل چاپ تحویل داده می‌شود؟",
"answer": "بله، تمامی فایل‌ها با استاندارد چاپ آماده می‌شوند."
},
{
"question": "چند مرحله اصلاح وجود دارد؟",
"answer": "بسته به پروژه، چند مرحله بازبینی در نظر گرفته می‌شود."
}
]
},
{
"id": "brand-identity",
"title": "هویت برند",
"subtitle": "ساخت تصویری ماندگار برای برند شما",
"description": "طراحی سیستم برند یکپارچه شامل لوگو، رنگ، تایپوگرافی و راهنمای هویت بصری.",
"intro": "هویت برند مجموعه‌ای از عناصر بصری و مفهومی است که شخصیت برند شما را شکل می‌دهد و باعث می‌شود در ذهن مخاطب ماندگار شوید.",
"includes": [
"طراحی لوگو",
"طراحی پالت رنگی",
"انتخاب تایپوگرافی",
"طراحی آیکون‌ها",
"طراحی المان‌های گرافیکی",
"تدوین Brand Guideline"
],
"benefits": [
"افزایش اعتبار برند",
"ایجاد یکپارچگی بصری",
"متمایز شدن از رقبا",
"افزایش اعتماد مخاطبان"
],
"process": [
{ "title": "شناخت برند", "description": "درک ارزش‌ها، مخاطب و اهداف برند." },
{ "title": "تحلیل بازار و رقبا", "description": "بررسی فضای رقابتی و فرصت‌ها." },
{ "title": "طراحی مفاهیم اولیه", "description": "ارائه چند مفهوم بصری برای انتخاب." },
{ "title": "توسعه هویت بصری", "description": "تکمیل لوگو، رنگ، تایپوگرافی و المان‌ها." },
{ "title": "تدوین راهنمای برند", "description": "مستندسازی قوانین استفاده از هویت." }
],
"deliverables": ["لوگو", "برندبوک", "کارت ویزیت", "ست اداری", "دارایی‌های دیجیتال برند"],
"faq": [
{
"question": "آیا برندبوک هم ارائه می‌شود؟",
"answer": "بله، راهنمای کامل استفاده از هویت بصری ارائه خواهد شد."
},
{
"question": "اگر لوگوی قبلی داشته باشیم چه؟",
"answer": "امکان بازطراحی یا توسعه هویت موجود وجود دارد."
}
]
},
{
"id": "social",
"title": "سوشال مدیا",
"subtitle": "مدیریت و تولید محتوای حرفه‌ای برای شبکه‌های اجتماعی",
"description": "طراحی و تولید محتوای بصری برای اینستاگرام، لینکدین و پلتفرم‌های اجتماعی.",
"intro": "حضور مؤثر در شبکه‌های اجتماعی نیازمند برنامه‌ریزی، طراحی و تولید محتوای هدفمند است. ما به برند شما کمک می‌کنیم تا ارتباطی مستمر و مؤثر با مخاطبان خود داشته باشد.",
"includes": [
"طراحی پست و استوری",
"تدوین تقویم محتوایی",
"طراحی قالب اختصاصی",
"تولید محتوای بصری",
"مشاوره استراتژی محتوا",
"مدیریت کمپین‌های شبکه اجتماعی"
],
"benefits": [
"افزایش آگاهی از برند",
"رشد تعامل کاربران",
"جذب مخاطب هدف",
"ایجاد هویت بصری منسجم"
],
"process": [
{ "title": "تحلیل وضعیت فعلی", "description": "بررسی حضور فعلی برند در شبکه‌ها." },
{ "title": "تدوین استراتژی محتوا", "description": "تعیین اهداف، لحن و برنامه محتوایی." },
{ "title": "طراحی قالب‌ها", "description": "ساخت قالب‌های بصری یکپارچه." },
{ "title": "تولید محتوا", "description": "طراحی و تولید پست، استوری و محتوای تعاملی." },
{ "title": "انتشار و بهینه‌سازی", "description": "انتشار محتوا و تحلیل عملکرد." }
],
"deliverables": [
"پست اینستاگرام",
"استوری",
"کاور لینکدین",
"بنر کمپین",
"ریلز و محتوای تعاملی"
],
"faq": [
{
"question": "برای چه پلتفرم‌هایی تولید محتوا انجام می‌شود؟",
"answer": "اینستاگرام، لینکدین و سایر شبکه‌های اجتماعی."
},
{
"question": "آیا استراتژی محتوا هم ارائه می‌شود؟",
"answer": "بله، پیش از تولید محتوا برنامه محتوایی تدوین می‌شود."
}
]
},
{
"id": "photography",
"title": "عکاسی",
"subtitle": "ثبت حرفه‌ای محصولات و برندها",
"description": "عکاسی حرفه‌ای محصول، پرتره و محتوای بصری برای استفاده در برند و تبلیغات.",
"intro": "تصاویر حرفه‌ای یکی از مهم‌ترین عوامل تأثیرگذاری بر تصمیم مخاطبان هستند. ما با عکاسی حرفه‌ای به نمایش بهتر محصولات و خدمات شما کمک می‌کنیم.",
"includes": [
"عکاسی محصول",
"عکاسی تبلیغاتی",
"عکاسی صنعتی",
"عکاسی برندینگ",
"ویرایش و روتوش تصاویر"
],
"benefits": [
"افزایش کیفیت نمایش محصولات",
"جلب اعتماد مشتریان",
"تولید محتوای حرفه‌ای",
"ارتقای تصویر برند"
],
"process": [
{ "title": "برنامه‌ریزی پروژه", "description": "هماهنگی اهداف، سبک و زمان‌بندی." },
{ "title": "آماده‌سازی صحنه", "description": "چیدمان نور، پس‌زمینه و تجهیزات." },
{ "title": "عکاسی", "description": "اجرای جلسه عکاسی حرفه‌ای." },
{ "title": "ویرایش تصاویر", "description": "رنگ‌بندی، روتوش و بهینه‌سازی." },
{ "title": "تحویل نهایی", "description": "تحویل تصاویر با کیفیت مناسب هر رسانه." }
],
"deliverables": [
"تصاویر فروشگاهی",
"عکس‌های تبلیغاتی",
"تصاویر وب‌سایت",
"تصاویر شبکه‌های اجتماعی"
],
"faq": [
{
"question": "آیا ادیت تصاویر هم انجام می‌شود؟",
"answer": "بله، تمامی تصاویر به صورت حرفه‌ای ویرایش می‌شوند."
},
{
"question": "امکان عکاسی در محل مشتری وجود دارد؟",
"answer": "بله، در صورت نیاز پروژه در محل اجرا می‌شود."
}
]
},
{
"id": "video",
"title": "تدوین و محتوای ویدیویی",
"subtitle": "روایت حرفه‌ای داستان برند شما با ویدئو",
"description": "تدوین، موشن گرافیک و تولید محتوای ویدیویی برای معرفی برند و کمپین‌ها.",
"intro": "ویدئو یکی از قدرتمندترین ابزارهای ارتباطی است. ما با تولید و تدوین محتوای ویدیویی حرفه‌ای به برند شما کمک می‌کنیم تا پیام خود را تأثیرگذارتر منتقل کند.",
"includes": [
"تدوین ویدئو",
"ساخت تیزر تبلیغاتی",
"موشن گرافیک",
"ویدئوهای شبکه اجتماعی",
"اصلاح رنگ و صدا",
"ساخت ویدئوهای معرفی برند"
],
"benefits": [
"افزایش تعامل مخاطبان",
"انتقال مؤثر پیام برند",
"افزایش نرخ تبدیل",
"ارتقای کیفیت محتوای دیجیتال"
],
"process": [
{ "title": "دریافت نیاز پروژه", "description": "شناخت اهداف و مخاطب ویدئو." },
{ "title": "سناریو و برنامه‌ریزی", "description": "تدوین سناریو و استوری‌بورد." },
{ "title": "تولید یا دریافت محتوا", "description": "فیلم‌برداری یا دریافت فوتیج." },
{ "title": "تدوین و اصلاح", "description": "تدوین، موشن و اصلاح رنگ و صدا." },
{ "title": "خروجی نهایی", "description": "تحویل ویدئو در فرمت‌های مورد نیاز." }
],
"deliverables": [
"تیزر تبلیغاتی",
"ویدئوی معرفی برند",
"موشن گرافیک",
"ریلز اینستاگرام",
"ویدئوهای آموزشی"
],
"faq": [
{
"question": "خروجی برای شبکه‌های اجتماعی هم آماده می‌شود؟",
"answer": "بله، ویدئوها متناسب با هر پلتفرم خروجی گرفته می‌شوند."
},
{
"question": "آیا موشن گرافیک هم ارائه می‌شود؟",
"answer": "بله، طراحی و اجرای موشن گرافیک بخشی از این خدمت است."
}
]
}
]
+7
View File
@@ -0,0 +1,7 @@
<template>
<RouterView />
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
+290
View File
@@ -0,0 +1,290 @@
const TOKEN_KEY = 'noro_admin_token'
export interface User {
email: string
role: string
}
export interface NavItem {
label: string
to: string
}
export interface PortfolioCategory {
slug: string
label: string
}
export interface Project {
id: string
title: string
category: string
description: string
longDescription?: string
year?: string
featured?: boolean
imageUrl?: string
galleryUrls?: string[]
client?: string
deliverables?: string[]
seoTitle?: string
seoDescription?: string
}
export interface ServiceStep {
title: string
description: string
}
export interface ServiceFaq {
question: string
answer: string
}
export interface Service {
id: string
title: string
subtitle: string
description: string
intro: string
includes: string[]
benefits: string[]
process: ServiceStep[]
deliverables: string[]
faq: ServiceFaq[]
iconUrl?: string
backgroundUrl?: string
portfolioCategorySlugs?: string[]
}
export interface Client {
id: string
name: string
logoUrl: string
}
export interface BlogPost {
id: string
title: string
slug: string
excerpt?: string
content?: string
coverUrl?: string
publishedAt?: string
published?: boolean
seoTitle?: string
seoDescription?: string
}
export interface MediaItem {
id: string
filename: string
url: string
mimeType?: string
size?: number
createdAt?: string
}
export interface SiteSettings {
name?: string
nameEn?: string
tagline?: string
description?: string
email?: string
website?: string
phone?: string
phoneTel?: string
location?: string
messengerHandle?: string
messengersLabel?: string
messengers?: Array<{ id: string; name: string; url: string; iconUrl?: string }>
seo?: { defaultTitle?: string; defaultDescription?: string }
hero?: { darkImageUrl?: string; lightImageUrl?: string }
[key: string]: unknown
}
class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.status = status
this.name = 'ApiError'
}
}
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY)
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
...(options.headers as Record<string, string> | undefined),
}
const token = getToken()
if (token) headers.Authorization = `Bearer ${token}`
const isFormData = options.body instanceof FormData
if (options.body && !isFormData && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json'
}
const res = await fetch(`/api${path}`, { ...options, headers })
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }))
throw new ApiError(body.error || res.statusText, res.status)
}
if (res.status === 204) return undefined as T
const text = await res.text()
return text ? JSON.parse(text) : (undefined as T)
}
export const api = {
login(email: string, password: string) {
return request<{ token: string; user: User }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
},
me() {
return request<{ user: User }>('/auth/me')
},
getSite() {
return request<SiteSettings>('/site')
},
putSite(data: SiteSettings) {
return request<SiteSettings>('/site', {
method: 'PUT',
body: JSON.stringify(data),
})
},
getNavigation() {
return request<NavItem[]>('/navigation')
},
putNavigation(data: NavItem[]) {
return request<NavItem[]>('/navigation', {
method: 'PUT',
body: JSON.stringify(data),
})
},
getPortfolioCategories() {
return request<PortfolioCategory[]>('/portfolio/categories')
},
getProjects() {
return request<Project[]>('/portfolio/projects')
},
getProject(id: string) {
return request<Project>(`/portfolio/projects/${id}`)
},
createProject(data: Partial<Project>) {
return request<Project>('/portfolio/projects', {
method: 'POST',
body: JSON.stringify(data),
})
},
updateProject(id: string, data: Partial<Project>) {
return request<Project>(`/portfolio/projects/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
deleteProject(id: string) {
return request<void>(`/portfolio/projects/${id}`, { method: 'DELETE' })
},
getServices() {
return request<Service[]>('/services')
},
getService(id: string) {
return request<Service>(`/services/${id}`)
},
putServices(data: Service[]) {
return request<Service[]>('/services', {
method: 'PUT',
body: JSON.stringify(data),
})
},
updateService(id: string, data: Partial<Service>) {
return request<Service>(`/services/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
getClients() {
return request<Client[]>('/clients')
},
putClients(data: Client[]) {
return request<Client[]>('/clients', {
method: 'PUT',
body: JSON.stringify(data),
})
},
getBlogPosts() {
return request<BlogPost[]>('/blog/posts/all')
},
getBlogPost(id: string) {
return request<BlogPost>(`/blog/posts/${id}`)
},
createBlogPost(data: Partial<BlogPost>) {
return request<BlogPost>('/blog/posts', {
method: 'POST',
body: JSON.stringify(data),
})
},
updateBlogPost(id: string, data: Partial<BlogPost>) {
return request<BlogPost>(`/blog/posts/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
deleteBlogPost(id: string) {
return request<void>(`/blog/posts/${id}`, { method: 'DELETE' })
},
getMedia() {
return request<MediaItem[]>('/media')
},
uploadMedia(file: File) {
const form = new FormData()
form.append('file', file)
return request<MediaItem>('/media/upload', {
method: 'POST',
body: form,
})
},
}
export { ApiError }
+110
View File
@@ -0,0 +1,110 @@
<template>
<div class="flex min-h-screen bg-slate-50">
<!-- Sidebar -->
<aside class="fixed inset-y-0 right-0 z-30 flex w-64 flex-col border-l border-slate-200 bg-white shadow-sm">
<div class="flex items-center gap-3 border-b border-slate-100 px-5 py-5">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-brand text-lg font-bold text-white">
ن
</div>
<div>
<p class="text-sm font-bold text-slate-900">استودیو نورو</p>
<p class="text-xs text-slate-500">پنل مدیریت CMS</p>
</div>
</div>
<nav class="flex-1 space-y-1 overflow-y-auto p-3">
<RouterLink
v-for="item in navItems"
:key="item.name"
:to="{ name: item.name }"
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors"
:class="isActive(item.name)
? 'bg-brand/10 text-brand-700'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'"
>
<span class="text-lg" aria-hidden="true">{{ item.icon }}</span>
{{ item.label }}
</RouterLink>
</nav>
<div class="border-t border-slate-100 p-4">
<p class="truncate text-xs text-slate-500">{{ userEmail }}</p>
<button
type="button"
class="mt-2 w-full rounded-lg px-3 py-2 text-right text-sm text-red-600 transition-colors hover:bg-red-50"
@click="logout"
>
خروج از حساب
</button>
</div>
</aside>
<!-- Main -->
<div class="mr-64 flex min-h-screen flex-1 flex-col">
<header class="sticky top-0 z-20 border-b border-slate-200 bg-white/90 px-8 py-4 backdrop-blur">
<h1 class="text-lg font-semibold text-slate-800">{{ pageTitle }}</h1>
</header>
<main class="flex-1 p-8">
<RouterView />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, clearToken } from '@/api/client'
const route = useRoute()
const router = useRouter()
const userEmail = ref('')
const navItems = [
{ name: 'dashboard', label: 'داشبورد', icon: '📊' },
{ name: 'site', label: 'تنظیمات سایت', icon: '⚙️' },
{ name: 'portfolio', label: 'نمونه‌کارها', icon: '🎨' },
{ name: 'services', label: 'خدمات', icon: '💼' },
{ name: 'clients', label: 'مشتریان', icon: '🤝' },
{ name: 'blog', label: 'وبلاگ', icon: '📝' },
{ name: 'media', label: 'رسانه', icon: '🖼️' },
]
const titles: Record<string, string> = {
dashboard: 'داشبورد',
site: 'تنظیمات سایت',
portfolio: 'نمونه‌کارها',
'portfolio-edit': 'ویرایش نمونه‌کار',
services: 'خدمات',
'service-edit': 'ویرایش خدمت',
clients: 'مشتریان',
blog: 'وبلاگ',
'blog-edit': 'ویرایش مطلب',
media: 'کتابخانه رسانه',
}
const pageTitle = computed(() => titles[String(route.name)] || 'پنل مدیریت')
function isActive(name: string) {
if (route.name === name) return true
if (name === 'portfolio' && route.name === 'portfolio-edit') return true
if (name === 'services' && route.name === 'service-edit') return true
if (name === 'blog' && route.name === 'blog-edit') return true
return false
}
function logout() {
clearToken()
router.push({ name: 'login' })
}
onMounted(async () => {
try {
const { user } = await api.me()
userEmail.value = user.email
} catch {
clearToken()
router.push({ name: 'login' })
}
})
</script>
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './style.css'
createApp(App).use(router).mount('#app')
+86
View File
@@ -0,0 +1,86 @@
import { createRouter, createWebHistory } from 'vue-router'
import { getToken } from '@/api/client'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { public: true },
},
{
path: '/',
component: () => import('@/layouts/AdminLayout.vue'),
children: [
{ path: '', redirect: { name: 'dashboard' } },
{
path: 'dashboard',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
},
{
path: 'site',
name: 'site',
component: () => import('@/views/SiteSettingsView.vue'),
},
{
path: 'portfolio',
name: 'portfolio',
component: () => import('@/views/PortfolioListView.vue'),
},
{
path: 'portfolio-edit/:id?',
name: 'portfolio-edit',
component: () => import('@/views/PortfolioEditView.vue'),
},
{
path: 'services',
name: 'services',
component: () => import('@/views/ServicesListView.vue'),
},
{
path: 'service-edit/:id',
name: 'service-edit',
component: () => import('@/views/ServiceEditView.vue'),
},
{
path: 'clients',
name: 'clients',
component: () => import('@/views/ClientsView.vue'),
},
{
path: 'blog',
name: 'blog',
component: () => import('@/views/BlogListView.vue'),
},
{
path: 'blog-edit/:id?',
name: 'blog-edit',
component: () => import('@/views/BlogEditView.vue'),
},
{
path: 'media',
name: 'media',
component: () => import('@/views/MediaView.vue'),
},
],
},
],
})
router.beforeEach((to) => {
const isPublic = to.meta.public === true
const token = getToken()
if (!isPublic && !token) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.name === 'login' && token) {
return { name: 'dashboard' }
}
})
export default router
+43
View File
@@ -0,0 +1,43 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
font-family: 'Vazirmatn', Tahoma, system-ui, sans-serif;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-brand/40 disabled:cursor-not-allowed disabled:opacity-60;
}
.btn-primary {
@apply btn bg-brand text-white hover:bg-brand-500;
}
.btn-secondary {
@apply btn border border-slate-200 bg-white text-slate-700 hover:bg-slate-50;
}
.btn-danger {
@apply btn bg-red-500 text-white hover:bg-red-600;
}
.input {
@apply w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:border-brand focus:outline-none focus:ring-2 focus:ring-brand/20;
}
.label {
@apply mb-1 block text-sm font-medium text-slate-700;
}
.card {
@apply rounded-xl border border-slate-200 bg-white p-6 shadow-sm;
}
.page-title {
@apply text-2xl font-bold text-slate-900;
}
}
+142
View File
@@ -0,0 +1,142 @@
<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>
<p v-if="message" class="mb-4 rounded-lg bg-green-50 px-4 py-2 text-sm text-green-700">{{ message }}</p>
<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>
<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" />
</div>
<div>
<label class="label">اسلاگ</label>
<input v-model="form.slug" class="input" dir="ltr" required />
</div>
<div class="flex items-center gap-2 pt-6">
<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">
<label class="label">آدرس تصویر کاور</label>
<input v-model="form.coverUrl" class="input" dir="ltr" placeholder="/uploads/..." />
</div>
</div>
</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>
<button type="submit" class="btn-primary" :disabled="saving">
{{ saving ? 'در حال ذخیره...' : 'ذخیره' }}
</button>
</form>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, type BlogPost } from '@/api/client'
const route = useRoute()
const router = useRouter()
const id = computed(() => route.params.id as string | undefined)
const isNew = computed(() => !id.value)
const slugTouched = ref(false)
const loading = ref(true)
const saving = ref(false)
const message = ref('')
const error = ref('')
const form = reactive<Partial<BlogPost>>({
title: '',
slug: '',
excerpt: '',
content: '',
coverUrl: '',
published: false,
seoTitle: '',
seoDescription: '',
})
function slugify(text: string) {
return text
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\w\u0600-\u06FF-]+/g, '')
.replace(/-+/g, '-')
}
function maybeSlug() {
if (!slugTouched.value && isNew.value) {
form.slug = slugify(form.title || '')
}
}
onMounted(async () => {
try {
if (id.value) {
const post = await api.getBlogPost(id.value)
Object.assign(form, post)
slugTouched.value = true
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
} finally {
loading.value = false
}
})
async function save() {
saving.value = true
message.value = ''
error.value = ''
try {
if (isNew.value) {
const created = await api.createBlogPost({ ...form })
router.replace({ name: 'blog-edit', params: { id: created.id } })
} else {
await api.updateBlogPost(id.value!, { ...form })
}
message.value = 'مطلب ذخیره شد.'
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در ذخیره'
} finally {
saving.value = false
}
}
</script>
+84
View File
@@ -0,0 +1,84 @@
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="page-title">وبلاگ</h2>
<RouterLink :to="{ name: 'blog-edit' }" class="btn-primary">مطلب جدید</RouterLink>
</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="posts.length === 0" class="card text-center text-slate-500">
هنوز مطلبی منتشر نشده است.
</div>
<div v-else class="card overflow-hidden p-0">
<table class="w-full text-sm">
<thead class="border-b border-slate-100 bg-slate-50 text-right">
<tr>
<th class="px-4 py-3 font-medium text-slate-600">عنوان</th>
<th class="px-4 py-3 font-medium text-slate-600">اسلاگ</th>
<th class="px-4 py-3 font-medium text-slate-600">وضعیت</th>
<th class="px-4 py-3 font-medium text-slate-600">عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="post in posts" :key="post.id" class="border-b border-slate-50 hover:bg-slate-50/50">
<td class="px-4 py-3 font-medium">{{ post.title }}</td>
<td class="px-4 py-3 text-slate-600" dir="ltr">{{ post.slug }}</td>
<td class="px-4 py-3">
<span
class="rounded-full px-2 py-0.5 text-xs"
:class="post.published ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-600'"
>
{{ post.published ? 'منتشر شده' : 'پیش‌نویس' }}
</span>
</td>
<td class="px-4 py-3">
<div class="flex gap-2">
<RouterLink :to="{ name: 'blog-edit', params: { id: post.id } }" class="text-brand hover:underline">
ویرایش
</RouterLink>
<button type="button" class="text-red-500 hover:underline" @click="remove(post.id)">حذف</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, type BlogPost } from '@/api/client'
const loading = ref(true)
const error = ref('')
const posts = ref<BlogPost[]>([])
async function load() {
loading.value = true
error.value = ''
try {
posts.value = await api.getBlogPosts()
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
} finally {
loading.value = false
}
}
async function remove(id: string) {
if (!confirm('آیا از حذف این مطلب مطمئن هستید؟')) return
try {
await api.deleteBlogPost(id)
posts.value = posts.value.filter((p) => p.id !== id)
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در حذف'
}
}
onMounted(load)
</script>
+89
View File
@@ -0,0 +1,89 @@
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="page-title">مشتریان</h2>
<div class="flex gap-2">
<button type="button" class="btn-secondary" @click="addClient">افزودن مشتری</button>
<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>
<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 class="space-y-4">
<div v-for="(client, i) in clients" :key="client.id" class="card">
<div class="grid gap-4 sm:grid-cols-3">
<div>
<label class="label">شناسه</label>
<input v-model="client.id" class="input" dir="ltr" />
</div>
<div>
<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>
</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>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, type Client } from '@/api/client'
const loading = ref(true)
const saving = ref(false)
const message = ref('')
const error = ref('')
const clients = ref<Client[]>([])
onMounted(async () => {
try {
clients.value = await api.getClients()
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
} finally {
loading.value = false
}
})
function addClient() {
clients.value.push({
id: `client-${Date.now()}`,
name: '',
logoUrl: '',
})
}
async function save() {
saving.value = true
message.value = ''
error.value = ''
try {
clients.value = await api.putClients(clients.value)
message.value = 'لیست مشتریان ذخیره شد.'
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در ذخیره'
} finally {
saving.value = false
}
}
</script>
+71
View File
@@ -0,0 +1,71 @@
<template>
<div>
<p class="mb-6 text-slate-600">به پنل مدیریت استودیو نورو خوش آمدید.</p>
<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-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>
</div>
</div>
<div class="mt-8 grid gap-4 lg:grid-cols-2">
<div class="card">
<h2 class="mb-4 font-semibold text-slate-800">دسترسی سریع</h2>
<div class="flex flex-wrap gap-2">
<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: 'media' }" class="btn-secondary">آپلود رسانه</RouterLink>
</div>
</div>
<div class="card">
<h2 class="mb-2 font-semibold text-slate-800">اطلاعات سایت</h2>
<p class="text-sm text-slate-600">{{ siteName }}</p>
<p class="mt-1 text-sm text-slate-500">{{ siteTagline }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api } from '@/api/client'
const loading = ref(true)
const siteName = ref('')
const siteTagline = ref('')
const stats = ref([
{ value: '—', label: 'نمونه‌کار' },
{ value: '—', label: 'خدمات' },
{ value: '—', label: 'مشتری' },
{ value: '—', label: 'مطلب وبلاگ' },
])
onMounted(async () => {
try {
const [site, projects, services, clients, posts] = await Promise.all([
api.getSite(),
api.getProjects(),
api.getServices(),
api.getClients(),
api.getBlogPosts(),
])
siteName.value = site.name || 'استودیو نورو'
siteTagline.value = site.tagline || ''
stats.value = [
{ value: String(projects.length), label: 'نمونه‌کار' },
{ value: String(services.length), label: 'خدمات' },
{ value: String(clients.length), label: 'مشتری' },
{ value: String(posts.length), label: 'مطلب وبلاگ' },
]
} catch {
// stats stay as dashes
} finally {
loading.value = false
}
})
</script>
+76
View File
@@ -0,0 +1,76 @@
<template>
<div class="flex min-h-screen items-center justify-center bg-gradient-to-bl from-brand-50 via-white to-slate-100 p-4">
<div class="w-full max-w-md">
<div class="mb-8 text-center">
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-brand text-2xl font-bold text-white shadow-lg shadow-brand/30">
ن
</div>
<h1 class="text-2xl font-bold text-slate-900">ورود به پنل مدیریت</h1>
<p class="mt-1 text-sm text-slate-500">استودیو نورو CMS</p>
</div>
<form class="card space-y-4" @submit.prevent="submit">
<div>
<label class="label" for="email">ایمیل</label>
<input
id="email"
v-model="email"
type="email"
class="input"
placeholder="admin@norostudio.ir"
required
autocomplete="username"
/>
</div>
<div>
<label class="label" for="password">رمز عبور</label>
<input
id="password"
v-model="password"
type="password"
class="input"
placeholder="••••••••"
required
autocomplete="current-password"
/>
</div>
<p v-if="error" class="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{{ error }}</p>
<button type="submit" class="btn-primary w-full" :disabled="loading">
{{ loading ? 'در حال ورود...' : 'ورود' }}
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, setToken } from '@/api/client'
const route = useRoute()
const router = useRouter()
const email = ref('admin@norostudio.ir')
const password = ref('admin123')
const loading = ref(false)
const error = ref('')
async function submit() {
loading.value = true
error.value = ''
try {
const { token } = await api.login(email.value, password.value)
setToken(token)
const redirect = (route.query.redirect as string) || '/dashboard'
router.push(redirect)
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در ورود'
} finally {
loading.value = false
}
}
</script>
+105
View File
@@ -0,0 +1,105 @@
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="page-title">کتابخانه رسانه</h2>
<label class="btn-primary cursor-pointer">
{{ uploading ? 'در حال آپلود...' : 'آپلود فایل' }}
<input type="file" class="hidden" accept="image/*,video/*,.svg,.pdf" :disabled="uploading" @change="onUpload" />
</label>
</div>
<p v-if="message" class="mb-4 rounded-lg bg-green-50 px-4 py-2 text-sm text-green-700">{{ message }}</p>
<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="items.length === 0" class="card text-center text-slate-500">
هنوز فایلی آپلود نشده است.
</div>
<div v-else class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<div v-for="item in items" :key="item.id" class="card overflow-hidden p-0">
<div class="flex aspect-square items-center justify-center bg-slate-100">
<img
v-if="isImage(item)"
:src="item.url"
:alt="item.filename"
class="h-full w-full object-cover"
/>
<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>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, type MediaItem } from '@/api/client'
const loading = ref(true)
const uploading = ref(false)
const message = ref('')
const error = ref('')
const items = ref<MediaItem[]>([])
function isImage(item: MediaItem) {
const mime = item.mimeType || ''
const name = item.filename?.toLowerCase() || ''
return mime.startsWith('image/') || /\.(png|jpe?g|gif|webp|svg)$/.test(name)
}
async function load() {
loading.value = true
error.value = ''
try {
items.value = await api.getMedia()
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
} finally {
loading.value = false
}
}
async function onUpload(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
uploading.value = true
message.value = ''
error.value = ''
try {
const uploaded = await api.uploadMedia(file)
items.value.unshift(uploaded)
message.value = 'فایل با موفقیت آپلود شد.'
} catch (err) {
error.value = err instanceof Error ? err.message : 'خطا در آپلود'
} finally {
uploading.value = false
input.value = ''
}
}
async function copyUrl(url: string) {
try {
await navigator.clipboard.writeText(url)
message.value = 'آدرس کپی شد.'
} catch {
message.value = url
}
}
onMounted(load)
</script>
+147
View File
@@ -0,0 +1,147 @@
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="page-title">{{ isNew ? 'نمونه‌کار جدید' : 'ویرایش نمونه‌کار' }}</h2>
<RouterLink :to="{ name: 'portfolio' }" class="btn-secondary">بازگشت</RouterLink>
</div>
<p v-if="message" class="mb-4 rounded-lg bg-green-50 px-4 py-2 text-sm text-green-700">{{ message }}</p>
<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>
<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 />
</div>
<div>
<label class="label">دسته‌بندی</label>
<select v-model="form.category" class="input" required>
<option value="">انتخاب کنید</option>
<option v-for="c in categories" :key="c.slug" :value="c.slug">{{ c.label }}</option>
</select>
</div>
<div>
<label class="label">سال</label>
<input v-model="form.year" class="input" />
</div>
<div>
<label class="label">مشتری</label>
<input v-model="form.client" class="input" />
</div>
<div class="flex items-center gap-2 pt-6">
<input id="featured" v-model="form.featured" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-brand focus:ring-brand" />
<label for="featured" class="text-sm text-slate-700">نمونه‌کار ویژه</label>
</div>
<div class="sm:col-span-2">
<label class="label">توضیح کوتاه</label>
<textarea v-model="form.description" class="input min-h-[80px]" rows="3" required />
</div>
<div class="sm:col-span-2">
<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" />
</div>
</div>
</section>
<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" />
</div>
<div>
<label class="label">توضیحات سئو</label>
<textarea v-model="form.seoDescription" class="input min-h-[80px]" rows="3" />
</div>
</section>
<button type="submit" class="btn-primary" :disabled="saving">
{{ saving ? 'در حال ذخیره...' : 'ذخیره' }}
</button>
</form>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, type PortfolioCategory, type Project } from '@/api/client'
const route = useRoute()
const router = useRouter()
const id = computed(() => route.params.id as string | undefined)
const isNew = computed(() => !id.value)
const loading = ref(true)
const saving = ref(false)
const message = ref('')
const error = ref('')
const categories = ref<PortfolioCategory[]>([])
const form = reactive<Partial<Project>>({
title: '',
category: '',
description: '',
longDescription: '',
year: '',
client: '',
featured: false,
imageUrl: '',
seoTitle: '',
seoDescription: '',
deliverables: [],
})
const deliverablesText = computed({
get: () => (form.deliverables || []).join('\n'),
set: (v: string) => {
form.deliverables = v.split('\n').map((s) => s.trim()).filter(Boolean)
},
})
onMounted(async () => {
try {
categories.value = await api.getPortfolioCategories()
if (id.value) {
const project = await api.getProject(id.value)
Object.assign(form, project)
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
} finally {
loading.value = false
}
})
async function save() {
saving.value = true
message.value = ''
error.value = ''
try {
if (isNew.value) {
const created = await api.createProject({ ...form })
router.replace({ name: 'portfolio-edit', params: { id: created.id } })
} else {
await api.updateProject(id.value!, { ...form })
}
message.value = 'نمونه‌کار ذخیره شد.'
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در ذخیره'
} finally {
saving.value = false
}
}
</script>
+89
View File
@@ -0,0 +1,89 @@
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="page-title">نمونه‌کارها</h2>
<RouterLink :to="{ name: 'portfolio-edit' }" class="btn-primary">نمونه‌کار جدید</RouterLink>
</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="projects.length === 0" class="card text-center text-slate-500">
هنوز نمونه‌کاری ثبت نشده است.
</div>
<div v-else class="card overflow-hidden p-0">
<table class="w-full text-sm">
<thead class="border-b border-slate-100 bg-slate-50 text-right">
<tr>
<th class="px-4 py-3 font-medium text-slate-600">عنوان</th>
<th class="px-4 py-3 font-medium text-slate-600">دسته</th>
<th class="px-4 py-3 font-medium text-slate-600">سال</th>
<th class="px-4 py-3 font-medium text-slate-600">ویژه</th>
<th class="px-4 py-3 font-medium text-slate-600">عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="p in projects" :key="p.id" class="border-b border-slate-50 hover:bg-slate-50/50">
<td class="px-4 py-3 font-medium">{{ p.title }}</td>
<td class="px-4 py-3 text-slate-600">{{ categoryLabel(p.category) }}</td>
<td class="px-4 py-3 text-slate-600">{{ p.year || '—' }}</td>
<td class="px-4 py-3">
<span v-if="p.featured" class="rounded-full bg-brand/10 px-2 py-0.5 text-xs text-brand-700">بله</span>
<span v-else class="text-slate-400">—</span>
</td>
<td class="px-4 py-3">
<div class="flex gap-2">
<RouterLink :to="{ name: 'portfolio-edit', params: { id: p.id } }" class="text-brand hover:underline">
ویرایش
</RouterLink>
<button type="button" class="text-red-500 hover:underline" @click="remove(p.id)">حذف</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, type PortfolioCategory, type Project } from '@/api/client'
const loading = ref(true)
const error = ref('')
const projects = ref<Project[]>([])
const categories = ref<PortfolioCategory[]>([])
function categoryLabel(slug: string) {
return categories.value.find((c) => c.slug === slug)?.label || slug
}
async function load() {
loading.value = true
error.value = ''
try {
const [p, c] = await Promise.all([api.getProjects(), api.getPortfolioCategories()])
projects.value = p
categories.value = c
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
} finally {
loading.value = false
}
}
async function remove(id: string) {
if (!confirm('آیا از حذف این نمونه‌کار مطمئن هستید؟')) return
try {
await api.deleteProject(id)
projects.value = projects.value.filter((p) => p.id !== id)
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در حذف'
}
}
onMounted(load)
</script>
+148
View File
@@ -0,0 +1,148 @@
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="page-title">ویرایش خدمت</h2>
<RouterLink :to="{ name: 'services' }" class="btn-secondary">بازگشت</RouterLink>
</div>
<p v-if="message" class="mb-4 rounded-lg bg-green-50 px-4 py-2 text-sm text-green-700">{{ message }}</p>
<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>
<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 />
</div>
<div class="sm:col-span-2">
<label class="label">زیرعنوان</label>
<input v-model="form.subtitle" class="input" />
</div>
<div class="sm:col-span-2">
<label class="label">توضیح کوتاه</label>
<textarea v-model="form.description" class="input min-h-[80px]" rows="3" />
</div>
<div class="sm:col-span-2">
<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>
<div>
<label class="label">آدرس پس‌زمینه</label>
<input v-model="form.backgroundUrl" class="input" dir="ltr" />
</div>
<div class="sm:col-span-2">
<label class="label">شامل می‌شود (هر خط یک مورد)</label>
<textarea v-model="includesText" class="input min-h-[100px]" rows="4" />
</div>
<div class="sm:col-span-2">
<label class="label">مزایا (هر خط یک مورد)</label>
<textarea v-model="benefitsText" class="input min-h-[100px]" rows="4" />
</div>
<div class="sm:col-span-2">
<label class="label">تحویلی‌ها (هر خط یک مورد)</label>
<textarea v-model="deliverablesText" class="input min-h-[80px]" rows="3" />
</div>
</div>
</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="form.faq.push({ question: '', answer: '' })">
افزودن
</button>
</div>
<div v-for="(item, i) in form.faq" :key="i" class="rounded-lg border border-slate-100 p-4 space-y-2">
<input v-model="item.question" class="input" placeholder="سوال" />
<textarea v-model="item.answer" class="input min-h-[60px]" rows="2" placeholder="پاسخ" />
<button type="button" class="text-sm text-red-500" @click="form.faq.splice(i, 1)">حذف</button>
</div>
</section>
<button type="submit" class="btn-primary" :disabled="saving">
{{ saving ? 'در حال ذخیره...' : 'ذخیره' }}
</button>
</form>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { api, type Service } from '@/api/client'
const route = useRoute()
const serviceId = route.params.id as string
const loading = ref(true)
const saving = ref(false)
const message = ref('')
const error = ref('')
const form = reactive<Service>({
id: serviceId,
title: '',
subtitle: '',
description: '',
intro: '',
includes: [],
benefits: [],
process: [],
deliverables: [],
faq: [],
iconUrl: '',
backgroundUrl: '',
})
function linesToArray(text: string) {
return text.split('\n').map((s) => s.trim()).filter(Boolean)
}
const includesText = computed({
get: () => form.includes.join('\n'),
set: (v) => { form.includes = linesToArray(v) },
})
const benefitsText = computed({
get: () => form.benefits.join('\n'),
set: (v) => { form.benefits = linesToArray(v) },
})
const deliverablesText = computed({
get: () => form.deliverables.join('\n'),
set: (v) => { form.deliverables = linesToArray(v) },
})
onMounted(async () => {
try {
const service = await api.getService(serviceId)
Object.assign(form, service)
if (!form.faq) form.faq = []
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
} finally {
loading.value = false
}
})
async function save() {
saving.value = true
message.value = ''
error.value = ''
try {
await api.updateService(serviceId, { ...form })
message.value = 'خدمت ذخیره شد.'
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در ذخیره'
} finally {
saving.value = false
}
}
</script>
+46
View File
@@ -0,0 +1,46 @@
<template>
<div>
<div class="mb-6">
<h2 class="page-title">خدمات</h2>
<p class="mt-1 text-sm text-slate-500">برای ویرایش جزئیات هر خدمت روی آن کلیک کنید.</p>
</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 class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<RouterLink
v-for="s in services"
:key="s.id"
:to="{ name: 'service-edit', params: { id: s.id } }"
class="card group transition-shadow hover:shadow-md"
>
<div class="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-brand/10 text-xl">
{{ s.title.charAt(0) }}
</div>
<h3 class="font-semibold text-slate-800 group-hover:text-brand">{{ s.title }}</h3>
<p class="mt-1 line-clamp-2 text-sm text-slate-500">{{ s.subtitle }}</p>
</RouterLink>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, type Service } from '@/api/client'
const loading = ref(true)
const error = ref('')
const services = ref<Service[]>([])
onMounted(async () => {
try {
services.value = await api.getServices()
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
} finally {
loading.value = false
}
})
</script>
+151
View File
@@ -0,0 +1,151 @@
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="page-title">تنظیمات سایت</h2>
<button type="button" class="btn-primary" :disabled="saving" @click="save">
{{ saving ? 'در حال ذخیره...' : 'ذخیره تغییرات' }}
</button>
</div>
<p v-if="message" class="mb-4 rounded-lg bg-green-50 px-4 py-2 text-sm text-green-700">{{ message }}</p>
<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 class="space-y-6">
<section class="card space-y-4">
<h3 class="font-semibold text-slate-800">اطلاعات عمومی</h3>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="label">نام فارسی</label>
<input v-model="site.name" class="input" />
</div>
<div>
<label class="label">نام انگلیسی</label>
<input v-model="site.nameEn" class="input" dir="ltr" />
</div>
<div class="sm:col-span-2">
<label class="label">شعار</label>
<input v-model="site.tagline" class="input" />
</div>
<div class="sm:col-span-2">
<label class="label">توضیحات</label>
<textarea v-model="site.description" class="input min-h-[100px]" rows="4" />
</div>
</div>
</section>
<section class="card space-y-4">
<h3 class="font-semibold text-slate-800">تماس</h3>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="label">ایمیل</label>
<input v-model="site.email" type="email" class="input" dir="ltr" />
</div>
<div>
<label class="label">وب‌سایت</label>
<input v-model="site.website" class="input" dir="ltr" />
</div>
<div>
<label class="label">تلفن</label>
<input v-model="site.phone" class="input" />
</div>
<div>
<label class="label">موقعیت</label>
<input v-model="site.location" class="input" />
</div>
</div>
</section>
<section class="card space-y-4">
<h3 class="font-semibold text-slate-800">سئو پیش‌فرض</h3>
<div class="grid gap-4">
<div>
<label class="label">عنوان پیش‌فرض</label>
<input v-model="seo.defaultTitle" class="input" />
</div>
<div>
<label class="label">توضیحات پیش‌فرض</label>
<textarea v-model="seo.defaultDescription" class="input min-h-[80px]" rows="3" />
</div>
</div>
</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="addNavItem">افزودن آیتم</button>
</div>
<div v-for="(item, i) in navigation" :key="i" class="flex gap-2">
<input v-model="item.label" class="input flex-1" placeholder="برچسب" />
<input v-model="item.to" class="input flex-1" placeholder="/path" dir="ltr" />
<button type="button" class="btn-danger px-3" @click="navigation.splice(i, 1)">حذف</button>
</div>
<button type="button" class="btn-secondary" :disabled="savingNav" @click="saveNavigation">
{{ savingNav ? '...' : 'ذخیره منو' }}
</button>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { api, type NavItem, type SiteSettings } from '@/api/client'
const loading = ref(true)
const saving = ref(false)
const savingNav = ref(false)
const message = ref('')
const error = ref('')
const site = reactive<SiteSettings>({})
const seo = reactive({ defaultTitle: '', defaultDescription: '' })
const navigation = ref<NavItem[]>([])
onMounted(async () => {
try {
const [siteData, nav] = await Promise.all([api.getSite(), api.getNavigation()])
Object.assign(site, siteData)
seo.defaultTitle = siteData.seo?.defaultTitle || ''
seo.defaultDescription = siteData.seo?.defaultDescription || ''
navigation.value = [...nav]
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
} finally {
loading.value = false
}
})
function addNavItem() {
navigation.value.push({ label: '', to: '' })
}
async function save() {
saving.value = true
message.value = ''
error.value = ''
try {
site.seo = { defaultTitle: seo.defaultTitle, defaultDescription: seo.defaultDescription }
await api.putSite({ ...site })
message.value = 'تنظیمات سایت ذخیره شد.'
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در ذخیره'
} finally {
saving.value = false
}
}
async function saveNavigation() {
savingNav.value = true
error.value = ''
try {
await api.putNavigation(navigation.value)
message.value = 'منوی ناوبری ذخیره شد.'
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در ذخیره منو'
} finally {
savingNav.value = false
}
}
</script>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
+27
View File
@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
brand: {
DEFAULT: '#39c0c4',
50: '#eefbfb',
100: '#d5f4f5',
200: '#aee9eb',
300: '#7ad9dc',
400: '#39c0c4',
500: '#2aa8ac',
600: '#22878a',
700: '#206c6f',
800: '#1f5759',
900: '#1e494b',
},
},
fontFamily: {
sans: ['Vazirmatn', 'Tahoma', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:4000',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:4000',
changeOrigin: true,
},
},
},
})