Przeglądaj źródła

Implement pagination for 3D scans portfolio on backend and frontend

unknown 2 miesięcy temu
rodzic
commit
18aac46b51

+ 20 - 6
backend/routers/portfolio.py

@@ -174,7 +174,13 @@ async def admin_delete_photo(photo_id: int, admin: dict = Depends(require_admin)
 # --- 3D Scan Portfolio Endpoints ---
 
 @router.get("/scans")
-async def get_public_scans():
+async def get_public_scans(page: int = 1, size: int = 10):
+    offset = (page - 1) * size
+    
+    # Get total count
+    count_res = db.execute_query("SELECT COUNT(*) as total FROM portfolio_scans WHERE is_public = TRUE")
+    total = count_res[0]['total'] if count_res else 0
+    
     query = """
     SELECT id, title_en, title_ru, title_me, title_ua,
            description_en, description_ru, description_me, description_ua,
@@ -182,29 +188,37 @@ async def get_public_scans():
     FROM portfolio_scans
     WHERE is_public = TRUE
     ORDER BY created_at DESC
+    LIMIT %s OFFSET %s
     """
-    scans = db.execute_query(query)
+    scans = db.execute_query(query, (size, offset))
     scans_dir = os.path.join(config.UPLOAD_DIR, "scans")
     for scan in scans:
         photo_path = os.path.join(scans_dir, scan["folder_name"], "photo.webp")
         scan["has_photo"] = os.path.exists(photo_path)
-    return scans
+    return {"scans": scans, "total": total}
 
 @router.get("/admin/scans")
-async def admin_get_all_scans(admin: dict = Depends(require_admin)):
+async def admin_get_all_scans(page: int = 1, size: int = 10, admin: dict = Depends(require_admin)):
+    offset = (page - 1) * size
+    
+    # Get total count
+    count_res = db.execute_query("SELECT COUNT(*) as total FROM portfolio_scans")
+    total = count_res[0]['total'] if count_res else 0
+    
     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
+    LIMIT %s OFFSET %s
     """
-    scans = db.execute_query(query)
+    scans = db.execute_query(query, (size, offset))
     scans_dir = os.path.join(config.UPLOAD_DIR, "scans")
     for scan in scans:
         photo_path = os.path.join(scans_dir, scan["folder_name"], "photo.webp")
         scan["has_photo"] = os.path.exists(photo_path)
-    return scans
+    return {"scans": scans, "total": total}
 
 @router.post("/admin/scans/upload-bundle")
 async def admin_upload_scan_bundle(

+ 27 - 3
src/components/admin/ScansSection.vue

@@ -79,11 +79,20 @@
         </div>
       </div>
     </div>
+
+    <!-- Pagination -->
+    <div v-if="total > size" class="flex items-center justify-center gap-2 py-4">
+       <button v-for="p in Math.ceil(total / size)" :key="p" 
+         @click="page = p" 
+         :class="['w-8 h-8 rounded-lg font-bold text-xs transition-all p-0 flex items-center justify-center border', page === p ? 'bg-primary text-black border-primary shadow-glow' : 'bg-card border border-border/50 text-muted-foreground hover:border-primary/50']">
+         {{ p }}
+       </button>
+    </div>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted } from "vue";
+import { ref, onMounted, watch } from "vue";
 import { useI18n } from "vue-i18n";
 import { 
   FolderArchive, FolderOpen, RefreshCw, Upload, Eye, EyeOff, Trash2 
@@ -101,6 +110,9 @@ const { t, locale } = useI18n();
 
 const isLoading = ref(true);
 const scans = ref<any[]>([]);
+const page = ref(1);
+const total = ref(0);
+const size = 15;
 
 const getLocalizedTitle = (scan: any) => {
   return scan[`title_${locale.value}`] || scan.title_en;
@@ -113,7 +125,9 @@ const getLocalizedDesc = (scan: any) => {
 const fetchScans = async () => {
   isLoading.value = true;
   try {
-    scans.value = await adminGetScans();
+    const res = await adminGetScans(page.value, size);
+    scans.value = res.scans || [];
+    total.value = res.total || 0;
   } catch (err: any) {
     toast.error("Failed to load scans: " + err.message);
   } finally {
@@ -136,6 +150,8 @@ const handleZipUpload = async (event: Event) => {
     // Clear the input
     (event.target as HTMLInputElement).value = "";
     
+    // Reset to page 1 to see the newly uploaded scan at the top
+    page.value = 1;
     await fetchScans();
   } catch (err: any) {
     toast.error(err.message || "Failed to process zip bundle", { id: toastId });
@@ -163,7 +179,13 @@ const deleteScan = async (scan: any) => {
   try {
     await adminDeleteScan(scan.id);
     toast.success("Scan deleted successfully", { id: toastId });
-    await fetchScans();
+    
+    // If we delete the last item on the page, go back a page
+    if (scans.value.length === 1 && page.value > 1) {
+      page.value--;
+    } else {
+      await fetchScans();
+    }
   } catch (err: any) {
     toast.error("Failed to delete scan: " + err.message, { id: toastId });
   }
@@ -174,6 +196,8 @@ const onImageError = (e: Event) => {
   (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>";
 };
 
+watch(page, fetchScans);
+
 onMounted(() => {
   fetchScans();
 });

+ 6 - 4
src/lib/api.ts

@@ -806,15 +806,17 @@ export const adminGetWarehouseReport = async () => {
   return response.json();
 };
 
-export const getPublicScans = async () => {
-  const response = await fetch(`${API_BASE_URL}/scans?lang=${i18n.global.locale.value}`);
+export const getPublicScans = async (page = 1, size = 10) => {
+  const query = new URLSearchParams({ page: page.toString(), size: size.toString() });
+  const response = await fetch(`${API_BASE_URL}/scans?${query.toString()}&lang=${i18n.global.locale.value}`);
   if (!response.ok) throw new Error("Failed to fetch public scans");
   return response.json();
 };
 
-export const adminGetScans = async () => {
+export const adminGetScans = async (page = 1, size = 10) => {
   const token = localStorage.getItem("token");
-  const response = await fetch(`${API_BASE_URL}/admin/scans?lang=${i18n.global.locale.value}`, {
+  const query = new URLSearchParams({ page: page.toString(), size: size.toString() });
+  const response = await fetch(`${API_BASE_URL}/admin/scans?${query.toString()}&lang=${i18n.global.locale.value}`, {
     headers: { 'Authorization': `Bearer ${token}` }
   });
   if (!response.ok) throw new Error("Failed to fetch admin scans");

+ 60 - 31
src/pages/Scanning.vue

@@ -125,35 +125,45 @@
           </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; showRealPhoto = false"
-              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 class="lg:col-span-5 lg:order-1 space-y-4">
+            <div class="max-h-[420px] lg:max-h-[500px] overflow-y-auto pr-2 custom-scrollbar space-y-4">
+              <button
+                v-for="scan in scans"
+                :key="scan.id"
+                @click="activeScan = scan; showRealPhoto = false"
+                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>
-                <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>
+                <!-- 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>
-              </div>
-            </button>
+              </button>
+            </div>
+            <!-- Pagination -->
+            <div v-if="total > size" class="flex items-center justify-center gap-2 pt-2">
+               <button v-for="p in Math.ceil(total / size)" :key="p" 
+                 @click="changePage(p)" 
+                 :class="['w-7 h-7 rounded-lg font-bold text-[10px] transition-all p-0 flex items-center justify-center border', page === p ? 'bg-primary text-black border-primary shadow-glow' : 'bg-card border border-border/50 text-muted-foreground hover:border-primary/50']">
+                 {{ p }}
+               </button>
+            </div>
           </div>
         </div>
       </section>
@@ -217,6 +227,9 @@ const scans = ref<any[]>([]);
 const activeScan = ref<any>(null);
 const showRealPhoto = ref(false);
 const loading = ref(true);
+const page = ref(1);
+const total = ref(0);
+const size = ref(10);
 
 const getIcon = (step: string) => {
   switch (step) {
@@ -243,18 +256,34 @@ const getLocalizedDescription = (scan: any) => {
   return scan.description_en;
 };
 
-onMounted(async () => {
+const fetchScansList = async () => {
+  loading.value = true;
   try {
-    const fetchedScans = await getPublicScans();
-    scans.value = fetchedScans || [];
+    const fetched = await getPublicScans(page.value, size.value);
+    scans.value = fetched.scans || [];
+    total.value = fetched.total || 0;
     if (scans.value.length > 0) {
-      activeScan.value = scans.value[0];
+      const exists = scans.value.some(s => s.id === activeScan.value?.id);
+      if (!exists) {
+        activeScan.value = scans.value[0];
+      }
+    } else {
+      activeScan.value = null;
     }
   } catch (error) {
     console.error("Failed to load public scans portfolio:", error);
   } finally {
     loading.value = false;
   }
+};
+
+const changePage = (p: number) => {
+  page.value = p;
+  fetchScansList();
+};
+
+onMounted(() => {
+  fetchScansList();
 });
 
 useHead({