浏览代码

feat: integrate interactive 3D scan portfolio and admin uploads

unknown 2 月之前
父节点
当前提交
d2d8a69905

+ 19 - 0
backend/migrations/007_portfolio_scans.sql

@@ -0,0 +1,19 @@
+-- Migration 007: Portfolio 3D Scans
+-- Create table for storing 3D scan bundles for the portfolio section
+
+CREATE TABLE IF NOT EXISTS `portfolio_scans` (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `title_en` varchar(255) NOT NULL,
+  `title_ru` varchar(255) NOT NULL,
+  `title_me` varchar(255) NOT NULL,
+  `title_ua` varchar(255) NOT NULL,
+  `description_en` text DEFAULT NULL,
+  `description_ru` text DEFAULT NULL,
+  `description_me` text DEFAULT NULL,
+  `description_ua` text DEFAULT NULL,
+  `folder_name` varchar(255) NOT NULL,
+  `frames_count` int(11) DEFAULT 48,
+  `is_public` tinyint(1) DEFAULT 1,
+  `created_at` timestamp NULL DEFAULT current_timestamp(),
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

+ 180 - 0
backend/routers/portfolio.py

@@ -1,5 +1,6 @@
 from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File
 from typing import Optional
+from pydantic import BaseModel
 import db
 import schemas
 import auth_utils
@@ -7,6 +8,8 @@ import config
 import os
 import uuid
 import shutil
+import zipfile
+import json
 from services.global_manager import global_manager
 
 from dependencies import require_admin
@@ -14,6 +17,9 @@ from services.audit_service import audit_service
 
 router = APIRouter(tags=["portfolio"])
 
+class ScanUpdate(BaseModel):
+    is_public: bool
+
 @router.get("/portfolio")
 async def get_public_portfolio():
     query = """
@@ -162,3 +168,177 @@ async def admin_delete_photo(photo_id: int, admin: dict = Depends(require_admin)
         await global_manager.notify_order_update(order_info[0]['user_id'], order_id)
 
     return {"id": photo_id, "status": "deleted"}
+
+
+# --- 3D Scan Portfolio Endpoints ---
+
+@router.get("/scans")
+async def get_public_scans():
+    query = """
+    SELECT id, title_en, title_ru, title_me, title_ua,
+           description_en, description_ru, description_me, description_ua,
+           folder_name, frames_count, created_at
+    FROM portfolio_scans
+    WHERE is_public = TRUE
+    ORDER BY created_at DESC
+    """
+    return db.execute_query(query)
+
+@router.get("/admin/scans")
+async def admin_get_all_scans(admin: dict = Depends(require_admin)):
+    query = """
+    SELECT id, title_en, title_ru, title_me, title_ua,
+           description_en, description_ru, description_me, description_ua,
+           folder_name, frames_count, is_public, created_at
+    FROM portfolio_scans
+    ORDER BY created_at DESC
+    """
+    return db.execute_query(query)
+
+@router.post("/admin/scans/upload-bundle")
+async def admin_upload_scan_bundle(
+    file: UploadFile = File(...),
+    admin: dict = Depends(require_admin)
+):
+    if not file.filename or not file.filename.endswith(".zip"):
+        raise HTTPException(status_code=400, detail="Invalid file: must be a ZIP archive")
+        
+    scan_id = str(uuid.uuid4())
+    scans_dir = os.path.join(config.UPLOAD_DIR, "scans")
+    os.makedirs(scans_dir, exist_ok=True)
+    dest_dir = os.path.join(scans_dir, f"scan_{scan_id}")
+    
+    os.makedirs(dest_dir, exist_ok=True)
+    
+    temp_zip_path = os.path.join(dest_dir, "temp_bundle.zip")
+    with open(temp_zip_path, "wb") as buffer:
+        shutil.copyfileobj(file.file, buffer)
+        
+    try:
+        # Extract files
+        with zipfile.ZipFile(temp_zip_path, 'r') as zip_ref:
+            zip_ref.extractall(dest_dir)
+            
+        # Clean up zip
+        if os.path.exists(temp_zip_path):
+            os.remove(temp_zip_path)
+            
+        # Validate metadata.json
+        metadata_path = os.path.join(dest_dir, "metadata.json")
+        if not os.path.exists(metadata_path):
+            raise HTTPException(status_code=400, detail="Invalid bundle: metadata.json is missing")
+            
+        with open(metadata_path, "r", encoding="utf-8") as f:
+            metadata = json.load(f)
+            
+        title_en = metadata.get("title_en")
+        if not title_en:
+            raise HTTPException(status_code=400, detail="Invalid bundle: title_en is required")
+            
+        title_ru = metadata.get("title_ru", "") or title_en
+        title_me = metadata.get("title_me", "") or title_en
+        title_ua = metadata.get("title_ua", "") or title_en
+        
+        description_en = metadata.get("description_en", "")
+        description_ru = metadata.get("description_ru", "") or description_en
+        description_me = metadata.get("description_me", "") or description_en
+        description_ua = metadata.get("description_ua", "") or description_en
+        
+        frames_count = int(metadata.get("frames_count", 48))
+        
+        # Verify frame WebP files
+        for i in range(frames_count):
+            frame_path = os.path.join(dest_dir, f"{i:03d}.webp")
+            if not os.path.exists(frame_path):
+                raise HTTPException(status_code=400, detail=f"Invalid bundle: frame {i:03d}.webp is missing")
+                
+        # Verify thumbnail
+        thumbnail_path = os.path.join(dest_dir, "thumbnail.webp")
+        if not os.path.exists(thumbnail_path):
+            raise HTTPException(status_code=400, detail="Invalid bundle: thumbnail.webp is missing")
+            
+        # Insert DB record
+        query = """
+        INSERT INTO portfolio_scans (
+            title_en, title_ru, title_me, title_ua,
+            description_en, description_ru, description_me, description_ua,
+            folder_name, frames_count, is_public
+        ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+        """
+        db_id = db.execute_commit(query, (
+            title_en, title_ru, title_me, title_ua,
+            description_en, description_ru, description_me, description_ua,
+            f"scan_{scan_id}", frames_count, 1
+        ))
+        
+        await audit_service.log(
+            user_id=admin['id'],
+            action="upload_scan_bundle",
+            target_type="scan",
+            target_id=db_id,
+            details={"title_en": title_en, "folder_name": f"scan_{scan_id}"}
+        )
+        
+        return {
+            "id": db_id,
+            "title_en": title_en,
+            "folder_name": f"scan_{scan_id}",
+            "frames_count": frames_count
+        }
+        
+    except Exception as e:
+        if os.path.exists(dest_dir):
+            shutil.rmtree(dest_dir)
+        if isinstance(e, HTTPException):
+            raise e
+        raise HTTPException(status_code=500, detail=f"Error processing bundle: {str(e)}")
+
+@router.patch("/admin/scans/{scan_id}")
+async def admin_update_scan_status(
+    scan_id: int, 
+    data: ScanUpdate, 
+    admin: dict = Depends(require_admin)
+):
+    scan = db.execute_query("SELECT id FROM portfolio_scans WHERE id = %s", (scan_id,))
+    if not scan:
+        raise HTTPException(status_code=404, detail="Scan not found")
+        
+    db.execute_commit("UPDATE portfolio_scans SET is_public = %s WHERE id = %s", (data.is_public, scan_id))
+    
+    await audit_service.log(
+        user_id=admin['id'],
+        action="update_scan_visibility",
+        target_type="scan",
+        target_id=scan_id,
+        details={"is_public": data.is_public}
+    )
+    
+    return {"id": scan_id, "is_public": data.is_public}
+
+@router.delete("/admin/scans/{scan_id}")
+async def admin_delete_scan(scan_id: int, admin: dict = Depends(require_admin)):
+    scan = db.execute_query("SELECT folder_name, title_en FROM portfolio_scans WHERE id = %s", (scan_id,))
+    if not scan:
+        raise HTTPException(status_code=404, detail="Scan not found")
+        
+    folder_name = scan[0]['folder_name']
+    title_en = scan[0]['title_en']
+    
+    dest_dir = os.path.join(config.UPLOAD_DIR, "scans", folder_name)
+    try:
+        if os.path.exists(dest_dir):
+            shutil.rmtree(dest_dir)
+    except Exception as e:
+        print(f"Error removing scan directory {folder_name}: {e}")
+        
+    db.execute_commit("DELETE FROM portfolio_scans WHERE id = %s", (scan_id,))
+    
+    await audit_service.log(
+        user_id=admin['id'],
+        action="delete_scan_bundle",
+        target_type="scan",
+        target_id=scan_id,
+        details={"title_en": title_en, "folder_name": folder_name}
+    )
+    
+    return {"id": scan_id, "status": "deleted"}

+ 213 - 0
src/components/ThreeSixtyViewer.vue

@@ -0,0 +1,213 @@
+<template>
+  <div class="relative w-full aspect-square bg-[#0b0c10] border border-border/40 rounded-3xl overflow-hidden shadow-2xl flex items-center justify-center select-none group">
+    <!-- Active Image Frame -->
+    <img 
+      v-if="isPreloaded && preloadedImages.length > 0"
+      :src="preloadedImages[currentFrameIndex].src" 
+      class="w-full h-full object-contain pointer-events-none" 
+      alt="3D Scan 360 View"
+    />
+
+    <!-- Preloading overlay -->
+    <div v-if="!isPreloaded" class="absolute inset-0 flex flex-col items-center justify-center bg-[#0b0c10]/95 backdrop-blur-sm z-10 space-y-4">
+      <RefreshCw class="w-8 h-8 text-primary animate-spin" />
+      <div class="text-center space-y-1">
+        <p class="text-xs uppercase font-black tracking-widest text-primary/80">Loading interactive 3D model</p>
+        <p class="text-[10px] text-muted-foreground">{{ loadedFramesCount }} / {{ framesCount }} frames ({{ Math.round((loadedFramesCount / framesCount) * 100) }}%)</p>
+      </div>
+      <div class="w-48 h-1 bg-white/5 rounded-full overflow-hidden border border-white/5">
+        <div class="h-full bg-primary transition-all duration-300 shadow-glow" :style="{ width: `${(loadedFramesCount / framesCount) * 100}%` }" />
+      </div>
+    </div>
+
+    <!-- Drag Rotation Hotspot Overlay -->
+    <div 
+      v-if="isPreloaded" 
+      class="absolute inset-0 cursor-grab active:cursor-grabbing z-20 flex items-center justify-center"
+      @mousedown="onMouseDown"
+      @touchstart="onTouchStart"
+    >
+      <!-- Interaction Hint Overlay (fades out after interactive movement) -->
+      <div 
+        v-if="showHint" 
+        class="absolute bottom-6 left-1/2 -translate-x-1/2 bg-black/80 backdrop-blur-md px-4 py-2 rounded-xl border border-white/10 text-[10px] font-bold text-muted-foreground flex items-center gap-2 pointer-events-none transition-opacity duration-1000"
+        :class="{ 'opacity-0': isInteracted }"
+      >
+        <MoveHorizontal class="w-3.5 h-3.5 text-primary" />
+        <span>Drag horizontally to rotate 3D scan</span>
+      </div>
+    </div>
+
+    <!-- Range Scrub Slider Control (Sleek overlay at the bottom) -->
+    <div 
+      v-if="isPreloaded"
+      class="absolute bottom-4 left-4 right-4 z-30 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity duration-300 bg-black/60 backdrop-blur-md border border-white/10 rounded-2xl p-4 space-y-2 flex flex-col"
+    >
+      <div class="flex justify-between text-[9px] font-bold uppercase tracking-wider text-muted-foreground/80">
+        <span>Manual Rotate</span>
+        <span>Frame {{ currentFrameIndex + 1 }} / {{ framesCount }}</span>
+      </div>
+      <input 
+        type="range" 
+        v-model.number="currentFrameIndex"
+        :min="0"
+        :max="framesCount - 1"
+        class="w-full accent-primary bg-white/10 cursor-pointer h-1 rounded-full outline-none"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, watch } from "vue";
+import { RefreshCw, MoveHorizontal } from "lucide-vue-next";
+
+const props = withDefaults(
+  defineProps<{
+    folderName: string;
+    framesCount?: number;
+    resourcesBaseUrl: string;
+  }>(),
+  {
+    framesCount: 48
+  }
+);
+
+const isPreloaded = ref(false);
+const loadedFramesCount = ref(0);
+const preloadedImages = ref<HTMLImageElement[]>([]);
+const currentFrameIndex = ref(0);
+
+const showHint = ref(true);
+const isInteracted = ref(false);
+
+// Drag state
+let isDragging = false;
+let startX = 0;
+let baseIndex = 0;
+
+// Preload helper
+const startPreloading = () => {
+  isPreloaded.value = false;
+  loadedFramesCount.value = 0;
+  preloadedImages.value = [];
+  currentFrameIndex.value = 0;
+
+  let loaded = 0;
+  const list: HTMLImageElement[] = [];
+
+  for (let i = 0; i < props.framesCount; i++) {
+    const img = new Image();
+    const frameNum = String(i).padStart(3, '0');
+    img.src = `${props.resourcesBaseUrl}/uploads/scans/${props.folderName}/${frameNum}.webp`;
+    
+    img.onload = () => {
+      loaded++;
+      loadedFramesCount.value = loaded;
+      if (loaded === props.framesCount) {
+        preloadedImages.value = list;
+        isPreloaded.value = true;
+        // Fade hint out after 4 seconds if no interaction occurs
+        setTimeout(() => {
+          if (!isInteracted.value) {
+            isInteracted.value = true;
+          }
+        }, 4000);
+      }
+    };
+
+    img.onerror = () => {
+      // Handle failed frames gracefully to prevent locking
+      loaded++;
+      loadedFramesCount.value = loaded;
+      if (loaded === props.framesCount) {
+        preloadedImages.value = list;
+        isPreloaded.value = true;
+      }
+    };
+
+    list.push(img);
+  }
+};
+
+// Mouse Events
+const onMouseDown = (e: MouseEvent) => {
+  isDragging = true;
+  isInteracted.value = true;
+  startX = e.clientX;
+  baseIndex = currentFrameIndex.value;
+  
+  window.addEventListener("mousemove", onMouseMove);
+  window.addEventListener("mouseup", onMouseUp);
+};
+
+const onMouseMove = (e: MouseEvent) => {
+  if (!isDragging) return;
+  const deltaX = e.clientX - startX;
+  // Rotate 1 frame per 10 pixels of horizontal drag
+  const frameShift = Math.floor(deltaX / 10);
+  
+  let targetIndex = baseIndex + frameShift;
+  // Loop index
+  currentFrameIndex.value = ((targetIndex % props.framesCount) + props.framesCount) % props.framesCount;
+};
+
+const onMouseUp = () => {
+  isDragging = false;
+  window.removeEventListener("mousemove", onMouseMove);
+  window.removeEventListener("mouseup", onMouseUp);
+};
+
+// Touch Events
+const onTouchStart = (e: TouchEvent) => {
+  isDragging = true;
+  isInteracted.value = true;
+  startX = e.touches[0].clientX;
+  baseIndex = currentFrameIndex.value;
+  
+  window.addEventListener("touchmove", onTouchMove);
+  window.addEventListener("touchend", onTouchEnd);
+};
+
+const onTouchMove = (e: TouchEvent) => {
+  if (!isDragging) return;
+  const deltaX = e.touches[0].clientX - startX;
+  const frameShift = Math.floor(deltaX / 10);
+  
+  let targetIndex = baseIndex + frameShift;
+  currentFrameIndex.value = ((targetIndex % props.framesCount) + props.framesCount) % props.framesCount;
+};
+
+const onTouchEnd = () => {
+  isDragging = false;
+  window.removeEventListener("touchmove", onTouchMove);
+  window.removeEventListener("touchend", onTouchEnd);
+};
+
+// Watch for folder change to trigger reload
+watch(() => props.folderName, () => {
+  startPreloading();
+}, { immediate: true });
+</script>
+
+<style scoped>
+input[type="range"] {
+  -webkit-appearance: none;
+}
+input[type="range"]::-webkit-slider-thumb {
+  -webkit-appearance: none;
+  height: 12px;
+  width: 12px;
+  border-radius: 50%;
+  background: var(--primary, #66fcf1);
+  box-shadow: 0 0 6px rgba(102, 252, 241, 0.8);
+  cursor: pointer;
+  margin-top: -4px;
+}
+input[type="range"]::-webkit-slider-runnable-track {
+  width: 100%;
+  height: 4px;
+  background: rgba(255, 255, 255, 0.1);
+  border-radius: 2px;
+}
+</style>

+ 180 - 0
src/components/admin/ScansSection.vue

@@ -0,0 +1,180 @@
+<template>
+  <div class="space-y-8">
+    <!-- ZIP 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">
+            <FolderArchive class="w-6 h-6" />
+          </div>
+          <div>
+            <h3 class="text-lg font-bold">Upload 3D Scan Bundle</h3>
+            <p class="text-xs text-muted-foreground">Select a compiled .zip bundle containing the optimized WebP frames, thumbnail, and metadata.</p>
+          </div>
+        </div>
+        <div>
+          <label class="cursor-pointer block">
+            <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" /> Select ZIP Bundle
+            </div>
+            <input type="file" class="hidden" accept=".zip" @change="handleZipUpload" />
+          </label>
+        </div>
+      </div>
+    </div>
+
+    <!-- Loading Indicator -->
+    <div v-if="isLoading" class="flex items-center justify-center py-20">
+      <RefreshCw class="w-8 h-8 text-primary animate-spin" />
+    </div>
+
+    <!-- Scans Grid -->
+    <div v-else-if="scans.length === 0" class="flex flex-col items-center justify-center py-20 bg-card/40 border border-border/50 rounded-3xl opacity-50">
+      <FolderOpen class="w-12 h-12 text-muted-foreground/20 mb-4" />
+      <p class="text-sm text-muted-foreground">No 3D scan bundles uploaded yet.</p>
+    </div>
+
+    <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="scan in scans" :key="scan.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]': !scan.is_public }">
+        
+        <!-- Thumbnail / Interactive Area preview -->
+        <div class="relative aspect-square overflow-hidden bg-black/20">
+          <img :src="`${resourcesBaseUrl}/uploads/scans/${scan.folder_name}/thumbnail.webp`" 
+               class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105" 
+               @error="onImageError" />
+          
+          <!-- Frame Badge / Status Overlay -->
+          <div class="absolute top-3 left-3 flex gap-2">
+             <div v-if="!scan.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">
+                {{ scan.frames_count }} Frames
+             </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(scan)" class="w-10 h-10 rounded-full flex items-center justify-center transition-all border shadow-lg"
+                     :class="scan.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="scan.is_public ? EyeOff : Eye" class="w-5 h-5" />
+             </button>
+             <button @click="deleteScan(scan)" 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 info -->
+        <div class="p-4 flex-1 flex flex-col gap-2">
+          <h4 class="font-bold text-sm truncate">{{ getLocalizedTitle(scan) }}</h4>
+          <p class="text-xs text-muted-foreground line-clamp-3 leading-relaxed">
+            {{ getLocalizedDesc(scan) || 'No description provided.' }}
+          </p>
+          <div class="pt-3 border-t border-border/50 flex justify-between items-center mt-auto">
+             <span class="text-[9px] font-bold text-muted-foreground/45 tracking-tight uppercase">Folder: {{ scan.folder_name }}</span>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from "vue";
+import { useI18n } from "vue-i18n";
+import { 
+  FolderArchive, FolderOpen, RefreshCw, Upload, Eye, EyeOff, Trash2 
+} from "lucide-vue-next";
+import { 
+  adminGetScans, adminUploadScanBundle, adminUpdateScanStatus, adminDeleteScan 
+} from "@/lib/api";
+import { toast } from "vue-sonner";
+
+const props = defineProps<{
+  resourcesBaseUrl: string;
+}>();
+
+const { t, locale } = useI18n();
+
+const isLoading = ref(true);
+const scans = ref<any[]>([]);
+
+const getLocalizedTitle = (scan: any) => {
+  return scan[`title_${locale.value}`] || scan.title_en;
+};
+
+const getLocalizedDesc = (scan: any) => {
+  return scan[`description_${locale.value}`] || scan.description_en;
+};
+
+const fetchScans = async () => {
+  isLoading.value = true;
+  try {
+    scans.value = await adminGetScans();
+  } catch (err: any) {
+    toast.error("Failed to load scans: " + err.message);
+  } finally {
+    isLoading.value = false;
+  }
+};
+
+const handleZipUpload = async (event: Event) => {
+  const file = (event.target as HTMLInputElement).files?.[0];
+  if (!file) return;
+
+  const toastId = toast.loading("Uploading 3D scan bundle ZIP...");
+  try {
+    const fd = new FormData();
+    fd.append("file", file);
+    
+    await adminUploadScanBundle(fd);
+    toast.success("3D scan bundle uploaded and unpacked successfully", { id: toastId });
+    
+    // Clear the input
+    (event.target as HTMLInputElement).value = "";
+    
+    await fetchScans();
+  } catch (err: any) {
+    toast.error(err.message || "Failed to process zip bundle", { id: toastId });
+  }
+};
+
+const toggleVisibility = async (scan: any) => {
+  try {
+    await adminUpdateScanStatus(scan.id, { 
+      is_public: !scan.is_public
+    });
+    toast.success("Scan visibility updated");
+    await fetchScans();
+  } catch (err: any) {
+    toast.error(err.message);
+  }
+};
+
+const deleteScan = async (scan: any) => {
+  if (!confirm(`Are you sure you want to delete scan "${getLocalizedTitle(scan)}"? This will permanently delete all render files.`)) {
+    return;
+  }
+
+  const toastId = toast.loading(`Deleting scan "${getLocalizedTitle(scan)}"...`);
+  try {
+    await adminDeleteScan(scan.id);
+    toast.success("Scan deleted successfully", { id: toastId });
+    await fetchScans();
+  } catch (err: any) {
+    toast.error("Failed to delete scan: " + err.message, { id: toastId });
+  }
+};
+
+const onImageError = (e: Event) => {
+  // Placeholder if thumbnail fails to load
+  (e.target as HTMLImageElement).src = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><rect width='100' height='100' fill='%23111'/><text x='50%' y='50%' dominant-baseline='middle' text-anchor='middle' fill='%23666' font-family='sans-serif' font-size='10'>No Preview</text></svg>";
+};
+
+onMounted(() => {
+  fetchScans();
+});
+</script>

+ 53 - 0
src/lib/api.ts

@@ -805,3 +805,56 @@ export const adminGetWarehouseReport = async () => {
   if (!response.ok) throw new Error("Failed to generate report");
   return response.json();
 };
+
+export const getPublicScans = async () => {
+  const response = await fetch(`${API_BASE_URL}/scans?lang=${i18n.global.locale.value}`);
+  if (!response.ok) throw new Error("Failed to fetch public scans");
+  return response.json();
+};
+
+export const adminGetScans = async () => {
+  const token = localStorage.getItem("token");
+  const response = await fetch(`${API_BASE_URL}/admin/scans?lang=${i18n.global.locale.value}`, {
+    headers: { 'Authorization': `Bearer ${token}` }
+  });
+  if (!response.ok) throw new Error("Failed to fetch admin scans");
+  return response.json();
+};
+
+export const adminUploadScanBundle = async (formData: FormData) => {
+  const token = localStorage.getItem("token");
+  const response = await fetch(`${API_BASE_URL}/admin/scans/upload-bundle?lang=${i18n.global.locale.value}`, {
+    method: 'POST',
+    headers: { 
+      'Authorization': `Bearer ${token}`
+    },
+    body: formData
+  });
+  if (!response.ok) throw new Error("Failed to upload scan bundle");
+  return response.json();
+};
+
+export const adminUpdateScanStatus = async (scanId: number, data: { is_public: boolean }) => {
+  const token = localStorage.getItem("token");
+  const response = await fetch(`${API_BASE_URL}/admin/scans/${scanId}?lang=${i18n.global.locale.value}`, {
+    method: 'PATCH',
+    headers: { 
+      'Content-Type': 'application/json',
+      'Authorization': `Bearer ${token}`
+    },
+    body: JSON.stringify(data)
+  });
+  if (!response.ok) throw new Error("Failed to update scan visibility");
+  return response.json();
+};
+
+export const adminDeleteScan = async (scanId: number) => {
+  const token = localStorage.getItem("token");
+  const response = await fetch(`${API_BASE_URL}/admin/scans/${scanId}?lang=${i18n.global.locale.value}`, {
+    method: 'DELETE',
+    headers: { 'Authorization': `Bearer ${token}` }
+  });
+  if (!response.ok) throw new Error("Failed to delete scan");
+  return response.json();
+};
+

+ 297 - 291
src/locales/translations.admin.json

@@ -7,30 +7,30 @@
         "ru": "Разблокировать аккаунт",
         "ua": "Розблокувати акаунт"
       },
-      "allowChat": {
-        "en": "Allow Chat",
-        "me": "Dozvoli čet",
-        "ru": "Разрешить чат",
-        "ua": "Дозволити чат"
-      },
-      "chatWithClient": {
-        "en": "Chat with Client",
-        "me": "Čat sa klijentom",
-        "ru": "Чат с клиентом",
-        "ua": "Чат з клієнтом"
-      },
       "add": {
         "en": "Add",
         "me": "Dodaj",
         "ru": "Добавить",
         "ua": "Додати"
       },
+      "allowChat": {
+        "en": "Allow Chat",
+        "me": "Dozvoli čet",
+        "ru": "Разрешить чат",
+        "ua": "Дозволити чат"
+      },
       "cancel": {
         "en": "Cancel",
         "me": "Otkaži",
         "ru": "Отмена",
         "ua": "Скасувати"
       },
+      "chatWithClient": {
+        "en": "Chat with Client",
+        "me": "Čat sa klijentom",
+        "ru": "Чат с клиентом",
+        "ua": "Чат з клієнтом"
+      },
       "create": {
         "en": "Create",
         "me": "Kreiraj",
@@ -85,6 +85,12 @@
         "ru": "Печать счета на оплату",
         "ua": "Друк рахунку на оплату"
       },
+      "report": {
+        "en": "Download Report",
+        "me": "Preuzmi izvještaj",
+        "ru": "Скачать отчет",
+        "ua": "Завантажити звіт"
+      },
       "resetPassword": {
         "en": "Reset Password",
         "me": "Resetuj lozinku",
@@ -127,23 +133,17 @@
         "ru": "Переключить роль админа",
         "ua": "Перемкнути роль адміна"
       },
-      "viewOriginal": {
-        "en": "View Original Snapshot",
-        "me": "Vidi originalne parametre",
-        "ru": "Оригинал",
-        "ua": "Оригінал"
-      },
-      "report": {
-        "en": "Download Report",
-        "me": "Preuzmi izvještaj",
-        "ru": "Скачать отчет",
-        "ua": "Завантажити звіт"
-      },
       "upload": {
         "en": "Upload",
         "me": "Otpremi",
         "ru": "Загрузить",
         "ua": "Завантажити"
+      },
+      "viewOriginal": {
+        "en": "View Original Snapshot",
+        "me": "Vidi originalne parametre",
+        "ru": "Оригинал",
+        "ua": "Оригінал"
       }
     },
     "addNew": {
@@ -165,12 +165,6 @@
       "ua": "Дешборд"
     },
     "fields": {
-      "address": {
-        "en": "Address",
-        "me": "Adresa",
-        "ru": "Адрес",
-        "ua": "Адреса"
-      },
       "action": {
         "en": "Action",
         "me": "Akcija",
@@ -183,11 +177,17 @@
         "ru": "Активно",
         "ua": "Активно"
       },
-      "notActive": {
-        "en": "Not Active / Private",
-        "me": "Nije aktivno / Privatno",
-        "ru": "Не активен / Приватный",
-        "ua": "Не активний / Приватний"
+      "address": {
+        "en": "Address",
+        "me": "Adresa",
+        "ru": "Адрес",
+        "ua": "Адреса"
+      },
+      "caption": {
+        "en": "Caption",
+        "me": "Potpis",
+        "ru": "Подпись",
+        "ua": "Підпис"
       },
       "category": {
         "en": "Category",
@@ -195,6 +195,12 @@
         "ru": "Категория",
         "ua": "Категорія"
       },
+      "color": {
+        "en": "Color Name",
+        "me": "Naziv boje",
+        "ru": "Название цвета",
+        "ua": "Назва кольору"
+      },
       "colors": {
         "en": "Available Colors",
         "me": "Dostupne boje",
@@ -207,12 +213,6 @@
         "ru": "Контент",
         "ua": "Контент"
       },
-      "caption": {
-        "en": "Caption",
-        "me": "Potpis",
-        "ru": "Подпись",
-        "ua": "Підпис"
-      },
       "customColorDirInfo": {
         "en": "Custom Color (No directory info)",
         "me": "Prilagođena boja (nema info u direktorijumu)",
@@ -237,6 +237,12 @@
         "ru": "Цвет по умолчанию",
         "ua": "Колір за замовчуванням"
       },
+      "deliveryType": {
+        "en": "Delivery Type",
+        "me": "Tip dostave",
+        "ru": "Тип доставки",
+        "ua": "Тип доставки"
+      },
       "description": {
         "en": "Description",
         "me": "Opis",
@@ -249,11 +255,11 @@
         "ru": "Детали",
         "ua": "Деталі"
       },
-      "deliveryType": {
-        "en": "Delivery Type",
-        "me": "Tip dostave",
-        "ru": "Тип доставки",
-        "ua": "Тип доставки"
+      "discount": {
+        "en": "Volume Discount",
+        "me": "Popust na količinu",
+        "ru": "Скидка за объем",
+        "ua": "Знижка за обсяг"
       },
       "email": {
         "en": "Email Address",
@@ -285,12 +291,6 @@
         "ru": "Финальная цена",
         "ua": "Фінальна ціна"
       },
-      "discount": {
-        "en": "Volume Discount",
-        "me": "Popust na količinu",
-        "ru": "Скидка за объем",
-        "ua": "Знижка за обсяг"
-      },
       "firstName": {
         "en": "First Name",
         "me": "Ime",
@@ -309,6 +309,30 @@
         "ru": "Фамилия",
         "ua": "Прізвище"
       },
+      "longDescEn": {
+        "en": "Detailed Description (EN)",
+        "me": "Detaljan opis (EN)",
+        "ru": "Подробное описание (EN)",
+        "ua": "Детальний опис (EN)"
+      },
+      "longDescMe": {
+        "en": "Detailed Description (ME)",
+        "me": "Detaljan opis (ME)",
+        "ru": "Подробное описание (ME)",
+        "ua": "Детальний опис (ME)"
+      },
+      "longDescRu": {
+        "en": "Detailed Description (RU)",
+        "me": "Detaljan opis (RU)",
+        "ru": "Подробное описание (RU)",
+        "ua": "Детальний опис (RU)"
+      },
+      "longDescUa": {
+        "en": "Detailed Description (UA)",
+        "me": "Detaljan opis (UA)",
+        "ru": "Подробное описание (UA)",
+        "ua": "Детальний опис (UA)"
+      },
       "material": {
         "en": "Material",
         "me": "Materijal",
@@ -327,6 +351,12 @@
         "ru": "Название (EN)",
         "ua": "Назва (EN)"
       },
+      "nameMe": {
+        "en": "Name (ME)",
+        "me": "Naziv (ME)",
+        "ru": "Название (ME)",
+        "ua": "Назва (ME)"
+      },
       "nameRu": {
         "en": "Name (RU)",
         "me": "Naziv (RU)",
@@ -339,36 +369,6 @@
         "ru": "Название (UA)",
         "ua": "Назва (UA)"
       },
-      "nameMe": {
-        "en": "Name (ME)",
-        "me": "Naziv (ME)",
-        "ru": "Название (ME)",
-        "ua": "Назва (ME)"
-      },
-      "longDescEn": {
-        "en": "Detailed Description (EN)",
-        "me": "Detaljan opis (EN)",
-        "ru": "Подробное описание (EN)",
-        "ua": "Детальний опис (EN)"
-      },
-      "longDescMe": {
-        "en": "Detailed Description (ME)",
-        "me": "Detaljan opis (ME)",
-        "ru": "Подробное описание (ME)",
-        "ua": "Детальний опис (ME)"
-      },
-      "longDescRu": {
-        "en": "Detailed Description (RU)",
-        "me": "Detaljan opis (RU)",
-        "ru": "Подробное описание (RU)",
-        "ua": "Детальний опис (RU)"
-      },
-      "longDescUa": {
-        "en": "Detailed Description (UA)",
-        "me": "Detaljan opis (UA)",
-        "ru": "Подробное описание (UA)",
-        "ua": "Детальний опис (UA)"
-      },
       "noPhotos": {
         "en": "No photos yet",
         "me": "Nema fotografija",
@@ -387,6 +387,18 @@
         "ru": "Пользователи не найдены",
         "ua": "Користувачів не знайдено"
       },
+      "notActive": {
+        "en": "Not Active / Private",
+        "me": "Nije aktivno / Privatno",
+        "ru": "Не активен / Приватный",
+        "ua": "Не активний / Приватний"
+      },
+      "notes": {
+        "en": "Notes",
+        "me": "Napomene",
+        "ru": "Заметки",
+        "ua": "Замітки"
+      },
       "notifyUser": {
         "en": "Notify User",
         "me": "Obavijesti korisnika",
@@ -399,6 +411,12 @@
         "ru": "Снимок заказа",
         "ua": "Знімок замовлення"
       },
+      "override": {
+        "en": "Override",
+        "me": "Predefiniši",
+        "ru": "Переопределить",
+        "ua": "Перевизначити"
+      },
       "password": {
         "en": "Password",
         "me": "Lozinka",
@@ -447,18 +465,6 @@
         "ru": "Цена за час",
         "ua": "Ціна за годину"
       },
-      "speedCoeff": {
-        "en": "Time Multiplier (Complexity)",
-        "me": "Koeficijent vremena (složenost)",
-        "ru": "Коэф. времени (сложность)",
-        "ua": "Коеф. часу (складність)"
-      },
-      "speedCoeffShort": {
-        "en": "speed",
-        "me": "brzina",
-        "ru": "скорость",
-        "ua": "швидкість"
-      },
       "pricingFactors": {
         "en": "Pricing Factors",
         "me": "Faktori cijene",
@@ -495,6 +501,12 @@
         "ru": "Строгий выбор материала",
         "ua": "Суворий вибір материала"
       },
+      "selectTechnology": {
+        "en": "Select Technology",
+        "me": "Izaberi tehnologiju",
+        "ru": "Выберите технологию",
+        "ua": "Оберіть технологію"
+      },
       "shippingAddress": {
         "en": "Shipping Address",
         "me": "Adresa za dostavu",
@@ -507,24 +519,6 @@
         "ru": "Slug (URL)",
         "ua": "Slug (URL)"
       },
-      "color": {
-        "en": "Color Name",
-        "me": "Naziv boje",
-        "ru": "Название цвета",
-        "ua": "Назва кольору"
-      },
-      "notes": {
-        "en": "Notes",
-        "me": "Napomene",
-        "ru": "Заметки",
-        "ua": "Замітки"
-      },
-      "status": {
-        "en": "Status",
-        "me": "Status",
-        "ru": "Статус",
-        "ua": "Статус"
-      },
       "snapshotInfo": {
         "en": "These are the parameters recorded at the moment of order submission.",
         "me": "Ovo su parametri zabilježeni u trenutku slanja narudžbe.",
@@ -537,6 +531,24 @@
         "ru": "Исходные файлы",
         "ua": "Вихідні файли"
       },
+      "speedCoeff": {
+        "en": "Time Multiplier (Complexity)",
+        "me": "Koeficijent vremena (složenost)",
+        "ru": "Коэф. времени (сложность)",
+        "ua": "Коеф. часу (складність)"
+      },
+      "speedCoeffShort": {
+        "en": "speed",
+        "me": "brzina",
+        "ru": "скорость",
+        "ua": "швидкість"
+      },
+      "status": {
+        "en": "Status",
+        "me": "Status",
+        "ru": "Статус",
+        "ua": "Статус"
+      },
       "strictSelectionInfo": {
         "en": "Prices and options are derived strictly from the catalog.",
         "me": "Cijene i opcije se izvode strogo iz kataloga.",
@@ -561,47 +573,23 @@
         "ru": "Технология",
         "ua": "Технологія"
       },
-      "selectTechnology": {
-        "en": "Select Technology",
-        "me": "Izaberi tehnologiju",
-        "ru": "Выберите технологию",
-        "ua": "Оберіть технологію"
-      },
-      "override": {
-        "en": "Override",
-        "me": "Predefiniši",
-        "ru": "Переопределить",
-        "ua": "Перевизначити"
-      },
       "timestamp": {
         "en": "Timestamp",
         "me": "Vrijeme",
         "ru": "Время",
         "ua": "Час"
       },
-      "totalPrice": {
-        "en": "Total Price",
-        "me": "Ukupna cijena",
-        "ru": "Итоговая цена",
-        "ua": "Загальна ціна"
-      },
       "title": {
         "en": "Title",
         "me": "Naslov",
         "ru": "Заголовок",
         "ua": "Заголовок"
       },
-      "updateFinalPrice": {
-        "en": "Update Final Price",
-        "me": "Ažuriraj konačnu cijenu",
-        "ru": "Обновить итоговую цену",
-        "ua": "Оновити підсумкову ціну"
-      },
-      "user": {
-        "en": "User",
-        "me": "Korisnik",
-        "ru": "Пользователь",
-        "ua": "Користувач"
+      "totalPrice": {
+        "en": "Total Price",
+        "me": "Ukupna cijena",
+        "ru": "Итоговая цена",
+        "ua": "Загальна ціна"
       },
       "unitMass": {
         "en": "Unit Mass (kg)",
@@ -614,6 +602,18 @@
         "me": "Broj komada",
         "ru": "Кол-во штук",
         "ua": "Кіл-ть штук"
+      },
+      "updateFinalPrice": {
+        "en": "Update Final Price",
+        "me": "Ažuriraj konačnu cijenu",
+        "ru": "Обновить итоговую цену",
+        "ua": "Оновити підсумкову ціну"
+      },
+      "user": {
+        "en": "User",
+        "me": "Korisnik",
+        "ru": "Пользователь",
+        "ua": "Користувач"
       }
     },
     "filters": {
@@ -629,35 +629,29 @@
       "ua": "Від"
     },
     "labels": {
-      "actions": {
-        "en": "Actions",
-        "me": "Akcije",
-        "ru": "Действия",
-        "ua": "Дії"
-      },
       "action": {
         "en": "Action",
         "me": "Akcija",
         "ru": "Действие",
         "ua": "Дія"
       },
+      "actions": {
+        "en": "Actions",
+        "me": "Akcije",
+        "ru": "Действия",
+        "ua": "Дії"
+      },
       "chat": {
         "en": "Chat",
         "me": "Čat",
         "ru": "Чат",
         "ua": "Чат"
       },
-      "editOrder": {
-        "en": "Edit Order",
-        "me": "Uredi narudžbu",
-        "ru": "Редактировать заказ",
-        "ua": "Редагувати замовлення"
-      },
-      "reviewContent": {
-        "en": "Client Review Content",
-        "me": "Sadržaj recenzije",
-        "ru": "Текст отзыва",
-        "ua": "Текст відгуку"
+      "contact": {
+        "en": "Contact",
+        "me": "Kontakt",
+        "ru": "Контакт",
+        "ua": "Контакт"
       },
       "current": {
         "en": "Current",
@@ -665,18 +659,6 @@
         "ru": "Текущий",
         "ua": "Поточний"
       },
-      "noFiles": {
-        "en": "No files attached",
-        "me": "Nema zakačenih fajlova",
-        "ru": "Файлы не прикреплены",
-        "ua": "Файли не додані"
-      },
-      "noPhotos": {
-        "en": "No photos uploaded",
-        "me": "Nema otpremljenih fotografija",
-        "ru": "Фотографии не загружены",
-        "ua": "Фотографії не завантажені"
-      },
       "date": {
         "en": "Date",
         "me": "Datum",
@@ -689,23 +671,35 @@
         "ru": "Детали",
         "ua": "Деталі"
       },
+      "editOrder": {
+        "en": "Edit Order",
+        "me": "Uredi narudžbu",
+        "ru": "Редактировать заказ",
+        "ua": "Редагувати замовлення"
+      },
       "fiscalization": {
         "en": "Fiscalization",
         "me": "Fiskalizacija",
         "ru": "Фискализация",
         "ua": "Фіскалізація"
       },
-      "target": {
-        "en": "Target",
-        "me": "Cilj",
-        "ru": "Объект",
-        "ua": "Об'єкт"
+      "hidden": {
+        "en": "Hidden",
+        "me": "Skriveno",
+        "ru": "Скрыто",
+        "ua": "Приховано"
       },
-      "contact": {
-        "en": "Contact",
-        "me": "Kontakt",
-        "ru": "Контакт",
-        "ua": "Контакт"
+      "noFiles": {
+        "en": "No files attached",
+        "me": "Nema zakačenih fajlova",
+        "ru": "Файлы не прикреплены",
+        "ua": "Файли не додані"
+      },
+      "noPhotos": {
+        "en": "No photos uploaded",
+        "me": "Nema otpremljenih fotografija",
+        "ru": "Фотографии не загружены",
+        "ua": "Фотографії не завантажені"
       },
       "registered": {
         "en": "Registered",
@@ -713,23 +707,29 @@
         "ru": "Зарегистрирован",
         "ua": "Зареєстрований"
       },
+      "reviewContent": {
+        "en": "Client Review Content",
+        "me": "Sadržaj recenzije",
+        "ru": "Текст отзыва",
+        "ua": "Текст відгуку"
+      },
       "role": {
         "en": "Role",
         "me": "Uloga",
         "ru": "Роль",
         "ua": "Роль"
       },
+      "target": {
+        "en": "Target",
+        "me": "Cilj",
+        "ru": "Объект",
+        "ua": "Об'єкт"
+      },
       "user": {
         "en": "User",
         "me": "Korisnik",
         "ru": "Пользователь",
         "ua": "Користувач"
-      },
-      "hidden": {
-        "en": "Hidden",
-        "me": "Skriveno",
-        "ru": "Скрыто",
-        "ua": "Приховано"
       }
     },
     "managementCenter": {
@@ -739,24 +739,18 @@
       "ua": "Центр управління"
     },
     "modals": {
-      "changeParams": {
-        "en": "Change Material & Color",
-        "me": "Promijeni materijal i boju",
-        "ru": "Изменить материал и цвет",
-        "ua": "Змінити матеріал та колір"
-      },
-      "editStock": {
-        "en": "Edit Stock Item",
-        "me": "Uredi stavku skladišta",
-        "ru": "Редактировать остаток",
-        "ua": "Редагувати залишок"
-      },
       "addStock": {
         "en": "Add Stock Item",
         "me": "Dodaj stavku na sklad",
         "ru": "Добавить на склад",
         "ua": "Додати на склад"
       },
+      "changeParams": {
+        "en": "Change Material & Color",
+        "me": "Promijeni materijal i boju",
+        "ru": "Изменить материал и цвет",
+        "ua": "Змінити матеріал та колір"
+      },
       "createMaterial": {
         "en": "Create New Material",
         "me": "Novi materijal",
@@ -799,6 +793,12 @@
         "ru": "Редактировать услугу",
         "ua": "Редагувати послугу"
       },
+      "editStock": {
+        "en": "Edit Stock Item",
+        "me": "Uredi stavku skladišta",
+        "ru": "Редактировать остаток",
+        "ua": "Редагувати залишок"
+      },
       "editUser": {
         "en": "Edit User",
         "me": "Uredi korisnika",
@@ -807,11 +807,23 @@
       }
     },
     "portfolio": {
-      "uploadTitle": {
-        "en": "Add to Portfolio",
-        "me": "Dodaj u portfolio",
-        "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": "Немає підпису. Натисніть, щоб додати..."
+      },
+      "statusUpdated": {
+        "en": "Visibility updated",
+        "me": "Vidljivost ažurirana",
+        "ru": "Видимость обновлена",
+        "ua": "Видимість оновлена"
       },
       "uploadDesc": {
         "en": "Upload a showcase photo directly. It will be public by default.",
@@ -819,35 +831,23 @@
         "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": "Підпис оновлено"
+      "uploadTitle": {
+        "en": "Add to Portfolio",
+        "me": "Dodaj u portfolio",
+        "ru": "Добавить в портфолио",
+        "ua": "Додати до портфоліо"
       },
-      "noCaption": {
-        "en": "No caption. Click to add...",
-        "me": "Nema opisa. Kliknite da dodate...",
-        "ru": "Нет подписи. Нажмите, чтобы добавить...",
-        "ua": "Немає підпису. Натисніть, щоб додати..."
+      "uploading": {
+        "en": "Uploading photo...",
+        "me": "Otpremanje fotografije...",
+        "ru": "Загрузка фото...",
+        "ua": "Завантаження фото..."
       }
     },
     "questions": {
@@ -871,23 +871,11 @@
       "ua": "Скинути"
     },
     "reviews": {
-      "title": {
-        "en": "Review Management",
-        "me": "Upravljanje recenzijama",
-        "ru": "Управление отзывами",
-        "ua": "Управління відгуками"
-      },
-      "total": {
-        "en": "Total Reviews",
-        "me": "Ukupno recenzija",
-        "ru": "Всего отзывов",
-        "ua": "Всього відгуків"
-      },
-      "noReviews": {
-        "en": "No reviews yet",
-        "me": "Još nema recenzija",
-        "ru": "Отзывов пока нет",
-        "ua": "Відгуків поки немає"
+      "approve": {
+        "en": "Approve",
+        "me": "Odobri",
+        "ru": "Одобрить",
+        "ua": "Схвалити"
       },
       "approved": {
         "en": "Approved",
@@ -895,17 +883,23 @@
         "ru": "Одобрен",
         "ua": "Схвалено"
       },
-      "pending": {
-        "en": "Pending",
-        "me": "Na čekanju",
-        "ru": "Ожидает",
-        "ua": "Очікує"
+      "approvedSuccess": {
+        "en": "Review approved",
+        "me": "Recenzija odobrena",
+        "ru": "Отзыв одобрен",
+        "ua": "Відгук схвалено"
       },
-      "approve": {
-        "en": "Approve",
-        "me": "Odobri",
-        "ru": "Одобрить",
-        "ua": "Схвалити"
+      "confirmDelete": {
+        "en": "Are you sure you want to delete this review?",
+        "me": "Da li ste sigurni da želite obrisati ovu recenziju?",
+        "ru": "Вы уверены, что хотите удалить этот отзыв?",
+        "ua": "Ви впевнені, що хочете видалити цей відгук?"
+      },
+      "content": {
+        "en": "Content",
+        "me": "Sadržaj",
+        "ru": "Текст отзыва",
+        "ua": "Текст відгуку"
       },
       "delete": {
         "en": "Delete",
@@ -913,17 +907,11 @@
         "ru": "Удалить",
         "ua": "Видалити"
       },
-      "hide": {
-        "en": "Hide",
-        "me": "Sakrij",
-        "ru": "Скрыть",
-        "ua": "Приховати"
-      },
-      "approvedSuccess": {
-        "en": "Review approved",
-        "me": "Recenzija odobrena",
-        "ru": "Отзыв одобрен",
-        "ua": "Відгук схвалено"
+      "deletedSuccess": {
+        "en": "Review deleted",
+        "me": "Recenzija obrisana",
+        "ru": "Отзыв удален",
+        "ua": "Відгук видалено"
       },
       "hiddenSuccess": {
         "en": "Review hidden from public view",
@@ -931,17 +919,23 @@
         "ru": "Отзыв скрыт от публикации",
         "ua": "Відгук приховано"
       },
-      "deletedSuccess": {
-        "en": "Review deleted",
-        "me": "Recenzija obrisana",
-        "ru": "Отзыв удален",
-        "ua": "Відгук видалено"
+      "hide": {
+        "en": "Hide",
+        "me": "Sakrij",
+        "ru": "Скрыть",
+        "ua": "Приховати"
       },
-      "confirmDelete": {
-        "en": "Are you sure you want to delete this review?",
-        "me": "Da li ste sigurni da želite obrisati ovu recenziju?",
-        "ru": "Вы уверены, что хотите удалить этот отзыв?",
-        "ua": "Ви впевнені, що хочете видалити цей відгук?"
+      "noReviews": {
+        "en": "No reviews yet",
+        "me": "Još nema recenzija",
+        "ru": "Отзывов пока нет",
+        "ua": "Відгуків поки немає"
+      },
+      "pending": {
+        "en": "Pending",
+        "me": "Na čekanju",
+        "ru": "Ожидает",
+        "ua": "Очікує"
       },
       "rating": {
         "en": "Rating",
@@ -949,17 +943,23 @@
         "ru": "Рейтинг",
         "ua": "Рейтинг"
       },
-      "content": {
-        "en": "Content",
-        "me": "Sadržaj",
-        "ru": "Текст отзыва",
-        "ua": "Текст відгуку"
-      },
       "status": {
         "en": "Status",
         "me": "Status",
         "ru": "Статус",
         "ua": "Статус"
+      },
+      "title": {
+        "en": "Review Management",
+        "me": "Upravljanje recenzijama",
+        "ru": "Управление отзывами",
+        "ua": "Управління відгуками"
+      },
+      "total": {
+        "en": "Total Reviews",
+        "me": "Ukupno recenzija",
+        "ru": "Всего отзывов",
+        "ua": "Всього відгуків"
       }
     },
     "searchPlaceholder": {
@@ -1011,18 +1011,24 @@
         "ru": "Блог",
         "ua": "Блог"
       },
-      "services": {
-        "en": "Services",
-        "me": "Usluge",
-        "ru": "Услуги",
-        "ua": "Послуги"
-      },
       "reviews": {
         "en": "Reviews",
         "me": "Recenzije",
         "ru": "Отзывы",
         "ua": "Відгуки"
       },
+      "scans": {
+        "en": "3D Scans",
+        "me": "3D Skenovi",
+        "ru": "3D Сканы",
+        "ua": "3D Скани"
+      },
+      "services": {
+        "en": "Services",
+        "me": "Usluge",
+        "ru": "Услуги",
+        "ua": "Послуги"
+      },
       "users": {
         "en": "Users",
         "me": "Korisnici",
@@ -1189,17 +1195,17 @@
       "ua": "Всього"
     },
     "warehouse": {
-      "noStockFound": {
-        "en": "No stock records found",
-        "me": "Nisu pronađeni zapisi o zalihama",
-        "ru": "Остатки не найдены",
-        "ua": "Залишки не знайдені"
-      },
       "noMaterials": {
         "en": "First, add materials to the catalog to manage stock",
         "me": "Prvo dodajte materijale u katalog da biste upravljali zalihama",
         "ru": "Сначала добавьте материалы в каталог",
         "ua": "Спочатку додайте материалы до каталогу"
+      },
+      "noStockFound": {
+        "en": "No stock records found",
+        "me": "Nisu pronađeni zapisi o zalihama",
+        "ru": "Остатки не найдены",
+        "ua": "Залишки не знайдені"
       }
     }
   }

+ 261 - 241
src/locales/translations.user.json

@@ -351,12 +351,6 @@
       "ua": "Студія 3D Друку"
     },
     "toasts": {
-      "reviewApproved": {
-        "en": "Review approved and published",
-        "me": "Recenzija odobrena i objavljena",
-        "ru": "Отзыв одобрен и опубликован",
-        "ua": "Відгук схвалено та опубліковано"
-      },
       "accountCreated": {
         "en": "Account created!",
         "me": "Nalog je kreiran!",
@@ -369,24 +363,18 @@
         "ru": "Пожалуйста, проверьте свою электронную почту, чтобы подтвердить свой аккаунт.",
         "ua": "Будь ласка, перевірте свою електронну пошту, щоб підтвердити свій обліковий запис."
       },
-      "linkExpired": {
-        "en": "Reset link has expired or is invalid.",
-        "me": "Link za resetovanje lozinke je istekao ili je nevažeći.",
-        "ru": "Ссылка для сброса пароля истекла или недействительна.",
-        "ua": "Посилання для скидання пароля закінчилося або є недійсним."
-      },
-      "verificationFailed": {
-        "en": "Email verification failed.",
-        "me": "Verifikacija email-a nije uspjela.",
-        "ru": "Ошибка верификации email.",
-        "ua": "Помилка верифікації email."
-      },
       "googleLoginFailed": {
         "en": "Google Login failed.",
         "me": "Google prijava nije uspjela.",
         "ru": "Ошибка входа через Google.",
         "ua": "Помилка входу через Google."
       },
+      "linkExpired": {
+        "en": "Reset link has expired or is invalid.",
+        "me": "Link za resetovanje lozinke je istekao ili je nevažeći.",
+        "ru": "Ссылка для сброса пароля истекла или недействительна.",
+        "ua": "Посилання для скидання пароля закінчилося або є недійсним."
+      },
       "passwordChanged": {
         "en": "Password changed successfully!",
         "me": "Lozinka uspješno promijenjena!",
@@ -405,12 +393,24 @@
         "ru": "Ссылка на сброс пароля отправлена на почту.",
         "ua": "Посилання на скидання пароля надіслано на пошту."
       },
+      "reviewApproved": {
+        "en": "Review approved and published",
+        "me": "Recenzija odobrena i objavljena",
+        "ru": "Отзыв одобрен и опубликован",
+        "ua": "Відгук схвалено та опубліковано"
+      },
       "socialSoon": {
         "en": "{provider} login coming soon!",
         "me": "{provider} prijava stiže uskoro!",
         "ru": "Вход через {provider} скоро появится!",
         "ua": "Вхід через {provider} скоро з'явиться!"
       },
+      "verificationFailed": {
+        "en": "Email verification failed.",
+        "me": "Verifikacija email-a nije uspjela.",
+        "ru": "Ошибка верификации email.",
+        "ua": "Помилка верифікації email."
+      },
       "verified": {
         "en": "Account verified! You can now log in.",
         "me": "Nalog je potvrđen! Sada se možete prijaviti.",
@@ -836,17 +836,23 @@
       "ru": "Поддержка",
       "ua": "Підтримка"
     },
+    "delete": {
+      "en": "Delete",
+      "me": "Izbriši",
+      "ru": "Удалить",
+      "ua": "Видалити"
+    },
     "empty": {
       "en": "No messages yet. Start a conversation!",
       "me": "Još nema poruka. Započnite razgovor!",
       "ru": "Сообщений пока нет. Начните диалог!",
       "ua": "Повідомлень поки що немає. Почніть діалог!"
     },
-    "delete": {
-      "en": "Delete",
-      "me": "Izbriši",
-      "ru": "Удалить",
-      "ua": "Видалити"
+    "new_status": {
+      "en": "Order #{id} status updated",
+      "me": "Narudžba #{id} status je ažuriran",
+      "ru": "Статус заказа #{id} обновлен",
+      "ua": "Статус замовлення #{id} оновлено"
     },
     "open": {
       "en": "Chat",
@@ -860,12 +866,6 @@
       "ru": "Напишите сообщение...",
       "ua": "Напишіть повідомлення..."
     },
-    "new_status": {
-      "en": "Order #{id} status updated",
-      "me": "Narudžba #{id} status je ažuriran",
-      "ru": "Статус заказа #{id} обновлен",
-      "ua": "Статус замовлення #{id} оновлено"
-    },
     "title": {
       "en": "Order Chat",
       "me": "Čat za narudžbu",
@@ -2163,18 +2163,18 @@
   },
   "home": {
     "reviews": {
-      "title": {
-        "en": "Client Experiences",
-        "me": "Iskustva klijenata",
-        "ru": "Отзывы наших клиентов",
-        "ua": "Відгуки наших клієнтів"
-      },
       "subtitle": {
         "en": "Discover what our customers say about our print quality and reliability.",
         "me": "Saznajte što naši klijenti misle o kvalitetu i pouzdanosti naše štampe.",
         "ru": "Узнайте, что наши клиенты думают о качестве нашей печати и надежности сервиса.",
         "ua": "Дізнайтеся, що наші клієнти думають про якість нашого друку та надійність сервісу."
       },
+      "title": {
+        "en": "Client Experiences",
+        "me": "Iskustva klijenata",
+        "ru": "Отзывы наших клиентов",
+        "ua": "Відгуки наших клієнтів"
+      },
       "verifiedCustomer": {
         "en": "Verified Customer",
         "me": "Verifikovani kupac",
@@ -2196,6 +2196,12 @@
       "ru": "Панель управления",
       "ua": "Панель управління"
     },
+    "changeLanguage": {
+      "en": "Change Language",
+      "me": "Promijeni jezik",
+      "ru": "Сменить язык",
+      "ua": "Змінити мову"
+    },
     "howItWorks": {
       "en": "How it works",
       "me": "Kako to funkcioniše",
@@ -2256,17 +2262,11 @@
       "ru": "Регистрация",
       "ua": "Реєстрація"
     },
-    "toggleMenu": {
-      "en": "Toggle Menu",
-      "me": "Otvori/Zatvori meni",
-      "ru": "Открыть/Закрыть меню",
-      "ua": "Відкрити/Закрити меню"
-    },
-    "changeLanguage": {
-      "en": "Change Language",
-      "me": "Promijeni jezik",
-      "ru": "Сменить язык",
-      "ua": "Змінити мову"
+    "scanning": {
+      "en": "3D Scanning",
+      "me": "3D Skeniranje",
+      "ru": "3D Сканирование",
+      "ua": "3D Сканування"
     },
     "services": {
       "en": "Services",
@@ -2274,6 +2274,12 @@
       "ru": "Услуги",
       "ua": "Послуги"
     },
+    "toggleMenu": {
+      "en": "Toggle Menu",
+      "me": "Otvori/Zatvori meni",
+      "ru": "Открыть/Закрыть меню",
+      "ua": "Відкрити/Закрити меню"
+    },
     "unreadMessages": {
       "en": "Unread Messages",
       "me": "Nepročitane poruke",
@@ -2285,12 +2291,6 @@
       "me": "Nepročitane poruke u chatu",
       "ru": "У вас есть непрочитанные сообщения",
       "ua": "У вас є непрочитані повідомлення"
-    },
-    "scanning": {
-      "en": "3D Scanning",
-      "me": "3D Skeniranje",
-      "ru": "3D Сканирование",
-      "ua": "3D Сканування"
     }
   },
   "nuances": {
@@ -2553,18 +2553,18 @@
         "ru": "Кол-во",
         "ua": "К-сть"
       },
-      "status": {
-        "en": "Status",
-        "me": "Status",
-        "ru": "Статус",
-        "ua": "Статус"
-      },
       "specification": {
         "en": "Specification",
         "me": "Specifikacija",
         "ru": "Спецификация",
         "ua": "Специфікація"
       },
+      "status": {
+        "en": "Status",
+        "me": "Status",
+        "ru": "Статус",
+        "ua": "Статус"
+      },
       "total": {
         "en": "Total",
         "me": "Ukupno",
@@ -2590,24 +2590,18 @@
       "ru": "Заказов пока нет",
       "ua": "Замовлення поки відсутні"
     },
-    "startProject": {
-      "en": "Start New Project",
-      "me": "Započni novi projekat",
-      "ru": "Создать новый проект",
-      "ua": "Створити новий проєкт"
-    },
-    "startProjectDesc": {
-      "en": "Once you start a 3D printing project, you'll be able to track its progress right here.",
-      "me": "Kada započnete projekat 3D štampe, ovdje ćete moći pratiti njegov napredak.",
-      "ru": "Как только вы создадите проект на 3D-печать, вы сможете отслеживать его прогресс здесь.",
-      "ua": "Як тільки ви створите проєкт на 3D-друк, ви зможете відстежувати його прогрес тут."
-    },
     "review": {
-      "writeTitle": {
-        "en": "Share Your Experience",
-        "me": "Podijelite Vaše iskustvo",
-        "ru": "Поделитесь впечатлениями",
-        "ua": "Поділіться вашими враженнями"
+      "approved": {
+        "en": "Public Review",
+        "me": "Javna recenzija",
+        "ru": "Опубликован",
+        "ua": "Опубліковано"
+      },
+      "pending": {
+        "en": "Pending Approval",
+        "me": "Na čekanju",
+        "ru": "На модерации",
+        "ua": "На модерації"
       },
       "placeholder": {
         "en": "How was the print quality? Was the delivery on time?",
@@ -2627,19 +2621,25 @@
         "ru": "Спасибо за ваш отзыв!",
         "ua": "Дякуємо за ваш відгук!"
       },
-      "pending": {
-        "en": "Pending Approval",
-        "me": "Na čekanju",
-        "ru": "На модерации",
-        "ua": "На модерації"
-      },
-      "approved": {
-        "en": "Public Review",
-        "me": "Javna recenzija",
-        "ru": "Опубликован",
-        "ua": "Опубліковано"
+      "writeTitle": {
+        "en": "Share Your Experience",
+        "me": "Podijelite Vaše iskustvo",
+        "ru": "Поделитесь впечатлениями",
+        "ua": "Поділіться вашими враженнями"
       }
     },
+    "startProject": {
+      "en": "Start New Project",
+      "me": "Započni novi projekat",
+      "ru": "Создать новый проект",
+      "ua": "Створити новий проєкт"
+    },
+    "startProjectDesc": {
+      "en": "Once you start a 3D printing project, you'll be able to track its progress right here.",
+      "me": "Kada započnete projekat 3D štampe, ovdje ćete moći pratiti njegov napredak.",
+      "ru": "Как только вы создадите проект на 3D-печать, вы сможете отслеживать его прогресс здесь.",
+      "ua": "Як тільки ви створите проєкт на 3D-друк, ви зможете відстежувати його прогрес тут."
+    },
     "titleSubtitle": {
       "en": "Track your 3D printing projects",
       "me": "Pratite tvoje projekte 3D štampe",
@@ -2970,11 +2970,39 @@
     }
   },
   "scanning": {
-    "title": {
-      "en": "Digital Twin of Your Object",
-      "me": "Digitalni blizanac vašeg objekta",
-      "ru": "Цифровой двойник вашего объекта",
-      "ua": "Цифровий двійник вашого об'єкта"
+    "cta": {
+      "button": {
+        "en": "Discuss Project",
+        "me": "Razgovarajte o projektu",
+        "ru": "Обсудить проект",
+        "ua": "Обговорити проект"
+      },
+      "text": {
+        "en": "Need a precise copy or a 3D model of an object? We'll help bring your project to life.",
+        "me": "Treba li vam precizna kopija ili 3D model objekta? Pomoći ćemo vam da realizujete vaš projekat.",
+        "ru": "Нужна точная копия или 3D-модель объекта? Мы поможем реализовать ваш проект.",
+        "ua": "Потрібна точна копія або 3D-модель об'єкта? Ми допоможемо реалізувати ваш проект."
+      }
+    },
+    "portfolio": {
+      "empty": {
+        "en": "No 3D scan showcases published yet.",
+        "me": "Galerija 3D skenova je trenutno prazna.",
+        "ru": "Галерея 3D-сканов пока не заполнена.",
+        "ua": "Галерея 3D-сканів поки що порожня."
+      },
+      "subtitle": {
+        "en": "Drag to spin and inspect these real-world objects digitized by our industrial 3D scanners.",
+        "me": "Prevucite prstom ili mišem horizontalno da biste rotirali i detaljno pregledali stvarne objekte digitalizovane našim 3D skenerima.",
+        "ru": "Зажмите левую кнопку мыши (или проведите пальцем) и двигайте в стороны, чтобы вращать и детально рассматривать реальные объекты, оцифрованные нашими 3D-сканерами.",
+        "ua": "Затисніть ліву кнопку миші (або проведіть пальцем) і рухайте в сторони, щоб обертати та детально розглядати реальні об'єкти, оцифровані нашими 3D-сканерами."
+      },
+      "title": {
+        "en": "Interactive 3D Scan Portfolio",
+        "me": "Interaktivni 3D portfolio skenova",
+        "ru": "Интерактивное 3D-портфолио сканов",
+        "ua": "Інтерактивне 3D-портфоліо сканів"
+      }
     },
     "subtitle": {
       "en": "Transfer real-world objects into the digital realm with precision up to 0.02 mm. Professional solution for maritime parts restoration, reverse engineering, and 3D printing.",
@@ -2982,144 +3010,114 @@
       "ru": "Переносим реальные объекты в цифровой мир с точностью до 0.02 мм. Профессиональное решение для восстановления деталей морского транспорта, реверс-инжиниринга и 3D-печати.",
       "ua": "Переносимо реальні об'єкти в цифровий світ з точністю до 0.02 мм. Професійне рішення для відновлення деталей морського транспорта, реверс-інжинірингу та 3D-друку."
     },
-    "workflow": {
-      "title": {
-        "en": "The Path from Object to Finished Product",
-        "me": "Put od objekta do gotovog proizvoda",
-        "ru": "Путь от объекта к готовому изделию",
-        "ua": "Шлях від об'єкта до готового виробу"
-      },
-      "scan": {
-        "title": {
-          "en": "High-Precision Scanning",
-          "me": "Visokoprecizno skeniranje",
-          "ru": "Высокоточное сканирование",
-          "ua": "Високоточне сканування"
-        },
+    "title": {
+      "en": "Digital Twin of Your Object",
+      "me": "Digitalni blizanac vašeg objekta",
+      "ru": "Цифровой двойник вашего объекта",
+      "ua": "Цифровий двійник вашого об'єкта"
+    },
+    "useCases": {
+      "archiving": {
         "description": {
-          "en": "Create a detailed digital copy of objects of any complexity — from small mechanisms to interior elements and ship hulls using professional equipment.",
-          "me": "Kreirajte detaljnu digitalnu kopiju objekata bilo koje složenosti — od malih mehanizama do elemenata enterijera i trupa plovila koristeći profesionalnu opremu.",
-          "ru": "Создаем детальную цифровую копию объекта любой сложности — от небольших механизмов до элементов интерьера и корпуса судна с использованием профессионального оборудования.",
-          "ua": "Створюємо детальну цифрову копію об'єкта будь-якої складності — від невеликих механізмів до елементів інтер'єру та корпусу судна з використанням професійного обладнання."
-        }
-      },
-      "reverse": {
-        "title": {
-          "en": "Reverse Engineering",
-          "me": "Reverzni inženjering",
-          "ru": "Реверс-инжиниринг",
-          "ua": "Реверс-інжиніринг"
+          "en": "Preserve precise copies of valuable items, tools, or prototypes in digital format.",
+          "me": "Sačuvajte precizne kopije vrijednih predmeta, alata ili prototipova u digitalnom formatu.",
+          "ru": "Сохранение точных копий ценных предметов, инструментов или прототипов в цифровом виде.",
+          "ua": "Збереження точних копій цінних предметів, інструментів або прототипів у цифровому вигляді."
         },
-        "description": {
-          "en": "Convert scanning results into a full CAD 3D model, ready for modifications and production.",
-          "me": "Pretvorite rezultate skeniranja u punopravni CAD 3D model, spreman za modifikacije i proizvodnju.",
-          "ru": "Превращаем результат сканирования в полноценную конструкторскую 3D-модель (CAD), готовую к правкам и производству.",
-          "ua": "Перетворюємо результат сканування в повноцінну конструкторську 3D-модель (CAD), готову до правок і виробництва."
-        }
-      },
-      "print": {
         "title": {
-          "en": "3D Printing a Copy",
-          "me": "3D štampa kopije",
-          "ru": "3D-печать копии",
-          "ua": "3D-друк копії"
-        },
-        "description": {
-          "en": "Print the finished model using durable, marine-grade plastics or photopolymer resins.",
-          "me": "Odštampajte gotov model koristeći izdržljivu plastiku otpornu na morsku sredinu ili fotopolimerne smole.",
-          "ru": "Печатаем готовую модель прочным, устойчивым к морской среде пластиком или фотополимерной смолой.",
-          "ua": "Друкуємо готову модель міцним, стійким до морського середовища пластиком або фотополімерною смолою."
+          "en": "Digital Archiving",
+          "me": "Digitalno arhiviranje",
+          "ru": "Цифровое архивирование",
+          "ua": "Цифрове архівування"
         }
-      }
-    },
-    "useCases": {
-      "title": {
-        "en": "What is it for?",
-        "me": "Za šta se koristi?",
-        "ru": "Для чего это нужно?",
-        "ua": "Для чого це потрібно?"
       },
       "restoration": {
-        "title": {
-          "en": "Parts Restoration",
-          "me": "Restauracija djelova",
-          "ru": "Восстановление деталей",
-          "ua": "Відновлення деталей"
-        },
         "description": {
           "en": "Broken brackets, engine parts, or rare hardware. Scan, restore, and manufacture anew.",
           "me": "Polomljeni nosači, djelovi motora ili rijetka oprema. Skenirajte, restaurirajte i ponovo proizvedite.",
           "ru": "Сломанные кронштейны, детали двигателей или редкая фурнитура. Сканируем, восстанавливаем и изготавливаем заново.",
           "ua": "Зламані кронштейни, деталі двигунів або рідкісна фурнітура. Скануємо, відновлюємо та виготовляємо заново."
+        },
+        "title": {
+          "en": "Parts Restoration",
+          "me": "Restauracija djelova",
+          "ru": "Восстановление деталей",
+          "ua": "Відновлення деталей"
         }
       },
+      "title": {
+        "en": "What is it for?",
+        "me": "Za šta se koristi?",
+        "ru": "Для чего это нужно?",
+        "ua": "Для чого це потрібно?"
+      },
       "yachting": {
+        "description": {
+          "en": "Create custom mounts, repair worn interior elements, and restore parts that are hard to order.",
+          "me": "Kreirajte prilagođene nosače, popravite istrošene elemente enterijera i restaurirajte djelove koje je teško naručiti.",
+          "ru": "Создание кастомных креплений, ремонт изношенных элементов интерьера и восстановление деталей, которые сложно заказать.",
+          "ua": "Створення кастомних кріплень, ремонт зношених елементів інтер'єру та відновлення деталей, які складно замовити."
+        },
         "title": {
           "en": "Yachting & Maritime",
           "me": "Jahting i pomorski transport",
           "ru": "Яхтинг и морской транспорт",
           "ua": "Яхтинг і морський транспорт"
-        },
+        }
+      }
+    },
+    "workflow": {
+      "print": {
         "description": {
-          "en": "Create custom mounts, repair worn interior elements, and restore parts that are hard to order.",
-          "me": "Kreirajte prilagođene nosače, popravite istrošene elemente enterijera i restaurirajte djelove koje je teško naručiti.",
-          "ru": "Создание кастомных креплений, ремонт изношенных элементов интерьера и восстановление деталей, которые сложно заказать.",
-          "ua": "Створення кастомних кріплень, ремонт зношених елементів інтер'єру та відновлення деталей, які складно замовити."
+          "en": "Print the finished model using durable, marine-grade plastics or photopolymer resins.",
+          "me": "Odštampajte gotov model koristeći izdržljivu plastiku otpornu na morsku sredinu ili fotopolimerne smole.",
+          "ru": "Печатаем готовую модель прочным, устойчивым к морской среде пластиком или фотополимерной смолой.",
+          "ua": "Друкуємо готову модель міцним, стійким до морського середовища пластиком або фотополімерною смолою."
+        },
+        "title": {
+          "en": "3D Printing a Copy",
+          "me": "3D štampa kopije",
+          "ru": "3D-печать копии",
+          "ua": "3D-друк копії"
         }
       },
-      "archiving": {
-        "title": {
-          "en": "Digital Archiving",
-          "me": "Digitalno arhiviranje",
-          "ru": "Цифровое архивирование",
-          "ua": "Цифрове архівування"
+      "reverse": {
+        "description": {
+          "en": "Convert scanning results into a full CAD 3D model, ready for modifications and production.",
+          "me": "Pretvorite rezultate skeniranja u punopravni CAD 3D model, spreman za modifikacije i proizvodnju.",
+          "ru": "Превращаем результат сканирования в полноценную конструкторскую 3D-модель (CAD), готовую к правкам и производству.",
+          "ua": "Перетворюємо результат сканування в повноцінну конструкторську 3D-модель (CAD), готову до правок і виробництва."
         },
+        "title": {
+          "en": "Reverse Engineering",
+          "me": "Reverzni inženjering",
+          "ru": "Реверс-инжиниринг",
+          "ua": "Реверс-інжиніринг"
+        }
+      },
+      "scan": {
         "description": {
-          "en": "Preserve precise copies of valuable items, tools, or prototypes in digital format.",
-          "me": "Sačuvajte precizne kopije vrijednih predmeta, alata ili prototipova u digitalnom formatu.",
-          "ru": "Сохранение точных копий ценных предметов, инструментов или прототипов в цифровом виде.",
-          "ua": "Збереження точних копій цінних предметів, інструментів або прототипів у цифровому вигляді."
+          "en": "Create a detailed digital copy of objects of any complexity — from small mechanisms to interior elements and ship hulls using professional equipment.",
+          "me": "Kreirajte detaljnu digitalnu kopiju objekata bilo koje složenosti — od malih mehanizama do elemenata enterijera i trupa plovila koristeći profesionalnu opremu.",
+          "ru": "Создаем детальную цифровую копию объекта любой сложности — от небольших механизмов до элементов интерьера и корпуса судна с использованием профессионального оборудования.",
+          "ua": "Створюємо детальну цифрову копію об'єкта будь-якої складності — від невеликих механізмів до елементів інтер'єру та корпусу судна з використанням професійного обладнання."
+        },
+        "title": {
+          "en": "High-Precision Scanning",
+          "me": "Visokoprecizno skeniranje",
+          "ru": "Высокоточное сканирование",
+          "ua": "Високоточне сканування"
         }
-      }
-    },
-    "cta": {
-      "text": {
-        "en": "Need a precise copy or a 3D model of an object? We'll help bring your project to life.",
-        "me": "Treba li vam precizna kopija ili 3D model objekta? Pomoći ćemo vam da realizujete vaš projekat.",
-        "ru": "Нужна точная копия или 3D-модель объекта? Мы поможем реализовать ваш проект.",
-        "ua": "Потрібна точна копія або 3D-модель об'єкта? Ми допоможемо реалізувати ваш проект."
       },
-      "button": {
-        "en": "Discuss Project",
-        "me": "Razgovarajte o projektu",
-        "ru": "Обсудить проект",
-        "ua": "Обговорити проект"
+      "title": {
+        "en": "The Path from Object to Finished Product",
+        "me": "Put od objekta do gotovog proizvoda",
+        "ru": "Путь от объекта к готовому изделию",
+        "ua": "Шлях від об'єкта до готового виробу"
       }
     }
   },
   "seo": {
-    "home": {
-      "title": {
-        "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.",
-        "me": "Profesionalna 3D štampa i izrada prototipova u Crnoj Gori. Brze ponude, industrijski materijali i visoka preciznost.",
-        "ru": "Профессиональная 3D-печать и прототипирование в Черногории. Мгновенный расчет, промышленные материалы и высокая точность.",
-        "ua": "Професійний 3D-друк та прототипування у Чорногорії. Миттєвий розрахунок, промислові матеріали та висока точність."
-      }
-    },
-    "portfolio": {
-      "title": {
-        "en": "Portfolio",
-        "me": "Portfolio",
-        "ru": "Портфолио",
-        "ua": "Портфоліо"
-      }
-    },
     "about": {
       "title": {
         "en": "About Us",
@@ -3135,6 +3133,28 @@
         "ru": "Контакты",
         "ua": "Контакти"
       }
+    },
+    "home": {
+      "description": {
+        "en": "Professional 3D printing and rapid prototyping services in Montenegro. Instant quotes, industrial materials, and high-precision results.",
+        "me": "Profesionalna 3D štampa i izrada prototipova u Crnoj Gori. Brze ponude, industrijski materijali i visoka preciznost.",
+        "ru": "Профессиональная 3D-печать и прототипирование в Черногории. Мгновенный расчет, промышленные материалы и высокая точность.",
+        "ua": "Професійний 3D-друк та прототипування у Чорногорії. Миттєвий розрахунок, промислові матеріали та висока точність."
+      },
+      "title": {
+        "en": "Professional 3D Printing in Montenegro",
+        "me": "Profesionalna 3D štampa u Crnoj Gori",
+        "ru": "Профессиональная 3D-печать в Черногории",
+        "ua": "Професійний 3D-друк у Чорногорії"
+      }
+    },
+    "portfolio": {
+      "title": {
+        "en": "Portfolio",
+        "me": "Portfolio",
+        "ru": "Портфолио",
+        "ua": "Портфоліо"
+      }
     }
   },
   "services": {
@@ -3192,12 +3212,42 @@
     }
   },
   "statuses": {
+    "approved": {
+      "en": "Approved",
+      "me": "Odobreno",
+      "ru": "Одобрено",
+      "ua": "Схвалено"
+    },
+    "cancelled": {
+      "en": "Cancelled",
+      "me": "Otkazano",
+      "ru": "Отменено",
+      "ua": "Скасовано"
+    },
+    "completed": {
+      "en": "Completed",
+      "me": "Završeno",
+      "ru": "Завершено",
+      "ua": "Завершено"
+    },
+    "delivered": {
+      "en": "Delivered",
+      "me": "Uručeno",
+      "ru": "Доставлено",
+      "ua": "Доставлено"
+    },
     "pending": {
       "en": "Pending",
       "me": "Na čekanju",
       "ru": "В ожидании",
       "ua": "В очікуванні"
     },
+    "printing": {
+      "en": "Printing",
+      "me": "Štampanje",
+      "ru": "Печать",
+      "ua": "Друк"
+    },
     "processing": {
       "en": "Processing",
       "me": "U obradi",
@@ -3209,36 +3259,6 @@
       "me": "Poslato",
       "ru": "Отправлено",
       "ua": "Відправлено"
-    },
-    "completed": {
-      "en": "Completed",
-      "me": "Završeno",
-      "ru": "Завершено",
-      "ua": "Завершено"
-    },
-    "cancelled": {
-      "en": "Cancelled",
-      "me": "Otkazano",
-      "ru": "Отменено",
-      "ua": "Скасовано"
-    },
-    "approved": {
-      "en": "Approved",
-      "me": "Odobreno",
-      "ru": "Одобрено",
-      "ua": "Схвалено"
-    },
-    "printing": {
-      "en": "Printing",
-      "me": "Štampanje",
-      "ru": "Печать",
-      "ua": "Друк"
-    },
-    "delivered": {
-      "en": "Delivered",
-      "me": "Uručeno",
-      "ru": "Доставлено",
-      "ua": "Доставлено"
     }
   },
   "terms": {
@@ -3582,6 +3602,12 @@
       "ru": "Город, Индекс, Адрес (в свободной форме)",
       "ua": "Місто, Індекс, Адреса (у вільній формі)"
     },
+    "agreeToNuances": {
+      "en": "I have read and agree with the",
+      "me": "Pročitao sam i slažem se sa",
+      "ru": "Я ознакомился и согласен с",
+      "ua": "Я ознайомився та згоден з"
+    },
     "allowPortfolio": {
       "en": "Allow featuring in public portfolio",
       "me": "Dozvoli prikazivanje u javnom portfoliju",
@@ -3594,18 +3620,6 @@
       "ru": "Мы покажем фото вашего изделия, чтобы вдохновить других клиентов.",
       "ua": "Ми покажемо фотографії вашого виробу, щоб надихнути інших клієнтів."
     },
-    "agreeToNuances": {
-      "en": "I have read and agree with the",
-      "me": "Pročitao sam i slažem se sa",
-      "ru": "Я ознакомился и согласен с",
-      "ua": "Я ознайомився та згоден з"
-    },
-    "nuancesLink": {
-      "en": "printing nuances",
-      "me": "detaljima 3D štampe",
-      "ru": "нюансами 3D-печати",
-      "ua": "нюансами 3D-друку"
-    },
     "badge": {
       "en": "Place Your Project",
       "me": "Kreiranje projekta",
@@ -3696,6 +3710,12 @@
       "ru": "Пожелания по цвету, материалу, толщине стенок или другие инструкции...",
       "ua": "Побажання щодо кольору, матеріалу, товщини стінок або інші інструкції."
     },
+    "nuancesLink": {
+      "en": "printing nuances",
+      "me": "detaljima 3D štampe",
+      "ru": "нюансами 3D-печати",
+      "ua": "нюансами 3D-друку"
+    },
     "phone": {
       "en": "Phone Number",
       "me": "Broj telefona",

+ 9 - 2
src/pages/Admin.vue

@@ -118,6 +118,11 @@
           @refresh="fetchData"
         />
 
+        <ScansSection
+          v-if="activeTab === 'scans'"
+          :resourcesBaseUrl="RESOURCES_BASE_URL"
+        />
+
         <AuditSection
           v-if="activeTab === 'audit'"
           :auditLogs="auditLogs"
@@ -407,6 +412,7 @@ import ServicesSection from "@/components/admin/ServicesSection.vue";
 import UsersSection from "@/components/admin/UsersSection.vue";
 import PostsSection from "@/components/admin/PostsSection.vue";
 import PortfolioSection from "@/components/admin/PortfolioSection.vue";
+import ScansSection from "@/components/admin/ScansSection.vue";
 import AuditSection from "@/components/admin/AuditSection.vue";
 import ReviewsSection from "@/components/admin/ReviewsSection.vue";
 import WarehouseSection from "@/components/admin/WarehouseSection.vue";
@@ -465,6 +471,7 @@ const tabs: { id: Tab }[] = [
   { id: "materials" },
   { id: "services" },
   { id: "portfolio" },
+  { id: "scans" },
   { id: "reviews" },
   { id: "users" },
   { id: "posts" },
@@ -472,11 +479,11 @@ const tabs: { id: Tab }[] = [
   { id: "warehouse" },
 ];
 
-type Tab = "orders" | "materials" | "services" | "posts" | "users" | "portfolio" | "audit" | "reviews" | "warehouse";
+type Tab = "orders" | "materials" | "services" | "posts" | "users" | "portfolio" | "scans" | "audit" | "reviews" | "warehouse";
 
 function getValidTab(val: any): Tab {
   const t = val?.toString();
-  return ["orders", "materials", "services", "posts", "users", "portfolio", "audit", "reviews", "warehouse"].includes(t) ? (t as Tab) : "orders";
+  return ["orders", "materials", "services", "posts", "users", "portfolio", "scans", "audit", "reviews", "warehouse"].includes(t) ? (t as Tab) : "orders";
 }
 
 const activeTab = ref<Tab>(getValidTab(route.query.tab));

+ 131 - 2
src/pages/Scanning.vue

@@ -58,6 +58,82 @@
         </div>
       </section>
 
+      <!-- Showcase Section (Interactive 3D Portfolio) -->
+      <section class="space-y-12" id="scans-showcase">
+        <div class="text-center space-y-4">
+          <h2 class="text-3xl sm:text-5xl font-black tracking-tight">
+            {{ t('scanning.portfolio.title').split(' ').slice(0, -1).join(' ') }}
+            <span class="bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
+              {{ t('scanning.portfolio.title').split(' ').slice(-1)[0] }}
+            </span>
+          </h2>
+          <p class="text-muted-foreground max-w-2xl mx-auto leading-relaxed text-sm sm:text-base">
+            {{ t('scanning.portfolio.subtitle') }}
+          </p>
+        </div>
+
+        <div v-if="loading" class="flex flex-col items-center justify-center py-20 space-y-4">
+          <RefreshCw class="w-10 h-10 text-primary animate-spin" />
+          <p class="text-xs uppercase font-black tracking-widest text-muted-foreground">Loading portfolio...</p>
+        </div>
+
+        <div v-else-if="scans.length === 0" class="text-center py-16 bg-card/20 border border-border/40 rounded-3xl space-y-2">
+          <Box class="w-12 h-12 text-muted-foreground/60 mx-auto" />
+          <p class="text-sm font-bold text-muted-foreground">{{ t('scanning.portfolio.empty') }}</p>
+        </div>
+
+        <div v-else class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
+          <!-- Active scan 3D viewer (Top on mobile, right on desktop) -->
+          <div class="lg:col-span-7 lg:order-2 space-y-4">
+            <ThreeSixtyViewer
+              :folder-name="activeScan.folder_name"
+              :frames-count="activeScan.frames_count"
+              :resources-base-url="RESOURCES_BASE_URL"
+            />
+            <div class="p-6 bg-card/30 border border-border/30 rounded-3xl space-y-2 backdrop-blur-sm">
+              <span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-black uppercase bg-primary/10 text-primary border border-primary/20">
+                <Box class="w-3 h-3" />
+                Active Model
+              </span>
+              <h3 class="text-xl font-bold tracking-tight text-white">{{ getLocalizedTitle(activeScan) }}</h3>
+              <p class="text-xs sm:text-sm text-muted-foreground leading-relaxed">{{ getLocalizedDescription(activeScan) }}</p>
+            </div>
+          </div>
+
+          <!-- Selection List (Bottom on mobile, left on desktop) -->
+          <div class="lg:col-span-5 lg:order-1 space-y-4 max-h-[500px] lg:max-h-[600px] overflow-y-auto pr-2 custom-scrollbar">
+            <button
+              v-for="scan in scans"
+              :key="scan.id"
+              @click="activeScan = scan"
+              class="w-full text-left p-4 rounded-2xl border transition-all duration-300 flex gap-4 bg-card/20 hover:bg-card/45"
+              :class="activeScan.id === scan.id ? 'border-primary/50 bg-primary/5 shadow-lg shadow-primary/5' : 'border-border/35 hover:border-border/60'"
+            >
+              <!-- Thumbnail -->
+              <div class="w-20 h-20 rounded-xl bg-black/60 border border-white/5 overflow-hidden flex-shrink-0 relative">
+                <img
+                  :src="`${RESOURCES_BASE_URL}/uploads/scans/${scan.folder_name}/thumbnail.webp`"
+                  class="w-full h-full object-cover"
+                  alt="Scan thumbnail"
+                />
+              </div>
+              <!-- Text -->
+              <div class="flex-1 min-w-0 flex flex-col justify-between py-1">
+                <div>
+                  <h4 class="font-bold text-sm tracking-tight text-white truncate">{{ getLocalizedTitle(scan) }}</h4>
+                  <p class="text-xs text-muted-foreground leading-snug line-clamp-2 mt-1">{{ getLocalizedDescription(scan) }}</p>
+                </div>
+                <div class="text-[10px] text-muted-foreground flex items-center gap-1 mt-2">
+                  <span class="font-mono text-primary/70">{{ scan.frames_count }} frames</span>
+                  <span>•</span>
+                  <span>Interactive</span>
+                </div>
+              </div>
+            </button>
+          </div>
+        </div>
+      </section>
+
       <!-- Use Cases -->
       <section class="bg-secondary/30 rounded-[3.5rem] p-8 sm:p-16 space-y-12">
         <h2 class="text-3xl sm:text-4xl font-bold tracking-tight text-center">{{ t('scanning.useCases.title') }}</h2>
@@ -102,15 +178,21 @@
 </template>
 
 <script setup lang="ts">
-import { computed } from "vue";
+import { ref, computed, onMounted } from "vue";
 import { useHead } from "@unhead/vue";
-import { Scan, FileCode, Printer, ChevronRight } from "lucide-vue-next";
+import { Scan, FileCode, Printer, ChevronRight, RefreshCw, Box } from "lucide-vue-next";
 import Header from "@/components/Header.vue";
 import Footer from "@/components/Footer.vue";
 import { useI18n } from "vue-i18n";
+import { getPublicScans, RESOURCES_BASE_URL } from "@/lib/api";
+import ThreeSixtyViewer from "@/components/ThreeSixtyViewer.vue";
 
 const { t, locale } = useI18n();
 
+const scans = ref<any[]>([]);
+const activeScan = ref<any>(null);
+const loading = ref(true);
+
 const getIcon = (step: string) => {
   switch (step) {
     case 'scan': return Scan;
@@ -120,6 +202,36 @@ const getIcon = (step: string) => {
   }
 };
 
+const getLocalizedTitle = (scan: any) => {
+  if (!scan) return "";
+  if (locale.value === 'ru') return scan.title_ru || scan.title_en;
+  if (locale.value === 'me') return scan.title_me || scan.title_en;
+  if (locale.value === 'ua') return scan.title_ua || scan.title_en;
+  return scan.title_en;
+};
+
+const getLocalizedDescription = (scan: any) => {
+  if (!scan) return "";
+  if (locale.value === 'ru') return scan.description_ru || scan.description_en;
+  if (locale.value === 'me') return scan.description_me || scan.description_en;
+  if (locale.value === 'ua') return scan.description_ua || scan.description_en;
+  return scan.description_en;
+};
+
+onMounted(async () => {
+  try {
+    const fetchedScans = await getPublicScans();
+    scans.value = fetchedScans || [];
+    if (scans.value.length > 0) {
+      activeScan.value = scans.value[0];
+    }
+  } catch (error) {
+    console.error("Failed to load public scans portfolio:", error);
+  } finally {
+    loading.value = false;
+  }
+});
+
 useHead({
   title: computed(() => `${t('scanning.title')} | Radionica 3D`),
   meta: [
@@ -127,3 +239,20 @@ useHead({
   ]
 });
 </script>
+
+<style scoped>
+.custom-scrollbar::-webkit-scrollbar {
+  width: 4px;
+}
+.custom-scrollbar::-webkit-scrollbar-track {
+  background: rgba(255, 255, 255, 0.02);
+  border-radius: 2px;
+}
+.custom-scrollbar::-webkit-scrollbar-thumb {
+  background: rgba(255, 255, 255, 0.1);
+  border-radius: 2px;
+}
+.custom-scrollbar::-webkit-scrollbar-thumb:hover {
+  background: var(--primary, #66fcf1);
+}
+</style>