| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204 |
- <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>
- <!-- 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, watch } 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 page = ref(1);
- const total = ref(0);
- const size = 15;
- 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 {
- 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 {
- 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 = "";
-
- // 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 });
- }
- };
- 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 });
-
- // 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 });
- }
- };
- 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>";
- };
- watch(page, fetchScans);
- onMounted(() => {
- fetchScans();
- });
- </script>
|