|
|
@@ -260,6 +260,34 @@
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
+
|
|
|
+ <!-- POSTS -->
|
|
|
+ <div v-else-if="activeTab === 'posts'" class="grid gap-4">
|
|
|
+ <div v-for="p in posts" :key="p.id"
|
|
|
+ class="p-4 bg-card/40 border border-border/50 rounded-2xl flex items-center justify-between group hover:border-primary/30 transition-all">
|
|
|
+ <div class="flex items-center gap-4 min-w-0">
|
|
|
+ <div class="w-16 h-16 rounded-xl bg-muted/20 overflow-hidden flex-shrink-0">
|
|
|
+ <img v-if="p.image_url" :src="p.image_url" class="w-full h-full object-cover" />
|
|
|
+ <Newspaper v-else class="w-full h-full p-4 text-muted-foreground/30" />
|
|
|
+ </div>
|
|
|
+ <div class="min-w-0">
|
|
|
+ <h4 class="font-bold truncate">{{ p.title_en }}</h4>
|
|
|
+ <div class="flex items-center gap-3 text-[10px] text-muted-foreground uppercase font-bold">
|
|
|
+ <span>{{ p.category }}</span>
|
|
|
+ <span>•</span>
|
|
|
+ <span>{{ p.slug }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <button @click="editingPost = { ...p }" class="p-2 hover:bg-white/5 rounded-lg text-muted-foreground hover:text-primary transition-colors"><Edit2 class="w-4 h-4" /></button>
|
|
|
+ <button @click="togglePostActive(p)" :class="`p-2 rounded-lg transition-colors ${p.is_published ? 'text-emerald-500 hover:bg-emerald-500/10' : 'text-rose-500 hover:bg-rose-500/10'}`">
|
|
|
+ <Eye v-if="p.is_published" class="w-5 h-5" /><EyeOff v-else class="w-5 h-5" />
|
|
|
+ </button>
|
|
|
+ <button @click="handleDeletePost(p.id, p.title_en)" class="p-2 hover:bg-rose-500/10 rounded-lg text-muted-foreground hover:text-rose-500 transition-colors"><Trash2 class="w-4 h-4" /></button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
</main>
|
|
|
|
|
|
<!-- ——— MODALS ——— -->
|
|
|
@@ -348,6 +376,68 @@
|
|
|
</div>
|
|
|
</div>
|
|
|
</Transition>
|
|
|
+
|
|
|
+ <!-- Post Modal -->
|
|
|
+ <Transition enter-active-class="transition duration-150" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="transition duration-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
|
|
|
+ <div v-if="editingPost || (showAddModal && activeTab === 'posts')" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
|
|
+ <div class="absolute inset-0 bg-background/80 backdrop-blur-sm" @click="closeModals" />
|
|
|
+ <div class="relative w-full max-w-4xl bg-card border border-border/50 rounded-3xl p-8 shadow-2xl max-h-[90vh] overflow-y-auto">
|
|
|
+ <h3 class="text-xl font-bold font-display mb-6">{{ editingPost?.id ? "Edit Blog Post" : "Create New Post" }}</h3>
|
|
|
+ <form @submit.prevent="handleSavePost" class="space-y-6">
|
|
|
+
|
|
|
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
|
+ <div class="space-y-1">
|
|
|
+ <label class="text-[10px] font-bold uppercase ml-1">Slug (URL)</label>
|
|
|
+ <input v-model="postForm.slug" required placeholder="my-new-post" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" />
|
|
|
+ </div>
|
|
|
+ <div class="space-y-1">
|
|
|
+ <label class="text-[10px] font-bold uppercase ml-1">Category</label>
|
|
|
+ <input v-model="postForm.category" required placeholder="Technology" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="space-y-1">
|
|
|
+ <label class="text-[10px] font-bold uppercase ml-1">Image URL</label>
|
|
|
+ <input v-model="postForm.image_url" placeholder="https://ex.com/img.jpg" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Titles -->
|
|
|
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Title (EN)</label><input v-model="postForm.title_en" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Title (ME)</label><input v-model="postForm.title_me" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Title (RU)</label><input v-model="postForm.title_ru" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Title (UA)</label><input v-model="postForm.title_ua" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3" /></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Excerpts -->
|
|
|
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Excerpt (EN)</label><textarea v-model="postForm.excerpt_en" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[80px]" /></div>
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Excerpt (ME)</label><textarea v-model="postForm.excerpt_me" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[80px]" /></div>
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Excerpt (RU)</label><textarea v-model="postForm.excerpt_ru" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[80px]" /></div>
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Excerpt (UA)</label><textarea v-model="postForm.excerpt_ua" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[80px]" /></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Content -->
|
|
|
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Content (EN)</label><textarea v-model="postForm.content_en" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[200px]" /></div>
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Content (ME)</label><textarea v-model="postForm.content_me" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[200px]" /></div>
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Content (RU)</label><textarea v-model="postForm.content_ru" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[200px]" /></div>
|
|
|
+ <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Content (UA)</label><textarea v-model="postForm.content_ua" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[200px]" /></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <input v-model="postForm.is_published" type="checkbox" id="post_published" class="w-5 h-5 rounded border-border" />
|
|
|
+ <label for="post_published" class="text-sm font-bold">Publish immediately</label>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="flex gap-3 pt-4">
|
|
|
+ <Button type="button" variant="ghost" class="flex-1" @click="closeModals">Cancel</Button>
|
|
|
+ <Button type="submit" variant="hero" class="flex-1">Save Post</Button>
|
|
|
+ </div>
|
|
|
+ </form>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Transition>
|
|
|
</Teleport>
|
|
|
|
|
|
<Footer />
|
|
|
@@ -359,13 +449,17 @@ import { ref, computed, watch, reactive, onMounted } from "vue";
|
|
|
import { RouterLink, useRouter } from "vue-router";
|
|
|
import { useI18n } from "vue-i18n";
|
|
|
import { toast } from "vue-sonner";
|
|
|
-import { Package, Clock, CheckCircle2, Truck, XCircle, AlertCircle, FileText, ExternalLink, ShieldCheck, Eye, RefreshCw, Search, Filter, Layers, Settings, Plus, Edit2, Trash2, ToggleLeft, ToggleRight, Database, Image as ImageIcon, EyeOff, Hash, MessageCircle, FileBox, Download } from "lucide-vue-next";
|
|
|
+import { Package, Clock, CheckCircle2, Truck, XCircle, AlertCircle, FileText, ExternalLink, ShieldCheck, Eye, RefreshCw, Search, Filter, Layers, Settings, Plus, Edit2, Trash2, ToggleLeft, ToggleRight, Database, Image as ImageIcon, EyeOff, Hash, MessageCircle, FileBox, Download, Newspaper } from "lucide-vue-next";
|
|
|
import Button from "@/components/ui/button.vue";
|
|
|
import Header from "@/components/Header.vue";
|
|
|
import Footer from "@/components/Footer.vue";
|
|
|
import OrderChat from "@/components/OrderChat.vue";
|
|
|
import { useAuthStore } from "@/stores/auth";
|
|
|
-import { adminGetOrders, adminUpdateOrder, adminGetMaterials, adminCreateMaterial, adminUpdateMaterial, adminDeleteMaterial, adminGetServices, adminCreateService, adminUpdateService, adminDeleteService, adminUploadOrderPhoto, adminUpdatePhotoStatus, adminAttachFile } from "@/lib/api";
|
|
|
+import {
|
|
|
+ adminGetOrders, adminUpdateOrder, adminGetMaterials, adminCreateMaterial, adminUpdateMaterial, adminDeleteMaterial,
|
|
|
+ adminGetServices, adminCreateService, adminUpdateService, adminDeleteService, adminUploadOrderPhoto,
|
|
|
+ adminUpdatePhotoStatus, adminAttachFile, getBlogPosts, adminCreatePost, adminUpdatePost, adminDeletePost
|
|
|
+} from "@/lib/api";
|
|
|
|
|
|
const { t } = useI18n();
|
|
|
const router = useRouter();
|
|
|
@@ -391,24 +485,34 @@ const tabs: { id: Tab; label: string; icon: any }[] = [
|
|
|
{ id: "orders", label: "Orders", icon: Package },
|
|
|
{ id: "materials", label: "Materials", icon: Layers },
|
|
|
{ id: "services", label: "Services", icon: Database },
|
|
|
+ { id: "posts", label: "Blog", icon: Newspaper },
|
|
|
];
|
|
|
|
|
|
-type Tab = "orders" | "materials" | "services";
|
|
|
+type Tab = "orders" | "materials" | "services" | "posts";
|
|
|
const activeTab = ref<Tab>("orders");
|
|
|
const orders = ref<any[]>([]);
|
|
|
const materials = ref<any[]>([]);
|
|
|
const services = ref<any[]>([]);
|
|
|
+const posts = ref<any[]>([]);
|
|
|
const isLoading = ref(true);
|
|
|
const searchQuery = ref("");
|
|
|
const statusFilter = ref("all");
|
|
|
const editingPrice = ref<{ id: number; price: string } | null>(null);
|
|
|
const editingMaterial = ref<any | null>(null);
|
|
|
const editingService = ref<any | null>(null);
|
|
|
+const editingPost = ref<any | null>(null);
|
|
|
const showAddModal = ref(false);
|
|
|
const notifyStatusMap = ref<Record<number, boolean>>({});
|
|
|
|
|
|
const matForm = reactive({ name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", price_per_cm3: 0, is_active: true });
|
|
|
const svcForm = reactive({ name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", tech_type: "", is_active: true });
|
|
|
+const postForm = reactive({
|
|
|
+ slug: "",
|
|
|
+ title_en: "", title_me: "", title_ru: "", title_ua: "",
|
|
|
+ excerpt_en: "", excerpt_me: "", excerpt_ru: "", excerpt_ua: "",
|
|
|
+ content_en: "", content_me: "", content_ru: "", content_ua: "",
|
|
|
+ category: "Technology", image_url: "", is_published: true
|
|
|
+});
|
|
|
|
|
|
const filteredOrders = computed(() => orders.value.filter(o => {
|
|
|
const qs = searchQuery.value.toLowerCase();
|
|
|
@@ -426,6 +530,7 @@ async function fetchData() {
|
|
|
}
|
|
|
else if (activeTab.value === "materials") materials.value = await adminGetMaterials();
|
|
|
else if (activeTab.value === "services") services.value = await adminGetServices();
|
|
|
+ else if (activeTab.value === "posts") posts.value = await getBlogPosts(false);
|
|
|
} catch { toast.error(`Failed to load ${activeTab.value}`); }
|
|
|
finally { isLoading.value = false; }
|
|
|
}
|
|
|
@@ -476,8 +581,14 @@ async function handleDeleteService(id: number, name: string) {
|
|
|
try { await adminDeleteService(id); toast.success("Deleted"); fetchData(); }
|
|
|
catch { toast.error("Failed to delete"); }
|
|
|
}
|
|
|
+async function handleDeletePost(id: number, title: string) {
|
|
|
+ if (!window.confirm(`Delete post "${title}"?`)) return;
|
|
|
+ try { await adminDeletePost(id); toast.success("Post deleted"); fetchData(); }
|
|
|
+ catch { toast.error("Failed to delete post"); }
|
|
|
+}
|
|
|
async function toggleMaterialActive(m: any) { await adminUpdateMaterial(m.id, { is_active: !m.is_active }); fetchData(); }
|
|
|
async function toggleServiceActive(s: any) { await adminUpdateService(s.id, { is_active: !s.is_active }); fetchData(); }
|
|
|
+async function togglePostActive(p: any) { await adminUpdatePost(p.id, { ...p, is_published: !p.is_published }); fetchData(); }
|
|
|
|
|
|
function handleAddNew() {
|
|
|
if (activeTab.value === 'materials') {
|
|
|
@@ -486,19 +597,13 @@ function handleAddNew() {
|
|
|
} else if (activeTab.value === 'services') {
|
|
|
Object.assign(svcForm, { name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", tech_type: "", is_active: true });
|
|
|
editingService.value = null;
|
|
|
+ } else if (activeTab.value === 'posts') {
|
|
|
+ Object.assign(postForm, { slug: "", title_en: "", title_me: "", title_ru: "", title_ua: "", excerpt_en: "", excerpt_me: "", excerpt_ru: "", excerpt_ua: "", content_en: "", content_me: "", content_ru: "", content_ua: "", category: "Technology", image_url: "", is_published: true });
|
|
|
+ editingPost.value = null;
|
|
|
}
|
|
|
showAddModal.value = true;
|
|
|
}
|
|
|
|
|
|
-function openMaterialForm(m?: any) {
|
|
|
- if (m) { Object.assign(matForm, m); editingMaterial.value = m; }
|
|
|
- else { handleAddNew(); }
|
|
|
-}
|
|
|
-
|
|
|
-function openServiceForm(s?: any) {
|
|
|
- if (s) { Object.assign(svcForm, s); editingService.value = s; }
|
|
|
- else { handleAddNew(); }
|
|
|
-}
|
|
|
async function handleSaveMaterial() {
|
|
|
try {
|
|
|
if (editingMaterial.value?.id) { await adminUpdateMaterial(editingMaterial.value.id, { ...matForm }); toast.success("Material updated"); }
|
|
|
@@ -513,9 +618,17 @@ async function handleSaveService() {
|
|
|
closeModals(); fetchData();
|
|
|
} catch { toast.error("Failed to save service"); }
|
|
|
}
|
|
|
-function closeModals() { editingMaterial.value = null; editingService.value = null; showAddModal.value = false; editingPrice.value = null; }
|
|
|
+async function handleSavePost() {
|
|
|
+ try {
|
|
|
+ if (editingPost.value?.id) { await adminUpdatePost(editingPost.value.id, { ...postForm }); toast.success("Post updated"); }
|
|
|
+ else { await adminCreatePost({ ...postForm }); toast.success("Post created"); }
|
|
|
+ closeModals(); fetchData();
|
|
|
+ } catch { toast.error("Failed to save post"); }
|
|
|
+}
|
|
|
+
|
|
|
+function closeModals() { editingMaterial.value = null; editingService.value = null; editingPost.value = null; showAddModal.value = false; editingPrice.value = null; }
|
|
|
|
|
|
-// Watch editing to sync form data
|
|
|
watch(editingMaterial, m => { if (m) Object.assign(matForm, m); });
|
|
|
watch(editingService, s => { if (s) Object.assign(svcForm, s); });
|
|
|
+watch(editingPost, p => { if (p) Object.assign(postForm, p); });
|
|
|
</script>
|