Initial commit: Admin panel
This commit is contained in:
@@ -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
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
uploads/
|
||||
data/
|
||||
*.log
|
||||
.DS_Store
|
||||
.idea
|
||||
@@ -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/`
|
||||
+16
@@ -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>
|
||||
Generated
+3830
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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 |
@@ -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 })
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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": "بله، طراحی و اجرای موشن گرافیک بخشی از این خدمت است."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Vendored
+7
@@ -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
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user