Forráskód Böngészése

feat: improve portfolio admin with visibility toggle, captions and standalone uploads

unknown 2 hónapja
szülő
commit
b4f7b63291

+ 49 - 16
backend/routers/portfolio.py

@@ -16,7 +16,7 @@ router = APIRouter(tags=["portfolio"])
 @router.get("/portfolio")
 async def get_public_portfolio():
     query = """
-    SELECT p.id, p.file_path, COALESCE(o.material_name, 'Showcase') as material_name, p.order_id
+    SELECT p.id, p.file_path, COALESCE(o.material_name, 'Showcase') as material_name, p.order_id, p.caption
     FROM order_photos p
     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)
@@ -27,10 +27,10 @@ async def get_public_portfolio():
 @router.get("/admin/all-photos")
 async def admin_get_all_photos(admin: dict = Depends(require_admin)):
     query = """
-    SELECT p.id, p.file_path, p.is_public, p.order_id, o.allow_portfolio, 
+    SELECT p.id, p.file_path, p.is_public, p.order_id, p.caption, o.allow_portfolio, 
            o.first_name, o.last_name, COALESCE(o.material_name, 'Manual') as material_name
     FROM order_photos p
-    JOIN orders o ON p.order_id = o.id
+    LEFT JOIN orders o ON p.order_id = o.id
     ORDER BY p.created_at DESC
     """
     return db.execute_query(query)
@@ -39,6 +39,7 @@ async def admin_get_all_photos(admin: dict = Depends(require_admin)):
 async def admin_upload_order_photo(
     order_id: int, 
     is_public: bool = Form(False),
+    caption: Optional[str] = Form(None),
     file: UploadFile = File(...), 
     admin: dict = Depends(require_admin)
 ):
@@ -54,15 +55,15 @@ async def admin_upload_order_photo(
     with open(disk_path, "wb") as buffer:
         shutil.copyfileobj(file.file, buffer)
         
-    query = "INSERT INTO order_photos (order_id, file_path, is_public) VALUES (%s, %s, %s)"
-    photo_id = db.execute_commit(query, (order_id, db_file_path, is_public))
+    query = "INSERT INTO order_photos (order_id, file_path, is_public, caption) VALUES (%s, %s, %s, %s)"
+    photo_id = db.execute_commit(query, (order_id, db_file_path, is_public, caption))
     
     await audit_service.log(
         user_id=admin['id'],
         action="upload_order_photo",
         target_type="order",
         target_id=order_id,
-        details={"photo_id": photo_id, "is_public": is_public}
+        details={"photo_id": photo_id, "is_public": is_public, "caption": caption}
     )
 
     # NOTIFY USER VIA WEBSOCKET
@@ -70,32 +71,64 @@ async def admin_upload_order_photo(
     if order_info:
         await global_manager.notify_order_update(order_info[0]['user_id'], order_id)
 
-    return {"id": photo_id, "file_path": db_file_path, "is_public": is_public}
+    return {"id": photo_id, "file_path": db_file_path, "is_public": is_public, "caption": caption}
+
+@router.post("/admin/portfolio/upload")
+async def admin_upload_portfolio_photo(
+    is_public: bool = Form(True),
+    caption: Optional[str] = Form(None),
+    file: UploadFile = File(...),
+    admin: dict = Depends(require_admin)
+):
+    if not file.filename: raise HTTPException(status_code=400, detail="Invalid file")
+    unique_filename = f"{uuid.uuid4()}{os.path.splitext(file.filename)[1]}"
+    disk_path = os.path.join(config.UPLOAD_DIR, unique_filename)
+    db_file_path = f"uploads/{unique_filename}"
+    
+    with open(disk_path, "wb") as buffer:
+        shutil.copyfileobj(file.file, buffer)
+        
+    query = "INSERT INTO order_photos (order_id, file_path, is_public, caption) VALUES (%s, %s, %s, %s)"
+    photo_id = db.execute_commit(query, (None, db_file_path, is_public, caption))
+    
+    await audit_service.log(
+        user_id=admin['id'],
+        action="upload_portfolio_photo",
+        target_type="photo",
+        target_id=photo_id,
+        details={"is_public": is_public, "caption": caption}
+    )
+
+    return {"id": photo_id, "file_path": db_file_path, "is_public": is_public, "caption": caption}
 
 @router.patch("/admin/photos/{photo_id}")
 async def admin_update_photo_status(photo_id: int, data: schemas.PhotoUpdate, admin: dict = Depends(require_admin)):
-    query = "SELECT p.*, o.allow_portfolio FROM order_photos p JOIN orders o ON p.order_id = o.id WHERE p.id = %s"
+    query = "SELECT p.*, o.allow_portfolio FROM order_photos p LEFT JOIN orders o ON p.order_id = o.id WHERE p.id = %s"
     photo_data = db.execute_query(query, (photo_id,))
     if not photo_data: raise HTTPException(status_code=404, detail="Photo not found")
-    if data.is_public and not photo_data[0]['allow_portfolio']:
+    
+    # Check consent if linked to order
+    if data.is_public and photo_data[0]['order_id'] is not None and not photo_data[0]['allow_portfolio']:
         raise HTTPException(status_code=400, detail="Cannot make public: User did not consent to portfolio usage")
-    db.execute_commit("UPDATE order_photos SET is_public = %s WHERE id = %s", (data.is_public, photo_id))
+    
+    db.execute_commit("UPDATE order_photos SET is_public = %s, caption = %s WHERE id = %s", (data.is_public, data.caption, photo_id))
     
     await audit_service.log(
         user_id=admin['id'],
         action="update_photo_visibility",
         target_type="photo",
         target_id=photo_id,
-        details={"is_public": data.is_public}
+        details={"is_public": data.is_public, "caption": data.caption}
     )
 
-    # NOTIFY USER VIA WEBSOCKET
+    # NOTIFY USER VIA WEBSOCKET if linked to order
     order_id = photo_data[0]['order_id']
-    order_info = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
-    if order_info:
-        await global_manager.notify_order_update(order_info[0]['user_id'], order_id)
+    if order_id:
+        order_info = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
+        if order_info:
+            await global_manager.notify_order_update(order_info[0]['user_id'], order_id)
 
-    return {"id": photo_id, "is_public": data.is_public}
+    return {"id": photo_id, "is_public": data.is_public, "caption": data.caption}
 
 @router.delete("/admin/photos/{photo_id}")
 async def admin_delete_photo(photo_id: int, admin: dict = Depends(require_admin)):

+ 1 - 0
backend/schemas.py

@@ -218,6 +218,7 @@ class ResetPassword(BaseModel):
 
 class PhotoUpdate(BaseModel):
     is_public: bool
+    caption: Optional[str] = None
 
 class OrderResponse(OrderCreate):
     id: int

+ 18 - 0
backend/scratch/inspect_db.py

@@ -0,0 +1,18 @@
+import sys
+import os
+sys.path.append(os.path.join(os.getcwd(), 'backend'))
+import db
+
+def inspect_tables():
+    print("--- order_photos table ---")
+    columns = db.execute_query("SHOW COLUMNS FROM order_photos")
+    for col in columns:
+        print(f"{col['Field']}: {col['Type']} (Null: {col['Null']}, Key: {col['Key']})")
+    
+    print("\n--- orders table ---")
+    columns = db.execute_query("SHOW COLUMNS FROM orders")
+    for col in columns:
+        print(f"{col['Field']}: {col['Type']}")
+
+if __name__ == "__main__":
+    inspect_tables()

+ 149 - 12
src/components/admin/PortfolioSection.vue

@@ -1,18 +1,93 @@
 <template>
-  <div class="space-y-4">
+  <div class="space-y-8">
+    <!-- Standalone Upload Area -->
+    <div class="bg-card/40 border border-border/50 rounded-3xl p-8 backdrop-blur-md">
+      <div class="flex flex-col md:flex-row items-center justify-between gap-6">
+        <div class="flex items-center gap-4">
+          <div class="w-12 h-12 bg-primary/10 rounded-2xl flex items-center justify-center text-primary">
+            <ImagePlus class="w-6 h-6" />
+          </div>
+          <div>
+            <h3 class="text-lg font-bold">{{ t("admin.portfolio.uploadTitle") }}</h3>
+            <p class="text-xs text-muted-foreground">{{ t("admin.portfolio.uploadDesc") }}</p>
+          </div>
+        </div>
+        <div class="flex flex-col sm:flex-row gap-3 w-full md:w-auto">
+          <input 
+            type="text" 
+            v-model="newCaption" 
+            :placeholder="t('admin.fields.caption')" 
+            class="flex-1 md:w-64 bg-background/50 border border-border/50 rounded-xl px-4 py-2.5 text-sm outline-none focus:ring-2 ring-primary/20" 
+          />
+          <label class="cursor-pointer">
+            <div class="bg-primary hover:bg-primary/90 text-primary-foreground px-6 py-2.5 rounded-xl text-sm font-bold shadow-glow transition-all flex items-center justify-center gap-2">
+              <Upload class="w-4 h-4" /> {{ t("admin.actions.upload") }}
+            </div>
+            <input type="file" class="hidden" accept="image/*" @change="handleStandaloneUpload" />
+          </label>
+        </div>
+      </div>
+    </div>
+
+    <!-- Items Grid -->
     <div v-if="portfolioItems.length === 0" class="flex flex-col items-center justify-center py-20 bg-card/40 border border-border/50 rounded-3xl opacity-50">
       <ImageIcon class="w-12 h-12 text-muted-foreground/20 mb-4" />
       <p class="text-sm text-muted-foreground">{{ t("admin.fields.noPortfolio") }}</p>
     </div>
 
-    <div v-else class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
-      <div v-for="pi in portfolioItems" :key="pi.id" class="group relative aspect-square bg-card/40 border border-border/50 rounded-2xl overflow-hidden hover:border-primary/30 transition-all shadow-md">
-        <img :src="`${resourcesBaseUrl}/${pi.file_path}`" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" />
-        <div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-all flex flex-col items-center justify-center p-4 text-center">
-            <button @click="$emit('delete', pi.id)" class="w-10 h-10 bg-rose-500/20 hover:bg-rose-500 text-white rounded-full flex items-center justify-center transition-all mb-3 border border-rose-500/30">
-              <Trash2 class="w-5 h-5" />
-            </button>
-            <p v-if="pi.material_name" class="text-[9px] font-bold text-primary uppercase tracking-widest bg-primary/10 px-2 py-1 rounded-md">{{ pi.material_name }}</p>
+    <div v-else class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
+      <div v-for="pi in portfolioItems" :key="pi.id" 
+           class="group relative bg-card/40 border border-border/50 rounded-3xl overflow-hidden hover:border-primary/30 transition-all shadow-lg flex flex-col h-full"
+           :class="{ 'opacity-60 grayscale-[0.5]': !pi.is_public }">
+        
+        <!-- Image Area -->
+        <div class="relative aspect-square overflow-hidden">
+          <img :src="`${resourcesBaseUrl}/${pi.file_path}`" class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105" />
+          
+          <!-- Quick Status Overlay -->
+          <div class="absolute top-3 left-3 flex gap-2">
+             <div v-if="!pi.is_public" class="bg-rose-500/80 backdrop-blur-md text-[9px] font-black uppercase text-white px-2 py-1 rounded-md shadow-lg flex items-center gap-1">
+                <EyeOff class="w-2.5 h-2.5" /> {{ t('admin.labels.hidden') }}
+             </div>
+             <div class="bg-background/80 backdrop-blur-md text-[9px] font-black uppercase text-foreground/70 px-2 py-1 rounded-md shadow-lg border border-border/50">
+                {{ pi.material_name || 'Showcase' }}
+             </div>
+          </div>
+
+          <!-- Hover Controls -->
+          <div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-all flex items-center justify-center gap-3">
+             <button @click="toggleVisibility(pi)" class="w-10 h-10 rounded-full flex items-center justify-center transition-all border shadow-lg"
+                     :class="pi.is_public ? 'bg-amber-500/20 hover:bg-amber-500 text-white border-amber-500/30' : 'bg-emerald-500/20 hover:bg-emerald-500 text-white border-emerald-500/30'">
+                <component :is="pi.is_public ? EyeOff : Eye" class="w-5 h-5" />
+             </button>
+             <button @click="$emit('delete', pi.id)" class="w-10 h-10 bg-rose-500/20 hover:bg-rose-500 text-white rounded-full flex items-center justify-center transition-all border border-rose-500/30 shadow-lg">
+               <Trash2 class="w-5 h-5" />
+             </button>
+          </div>
+        </div>
+
+        <!-- Meta Area -->
+        <div class="p-4 flex-1 flex flex-col gap-3">
+           <div class="flex-1">
+              <div v-if="editingId === pi.id" class="space-y-2">
+                 <textarea v-model="editCaption" class="w-full bg-background/50 border border-primary/30 rounded-xl px-3 py-2 text-xs outline-none focus:ring-2 ring-primary/10 resize-none h-16" />
+                 <div class="flex gap-2">
+                    <button @click="saveCaption(pi)" class="flex-1 bg-primary text-primary-foreground py-1.5 rounded-lg text-[10px] font-bold shadow-glow">{{ t('admin.actions.save') }}</button>
+                    <button @click="editingId = null" class="px-3 bg-muted hover:bg-muted/80 py-1.5 rounded-lg text-[10px] font-bold">{{ t('admin.actions.cancel') }}</button>
+                 </div>
+              </div>
+              <div v-else @click="startEdit(pi)" class="cursor-pointer group/text flex items-start justify-between gap-2">
+                 <p class="text-xs text-muted-foreground leading-relaxed line-clamp-3 italic">
+                    {{ pi.caption || t('admin.portfolio.noCaption') }}
+                 </p>
+                 <Edit3 class="w-3 h-3 text-muted-foreground/0 group-hover/text:text-primary transition-all mt-1" />
+              </div>
+           </div>
+           
+           <div v-if="pi.order_id" class="pt-3 border-t border-border/50 flex justify-between items-center">
+              <span class="text-[9px] font-bold uppercase text-muted-foreground/50 tracking-tighter">Order #{{ pi.order_id }}</span>
+              <span v-if="pi.first_name" class="text-[9px] font-medium text-muted-foreground/60">{{ pi.first_name }} {{ pi.last_name }}</span>
+           </div>
         </div>
       </div>
     </div>
@@ -20,15 +95,77 @@
 </template>
 
 <script setup lang="ts">
+import { ref } from "vue";
 import { useI18n } from "vue-i18n";
-import { Image as ImageIcon, Trash2 } from "lucide-vue-next";
+import { 
+  Image as ImageIcon, Trash2, Eye, EyeOff, Edit3, 
+  Plus, ImagePlus, Upload, Check, X 
+} from "lucide-vue-next";
+import { adminUpdatePhotoStatus, adminUploadPortfolioPhoto } from "@/lib/api";
+import { toast } from "vue-sonner";
 
 const { t } = useI18n();
 
-defineProps<{
+const props = defineProps<{
   portfolioItems: any[];
   resourcesBaseUrl: string;
 }>();
 
-defineEmits(['delete']);
+const emit = defineEmits(['delete', 'refresh']);
+
+const newCaption = ref("");
+const editingId = ref<number | null>(null);
+const editCaption = ref("");
+
+const handleStandaloneUpload = async (event: Event) => {
+  const file = (event.target as HTMLInputElement).files?.[0];
+  if (!file) return;
+
+  const toastId = toast.loading(t("admin.portfolio.uploading"));
+  try {
+    const fd = new FormData();
+    fd.append("file", file);
+    fd.append("caption", newCaption.value);
+    fd.append("is_public", "true");
+    
+    await adminUploadPortfolioPhoto(fd);
+    toast.success(t("admin.portfolio.uploadSuccess"), { id: toastId });
+    newCaption.value = "";
+    emit('refresh');
+  } catch (err: any) {
+    toast.error(err.message, { id: toastId });
+  }
+};
+
+const toggleVisibility = async (item: any) => {
+  try {
+    await adminUpdatePhotoStatus(item.id, { 
+      is_public: !item.is_public,
+      caption: item.caption 
+    });
+    toast.success(t("admin.portfolio.statusUpdated"));
+    emit('refresh');
+  } catch (err: any) {
+    toast.error(err.message);
+  }
+};
+
+const startEdit = (item: any) => {
+  editingId.value = item.id;
+  editCaption.value = item.caption || "";
+};
+
+const saveCaption = async (item: any) => {
+  try {
+    await adminUpdatePhotoStatus(item.id, { 
+      is_public: item.is_public,
+      caption: editCaption.value 
+    });
+    toast.success(t("admin.portfolio.captionUpdated"));
+    editingId.value = null;
+    emit('refresh');
+  } catch (err: any) {
+    toast.error(err.message);
+  }
+};
 </script>

+ 14 - 1
src/lib/api.ts

@@ -500,7 +500,7 @@ export const adminUpdateFile = async (orderId: number, fileId: number, data: any
 };
 
 
-export const adminUpdatePhotoStatus = async (photoId: number, data: { is_public: boolean }) => {
+export const adminUpdatePhotoStatus = async (photoId: number, data: { is_public: boolean, caption?: string }) => {
   const token = localStorage.getItem("token");
   const response = await fetch(`${API_BASE_URL}/admin/photos/${photoId}?lang=${i18n.global.locale.value}`, {
     method: 'PATCH',
@@ -514,6 +514,19 @@ export const adminUpdatePhotoStatus = async (photoId: number, data: { is_public:
   return response.json();
 };
 
+export const adminUploadPortfolioPhoto = async (formData: FormData) => {
+  const token = localStorage.getItem("token");
+  const response = await fetch(`${API_BASE_URL}/admin/portfolio/upload?lang=${i18n.global.locale.value}`, {
+    method: 'POST',
+    headers: { 
+      'Authorization': `Bearer ${token}`
+    },
+    body: formData
+  });
+  if (!response.ok) throw new Error("Failed to upload portfolio photo");
+  return response.json();
+};
+
 export const getPortfolio = async () => {
   const response = await fetch(`${API_BASE_URL}/portfolio?lang=${i18n.global.locale.value}`);
   if (!response.ok) throw new Error("Failed to fetch portfolio");

+ 6 - 0
src/locales/master_admin/fields.json

@@ -42,6 +42,12 @@
       "ru": "Контент",
       "ua": "Контент"
     },
+    "caption": {
+      "en": "Caption",
+      "me": "Potpis",
+      "ru": "Подпись",
+      "ua": "Підпис"
+    },
     "customColorDirInfo": {
       "en": "Custom Color (No directory info)",
       "me": "Prilagođena boja (nema info u direktorijumu)",

+ 48 - 0
src/locales/master_admin/portfolio.json

@@ -0,0 +1,48 @@
+{
+  "admin": {
+    "portfolio": {
+      "uploadTitle": {
+        "en": "Add to Portfolio",
+        "me": "Dodaj u portfolio",
+        "ru": "Добавить в портфолио",
+        "ua": "Додати до портфоліо"
+      },
+      "uploadDesc": {
+        "en": "Upload a showcase photo directly. It will be public by default.",
+        "me": "Direktno otpremite fotografiju. Biće javna po podrazumijevanim postavkama.",
+        "ru": "Загрузите фото напрямую. Оно будет публичным по умолчанию.",
+        "ua": "Завантажте фото напряму. Воно буде публічним за замовчуванням."
+      },
+      "uploading": {
+        "en": "Uploading photo...",
+        "me": "Otpremanje fotografije...",
+        "ru": "Загрузка фото...",
+        "ua": "Завантаження фото..."
+      },
+      "uploadSuccess": {
+        "en": "Photo uploaded successfully",
+        "me": "Fotografija je uspješno otpremljena",
+        "ru": "Фото успешно загружено",
+        "ua": "Фото успішно завантажено"
+      },
+      "statusUpdated": {
+        "en": "Visibility updated",
+        "me": "Vidljivost ažurirana",
+        "ru": "Видимость обновлена",
+        "ua": "Видимість оновлена"
+      },
+      "captionUpdated": {
+        "en": "Caption updated",
+        "me": "Opis ažuriran",
+        "ru": "Подпись обновлена",
+        "ua": "Підпис оновлено"
+      },
+      "noCaption": {
+        "en": "No caption. Click to add...",
+        "me": "Nema opisa. Kliknite da dodate...",
+        "ru": "Нет подписи. Нажмите, чтобы добавить...",
+        "ua": "Немає підпису. Натисніть, щоб додати..."
+      }
+    }
+  }
+}

+ 52 - 0
src/locales/translations.admin.json

@@ -201,6 +201,12 @@
         "ru": "Контент",
         "ua": "Контент"
       },
+      "caption": {
+        "en": "Caption",
+        "me": "Potpis",
+        "ru": "Подпись",
+        "ua": "Підпис"
+      },
       "customColorDirInfo": {
         "en": "Custom Color (No directory info)",
         "me": "Prilagođena boja (nema info u direktorijumu)",
@@ -710,6 +716,52 @@
         "ua": "Редагувати користувача"
       }
     },
+    "admin": {
+      "portfolio": {
+        "uploadTitle": {
+          "en": "Add to Portfolio",
+          "me": "Dodaj u portfolio",
+          "ru": "Добавить в портфолио",
+          "ua": "Додати до портфоліо"
+        },
+        "uploadDesc": {
+          "en": "Upload a showcase photo directly. It will be public by default.",
+          "me": "Direktno otpremite fotografiju. Biće javna po podrazumijevanim postavkama.",
+          "ru": "Загрузите фото напрямую. Оно будет публичным по умолчанию.",
+          "ua": "Завантажте фото напряму. Воно буде публічним за замовчуванням."
+        },
+        "uploading": {
+          "en": "Uploading photo...",
+          "me": "Otpremanje fotografije...",
+          "ru": "Загрузка фото...",
+          "ua": "Завантаження фото..."
+        },
+        "uploadSuccess": {
+          "en": "Photo uploaded successfully",
+          "me": "Fotografija je uspješno otpremljena",
+          "ru": "Фото успешно загружено",
+          "ua": "Фото успішно завантажено"
+        },
+        "statusUpdated": {
+          "en": "Visibility updated",
+          "me": "Vidljivost ažurirana",
+          "ru": "Видимость обновлена",
+          "ua": "Видимість оновлена"
+        },
+        "captionUpdated": {
+          "en": "Caption updated",
+          "me": "Opis ažuriran",
+          "ru": "Подпись обновлена",
+          "ua": "Підпис оновлено"
+        },
+        "noCaption": {
+          "en": "No caption. Click to add...",
+          "me": "Nema opisa. Kliknite da dodate...",
+          "ru": "Нет подписи. Нажмите, чтобы добавить...",
+          "ua": "Немає підпису. Натисніть, щоб додати..."
+        }
+      }
+    },
     "questions": {
       "deletePhoto": {
         "en": "Are you sure you want to delete this photo?",

+ 4 - 4
src/locales/translations.user.json

@@ -2910,10 +2910,10 @@
   "seo": {
     "home": {
       "title": {
-        "en": "Radionica 3D | Professional 3D Printing in Montenegro",
-        "me": "Radionica 3D | Profesionalna 3D štampa u Crnoj Gori",
-        "ru": "Radionica 3D | Профессиональная 3D-печать в Черногории",
-        "ua": "Radionica 3D | Професійний 3D-друк у Чорногорії"
+        "en": "Professional 3D Printing in Montenegro",
+        "me": "Profesionalna 3D štampa u Crnoj Gori",
+        "ru": "Профессиональная 3D-печать в Черногории",
+        "ua": "Професійний 3D-друк у Чорногорії"
       },
       "description": {
         "en": "Professional 3D printing and rapid prototyping services in Montenegro. Instant quotes, industrial materials, and high-precision results.",

+ 1 - 0
src/pages/Admin.vue

@@ -115,6 +115,7 @@
           :portfolioItems="portfolioItems"
           :resourcesBaseUrl="RESOURCES_BASE_URL"
           @delete="handleDeletePhoto"
+          @refresh="fetchData"
         />
 
         <AuditSection