ScansSection.vue 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. <template>
  2. <div class="space-y-8">
  3. <!-- ZIP Upload Area -->
  4. <div class="bg-card/40 border border-border/50 rounded-3xl p-8 backdrop-blur-md">
  5. <div class="flex flex-col md:flex-row items-center justify-between gap-6">
  6. <div class="flex items-center gap-4">
  7. <div class="w-12 h-12 bg-primary/10 rounded-2xl flex items-center justify-center text-primary">
  8. <FolderArchive class="w-6 h-6" />
  9. </div>
  10. <div>
  11. <h3 class="text-lg font-bold">Upload 3D Scan Bundle</h3>
  12. <p class="text-xs text-muted-foreground">Select a compiled .zip bundle containing the optimized WebP frames, thumbnail, and metadata.</p>
  13. </div>
  14. </div>
  15. <div>
  16. <label class="cursor-pointer block">
  17. <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">
  18. <Upload class="w-4 h-4" /> Select ZIP Bundle
  19. </div>
  20. <input type="file" class="hidden" accept=".zip" @change="handleZipUpload" />
  21. </label>
  22. </div>
  23. </div>
  24. </div>
  25. <!-- Loading Indicator -->
  26. <div v-if="isLoading" class="flex items-center justify-center py-20">
  27. <RefreshCw class="w-8 h-8 text-primary animate-spin" />
  28. </div>
  29. <!-- Scans Grid -->
  30. <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">
  31. <FolderOpen class="w-12 h-12 text-muted-foreground/20 mb-4" />
  32. <p class="text-sm text-muted-foreground">No 3D scan bundles uploaded yet.</p>
  33. </div>
  34. <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">
  35. <div v-for="scan in scans" :key="scan.id"
  36. 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"
  37. :class="{ 'opacity-60 grayscale-[0.5]': !scan.is_public }">
  38. <!-- Thumbnail / Interactive Area preview -->
  39. <div class="relative aspect-square overflow-hidden bg-black/20">
  40. <img :src="`${resourcesBaseUrl}/uploads/scans/${scan.folder_name}/thumbnail.webp`"
  41. class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
  42. @error="onImageError" />
  43. <!-- Frame Badge / Status Overlay -->
  44. <div class="absolute top-3 left-3 flex gap-2">
  45. <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">
  46. <EyeOff class="w-2.5 h-2.5" /> {{ t('admin.labels.hidden') }}
  47. </div>
  48. <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">
  49. {{ scan.frames_count }} Frames
  50. </div>
  51. </div>
  52. <!-- Hover Controls -->
  53. <div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-all flex items-center justify-center gap-3">
  54. <button @click="toggleVisibility(scan)" class="w-10 h-10 rounded-full flex items-center justify-center transition-all border shadow-lg"
  55. :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'">
  56. <component :is="scan.is_public ? EyeOff : Eye" class="w-5 h-5" />
  57. </button>
  58. <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">
  59. <Trash2 class="w-5 h-5" />
  60. </button>
  61. </div>
  62. </div>
  63. <!-- Meta info -->
  64. <div class="p-4 flex-1 flex flex-col gap-2">
  65. <h4 class="font-bold text-sm truncate">{{ getLocalizedTitle(scan) }}</h4>
  66. <p class="text-xs text-muted-foreground line-clamp-3 leading-relaxed">
  67. {{ getLocalizedDesc(scan) || 'No description provided.' }}
  68. </p>
  69. <div class="pt-3 border-t border-border/50 flex justify-between items-center mt-auto">
  70. <span class="text-[9px] font-bold text-muted-foreground/45 tracking-tight uppercase">Folder: {{ scan.folder_name }}</span>
  71. </div>
  72. </div>
  73. </div>
  74. </div>
  75. <!-- Pagination -->
  76. <div v-if="total > size" class="flex items-center justify-center gap-2 py-4">
  77. <button v-for="p in Math.ceil(total / size)" :key="p"
  78. @click="page = p"
  79. :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']">
  80. {{ p }}
  81. </button>
  82. </div>
  83. </div>
  84. </template>
  85. <script setup lang="ts">
  86. import { ref, onMounted, watch } from "vue";
  87. import { useI18n } from "vue-i18n";
  88. import {
  89. FolderArchive, FolderOpen, RefreshCw, Upload, Eye, EyeOff, Trash2
  90. } from "lucide-vue-next";
  91. import {
  92. adminGetScans, adminUploadScanBundle, adminUpdateScanStatus, adminDeleteScan
  93. } from "@/lib/api";
  94. import { toast } from "vue-sonner";
  95. const props = defineProps<{
  96. resourcesBaseUrl: string;
  97. }>();
  98. const { t, locale } = useI18n();
  99. const isLoading = ref(true);
  100. const scans = ref<any[]>([]);
  101. const page = ref(1);
  102. const total = ref(0);
  103. const size = 15;
  104. const getLocalizedTitle = (scan: any) => {
  105. return scan[`title_${locale.value}`] || scan.title_en;
  106. };
  107. const getLocalizedDesc = (scan: any) => {
  108. return scan[`description_${locale.value}`] || scan.description_en;
  109. };
  110. const fetchScans = async () => {
  111. isLoading.value = true;
  112. try {
  113. const res = await adminGetScans(page.value, size);
  114. scans.value = res.scans || [];
  115. total.value = res.total || 0;
  116. } catch (err: any) {
  117. toast.error("Failed to load scans: " + err.message);
  118. } finally {
  119. isLoading.value = false;
  120. }
  121. };
  122. const handleZipUpload = async (event: Event) => {
  123. const file = (event.target as HTMLInputElement).files?.[0];
  124. if (!file) return;
  125. const toastId = toast.loading("Uploading 3D scan bundle ZIP...");
  126. try {
  127. const fd = new FormData();
  128. fd.append("file", file);
  129. await adminUploadScanBundle(fd);
  130. toast.success("3D scan bundle uploaded and unpacked successfully", { id: toastId });
  131. // Clear the input
  132. (event.target as HTMLInputElement).value = "";
  133. // Reset to page 1 to see the newly uploaded scan at the top
  134. page.value = 1;
  135. await fetchScans();
  136. } catch (err: any) {
  137. toast.error(err.message || "Failed to process zip bundle", { id: toastId });
  138. }
  139. };
  140. const toggleVisibility = async (scan: any) => {
  141. try {
  142. await adminUpdateScanStatus(scan.id, {
  143. is_public: !scan.is_public
  144. });
  145. toast.success("Scan visibility updated");
  146. await fetchScans();
  147. } catch (err: any) {
  148. toast.error(err.message);
  149. }
  150. };
  151. const deleteScan = async (scan: any) => {
  152. if (!confirm(`Are you sure you want to delete scan "${getLocalizedTitle(scan)}"? This will permanently delete all render files.`)) {
  153. return;
  154. }
  155. const toastId = toast.loading(`Deleting scan "${getLocalizedTitle(scan)}"...`);
  156. try {
  157. await adminDeleteScan(scan.id);
  158. toast.success("Scan deleted successfully", { id: toastId });
  159. // If we delete the last item on the page, go back a page
  160. if (scans.value.length === 1 && page.value > 1) {
  161. page.value--;
  162. } else {
  163. await fetchScans();
  164. }
  165. } catch (err: any) {
  166. toast.error("Failed to delete scan: " + err.message, { id: toastId });
  167. }
  168. };
  169. const onImageError = (e: Event) => {
  170. // Placeholder if thumbnail fails to load
  171. (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>";
  172. };
  173. watch(page, fetchScans);
  174. onMounted(() => {
  175. fetchScans();
  176. });
  177. </script>