Initial commit: Admin panel

This commit is contained in:
Noro Studio
2026-06-30 00:50:13 +03:30
commit 79491cd8ef
36 changed files with 6448 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
<template>
<RouterView />
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
+290
View File
@@ -0,0 +1,290 @@
const TOKEN_KEY = 'noro_admin_token'
export interface User {
email: string
role: string
}
export interface NavItem {
label: string
to: string
}
export interface PortfolioCategory {
slug: string
label: string
}
export interface Project {
id: string
title: string
category: string
description: string
longDescription?: string
year?: string
featured?: boolean
imageUrl?: string
galleryUrls?: string[]
client?: string
deliverables?: string[]
seoTitle?: string
seoDescription?: string
}
export interface ServiceStep {
title: string
description: string
}
export interface ServiceFaq {
question: string
answer: string
}
export interface Service {
id: string
title: string
subtitle: string
description: string
intro: string
includes: string[]
benefits: string[]
process: ServiceStep[]
deliverables: string[]
faq: ServiceFaq[]
iconUrl?: string
backgroundUrl?: string
portfolioCategorySlugs?: string[]
}
export interface Client {
id: string
name: string
logoUrl: string
}
export interface BlogPost {
id: string
title: string
slug: string
excerpt?: string
content?: string
coverUrl?: string
publishedAt?: string
published?: boolean
seoTitle?: string
seoDescription?: string
}
export interface MediaItem {
id: string
filename: string
url: string
mimeType?: string
size?: number
createdAt?: string
}
export interface SiteSettings {
name?: string
nameEn?: string
tagline?: string
description?: string
email?: string
website?: string
phone?: string
phoneTel?: string
location?: string
messengerHandle?: string
messengersLabel?: string
messengers?: Array<{ id: string; name: string; url: string; iconUrl?: string }>
seo?: { defaultTitle?: string; defaultDescription?: string }
hero?: { darkImageUrl?: string; lightImageUrl?: string }
[key: string]: unknown
}
class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.status = status
this.name = 'ApiError'
}
}
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY)
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
...(options.headers as Record<string, string> | undefined),
}
const token = getToken()
if (token) headers.Authorization = `Bearer ${token}`
const isFormData = options.body instanceof FormData
if (options.body && !isFormData && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json'
}
const res = await fetch(`/api${path}`, { ...options, headers })
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }))
throw new ApiError(body.error || res.statusText, res.status)
}
if (res.status === 204) return undefined as T
const text = await res.text()
return text ? JSON.parse(text) : (undefined as T)
}
export const api = {
login(email: string, password: string) {
return request<{ token: string; user: User }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
},
me() {
return request<{ user: User }>('/auth/me')
},
getSite() {
return request<SiteSettings>('/site')
},
putSite(data: SiteSettings) {
return request<SiteSettings>('/site', {
method: 'PUT',
body: JSON.stringify(data),
})
},
getNavigation() {
return request<NavItem[]>('/navigation')
},
putNavigation(data: NavItem[]) {
return request<NavItem[]>('/navigation', {
method: 'PUT',
body: JSON.stringify(data),
})
},
getPortfolioCategories() {
return request<PortfolioCategory[]>('/portfolio/categories')
},
getProjects() {
return request<Project[]>('/portfolio/projects')
},
getProject(id: string) {
return request<Project>(`/portfolio/projects/${id}`)
},
createProject(data: Partial<Project>) {
return request<Project>('/portfolio/projects', {
method: 'POST',
body: JSON.stringify(data),
})
},
updateProject(id: string, data: Partial<Project>) {
return request<Project>(`/portfolio/projects/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
deleteProject(id: string) {
return request<void>(`/portfolio/projects/${id}`, { method: 'DELETE' })
},
getServices() {
return request<Service[]>('/services')
},
getService(id: string) {
return request<Service>(`/services/${id}`)
},
putServices(data: Service[]) {
return request<Service[]>('/services', {
method: 'PUT',
body: JSON.stringify(data),
})
},
updateService(id: string, data: Partial<Service>) {
return request<Service>(`/services/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
getClients() {
return request<Client[]>('/clients')
},
putClients(data: Client[]) {
return request<Client[]>('/clients', {
method: 'PUT',
body: JSON.stringify(data),
})
},
getBlogPosts() {
return request<BlogPost[]>('/blog/posts/all')
},
getBlogPost(id: string) {
return request<BlogPost>(`/blog/posts/${id}`)
},
createBlogPost(data: Partial<BlogPost>) {
return request<BlogPost>('/blog/posts', {
method: 'POST',
body: JSON.stringify(data),
})
},
updateBlogPost(id: string, data: Partial<BlogPost>) {
return request<BlogPost>(`/blog/posts/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
deleteBlogPost(id: string) {
return request<void>(`/blog/posts/${id}`, { method: 'DELETE' })
},
getMedia() {
return request<MediaItem[]>('/media')
},
uploadMedia(file: File) {
const form = new FormData()
form.append('file', file)
return request<MediaItem>('/media/upload', {
method: 'POST',
body: form,
})
},
}
export { ApiError }
+110
View File
@@ -0,0 +1,110 @@
<template>
<div class="flex min-h-screen bg-slate-50">
<!-- Sidebar -->
<aside class="fixed inset-y-0 right-0 z-30 flex w-64 flex-col border-l border-slate-200 bg-white shadow-sm">
<div class="flex items-center gap-3 border-b border-slate-100 px-5 py-5">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-brand text-lg font-bold text-white">
ن
</div>
<div>
<p class="text-sm font-bold text-slate-900">استودیو نورو</p>
<p class="text-xs text-slate-500">پنل مدیریت CMS</p>
</div>
</div>
<nav class="flex-1 space-y-1 overflow-y-auto p-3">
<RouterLink
v-for="item in navItems"
:key="item.name"
:to="{ name: item.name }"
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors"
:class="isActive(item.name)
? 'bg-brand/10 text-brand-700'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'"
>
<span class="text-lg" aria-hidden="true">{{ item.icon }}</span>
{{ item.label }}
</RouterLink>
</nav>
<div class="border-t border-slate-100 p-4">
<p class="truncate text-xs text-slate-500">{{ userEmail }}</p>
<button
type="button"
class="mt-2 w-full rounded-lg px-3 py-2 text-right text-sm text-red-600 transition-colors hover:bg-red-50"
@click="logout"
>
خروج از حساب
</button>
</div>
</aside>
<!-- Main -->
<div class="mr-64 flex min-h-screen flex-1 flex-col">
<header class="sticky top-0 z-20 border-b border-slate-200 bg-white/90 px-8 py-4 backdrop-blur">
<h1 class="text-lg font-semibold text-slate-800">{{ pageTitle }}</h1>
</header>
<main class="flex-1 p-8">
<RouterView />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, clearToken } from '@/api/client'
const route = useRoute()
const router = useRouter()
const userEmail = ref('')
const navItems = [
{ name: 'dashboard', label: 'داشبورد', icon: '📊' },
{ name: 'site', label: 'تنظیمات سایت', icon: '⚙️' },
{ name: 'portfolio', label: 'نمونه‌کارها', icon: '🎨' },
{ name: 'services', label: 'خدمات', icon: '💼' },
{ name: 'clients', label: 'مشتریان', icon: '🤝' },
{ name: 'blog', label: 'وبلاگ', icon: '📝' },
{ name: 'media', label: 'رسانه', icon: '🖼️' },
]
const titles: Record<string, string> = {
dashboard: 'داشبورد',
site: 'تنظیمات سایت',
portfolio: 'نمونه‌کارها',
'portfolio-edit': 'ویرایش نمونه‌کار',
services: 'خدمات',
'service-edit': 'ویرایش خدمت',
clients: 'مشتریان',
blog: 'وبلاگ',
'blog-edit': 'ویرایش مطلب',
media: 'کتابخانه رسانه',
}
const pageTitle = computed(() => titles[String(route.name)] || 'پنل مدیریت')
function isActive(name: string) {
if (route.name === name) return true
if (name === 'portfolio' && route.name === 'portfolio-edit') return true
if (name === 'services' && route.name === 'service-edit') return true
if (name === 'blog' && route.name === 'blog-edit') return true
return false
}
function logout() {
clearToken()
router.push({ name: 'login' })
}
onMounted(async () => {
try {
const { user } = await api.me()
userEmail.value = user.email
} catch {
clearToken()
router.push({ name: 'login' })
}
})
</script>
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './style.css'
createApp(App).use(router).mount('#app')
+86
View File
@@ -0,0 +1,86 @@
import { createRouter, createWebHistory } from 'vue-router'
import { getToken } from '@/api/client'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { public: true },
},
{
path: '/',
component: () => import('@/layouts/AdminLayout.vue'),
children: [
{ path: '', redirect: { name: 'dashboard' } },
{
path: 'dashboard',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
},
{
path: 'site',
name: 'site',
component: () => import('@/views/SiteSettingsView.vue'),
},
{
path: 'portfolio',
name: 'portfolio',
component: () => import('@/views/PortfolioListView.vue'),
},
{
path: 'portfolio-edit/:id?',
name: 'portfolio-edit',
component: () => import('@/views/PortfolioEditView.vue'),
},
{
path: 'services',
name: 'services',
component: () => import('@/views/ServicesListView.vue'),
},
{
path: 'service-edit/:id',
name: 'service-edit',
component: () => import('@/views/ServiceEditView.vue'),
},
{
path: 'clients',
name: 'clients',
component: () => import('@/views/ClientsView.vue'),
},
{
path: 'blog',
name: 'blog',
component: () => import('@/views/BlogListView.vue'),
},
{
path: 'blog-edit/:id?',
name: 'blog-edit',
component: () => import('@/views/BlogEditView.vue'),
},
{
path: 'media',
name: 'media',
component: () => import('@/views/MediaView.vue'),
},
],
},
],
})
router.beforeEach((to) => {
const isPublic = to.meta.public === true
const token = getToken()
if (!isPublic && !token) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.name === 'login' && token) {
return { name: 'dashboard' }
}
})
export default router
+43
View File
@@ -0,0 +1,43 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
font-family: 'Vazirmatn', Tahoma, system-ui, sans-serif;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-brand/40 disabled:cursor-not-allowed disabled:opacity-60;
}
.btn-primary {
@apply btn bg-brand text-white hover:bg-brand-500;
}
.btn-secondary {
@apply btn border border-slate-200 bg-white text-slate-700 hover:bg-slate-50;
}
.btn-danger {
@apply btn bg-red-500 text-white hover:bg-red-600;
}
.input {
@apply w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:border-brand focus:outline-none focus:ring-2 focus:ring-brand/20;
}
.label {
@apply mb-1 block text-sm font-medium text-slate-700;
}
.card {
@apply rounded-xl border border-slate-200 bg-white p-6 shadow-sm;
}
.page-title {
@apply text-2xl font-bold text-slate-900;
}
}
+142
View File
@@ -0,0 +1,142 @@
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="page-title">{{ isNew ? 'مطلب جدید' : 'ویرایش مطلب' }}</h2>
<RouterLink :to="{ name: 'blog' }" class="btn-secondary">بازگشت</RouterLink>
</div>
<p v-if="message" class="mb-4 rounded-lg bg-green-50 px-4 py-2 text-sm text-green-700">{{ message }}</p>
<p v-if="error" class="mb-4 rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600">{{ error }}</p>
<div v-if="loading" class="text-slate-500">در حال بارگذاری...</div>
<form v-else class="space-y-6" @submit.prevent="save">
<section class="card space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label class="label">عنوان</label>
<input v-model="form.title" class="input" required @input="maybeSlug" />
</div>
<div>
<label class="label">اسلاگ</label>
<input v-model="form.slug" class="input" dir="ltr" required />
</div>
<div class="flex items-center gap-2 pt-6">
<input
id="published"
v-model="form.published"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-brand focus:ring-brand"
/>
<label for="published" class="text-sm text-slate-700">منتشر شده</label>
</div>
<div class="sm:col-span-2">
<label class="label">خلاصه</label>
<textarea v-model="form.excerpt" class="input min-h-[80px]" rows="3" />
</div>
<div class="sm:col-span-2">
<label class="label">محتوا</label>
<textarea v-model="form.content" class="input min-h-[200px] font-mono text-sm" rows="12" />
</div>
<div class="sm:col-span-2">
<label class="label">آدرس تصویر کاور</label>
<input v-model="form.coverUrl" class="input" dir="ltr" placeholder="/uploads/..." />
</div>
</div>
</section>
<section class="card space-y-4">
<h3 class="font-semibold text-slate-800">سئو</h3>
<div>
<label class="label">عنوان سئو (seoTitle)</label>
<input v-model="form.seoTitle" class="input" />
</div>
<div>
<label class="label">توضیحات سئو (seoDescription)</label>
<textarea v-model="form.seoDescription" class="input min-h-[80px]" rows="3" />
</div>
</section>
<button type="submit" class="btn-primary" :disabled="saving">
{{ saving ? 'در حال ذخیره...' : 'ذخیره' }}
</button>
</form>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, type BlogPost } from '@/api/client'
const route = useRoute()
const router = useRouter()
const id = computed(() => route.params.id as string | undefined)
const isNew = computed(() => !id.value)
const slugTouched = ref(false)
const loading = ref(true)
const saving = ref(false)
const message = ref('')
const error = ref('')
const form = reactive<Partial<BlogPost>>({
title: '',
slug: '',
excerpt: '',
content: '',
coverUrl: '',
published: false,
seoTitle: '',
seoDescription: '',
})
function slugify(text: string) {
return text
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\w\u0600-\u06FF-]+/g, '')
.replace(/-+/g, '-')
}
function maybeSlug() {
if (!slugTouched.value && isNew.value) {
form.slug = slugify(form.title || '')
}
}
onMounted(async () => {
try {
if (id.value) {
const post = await api.getBlogPost(id.value)
Object.assign(form, post)
slugTouched.value = true
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
} finally {
loading.value = false
}
})
async function save() {
saving.value = true
message.value = ''
error.value = ''
try {
if (isNew.value) {
const created = await api.createBlogPost({ ...form })
router.replace({ name: 'blog-edit', params: { id: created.id } })
} else {
await api.updateBlogPost(id.value!, { ...form })
}
message.value = 'مطلب ذخیره شد.'
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در ذخیره'
} finally {
saving.value = false
}
}
</script>
+84
View File
@@ -0,0 +1,84 @@
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="page-title">وبلاگ</h2>
<RouterLink :to="{ name: 'blog-edit' }" class="btn-primary">مطلب جدید</RouterLink>
</div>
<p v-if="error" class="mb-4 rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600">{{ error }}</p>
<div v-if="loading" class="text-slate-500">در حال بارگذاری...</div>
<div v-else-if="posts.length === 0" class="card text-center text-slate-500">
هنوز مطلبی منتشر نشده است.
</div>
<div v-else class="card overflow-hidden p-0">
<table class="w-full text-sm">
<thead class="border-b border-slate-100 bg-slate-50 text-right">
<tr>
<th class="px-4 py-3 font-medium text-slate-600">عنوان</th>
<th class="px-4 py-3 font-medium text-slate-600">اسلاگ</th>
<th class="px-4 py-3 font-medium text-slate-600">وضعیت</th>
<th class="px-4 py-3 font-medium text-slate-600">عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="post in posts" :key="post.id" class="border-b border-slate-50 hover:bg-slate-50/50">
<td class="px-4 py-3 font-medium">{{ post.title }}</td>
<td class="px-4 py-3 text-slate-600" dir="ltr">{{ post.slug }}</td>
<td class="px-4 py-3">
<span
class="rounded-full px-2 py-0.5 text-xs"
:class="post.published ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-600'"
>
{{ post.published ? 'منتشر شده' : 'پیش‌نویس' }}
</span>
</td>
<td class="px-4 py-3">
<div class="flex gap-2">
<RouterLink :to="{ name: 'blog-edit', params: { id: post.id } }" class="text-brand hover:underline">
ویرایش
</RouterLink>
<button type="button" class="text-red-500 hover:underline" @click="remove(post.id)">حذف</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, type BlogPost } from '@/api/client'
const loading = ref(true)
const error = ref('')
const posts = ref<BlogPost[]>([])
async function load() {
loading.value = true
error.value = ''
try {
posts.value = await api.getBlogPosts()
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
} finally {
loading.value = false
}
}
async function remove(id: string) {
if (!confirm('آیا از حذف این مطلب مطمئن هستید؟')) return
try {
await api.deleteBlogPost(id)
posts.value = posts.value.filter((p) => p.id !== id)
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در حذف'
}
}
onMounted(load)
</script>
+89
View File
@@ -0,0 +1,89 @@
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="page-title">مشتریان</h2>
<div class="flex gap-2">
<button type="button" class="btn-secondary" @click="addClient">افزودن مشتری</button>
<button type="button" class="btn-primary" :disabled="saving" @click="save">
{{ saving ? 'در حال ذخیره...' : 'ذخیره' }}
</button>
</div>
</div>
<p v-if="message" class="mb-4 rounded-lg bg-green-50 px-4 py-2 text-sm text-green-700">{{ message }}</p>
<p v-if="error" class="mb-4 rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600">{{ error }}</p>
<div v-if="loading" class="text-slate-500">در حال بارگذاری...</div>
<div v-else class="space-y-4">
<div v-for="(client, i) in clients" :key="client.id" class="card">
<div class="grid gap-4 sm:grid-cols-3">
<div>
<label class="label">شناسه</label>
<input v-model="client.id" class="input" dir="ltr" />
</div>
<div>
<label class="label">نام</label>
<input v-model="client.name" class="input" />
</div>
<div>
<label class="label">آدرس لوگو</label>
<input v-model="client.logoUrl" class="input" dir="ltr" />
</div>
</div>
<div class="mt-3 flex items-center gap-4">
<img
v-if="client.logoUrl"
:src="client.logoUrl"
:alt="client.name"
class="h-10 w-auto object-contain"
/>
<button type="button" class="text-sm text-red-500" @click="clients.splice(i, 1)">حذف</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, type Client } from '@/api/client'
const loading = ref(true)
const saving = ref(false)
const message = ref('')
const error = ref('')
const clients = ref<Client[]>([])
onMounted(async () => {
try {
clients.value = await api.getClients()
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
} finally {
loading.value = false
}
})
function addClient() {
clients.value.push({
id: `client-${Date.now()}`,
name: '',
logoUrl: '',
})
}
async function save() {
saving.value = true
message.value = ''
error.value = ''
try {
clients.value = await api.putClients(clients.value)
message.value = 'لیست مشتریان ذخیره شد.'
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در ذخیره'
} finally {
saving.value = false
}
}
</script>
+71
View File
@@ -0,0 +1,71 @@
<template>
<div>
<p class="mb-6 text-slate-600">به پنل مدیریت استودیو نورو خوش آمدید.</p>
<div v-if="loading" class="text-slate-500">در حال بارگذاری...</div>
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div v-for="stat in stats" :key="stat.label" class="card">
<p class="text-3xl font-bold text-brand">{{ stat.value }}</p>
<p class="mt-1 text-sm text-slate-600">{{ stat.label }}</p>
</div>
</div>
<div class="mt-8 grid gap-4 lg:grid-cols-2">
<div class="card">
<h2 class="mb-4 font-semibold text-slate-800">دسترسی سریع</h2>
<div class="flex flex-wrap gap-2">
<RouterLink :to="{ name: 'site' }" class="btn-secondary">تنظیمات سایت</RouterLink>
<RouterLink :to="{ name: 'portfolio-edit' }" class="btn-secondary">نمونه‌کار جدید</RouterLink>
<RouterLink :to="{ name: 'blog-edit' }" class="btn-secondary">مطلب جدید</RouterLink>
<RouterLink :to="{ name: 'media' }" class="btn-secondary">آپلود رسانه</RouterLink>
</div>
</div>
<div class="card">
<h2 class="mb-2 font-semibold text-slate-800">اطلاعات سایت</h2>
<p class="text-sm text-slate-600">{{ siteName }}</p>
<p class="mt-1 text-sm text-slate-500">{{ siteTagline }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api } from '@/api/client'
const loading = ref(true)
const siteName = ref('')
const siteTagline = ref('')
const stats = ref([
{ value: '—', label: 'نمونه‌کار' },
{ value: '—', label: 'خدمات' },
{ value: '—', label: 'مشتری' },
{ value: '—', label: 'مطلب وبلاگ' },
])
onMounted(async () => {
try {
const [site, projects, services, clients, posts] = await Promise.all([
api.getSite(),
api.getProjects(),
api.getServices(),
api.getClients(),
api.getBlogPosts(),
])
siteName.value = site.name || 'استودیو نورو'
siteTagline.value = site.tagline || ''
stats.value = [
{ value: String(projects.length), label: 'نمونه‌کار' },
{ value: String(services.length), label: 'خدمات' },
{ value: String(clients.length), label: 'مشتری' },
{ value: String(posts.length), label: 'مطلب وبلاگ' },
]
} catch {
// stats stay as dashes
} finally {
loading.value = false
}
})
</script>
+76
View File
@@ -0,0 +1,76 @@
<template>
<div class="flex min-h-screen items-center justify-center bg-gradient-to-bl from-brand-50 via-white to-slate-100 p-4">
<div class="w-full max-w-md">
<div class="mb-8 text-center">
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-brand text-2xl font-bold text-white shadow-lg shadow-brand/30">
ن
</div>
<h1 class="text-2xl font-bold text-slate-900">ورود به پنل مدیریت</h1>
<p class="mt-1 text-sm text-slate-500">استودیو نورو CMS</p>
</div>
<form class="card space-y-4" @submit.prevent="submit">
<div>
<label class="label" for="email">ایمیل</label>
<input
id="email"
v-model="email"
type="email"
class="input"
placeholder="admin@norostudio.ir"
required
autocomplete="username"
/>
</div>
<div>
<label class="label" for="password">رمز عبور</label>
<input
id="password"
v-model="password"
type="password"
class="input"
placeholder="••••••••"
required
autocomplete="current-password"
/>
</div>
<p v-if="error" class="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{{ error }}</p>
<button type="submit" class="btn-primary w-full" :disabled="loading">
{{ loading ? 'در حال ورود...' : 'ورود' }}
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, setToken } from '@/api/client'
const route = useRoute()
const router = useRouter()
const email = ref('admin@norostudio.ir')
const password = ref('admin123')
const loading = ref(false)
const error = ref('')
async function submit() {
loading.value = true
error.value = ''
try {
const { token } = await api.login(email.value, password.value)
setToken(token)
const redirect = (route.query.redirect as string) || '/dashboard'
router.push(redirect)
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در ورود'
} finally {
loading.value = false
}
}
</script>
+105
View File
@@ -0,0 +1,105 @@
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="page-title">کتابخانه رسانه</h2>
<label class="btn-primary cursor-pointer">
{{ uploading ? 'در حال آپلود...' : 'آپلود فایل' }}
<input type="file" class="hidden" accept="image/*,video/*,.svg,.pdf" :disabled="uploading" @change="onUpload" />
</label>
</div>
<p v-if="message" class="mb-4 rounded-lg bg-green-50 px-4 py-2 text-sm text-green-700">{{ message }}</p>
<p v-if="error" class="mb-4 rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600">{{ error }}</p>
<div v-if="loading" class="text-slate-500">در حال بارگذاری...</div>
<div v-else-if="items.length === 0" class="card text-center text-slate-500">
هنوز فایلی آپلود نشده است.
</div>
<div v-else class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<div v-for="item in items" :key="item.id" class="card overflow-hidden p-0">
<div class="flex aspect-square items-center justify-center bg-slate-100">
<img
v-if="isImage(item)"
:src="item.url"
:alt="item.filename"
class="h-full w-full object-cover"
/>
<span v-else class="text-4xl text-slate-400">📄</span>
</div>
<div class="p-3">
<p class="truncate text-sm font-medium text-slate-800">{{ item.filename }}</p>
<p class="mt-1 truncate text-xs text-slate-500" dir="ltr">{{ item.url }}</p>
<button
type="button"
class="mt-2 text-xs text-brand hover:underline"
@click="copyUrl(item.url)"
>
کپی آدرس
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, type MediaItem } from '@/api/client'
const loading = ref(true)
const uploading = ref(false)
const message = ref('')
const error = ref('')
const items = ref<MediaItem[]>([])
function isImage(item: MediaItem) {
const mime = item.mimeType || ''
const name = item.filename?.toLowerCase() || ''
return mime.startsWith('image/') || /\.(png|jpe?g|gif|webp|svg)$/.test(name)
}
async function load() {
loading.value = true
error.value = ''
try {
items.value = await api.getMedia()
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
} finally {
loading.value = false
}
}
async function onUpload(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
uploading.value = true
message.value = ''
error.value = ''
try {
const uploaded = await api.uploadMedia(file)
items.value.unshift(uploaded)
message.value = 'فایل با موفقیت آپلود شد.'
} catch (err) {
error.value = err instanceof Error ? err.message : 'خطا در آپلود'
} finally {
uploading.value = false
input.value = ''
}
}
async function copyUrl(url: string) {
try {
await navigator.clipboard.writeText(url)
message.value = 'آدرس کپی شد.'
} catch {
message.value = url
}
}
onMounted(load)
</script>
+147
View File
@@ -0,0 +1,147 @@
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="page-title">{{ isNew ? 'نمونه‌کار جدید' : 'ویرایش نمونه‌کار' }}</h2>
<RouterLink :to="{ name: 'portfolio' }" class="btn-secondary">بازگشت</RouterLink>
</div>
<p v-if="message" class="mb-4 rounded-lg bg-green-50 px-4 py-2 text-sm text-green-700">{{ message }}</p>
<p v-if="error" class="mb-4 rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600">{{ error }}</p>
<div v-if="loading" class="text-slate-500">در حال بارگذاری...</div>
<form v-else class="space-y-6" @submit.prevent="save">
<section class="card space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label class="label">عنوان</label>
<input v-model="form.title" class="input" required />
</div>
<div>
<label class="label">دسته‌بندی</label>
<select v-model="form.category" class="input" required>
<option value="">انتخاب کنید</option>
<option v-for="c in categories" :key="c.slug" :value="c.slug">{{ c.label }}</option>
</select>
</div>
<div>
<label class="label">سال</label>
<input v-model="form.year" class="input" />
</div>
<div>
<label class="label">مشتری</label>
<input v-model="form.client" class="input" />
</div>
<div class="flex items-center gap-2 pt-6">
<input id="featured" v-model="form.featured" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-brand focus:ring-brand" />
<label for="featured" class="text-sm text-slate-700">نمونه‌کار ویژه</label>
</div>
<div class="sm:col-span-2">
<label class="label">توضیح کوتاه</label>
<textarea v-model="form.description" class="input min-h-[80px]" rows="3" required />
</div>
<div class="sm:col-span-2">
<label class="label">توضیح کامل</label>
<textarea v-model="form.longDescription" class="input min-h-[120px]" rows="5" />
</div>
<div class="sm:col-span-2">
<label class="label">آدرس تصویر کاور</label>
<input v-model="form.imageUrl" class="input" dir="ltr" placeholder="/uploads/..." />
</div>
<div class="sm:col-span-2">
<label class="label">تحویلی‌ها (هر خط یک مورد)</label>
<textarea v-model="deliverablesText" class="input min-h-[80px]" rows="3" />
</div>
</div>
</section>
<section class="card space-y-4">
<h3 class="font-semibold text-slate-800">سئو</h3>
<div>
<label class="label">عنوان سئو</label>
<input v-model="form.seoTitle" class="input" />
</div>
<div>
<label class="label">توضیحات سئو</label>
<textarea v-model="form.seoDescription" class="input min-h-[80px]" rows="3" />
</div>
</section>
<button type="submit" class="btn-primary" :disabled="saving">
{{ saving ? 'در حال ذخیره...' : 'ذخیره' }}
</button>
</form>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, type PortfolioCategory, type Project } from '@/api/client'
const route = useRoute()
const router = useRouter()
const id = computed(() => route.params.id as string | undefined)
const isNew = computed(() => !id.value)
const loading = ref(true)
const saving = ref(false)
const message = ref('')
const error = ref('')
const categories = ref<PortfolioCategory[]>([])
const form = reactive<Partial<Project>>({
title: '',
category: '',
description: '',
longDescription: '',
year: '',
client: '',
featured: false,
imageUrl: '',
seoTitle: '',
seoDescription: '',
deliverables: [],
})
const deliverablesText = computed({
get: () => (form.deliverables || []).join('\n'),
set: (v: string) => {
form.deliverables = v.split('\n').map((s) => s.trim()).filter(Boolean)
},
})
onMounted(async () => {
try {
categories.value = await api.getPortfolioCategories()
if (id.value) {
const project = await api.getProject(id.value)
Object.assign(form, project)
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
} finally {
loading.value = false
}
})
async function save() {
saving.value = true
message.value = ''
error.value = ''
try {
if (isNew.value) {
const created = await api.createProject({ ...form })
router.replace({ name: 'portfolio-edit', params: { id: created.id } })
} else {
await api.updateProject(id.value!, { ...form })
}
message.value = 'نمونه‌کار ذخیره شد.'
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در ذخیره'
} finally {
saving.value = false
}
}
</script>
+89
View File
@@ -0,0 +1,89 @@
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="page-title">نمونه‌کارها</h2>
<RouterLink :to="{ name: 'portfolio-edit' }" class="btn-primary">نمونه‌کار جدید</RouterLink>
</div>
<p v-if="error" class="mb-4 rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600">{{ error }}</p>
<div v-if="loading" class="text-slate-500">در حال بارگذاری...</div>
<div v-else-if="projects.length === 0" class="card text-center text-slate-500">
هنوز نمونه‌کاری ثبت نشده است.
</div>
<div v-else class="card overflow-hidden p-0">
<table class="w-full text-sm">
<thead class="border-b border-slate-100 bg-slate-50 text-right">
<tr>
<th class="px-4 py-3 font-medium text-slate-600">عنوان</th>
<th class="px-4 py-3 font-medium text-slate-600">دسته</th>
<th class="px-4 py-3 font-medium text-slate-600">سال</th>
<th class="px-4 py-3 font-medium text-slate-600">ویژه</th>
<th class="px-4 py-3 font-medium text-slate-600">عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="p in projects" :key="p.id" class="border-b border-slate-50 hover:bg-slate-50/50">
<td class="px-4 py-3 font-medium">{{ p.title }}</td>
<td class="px-4 py-3 text-slate-600">{{ categoryLabel(p.category) }}</td>
<td class="px-4 py-3 text-slate-600">{{ p.year || '—' }}</td>
<td class="px-4 py-3">
<span v-if="p.featured" class="rounded-full bg-brand/10 px-2 py-0.5 text-xs text-brand-700">بله</span>
<span v-else class="text-slate-400">—</span>
</td>
<td class="px-4 py-3">
<div class="flex gap-2">
<RouterLink :to="{ name: 'portfolio-edit', params: { id: p.id } }" class="text-brand hover:underline">
ویرایش
</RouterLink>
<button type="button" class="text-red-500 hover:underline" @click="remove(p.id)">حذف</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, type PortfolioCategory, type Project } from '@/api/client'
const loading = ref(true)
const error = ref('')
const projects = ref<Project[]>([])
const categories = ref<PortfolioCategory[]>([])
function categoryLabel(slug: string) {
return categories.value.find((c) => c.slug === slug)?.label || slug
}
async function load() {
loading.value = true
error.value = ''
try {
const [p, c] = await Promise.all([api.getProjects(), api.getPortfolioCategories()])
projects.value = p
categories.value = c
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
} finally {
loading.value = false
}
}
async function remove(id: string) {
if (!confirm('آیا از حذف این نمونه‌کار مطمئن هستید؟')) return
try {
await api.deleteProject(id)
projects.value = projects.value.filter((p) => p.id !== id)
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در حذف'
}
}
onMounted(load)
</script>
+148
View File
@@ -0,0 +1,148 @@
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="page-title">ویرایش خدمت</h2>
<RouterLink :to="{ name: 'services' }" class="btn-secondary">بازگشت</RouterLink>
</div>
<p v-if="message" class="mb-4 rounded-lg bg-green-50 px-4 py-2 text-sm text-green-700">{{ message }}</p>
<p v-if="error" class="mb-4 rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600">{{ error }}</p>
<div v-if="loading" class="text-slate-500">در حال بارگذاری...</div>
<form v-else class="space-y-6" @submit.prevent="save">
<section class="card space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label class="label">عنوان</label>
<input v-model="form.title" class="input" required />
</div>
<div class="sm:col-span-2">
<label class="label">زیرعنوان</label>
<input v-model="form.subtitle" class="input" />
</div>
<div class="sm:col-span-2">
<label class="label">توضیح کوتاه</label>
<textarea v-model="form.description" class="input min-h-[80px]" rows="3" />
</div>
<div class="sm:col-span-2">
<label class="label">مقدمه</label>
<textarea v-model="form.intro" class="input min-h-[100px]" rows="4" />
</div>
<div>
<label class="label">آدرس آیکون</label>
<input v-model="form.iconUrl" class="input" dir="ltr" />
</div>
<div>
<label class="label">آدرس پس‌زمینه</label>
<input v-model="form.backgroundUrl" class="input" dir="ltr" />
</div>
<div class="sm:col-span-2">
<label class="label">شامل می‌شود (هر خط یک مورد)</label>
<textarea v-model="includesText" class="input min-h-[100px]" rows="4" />
</div>
<div class="sm:col-span-2">
<label class="label">مزایا (هر خط یک مورد)</label>
<textarea v-model="benefitsText" class="input min-h-[100px]" rows="4" />
</div>
<div class="sm:col-span-2">
<label class="label">تحویلی‌ها (هر خط یک مورد)</label>
<textarea v-model="deliverablesText" class="input min-h-[80px]" rows="3" />
</div>
</div>
</section>
<section class="card space-y-4">
<div class="flex items-center justify-between">
<h3 class="font-semibold text-slate-800">سوالات متداول</h3>
<button type="button" class="btn-secondary text-xs" @click="form.faq.push({ question: '', answer: '' })">
افزودن
</button>
</div>
<div v-for="(item, i) in form.faq" :key="i" class="rounded-lg border border-slate-100 p-4 space-y-2">
<input v-model="item.question" class="input" placeholder="سوال" />
<textarea v-model="item.answer" class="input min-h-[60px]" rows="2" placeholder="پاسخ" />
<button type="button" class="text-sm text-red-500" @click="form.faq.splice(i, 1)">حذف</button>
</div>
</section>
<button type="submit" class="btn-primary" :disabled="saving">
{{ saving ? 'در حال ذخیره...' : 'ذخیره' }}
</button>
</form>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { api, type Service } from '@/api/client'
const route = useRoute()
const serviceId = route.params.id as string
const loading = ref(true)
const saving = ref(false)
const message = ref('')
const error = ref('')
const form = reactive<Service>({
id: serviceId,
title: '',
subtitle: '',
description: '',
intro: '',
includes: [],
benefits: [],
process: [],
deliverables: [],
faq: [],
iconUrl: '',
backgroundUrl: '',
})
function linesToArray(text: string) {
return text.split('\n').map((s) => s.trim()).filter(Boolean)
}
const includesText = computed({
get: () => form.includes.join('\n'),
set: (v) => { form.includes = linesToArray(v) },
})
const benefitsText = computed({
get: () => form.benefits.join('\n'),
set: (v) => { form.benefits = linesToArray(v) },
})
const deliverablesText = computed({
get: () => form.deliverables.join('\n'),
set: (v) => { form.deliverables = linesToArray(v) },
})
onMounted(async () => {
try {
const service = await api.getService(serviceId)
Object.assign(form, service)
if (!form.faq) form.faq = []
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
} finally {
loading.value = false
}
})
async function save() {
saving.value = true
message.value = ''
error.value = ''
try {
await api.updateService(serviceId, { ...form })
message.value = 'خدمت ذخیره شد.'
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در ذخیره'
} finally {
saving.value = false
}
}
</script>
+46
View File
@@ -0,0 +1,46 @@
<template>
<div>
<div class="mb-6">
<h2 class="page-title">خدمات</h2>
<p class="mt-1 text-sm text-slate-500">برای ویرایش جزئیات هر خدمت روی آن کلیک کنید.</p>
</div>
<p v-if="error" class="mb-4 rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600">{{ error }}</p>
<div v-if="loading" class="text-slate-500">در حال بارگذاری...</div>
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<RouterLink
v-for="s in services"
:key="s.id"
:to="{ name: 'service-edit', params: { id: s.id } }"
class="card group transition-shadow hover:shadow-md"
>
<div class="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-brand/10 text-xl">
{{ s.title.charAt(0) }}
</div>
<h3 class="font-semibold text-slate-800 group-hover:text-brand">{{ s.title }}</h3>
<p class="mt-1 line-clamp-2 text-sm text-slate-500">{{ s.subtitle }}</p>
</RouterLink>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, type Service } from '@/api/client'
const loading = ref(true)
const error = ref('')
const services = ref<Service[]>([])
onMounted(async () => {
try {
services.value = await api.getServices()
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
} finally {
loading.value = false
}
})
</script>
+151
View File
@@ -0,0 +1,151 @@
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="page-title">تنظیمات سایت</h2>
<button type="button" class="btn-primary" :disabled="saving" @click="save">
{{ saving ? 'در حال ذخیره...' : 'ذخیره تغییرات' }}
</button>
</div>
<p v-if="message" class="mb-4 rounded-lg bg-green-50 px-4 py-2 text-sm text-green-700">{{ message }}</p>
<p v-if="error" class="mb-4 rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600">{{ error }}</p>
<div v-if="loading" class="text-slate-500">در حال بارگذاری...</div>
<div v-else class="space-y-6">
<section class="card space-y-4">
<h3 class="font-semibold text-slate-800">اطلاعات عمومی</h3>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="label">نام فارسی</label>
<input v-model="site.name" class="input" />
</div>
<div>
<label class="label">نام انگلیسی</label>
<input v-model="site.nameEn" class="input" dir="ltr" />
</div>
<div class="sm:col-span-2">
<label class="label">شعار</label>
<input v-model="site.tagline" class="input" />
</div>
<div class="sm:col-span-2">
<label class="label">توضیحات</label>
<textarea v-model="site.description" class="input min-h-[100px]" rows="4" />
</div>
</div>
</section>
<section class="card space-y-4">
<h3 class="font-semibold text-slate-800">تماس</h3>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="label">ایمیل</label>
<input v-model="site.email" type="email" class="input" dir="ltr" />
</div>
<div>
<label class="label">وب‌سایت</label>
<input v-model="site.website" class="input" dir="ltr" />
</div>
<div>
<label class="label">تلفن</label>
<input v-model="site.phone" class="input" />
</div>
<div>
<label class="label">موقعیت</label>
<input v-model="site.location" class="input" />
</div>
</div>
</section>
<section class="card space-y-4">
<h3 class="font-semibold text-slate-800">سئو پیش‌فرض</h3>
<div class="grid gap-4">
<div>
<label class="label">عنوان پیش‌فرض</label>
<input v-model="seo.defaultTitle" class="input" />
</div>
<div>
<label class="label">توضیحات پیش‌فرض</label>
<textarea v-model="seo.defaultDescription" class="input min-h-[80px]" rows="3" />
</div>
</div>
</section>
<section class="card space-y-4">
<div class="flex items-center justify-between">
<h3 class="font-semibold text-slate-800">منوی ناوبری</h3>
<button type="button" class="btn-secondary text-xs" @click="addNavItem">افزودن آیتم</button>
</div>
<div v-for="(item, i) in navigation" :key="i" class="flex gap-2">
<input v-model="item.label" class="input flex-1" placeholder="برچسب" />
<input v-model="item.to" class="input flex-1" placeholder="/path" dir="ltr" />
<button type="button" class="btn-danger px-3" @click="navigation.splice(i, 1)">حذف</button>
</div>
<button type="button" class="btn-secondary" :disabled="savingNav" @click="saveNavigation">
{{ savingNav ? '...' : 'ذخیره منو' }}
</button>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { api, type NavItem, type SiteSettings } from '@/api/client'
const loading = ref(true)
const saving = ref(false)
const savingNav = ref(false)
const message = ref('')
const error = ref('')
const site = reactive<SiteSettings>({})
const seo = reactive({ defaultTitle: '', defaultDescription: '' })
const navigation = ref<NavItem[]>([])
onMounted(async () => {
try {
const [siteData, nav] = await Promise.all([api.getSite(), api.getNavigation()])
Object.assign(site, siteData)
seo.defaultTitle = siteData.seo?.defaultTitle || ''
seo.defaultDescription = siteData.seo?.defaultDescription || ''
navigation.value = [...nav]
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در بارگذاری'
} finally {
loading.value = false
}
})
function addNavItem() {
navigation.value.push({ label: '', to: '' })
}
async function save() {
saving.value = true
message.value = ''
error.value = ''
try {
site.seo = { defaultTitle: seo.defaultTitle, defaultDescription: seo.defaultDescription }
await api.putSite({ ...site })
message.value = 'تنظیمات سایت ذخیره شد.'
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در ذخیره'
} finally {
saving.value = false
}
}
async function saveNavigation() {
savingNav.value = true
error.value = ''
try {
await api.putNavigation(navigation.value)
message.value = 'منوی ناوبری ذخیره شد.'
} catch (e) {
error.value = e instanceof Error ? e.message : 'خطا در ذخیره منو'
} finally {
savingNav.value = false
}
}
</script>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}