Initial commit: Admin panel
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user