Sfoglia il codice sorgente

Platform_Updates_Blog_Portfolio_Admin_UI

unknown 1 settimana fa
parent
commit
b14f6efbae

+ 10 - 2
backend/main.py

@@ -8,14 +8,19 @@ import os
 
 import locales
 import config
-from routers import auth, orders, catalog, portfolio, files, chat
+from routers import auth, orders, catalog, portfolio, files, chat, blog
 
 app = FastAPI(title="Radionica 3D API")
 
 # Configure CORS
 app.add_middleware(
     CORSMiddleware,
-    allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
+    allow_origins=[
+        "http://localhost:5173",
+        "http://127.0.0.1:5173",
+        "http://localhost:5000", # if user uses different port
+        "https://localhost:5173"
+    ],
     allow_credentials=True,
     allow_methods=["*"],
     allow_headers=["*"],
@@ -39,6 +44,8 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
 
 @app.exception_handler(Exception)
 async def all_exception_handler(request: Request, exc: Exception):
+    print(f"ERROR: {exc}")
+    traceback.print_exc()
     if config.DEBUG:
         return JSONResponse(
             status_code=500,
@@ -55,6 +62,7 @@ app.include_router(catalog.router)
 app.include_router(portfolio.router)
 app.include_router(files.router)
 app.include_router(chat.router)
+app.include_router(blog.router)
 
 # Mount Static Files
 if not os.path.exists("uploads"):

+ 10 - 3
backend/reset_db.py

@@ -13,9 +13,9 @@ def reset_and_seed():
         
         print("Dropping tables...")
         cursor.execute("SET FOREIGN_KEY_CHECKS = 0;")
-        cursor.execute("DROP TABLE IF EXISTS materials;")
-        cursor.execute("DROP TABLE IF EXISTS services;")
-        # cursor.execute("DROP TABLE IF EXISTS order_files;") # Avoid dropping if not needed, but they depend on materials? No, orders depend on materials?
+        tables = ["order_photos", "order_files", "orders", "password_reset_tokens", "users", "services", "materials", "posts"]
+        for table in tables:
+            cursor.execute(f"DROP TABLE IF EXISTS {table};")
         cursor.execute("SET FOREIGN_KEY_CHECKS = 1;")
         
         print("Running schema.sql...")
@@ -38,6 +38,13 @@ def reset_and_seed():
         
         conn.commit()
         print("Database reset and seeded successfully.")
+
+        # Seed portfolio images
+        try:
+            from seed_portfolio import seed_portfolio
+            seed_portfolio()
+        except Exception as e:
+            print(f"Portfolio seeding failed: {e}")
         
     except Exception as e:
         print(f"Error: {e}")

+ 1 - 0
backend/routers/__init__.py

@@ -5,3 +5,4 @@ from . import catalog
 from . import portfolio
 from . import files
 from . import chat
+from . import blog

+ 116 - 0
backend/routers/blog.py

@@ -0,0 +1,116 @@
+from fastapi import APIRouter, Depends, HTTPException
+from typing import List, Optional
+from pydantic import BaseModel
+from datetime import datetime
+import db
+import mysql.connector
+
+router = APIRouter(prefix="/blog", tags=["blog"])
+
+class PostBase(BaseModel):
+    slug: str
+    title_en: str
+    title_me: Optional[str] = None
+    title_ru: Optional[str] = None
+    title_ua: Optional[str] = None
+    excerpt_en: Optional[str] = None
+    excerpt_me: Optional[str] = None
+    excerpt_ru: Optional[str] = None
+    excerpt_ua: Optional[str] = None
+    content_en: str
+    content_me: Optional[str] = None
+    content_ru: Optional[str] = None
+    content_ua: Optional[str] = None
+    category: Optional[str] = None
+    image_url: Optional[str] = None
+    is_published: bool = False
+
+class PostCreate(PostBase):
+    pass
+
+class PostUpdate(PostBase):
+    pass
+
+class Post(PostBase):
+    id: int
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+@router.get("/", response_model=List[Post])
+async def get_posts(published_only: bool = True):
+    query = "SELECT * FROM posts"
+    if published_only:
+        query += " WHERE is_published = TRUE"
+    query += " ORDER BY created_at DESC"
+    return db.execute_query(query)
+
+@router.get("/{id_or_slug}", response_model=Post)
+async def get_post(id_or_slug: str):
+    # Try by slug first
+    res = db.execute_query("SELECT * FROM posts WHERE slug = %s", (id_or_slug,))
+    if res: return res[0]
+    
+    # If not found, try by ID if it looks like an int
+    if id_or_slug.isdigit():
+        res = db.execute_query("SELECT * FROM posts WHERE id = %s", (int(id_or_slug),))
+        if res: return res[0]
+        
+    raise HTTPException(status_code=404, detail="Post not found")
+
+@router.post("/", response_model=Post)
+async def create_post(post: PostCreate):
+    query = """
+    INSERT INTO posts (
+        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, image_url, is_published
+    ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+    """
+    values = (
+        post.slug, post.title_en, post.title_me, post.title_ru, post.title_ua,
+        post.excerpt_en, post.excerpt_me, post.excerpt_ru, post.excerpt_ua,
+        post.content_en, post.content_me, post.content_ru, post.content_ua,
+        post.category, post.image_url, post.is_published
+    )
+    
+    try:
+        new_id = db.execute_commit(query, values)
+        res = db.execute_query("SELECT * FROM posts WHERE id = %s", (new_id,))
+        return res[0]
+    except mysql.connector.Error as err:
+        raise HTTPException(status_code=400, detail=str(err))
+
+@router.put("/{post_id}", response_model=Post)
+async def update_post(post_id: int, post: PostUpdate):
+    query = """
+    UPDATE posts SET 
+        slug=%s, title_en=%s, title_me=%s, title_ru=%s, title_ua=%s,
+        excerpt_en=%s, excerpt_me=%s, excerpt_ru=%s, excerpt_ua=%s,
+        content_en=%s, content_me=%s, content_ru=%s, content_ua=%s,
+        category=%s, image_url=%s, is_published=%s
+    WHERE id = %s
+    """
+    values = (
+        post.slug, post.title_en, post.title_me, post.title_ru, post.title_ua,
+        post.excerpt_en, post.excerpt_me, post.excerpt_ru, post.excerpt_ua,
+        post.content_en, post.content_me, post.content_ru, post.content_ua,
+        post.category, post.image_url, post.is_published, post_id
+    )
+    
+    db.execute_commit(query, values)
+    res = db.execute_query("SELECT * FROM posts WHERE id = %s", (post_id,))
+    if not res:
+        raise HTTPException(status_code=404, detail="Post not found")
+    return res[0]
+
+@router.delete("/{post_id}")
+async def delete_post(post_id: int):
+    # We don't easily get rowcount from execute_commit as I wrote it, 
+    # but we can check existence first or modify execute_commit.
+    # For now, let's just execute it.
+    db.execute_commit("DELETE FROM posts WHERE id = %s", (post_id,))
+    return {"message": "Post deleted successfully"}

+ 4 - 4
backend/routers/chat.py

@@ -1,8 +1,9 @@
-from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query
+from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query, HTTPException
 from services.chat_manager import manager
 import db
 import auth_utils
 import datetime
+import schemas
 
 router = APIRouter(tags=["chat"])
 
@@ -28,11 +29,10 @@ async def get_order_messages(order_id: int, token: str = Depends(auth_utils.oaut
     return messages
 
 @router.post("/orders/{order_id}/messages")
-async def post_order_message(order_id: int, request: Request, token: str = Depends(auth_utils.oauth2_scheme)):
+async def post_order_message(order_id: int, data: schemas.MessageCreate, token: str = Depends(auth_utils.oauth2_scheme)):
     payload = auth_utils.decode_token(token)
     if not payload: raise HTTPException(status_code=401, detail="Invalid token")
-    data = await request.json()
-    message = data.get("message", "").strip()
+    message = data.message.strip()
     if not message: raise HTTPException(status_code=400, detail="Empty message")
     role = payload.get("role")
     user_id = payload.get("id")

+ 3 - 3
backend/routers/portfolio.py

@@ -12,10 +12,10 @@ router = APIRouter(tags=["portfolio"])
 @router.get("/portfolio")
 async def get_public_portfolio():
     query = """
-    SELECT p.id, p.file_path, o.material_name, o.id as order_id
+    SELECT p.id, p.file_path, COALESCE(o.material_name, 'Showcase') as material_name, p.order_id
     FROM order_photos p
-    JOIN orders o ON p.order_id = o.id
-    WHERE p.is_public = TRUE AND o.allow_portfolio = TRUE
+    LEFT JOIN orders o ON p.order_id = o.id
+    WHERE p.is_public = TRUE AND (o.id IS NULL OR o.allow_portfolio = TRUE)
     ORDER BY p.created_at DESC
     """
     return db.execute_query(query)

File diff suppressed because it is too large
+ 35 - 3
backend/schema.sql


+ 3 - 0
backend/schemas.py

@@ -171,3 +171,6 @@ class OrderResponse(OrderCreate):
     created_at: datetime
     
     model_config = ConfigDict(from_attributes=True)
+
+class MessageCreate(BaseModel):
+    message: str

+ 20 - 39
backend/seed_portfolio.py

@@ -1,46 +1,27 @@
 import db
+import os
 
-def seed():
-    # Disable foreign key checks to make it easier to seed orders without users
-    db.execute_commit("SET FOREIGN_KEY_CHECKS = 0;")
+def seed_portfolio():
+    print("Seeding portfolio...")
+    # Portfolio items are order_photos with is_public = TRUE
+    # Since they don't necessarily need an order, we can set order_id to NULL
+    # But let's check if the table allows it
     
-    # 1. Clear existing portfolio data for clean test
-    db.execute_commit("DELETE FROM order_photos;")
-    
-    # 2. Insert Orders with consent
-    orders_data = [
-        ('Nikola', 'Tesla', '+38267123456', 'nikola@tesla.me', 'Podgorica, Montenegro', 'SLA Resin', True),
-        ('John', 'Doe', '+38267000111', 'john@gmail.com', 'Budva, Montenegro', 'PLA Plastic', True),
-        ('Alice', 'Smith', '+38268333444', 'alice@matrix.me', 'Kotor, Montenegro', 'Resin', True),
-        ('Bob', 'Ross', '+38269555666', 'bob@art.me', 'Herceg Novi, Montenegro', 'ABS Plastic', True),
-        ('Tony', 'Stark', '+38267999888', 'tony@stark.me', 'Tivat, Montenegro', 'Engineering Plastic', True),
-    ]
-    
-    order_ids = []
-    for o in orders_data:
-        qid = db.execute_commit(
-            "INSERT INTO orders (first_name, last_name, phone, email, shipping_address, material_name, allow_portfolio, status) VALUES (%s, %s, %s, %s, %s, %s, %s, 'completed')",
-            o
-        )
-        order_ids.append(qid)
-        
-    # 3. Insert Photos
-    photos = [
-        (order_ids[0], 'uploads/portfolio_gear.png', True),
-        (order_ids[1], 'uploads/portfolio_arch.png', True),
-        (order_ids[2], 'uploads/portfolio_voronoi.png', True),
-        (order_ids[3], 'uploads/portfolio_prosthetic.png', True),
-        (order_ids[4], 'uploads/portfolio_minifigs.png', True),
+    portfolio_items = [
+        {"path": "/uploads/portfolio/gear.png"},
+        {"path": "/uploads/portfolio/villa.png"},
+        {"path": "/uploads/portfolio/warrior.png"}
     ]
     
-    for p in photos:
-        db.execute_commit(
-            "INSERT INTO order_photos (order_id, file_path, is_public) VALUES (%s, %s, %s)",
-            p
-        )
-    
-    db.execute_commit("SET FOREIGN_KEY_CHECKS = 1;")
-    print("Portfolio seeded successfully with 5 items.")
+    for item in portfolio_items:
+        # Check if already exists
+        exists = db.execute_query("SELECT id FROM order_photos WHERE file_path = %s", (item["path"],))
+        if not exists:
+            db.execute_commit(
+                "INSERT INTO order_photos (order_id, file_path, is_public) VALUES (NULL, %s, TRUE)",
+                (item["path"],)
+            )
+            print(f"Added {item['path']} to portfolio")
 
 if __name__ == "__main__":
-    seed()
+    seed_portfolio()

+ 23 - 18
scripts/fix_translations.py

@@ -7,24 +7,29 @@ def fix_translations():
     with open(file_path, "r", encoding="utf-8") as f:
         data = json.load(f)
 
-    # 1. About Page Fixes
-    if "about" not in data: data["about"] = {}
-    data["about"].update({
-        "values": {
-            "title": { "en": "Our Values", "me": "Naše vrijednosti", "ru": "Наши ценности", "ua": "Наші цінності" },
-            "trust": { "title": { "en": "Trust", "me": "Povjerenje", "ru": "Доверие", "ua": "Довіра" }, "content": { "en": "We trust you to value our work.", "me": "Vjerujemo da ćete cijeniti naš rad.", "ru": "Мы доверяем вам оценивать нашу работу.", "ua": "Ми довіряємо вам оцінювати нашу роботу." } },
-            "quality": { "title": { "en": "Quality", "me": "Kvalitet", "ru": "Качество", "ua": "Якість" }, "content": { "en": "Precision in every layer.", "me": "Preciznost u svakom sloju.", "ru": "Точность в каждом слое.", "ua": "Точність у кожному шарі." } },
-            "innovation": { "title": { "en": "Innovation", "me": "Inovacije", "ru": "Инновации", "ua": "Інновації" }, "content": { "en": "Latest tech applied.", "me": "Primjena najnovije tehnologije.", "ru": "Применение новейших технологий.", "ua": "Застосування новітніх технологій." } },
-            "community": { "title": { "en": "Community", "me": "Zajednica", "ru": "Сообщество", "ua": "Спільнота" }, "content": { "en": "Support for local makers.", "me": "Podrška lokalnim stvaraocima.", "ru": "Поддержка местных мейкеров.", "ua": "Підтримка місцевих мейкерів." } }
-        },
-        "team": {
-            "title": { "en": "Our Team", "me": "Naš tim", "ru": "Наша команда", "ua": "Наша команда" },
-            "member1": { "name": "Luka", "role": { "en": "Founder", "me": "Osnivač", "ru": "Основатель", "ua": "Засновник" } },
-            "member2": { "name": "Milica", "role": { "en": "Designer", "me": "Dizajner", "ru": "Дизайнер", "ua": "Дизайнер" } },
-            "member3": { "name": "Nikola", "role": { "en": "Engineer", "me": "Inženjer", "ru": "Инженер", "ua": "Інженер" } }
-        },
-        "cta": { "title": { "en": "Ready to print?", "me": "Spremni za štampu?", "ru": "Готовы к печати?", "ua": "Готові до друку?" }, "content": { "en": "Contact us today for your project.", "me": "Kontaktirajte nas danas za vaš projekat.", "ru": "Свяжитесь с нами сегодня для вашего проекта.", "ua": "Зв'яжіться з нами сьогодні для вашого проекту." } }
-    })
+    if "common" not in data: data["common"] = {}
+    data["common"]["save_continue"] = {
+        "en": "Save and Continue",
+        "me": "Sačuvaj i nastavi",
+        "ru": "Сохранить и продолжить",
+        "ua": "Зберегти та продовжити"
+    }
+
+    if "profile" not in data: data["profile"] = {}
+    
+    data["profile"]["complete_title"] = {
+        "en": "Complete Your Profile",
+        "me": "Popunite svoj profil",
+        "ru": "Заполните профиль",
+        "ua": "Заповніть профіль"
+    }
+    
+    data["profile"]["complete_subtitle"] = {
+        "en": "Please provide your contact information to continue with the order.",
+        "me": "Molimo unesite svoje kontakt podatke da biste nastavili sa narudžbom.",
+        "ru": "Пожалуйста, предоставьте контактную информацию, чтобы продолжить оформление заказа.",
+        "ua": "Будь ласка, надайте контактну інформацію, щоб продовжити оформлення замовлення."
+    }
 
     with open(file_path, "w", encoding="utf-8") as f:
         json.dump(data, f, ensure_ascii=False, indent=2)

+ 2 - 19
src/components/Footer.vue

@@ -1,7 +1,7 @@
 <template>
   <footer class="bg-white border-t border-black/[0.04] pt-8 sm:pt-12 pb-6">
     <div class="container mx-auto px-4">
-      <div class="grid sm:grid-cols-2 lg:grid-cols-5 gap-12 mb-8 sm:mb-12">
+      <div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-12 mb-8 sm:mb-12">
         <!-- Brand -->
         <div class="lg:col-span-2">
           <Logo />
@@ -37,16 +37,6 @@
           </ul>
         </div>
 
-        <!-- Support -->
-        <div>
-          <h4 class="font-display text-xs font-bold uppercase tracking-widest text-foreground/40 mb-3">{{ t("footer.support") }}</h4>
-          <ul class="space-y-1.5">
-            <li v-for="link in footerLinks.support" :key="link.label">
-              <router-link v-if="link.to" :to="link.to" class="text-xs font-bold text-foreground/60 hover:text-primary transition-colors">{{ link.label }}</router-link>
-              <a v-else-if="link.href" :href="link.href" class="text-xs font-bold text-foreground/60 hover:text-primary transition-colors">{{ link.label }}</a>
-            </li>
-          </ul>
-        </div>
       </div>
 
       <!-- Bottom -->
@@ -83,15 +73,8 @@ const footerLinks = computed(() => ({
   company: [
     { label: t("footer.about"), to: "/about" },
     { label: t("footer.privacy"), to: "/privacy" },
-    { label: t("footer.careers"), to: "/careers" },
-    // { label: t("footer.blog"), to: "/blog" },
+    { label: t("footer.blog"), to: "/blog" },
     { label: t("footer.contact"), to: "/contact" },
   ] as FooterLink[],
-  support: [
-    { label: t("footer.help"), to: "/help" },
-    { label: t("footer.guidelines"), to: "/guidelines" },
-    { label: t("footer.materials"), href: "#" },
-    { label: t("footer.api"), href: "#" },
-  ] as FooterLink[],
 }));
 </script>

+ 4 - 5
src/components/Header.vue

@@ -143,7 +143,7 @@
           </div>
 
           <div class="pt-4">
-            <LanguageSwitcher />
+            <LanguageSwitcher @select="mobileOpen = false" />
           </div>
         </nav>
       </div>
@@ -167,7 +167,7 @@ const router = useRouter();
 const authStore = useAuthStore();
 const mobileOpen = ref(false);
 
-const isLoggedIn = computed(() => !!localStorage.getItem("token"));
+const isLoggedIn = computed(() => !!authStore.user);
 const isAdmin = computed(() => authStore.user?.role === "admin");
 
 const navLinks = computed(() => [
@@ -178,9 +178,8 @@ const navLinks = computed(() => [
   { label: t("nav.philosophy"), href: "/#philosophy", isInternal: false },
 ]);
 
-function handleLogout() {
-  localStorage.removeItem("token");
-  authStore.setUser(null);
+async function handleLogout() {
+  await authStore.logout();
   toast.success("Successfully logged out");
   router.push("/");
   mobileOpen.value = false;

+ 3 - 0
src/components/LanguageSwitcher.vue

@@ -54,8 +54,11 @@ const currentLang = computed(
 
 onClickOutside(containerRef, () => (isOpen.value = false));
 
+const emit = defineEmits(["select"]);
+
 function changeLang(code: string) {
   setLanguage(code);
   isOpen.value = false;
+  emit("select");
 }
 </script>

+ 63 - 3
src/lib/api.ts

@@ -120,6 +120,15 @@ export const loginUser = async (userData: any) => {
   return response.json();
 };
 
+export const logoutUser = async () => {
+  const token = localStorage.getItem("token");
+  if (!token) return;
+  await fetch(`${API_BASE_URL}/auth/logout`, {
+    method: 'POST',
+    headers: { 'Authorization': `Bearer ${token}` }
+  });
+};
+
 export const socialLogin = async (socialData: any) => {
   const response = await fetch(`${API_BASE_URL}/auth/social-login?lang=${i18n.global.locale.value}`, {
     method: 'POST',
@@ -202,7 +211,7 @@ export const resetPassword = async (data: any) => {
 
 export const adminGetOrders = async () => {
   const token = localStorage.getItem("token");
-  const response = await fetch(`${API_BASE_URL}/admin/orders?lang=${i18n.global.locale.value}`, {
+  const response = await fetch(`${API_BASE_URL}/orders/admin/list?lang=${i18n.global.locale.value}`, {
     headers: { 'Authorization': `Bearer ${token}` }
   });
   if (!response.ok) throw new Error("Failed to fetch admin orders");
@@ -211,7 +220,7 @@ export const adminGetOrders = async () => {
 
 export const adminUpdateOrder = async (orderId: number, data: any) => {
   const token = localStorage.getItem("token");
-  const response = await fetch(`${API_BASE_URL}/admin/orders/${orderId}?lang=${i18n.global.locale.value}`, {
+  const response = await fetch(`${API_BASE_URL}/orders/${orderId}/admin?lang=${i18n.global.locale.value}`, {
     method: 'PATCH',
     headers: { 
       'Content-Type': 'application/json',
@@ -342,7 +351,7 @@ export const adminUploadOrderPhoto = async (orderId: number, formData: FormData)
 
 export const adminAttachFile = async (orderId: number, formData: FormData) => {
   const token = localStorage.getItem("token");
-  const response = await fetch(`${API_BASE_URL}/admin/orders/${orderId}/attach-file?lang=${i18n.global.locale.value}`, {
+  const response = await fetch(`${API_BASE_URL}/orders/${orderId}/attach-file?lang=${i18n.global.locale.value}`, {
     method: 'POST',
     headers: { 
       'Authorization': `Bearer ${token}`
@@ -418,3 +427,54 @@ export const authPing = async () => {
   }
   return null;
 };
+
+// Blog API
+export const getBlogPosts = async (publishedOnly: boolean = true) => {
+  const response = await fetch(`${API_BASE_URL}/blog?published_only=${publishedOnly}&lang=${i18n.global.locale.value}`);
+  if (!response.ok) throw new Error("Failed to fetch blog posts");
+  return response.json();
+};
+
+export const getBlogPost = async (idOrSlug: string) => {
+  const response = await fetch(`${API_BASE_URL}/blog/${idOrSlug}?lang=${i18n.global.locale.value}`);
+  if (!response.ok) throw new Error("Failed to fetch blog post");
+  return response.json();
+};
+
+export const adminCreatePost = async (data: any) => {
+  const token = localStorage.getItem("token");
+  const response = await fetch(`${API_BASE_URL}/blog?lang=${i18n.global.locale.value}`, {
+    method: 'POST',
+    headers: { 
+      'Content-Type': 'application/json',
+      'Authorization': `Bearer ${token}`
+    },
+    body: JSON.stringify(data)
+  });
+  if (!response.ok) throw new Error("Failed to create blog post");
+  return response.json();
+};
+
+export const adminUpdatePost = async (id: number, data: any) => {
+  const token = localStorage.getItem("token");
+  const response = await fetch(`${API_BASE_URL}/blog/${id}?lang=${i18n.global.locale.value}`, {
+    method: 'PUT',
+    headers: { 
+      'Content-Type': 'application/json',
+      'Authorization': `Bearer ${token}`
+    },
+    body: JSON.stringify(data)
+  });
+  if (!response.ok) throw new Error("Failed to update blog post");
+  return response.json();
+};
+
+export const adminDeletePost = async (id: number) => {
+  const token = localStorage.getItem("token");
+  const response = await fetch(`${API_BASE_URL}/blog/${id}?lang=${i18n.global.locale.value}`, {
+    method: 'DELETE',
+    headers: { 'Authorization': `Bearer ${token}` }
+  });
+  if (!response.ok) throw new Error("Failed to delete blog post");
+  return response.json();
+};

+ 7 - 0
src/locales/en.json

@@ -665,5 +665,12 @@
       "button": "Legal",
       "email": "Email"
     }
+  },
+  "profile": {
+    "complete_title": "Complete Your Profile",
+    "complete_subtitle": "Please provide your contact information to continue with the order."
+  },
+  "common": {
+    "save_continue": "Save and Continue"
   }
 }

+ 7 - 0
src/locales/me.json

@@ -665,5 +665,12 @@
       "button": "Pravnik",
       "email": "Email"
     }
+  },
+  "profile": {
+    "complete_title": "Popunite svoj profil",
+    "complete_subtitle": "Molimo unesite svoje kontakt podatke da biste nastavili sa narudžbom."
+  },
+  "common": {
+    "save_continue": "Sačuvaj i nastavi"
   }
 }

+ 7 - 0
src/locales/ru.json

@@ -665,5 +665,12 @@
       "button": "Юрист",
       "email": "Email"
     }
+  },
+  "profile": {
+    "complete_title": "Заполните профиль",
+    "complete_subtitle": "Пожалуйста, предоставьте контактную информацию, чтобы продолжить оформление заказа."
+  },
+  "common": {
+    "save_continue": "Сохранить и продолжить"
   }
 }

+ 22 - 0
src/locales/translations.json

@@ -2400,5 +2400,27 @@
         "ua": "Email"
       }
     }
+  },
+  "profile": {
+    "complete_title": {
+      "en": "Complete Your Profile",
+      "me": "Popunite svoj profil",
+      "ru": "Заполните профиль",
+      "ua": "Заповніть профіль"
+    },
+    "complete_subtitle": {
+      "en": "Please provide your contact information to continue with the order.",
+      "me": "Molimo unesite svoje kontakt podatke da biste nastavili sa narudžbom.",
+      "ru": "Пожалуйста, предоставьте контактную информацию, чтобы продолжить оформление заказа.",
+      "ua": "Будь ласка, надайте контактну інформацію, щоб продовжити оформлення замовлення."
+    }
+  },
+  "common": {
+    "save_continue": {
+      "en": "Save and Continue",
+      "me": "Sačuvaj i nastavi",
+      "ru": "Сохранить и продолжить",
+      "ua": "Зберегти та продовжити"
+    }
   }
 }

+ 7 - 0
src/locales/ua.json

@@ -665,5 +665,12 @@
       "button": "Юрист",
       "email": "Email"
     }
+  },
+  "profile": {
+    "complete_title": "Заповніть профіль",
+    "complete_subtitle": "Будь ласка, надайте контактну інформацію, щоб продовжити оформлення замовлення."
+  },
+  "common": {
+    "save_continue": "Зберегти та продовжити"
   }
 }

+ 0 - 35
src/pages/About.vue

@@ -67,41 +67,6 @@
             </div>
           </section>
 
-          <!-- Team Section -->
-          <section>
-            <h2 class="text-2xl font-display font-bold text-foreground mb-6">
-              {{ t("about.team.title") }}
-            </h2>
-            <div class="grid md:grid-cols-3 gap-6">
-              <div class="text-center">
-                <div class="w-32 h-32 bg-gray-200 rounded-full mx-auto mb-4"></div>
-                <h3 class="text-lg font-display font-bold text-foreground mb-1">
-                  {{ t("about.team.member1.name") }}
-                </h3>
-                <p class="text-foreground/60 text-sm">
-                  {{ t("about.team.member1.role") }}
-                </p>
-              </div>
-              <div class="text-center">
-                <div class="w-32 h-32 bg-gray-200 rounded-full mx-auto mb-4"></div>
-                <h3 class="text-lg font-display font-bold text-foreground mb-1">
-                  {{ t("about.team.member2.name") }}
-                </h3>
-                <p class="text-foreground/60 text-sm">
-                  {{ t("about.team.member2.role") }}
-                </p>
-              </div>
-              <div class="text-center">
-                <div class="w-32 h-32 bg-gray-200 rounded-full mx-auto mb-4"></div>
-                <h3 class="text-lg font-display font-bold text-foreground mb-1">
-                  {{ t("about.team.member3.name") }}
-                </h3>
-                <p class="text-foreground/60 text-sm">
-                  {{ t("about.team.member3.role") }}
-                </p>
-              </div>
-            </div>
-          </section>
 
           <!-- Contact CTA -->
           <section class="bg-primary/5 rounded-2xl p-8 text-center">

+ 127 - 14
src/pages/Admin.vue

@@ -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>

+ 110 - 165
src/pages/Blog.vue

@@ -1,6 +1,7 @@
 <template>
   <div class="min-h-screen bg-white">
-    <div class="container mx-auto px-4 py-12">
+    <Header />
+    <div class="container mx-auto px-4 py-32">
       <div class="max-w-6xl mx-auto">
         <!-- Header -->
         <div class="mb-12">
@@ -12,198 +13,142 @@
           </p>
         </div>
 
-        <!-- Featured Post -->
-        <section class="mb-12">
-          <div class="bg-gray-50 rounded-2xl overflow-hidden">
-            <div class="md:flex">
-              <div class="md:w-1/2">
-                <div class="h-64 md:h-full bg-gray-200"></div>
-              </div>
-              <div class="md:w-1/2 p-8 md:p-12">
-                <div class="flex items-center gap-2 mb-4">
-                  <span class="px-3 py-1 bg-primary/10 text-primary text-xs font-bold rounded-full">
-                    {{ t("blog.featured") }}
-                  </span>
-                  <span class="text-sm text-foreground/60">
-                    {{ t("blog.dateFormat", { date: "2024-03-15" }) }}
-                  </span>
-                </div>
-                <h2 class="text-2xl md:text-3xl font-display font-bold text-foreground mb-4">
-                  {{ t("blog.featuredPost.title") }}
-                </h2>
-                <p class="text-foreground/70 mb-6">
-                  {{ t("blog.featuredPost.excerpt") }}
-                </p>
-                <router-link 
-                  to="/blog/the-future-of-3d-printing"
-                  class="inline-flex items-center text-primary font-bold hover:text-primary/80 transition-colors"
-                >
-                  {{ t("blog.readMore") }}
-                  <svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
-                  </svg>
-                </router-link>
-              </div>
-            </div>
-          </div>
-        </section>
+        <!-- Loading State -->
+        <div v-if="isLoading" class="flex flex-col items-center justify-center py-20 opacity-50">
+          <RefreshCw class="w-10 h-10 animate-spin text-primary mb-4" />
+          <p class="font-bold tracking-widest uppercase text-[10px]">Loading articles...</p>
+        </div>
 
-        <!-- Blog Posts Grid -->
-        <section>
-          <h2 class="text-2xl font-display font-bold text-foreground mb-8">
-            {{ t("blog.latestPosts") }}
-          </h2>
-          <div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
-            <!-- Post 1 -->
-            <article class="border border-gray-200 rounded-xl overflow-hidden hover:border-primary/30 transition-colors">
-              <div class="h-48 bg-gray-200"></div>
-              <div class="p-6">
-                <div class="flex items-center justify-between mb-4">
-                  <span class="text-sm text-foreground/60">
-                    {{ t("blog.dateFormat", { date: "2024-03-10" }) }}
-                  </span>
-                  <span class="px-3 py-1 bg-gray-100 text-foreground/70 text-xs font-bold rounded-full">
-                    {{ t("blog.categories.technology") }}
-                  </span>
+        <template v-else>
+          <!-- Featured Post (First one) -->
+          <section v-if="featuredPost" class="mb-12">
+            <div class="bg-gray-50 rounded-2xl overflow-hidden border border-black/[0.03]">
+              <div class="md:flex">
+                <div class="md:w-1/2">
+                  <div class="h-64 md:h-full bg-muted/20 overflow-hidden">
+                    <img v-if="featuredPost.image_url" :src="featuredPost.image_url" class="w-full h-full object-cover" />
+                  </div>
                 </div>
-                <h3 class="text-xl font-display font-bold text-foreground mb-3">
-                  {{ t("blog.post1.title") }}
-                </h3>
-                <p class="text-foreground/70 mb-4">
-                  {{ t("blog.post1.excerpt") }}
-                </p>
-                <router-link 
-                  to="/blog/fdm-vs-sla"
-                  class="inline-flex items-center text-primary font-bold hover:text-primary/80 transition-colors text-sm"
-                >
-                  {{ t("blog.readMore") }}
-                </router-link>
-              </div>
-            </article>
-
-            <!-- Post 2 -->
-            <article class="border border-gray-200 rounded-xl overflow-hidden hover:border-primary/30 transition-colors">
-              <div class="h-48 bg-gray-200"></div>
-              <div class="p-6">
-                <div class="flex items-center justify-between mb-4">
-                  <span class="text-sm text-foreground/60">
-                    {{ t("blog.dateFormat", { date: "2024-03-05" }) }}
-                  </span>
-                  <span class="px-3 py-1 bg-gray-100 text-foreground/70 text-xs font-bold rounded-full">
-                    {{ t("blog.categories.materials") }}
-                  </span>
+                <div class="md:w-1/2 p-8 md:p-12">
+                  <div class="flex items-center gap-2 mb-4">
+                    <span class="px-3 py-1 bg-primary text-white text-[10px] font-bold rounded-full uppercase tracking-widest">
+                      {{ t("blog.featured") }}
+                    </span>
+                    <span class="text-xs font-bold text-foreground/40 uppercase">
+                      {{ new Date(featuredPost.created_at).toLocaleDateString() }}
+                    </span>
+                  </div>
+                  <h2 class="text-2xl md:text-3xl font-display font-bold text-foreground mb-4">
+                    {{ getLoc(featuredPost, 'title') }}
+                  </h2>
+                  <p class="text-foreground/70 mb-6 line-clamp-3">
+                    {{ getLoc(featuredPost, 'excerpt') }}
+                  </p>
+                  <router-link 
+                    :to="`/blog/${featuredPost.slug}`"
+                    class="inline-flex items-center text-primary font-bold hover:text-primary/80 transition-colors"
+                  >
+                    {{ t("blog.readMore") }}
+                    <ArrowRight class="w-4 h-4 ml-2" />
+                  </router-link>
                 </div>
-                <h3 class="text-xl font-display font-bold text-foreground mb-3">
-                  {{ t("blog.post2.title") }}
-                </h3>
-                <p class="text-foreground/70 mb-4">
-                  {{ t("blog.post2.excerpt") }}
-                </p>
-                <router-link 
-                  to="/blog/choosing-materials"
-                  class="inline-flex items-center text-primary font-bold hover:text-primary/80 transition-colors text-sm"
-                >
-                  {{ t("blog.readMore") }}
-                </router-link>
               </div>
-            </article>
+            </div>
+          </section>
 
-            <!-- Post 3 -->
-            <article class="border border-gray-200 rounded-xl overflow-hidden hover:border-primary/30 transition-colors">
-              <div class="h-48 bg-gray-200"></div>
-              <div class="p-6">
-                <div class="flex items-center justify-between mb-4">
-                  <span class="text-sm text-foreground/60">
-                    {{ t("blog.dateFormat", { date: "2024-02-28" }) }}
-                  </span>
-                  <span class="px-3 py-1 bg-gray-100 text-foreground/70 text-xs font-bold rounded-full">
-                    {{ t("blog.categories.tutorials") }}
-                  </span>
+          <!-- Blog Posts Grid -->
+          <section v-if="remainingPosts.length > 0">
+            <h2 class="text-xl font-display font-bold text-foreground mb-8 uppercase tracking-widest opacity-40">
+              {{ t("blog.latestPosts") }}
+            </h2>
+            <div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
+              <article v-for="post in remainingPosts" :key="post.id" class="flex flex-col border border-black/[0.05] rounded-2xl overflow-hidden hover:border-primary/30 transition-all duration-300 group">
+                <div class="h-48 bg-muted/20 overflow-hidden">
+                  <img v-if="post.image_url" :src="post.image_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
                 </div>
-                <h3 class="text-xl font-display font-bold text-foreground mb-3">
-                  {{ t("blog.post3.title") }}
-                </h3>
-                <p class="text-foreground/70 mb-4">
-                  {{ t("blog.post3.excerpt") }}
-                </p>
-                <router-link 
-                  to="/blog/design-tips"
-                  class="inline-flex items-center text-primary font-bold hover:text-primary/80 transition-colors text-sm"
-                >
-                  {{ t("blog.readMore") }}
-                </router-link>
-              </div>
-            </article>
-          </div>
-        </section>
-
-        <!-- Categories -->
-        <section class="mt-12">
-          <h2 class="text-2xl font-display font-bold text-foreground mb-8">
-            {{ t("blog.categories.title") }}
-          </h2>
-          <div class="flex flex-wrap gap-3">
-            <router-link 
-              to="/blog/category/technology"
-              class="px-4 py-2 bg-gray-100 text-foreground/70 font-bold rounded-lg hover:bg-primary/10 hover:text-primary transition-colors"
-            >
-              {{ t("blog.categories.technology") }}
-            </router-link>
-            <router-link 
-              to="/blog/category/materials"
-              class="px-4 py-2 bg-gray-100 text-foreground/70 font-bold rounded-lg hover:bg-primary/10 hover:text-primary transition-colors"
-            >
-              {{ t("blog.categories.materials") }}
-            </router-link>
-            <router-link 
-              to="/blog/category/tutorials"
-              class="px-4 py-2 bg-gray-100 text-foreground/70 font-bold rounded-lg hover:bg-primary/10 hover:text-primary transition-colors"
-            >
-              {{ t("blog.categories.tutorials") }}
-            </router-link>
-            <router-link 
-              to="/blog/category/case-studies"
-              class="px-4 py-2 bg-gray-100 text-foreground/70 font-bold rounded-lg hover:bg-primary/10 hover:text-primary transition-colors"
-            >
-              {{ t("blog.categories.caseStudies") }}
-            </router-link>
-            <router-link 
-              to="/blog/category/industry"
-              class="px-4 py-2 bg-gray-100 text-foreground/70 font-bold rounded-lg hover:bg-primary/10 hover:text-primary transition-colors"
-            >
-              {{ t("blog.categories.industry") }}
-            </router-link>
-          </div>
-        </section>
+                <div class="p-6 flex-1 flex flex-col">
+                  <div class="flex items-center justify-between mb-4">
+                    <span class="text-[10px] font-bold text-foreground/30 uppercase">
+                      {{ new Date(post.created_at).toLocaleDateString() }}
+                    </span>
+                    <span class="px-2 py-0.5 bg-gray-50 text-foreground/40 text-[9px] font-bold rounded-md border border-black/[0.03] uppercase tracking-wider">
+                      {{ post.category }}
+                    </span>
+                  </div>
+                  <h3 class="text-lg font-display font-bold text-foreground mb-3 line-clamp-2">
+                    {{ getLoc(post, 'title') }}
+                  </h3>
+                  <p class="text-foreground/60 text-sm mb-6 line-clamp-3 flex-1">
+                    {{ getLoc(post, 'excerpt') }}
+                  </p>
+                  <router-link 
+                    :to="`/blog/${post.slug}`"
+                    class="inline-flex items-center text-primary font-bold hover:text-primary-foreground hover:bg-primary px-4 py-2 rounded-xl transition-all self-start text-xs border border-primary/20"
+                  >
+                    {{ t("blog.readMore") }}
+                  </router-link>
+                </div>
+              </article>
+            </div>
+          </section>
+        </template>
 
         <!-- Newsletter CTA -->
-        <section class="mt-12 bg-primary/5 rounded-2xl p-8 text-center">
+        <section class="mt-20 bg-primary/[0.02] border border-primary/10 rounded-3xl p-8 md:p-12 text-center">
           <h2 class="text-2xl font-display font-bold text-foreground mb-4">
             {{ t("blog.newsletter.title") }}
           </h2>
-          <p class="text-foreground/70 mb-6 max-w-2xl mx-auto">
+          <p class="text-foreground/70 mb-8 max-w-xl mx-auto text-sm leading-relaxed">
             {{ t("blog.newsletter.content") }}
           </p>
           <div class="max-w-md mx-auto">
-            <div class="flex flex-col sm:flex-row gap-3">
+            <div class="flex flex-col sm:flex-row gap-2">
               <input 
                 type="email" 
                 :placeholder="t('blog.newsletter.placeholder')"
-                class="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:border-primary"
+                class="flex-1 px-5 py-3 bg-white border border-black/[0.05] rounded-2xl focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all text-sm"
               />
-              <button class="px-6 py-3 bg-primary text-white font-bold rounded-lg hover:bg-primary/90 transition-colors whitespace-nowrap">
+              <Button variant="hero" class="px-8 whitespace-nowrap">
                 {{ t("blog.newsletter.subscribe") }}
-              </button>
+              </Button>
             </div>
           </div>
         </section>
       </div>
     </div>
+    <Footer />
   </div>
 </template>
 
 <script setup lang="ts">
+import { ref, onMounted, computed } from "vue";
 import { useI18n } from "vue-i18n";
+import { ArrowRight, RefreshCw } from "lucide-vue-next";
+import Header from "@/components/Header.vue";
+import Footer from "@/components/Footer.vue";
+import Button from "@/components/ui/button.vue";
+import { getBlogPosts } from "@/lib/api";
+
+const { t, locale } = useI18n();
+const posts = ref<any[]>([]);
+const isLoading = ref(true);
+
+const featuredPost = computed(() => posts.value[0]);
+const remainingPosts = computed(() => posts.value.slice(1));
+
+function getLoc(item: any, field: string) {
+  const currentLocale = locale.value;
+  // Try current locale, fallback to EN
+  return item[`${field}_${currentLocale}`] || item[`${field}_en`] || "";
+}
 
-const { t } = useI18n();
+onMounted(async () => {
+  try {
+    posts.value = await getBlogPosts();
+  } catch (e) {
+    console.error("Failed to fetch posts", e);
+  } finally {
+    isLoading.value = false;
+  }
+});
 </script>

+ 44 - 42
src/pages/BlogPost.vue

@@ -1,76 +1,78 @@
 <template>
   <div class="min-h-screen bg-white">
-    <div class="container mx-auto px-4 py-12">
+    <Header />
+    <div class="container mx-auto px-4 py-32">
       <div class="max-w-4xl mx-auto">
-        <router-link to="/blog" class="inline-flex items-center text-primary font-bold mb-8 hover:opacity-80">
-          <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
-          </svg>
+        <router-link to="/blog" class="inline-flex items-center text-primary font-bold mb-8 hover:translate-x-[-4px] transition-transform group">
+          <ArrowLeft class="w-4 h-4 mr-2 group-hover:scale-110 transition-transform" />
           {{ t("blog.back") }}
         </router-link>
 
-        <article v-if="post">
+        <div v-if="isLoading" class="flex flex-col items-center justify-center py-24 opacity-50">
+          <RefreshCw class="w-10 h-10 animate-spin text-primary mb-4" />
+          <p class="font-bold tracking-widest uppercase text-[10px]">Loading article...</p>
+        </div>
+
+        <article v-else-if="post">
           <div class="flex items-center gap-4 mb-6">
-            <span class="px-3 py-1 bg-primary/10 text-primary text-xs font-bold rounded-full">
+            <span class="px-3 py-1 bg-primary text-white text-[10px] font-bold rounded-full uppercase tracking-widest">
               {{ post.category }}
             </span>
-            <span class="text-sm text-foreground/60">
-              {{ t("blog.dateFormat", { date: post.date }) }}
+            <span class="text-xs font-bold text-foreground/40 uppercase">
+              {{ new Date(post.created_at).toLocaleDateString() }}
             </span>
           </div>
 
-          <h1 class="text-4xl md:text-5xl font-display font-bold text-foreground mb-8">
-            {{ post.title }}
+          <h1 class="text-4xl md:text-5xl font-display font-bold text-foreground mb-12 leading-tight">
+            {{ getLoc(post, 'title') }}
           </h1>
 
-          <div class="aspect-video bg-gray-100 rounded-3xl mb-12 flex items-center justify-center">
-             <span class="text-foreground/20 font-display text-2xl">Illustration Placeholder</span>
+          <div v-if="post.image_url" class="aspect-video bg-muted/20 rounded-[2rem] mb-12 overflow-hidden border border-black/[0.03]">
+             <img :src="post.image_url" class="w-full h-full object-cover" />
           </div>
 
-          <div class="prose prose-lg max-w-none text-foreground/80 leading-relaxed space-y-6">
-            <p v-for="(p, i) in post.content" :key="i">{{ p }}</p>
+          <!-- Markdown-like content rendering -->
+          <div class="prose prose-lg max-w-none text-foreground/80 leading-relaxed whitespace-pre-wrap font-medium">
+            {{ getLoc(post, 'content') }}
           </div>
         </article>
 
-        <div v-else class="py-24 text-center">
+        <div v-else class="py-24 text-center bg-gray-50 rounded-[2.5rem] border border-dashed border-black/[0.05]">
           <h2 class="text-2xl font-display font-bold text-foreground mb-4">Post not found</h2>
-          <router-link to="/blog" class="text-primary font-bold">Back to blog</router-link>
+          <router-link to="/blog" class="text-primary font-bold hover:underline italic">Explore other articles</router-link>
         </div>
       </div>
     </div>
+    <Footer />
   </div>
 </template>
 
 <script setup lang="ts">
-import { computed } from "vue";
+import { ref, onMounted } from "vue";
 import { useRoute } from "vue-router";
 import { useI18n } from "vue-i18n";
+import { ArrowLeft, RefreshCw } from "lucide-vue-next";
+import Header from "@/components/Header.vue";
+import Footer from "@/components/Footer.vue";
+import { getBlogPost } from "@/lib/api";
 
-const { t } = useI18n();
+const { t, locale } = useI18n();
 const route = useRoute();
+const post = ref<any | null>(null);
+const isLoading = ref(true);
 
-const posts: Record<string, any> = {
-  "the-future-of-3d-printing": {
-    title: t("blog.featuredPost.title"),
-    date: "2024-03-15",
-    category: t("blog.categories.technology"),
-    content: [
-      "3D printing is no longer just a hobbyist's toy. It's becoming a cornerstone of local manufacturing and decentralized production.",
-      "In Montenegro, we are seeing a surge in demand for custom parts that were previously impossible or too expensive to source locally.",
-      "From drone parts to architectural models, the technology allows for rapid iteration and trust-based service delivery."
-    ]
-  },
-  "fdm-vs-sla": {
-    title: t("blog.post1.title"),
-    date: "2024-03-10",
-    category: t("blog.categories.technology"),
-    content: [
-      "Choosing between FDM and SLA depends on your project goals. FDM is best for functional, durable parts.",
-      "SLA, on the other hand, offers unmatched detail and surface finish, making it ideal for miniatures and prototypes.",
-      "In this article, we'll dive deep into the pros and cons of each method."
-    ]
-  }
-};
+function getLoc(item: any, field: string) {
+  const currentLocale = locale.value;
+  return item[`${field}_${currentLocale}`] || item[`${field}_en`] || "";
+}
 
-const post = computed(() => posts[route.params.id as string]);
+onMounted(async () => {
+  try {
+    post.value = await getBlogPost(route.params.id as string);
+  } catch (e) {
+    console.error("Failed to fetch post", e);
+  } finally {
+    isLoading.value = false;
+  }
+});
 </script>

+ 3 - 3
src/pages/Portfolio.vue

@@ -29,14 +29,14 @@
           :initial="{ opacity: 0, y: 20 }"
           :enter="{ opacity: 1, y: 0, transition: { delay: idx * 50 } }"
           class="group relative aspect-square overflow-hidden rounded-3xl border border-border/50 bg-card/40 backdrop-blur-sm cursor-pointer hover:border-primary/50 transition-all duration-500"
-          @click="selectedImage = `http://localhost:8000/${item.file_path}`"
+          @click="selectedImage = `http://localhost:8000${item.file_path.startsWith('/') ? '' : '/'}${item.file_path}`"
         >
-          <img :src="`http://localhost:8000/${item.file_path}`" :alt="item.material_name"
+          <img :src="`http://localhost:8000${item.file_path.startsWith('/') ? '' : '/'}${item.file_path}`" :alt="item.material_name"
             class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110" />
           <div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-all duration-500 flex flex-col justify-end p-6">
             <span class="text-[10px] font-bold uppercase tracking-widest text-primary mb-1">{{ item.material_name }}</span>
             <div class="flex items-center justify-between">
-              <p class="text-white font-bold">Order #{{ item.order_id }}</p>
+              <p class="text-white font-bold">{{ item.order_id ? `Order #${item.order_id}` : 'Radionica3D' }}</p>
               <ExternalLink class="w-4 h-4 text-white/70" />
             </div>
           </div>

+ 11 - 0
src/stores/auth.ts

@@ -91,6 +91,16 @@ export const useAuthStore = defineStore("auth", () => {
     refreshUser();
   }
 
+  async function logout() {
+    import("@/lib/api").then(async ({ logoutUser }) => {
+      await logoutUser();
+      localStorage.removeItem("token");
+      user.value = null;
+      unreadMessagesCount.value = 0;
+      stopPing();
+    });
+  }
+
   return {
     user,
     isLoading,
@@ -100,5 +110,6 @@ export const useAuthStore = defineStore("auth", () => {
     setUser,
     refreshUser,
     onProfileComplete,
+    logout,
   };
 });

Some files were not shown because too many files changed in this diff